Commit d6580489 authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

Allow multiple FloatingActionButtons to be used on one screen. (#12074)

* Allow FloatingActionButton to not have a heroTag.
* Allow FloatingActionButton to not have a child.
* Allow Tooltip to not have a child.
* Improve the debug output of the default FloatingActionButton hero tag.
* Improve the error message in the Hero clashing-tag case.
* Improve the debug output of the Hero widget.
* Improve the debug output of gesture-related widgets.
* Minor improvements to documentation.
* Fix some typos in comments.
* Fix some style nits.
parent 08869497
...@@ -549,7 +549,7 @@ class _PrefixedStringBuilder { ...@@ -549,7 +549,7 @@ class _PrefixedStringBuilder {
if (s == '\n') { if (s == '\n') {
// Edge case to avoid adding trailing whitespace when the caller did // Edge case to avoid adding trailing whitespace when the caller did
// not explicitly add trailing trailing whitespace. // not explicitly add trailing whitespace.
if (_buffer.isEmpty) { if (_buffer.isEmpty) {
_buffer.write(prefixLineOne.trimRight()); _buffer.write(prefixLineOne.trimRight());
} else if (_atLineStart) { } else if (_atLineStart) {
...@@ -810,6 +810,11 @@ abstract class DiagnosticsNode { ...@@ -810,6 +810,11 @@ abstract class DiagnosticsNode {
/// Returns a string representation of this node and its descendants. /// Returns a string representation of this node and its descendants.
/// ///
/// `prefixLineOne` will be added to the front of the first line of the
/// output. `prefixOtherLines` will be added to the front of each other line.
/// If `prefixOtherLines` is null, the `prefixLineOne` is used for every line.
/// By default, there is no prefix.
///
/// `minLevel` specifies the minimum [DiagnosticLevel] for properties included /// `minLevel` specifies the minimum [DiagnosticLevel] for properties included
/// in the output. /// in the output.
/// ///
...@@ -836,7 +841,9 @@ abstract class DiagnosticsNode { ...@@ -836,7 +841,9 @@ abstract class DiagnosticsNode {
prefixOtherLines += config.prefixOtherLinesRootNode; prefixOtherLines += config.prefixOtherLinesRootNode;
final _PrefixedStringBuilder builder = new _PrefixedStringBuilder( final _PrefixedStringBuilder builder = new _PrefixedStringBuilder(
prefixLineOne, prefixOtherLines); prefixLineOne,
prefixOtherLines,
);
final String description = toDescription(parentConfiguration: parentConfiguration); final String description = toDescription(parentConfiguration: parentConfiguration);
if (description == null || description.isEmpty) { if (description == null || description.isEmpty) {
...@@ -856,8 +863,9 @@ abstract class DiagnosticsNode { ...@@ -856,8 +863,9 @@ abstract class DiagnosticsNode {
builder.write(description); builder.write(description);
} }
final List<DiagnosticsNode> properties = final List<DiagnosticsNode> properties = getProperties().where(
getProperties().where((DiagnosticsNode n) => !n.isFiltered(minLevel)).toList(); (DiagnosticsNode n) => !n.isFiltered(minLevel)
).toList();
if (properties.isNotEmpty || children.isNotEmpty || emptyBodyDescription != null) if (properties.isNotEmpty || children.isNotEmpty || emptyBodyDescription != null)
builder.write(config.afterDescriptionIfBody); builder.write(config.afterDescriptionIfBody);
...@@ -2322,6 +2330,11 @@ abstract class DiagnosticableTree extends Diagnosticable { ...@@ -2322,6 +2330,11 @@ abstract class DiagnosticableTree extends Diagnosticable {
/// Returns a string representation of this node and its descendants. /// Returns a string representation of this node and its descendants.
/// ///
/// `prefixLineOne` will be added to the front of the first line of the
/// output. `prefixOtherLines` will be added to the front of each other line.
/// If `prefixOtherLines` is null, the `prefixLineOne` is used for every line.
/// By default, there is no prefix.
///
/// `minLevel` specifies the minimum [DiagnosticLevel] for properties included /// `minLevel` specifies the minimum [DiagnosticLevel] for properties included
/// in the output. /// in the output.
/// ///
......
...@@ -15,7 +15,12 @@ import 'tooltip.dart'; ...@@ -15,7 +15,12 @@ import 'tooltip.dart';
// http://material.google.com/layout/metrics-keylines.html#metrics-keylines-keylines-spacing // http://material.google.com/layout/metrics-keylines.html#metrics-keylines-keylines-spacing
const double _kSize = 56.0; const double _kSize = 56.0;
const double _kSizeMini = 40.0; const double _kSizeMini = 40.0;
final Object _kDefaultHeroTag = new Object();
class _DefaultHeroTag {
const _DefaultHeroTag();
@override
String toString() => '<default FloatingActionButton tag>';
}
/// A material design floating action button. /// A material design floating action button.
/// ///
...@@ -42,10 +47,10 @@ class FloatingActionButton extends StatefulWidget { ...@@ -42,10 +47,10 @@ class FloatingActionButton extends StatefulWidget {
/// Most commonly used in the [Scaffold.floatingActionButton] field. /// Most commonly used in the [Scaffold.floatingActionButton] field.
const FloatingActionButton({ const FloatingActionButton({
Key key, Key key,
@required this.child, this.child,
this.tooltip, this.tooltip,
this.backgroundColor, this.backgroundColor,
this.heroTag, this.heroTag: const _DefaultHeroTag(),
this.elevation: 6.0, this.elevation: 6.0,
this.highlightElevation: 12.0, this.highlightElevation: 12.0,
@required this.onPressed, @required this.onPressed,
...@@ -69,6 +74,15 @@ class FloatingActionButton extends StatefulWidget { ...@@ -69,6 +74,15 @@ class FloatingActionButton extends StatefulWidget {
/// The tag to apply to the button's [Hero] widget. /// The tag to apply to the button's [Hero] widget.
/// ///
/// Defaults to a tag that matches other floating action buttons. /// Defaults to a tag that matches other floating action buttons.
///
/// Set this to null explicitly if you don't want the floating action button to
/// have a hero tag.
///
/// If this is not explicitly set, then there can only be one
/// [FloatingActionButton] per route (that is, per screen), since otherwise
/// there would be a tag conflict (multiple heroes on one route can't have the
/// same tag). The material design specification recommends only using one
/// floating action button per screen.
final Object heroTag; final Object heroTag;
/// The callback that is called when the button is tapped or otherwise activated. /// The callback that is called when the button is tapped or otherwise activated.
...@@ -124,23 +138,25 @@ class _FloatingActionButtonState extends State<FloatingActionButton> { ...@@ -124,23 +138,25 @@ class _FloatingActionButtonState extends State<FloatingActionButton> {
iconColor = themeData.accentIconTheme.color; iconColor = themeData.accentIconTheme.color;
} }
Widget result = new Center( Widget result;
if (widget.child != null) {
result = new Center(
child: IconTheme.merge( child: IconTheme.merge(
data: new IconThemeData(color: iconColor), data: new IconThemeData(color: iconColor),
child: widget.child child: widget.child,
) ),
); );
}
if (widget.tooltip != null) { if (widget.tooltip != null) {
result = new Tooltip( result = new Tooltip(
message: widget.tooltip, message: widget.tooltip,
child: result child: result,
); );
} }
return new Hero( result = new Material(
tag: widget.heroTag ?? _kDefaultHeroTag,
child: new Material(
color: materialColor, color: materialColor,
type: MaterialType.circle, type: MaterialType.circle,
elevation: _highlight ? widget.highlightElevation : widget.elevation, elevation: _highlight ? widget.highlightElevation : widget.elevation,
...@@ -150,10 +166,18 @@ class _FloatingActionButtonState extends State<FloatingActionButton> { ...@@ -150,10 +166,18 @@ class _FloatingActionButtonState extends State<FloatingActionButton> {
child: new InkWell( child: new InkWell(
onTap: widget.onPressed, onTap: widget.onPressed,
onHighlightChanged: _handleHighlightChanged, onHighlightChanged: _handleHighlightChanged,
child: result child: result,
) ),
) ),
) );
if (widget.heroTag != null) {
result = new Hero(
tag: widget.heroTag,
child: result,
); );
} }
return result;
}
} }
...@@ -231,9 +231,7 @@ class InkResponse extends StatefulWidget { ...@@ -231,9 +231,7 @@ class InkResponse extends StatefulWidget {
gestures.add('double tap'); gestures.add('double tap');
if (onLongPress != null) if (onLongPress != null)
gestures.add('long press'); gestures.add('long press');
if (gestures.isEmpty) description.add(new IterableProperty<String>('gestures', gestures, ifEmpty: '<none>'));
gestures.add('<none>');
description.add(new IterableProperty<String>('gestures', gestures));
description.add(new DiagnosticsProperty<bool>('containedInkWell', containedInkWell, level: DiagnosticLevel.fine)); description.add(new DiagnosticsProperty<bool>('containedInkWell', containedInkWell, level: DiagnosticLevel.fine));
description.add(new DiagnosticsProperty<BoxShape>( description.add(new DiagnosticsProperty<BoxShape>(
'highlightShape', 'highlightShape',
......
...@@ -48,13 +48,12 @@ class Tooltip extends StatefulWidget { ...@@ -48,13 +48,12 @@ class Tooltip extends StatefulWidget {
this.padding: const EdgeInsets.symmetric(horizontal: 16.0), this.padding: const EdgeInsets.symmetric(horizontal: 16.0),
this.verticalOffset: 24.0, this.verticalOffset: 24.0,
this.preferBelow: true, this.preferBelow: true,
@required this.child, this.child,
}) : assert(message != null), }) : assert(message != null),
assert(height != null), assert(height != null),
assert(padding != null), assert(padding != null),
assert(verticalOffset != null), assert(verticalOffset != null),
assert(preferBelow != null), assert(preferBelow != null),
assert(child != null),
super(key: key); super(key: key);
/// The text to display in the tooltip. /// The text to display in the tooltip.
......
...@@ -4102,9 +4102,7 @@ class Listener extends SingleChildRenderObjectWidget { ...@@ -4102,9 +4102,7 @@ class Listener extends SingleChildRenderObjectWidget {
listeners.add('up'); listeners.add('up');
if (onPointerCancel != null) if (onPointerCancel != null)
listeners.add('cancel'); listeners.add('cancel');
if (listeners.isEmpty) description.add(new IterableProperty<String>('listeners', listeners, ifEmpty: '<none>'));
listeners.add('<none>');
description.add(new IterableProperty<String>('listeners', listeners));
description.add(new EnumProperty<HitTestBehavior>('behavior', behavior)); description.add(new EnumProperty<HitTestBehavior>('behavior', behavior));
} }
} }
......
...@@ -710,9 +710,7 @@ class RawGestureDetectorState extends State<RawGestureDetector> { ...@@ -710,9 +710,7 @@ class RawGestureDetectorState extends State<RawGestureDetector> {
description.add(new DiagnosticsNode.message('DISPOSED')); description.add(new DiagnosticsNode.message('DISPOSED'));
} else { } else {
final List<String> gestures = _recognizers.values.map<String>((GestureRecognizer recognizer) => recognizer.debugDescription).toList(); final List<String> gestures = _recognizers.values.map<String>((GestureRecognizer recognizer) => recognizer.debugDescription).toList();
if (gestures.isEmpty) description.add(new IterableProperty<String>('gestures', gestures, ifEmpty: '<none>'));
gestures.add('<none>');
description.add(new IterableProperty<String>('gestures', gestures));
description.add(new IterableProperty<GestureRecognizer>('recognizers', _recognizers.values, level: DiagnosticLevel.fine)); description.add(new IterableProperty<GestureRecognizer>('recognizers', _recognizers.values, level: DiagnosticLevel.fine));
} }
description.add(new EnumProperty<HitTestBehavior>('behavior', widget.behavior, defaultValue: null)); description.add(new EnumProperty<HitTestBehavior>('behavior', widget.behavior, defaultValue: null));
......
...@@ -115,7 +115,9 @@ class Hero extends StatefulWidget { ...@@ -115,7 +115,9 @@ class Hero extends StatefulWidget {
'There are multiple heroes that share the same tag within a subtree.\n' 'There are multiple heroes that share the same tag within a subtree.\n'
'Within each subtree for which heroes are to be animated (typically a PageRoute subtree), ' 'Within each subtree for which heroes are to be animated (typically a PageRoute subtree), '
'each Hero must have a unique non-null tag.\n' 'each Hero must have a unique non-null tag.\n'
'In this case, multiple heroes had the tag "$tag".' 'In this case, multiple heroes had the following tag: $tag\n'
'Here is the subtree for one of the offending heroes:\n'
'${element.toStringDeep(prefixLineOne: "# ")}'
); );
} }
return true; return true;
...@@ -131,6 +133,12 @@ class Hero extends StatefulWidget { ...@@ -131,6 +133,12 @@ class Hero extends StatefulWidget {
@override @override
_HeroState createState() => new _HeroState(); _HeroState createState() => new _HeroState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
description.add(new DiagnosticsProperty<Object>('tag', tag));
}
} }
class _HeroState extends State<Hero> { class _HeroState extends State<Hero> {
......
...@@ -43,4 +43,93 @@ void main() { ...@@ -43,4 +43,93 @@ void main() {
await tester.tap(find.byType(Icon)); await tester.tap(find.byType(Icon));
expect(find.byTooltip('Add'), findsOneWidget); expect(find.byTooltip('Add'), findsOneWidget);
}); });
testWidgets('Floating Action Button tooltip (no child)', (WidgetTester tester) async {
await tester.pumpWidget(
new MaterialApp(
home: const Scaffold(
floatingActionButton: const FloatingActionButton(
onPressed: null,
tooltip: 'Add',
),
),
),
);
expect(find.byType(Text), findsNothing);
await tester.longPress(find.byType(FloatingActionButton));
await tester.pump();
expect(find.byType(Text), findsOneWidget);
});
testWidgets('Floating Action Button heroTag', (WidgetTester tester) async {
BuildContext theContext;
await tester.pumpWidget(
new MaterialApp(
home: new Scaffold(
body: new Builder(
builder: (BuildContext context) {
theContext = context;
return const FloatingActionButton(heroTag: 1, onPressed: null);
},
),
floatingActionButton: const FloatingActionButton(heroTag: 2, onPressed: null),
),
),
);
Navigator.push(theContext, new PageRouteBuilder<Null>(
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
return const Placeholder();
},
));
await tester.pump(); // this would fail if heroTag was the same on both FloatingActionButtons (see below).
});
testWidgets('Floating Action Button heroTag - with duplicate', (WidgetTester tester) async {
BuildContext theContext;
await tester.pumpWidget(
new MaterialApp(
home: new Scaffold(
body: new Builder(
builder: (BuildContext context) {
theContext = context;
return const FloatingActionButton(onPressed: null);
},
),
floatingActionButton: const FloatingActionButton(onPressed: null),
),
),
);
Navigator.push(theContext, new PageRouteBuilder<Null>(
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
return const Placeholder();
},
));
await tester.pump();
expect(tester.takeException().toString(), contains('FloatingActionButton'));
});
testWidgets('Floating Action Button heroTag - with duplicate', (WidgetTester tester) async {
BuildContext theContext;
await tester.pumpWidget(
new MaterialApp(
home: new Scaffold(
body: new Builder(
builder: (BuildContext context) {
theContext = context;
return const FloatingActionButton(heroTag: 'xyzzy', onPressed: null);
},
),
floatingActionButton: const FloatingActionButton(heroTag: 'xyzzy', onPressed: null),
),
),
);
Navigator.push(theContext, new PageRouteBuilder<Null>(
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
return const Placeholder();
},
));
await tester.pump();
expect(tester.takeException().toString(), contains('xyzzy'));
});
} }
...@@ -70,15 +70,15 @@ void main() { ...@@ -70,15 +70,15 @@ void main() {
// Analyze in the current directory - no arguments // Analyze in the current directory - no arguments
testUsingContext('flutter analyze working directory with errors', () async { testUsingContext('flutter analyze working directory with errors', () async {
// Break the code to produce the "The parameter 'child' is required" hint // Break the code to produce the "The parameter 'onPressed' is required" hint
// that is upgraded to a warning in package:flutter/analysis_options_user.yaml // that is upgraded to a warning in package:flutter/analysis_options_user.yaml
// to assert that we are using the default Flutter analysis options. // to assert that we are using the default Flutter analysis options.
// Also insert a statement that should not trigger a lint here // Also insert a statement that should not trigger a lint here
// but will trigger a lint later on when an analysis_options.yaml is added. // but will trigger a lint later on when an analysis_options.yaml is added.
String source = await libMain.readAsString(); String source = await libMain.readAsString();
source = source.replaceFirst( source = source.replaceFirst(
'child: new Icon(Icons.add),', 'onPressed: _incrementCounter,',
'// child: new Icon(Icons.add),', '// onPressed: _incrementCounter,',
); );
source = source.replaceFirst( source = source.replaceFirst(
'_counter++;', '_counter++;',
...@@ -92,8 +92,9 @@ void main() { ...@@ -92,8 +92,9 @@ void main() {
arguments: <String>['analyze'], arguments: <String>['analyze'],
statusTextContains: <String>[ statusTextContains: <String>[
'Analyzing', 'Analyzing',
'warning $analyzerSeparator The parameter \'child\' is required', 'warning $analyzerSeparator The parameter \'onPressed\' is required',
'1 issue found.', 'hint $analyzerSeparator The method \'_incrementCounter\' isn\'t used',
'2 issues found.',
], ],
toolExit: true, toolExit: true,
); );
...@@ -106,8 +107,9 @@ void main() { ...@@ -106,8 +107,9 @@ void main() {
arguments: <String>['analyze', libMain.path], arguments: <String>['analyze', libMain.path],
statusTextContains: <String>[ statusTextContains: <String>[
'Analyzing', 'Analyzing',
'warning $analyzerSeparator The parameter \'child\' is required', 'warning $analyzerSeparator The parameter \'onPressed\' is required',
'1 issue found.', 'hint $analyzerSeparator The method \'_incrementCounter\' isn\'t used',
'2 issues found.',
], ],
toolExit: true, toolExit: true,
); );
...@@ -132,9 +134,10 @@ void main() { ...@@ -132,9 +134,10 @@ void main() {
arguments: <String>['analyze'], arguments: <String>['analyze'],
statusTextContains: <String>[ statusTextContains: <String>[
'Analyzing', 'Analyzing',
'warning $analyzerSeparator The parameter \'child\' is required', 'warning $analyzerSeparator The parameter \'onPressed\' is required',
'hint $analyzerSeparator The method \'_incrementCounter\' isn\'t used',
'lint $analyzerSeparator Only throw instances of classes extending either Exception or Error', 'lint $analyzerSeparator Only throw instances of classes extending either Exception or Error',
'2 issues found.', '3 issues found.',
], ],
toolExit: true, toolExit: true,
); );
...@@ -181,9 +184,10 @@ void bar() { ...@@ -181,9 +184,10 @@ void bar() {
arguments: <String>['analyze', libMain.path], arguments: <String>['analyze', libMain.path],
statusTextContains: <String>[ statusTextContains: <String>[
'Analyzing', 'Analyzing',
'warning $analyzerSeparator The parameter \'child\' is required', 'warning $analyzerSeparator The parameter \'onPressed\' is required',
'hint $analyzerSeparator The method \'_incrementCounter\' isn\'t used',
'lint $analyzerSeparator Only throw instances of classes extending either Exception or Error', 'lint $analyzerSeparator Only throw instances of classes extending either Exception or Error',
'2 issues found.', '3 issues found.',
], ],
toolExit: true, toolExit: true,
); );
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment