Unverified Commit 50dc84bc authored by MH Johnson's avatar MH Johnson Committed by GitHub

[Material] Add support for customizing active + disabled state color for...

[Material] Add support for customizing active + disabled state color for selection controls. (#68831)

* Add new MaterialStateProperty param to selection controls
parent 018467cd
...@@ -63,6 +63,7 @@ class Checkbox extends StatefulWidget { ...@@ -63,6 +63,7 @@ class Checkbox extends StatefulWidget {
required this.onChanged, required this.onChanged,
this.mouseCursor, this.mouseCursor,
this.activeColor, this.activeColor,
this.fillColor,
this.checkColor, this.checkColor,
this.focusColor, this.focusColor,
this.hoverColor, this.hoverColor,
...@@ -130,8 +131,23 @@ class Checkbox extends StatefulWidget { ...@@ -130,8 +131,23 @@ class Checkbox extends StatefulWidget {
/// The color to use when this checkbox is checked. /// The color to use when this checkbox is checked.
/// ///
/// Defaults to [ThemeData.toggleableActiveColor]. /// Defaults to [ThemeData.toggleableActiveColor].
///
/// If [fillColor] returns a non-null color in the [MaterialState.selected]
/// state, it will be used instead of this color.
final Color? activeColor; final Color? activeColor;
/// The color that fills the checkbox when it is checked, in all
/// [MaterialState]s.
///
/// If this is provided, it will be used over [activeColor].
///
/// Resolves in the following states:
/// * [MaterialState.selected].
/// * [MaterialState.hovered].
/// * [MaterialState.focused].
/// * [MaterialState.disabled].
final MaterialStateProperty<Color?>? fillColor;
/// The color to use for the check icon when this checkbox is checked. /// The color to use for the check icon when this checkbox is checked.
/// ///
/// Defaults to Color(0xFFFFFFFF) /// Defaults to Color(0xFFFFFFFF)
...@@ -236,6 +252,38 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin { ...@@ -236,6 +252,38 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin {
} }
} }
Set<MaterialState> get _states => <MaterialState>{
if (!enabled) MaterialState.disabled,
if (_hovering) MaterialState.hovered,
if (_focused) MaterialState.focused,
if (widget.value == null || widget.value!) MaterialState.selected,
};
MaterialStateProperty<Color?> get _widgetFillColor {
return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.disabled)) {
return null;
}
if (states.contains(MaterialState.selected)) {
return widget.activeColor;
}
return null;
});
}
MaterialStateProperty<Color> get _defaultFillColor {
final ThemeData themeData = Theme.of(context);
return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.disabled)) {
return themeData.disabledColor;
}
if (states.contains(MaterialState.selected)) {
return themeData.toggleableActiveColor;
}
return themeData.unselectedWidgetColor;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(debugCheckHasMaterial(context)); assert(debugCheckHasMaterial(context));
...@@ -253,13 +301,18 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin { ...@@ -253,13 +301,18 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin {
final BoxConstraints additionalConstraints = BoxConstraints.tight(size); final BoxConstraints additionalConstraints = BoxConstraints.tight(size);
final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor>( final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor>(
widget.mouseCursor ?? MaterialStateMouseCursor.clickable, widget.mouseCursor ?? MaterialStateMouseCursor.clickable,
<MaterialState>{ _states,
if (!enabled) MaterialState.disabled,
if (_hovering) MaterialState.hovered,
if (_focused) MaterialState.focused,
if (widget.tristate || widget.value!) MaterialState.selected,
},
); );
// Colors need to be resolved in selected and non selected states separately
// so that they can be lerped between.
final Set<MaterialState> activeStates = _states..add(MaterialState.selected);
final Set<MaterialState> inactiveStates = _states..remove(MaterialState.selected);
final Color effectiveActiveColor = widget.fillColor?.resolve(activeStates)
?? _widgetFillColor.resolve(activeStates)
?? _defaultFillColor.resolve(activeStates);
final Color effectiveInactiveColor = widget.fillColor?.resolve(inactiveStates)
?? _widgetFillColor.resolve(inactiveStates)
?? _defaultFillColor.resolve(inactiveStates);
return FocusableActionDetector( return FocusableActionDetector(
actions: _actionMap, actions: _actionMap,
...@@ -274,9 +327,9 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin { ...@@ -274,9 +327,9 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin {
return _CheckboxRenderObjectWidget( return _CheckboxRenderObjectWidget(
value: widget.value, value: widget.value,
tristate: widget.tristate, tristate: widget.tristate,
activeColor: widget.activeColor ?? themeData.toggleableActiveColor, activeColor: effectiveActiveColor,
checkColor: widget.checkColor ?? const Color(0xFFFFFFFF), checkColor: widget.checkColor ?? const Color(0xFFFFFFFF),
inactiveColor: enabled ? themeData.unselectedWidgetColor : themeData.disabledColor, inactiveColor: effectiveInactiveColor,
focusColor: widget.focusColor ?? themeData.focusColor, focusColor: widget.focusColor ?? themeData.focusColor,
hoverColor: widget.hoverColor ?? themeData.hoverColor, hoverColor: widget.hoverColor ?? themeData.hoverColor,
splashRadius: widget.splashRadius ?? kRadialReactionRadius, splashRadius: widget.splashRadius ?? kRadialReactionRadius,
...@@ -434,9 +487,7 @@ class _RenderCheckbox extends RenderToggleable { ...@@ -434,9 +487,7 @@ class _RenderCheckbox extends RenderToggleable {
// value == true or null. // value == true or null.
Color _colorAt(double t) { Color _colorAt(double t) {
// As t goes from 0.0 to 0.25, animate from the inactiveColor to activeColor. // As t goes from 0.0 to 0.25, animate from the inactiveColor to activeColor.
return onChanged == null return t >= 0.25 ? activeColor : Color.lerp(inactiveColor, activeColor, t * 4.0)!;
? inactiveColor
: (t >= 0.25 ? activeColor : Color.lerp(inactiveColor, activeColor, t * 4.0)!);
} }
// White stroke used to paint the check and dash. // White stroke used to paint the check and dash.
......
...@@ -112,6 +112,7 @@ class Radio<T> extends StatefulWidget { ...@@ -112,6 +112,7 @@ class Radio<T> extends StatefulWidget {
this.mouseCursor, this.mouseCursor,
this.toggleable = false, this.toggleable = false,
this.activeColor, this.activeColor,
this.fillColor,
this.focusColor, this.focusColor,
this.hoverColor, this.hoverColor,
this.splashRadius, this.splashRadius,
...@@ -240,8 +241,23 @@ class Radio<T> extends StatefulWidget { ...@@ -240,8 +241,23 @@ class Radio<T> extends StatefulWidget {
/// The color to use when this radio button is selected. /// The color to use when this radio button is selected.
/// ///
/// Defaults to [ThemeData.toggleableActiveColor]. /// Defaults to [ThemeData.toggleableActiveColor].
///
/// If [fillColor] returns a non-null color in the [MaterialState.selected]
/// state, it will be used instead of this color.
final Color? activeColor; final Color? activeColor;
/// The color that fills the checkbox when it is checked, in all
/// [MaterialState]s.
///
/// If this is provided, it will be used over [activeColor].
///
/// Resolves in the following states:
/// * [MaterialState.selected].
/// * [MaterialState.hovered].
/// * [MaterialState.focused].
/// * [MaterialState.disabled].
final MaterialStateProperty<Color?>? fillColor;
/// Configures the minimum size of the tap target. /// Configures the minimum size of the tap target.
/// ///
/// Defaults to [ThemeData.materialTapTargetSize]. /// Defaults to [ThemeData.materialTapTargetSize].
...@@ -318,10 +334,6 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin { ...@@ -318,10 +334,6 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
} }
} }
Color _getInactiveColor(ThemeData themeData) {
return enabled ? themeData.unselectedWidgetColor : themeData.disabledColor;
}
void _handleChanged(bool? selected) { void _handleChanged(bool? selected) {
if (selected == null) { if (selected == null) {
widget.onChanged!(null); widget.onChanged!(null);
...@@ -332,6 +344,40 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin { ...@@ -332,6 +344,40 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
} }
} }
bool get _selected => widget.value == widget.groupValue;
Set<MaterialState> get _states => <MaterialState>{
if (!enabled) MaterialState.disabled,
if (_hovering) MaterialState.hovered,
if (_focused) MaterialState.focused,
if (_selected) MaterialState.selected,
};
MaterialStateProperty<Color?> get _widgetFillColor {
return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.disabled)) {
return null;
}
if (states.contains(MaterialState.selected)) {
return widget.activeColor;
}
return null;
});
}
MaterialStateProperty<Color> get _defaultFillColor {
final ThemeData themeData = Theme.of(context);
return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.disabled)) {
return themeData.disabledColor;
}
if (states.contains(MaterialState.selected)) {
return themeData.toggleableActiveColor;
}
return themeData.unselectedWidgetColor;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(debugCheckHasMaterial(context)); assert(debugCheckHasMaterial(context));
...@@ -347,16 +393,20 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin { ...@@ -347,16 +393,20 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
} }
size += (widget.visualDensity ?? themeData.visualDensity).baseSizeAdjustment; size += (widget.visualDensity ?? themeData.visualDensity).baseSizeAdjustment;
final BoxConstraints additionalConstraints = BoxConstraints.tight(size); final BoxConstraints additionalConstraints = BoxConstraints.tight(size);
final bool selected = widget.value == widget.groupValue;
final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor>( final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor>(
widget.mouseCursor ?? MaterialStateMouseCursor.clickable, widget.mouseCursor ?? MaterialStateMouseCursor.clickable,
<MaterialState>{ _states,
if (!enabled) MaterialState.disabled,
if (_hovering) MaterialState.hovered,
if (_focused) MaterialState.focused,
if (selected) MaterialState.selected,
},
); );
// Colors need to be resolved in selected and non selected states separately
// so that they can be lerped between.
final Set<MaterialState> activeStates = _states..add(MaterialState.selected);
final Set<MaterialState> inactiveStates = _states..remove(MaterialState.selected);
final Color effectiveActiveColor = widget.fillColor?.resolve(activeStates)
?? _widgetFillColor.resolve(activeStates)
?? _defaultFillColor.resolve(activeStates);
final Color effectiveInactiveColor = widget.fillColor?.resolve(inactiveStates)
?? _widgetFillColor.resolve(inactiveStates)
?? _defaultFillColor.resolve(inactiveStates);
return FocusableActionDetector( return FocusableActionDetector(
actions: _actionMap, actions: _actionMap,
...@@ -369,9 +419,9 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin { ...@@ -369,9 +419,9 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
child: Builder( child: Builder(
builder: (BuildContext context) { builder: (BuildContext context) {
return _RadioRenderObjectWidget( return _RadioRenderObjectWidget(
selected: selected, selected: _selected,
activeColor: widget.activeColor ?? themeData.toggleableActiveColor, activeColor: effectiveActiveColor,
inactiveColor: _getInactiveColor(themeData), inactiveColor: effectiveInactiveColor,
focusColor: widget.focusColor ?? themeData.focusColor, focusColor: widget.focusColor ?? themeData.focusColor,
hoverColor: widget.hoverColor ?? themeData.hoverColor, hoverColor: widget.hoverColor ?? themeData.hoverColor,
splashRadius: widget.splashRadius ?? kRadialReactionRadius, splashRadius: widget.splashRadius ?? kRadialReactionRadius,
...@@ -493,11 +543,10 @@ class _RenderRadio extends RenderToggleable { ...@@ -493,11 +543,10 @@ class _RenderRadio extends RenderToggleable {
paintRadialReaction(canvas, offset, size.center(Offset.zero)); paintRadialReaction(canvas, offset, size.center(Offset.zero));
final Offset center = (offset & size).center; final Offset center = (offset & size).center;
final Color radioColor = onChanged != null ? activeColor : inactiveColor;
// Outer circle // Outer circle
final Paint paint = Paint() final Paint paint = Paint()
..color = Color.lerp(inactiveColor, radioColor, position.value)! ..color = Color.lerp(inactiveColor, activeColor, position.value)!
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 2.0; ..strokeWidth = 2.0;
canvas.drawCircle(center, _kOuterRadius, paint); canvas.drawCircle(center, _kOuterRadius, paint);
......
...@@ -794,6 +794,112 @@ void main() { ...@@ -794,6 +794,112 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
}); });
testWidgets('Checkbox fill color resolves in enabled/disabled states', (WidgetTester tester) async {
const Color activeEnabledFillColor = Color(0xFF000001);
const Color activeDisabledFillColor = Color(0xFF000002);
Color getFillColor(Set<MaterialState> states) {
if (states.contains(MaterialState.disabled)) {
return activeDisabledFillColor;
}
return activeEnabledFillColor;
}
final MaterialStateProperty<Color> fillColor =
MaterialStateColor.resolveWith(getFillColor);
Widget buildFrame({required bool enabled}) {
return Material(
child: Theme(
data: ThemeData(),
child: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return Checkbox(
value: true,
fillColor: fillColor,
onChanged: enabled ? (bool? value) { } : null,
);
},
),
),
);
}
RenderToggleable getCheckboxRenderer() {
return tester.renderObject<RenderToggleable>(find.byWidgetPredicate((Widget widget) {
return widget.runtimeType.toString() == '_CheckboxRenderObjectWidget';
}));
}
await tester.pumpWidget(buildFrame(enabled: true));
await tester.pumpAndSettle();
expect(getCheckboxRenderer(), paints..rrect(color: activeEnabledFillColor));
await tester.pumpWidget(buildFrame(enabled: false));
await tester.pumpAndSettle();
expect(getCheckboxRenderer(), paints..rrect(color: activeDisabledFillColor));
});
testWidgets('Checkbox fill color resolves in hovered/focused states', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode(debugLabel: 'checkbox');
tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
const Color hoveredFillColor = Color(0xFF000001);
const Color focusedFillColor = Color(0xFF000002);
Color getFillColor(Set<MaterialState> states) {
if (states.contains(MaterialState.hovered)) {
return hoveredFillColor;
}
if (states.contains(MaterialState.focused)) {
return focusedFillColor;
}
return Colors.transparent;
}
final MaterialStateProperty<Color> fillColor =
MaterialStateColor.resolveWith(getFillColor);
Widget buildFrame() {
return Material(
child: Theme(
data: ThemeData(),
child: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return Checkbox(
focusNode: focusNode,
autofocus: true,
value: true,
fillColor: fillColor,
onChanged: (bool? value) { },
);
},
),
),
);
}
RenderToggleable getCheckboxRenderer() {
return tester.renderObject<RenderToggleable>(find.byWidgetPredicate((Widget widget) {
return widget.runtimeType.toString() == '_CheckboxRenderObjectWidget';
}));
}
await tester.pumpWidget(buildFrame());
await tester.pumpAndSettle();
expect(focusNode.hasPrimaryFocus, isTrue);
expect(getCheckboxRenderer(), paints..rrect(color: focusedFillColor));
// Start hovering
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.byType(Checkbox)));
await tester.pumpAndSettle();
expect(getCheckboxRenderer(), paints..rrect(color: hoveredFillColor));
});
} }
class _SelectedGrabMouseCursor extends MaterialStateMouseCursor { class _SelectedGrabMouseCursor extends MaterialStateMouseCursor {
......
...@@ -747,4 +747,192 @@ void main() { ...@@ -747,4 +747,192 @@ void main() {
expect(RendererBinding.instance!.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic); expect(RendererBinding.instance!.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic);
}); });
testWidgets('Radio button fill color resolves in enabled/disabled states', (WidgetTester tester) async {
const Color activeEnabledFillColor = Color(0xFF000001);
const Color activeDisabledFillColor = Color(0xFF000002);
const Color inactiveEnabledFillColor = Color(0xFF000003);
const Color inactiveDisabledFillColor = Color(0xFF000004);
Color getFillColor(Set<MaterialState> states) {
if (states.contains(MaterialState.disabled)) {
if (states.contains(MaterialState.selected)) {
return activeDisabledFillColor;
}
return inactiveDisabledFillColor;
}
if (states.contains(MaterialState.selected)) {
return activeEnabledFillColor;
}
return inactiveEnabledFillColor;
}
final MaterialStateProperty<Color> fillColor =
MaterialStateColor.resolveWith(getFillColor);
int? groupValue = 0;
const Key radioKey = Key('radio');
Widget buildApp({required bool enabled}) {
return MaterialApp(
home: Material(
child: Center(
child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return Container(
width: 100,
height: 100,
color: Colors.white,
child: Radio<int>(
key: radioKey,
value: 0,
fillColor: fillColor,
onChanged: enabled ? (int? newValue) {
setState(() {
groupValue = newValue;
});
} : null,
groupValue: groupValue,
),
);
}),
),
),
);
}
await tester.pumpWidget(buildApp(enabled: true));
// Selected and enabled.
await tester.pumpAndSettle();
expect(
Material.of(tester.element(find.byKey(radioKey))),
paints
..rect(
color: const Color(0xffffffff),
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0))
..circle(color: activeEnabledFillColor)
..circle(color: activeEnabledFillColor),
);
// Check when the radio isn't selected.
groupValue = 1;
await tester.pumpWidget(buildApp(enabled: true));
await tester.pumpAndSettle();
expect(
Material.of(tester.element(find.byKey(radioKey))),
paints
..rect(
color: const Color(0xffffffff),
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0))
..circle(color: inactiveEnabledFillColor, style: PaintingStyle.stroke, strokeWidth: 2.0)
);
// Check when the radio is selected, but disabled.
groupValue = 0;
await tester.pumpWidget(buildApp(enabled: false));
await tester.pumpAndSettle();
expect(
Material.of(tester.element(find.byKey(radioKey))),
paints
..rect(
color: const Color(0xffffffff),
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0))
..circle(color: activeDisabledFillColor)
..circle(color: activeDisabledFillColor),
);
// Check when the radio is unselected and disabled.
groupValue = 1;
await tester.pumpWidget(buildApp(enabled: false));
await tester.pumpAndSettle();
expect(
Material.of(tester.element(find.byKey(radioKey))),
paints
..rect(
color: const Color(0xffffffff),
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0))
..circle(color: inactiveDisabledFillColor, style: PaintingStyle.stroke, strokeWidth: 2.0),
);
});
testWidgets('Checkbox fill color resolves in hovered/focused states', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode(debugLabel: 'checkbox');
tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
const Color hoveredFillColor = Color(0xFF000001);
const Color focusedFillColor = Color(0xFF000002);
Color getFillColor(Set<MaterialState> states) {
if (states.contains(MaterialState.hovered)) {
return hoveredFillColor;
}
if (states.contains(MaterialState.focused)) {
return focusedFillColor;
}
return Colors.transparent;
}
final MaterialStateProperty<Color> fillColor =
MaterialStateColor.resolveWith(getFillColor);
int? groupValue = 0;
const Key radioKey = Key('radio');
Widget buildApp() {
return MaterialApp(
home: Material(
child: Center(
child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return Container(
width: 100,
height: 100,
color: Colors.white,
child: Radio<int>(
autofocus: true,
focusNode: focusNode,
key: radioKey,
value: 0,
fillColor: fillColor,
onChanged: (int? newValue) {
setState(() {
groupValue = newValue;
});
},
groupValue: groupValue,
),
);
}),
),
),
);
}
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
expect(focusNode.hasPrimaryFocus, isTrue);
expect(
Material.of(tester.element(find.byKey(radioKey))),
paints
..rect(
color: const Color(0xffffffff),
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0))
..circle(color: Colors.black12)
..circle(color: focusedFillColor),
);
// Start hovering
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.byKey(radioKey)));
await tester.pumpAndSettle();
expect(
Material.of(tester.element(find.byKey(radioKey))),
paints
..rect(
color: const Color(0xffffffff),
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0))
..circle(color: Colors.black12)
..circle(color: hoveredFillColor),
);
});
} }
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