From 91dc513a395f51e84b237617a7573336cf61c20b Mon Sep 17 00:00:00 2001 From: Qun Cheng <36861262+QuncCccccc@users.noreply.github.com> Date: Thu, 9 Feb 2023 10:41:09 -0800 Subject: [PATCH] Add missing parameters to `CheckboxListTile` (#120118) * Add missing parameters to CheckboxListTile * Update test message and api doc * Reorder parameters --------- Co-authored-by: Qun Cheng --- .../flutter/lib/src/material/checkbox.dart | 4 + .../lib/src/material/checkbox_list_tile.dart | 132 ++++-- .../material/checkbox_list_tile_test.dart | 435 ++++++++++++++++++ 3 files changed, 541 insertions(+), 30 deletions(-) diff --git a/packages/flutter/lib/src/material/checkbox.dart b/packages/flutter/lib/src/material/checkbox.dart index 04aa6703af..189b3f1909 100644 --- a/packages/flutter/lib/src/material/checkbox.dart +++ b/packages/flutter/lib/src/material/checkbox.dart @@ -254,10 +254,12 @@ class Checkbox extends StatefulWidget { /// [ThemeData.focusColor] is used. final Color? focusColor; + /// {@template flutter.material.checkbox.hoverColor} /// The color for the checkbox'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 [CheckboxThemeData.overlayColor] is used in the /// hovered state. If that is also null, then the value of @@ -332,10 +334,12 @@ class Checkbox extends StatefulWidget { /// will be width 2. final BorderSide? side; + /// {@template flutter.material.checkbox.isError} /// True if this checkbox wants to show an error state. /// /// The checkbox will have different default container color and check color when /// this is true. This is only used when [ThemeData.useMaterial3] is set to true. + /// {@endtemplate} /// /// Must not be null. Defaults to false. final bool isError; diff --git a/packages/flutter/lib/src/material/checkbox_list_tile.dart b/packages/flutter/lib/src/material/checkbox_list_tile.dart index 3711e6f7f4..5e5c131454 100644 --- a/packages/flutter/lib/src/material/checkbox_list_tile.dart +++ b/packages/flutter/lib/src/material/checkbox_list_tile.dart @@ -163,8 +163,20 @@ class CheckboxListTile extends StatelessWidget { super.key, required this.value, required this.onChanged, + this.mouseCursor, this.activeColor, + this.fillColor, this.checkColor, + this.hoverColor, + this.overlayColor, + this.splashRadius, + this.materialTapTargetSize, + this.visualDensity, + this.focusNode, + this.autofocus = false, + this.shape, + this.side, + this.isError = false, this.enabled, this.tileColor, this.title, @@ -174,15 +186,10 @@ class CheckboxListTile extends StatelessWidget { this.secondary, this.selected = false, this.controlAffinity = ListTileControlAffinity.platform, - this.autofocus = false, this.contentPadding, this.tristate = false, - this.shape, this.checkboxShape, this.selectedTileColor, - this.side, - this.visualDensity, - this.focusNode, this.onFocusChange, this.enableFeedback, }) : assert(tristate || value != null), @@ -219,16 +226,98 @@ class CheckboxListTile extends StatelessWidget { /// {@end-tool} 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 [CheckboxThemeData.mouseCursor] is used. If + /// that is also null, then [MaterialStateMouseCursor.clickable] is used. + final MouseCursor? mouseCursor; + /// The color to use when this checkbox is checked. /// /// Defaults to [ColorScheme.secondary] of the current [Theme]. final Color? activeColor; + /// The color that fills the checkbox. + /// + /// 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, the value of [CheckboxThemeData.fillColor] + /// is used. If that is also null, then the default value is used. + final MaterialStateProperty? fillColor; + /// The color to use for the check icon when this checkbox is checked. /// /// Defaults to Color(0xFFFFFFFF). final Color? checkColor; + /// {@macro flutter.material.checkbox.hoverColor} + final Color? hoverColor; + + /// The color for the checkbox'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 [CheckboxThemeData.overlayColor] is used. If that is also null, + /// then the the default value is used in the pressed and hovered state. + final MaterialStateProperty? overlayColor; + + /// {@macro flutter.material.checkbox.splashRadius} + /// + /// If null, then the value of [CheckboxThemeData.splashRadius] is used. If + /// that is also null, then [kRadialReactionRadius] is used. + final double? splashRadius; + + /// {@macro flutter.material.checkbox.materialTapTargetSize} + /// + /// Defaults to [MaterialTapTargetSize.shrinkWrap]. + final MaterialTapTargetSize? materialTapTargetSize; + + /// Defines how compact the list tile's layout will be. + /// + /// {@macro flutter.material.themedata.visualDensity} + final VisualDensity? visualDensity; + + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// {@macro flutter.material.ListTile.shape} + final ShapeBorder? shape; + + /// {@macro flutter.material.checkbox.side} + /// + /// The given value is passed directly to [Checkbox.side]. + /// + /// If this property is null, then [CheckboxThemeData.side] of + /// [ThemeData.checkboxTheme] is used. If that is also null, then the side + /// will be width 2. + final BorderSide? side; + + /// {@macro flutter.material.checkbox.isError} + /// + /// Defaults to false. + final bool isError; + /// {@macro flutter.material.ListTile.tileColor} final Color? tileColor; @@ -270,9 +359,6 @@ class CheckboxListTile extends StatelessWidget { /// Where to place the control relative to the text. final ListTileControlAffinity controlAffinity; - /// {@macro flutter.widgets.Focus.autofocus} - final bool autofocus; - /// Defines insets surrounding the tile's contents. /// /// This value will surround the [Checkbox], [title], [subtitle], and [secondary] @@ -293,9 +379,6 @@ class CheckboxListTile extends StatelessWidget { /// If tristate is false (the default), [value] must not be null. final bool tristate; - /// {@macro flutter.material.ListTile.shape} - final ShapeBorder? shape; - /// {@macro flutter.material.checkbox.shape} /// /// If this property is null then [CheckboxThemeData.shape] of [ThemeData.checkboxTheme] @@ -306,23 +389,6 @@ class CheckboxListTile extends StatelessWidget { /// If non-null, defines the background color when [CheckboxListTile.selected] is true. final Color? selectedTileColor; - /// {@macro flutter.material.checkbox.side} - /// - /// The given value is passed directly to [Checkbox.side]. - /// - /// If this property is null, then [CheckboxThemeData.side] of - /// [ThemeData.checkboxTheme] is used. If that is also null, then the side - /// will be width 2. - final BorderSide? side; - - /// Defines how compact the list tile's layout will be. - /// - /// {@macro flutter.material.themedata.visualDensity} - final VisualDensity? visualDensity; - - /// {@macro flutter.widgets.Focus.focusNode} - final FocusNode? focusNode; - /// {@macro flutter.material.inkwell.onFocusChange} final ValueChanged? onFocusChange; @@ -359,14 +425,20 @@ class CheckboxListTile extends StatelessWidget { Widget build(BuildContext context) { final Widget control = Checkbox( value: value, - onChanged: enabled ?? true ? onChanged : null , + onChanged: enabled ?? true ? onChanged : null, + mouseCursor: mouseCursor, activeColor: activeColor, + fillColor: fillColor, checkColor: checkColor, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + hoverColor: hoverColor, + overlayColor: overlayColor, + splashRadius: splashRadius, + materialTapTargetSize: materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap, autofocus: autofocus, tristate: tristate, shape: checkboxShape, side: side, + isError: isError, ); Widget? leading, trailing; switch (controlAffinity) { diff --git a/packages/flutter/test/material/checkbox_list_tile_test.dart b/packages/flutter/test/material/checkbox_list_tile_test.dart index 8325d8ec83..8685da76df 100644 --- a/packages/flutter/test/material/checkbox_list_tile_test.dart +++ b/packages/flutter/test/material/checkbox_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_test/flutter_test.dart'; @@ -501,6 +502,425 @@ void main() { expect(tester.widget(checkbox).value, true); }); + testWidgets('CheckboxListTile respects mouseCursor when hovered', (WidgetTester tester) async { + // Test Checkbox() constructor + await tester.pumpWidget( + wrap( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: CheckboxListTile( + mouseCursor: SystemMouseCursors.text, + value: true, + onChanged: (_) {}, + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1); + await gesture.addPointer(location: tester.getCenter(find.byType(Checkbox))); + + await tester.pump(); + + await gesture.moveTo(tester.getCenter(find.byType(Checkbox))); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.text); + + // Test default cursor + await tester.pumpWidget( + wrap( + child: CheckboxListTile( + value: true, + onChanged: (_) {}, + ), + ), + ); + + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.click); + + // Test default cursor when disabled + await tester.pumpWidget( + wrap( + child: const MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: CheckboxListTile( + value: true, + onChanged: null, + ), + ), + ), + ); + + await gesture.moveTo(tester.getCenter(find.byType(Checkbox))); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic); + + // Test cursor when tristate + await tester.pumpWidget( + wrap( + child: const MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: CheckboxListTile( + value: null, + tristate: true, + onChanged: null, + mouseCursor: _SelectedGrabMouseCursor(), + ), + ), + ), + ); + + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.grab); + + await tester.pumpAndSettle(); + }); + + testWidgets('CheckboxListTile respects fillColor in enabled/disabled states', (WidgetTester tester) async { + const Color activeEnabledFillColor = Color(0xFF000001); + const Color activeDisabledFillColor = Color(0xFF000002); + + Color getFillColor(Set states) { + if (states.contains(MaterialState.disabled)) { + return activeDisabledFillColor; + } + return activeEnabledFillColor; + } + + final MaterialStateProperty fillColor = MaterialStateColor.resolveWith(getFillColor); + + Widget buildFrame({required bool enabled}) { + return wrap( + child: CheckboxListTile( + value: true, + fillColor: fillColor, + onChanged: enabled ? (bool? value) { } : null, + ) + ); + } + + RenderBox getCheckboxRenderer() { + return tester.renderObject(find.byType(Checkbox)); + } + + await tester.pumpWidget(buildFrame(enabled: true)); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..path(color: activeEnabledFillColor)); + + await tester.pumpWidget(buildFrame(enabled: false)); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..path(color: activeDisabledFillColor)); + }); + + testWidgets('CheckboxListTile 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); + + Widget buildFrame() { + return wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CheckboxListTile( + value: true, + fillColor: fillColor, + onChanged: (bool? value) { }, + ); + }, + ), + ); + } + + RenderBox getCheckboxRenderer() { + return tester.renderObject(find.byType(Checkbox)); + } + + await tester.pumpWidget(buildFrame()); + await tester.pumpAndSettle(); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect(getCheckboxRenderer(), paints..path(color: hoveredFillColor)); + }); + + testWidgets('CheckboxListTile respects hoverColor', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + bool? value = true; + Widget buildApp({bool enabled = true}) { + return wrap( + child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) { + return CheckboxListTile( + value: value, + onChanged: enabled ? (bool? newValue) { + setState(() { + value = newValue; + }); + } : null, + hoverColor: Colors.orange[500], + ); + }), + ); + } + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..path(style: PaintingStyle.fill) + ..path(style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(tester.getCenter(find.byType(Checkbox))); + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..circle(color: Colors.orange[500]) + ..path(style: PaintingStyle.fill) + ..path(style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + + // Check what happens when disabled. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..path(style: PaintingStyle.fill) + ..path(style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + }); + + testWidgets('CheckboxListTile 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; + } + const double splashRadius = 24.0; + + Widget buildCheckbox({bool active = false, bool useOverlay = true}) { + return wrap( + child: CheckboxListTile( + value: active, + onChanged: (_) { }, + fillColor: const MaterialStatePropertyAll(fillColor), + overlayColor: useOverlay ? MaterialStateProperty.resolveWith(getOverlayColor) : null, + hoverColor: hoverColor, + splashRadius: splashRadius, + ), + ); + } + + await tester.pumpWidget(buildCheckbox(useOverlay: false)); + await tester.startGesture(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle() + ..circle( + color: fillColor.withAlpha(kRadialReactionAlpha), + radius: splashRadius, + ), + reason: 'Default inactive pressed Checkbox should have overlay color from fillColor', + ); + + await tester.pumpWidget(buildCheckbox(active: true, useOverlay: false)); + await tester.startGesture(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle() + ..circle( + color: fillColor.withAlpha(kRadialReactionAlpha), + radius: splashRadius, + ), + reason: 'Default active pressed Checkbox should have overlay color from fillColor', + ); + + await tester.pumpWidget(buildCheckbox()); + await tester.startGesture(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle() + ..circle( + color: inactivePressedOverlayColor, + radius: splashRadius, + ), + reason: 'Inactive pressed Checkbox should have overlay color: $inactivePressedOverlayColor', + ); + + await tester.pumpWidget(buildCheckbox(active: true)); + await tester.startGesture(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle() + ..circle( + color: activePressedOverlayColor, + radius: splashRadius, + ), + reason: 'Active pressed Checkbox should have overlay color: $activePressedOverlayColor', + ); + + // Start hovering + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildCheckbox()); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..circle( + color: hoverOverlayColor, + radius: splashRadius, + ), + reason: 'Hovered Checkbox should use overlay color $hoverOverlayColor over $hoverColor', + ); + }); + + testWidgets('CheckboxListTile 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 CheckboxListTile( + value: false, + onChanged: (bool? newValue) {}, + hoverColor: Colors.orange[500], + 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(Checkbox))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: Colors.orange[500], radius: splashRadius), + ); + }); + + testWidgets('CheckboxListTile respects materialTapTargetSize', (WidgetTester tester) async { + await tester.pumpWidget( + wrap( + child: CheckboxListTile( + value: true, + onChanged: (bool? newValue) { }, + ), + ), + ); + + // default test + expect(tester.getSize(find.byType(Checkbox)), const Size(40.0, 40.0)); + + await tester.pumpWidget( + wrap( + child: CheckboxListTile( + materialTapTargetSize: MaterialTapTargetSize.padded, + value: true, + onChanged: (bool? newValue) { }, + ), + ), + ); + + expect(tester.getSize(find.byType(Checkbox)), const Size(48.0, 48.0)); + }); + + testWidgets('CheckboxListTile respects isError - M3', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(useMaterial3: true); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + bool? value = true; + Widget buildApp() { + return MaterialApp( + theme: themeData, + home: Material( + child: Center( + child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) { + return CheckboxListTile( + isError: true, + value: value, + onChanged: (bool? newValue) { + setState(() { + value = newValue; + }); + }, + ); + }), + ), + ), + ); + } + + // Default color + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..path(color: themeData.colorScheme.error)..path(color: themeData.colorScheme.onError) + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..circle(color: themeData.colorScheme.error.withOpacity(0.08)) + ..path(color: themeData.colorScheme.error) + ); + }); + group('feedback', () { late FeedbackTester feedback; @@ -541,3 +961,18 @@ void main() { }); }); } + +class _SelectedGrabMouseCursor extends MaterialStateMouseCursor { + const _SelectedGrabMouseCursor(); + + @override + MouseCursor resolve(Set states) { + if (states.contains(MaterialState.selected)) { + return SystemMouseCursors.grab; + } + return SystemMouseCursors.basic; + } + + @override + String get debugDescription => '_SelectedGrabMouseCursor()'; +}