diff --git a/packages/flutter/lib/src/material/radio.dart b/packages/flutter/lib/src/material/radio.dart index 6cb2e36048..8fdd196b7e 100644 --- a/packages/flutter/lib/src/material/radio.dart +++ b/packages/flutter/lib/src/material/radio.dart @@ -264,10 +264,12 @@ class Radio extends StatefulWidget { /// [ThemeData.focusColor] is used. final Color? focusColor; + /// {@template flutter.material.radio.hoverColor} /// The color for the radio's [Material] when a pointer is hovering over it. /// /// If [overlayColor] returns a non-null color in the [MaterialState.hovered] /// state, it will be used instead. + /// {@endtemplate} /// /// If null, then the value of [RadioThemeData.overlayColor] is used in the /// hovered state. If that is also null, then the value of @@ -275,7 +277,7 @@ class Radio extends StatefulWidget { final Color? hoverColor; /// {@template flutter.material.radio.overlayColor} - /// The color for the checkbox's [Material]. + /// The color for the radio's [Material]. /// /// Resolves in the following states: /// * [MaterialState.pressed]. diff --git a/packages/flutter/lib/src/material/radio_list_tile.dart b/packages/flutter/lib/src/material/radio_list_tile.dart index 756e9836b1..1e0433c77a 100644 --- a/packages/flutter/lib/src/material/radio_list_tile.dart +++ b/packages/flutter/lib/src/material/radio_list_tile.dart @@ -162,8 +162,14 @@ class RadioListTile extends StatelessWidget { required this.value, required this.groupValue, required this.onChanged, + this.mouseCursor, this.toggleable = false, this.activeColor, + this.fillColor, + this.hoverColor, + this.overlayColor, + this.splashRadius, + this.materialTapTargetSize, this.title, this.subtitle, this.isThreeLine = false, @@ -220,6 +226,20 @@ class RadioListTile extends StatelessWidget { /// ``` final ValueChanged? onChanged; + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If [mouseCursor] is a [MaterialStateProperty], + /// [MaterialStateProperty.resolve] is used for the following [MaterialState]s: + /// + /// * [MaterialState.selected]. + /// * [MaterialState.hovered]. + /// * [MaterialState.disabled]. + /// + /// If null, then the value of [RadioThemeData.mouseCursor] is used. + /// If that is also null, then [MaterialStateMouseCursor.clickable] is used. + final MouseCursor? mouseCursor; + /// Set to true if this radio list tile is allowed to be returned to an /// indeterminate state by selecting it again when selected. /// @@ -250,6 +270,45 @@ class RadioListTile extends StatelessWidget { /// Defaults to [ColorScheme.secondary] of the current [Theme]. final Color? activeColor; + /// The color that fills the radio button. + /// + /// Resolves in the following states: + /// * [MaterialState.selected]. + /// * [MaterialState.hovered]. + /// * [MaterialState.disabled]. + /// + /// If null, then the value of [activeColor] is used in the selected state. If + /// that is also null, then the value of [RadioThemeData.fillColor] is used. + /// If that is also null, then the default value is used. + final MaterialStateProperty? fillColor; + + /// {@macro flutter.material.radio.materialTapTargetSize} + /// + /// Defaults to [MaterialTapTargetSize.shrinkWrap]. + final MaterialTapTargetSize? materialTapTargetSize; + + /// {@macro flutter.material.radio.hoverColor} + final Color? hoverColor; + + /// The color for the radio's [Material]. + /// + /// Resolves in the following states: + /// * [MaterialState.pressed]. + /// * [MaterialState.selected]. + /// * [MaterialState.hovered]. + /// + /// If null, then the value of [activeColor] with alpha [kRadialReactionAlpha] + /// and [hoverColor] is used in the pressed and hovered state. If that is also + /// null, the value of [SwitchThemeData.overlayColor] is used. If that is + /// also null, then the default value is used in the pressed and hovered state. + final MaterialStateProperty? overlayColor; + + /// {@macro flutter.material.radio.splashRadius} + /// + /// If null, then the value of [RadioThemeData.splashRadius] is used. If that + /// is also null, then [kRadialReactionRadius] is used. + final double? splashRadius; + /// The primary content of the list tile. /// /// Typically a [Text] widget. @@ -341,8 +400,13 @@ class RadioListTile extends StatelessWidget { onChanged: onChanged, toggleable: toggleable, activeColor: activeColor, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + materialTapTargetSize: materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap, autofocus: autofocus, + fillColor: fillColor, + mouseCursor: mouseCursor, + hoverColor: hoverColor, + overlayColor: overlayColor, + splashRadius: splashRadius, ); Widget? leading, trailing; switch (controlAffinity) { diff --git a/packages/flutter/test/material/radio_list_tile_test.dart b/packages/flutter/test/material/radio_list_tile_test.dart index de5664ddc0..f9470626ef 100644 --- a/packages/flutter/test/material/radio_list_tile_test.dart +++ b/packages/flutter/test/material/radio_list_tile_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; @@ -823,6 +824,423 @@ void main() { expect(node.hasFocus, isFalse); }); + testWidgets('Radio changes mouse cursor when hovered', (WidgetTester tester) async { + // Test Radio() constructor + await tester.pumpWidget( + wrap(child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: RadioListTile( + mouseCursor: SystemMouseCursors.text, + value: 1, + onChanged: (int? v) {}, + groupValue: 2, + ), + )), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1); + await gesture.addPointer(location: tester.getCenter(find.byType(Radio))); + + await tester.pump(); + + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.text); + + + // Test default cursor + await tester.pumpWidget( + wrap(child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: RadioListTile( + value: 1, + onChanged: (int? v) {}, + groupValue: 2, + ), + )), + ); + + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.click); + + // Test default cursor when disabled + await tester.pumpWidget(wrap( + child: const MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: RadioListTile( + value: 1, + onChanged: null, + groupValue: 2, + ), + ), + ),); + + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic); + }); + + testWidgets('RadioListTile respects fillColor 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 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 fillColor = + MaterialStateColor.resolveWith(getFillColor); + + int? groupValue = 0; + Widget buildApp({required bool enabled}) { + return wrap( + child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) { + return RadioListTile( + 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.byType(Radio))), + paints + ..rect() + ..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.byType(Radio))), + paints + ..rect() + ..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.byType(Radio))), + paints + ..rect() + ..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.byType(Radio))), + paints + ..rect() + ..circle(color: inactiveDisabledFillColor, style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + }); + + testWidgets('RadioListTile respects fillColor in hovered state', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const Color hoveredFillColor = Color(0xFF000001); + + Color getFillColor(Set states) { + if (states.contains(MaterialState.hovered)) { + return hoveredFillColor; + } + return Colors.transparent; + } + + final MaterialStateProperty fillColor = + MaterialStateColor.resolveWith(getFillColor); + + int? groupValue = 0; + Widget buildApp() { + return wrap( + child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) { + return RadioListTile( + value: 0, + fillColor: fillColor, + onChanged: (int? newValue) { + setState(() { + groupValue = newValue; + }); + }, + groupValue: groupValue, + ); + }), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Radio))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio))), + paints + ..rect()..circle() + ..circle(color: hoveredFillColor), + ); + }); + + testWidgets('RadioListTile respects hoverColor', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + int? groupValue = 0; + final Color? hoverColor = Colors.orange[500]; + Widget buildApp({bool enabled = true}) { + return wrap( + child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) { + return RadioListTile( + value: 0, + onChanged: enabled ? (int? newValue) { + setState(() { + groupValue = newValue; + }); + } : null, + hoverColor: hoverColor, + groupValue: groupValue, + ); + }), + ); + } + await tester.pumpWidget(buildApp()); + + await tester.pump(); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Radio))), + paints + ..rect() + ..circle(color: const Color(0xff2196f3)) + ..circle(color: const Color(0xff2196f3)), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(tester.getCenter(find.byType(Radio))); + + // Check when the radio isn't selected. + groupValue = 1; + await tester.pumpWidget(buildApp()); + await tester.pump(); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Radio))), + paints + ..rect() + ..circle(color: hoverColor) + ); + + // Check when the radio is selected, but disabled. + groupValue = 0; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pump(); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Radio))), + paints + ..rect() + ..circle(color: const Color(0x61000000)) + ..circle(color: const Color(0x61000000)), + ); + }); + + testWidgets('RadioListTile respects overlayColor in active/pressed/hovered states', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + const Color fillColor = Color(0xFF000000); + const Color activePressedOverlayColor = Color(0xFF000001); + const Color inactivePressedOverlayColor = Color(0xFF000002); + const Color hoverOverlayColor = Color(0xFF000003); + const Color hoverColor = Color(0xFF000005); + + Color? getOverlayColor(Set states) { + if (states.contains(MaterialState.pressed)) { + if (states.contains(MaterialState.selected)) { + return activePressedOverlayColor; + } + return inactivePressedOverlayColor; + } + if (states.contains(MaterialState.hovered)) { + return hoverOverlayColor; + } + return null; + } + + Widget buildRadio({bool active = false, bool useOverlay = true}) { + return wrap( + child: RadioListTile( + value: active, + groupValue: true, + onChanged: (_) { }, + fillColor: const MaterialStatePropertyAll(fillColor), + overlayColor: useOverlay ? MaterialStateProperty.resolveWith(getOverlayColor) : null, + hoverColor: hoverColor, + ), + ); + } + + await tester.pumpWidget(buildRadio(useOverlay: false)); + await tester.press(find.byType(Radio)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio))), + paints..circle() + ..circle( + color: fillColor.withAlpha(kRadialReactionAlpha), + radius: 20, + ), + reason: 'Default inactive pressed Radio should have overlay color from fillColor', + ); + + await tester.pumpWidget(buildRadio(active: true, useOverlay: false)); + await tester.press(find.byType(Radio)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio))), + paints..circle() + ..circle( + color: fillColor.withAlpha(kRadialReactionAlpha), + radius: 20, + ), + reason: 'Default active pressed Radio should have overlay color from fillColor', + ); + + await tester.pumpWidget(buildRadio()); + await tester.press(find.byType(Radio)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio))), + paints..circle() + ..circle( + color: inactivePressedOverlayColor, + radius: 20, + ), + reason: 'Inactive pressed Radio should have overlay color: $inactivePressedOverlayColor', + ); + + await tester.pumpWidget(buildRadio(active: true)); + await tester.press(find.byType(Radio)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio))), + paints..circle() + ..circle( + color: activePressedOverlayColor, + radius: 20, + ), + reason: 'Active pressed Radio should have overlay color: $activePressedOverlayColor', + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Radio))); + await tester.pumpAndSettle(); + + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildRadio()); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio))), + paints + ..circle( + color: hoverOverlayColor, + radius: 20, + ), + reason: 'Hovered Radio should use overlay color $hoverOverlayColor over $hoverColor', + ); + }); + + testWidgets('RadioListTile respects splashRadius', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const double splashRadius = 30; + Widget buildApp() { + return wrap( + child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) { + return RadioListTile( + value: 0, + onChanged: (_) {}, + hoverColor: Colors.orange[500], + groupValue: 0, + splashRadius: splashRadius, + ); + }), + ); + } + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Radio))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element( + find.byWidgetPredicate((Widget widget) => widget is Radio), + )), + paints..circle(color: Colors.orange[500], radius: splashRadius), + ); + }); + + testWidgets('Radio respects materialTapTargetSize', (WidgetTester tester) async { + await tester.pumpWidget( + wrap(child: RadioListTile( + groupValue: true, + value: true, + onChanged: (bool? newValue) { }, + )), + ); + + // default test + expect(tester.getSize(find.byType(Radio)), const Size(40.0, 40.0)); + + await tester.pumpWidget( + wrap(child: RadioListTile( + materialTapTargetSize: MaterialTapTargetSize.padded, + groupValue: true, + value: true, + onChanged: (bool? newValue) { }, + )), + ); + + expect(tester.getSize(find.byType(Radio)), const Size(48.0, 48.0)); + }); + group('feedback', () { late FeedbackTester feedback;