Unverified Commit 29928a46 authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

Support setting the elevation of disabled floating action buttons (#24728)

Previously, a disabled floating action button always had zero
elevation, which looks dumb.

This also fixes the issue whereby highlightElevation was not honoured
on floating action buttons.

This also fixes an issue I found during testing whereby setState was
being called during build when onHighlightChanged fired due to
onPressed becoming null while a gesture is ongoing (which triggers an
onTapCancel synchronously during build).
parent d7458e3d
034b2a540bc46375cf0c175a0fd512dcd46971e0
7e3f945a906f9f1ffdcb1edba870f4533d2c2b86
......@@ -68,6 +68,10 @@ class RawMaterialButton extends StatefulWidget {
/// Called by the underlying [InkWell] widget's [InkWell.onHighlightChanged]
/// callback.
///
/// If [onPressed] changes from null to non-null while a gesture is ongoing,
/// this can fire during the build phase (in which case calling
/// [State.setState] is not allowed).
final ValueChanged<bool> onHighlightChanged;
/// Defines the default text style, with [Material.textStyle], for the
......@@ -110,6 +114,8 @@ class RawMaterialButton extends StatefulWidget {
///
/// Defaults to 0.0. The value is always non-negative.
///
/// See also:
///
/// * [elevation], the default elevation.
/// * [highlightElevation], the elevation when the button is pressed.
final double disabledElevation;
......@@ -161,12 +167,24 @@ class RawMaterialButton extends StatefulWidget {
class _RawMaterialButtonState extends State<RawMaterialButton> {
bool _highlight = false;
void _handleHighlightChanged(bool value) {
if (_highlight != value) {
setState(() {
_highlight = value;
if (widget.onHighlightChanged != null)
widget.onHighlightChanged(value);
});
}
}
@override
void didUpdateWidget(RawMaterialButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (_highlight && !widget.enabled) {
_highlight = false;
if (widget.onHighlightChanged != null)
widget.onHighlightChanged(false);
}
}
@override
Widget build(BuildContext context) {
......
......@@ -444,7 +444,7 @@ class ButtonThemeData extends Diagnosticable {
}
/// The [button]'s background color when [MaterialButton.onPressed] is null
/// (when MaterialButton.enabled is false).
/// (when [MaterialButton.enabled] is false).
///
/// Returns the button's [MaterialButton.disabledColor] if it is non-null.
///
......
......@@ -118,7 +118,6 @@ class FlatButton extends MaterialButton {
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ButtonThemeData buttonTheme = ButtonTheme.of(context);
return RawMaterialButton(
onPressed: onPressed,
onHighlightChanged: onHighlightChanged,
......
......@@ -43,10 +43,15 @@ class _DefaultHeroTag {
///
/// Use at most a single floating action button per screen. Floating action
/// buttons should be used for positive actions such as "create", "share", or
/// "navigate".
/// "navigate". (If more than one floating action button is used within a
/// [Route], then make sure that each button has a unique [heroTag], otherwise
/// an exception will be thrown.)
///
/// If the [onPressed] callback is null, then the button will be disabled and
/// will not react to touch.
/// will not react to touch. It is highly discouraged to disable a floating
/// action button as there is no indication to the user that the button is
/// disabled. Consider changing the [backgroundColor] if disabling the floating
/// action button.
///
/// See also:
///
......@@ -54,12 +59,13 @@ class _DefaultHeroTag {
/// * [RaisedButton], another kind of button that appears to float above the
/// content.
/// * <https://material.io/design/components/buttons-floating-action-button.html>
class FloatingActionButton extends StatefulWidget {
class FloatingActionButton extends StatelessWidget {
/// Creates a circular floating action button.
///
/// The [elevation], [highlightElevation], [mini], [shape], and [clipBehavior]
/// arguments must not be null. Additionally, [elevation] and
/// [highlightElevation] must be non-negative.
/// arguments must not be null. Additionally, [elevation],
/// [highlightElevation], and [disabledElevation] (if specified) must be
/// non-negative.
const FloatingActionButton({
Key key,
this.child,
......@@ -69,6 +75,7 @@ class FloatingActionButton extends StatefulWidget {
this.heroTag = const _DefaultHeroTag(),
this.elevation = 6.0,
this.highlightElevation = 12.0,
double disabledElevation,
@required this.onPressed,
this.mini = false,
this.shape = const CircleBorder(),
......@@ -77,18 +84,21 @@ class FloatingActionButton extends StatefulWidget {
this.isExtended = false,
}) : assert(elevation != null && elevation >= 0.0),
assert(highlightElevation != null && highlightElevation >= 0.0),
assert(disabledElevation == null || disabledElevation >= 0.0),
assert(mini != null),
assert(shape != null),
assert(isExtended != null),
_sizeConstraints = mini ? _kMiniSizeConstraints : _kSizeConstraints,
disabledElevation = disabledElevation ?? elevation,
super(key: key);
/// Creates a wider [StadiumBorder] shaped floating action button with both
/// Creates a wider [StadiumBorder]-shaped floating action button with both
/// an [icon] and a [label].
///
/// The [label], [icon], [elevation], [highlightElevation], [clipBehavior]
/// and [shape] arguments must not be null. Additionally, [elevation] and
// [highlightElevation] must be non-negative.
/// The [label], [icon], [elevation], [highlightElevation], [clipBehavior] and
/// [shape] arguments must not be null. Additionally, [elevation]
/// [highlightElevation], and [disabledElevation] (if specified) must be
/// non-negative.
FloatingActionButton.extended({
Key key,
this.tooltip,
......@@ -97,6 +107,7 @@ class FloatingActionButton extends StatefulWidget {
this.heroTag = const _DefaultHeroTag(),
this.elevation = 6.0,
this.highlightElevation = 12.0,
double disabledElevation,
@required this.onPressed,
this.shape = const StadiumBorder(),
this.isExtended = true,
......@@ -106,10 +117,12 @@ class FloatingActionButton extends StatefulWidget {
@required Widget label,
}) : assert(elevation != null && elevation >= 0.0),
assert(highlightElevation != null && highlightElevation >= 0.0),
assert(disabledElevation == null || disabledElevation >= 0.0),
assert(shape != null),
assert(isExtended != null),
assert(clipBehavior != null),
_sizeConstraints = _kExtendedSizeConstraints,
disabledElevation = disabledElevation ?? elevation,
mini = false,
child = _ChildOverflowBox(
child: Row(
......@@ -167,11 +180,15 @@ class FloatingActionButton extends StatefulWidget {
/// The z-coordinate at which to place this button releative to its parent.
///
///
/// This controls the size of the shadow below the floating action button.
///
/// Defaults to 6, the appropriate elevation for floating action buttons. The
/// value is always non-negative.
///
/// See also:
///
/// * [highlightElevation], the elevation when the button is pressed.
/// * [disabledElevation], the elevation when the button is disabled.
final double elevation;
/// The z-coordinate at which to place this button relative to its parent when
......@@ -187,6 +204,21 @@ class FloatingActionButton extends StatefulWidget {
/// * [elevation], the default elevation.
final double highlightElevation;
/// The z-coordinate at which to place this button when the button is disabled
/// ([onPressed] is null).
///
/// This controls the size of the shadow below the floating action button.
///
/// Defaults to the same value as [elevation]. Setting this to zero makes the
/// floating action button work similar to a [RaisedButton] but the titular
/// "floating" effect is lost. The value is always non-negative.
///
/// See also:
///
/// * [elevation], the default elevation.
/// * [highlightElevation], the elevation when the button is pressed.
final double disabledElevation;
/// Controls the size of this button.
///
/// By default, floating action buttons are non-mini and have a height and
......@@ -229,62 +261,50 @@ class FloatingActionButton extends StatefulWidget {
final BoxConstraints _sizeConstraints;
@override
_FloatingActionButtonState createState() => _FloatingActionButtonState();
}
class _FloatingActionButtonState extends State<FloatingActionButton> {
bool _highlight = false;
void _handleHighlightChanged(bool value) {
setState(() {
_highlight = value;
});
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final Color foregroundColor = widget.foregroundColor ?? theme.accentIconTheme.color;
final Color foregroundColor = this.foregroundColor ?? theme.accentIconTheme.color;
Widget result;
if (widget.child != null) {
if (child != null) {
result = IconTheme.merge(
data: IconThemeData(
color: foregroundColor,
),
child: widget.child,
child: child,
);
}
result = RawMaterialButton(
onPressed: widget.onPressed,
onHighlightChanged: _handleHighlightChanged,
elevation: _highlight ? widget.highlightElevation : widget.elevation,
constraints: widget._sizeConstraints,
materialTapTargetSize: widget.materialTapTargetSize ?? theme.materialTapTargetSize,
fillColor: widget.backgroundColor ?? theme.accentColor,
onPressed: onPressed,
elevation: elevation,
highlightElevation: highlightElevation,
disabledElevation: disabledElevation,
constraints: _sizeConstraints,
materialTapTargetSize: materialTapTargetSize ?? theme.materialTapTargetSize,
fillColor: backgroundColor ?? theme.accentColor,
textStyle: theme.accentTextTheme.button.copyWith(
color: foregroundColor,
letterSpacing: 1.2,
),
shape: widget.shape,
clipBehavior: widget.clipBehavior,
shape: shape,
clipBehavior: clipBehavior,
child: result,
);
if (widget.tooltip != null) {
if (tooltip != null) {
result = MergeSemantics(
child: Tooltip(
message: widget.tooltip,
message: tooltip,
child: result,
),
);
}
if (widget.heroTag != null) {
if (heroTag != null) {
result = Hero(
tag: widget.heroTag,
tag: heroTag,
child: result,
);
}
......
......@@ -242,6 +242,12 @@ class InkResponse extends StatefulWidget {
/// The value passed to the callback is true if this part of the material has
/// become highlighted and false if this part of the material has stopped
/// being highlighted.
///
/// If all of [onTap], [onDoubleTap], and [onLongPress] become null while a
/// gesture is ongoing, then [onTapCancel] will be fired and
/// [onHighlightChanged] will be fired with the value false _during the
/// build_. This means, for instance, that in that scenario [State.setState]
/// cannot be called.
final ValueChanged<bool> onHighlightChanged;
/// Whether this ink response should be clipped its bounds.
......
......@@ -73,6 +73,10 @@ class MaterialButton extends StatelessWidget {
/// Called by the underlying [InkWell] widget's [InkWell.onHighlightChanged]
/// callback.
///
/// If [onPressed] changes from null to non-null while a gesture is ongoing,
/// this can fire during the build phase (in which case calling
/// [State.setState] is not allowed).
final ValueChanged<bool> onHighlightChanged;
/// Defines the button's base colors, and the defaults for the button's minimum
......@@ -255,6 +259,7 @@ class MaterialButton extends StatelessWidget {
return RawMaterialButton(
onPressed: onPressed,
onHighlightChanged: onHighlightChanged,
fillColor: color,
textStyle: theme.textTheme.button.copyWith(color: buttonTheme.getTextColor(this)),
highlightColor: highlightColor ?? theme.highlightColor,
......
......@@ -315,6 +315,27 @@ class _OutlineButtonState extends State<_OutlineButton> with SingleTickerProvide
);
}
@override
void didUpdateWidget(_OutlineButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (_pressed && !widget.enabled) {
_pressed = false;
_controller.reverse();
}
}
void _handleHighlightChanged(bool value) {
if (_pressed == value)
return;
setState(() {
_pressed = value;
if (value)
_controller.forward();
else
_controller.reverse();
});
}
@override
void dispose() {
_controller.dispose();
......@@ -375,15 +396,7 @@ class _OutlineButtonState extends State<_OutlineButton> with SingleTickerProvide
elevation: 0.0,
disabledElevation: 0.0,
highlightElevation: _getHighlightElevation(),
onHighlightChanged: (bool value) {
setState(() {
_pressed = value;
if (value)
_controller.forward();
else
_controller.reverse();
});
},
onHighlightChanged: _handleHighlightChanged,
padding: widget.padding,
shape: _OutlineBorder(
shape: widget.shape,
......
......@@ -128,7 +128,6 @@ class RaisedButton extends MaterialButton {
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ButtonThemeData buttonTheme = ButtonTheme.of(context);
return RawMaterialButton(
onPressed: onPressed,
onHighlightChanged: onHighlightChanged,
......
......@@ -110,6 +110,137 @@ void main() {
expect(find.text('Add'), findsOneWidget);
});
testWidgets('Floating Action Button elevation when highlighted - defaults', (WidgetTester tester) async {
expect(const FloatingActionButton(onPressed: null).highlightElevation, 12.0);
expect(const FloatingActionButton(onPressed: null, highlightElevation: 0.0).highlightElevation, 0.0);
});
testWidgets('Floating Action Button elevation when highlighted - effect', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () { },
),
),
),
);
expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0);
final TestGesture gesture = await tester.press(find.byType(PhysicalShape));
await tester.pump();
expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0);
await tester.pump(const Duration(seconds: 1));
expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 12.0);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () { },
highlightElevation: 20.0
),
),
),
);
await tester.pump();
expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 12.0);
await tester.pump(const Duration(seconds: 1));
expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 20.0);
await gesture.up();
await tester.pump();
expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 20.0);
await tester.pump(const Duration(seconds: 1));
expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0);
});
testWidgets('Floating Action Button elevation when disabled - defaults', (WidgetTester tester) async {
expect(FloatingActionButton(onPressed: () { }).disabledElevation, 6.0);
expect(const FloatingActionButton(onPressed: null).disabledElevation, 6.0);
expect(FloatingActionButton(onPressed: () { }, disabledElevation: 0.0).disabledElevation, 0.0);
});
testWidgets('Floating Action Button elevation when disabled - effect', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: null,
),
),
),
);
expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0);
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: null,
disabledElevation: 3.0,
),
),
),
);
expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0);
await tester.pump(const Duration(seconds: 1));
expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 3.0);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () { },
disabledElevation: 3.0,
),
),
),
);
expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 3.0);
await tester.pump(const Duration(seconds: 1));
expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0);
});
testWidgets('Floating Action Button elevation when disabled while highlighted - effect', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () { },
),
),
),
);
expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0);
await tester.press(find.byType(PhysicalShape));
await tester.pump();
expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0);
await tester.pump(const Duration(seconds: 1));
expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 12.0);
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: null,
),
),
),
);
await tester.pump();
expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 12.0);
await tester.pump(const Duration(seconds: 1));
expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () { },
),
),
),
);
await tester.pump();
expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0);
await tester.pump(const Duration(seconds: 1));
expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0);
});
testWidgets('FlatActionButton mini size is configurable by ThemeData.materialTapTargetSize', (WidgetTester tester) async {
final Key key1 = UniqueKey();
await tester.pumpWidget(
......@@ -476,7 +607,7 @@ void main() {
await tester.pump(const Duration(milliseconds: 1000));
await expectLater(
find.byKey(key),
matchesGoldenFile('floating_action_button_test.clip.1.png'),
matchesGoldenFile('floating_action_button_test.clip.2.png'), // .clip.1.png is obsolete and can be removed
skip: !Platform.isLinux,
);
});
......
......@@ -43,6 +43,25 @@ void main() {
expect(pressedCount, 1);
});
testWidgets('Outline button doesn\'t crash if disabled during a gesture', (WidgetTester tester) async {
Widget buildFrame(VoidCallback onPressed) {
return Directionality(
textDirection: TextDirection.ltr,
child: Theme(
data: ThemeData(),
child: Center(
child: OutlineButton(onPressed: onPressed),
),
),
);
}
await tester.pumpWidget(buildFrame(() { }));
await tester.press(find.byType(OutlineButton));
await tester.pumpAndSettle();
await tester.pumpWidget(buildFrame(null));
await tester.pumpAndSettle();
});
testWidgets('OutlineButton shape and border component overrides', (WidgetTester tester) async {
const Color fillColor = Color(0xFF00FF00);
......
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