From a395c952e9ce5e54da19f812f491fc3d3ae2390c Mon Sep 17 00:00:00 2001 From: Rami <2364772+rami-a@users.noreply.github.com> Date: Fri, 19 Nov 2021 18:41:33 -0500 Subject: [PATCH] Support for MaterialTapTargetSize within ToggleButtons (#93259) --- .../lib/src/material/toggle_buttons.dart | 162 +++++++++++++++++- .../test/material/toggle_buttons_test.dart | 90 ++++++++++ 2 files changed, 251 insertions(+), 1 deletion(-) diff --git a/packages/flutter/lib/src/material/toggle_buttons.dart b/packages/flutter/lib/src/material/toggle_buttons.dart index 9234050434e..f4c23dcf632 100644 --- a/packages/flutter/lib/src/material/toggle_buttons.dart +++ b/packages/flutter/lib/src/material/toggle_buttons.dart @@ -172,6 +172,7 @@ class ToggleButtons extends StatelessWidget { required this.isSelected, this.onPressed, this.mouseCursor, + this.tapTargetSize, this.textStyle, this.constraints, this.color, @@ -231,6 +232,15 @@ class ToggleButtons extends StatelessWidget { /// {@macro flutter.material.RawMaterialButton.mouseCursor} final MouseCursor? mouseCursor; + /// Configures the minimum size of the area within which the buttons may + /// be pressed. + /// + /// If the [tapTargetSize] is larger than [constraints], the buttons will + /// include a transparent margin that responds to taps. + /// + /// Defaults to [ThemeData.materialTapTargetSize]. + final MaterialTapTargetSize? tapTargetSize; + /// The [TextStyle] to apply to any text in these toggle buttons. /// /// [TextStyle.color] will be ignored and substituted by [color], @@ -686,7 +696,7 @@ class ToggleButtons extends StatelessWidget { ); }); - return direction == Axis.horizontal + final Widget result = direction == Axis.horizontal ? IntrinsicHeight( child: Row( mainAxisSize: MainAxisSize.min, @@ -702,6 +712,18 @@ class ToggleButtons extends StatelessWidget { children: buttons, ), ); + + final MaterialTapTargetSize resolvedTapTargetSize = tapTargetSize ?? theme.materialTapTargetSize; + switch (resolvedTapTargetSize) { + case MaterialTapTargetSize.padded: + return _InputPadding( + minSize: const Size(kMinInteractiveDimension, kMinInteractiveDimension), + direction: direction, + child: result, + ); + case MaterialTapTargetSize.shrinkWrap: + return result; + } } @override @@ -1550,3 +1572,141 @@ class _SelectToggleButtonRenderObject extends RenderShiftedBox { } } } + +/// A widget to pad the area around a [ToggleButtons]'s children. +/// +/// This widget is based on a similar one used in [ButtonStyleButton] but it +/// only redirects taps along one axis to ensure the correct button is tapped +/// within the [ToggleButtons]. +/// +/// This ensures that a widget takes up at least as much space as the minSize +/// parameter to ensure adequate tap target size, while keeping the widget +/// visually smaller to the user. +class _InputPadding extends SingleChildRenderObjectWidget { + const _InputPadding({ + Key? key, + Widget? child, + required this.minSize, + required this.direction, + }) : super(key: key, child: child); + + final Size minSize; + final Axis direction; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderInputPadding(minSize, direction); + } + + @override + void updateRenderObject(BuildContext context, covariant _RenderInputPadding renderObject) { + renderObject.minSize = minSize; + renderObject.direction = direction; + } +} + +class _RenderInputPadding extends RenderShiftedBox { + _RenderInputPadding(this._minSize, this._direction, [RenderBox? child]) : super(child); + + Size get minSize => _minSize; + Size _minSize; + set minSize(Size value) { + if (_minSize == value) + return; + _minSize = value; + markNeedsLayout(); + } + + Axis get direction => _direction; + Axis _direction; + set direction(Axis value) { + if (_direction == value) + return; + _direction = value; + markNeedsLayout(); + } + + @override + double computeMinIntrinsicWidth(double height) { + if (child != null) + return math.max(child!.getMinIntrinsicWidth(height), minSize.width); + return 0.0; + } + + @override + double computeMinIntrinsicHeight(double width) { + if (child != null) + return math.max(child!.getMinIntrinsicHeight(width), minSize.height); + return 0.0; + } + + @override + double computeMaxIntrinsicWidth(double height) { + if (child != null) + return math.max(child!.getMaxIntrinsicWidth(height), minSize.width); + return 0.0; + } + + @override + double computeMaxIntrinsicHeight(double width) { + if (child != null) + return math.max(child!.getMaxIntrinsicHeight(width), minSize.height); + return 0.0; + } + + Size _computeSize({required BoxConstraints constraints, required ChildLayouter layoutChild}) { + if (child != null) { + final Size childSize = layoutChild(child!, constraints); + final double height = math.max(childSize.width, minSize.width); + final double width = math.max(childSize.height, minSize.height); + return constraints.constrain(Size(height, width)); + } + return Size.zero; + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + return _computeSize( + constraints: constraints, + layoutChild: ChildLayoutHelper.dryLayoutChild, + ); + } + + @override + void performLayout() { + size = _computeSize( + constraints: constraints, + layoutChild: ChildLayoutHelper.layoutChild, + ); + if (child != null) { + final BoxParentData childParentData = child!.parentData! as BoxParentData; + childParentData.offset = Alignment.center.alongOffset(size - child!.size as Offset); + } + } + + @override + bool hitTest(BoxHitTestResult result, { required Offset position }) { + // The super.hitTest() method also checks hitTestChildren(). We don't + // want that in this case because we've padded around the children per + // tapTargetSize. + if (!size.contains(position)) { + return false; + } + + // Only adjust one axis to ensure the correct button is tapped. + Offset center; + if (direction == Axis.horizontal) { + center = Offset(position.dx, child!.size.height / 2); + } else { + center = Offset(child!.size.width / 2, position.dy); + } + return result.addWithRawTransform( + transform: MatrixUtils.forceToPoint(center), + position: center, + hitTest: (BoxHitTestResult result, Offset position) { + assert(position == center); + return child!.hitTest(result, position: center); + }, + ); + } +} diff --git a/packages/flutter/test/material/toggle_buttons_test.dart b/packages/flutter/test/material/toggle_buttons_test.dart index 3e01b470833..a414e316655 100644 --- a/packages/flutter/test/material/toggle_buttons_test.dart +++ b/packages/flutter/test/material/toggle_buttons_test.dart @@ -1591,6 +1591,96 @@ void main() { }, ); + testWidgets('Tap target size is configurable by ThemeData.materialTapTargetSize', (WidgetTester tester) async { + Widget buildFrame(MaterialTapTargetSize tapTargetSize, Key key) { + return Theme( + data: ThemeData(materialTapTargetSize: tapTargetSize), + child: Material( + child: boilerplate( + child: ToggleButtons( + key: key, + constraints: const BoxConstraints(minWidth: 32.0, minHeight: 32.0), + isSelected: const [false, true, false], + onPressed: (int index) {}, + children: const [ + Text('First'), + Text('Second'), + Text('Third'), + ], + ), + ), + ), + ); + } + + final Key key1 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.padded, key1)); + expect(tester.getSize(find.byKey(key1)), const Size(228.0, 48.0)); + + final Key key2 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.shrinkWrap, key2)); + expect(tester.getSize(find.byKey(key2)), const Size(228.0, 34.0)); + }); + + testWidgets('Tap target size is configurable', (WidgetTester tester) async { + Widget buildFrame(MaterialTapTargetSize tapTargetSize, Key key) { + return Material( + child: boilerplate( + child: ToggleButtons( + key: key, + tapTargetSize: tapTargetSize, + constraints: const BoxConstraints(minWidth: 32.0, minHeight: 32.0), + isSelected: const [false, true, false], + onPressed: (int index) {}, + children: const [ + Text('First'), + Text('Second'), + Text('Third'), + ], + ), + ), + ); + } + + final Key key1 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.padded, key1)); + expect(tester.getSize(find.byKey(key1)), const Size(228.0, 48.0)); + + final Key key2 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.shrinkWrap, key2)); + expect(tester.getSize(find.byKey(key2)), const Size(228.0, 34.0)); + }); + + testWidgets('Tap target size is configurable for vertical axis', (WidgetTester tester) async { + Widget buildFrame(MaterialTapTargetSize tapTargetSize, Key key) { + return Material( + child: boilerplate( + child: ToggleButtons( + key: key, + tapTargetSize: tapTargetSize, + constraints: const BoxConstraints(minWidth: 32.0, minHeight: 32.0), + direction: Axis.vertical, + isSelected: const [false, true, false], + onPressed: (int index) {}, + children: const [ + Text('1'), + Text('2'), + Text('3'), + ], + ), + ), + ); + } + + final Key key1 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.padded, key1)); + expect(tester.getSize(find.byKey(key1)), const Size(48.0, 100.0)); + + final Key key2 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.shrinkWrap, key2)); + expect(tester.getSize(find.byKey(key2)), const Size(34.0, 100.0)); + }); + // Regression test for https://github.com/flutter/flutter/issues/73725 testWidgets('Border radius paint test when there is only one button', (WidgetTester tester) async { final ThemeData theme = ThemeData();