diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index 1623c025b2..9f6363a15f 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -96,6 +96,7 @@ export 'src/material/material.dart'; export 'src/material/material_button.dart'; export 'src/material/material_localizations.dart'; export 'src/material/material_state.dart'; +export 'src/material/material_state_mixin.dart'; export 'src/material/mergeable_material.dart'; export 'src/material/navigation_rail.dart'; export 'src/material/navigation_rail_theme.dart'; diff --git a/packages/flutter/lib/src/material/button.dart b/packages/flutter/lib/src/material/button.dart index 1d65479811..8096cb04d8 100644 --- a/packages/flutter/lib/src/material/button.dart +++ b/packages/flutter/lib/src/material/button.dart @@ -13,6 +13,7 @@ import 'constants.dart'; import 'ink_well.dart'; import 'material.dart'; import 'material_state.dart'; +import 'material_state_mixin.dart'; import 'theme.dart'; import 'theme_data.dart'; @@ -314,75 +315,40 @@ class RawMaterialButton extends StatefulWidget { State createState() => _RawMaterialButtonState(); } -class _RawMaterialButtonState extends State { - final Set _states = {}; - - bool get _hovered => _states.contains(MaterialState.hovered); - bool get _focused => _states.contains(MaterialState.focused); - bool get _pressed => _states.contains(MaterialState.pressed); - bool get _disabled => _states.contains(MaterialState.disabled); - - void _updateState(MaterialState state, bool value) { - value ? _states.add(state) : _states.remove(state); - } - - void _handleHighlightChanged(bool value) { - if (_pressed != value) { - setState(() { - _updateState(MaterialState.pressed, value); - widget.onHighlightChanged?.call(value); - }); - } - } - - void _handleHoveredChanged(bool value) { - if (_hovered != value) { - setState(() { - _updateState(MaterialState.hovered, value); - }); - } - } - - void _handleFocusedChanged(bool value) { - if (_focused != value) { - setState(() { - _updateState(MaterialState.focused, value); - }); - } - } +class _RawMaterialButtonState extends State with MaterialStateMixin { @override void initState() { super.initState(); - _updateState(MaterialState.disabled, !widget.enabled); + setMaterialState(MaterialState.disabled, !widget.enabled); } @override void didUpdateWidget(RawMaterialButton oldWidget) { super.didUpdateWidget(oldWidget); - _updateState(MaterialState.disabled, !widget.enabled); + setMaterialState(MaterialState.disabled, !widget.enabled); // If the button is disabled while a press gesture is currently ongoing, // InkWell makes a call to handleHighlightChanged. This causes an exception // because it calls setState in the middle of a build. To preempt this, we // manually update pressed to false when this situation occurs. - if (_disabled && _pressed) { - _handleHighlightChanged(false); + if (isDisabled && isPressed) { + removeMaterialState(MaterialState.pressed); } } double get _effectiveElevation { // These conditionals are in order of precedence, so be careful about // reorganizing them. - if (_disabled) { + if (isDisabled) { return widget.disabledElevation; } - if (_pressed) { + if (isPressed) { return widget.highlightElevation; } - if (_hovered) { + if (isHovered) { return widget.hoverElevation; } - if (_focused) { + if (isFocused) { return widget.focusElevation; } return widget.elevation; @@ -390,13 +356,13 @@ class _RawMaterialButtonState extends State { @override Widget build(BuildContext context) { - final Color? effectiveTextColor = MaterialStateProperty.resolveAs(widget.textStyle?.color, _states); - final ShapeBorder? effectiveShape = MaterialStateProperty.resolveAs(widget.shape, _states); + final Color? effectiveTextColor = MaterialStateProperty.resolveAs(widget.textStyle?.color, materialStates); + final ShapeBorder? effectiveShape = MaterialStateProperty.resolveAs(widget.shape, materialStates); final Offset densityAdjustment = widget.visualDensity.baseSizeAdjustment; final BoxConstraints effectiveConstraints = widget.visualDensity.effectiveConstraints(widget.constraints); final MouseCursor? effectiveMouseCursor = MaterialStateProperty.resolveAs( widget.mouseCursor ?? MaterialStateMouseCursor.clickable, - _states, + materialStates, ); final EdgeInsetsGeometry padding = widget.padding.add( EdgeInsets.only( @@ -421,14 +387,14 @@ class _RawMaterialButtonState extends State { child: InkWell( focusNode: widget.focusNode, canRequestFocus: widget.enabled, - onFocusChange: _handleFocusedChanged, + onFocusChange: updateMaterialState(MaterialState.focused), autofocus: widget.autofocus, - onHighlightChanged: _handleHighlightChanged, + onHighlightChanged: updateMaterialState(MaterialState.pressed, onChanged: widget.onHighlightChanged), splashColor: widget.splashColor, highlightColor: widget.highlightColor, focusColor: widget.focusColor, hoverColor: widget.hoverColor, - onHover: _handleHoveredChanged, + onHover: updateMaterialState(MaterialState.hovered), onTap: widget.onPressed, onLongPress: widget.onLongPress, enableFeedback: widget.enableFeedback, diff --git a/packages/flutter/lib/src/material/button_style_button.dart b/packages/flutter/lib/src/material/button_style_button.dart index f50abda998..2798d7547d 100644 --- a/packages/flutter/lib/src/material/button_style_button.dart +++ b/packages/flutter/lib/src/material/button_style_button.dart @@ -14,6 +14,7 @@ import 'constants.dart'; import 'ink_well.dart'; import 'material.dart'; import 'material_state.dart'; +import 'material_state_mixin.dart'; import 'theme_data.dart'; /// The base [StatefulWidget] class for buttons whose style is defined by a [ButtonStyle] object. @@ -176,49 +177,15 @@ abstract class ButtonStyleButton extends StatefulWidget { /// * [TextButton], a simple button without a shadow. /// * [ElevatedButton], a filled button whose material elevates when pressed. /// * [OutlinedButton], similar to [TextButton], but with an outline. -class _ButtonStyleState extends State with TickerProviderStateMixin { +class _ButtonStyleState extends State with MaterialStateMixin, TickerProviderStateMixin { AnimationController? _controller; double? _elevation; Color? _backgroundColor; - final Set _states = {}; - - bool get _hovered => _states.contains(MaterialState.hovered); - bool get _focused => _states.contains(MaterialState.focused); - bool get _pressed => _states.contains(MaterialState.pressed); - bool get _disabled => _states.contains(MaterialState.disabled); - - void _updateState(MaterialState state, bool value) { - value ? _states.add(state) : _states.remove(state); - } - - void _handleHighlightChanged(bool value) { - if (_pressed != value) { - setState(() { - _updateState(MaterialState.pressed, value); - }); - } - } - - void _handleHoveredChanged(bool value) { - if (_hovered != value) { - setState(() { - _updateState(MaterialState.hovered, value); - }); - } - } - - void _handleFocusedChanged(bool value) { - if (_focused != value) { - setState(() { - _updateState(MaterialState.focused, value); - }); - } - } @override void initState() { super.initState(); - _updateState(MaterialState.disabled, !widget.enabled); + setMaterialState(MaterialState.disabled, !widget.enabled); } @override @@ -230,13 +197,13 @@ class _ButtonStyleState extends State with TickerProviderStat @override void didUpdateWidget(ButtonStyleButton oldWidget) { super.didUpdateWidget(oldWidget); - _updateState(MaterialState.disabled, !widget.enabled); + setMaterialState(MaterialState.disabled, !widget.enabled); // If the button is disabled while a press gesture is currently ongoing, // InkWell makes a call to handleHighlightChanged. This causes an exception // because it calls setState in the middle of a build. To preempt this, we // manually update pressed to false when this situation occurs. - if (_disabled && _pressed) { - _handleHighlightChanged(false); + if (isDisabled && isPressed) { + removeMaterialState(MaterialState.pressed); } } @@ -256,7 +223,7 @@ class _ButtonStyleState extends State with TickerProviderStat T? resolve(MaterialStateProperty? Function(ButtonStyle? style) getProperty) { return effectiveValue( - (ButtonStyle? style) => getProperty(style)?.resolve(_states), + (ButtonStyle? style) => getProperty(style)?.resolve(materialStates), ); } @@ -367,13 +334,13 @@ class _ButtonStyleState extends State with TickerProviderStat child: InkWell( onTap: widget.onPressed, onLongPress: widget.onLongPress, - onHighlightChanged: _handleHighlightChanged, - onHover: _handleHoveredChanged, + onHighlightChanged: updateMaterialState(MaterialState.pressed), + onHover: updateMaterialState(MaterialState.hovered), mouseCursor: resolvedMouseCursor, enableFeedback: resolvedEnableFeedback, focusNode: widget.focusNode, canRequestFocus: widget.enabled, - onFocusChange: _handleFocusedChanged, + onFocusChange: updateMaterialState(MaterialState.focused), autofocus: widget.autofocus, splashFactory: resolvedSplashFactory, overlayColor: overlayColor, diff --git a/packages/flutter/lib/src/material/chip.dart b/packages/flutter/lib/src/material/chip.dart index db56e5fc24..49e4b9ff02 100644 --- a/packages/flutter/lib/src/material/chip.dart +++ b/packages/flutter/lib/src/material/chip.dart @@ -17,6 +17,7 @@ import 'ink_well.dart'; import 'material.dart'; import 'material_localizations.dart'; import 'material_state.dart'; +import 'material_state_mixin.dart'; import 'theme.dart'; import 'theme_data.dart'; import 'tooltip.dart'; @@ -1631,7 +1632,7 @@ class RawChip extends StatefulWidget State createState() => _RawChipState(); } -class _RawChipState extends State with TickerProviderStateMixin { +class _RawChipState extends State with MaterialStateMixin, TickerProviderStateMixin { static const Duration pressedAnimationDuration = Duration(milliseconds: 75); late AnimationController selectController; @@ -1644,8 +1645,6 @@ class _RawChipState extends State with TickerProviderStateMixin enableAnimation; late Animation selectionFade; - final Set _states = {}; - final GlobalKey deleteIconKey = GlobalKey(); bool get hasDeleteButton => widget.onDeleted != null; @@ -1664,8 +1663,8 @@ class _RawChipState extends State with TickerProviderStateMixin with TickerProviderStateMixin with TickerProviderStateMixin with TickerProviderStateMixin(widget.side, _states) - ?? MaterialStateProperty.resolveAs(theme.side, _states); - final OutlinedBorder resolvedShape = MaterialStateProperty.resolveAs(widget.shape, _states) - ?? MaterialStateProperty.resolveAs(theme.shape, _states) + final BorderSide? resolvedSide = MaterialStateProperty.resolveAs(widget.side, materialStates) + ?? MaterialStateProperty.resolveAs(theme.side, materialStates); + final OutlinedBorder resolvedShape = MaterialStateProperty.resolveAs(widget.shape, materialStates) + ?? MaterialStateProperty.resolveAs(theme.shape, materialStates) ?? const StadiumBorder(); return resolvedShape.copyWith(side: resolvedSide); } @@ -1813,7 +1796,7 @@ class _RawChipState extends State with TickerProviderStateMixin with TickerProviderStateMixin with TickerProviderStateMixin(effectiveLabelStyle.color, _states); + final Color? resolvedLabelColor = MaterialStateProperty.resolveAs(effectiveLabelStyle.color, materialStates); final TextStyle resolvedLabelStyle = effectiveLabelStyle.copyWith(color: resolvedLabelColor); final EdgeInsetsGeometry labelPadding = widget.labelPadding ?? chipTheme.labelPadding ?? _defaultLabelPadding; @@ -1943,14 +1926,14 @@ class _RawChipState extends State with TickerProviderStateMixin` function and allow [MaterialStateMixin] +/// to manage the set of active [MaterialState]s, and the calling of [setState] +/// as necessary. +/// +/// {@tool snippet} +/// This example shows how to write a [StatefulWidget] that uses the +/// [MaterialStateMixin] class to watch [MaterialState] values. +/// +/// ```dart +/// class MyWidget extends StatefulWidget { +/// const MyWidget({required this.color, required this.child, Key? key}) : super(key: key); +/// +/// final MaterialStateColor color; +/// final Widget child; +/// +/// @override +/// State createState() => MyWidgetState(); +/// } +/// +/// class MyWidgetState extends State with MaterialStateMixin { +/// @override +/// Widget build(BuildContext context) { +/// return InkWell( +/// onFocusChange: updateMaterialState(MaterialState.focused), +/// child: Container( +/// color: widget.color.resolve(materialStates), +/// child: widget.child, +/// ), +/// ); +/// } +/// } +/// ``` +/// {@end-tool} +@optionalTypeArgs +mixin MaterialStateMixin on State { + /// Managed set of active [MaterialState] values; designed to be passed to + /// [MaterialStateProperty.resolve] methods. + /// + /// To mutate and have [setState] called automatically for you, use + /// [setMaterialState], [addMaterialState], or [removeMaterialState]. Directly + /// mutating the set is possible, and may be necessary if you need to alter its + /// list without calling [setState] (and thus triggering a re-render). + /// + /// To check for a single condition, convenience getters [isPressed], [isHovered], + /// [isFocused], etc, are available for each [MaterialState] value. + @protected + Set materialStates = {}; + + /// Callback factory which accepts a [MaterialState] value and returns a + /// closure to mutate [materialStates] and call [setState]. + /// + /// Accepts an optional second named parameter, `onChanged`, which allows + /// arbitrary functionality to be wired through the [MaterialStateMixin]. + /// If supplied, the [onChanged] function is only called when child widgets + /// report events that make changes to the current set of [MaterialState]s. + /// + /// {@tool snippet} + /// This example shows how to use the [updateMaterialState] callback factory + /// in other widgets, including the optional [onChanged] callback. + /// + /// ```dart + /// class MyWidget extends StatefulWidget { + /// const MyWidget({this.onPressed, Key? key}) : super(key: key); + /// + /// /// Something important this widget must do when pressed. + /// final VoidCallback? onPressed; + /// + /// @override + /// State createState() => MyWidgetState(); + /// } + /// + /// class MyWidgetState extends State with MaterialStateMixin { + /// @override + /// Widget build(BuildContext context) { + /// return Container( + /// color: isPressed ? Colors.black : Colors.white, + /// child: InkWell( + /// onHighlightChanged: updateMaterialState( + /// MaterialState.pressed, + /// onChanged: (bool val) { + /// if (val) { + /// widget.onPressed?.call(); + /// } + /// }, + /// ), + /// ), + /// ); + /// } + /// } + /// ``` + /// {@end-tool} + @protected + ValueChanged updateMaterialState(MaterialState key, {ValueChanged? onChanged}) { + return (bool value) { + if (materialStates.contains(key) == value) + return; + setMaterialState(key, value); + onChanged?.call(value); + }; + } + + /// Mutator to mark a [MaterialState] value as either active or inactive. + @protected + void setMaterialState(MaterialState _state, bool isSet) { + return isSet ? addMaterialState(_state) : removeMaterialState(_state); + } + + /// Mutator to mark a [MaterialState] value as active. + @protected + void addMaterialState(MaterialState _state) { + if (materialStates.add(_state)) + setState((){}); + } + + /// Mutator to mark a [MaterialState] value as inactive. + @protected + void removeMaterialState(MaterialState _state) { + if (materialStates.remove(_state)) + setState((){}); + } + + /// Getter for whether this class considers [MaterialState.disabled] to be active. + bool get isDisabled => materialStates.contains(MaterialState.disabled); + + /// Getter for whether this class considers [MaterialState.dragged] to be active. + bool get isDragged => materialStates.contains(MaterialState.dragged); + + /// Getter for whether this class considers [MaterialState.error] to be active. + bool get isErrored => materialStates.contains(MaterialState.error); + + /// Getter for whether this class considers [MaterialState.focused] to be active. + bool get isFocused => materialStates.contains(MaterialState.focused); + + /// Getter for whether this class considers [MaterialState.hovered] to be active. + bool get isHovered => materialStates.contains(MaterialState.hovered); + + /// Getter for whether this class considers [MaterialState.pressed] to be active. + bool get isPressed => materialStates.contains(MaterialState.pressed); + + /// Getter for whether this class considers [MaterialState.scrolledUnder] to be active. + bool get isScrolledUnder => materialStates.contains(MaterialState.scrolledUnder); + + /// Getter for whether this class considers [MaterialState.selected] to be active. + bool get isSelected => materialStates.contains(MaterialState.selected); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty>('materialStates', materialStates, defaultValue: {})); + } +} diff --git a/packages/flutter/test/material/material_state_mixin_test.dart b/packages/flutter/test/material/material_state_mixin_test.dart new file mode 100644 index 0000000000..3da56dfead --- /dev/null +++ b/packages/flutter/test/material/material_state_mixin_test.dart @@ -0,0 +1,168 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const Key key = Key('testContainer'); +const Color trueColor = Colors.red; +const Color falseColor = Colors.green; + +/// Mock widget which plays the role of a button -- it can emit notifications +/// that [MaterialState] values are now in or out of play. +class _InnerWidget extends StatefulWidget { + const _InnerWidget({required this.onValueChanged, required this.controller, Key? key}) : super(key: key); + final ValueChanged onValueChanged; + final StreamController controller; + + @override + _InnerWidgetState createState() => _InnerWidgetState(); +} + +class _InnerWidgetState extends State<_InnerWidget> { + @override + void initState() { + super.initState(); + widget.controller.stream.listen((bool val) => widget.onValueChanged(val)); + } + @override + Widget build(BuildContext context) => Container(); +} + +class _MyWidget extends StatefulWidget { + const _MyWidget({ + required this.controller, + required this.evaluator, + required this.materialState, + Key? key, + }) : super(key: key); + + /// Wrapper around `MaterialStateMixin.isPressed/isHovered/isFocused/etc`. + final bool Function(_MyWidgetState state) evaluator; + + /// Stream passed down to the child [_InnerWidget] to begin the process. + /// This plays the role of an actual user interaction in the wild, but allows + /// us to engage the system without mocking pointers/hovers etc. + final StreamController controller; + + /// The value we're watching in the given test. + final MaterialState materialState; + + @override + State createState() => _MyWidgetState(); +} + +class _MyWidgetState extends State<_MyWidget> with MaterialStateMixin { + + @override + Widget build(BuildContext context) { + return Container( + key: key, + color: widget.evaluator(this) ? trueColor : falseColor, + child: _InnerWidget( + onValueChanged: updateMaterialState(widget.materialState), + controller: widget.controller, + ), + ); + } +} + +void main() { + + Future _verify(WidgetTester tester, Widget widget, StreamController controller,) async { + await tester.pumpWidget(MaterialApp(home: Scaffold(body: widget))); + // Set the value to True + controller.sink.add(true); + await tester.pumpAndSettle(); + expect(tester.widget(find.byKey(key)).color, trueColor); + + // Set the value to False + controller.sink.add(false); + await tester.pumpAndSettle(); + expect(tester.widget(find.byKey(key)).color, falseColor); + } + + testWidgets('MaterialState.pressed is tracked', (WidgetTester tester) async { + final StreamController controller = StreamController(); + final _MyWidget widget = _MyWidget( + controller: controller, + evaluator: (_MyWidgetState state) => state.isPressed, + materialState: MaterialState.pressed, + ); + await _verify(tester, widget, controller); + }); + + testWidgets('MaterialState.focused is tracked', (WidgetTester tester) async { + final StreamController controller = StreamController(); + final _MyWidget widget = _MyWidget( + controller: controller, + evaluator: (_MyWidgetState state) => state.isFocused, + materialState: MaterialState.focused, + ); + await _verify(tester, widget, controller); + }); + + testWidgets('MaterialState.hovered is tracked', (WidgetTester tester) async { + final StreamController controller = StreamController(); + final _MyWidget widget = _MyWidget( + controller: controller, + evaluator: (_MyWidgetState state) => state.isHovered, + materialState: MaterialState.hovered, + ); + await _verify(tester, widget, controller); + }); + + testWidgets('MaterialState.disabled is tracked', (WidgetTester tester) async { + final StreamController controller = StreamController(); + final _MyWidget widget = _MyWidget( + controller: controller, + evaluator: (_MyWidgetState state) => state.isDisabled, + materialState: MaterialState.disabled, + ); + await _verify(tester, widget, controller); + }); + + testWidgets('MaterialState.selected is tracked', (WidgetTester tester) async { + final StreamController controller = StreamController(); + final _MyWidget widget = _MyWidget( + controller: controller, + evaluator: (_MyWidgetState state) => state.isSelected, + materialState: MaterialState.selected, + ); + await _verify(tester, widget, controller); + }); + + testWidgets('MaterialState.scrolledUnder is tracked', (WidgetTester tester) async { + final StreamController controller = StreamController(); + final _MyWidget widget = _MyWidget( + controller: controller, + evaluator: (_MyWidgetState state) => state.isScrolledUnder, + materialState: MaterialState.scrolledUnder, + ); + await _verify(tester, widget, controller); + }); + + testWidgets('MaterialState.dragged is tracked', (WidgetTester tester) async { + final StreamController controller = StreamController(); + final _MyWidget widget = _MyWidget( + controller: controller, + evaluator: (_MyWidgetState state) => state.isDragged, + materialState: MaterialState.dragged, + ); + await _verify(tester, widget, controller); + }); + + testWidgets('MaterialState.error is tracked', (WidgetTester tester) async { + final StreamController controller = StreamController(); + final _MyWidget widget = _MyWidget( + controller: controller, + evaluator: (_MyWidgetState state) => state.isErrored, + materialState: MaterialState.error, + ); + await _verify(tester, widget, controller); + }); +}