From 83ac76050d094ca93518788ffdb02762c05ac468 Mon Sep 17 00:00:00 2001 From: Furkan Acar <65075121+AcarFurkan@users.noreply.github.com> Date: Thu, 4 Jan 2024 00:26:02 +0300 Subject: [PATCH] Add `SegmentedButton.styleFrom` (#137542) fixes https://github.com/flutter/flutter/issues/138289 --- SegmentedButtom.styleFrom has been added to the segment button, so there is no longer any need to the button style from the beginning. It works like ElevatedButton.styleFrom only I added selectedForegroundColor, selectedBackgroundColor. In this way, the user will be able to change the color first without checking the MaterialState states. I added tests of the same controls. #129215 I opened this problem myself, but I was rejected because I handled too many items in a PR. For now, I wrote a structure that only handles MaterialStates instead of users. old (still avaliable) image new (just an option for developer) image ### Code sample
expand to view the code sample ```dart import 'package:flutter/material.dart'; /// Flutter code sample for [SegmentedButton]. void main() { runApp(const SegmentedButtonApp()); } enum Calendar { day, week, month, year } class SegmentedButtonApp extends StatefulWidget { const SegmentedButtonApp({super.key}); @override State createState() => _SegmentedButtonAppState(); } class _SegmentedButtonAppState extends State { Calendar calendarView = Calendar.day; @override Widget build(BuildContext context) { return MaterialApp( theme: ThemeData(useMaterial3: true), home: Scaffold( body: Center( child: SegmentedButton( style: SegmentedButton.styleFrom( foregroundColor: Colors.amber, visualDensity: VisualDensity.comfortable, ), // style: const ButtonStyle( // foregroundColor: MaterialStatePropertyAll(Colors.deepPurple), // visualDensity: VisualDensity.comfortable, // ), segments: const >[ ButtonSegment( value: Calendar.day, label: Text('Day'), icon: Icon(Icons.calendar_view_day)), ButtonSegment( value: Calendar.week, label: Text('Week'), icon: Icon(Icons.calendar_view_week)), ButtonSegment( value: Calendar.month, label: Text('Month'), icon: Icon(Icons.calendar_view_month)), ButtonSegment( value: Calendar.year, label: Text('Year'), icon: Icon(Icons.calendar_today)), ], selected: {calendarView}, onSelectionChanged: (Set newSelection) { setState(() { calendarView = newSelection.first; }); }, ), ), ), ); } } ```
--- .../lib/segmented_button_template.dart | 27 +++ .../segmented_button/segmented_button.1.dart | 66 +++++++ .../segmented_button.1_test.dart | 36 ++++ .../lib/src/material/segmented_button.dart | 174 ++++++++++++++++++ .../test/material/segmented_button_test.dart | 114 ++++++++++++ 5 files changed, 417 insertions(+) create mode 100644 examples/api/lib/material/segmented_button/segmented_button.1.dart create mode 100644 examples/api/test/material/segmented_button/segmented_button.1_test.dart diff --git a/dev/tools/gen_defaults/lib/segmented_button_template.dart b/dev/tools/gen_defaults/lib/segmented_button_template.dart index 51f859a67d..8f9e8f6a3d 100644 --- a/dev/tools/gen_defaults/lib/segmented_button_template.dart +++ b/dev/tools/gen_defaults/lib/segmented_button_template.dart @@ -119,6 +119,33 @@ class _${blockName}DefaultsM3 extends SegmentedButtonThemeData { } @override Widget? get selectedIcon => const Icon(Icons.check); + + static MaterialStateProperty resolveStateColor(Color? unselectedColor, Color? selectedColor){ + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + if (states.contains(MaterialState.pressed)) { + return selectedColor?.withOpacity(0.12); + } + if (states.contains(MaterialState.hovered)) { + return selectedColor?.withOpacity(0.08); + } + if (states.contains(MaterialState.focused)) { + return selectedColor?.withOpacity(0.12); + } + } else { + if (states.contains(MaterialState.pressed)) { + return unselectedColor?.withOpacity(0.12); + } + if (states.contains(MaterialState.hovered)) { + return unselectedColor?.withOpacity(0.08); + } + if (states.contains(MaterialState.focused)) { + return unselectedColor?.withOpacity(0.12); + } + } + return Colors.transparent; + }); + } } '''; } diff --git a/examples/api/lib/material/segmented_button/segmented_button.1.dart b/examples/api/lib/material/segmented_button/segmented_button.1.dart new file mode 100644 index 0000000000..c277e55f56 --- /dev/null +++ b/examples/api/lib/material/segmented_button/segmented_button.1.dart @@ -0,0 +1,66 @@ +// 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 'package:flutter/material.dart'; + +/// Flutter code sample for [SegmentedButton.styleFrom]. + +void main() { + runApp(const SegmentedButtonApp()); +} + +class SegmentedButtonApp extends StatelessWidget { + const SegmentedButtonApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold( + body: Center( + child: SegmentedButtonExample(), + ), + ), + ); + } +} + +class SegmentedButtonExample extends StatefulWidget { + const SegmentedButtonExample({super.key}); + + @override + State createState() => _SegmentedButtonExampleState(); +} + +enum Calendar { day, week, month, year } + +class _SegmentedButtonExampleState extends State { + Calendar calendarView = Calendar.week; + + @override + Widget build(BuildContext context) { + return SegmentedButton( + style: SegmentedButton.styleFrom( + backgroundColor: Colors.grey[200], + foregroundColor: Colors.red, + selectedForegroundColor: Colors.white, + selectedBackgroundColor: Colors.green, + ), + segments: const >[ + ButtonSegment(value: Calendar.day, label: Text('Day'), icon: Icon(Icons.calendar_view_day)), + ButtonSegment(value: Calendar.week, label: Text('Week'), icon: Icon(Icons.calendar_view_week)), + ButtonSegment(value: Calendar.month, label: Text('Month'), icon: Icon(Icons.calendar_view_month)), + ButtonSegment(value: Calendar.year, label: Text('Year'), icon: Icon(Icons.calendar_today)), + ], + selected: {calendarView}, + onSelectionChanged: (Set newSelection) { + setState(() { + // By default there is only a single segment that can be + // selected at one time, so its value is always the first + // item in the selected set. + calendarView = newSelection.first; + }); + }, + ); + } +} diff --git a/examples/api/test/material/segmented_button/segmented_button.1_test.dart b/examples/api/test/material/segmented_button/segmented_button.1_test.dart new file mode 100644 index 0000000000..885bf0e722 --- /dev/null +++ b/examples/api/test/material/segmented_button/segmented_button.1_test.dart @@ -0,0 +1,36 @@ +// 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 'package:flutter/material.dart'; +import 'package:flutter_api_samples/material/segmented_button/segmented_button.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can use SegmentedButton.styleFrom to customize SegmentedButton', (WidgetTester tester) async { + await tester.pumpWidget( + const example.SegmentedButtonApp(), + ); + + final Color unselectedBackgroundColor = Colors.grey[200]!; + const Color unselectedForegroundColor = Colors.red; + const Color selectedBackgroundColor = Colors.green; + const Color selectedForegroundColor = Colors.white; + + Material getMaterial(String text) { + return tester.widget(find.ancestor( + of: find.text(text), + matching: find.byType(Material), + ).first); + } + + // Verify the unselected button style. + expect(getMaterial('Day').textStyle?.color, unselectedForegroundColor); + expect(getMaterial('Day').color, unselectedBackgroundColor); + + // Verify the selected button style. + expect(getMaterial('Week').textStyle?.color, selectedForegroundColor); + expect(getMaterial('Week').color, selectedBackgroundColor); + }); +} diff --git a/packages/flutter/lib/src/material/segmented_button.dart b/packages/flutter/lib/src/material/segmented_button.dart index a68c372135..917526d35b 100644 --- a/packages/flutter/lib/src/material/segmented_button.dart +++ b/packages/flutter/lib/src/material/segmented_button.dart @@ -8,15 +8,18 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'button_style.dart'; +import 'button_style_button.dart'; import 'color_scheme.dart'; import 'colors.dart'; import 'icons.dart'; +import 'ink_well.dart'; import 'material.dart'; import 'material_state.dart'; import 'segmented_button_theme.dart'; import 'text_button.dart'; import 'text_button_theme.dart'; import 'theme.dart'; +import 'theme_data.dart'; import 'tooltip.dart'; /// Data describing a segment of a [SegmentedButton]. @@ -86,6 +89,12 @@ class ButtonSegment { /// ** See code in examples/api/lib/material/segmented_button/segmented_button.0.dart ** /// {@end-tool} /// +/// {@tool dartpad} +/// This sample showcases how to customize [SegmentedButton] using [SegmentedButton.styleFrom]. +/// +/// ** See code in examples/api/lib/material/segmented_button/segmented_button.1.dart ** +/// {@end-tool} +/// /// See also: /// /// * Material Design spec: @@ -178,6 +187,123 @@ class SegmentedButton extends StatefulWidget { /// [onSelectionChanged] will not be called. final bool emptySelectionAllowed; + /// A static convenience method that constructs a segmented button + /// [ButtonStyle] given simple values. + /// + /// The [foregroundColor], [selectedForegroundColor], and [disabledForegroundColor] + /// colors are used to create a [MaterialStateProperty] [ButtonStyle.foregroundColor], + /// and a derived [ButtonStyle.overlayColor]. + /// + /// The [backgroundColor], [selectedBackgroundColor] and [disabledBackgroundColor] + /// colors are used to create a [MaterialStateProperty] [ButtonStyle.backgroundColor]. + /// + /// Similarly, the [enabledMouseCursor] and [disabledMouseCursor] + /// parameters are used to construct [ButtonStyle.mouseCursor]. + /// + /// All of the other parameters are either used directly or used to + /// create a [MaterialStateProperty] with a single value for all + /// states. + /// + /// All parameters default to null. By default this method returns + /// a [ButtonStyle] that doesn't override anything. + /// + /// {@tool snippet} + /// + /// For example, to override the default text and icon colors for a + /// [SegmentedButton], as well as its overlay color, with all of the + /// standard opacity adjustments for the pressed, focused, and + /// hovered states, one could write: + /// + /// ** See code in examples/api/lib/material/segmented_button/segmented_button.1.dart ** + /// + /// ```dart + /// SegmentedButton( + /// style: SegmentedButton.styleFrom( + /// foregroundColor: Colors.black, + /// selectedForegroundColor: Colors.white, + /// backgroundColor: Colors.amber, + /// selectedBackgroundColor: Colors.red, + /// ), + /// segments: const >[ + /// ButtonSegment( + /// value: 0, + /// label: Text('0'), + /// icon: Icon(Icons.calendar_view_day), + /// ), + /// ButtonSegment( + /// value: 1, + /// label: Text('1'), + /// icon: Icon(Icons.calendar_view_week), + /// ), + /// ], + /// selected: const {0}, + /// onSelectionChanged: (Set selection) {}, + /// ), + /// ``` + /// {@end-tool} + static ButtonStyle styleFrom({ + Color? foregroundColor, + Color? backgroundColor, + Color? selectedForegroundColor, + Color? selectedBackgroundColor, + Color? disabledForegroundColor, + Color? disabledBackgroundColor, + Color? shadowColor, + Color? surfaceTintColor, + double? elevation, + TextStyle? textStyle, + EdgeInsetsGeometry? padding, + Size? minimumSize, + Size? fixedSize, + Size? maximumSize, + BorderSide? side, + OutlinedBorder? shape, + MouseCursor? enabledMouseCursor, + MouseCursor? disabledMouseCursor, + VisualDensity? visualDensity, + MaterialTapTargetSize? tapTargetSize, + Duration? animationDuration, + bool? enableFeedback, + AlignmentGeometry? alignment, + InteractiveInkFeatureFactory? splashFactory, + }) { + final MaterialStateProperty? foregroundColorProp = + (foregroundColor == null && disabledForegroundColor == null && selectedForegroundColor == null) + ? null + : _SegmentButtonDefaultColor(foregroundColor, disabledForegroundColor, selectedForegroundColor); + final MaterialStateProperty? backgroundColorProp = + (backgroundColor == null && disabledBackgroundColor == null && selectedBackgroundColor == null) + ? null + : _SegmentButtonDefaultColor(backgroundColor, disabledBackgroundColor, selectedBackgroundColor); + final MaterialStateProperty? overlayColor = (foregroundColor == null && selectedForegroundColor == null) + ? null + : _SegmentedButtonDefaultsM3.resolveStateColor(foregroundColor, selectedForegroundColor); + return TextButton.styleFrom( + textStyle: textStyle, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + elevation: elevation, + padding: padding, + minimumSize: minimumSize, + fixedSize: fixedSize, + maximumSize: maximumSize, + side: side, + shape: shape, + enabledMouseCursor: enabledMouseCursor, + disabledMouseCursor: disabledMouseCursor, + visualDensity: visualDensity, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + alignment: alignment, + splashFactory: splashFactory, + ).copyWith( + foregroundColor: foregroundColorProp, + backgroundColor: backgroundColorProp, + overlayColor: overlayColor, + ); + } + /// Customizes this button's appearance. /// /// The following style properties apply to the entire segmented button: @@ -417,6 +543,27 @@ class SegmentedButtonState extends State> { super.dispose(); } } + +@immutable +class _SegmentButtonDefaultColor extends MaterialStateProperty with Diagnosticable { + _SegmentButtonDefaultColor(this.color, this.disabled, this.selected); + + final Color? color; + final Color? disabled; + final Color? selected; + + @override + Color? resolve(Set states) { + if (states.contains(MaterialState.disabled)) { + return disabled; + } + if (states.contains(MaterialState.selected)) { + return selected; + } + return color; + } +} + class _SegmentedButtonRenderWidget extends MultiChildRenderObjectWidget { const _SegmentedButtonRenderWidget({ super.key, @@ -842,6 +989,33 @@ class _SegmentedButtonDefaultsM3 extends SegmentedButtonThemeData { } @override Widget? get selectedIcon => const Icon(Icons.check); + + static MaterialStateProperty resolveStateColor(Color? unselectedColor, Color? selectedColor){ + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + if (states.contains(MaterialState.pressed)) { + return selectedColor?.withOpacity(0.12); + } + if (states.contains(MaterialState.hovered)) { + return selectedColor?.withOpacity(0.08); + } + if (states.contains(MaterialState.focused)) { + return selectedColor?.withOpacity(0.12); + } + } else { + if (states.contains(MaterialState.pressed)) { + return unselectedColor?.withOpacity(0.12); + } + if (states.contains(MaterialState.hovered)) { + return unselectedColor?.withOpacity(0.08); + } + if (states.contains(MaterialState.focused)) { + return unselectedColor?.withOpacity(0.12); + } + } + return Colors.transparent; + }); + } } // END GENERATED TOKEN PROPERTIES - SegmentedButton diff --git a/packages/flutter/test/material/segmented_button_test.dart b/packages/flutter/test/material/segmented_button_test.dart index 61ff0498a7..6fa33b08fb 100644 --- a/packages/flutter/test/material/segmented_button_test.dart +++ b/packages/flutter/test/material/segmented_button_test.dart @@ -664,4 +664,118 @@ testWidgets('SegmentedButton shows checkboxes for selected segments', (WidgetTes expect(find.byTooltip('t2'), findsOneWidget); expect(find.byTooltip('t3'), findsOneWidget); }); + + testWidgets('SegmentedButton.styleFrom is applied to the SegmentedButton', (WidgetTester tester) async { + const Color foregroundColor = Color(0xfffffff0); + const Color backgroundColor = Color(0xfffffff1); + const Color selectedBackgroundColor = Color(0xfffffff2); + const Color selectedForegroundColor = Color(0xfffffff3); + const Color disabledBackgroundColor = Color(0xfffffff4); + const Color disabledForegroundColor = Color(0xfffffff5); + const MouseCursor enabledMouseCursor = SystemMouseCursors.text; + const MouseCursor disabledMouseCursor = SystemMouseCursors.grab; + + final ButtonStyle styleFromStyle = SegmentedButton.styleFrom( + foregroundColor: foregroundColor, + backgroundColor: backgroundColor, + selectedForegroundColor: selectedForegroundColor, + selectedBackgroundColor: selectedBackgroundColor, + disabledForegroundColor: disabledForegroundColor, + disabledBackgroundColor: disabledBackgroundColor, + shadowColor: const Color(0xfffffff6), + surfaceTintColor: const Color(0xfffffff7), + elevation: 1, + textStyle: const TextStyle(color: Color(0xfffffff8)), + padding: const EdgeInsets.all(2), + side: const BorderSide(color: Color(0xfffffff9)), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(3))), + enabledMouseCursor: enabledMouseCursor, + disabledMouseCursor: disabledMouseCursor, + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + animationDuration: const Duration(milliseconds: 100), + enableFeedback: true, + alignment: Alignment.center, + splashFactory: NoSplash.splashFactory, + ); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Center( + child: SegmentedButton( + style: styleFromStyle, + segments: const >[ + ButtonSegment(value: 1, label: Text('1')), + ButtonSegment(value: 2, label: Text('2')), + ButtonSegment(value: 3, label: Text('3'), enabled: false), + ], + selected: const {2}, + onSelectionChanged: (Set selected) { }, + selectedIcon: const Icon(Icons.alarm), + ), + ), + ), + )); + + // Test provided button style is applied to the enabled button segment. + ButtonStyle? buttonStyle = tester.widget(find.byType(TextButton).first).style; + expect(buttonStyle?.foregroundColor?.resolve(enabled), foregroundColor); + expect(buttonStyle?.backgroundColor?.resolve(enabled), backgroundColor); + expect(buttonStyle?.overlayColor, styleFromStyle.overlayColor); + expect(buttonStyle?.surfaceTintColor, styleFromStyle.surfaceTintColor); + expect(buttonStyle?.elevation, styleFromStyle.elevation); + expect(buttonStyle?.textStyle, styleFromStyle.textStyle); + expect(buttonStyle?.padding, styleFromStyle.padding); + expect(buttonStyle?.mouseCursor?.resolve(enabled), enabledMouseCursor); + expect(buttonStyle?.visualDensity, styleFromStyle.visualDensity); + expect(buttonStyle?.tapTargetSize, styleFromStyle.tapTargetSize); + expect(buttonStyle?.animationDuration, styleFromStyle.animationDuration); + expect(buttonStyle?.enableFeedback, styleFromStyle.enableFeedback); + expect(buttonStyle?.alignment, styleFromStyle.alignment); + expect(buttonStyle?.splashFactory, styleFromStyle.splashFactory); + + // Test provided button style is applied selected button segment. + buttonStyle = tester.widget(find.byType(TextButton).at(1)).style; + expect(buttonStyle?.foregroundColor?.resolve(selected), selectedForegroundColor); + expect(buttonStyle?.backgroundColor?.resolve(selected), selectedBackgroundColor); + expect(buttonStyle?.mouseCursor?.resolve(enabled), enabledMouseCursor); + + // Test provided button style is applied disabled button segment. + buttonStyle = tester.widget(find.byType(TextButton).last).style; + expect(buttonStyle?.foregroundColor?.resolve(disabled), disabledForegroundColor); + expect(buttonStyle?.backgroundColor?.resolve(disabled), disabledBackgroundColor); + expect(buttonStyle?.mouseCursor?.resolve(disabled), disabledMouseCursor); + + // Test provided button style is applied to the segmented button material. + final Material material = tester.widget(find.descendant( + of: find.byType(SegmentedButton), + matching: find.byType(Material), + ).first); + expect(material.shape, styleFromStyle.shape?.resolve(enabled)?.copyWith(side: BorderSide.none)); + expect(material.elevation, styleFromStyle.elevation?.resolve(enabled)); + expect(material.shadowColor, styleFromStyle.shadowColor?.resolve(enabled)); + expect(material.surfaceTintColor, styleFromStyle.surfaceTintColor?.resolve(enabled)); + + // Test provided button style border is applied to the segmented button border. + expect( + find.byType(SegmentedButton), + paints..line(color: styleFromStyle.side?.resolve(enabled)?.color), + ); + + // Test foreground color is applied to the overlay color. + RenderObject overlayColor() { + return tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + } + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.down(tester.getCenter(find.text('1'))); + await tester.pumpAndSettle(); + expect(overlayColor(), paints..rect(color: foregroundColor.withOpacity(0.08))); + }); } + +Set enabled = const {}; +Set disabled = const { MaterialState.disabled }; +Set selected = const { MaterialState.selected };