diff --git a/dev/tools/gen_defaults/bin/gen_defaults.dart b/dev/tools/gen_defaults/bin/gen_defaults.dart index 57ec5257915..197d6cfbe69 100644 --- a/dev/tools/gen_defaults/bin/gen_defaults.dart +++ b/dev/tools/gen_defaults/bin/gen_defaults.dart @@ -40,6 +40,7 @@ import 'package:gen_defaults/navigation_rail_template.dart'; import 'package:gen_defaults/popup_menu_template.dart'; import 'package:gen_defaults/progress_indicator_template.dart'; import 'package:gen_defaults/radio_template.dart'; +import 'package:gen_defaults/segmented_button_template.dart'; import 'package:gen_defaults/slider_template.dart'; import 'package:gen_defaults/surface_tint.dart'; import 'package:gen_defaults/switch_template.dart'; @@ -155,6 +156,7 @@ Future main(List args) async { PopupMenuTemplate('PopupMenu', '$materialLib/popup_menu.dart', tokens).updateFile(); ProgressIndicatorTemplate('ProgressIndicator', '$materialLib/progress_indicator.dart', tokens).updateFile(); RadioTemplate('Radio', '$materialLib/radio.dart', tokens).updateFile(); + SegmentedButtonTemplate('SegmentedButton', '$materialLib/segmented_button.dart', tokens).updateFile(); SliderTemplate('md.comp.slider', 'Slider', '$materialLib/slider.dart', tokens).updateFile(); SurfaceTintTemplate('SurfaceTint', '$materialLib/elevation_overlay.dart', tokens).updateFile(); SwitchTemplate('Switch', '$materialLib/switch.dart', tokens).updateFile(); diff --git a/dev/tools/gen_defaults/lib/segmented_button_template.dart b/dev/tools/gen_defaults/lib/segmented_button_template.dart new file mode 100644 index 00000000000..4d765a7ca00 --- /dev/null +++ b/dev/tools/gen_defaults/lib/segmented_button_template.dart @@ -0,0 +1,124 @@ +// 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 'template.dart'; + +class SegmentedButtonTemplate extends TokenTemplate { + const SegmentedButtonTemplate(super.blockName, super.fileName, super.tokens, { + super.colorSchemePrefix = '_colors.', + }); + + String _layerOpacity(String layerToken) { + if (tokens.containsKey(layerToken)) { + final String? layerValue = tokens[layerToken] as String?; + if (tokens.containsKey(layerValue)) { + final String? opacityValue = opacity(layerValue!); + if (opacityValue != null) { + return '.withOpacity($opacityValue)'; + } + } + } + return ''; + } + + String _stateColor(String componentToken, String type, String state) { + final String baseColor = color('$componentToken.$type.$state.state-layer.color', ''); + if (baseColor.isEmpty) { + return 'null'; + } + final String opacity = _layerOpacity('$componentToken.$state.state-layer.opacity'); + return '$baseColor$opacity'; + } + + @override + String generate() => ''' +class _SegmentedButtonDefaultsM3 extends SegmentedButtonThemeData { + _SegmentedButtonDefaultsM3(this.context); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + + @override ButtonStyle? get style { + return ButtonStyle( + textStyle: MaterialStatePropertyAll(${textStyle('md.comp.outlined-segmented-button.label-text')}), + backgroundColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return ${componentColor('md.comp.outlined-segmented-button.disabled')}; + } + if (states.contains(MaterialState.selected)) { + return ${componentColor('md.comp.outlined-segmented-button.selected.container')}; + } + return ${componentColor('md.comp.outlined-segmented-button.unselected.container')}; + }), + foregroundColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return ${componentColor('md.comp.outlined-segmented-button.disabled.label-text')}; + } + if (states.contains(MaterialState.selected)) { + if (states.contains(MaterialState.pressed)) { + return ${componentColor('md.comp.outlined-segmented-button.selected.pressed.label-text')}; + } + if (states.contains(MaterialState.hovered)) { + return ${componentColor('md.comp.outlined-segmented-button.selected.hover.label-text')}; + } + if (states.contains(MaterialState.focused)) { + return ${componentColor('md.comp.outlined-segmented-button.selected.focus.label-text')}; + } + return ${componentColor('md.comp.outlined-segmented-button.selected.label-text')}; + } else { + if (states.contains(MaterialState.pressed)) { + return ${componentColor('md.comp.outlined-segmented-button.unselected.pressed.label-text')}; + } + if (states.contains(MaterialState.hovered)) { + return ${componentColor('md.comp.outlined-segmented-button.unselected.hover.label-text')}; + } + if (states.contains(MaterialState.focused)) { + return ${componentColor('md.comp.outlined-segmented-button.unselected.focus.label-text')}; + } + return ${componentColor('md.comp.outlined-segmented-button.unselected.container')}; + } + }), + overlayColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + if (states.contains(MaterialState.hovered)) { + return ${_stateColor('md.comp.outlined-segmented-button', 'selected', 'hover')}; + } + if (states.contains(MaterialState.focused)) { + return ${_stateColor('md.comp.outlined-segmented-button', 'selected', 'focus')}; + } + if (states.contains(MaterialState.pressed)) { + return ${_stateColor('md.comp.outlined-segmented-button', 'selected', 'pressed')}; + } + } else { + if (states.contains(MaterialState.hovered)) { + return ${_stateColor('md.comp.outlined-segmented-button', 'unselected', 'hover')}; + } + if (states.contains(MaterialState.focused)) { + return ${_stateColor('md.comp.outlined-segmented-button', 'unselected', 'focus')}; + } + if (states.contains(MaterialState.pressed)) { + return ${_stateColor('md.comp.outlined-segmented-button', 'unselected', 'pressed')}; + } + } + return null; + }), + surfaceTintColor: const MaterialStatePropertyAll(Colors.transparent), + elevation: const MaterialStatePropertyAll(0), + iconSize: const MaterialStatePropertyAll(${tokens['md.comp.outlined-segmented-button.with-icon.icon.size']}), + side: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return ${border("md.comp.outlined-segmented-button.disabled.outline")}; + } + return ${border("md.comp.outlined-segmented-button.outline")}; + }), + shape: const MaterialStatePropertyAll(${shape("md.comp.outlined-segmented-button", '')}), + ); + } + + @override + Widget? get selectedIcon => const Icon(Icons.check); +} +'''; +} diff --git a/examples/api/lib/material/segmented_button/segmented_button.0.dart b/examples/api/lib/material/segmented_button/segmented_button.0.dart new file mode 100644 index 00000000000..dfc738e7410 --- /dev/null +++ b/examples/api/lib/material/segmented_button/segmented_button.0.dart @@ -0,0 +1,106 @@ +// 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. + +/// Flutter code sample for [SegmentedButton]. + +import 'package:flutter/material.dart'; + +void main() { + runApp(const SegmentedButtonApp()); +} + +class SegmentedButtonApp extends StatelessWidget { + const SegmentedButtonApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Spacer(), + Text('Single choice'), + SingleChoice(), + SizedBox(height: 20), + Text('Multiple choice'), + MultipleChoice(), + Spacer(), + ], + ), + ), + ), + ); + } +} + +enum Calendar { day, week, month, year } + +class SingleChoice extends StatefulWidget { + const SingleChoice({super.key}); + + @override + State createState() => _SingleChoiceState(); +} + +class _SingleChoiceState extends State { + + Calendar calendarView = Calendar.day; + + @override + Widget build(BuildContext context) { + return SegmentedButton( + 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; + }); + }, + ); + } +} + +enum Sizes { extraSmall, small, medium, large, extraLarge } + +class MultipleChoice extends StatefulWidget { + const MultipleChoice({super.key}); + + @override + State createState() => _MultipleChoiceState(); +} + +class _MultipleChoiceState extends State { + Set selection = {Sizes.large, Sizes.extraLarge}; + + @override + Widget build(BuildContext context) { + return SegmentedButton( + segments: const >[ + ButtonSegment(value: Sizes.extraSmall, label: Text('XS')), + ButtonSegment(value: Sizes.small, label: Text('S')), + ButtonSegment(value: Sizes.medium, label: Text('M')), + ButtonSegment(value: Sizes.large, label: Text('L'),), + ButtonSegment(value: Sizes.extraLarge, label: Text('XL')), + ], + selected: selection, + onSelectionChanged: (Set newSelection) { + setState(() { + selection = newSelection; + }); + }, + multiSelectionEnabled: true, + ); + } +} diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index cd057df69f4..3377f738ed4 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -146,6 +146,8 @@ export 'src/material/scaffold.dart'; export 'src/material/scrollbar.dart'; export 'src/material/scrollbar_theme.dart'; export 'src/material/search.dart'; +export 'src/material/segmented_button.dart'; +export 'src/material/segmented_button_theme.dart'; export 'src/material/selectable_text.dart'; export 'src/material/selection_area.dart'; export 'src/material/shadows.dart'; diff --git a/packages/flutter/lib/src/material/segmented_button.dart b/packages/flutter/lib/src/material/segmented_button.dart new file mode 100644 index 00000000000..1470acf5220 --- /dev/null +++ b/packages/flutter/lib/src/material/segmented_button.dart @@ -0,0 +1,813 @@ +// 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 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'button_style.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'icons.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'; + +/// Data describing a segment of a [SegmentedButton]. +class ButtonSegment { + /// Construct a SegmentData + /// + /// One of [icon] or [label] must be non-null. + const ButtonSegment({ + required this.value, + this.icon, + this.label, + this.enabled = true, + }) : assert(icon != null || label != null); + + /// Value used to identify the segment. + /// + /// This value must be unique across all segments in a [SegmentedButton]. + final T value; + + /// Optional icon displayed in the segment. + final Widget? icon; + + /// Optional label displayed in the segment. + final Widget? label; + + /// Determines if the segment is available for selection. + final bool enabled; +} + +/// A Material button that allows the user to select from limited set of options. +/// +/// Segmented buttons are used to help people select options, switch views, or +/// sort elements. They are typically used in cases where there are only 2-5 +/// options. +/// +/// The options are represented by segments described with [ButtonSegment] +/// entries in the [segments] field. Each segment has a [ButtonSegment.value] +/// that is used to indicate which segments are selected. +/// +/// The [selected] field is a set of selected [ButtonSegment.value]s. This +/// should be updated by the app in response to [onSelectionChanged] updates. +/// +/// By default, only a single segment can be selected (for mutually exclusive +/// choices). This can be relaxed with the [multiSelectionEnabled] field. +/// +/// Like [ButtonStyleButton]s, the [SegmentedButton]'s visuals can be +/// configured with a [ButtonStyle] [style] field. However, unlike other +/// buttons, some of the style parameters are applied to the entire segmented +/// button, and others are used for each of the segments. +/// +/// By default, a checkmark icon is used to show selected items. To configure +/// this behavior, you can use the [showSelectedIcon] and [selectedIcon] fields. +/// +/// Individual segments can be enabled or disabled with their +/// [ButtonSegment.enabled] flag. If the [onSelectionChanged] field is null, +/// then the entire segmented button will be disabled, regardless of the +/// individual segment settings. +/// +/// {@tool dartpad} +/// This sample shows how to display a [SegmentedButton] with either a single or +/// multiple selection. +/// +/// ** See code in examples/api/lib/material/segmented_button/segmented_button.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * Material Design spec: +/// * [ButtonStyle], which can be used in the [style] field to configure +/// the appearance of the button and its segments. +/// * [ToggleButtons], a similar widget that was built for Material 2. +/// [SegmentedButton] should be considered as a replacement for +/// [ToggleButtons]. +/// * [Radio], an alternative way to present the user with a mutually exclusive set of options. +/// * [FilterChip], [ChoiceChip], which can be used when you need to show more than five options. +class SegmentedButton extends StatelessWidget { + /// Creates a const [SegmentedButton]. + /// + /// [segments] must contain at least one segment, but it is recommended + /// to have two to five segments. If you need only single segment, + /// consider using a [Checkbox] or [Radio] widget instead. If you need + /// more than five options, consider using [FilterChip] or [ChoiceChip] + /// widgets. + /// + /// If [onSelectionChanged] is null, then the entire segemented button will + /// be disabled. + /// + /// By default [selected] must only contain one entry. However, if + /// [multiSelectionEnabled] is true, then [selected] can contain multiple + /// entries. If [emptySelectionAllowed] is true, then [selected] can be empty. + const SegmentedButton({ + super.key, + required this.segments, + required this.selected, + this.onSelectionChanged, + this.multiSelectionEnabled = false, + this.emptySelectionAllowed = false, + this.style, + this.showSelectedIcon = true, + this.selectedIcon, + }) : assert(segments != null), + assert(segments.length > 0), + assert(selected != null), + assert(selected.length > 0 || emptySelectionAllowed), + assert(selected.length < 2 || multiSelectionEnabled); + + /// Descriptions of the segments in the button. + /// + /// This a required parameter and must contain at least one segment, + /// but it is recommended to contain two to five segments. If you need only + /// a single segment, consider using a [Checkbox] or [Radio] widget instead. + /// If you need more than five options, consider using [FilterChip] or + /// [ChoiceChip] widgets. + final List> segments; + + /// The set of [ButtonSegment.value]s that indicate which [segments] are + /// selected. + /// + /// As the [SegmentedButton] does not maintain the state of the selection, + /// you will need to update this in response to [onSelectionChanged] calls. + /// + /// This is a required parameter. + final Set selected; + + /// The function that is called when the selection changes. + /// + /// The callback's parameter indicates which of the segments are selected. + /// + /// When the callback is null, the entire [SegmentedButton] is disabled, + /// and will not respond to input. + /// + /// The default is null. + final void Function(Set)? onSelectionChanged; + + /// Determines if multiple segments can be selected at one time. + /// + /// If true, more than one segment can be selected. When selecting a + /// segment, the other selected segments will stay selected. Selecting an + /// already selected segment will unselect it. + /// + /// If false, only one segment may be selected at a time. When a segment + /// is selected, any previously selected segment will be unselected. + /// + /// The default is false, so only a single segement may be selected at one + /// time. + final bool multiSelectionEnabled; + + /// Determines if having no selected segments is allowed. + /// + /// If true, then it is acceptable for none of the segements to be selected. + /// This means that [selected] can be empty. If the user taps on a + /// selected segment, it will be removed from the selection set passed into + /// [onSelectionChanged]. + /// + /// If false (the default), there must be at least one segment selected. If + /// the user taps on the only selected segment it will not be deselected, and + /// [onSelectionChanged] will not be called. + final bool emptySelectionAllowed; + + /// Customizes this button's appearance. + /// + /// The following style properties apply to the entire segmented button: + /// + /// * [ButtonStyle.shadowColor] + /// * [ButtonStyle.elevation] + /// * [ButtonStyle.side] - which is used for both the outer shape and + /// dividers between segments. + /// * [ButtonStyle.shape] + /// + /// The following style properties are applied to each of the invidual + /// button segments. For properties that are a [MaterialStateProperty], + /// they will be resolved with the current state of the segment: + /// + /// * [ButtonStyle.textStyle] + /// * [ButtonStyle.backgroundColor] + /// * [ButtonStyle.foregroundColor] + /// * [ButtonStyle.overlayColor] + /// * [ButtonStyle.surfaceTintColor] + /// * [ButtonStyle.elevation] + /// * [ButtonStyle.padding] + /// * [ButtonStyle.iconColor] + /// * [ButtonStyle.iconSize] + /// * [ButtonStyle.mouseCursor] + /// * [ButtonStyle.visualDensity] + /// * [ButtonStyle.tapTargetSize] + /// * [ButtonStyle.animationDuration] + /// * [ButtonStyle.enableFeedback] + /// * [ButtonStyle.alignment] + /// * [ButtonStyle.splashFactory] + final ButtonStyle? style; + + /// Determines if the [selectedIcon] (usually an icon using [Icons.check]) + /// is displayed on the selected segments. + /// + /// If true, the [selectedIcon] will be displayed at the start of the segment. + /// If both the [ButtonSegment.label] and [ButtonSegment.icon] are provided, + /// then the icon will be replaced with the [selectedIcon]. If only the icon + /// or the label is present then the [selectedIcon] will be shown at the start + /// of the segment. + /// + /// If false, then the [selectedIcon] is not used and will not be displayed + /// on selected segments. + /// + /// The default is true, meaning the [selectedIcon] will be shown on selected + /// segments. + final bool showSelectedIcon; + + /// An icon that is used to indicate a segment is selected. + /// + /// If [showSelectedIcon] is true then for selected segments this icon + /// will be shown before the [ButtonSegment.label], replacing the + /// [ButtonSegment.icon] if it is specified. + /// + /// Defaults to an [Icon] with [Icons.check]. + final Widget? selectedIcon; + + bool get _enabled => onSelectionChanged != null; + + void _handleOnPressed(T segmentValue) { + if (!_enabled) { + return; + } + final bool onlySelectedSegment = selected.length == 1 && selected.contains(segmentValue); + final bool validChange = emptySelectionAllowed || !onlySelectedSegment; + if (validChange) { + final bool toggle = multiSelectionEnabled || (emptySelectionAllowed && onlySelectedSegment); + final Set pressedSegment = {segmentValue}; + late final Set updatedSelection; + if (toggle) { + updatedSelection = selected.contains(segmentValue) + ? selected.difference(pressedSegment) + : selected.union(pressedSegment); + } else { + updatedSelection = pressedSegment; + } + if (!setEquals(updatedSelection, selected)) { + onSelectionChanged!(updatedSelection); + } + } + } + + @override + Widget build(BuildContext context) { + final SegmentedButtonThemeData theme = SegmentedButtonTheme.of(context); + final SegmentedButtonThemeData defaults = _SegmentedButtonDefaultsM3(context); + final TextDirection direction = Directionality.of(context); + + const Set enabledState = {}; + const Set disabledState = { MaterialState.disabled }; + final Set currentState = _enabled ? enabledState : disabledState; + + P? effectiveValue

(P? Function(ButtonStyle? style) getProperty) { + late final P? widgetValue = getProperty(style); + late final P? themeValue = getProperty(theme.style); + late final P? defaultValue = getProperty(defaults.style); + return widgetValue ?? themeValue ?? defaultValue; + } + + P? resolve

(MaterialStateProperty

? Function(ButtonStyle? style) getProperty, [Set? states]) { + return effectiveValue( + (ButtonStyle? style) => getProperty(style)?.resolve(states ?? currentState), + ); + } + + ButtonStyle segmentStyleFor(ButtonStyle? style) { + return ButtonStyle( + textStyle: style?.textStyle, + backgroundColor: style?.backgroundColor, + foregroundColor: style?.foregroundColor, + overlayColor: style?.overlayColor, + surfaceTintColor: style?.surfaceTintColor, + elevation: style?.elevation, + padding: style?.padding, + iconColor: style?.iconColor, + iconSize: style?.iconSize, + shape: const MaterialStatePropertyAll(RoundedRectangleBorder()), + mouseCursor: style?.mouseCursor, + visualDensity: style?.visualDensity, + tapTargetSize: style?.tapTargetSize, + animationDuration: style?.animationDuration, + enableFeedback: style?.enableFeedback, + alignment: style?.alignment, + splashFactory: style?.splashFactory, + ); + } + + final ButtonStyle segmentStyle = segmentStyleFor(style); + final ButtonStyle segmentThemeStyle = segmentStyleFor(theme.style).merge(segmentStyleFor(defaults.style)); + final Widget? selectedIcon = showSelectedIcon + ? this.selectedIcon ?? theme.selectedIcon ?? defaults.selectedIcon + : null; + + Widget buttonFor(ButtonSegment segment) { + final Widget label = segment.label ?? segment.icon ?? const SizedBox.shrink(); + final bool segmentSelected = selected.contains(segment.value); + final Widget? icon = (segmentSelected && showSelectedIcon) + ? selectedIcon + : segment.label != null + ? segment.icon + : null; + final MaterialStatesController controller = MaterialStatesController( + { + if (segmentSelected) MaterialState.selected, + } + ); + + final Widget button = icon != null + ? TextButton.icon( + style: segmentStyle, + statesController: controller, + onPressed: (_enabled && segment.enabled) ? () => _handleOnPressed(segment.value) : null, + icon: icon, + label: label, + ) + : TextButton( + style: segmentStyle, + statesController: controller, + onPressed: (_enabled && segment.enabled) ? () => _handleOnPressed(segment.value) : null, + child: label, + ); + + return MergeSemantics( + child: Semantics( + checked: segmentSelected, + inMutuallyExclusiveGroup: multiSelectionEnabled ? null : true, + child: button, + ), + ); + } + + final OutlinedBorder resolvedEnabledBorder = resolve((ButtonStyle? style) => style?.shape, disabledState) ?? const RoundedRectangleBorder(); + final OutlinedBorder resolvedDisabledBorder = resolve((ButtonStyle? style) => style?.shape, disabledState)?? const RoundedRectangleBorder(); + final BorderSide enabledSide = resolve((ButtonStyle? style) => style?.side, enabledState) ?? BorderSide.none; + final BorderSide disabledSide = resolve((ButtonStyle? style) => style?.side, disabledState) ?? BorderSide.none; + final OutlinedBorder enabledBorder = resolvedEnabledBorder.copyWith(side: enabledSide); + final OutlinedBorder disabledBorder = resolvedDisabledBorder.copyWith(side: disabledSide); + + final List buttons = segments.map(buttonFor).toList(); + + return Material( + shape: enabledBorder.copyWith(side: BorderSide.none), + elevation: resolve((ButtonStyle? style) => style?.elevation)!, + shadowColor: resolve((ButtonStyle? style) => style?.shadowColor), + surfaceTintColor: resolve((ButtonStyle? style) => style?.surfaceTintColor), + child: TextButtonTheme( + data: TextButtonThemeData(style: segmentThemeStyle), + child: _SegmentedButtonRenderWidget( + segments: segments, + enabledBorder: _enabled ? enabledBorder : disabledBorder, + disabledBorder: disabledBorder, + direction: direction, + children: buttons, + ), + ), + ); + } +} +class _SegmentedButtonRenderWidget extends MultiChildRenderObjectWidget { + _SegmentedButtonRenderWidget({ + super.key, + required this.segments, + required this.enabledBorder, + required this.disabledBorder, + required this.direction, + required super.children, + }) : assert(children.length == segments.length); + + final List> segments; + final OutlinedBorder enabledBorder; + final OutlinedBorder disabledBorder; + final TextDirection direction; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderSegmentedButton( + segments: segments, + enabledBorder: enabledBorder, + disabledBorder: disabledBorder, + textDirection: direction, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderSegmentedButton renderObject) { + renderObject + ..segments = segments + ..enabledBorder = enabledBorder + ..disabledBorder = disabledBorder + ..textDirection = direction; + } +} + +class _SegmentedButtonContainerBoxParentData extends ContainerBoxParentData { + RRect? surroundingRect; +} + +typedef _NextChild = RenderBox? Function(RenderBox child); + +class _RenderSegmentedButton extends RenderBox with + ContainerRenderObjectMixin>, + RenderBoxContainerDefaultsMixin> { + _RenderSegmentedButton({ + required List> segments, + required OutlinedBorder enabledBorder, + required OutlinedBorder disabledBorder, + required TextDirection textDirection, + }) : _segments = segments, + _enabledBorder = enabledBorder, + _disabledBorder = disabledBorder, + _textDirection = textDirection; + + List> get segments => _segments; + List> _segments; + set segments(List> value) { + if (listEquals(segments, value)) { + return; + } + _segments = value; + markNeedsLayout(); + } + + OutlinedBorder get enabledBorder => _enabledBorder; + OutlinedBorder _enabledBorder; + set enabledBorder(OutlinedBorder value) { + if (_enabledBorder == value) { + return; + } + _enabledBorder = value; + markNeedsLayout(); + } + + OutlinedBorder get disabledBorder => _disabledBorder; + OutlinedBorder _disabledBorder; + set disabledBorder(OutlinedBorder value) { + if (_disabledBorder == value) { + return; + } + _disabledBorder = value; + markNeedsLayout(); + } + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + set textDirection(TextDirection value) { + if (value == _textDirection) { + return; + } + _textDirection = value; + markNeedsLayout(); + } + + @override + double computeMinIntrinsicWidth(double height) { + RenderBox? child = firstChild; + double minWidth = 0.0; + while (child != null) { + final _SegmentedButtonContainerBoxParentData childParentData = child.parentData! as _SegmentedButtonContainerBoxParentData; + final double childWidth = child.getMinIntrinsicWidth(height); + minWidth = math.max(minWidth, childWidth); + child = childParentData.nextSibling; + } + return minWidth * childCount; + } + + @override + double computeMaxIntrinsicWidth(double height) { + RenderBox? child = firstChild; + double maxWidth = 0.0; + while (child != null) { + final _SegmentedButtonContainerBoxParentData childParentData = child.parentData! as _SegmentedButtonContainerBoxParentData; + final double childWidth = child.getMaxIntrinsicWidth(height); + maxWidth = math.max(maxWidth, childWidth); + child = childParentData.nextSibling; + } + return maxWidth * childCount; + } + + @override + double computeMinIntrinsicHeight(double width) { + RenderBox? child = firstChild; + double minHeight = 0.0; + while (child != null) { + final _SegmentedButtonContainerBoxParentData childParentData = child.parentData! as _SegmentedButtonContainerBoxParentData; + final double childHeight = child.getMinIntrinsicHeight(width); + minHeight = math.max(minHeight, childHeight); + child = childParentData.nextSibling; + } + return minHeight; + } + + @override + double computeMaxIntrinsicHeight(double width) { + RenderBox? child = firstChild; + double maxHeight = 0.0; + while (child != null) { + final _SegmentedButtonContainerBoxParentData childParentData = child.parentData! as _SegmentedButtonContainerBoxParentData; + final double childHeight = child.getMaxIntrinsicHeight(width); + maxHeight = math.max(maxHeight, childHeight); + child = childParentData.nextSibling; + } + return maxHeight; + } + + @override + double? computeDistanceToActualBaseline(TextBaseline baseline) { + return defaultComputeDistanceToHighestActualBaseline(baseline); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _SegmentedButtonContainerBoxParentData) { + child.parentData = _SegmentedButtonContainerBoxParentData(); + } + } + + void _layoutRects(_NextChild nextChild, RenderBox? leftChild, RenderBox? rightChild) { + RenderBox? child = leftChild; + double start = 0.0; + while (child != null) { + final _SegmentedButtonContainerBoxParentData childParentData = child.parentData! as _SegmentedButtonContainerBoxParentData; + final Offset childOffset = Offset(start, 0.0); + childParentData.offset = childOffset; + final Rect childRect = Rect.fromLTWH(start, 0.0, child.size.width, child.size.height); + final RRect rChildRect = RRect.fromRectAndCorners(childRect); + childParentData.surroundingRect = rChildRect; + start += child.size.width; + child = nextChild(child); + } + } + + Size _calculateChildSize(BoxConstraints constraints) { + double maxHeight = 0; + double childWidth = constraints.minWidth / childCount; + RenderBox? child = firstChild; + while (child != null) { + childWidth = math.max(childWidth, child.getMaxIntrinsicWidth(double.infinity)); + child = childAfter(child); + } + childWidth = math.min(childWidth, constraints.maxWidth / childCount); + child = firstChild; + while (child != null) { + final double boxHeight = child.getMaxIntrinsicHeight(childWidth); + maxHeight = math.max(maxHeight, boxHeight); + child = childAfter(child); + } + return Size(childWidth, maxHeight); + } + + Size _computeOverallSizeFromChildSize(Size childSize) { + return constraints.constrain(Size(childSize.width * childCount, childSize.height)); + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + final Size childSize = _calculateChildSize(constraints); + return _computeOverallSizeFromChildSize(childSize); + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + final Size childSize = _calculateChildSize(constraints); + + final BoxConstraints childConstraints = BoxConstraints.tightFor( + width: childSize.width, + height: childSize.height, + ); + + RenderBox? child = firstChild; + while (child != null) { + child.layout(childConstraints, parentUsesSize: true); + child = childAfter(child); + } + + switch (textDirection) { + case TextDirection.rtl: + _layoutRects( + childBefore, + lastChild, + firstChild, + ); + break; + case TextDirection.ltr: + _layoutRects( + childAfter, + firstChild, + lastChild, + ); + break; + } + + size = _computeOverallSizeFromChildSize(childSize); + } + + @override + void paint(PaintingContext context, Offset offset) { + final Canvas canvas = context.canvas; + final Rect borderRect = offset & size; + final Path borderClipPath = enabledBorder.getInnerPath(borderRect, textDirection: textDirection); + RenderBox? child = firstChild; + RenderBox? previousChild; + int index = 0; + Path? enabledClipPath; + Path? disabledClipPath; + + canvas..save()..clipPath(borderClipPath); + while (child != null) { + final _SegmentedButtonContainerBoxParentData childParentData = child.parentData! as _SegmentedButtonContainerBoxParentData; + final Rect childRect = childParentData.surroundingRect!.outerRect.shift(offset); + + canvas..save()..clipRect(childRect); + context.paintChild(child, childParentData.offset + offset); + canvas.restore(); + + // Compute a clip rect for the outer border of the child. + late final double segmentLeft; + late final double segmentRight; + late final double dividerPos; + final double borderOutset = math.max(enabledBorder.side.strokeOutset, disabledBorder.side.strokeOutset); + switch (textDirection) { + case TextDirection.rtl: + segmentLeft = child == lastChild ? borderRect.left - borderOutset : childRect.left; + segmentRight = child == firstChild ? borderRect.right + borderOutset : childRect.right; + dividerPos = segmentRight; + break; + case TextDirection.ltr: + segmentLeft = child == firstChild ? borderRect.left - borderOutset : childRect.left; + segmentRight = child == lastChild ? borderRect.right + borderOutset : childRect.right; + dividerPos = segmentLeft; + break; + } + final Rect segmentClipRect = Rect.fromLTRB( + segmentLeft, borderRect.top - borderOutset, + segmentRight, borderRect.bottom + borderOutset); + + // Add the clip rect to the appropriate border clip path + if (segments[index].enabled) { + enabledClipPath = (enabledClipPath ?? Path())..addRect(segmentClipRect); + } else { + disabledClipPath = (disabledClipPath ?? Path())..addRect(segmentClipRect); + } + + // Paint the divider between this segment and the previous one. + if (previousChild != null) { + final BorderSide divider = segments[index - 1].enabled || segments[index].enabled + ? enabledBorder.side.copyWith(strokeAlign: 0.0) + : disabledBorder.side.copyWith(strokeAlign: 0.0); + final Offset top = Offset(dividerPos, childRect.top); + final Offset bottom = Offset(dividerPos, childRect.bottom); + canvas.drawLine(top, bottom, divider.toPaint()); + } + + previousChild = child; + child = childAfter(child); + index += 1; + } + canvas.restore(); + + // Paint the outer border for both disabled and enabled clip rect if needed. + if (disabledClipPath == null) { + // Just paint the enabled border with no clip. + enabledBorder.paint(context.canvas, borderRect, textDirection: textDirection); + } else if (enabledClipPath == null) { + // Just paint the disabled border with no. + disabledBorder.paint(context.canvas, borderRect, textDirection: textDirection); + } else { + // Paint both of them clipped appropriately for the children segments. + canvas..save()..clipPath(enabledClipPath); + enabledBorder.paint(context.canvas, borderRect, textDirection: textDirection); + canvas..restore()..save()..clipPath(disabledClipPath); + disabledBorder.paint(context.canvas, borderRect, textDirection: textDirection); + canvas.restore(); + } + } + + @override + bool hitTestChildren(BoxHitTestResult result, { required Offset position }) { + assert(position != null); + RenderBox? child = lastChild; + while (child != null) { + final _SegmentedButtonContainerBoxParentData childParentData = child.parentData! as _SegmentedButtonContainerBoxParentData; + if (childParentData.surroundingRect!.contains(position)) { + return result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset localOffset) { + assert(localOffset == position - childParentData.offset); + return child!.hitTest(result, position: localOffset); + }, + ); + } + child = childParentData.previousSibling; + } + return false; + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - SegmentedButton + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +// Token database version: v0_137 + +class _SegmentedButtonDefaultsM3 extends SegmentedButtonThemeData { + _SegmentedButtonDefaultsM3(this.context); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + + @override ButtonStyle? get style { + return ButtonStyle( + textStyle: MaterialStatePropertyAll(Theme.of(context).textTheme.labelLarge), + backgroundColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return _colors.secondaryContainer; + } + return null; + }), + foregroundColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + if (states.contains(MaterialState.selected)) { + if (states.contains(MaterialState.pressed)) { + return _colors.onSecondaryContainer; + } + if (states.contains(MaterialState.hovered)) { + return _colors.onSecondaryContainer; + } + if (states.contains(MaterialState.focused)) { + return _colors.onSecondaryContainer; + } + return _colors.onSecondaryContainer; + } else { + if (states.contains(MaterialState.pressed)) { + return _colors.onSurface; + } + if (states.contains(MaterialState.hovered)) { + return _colors.onSurface; + } + if (states.contains(MaterialState.focused)) { + return _colors.onSurface; + } + return null; + } + }), + overlayColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + if (states.contains(MaterialState.hovered)) { + return _colors.onSecondaryContainer.withOpacity(0.08); + } + if (states.contains(MaterialState.focused)) { + return _colors.onSecondaryContainer.withOpacity(0.12); + } + if (states.contains(MaterialState.pressed)) { + return _colors.onSecondaryContainer.withOpacity(0.12); + } + } else { + if (states.contains(MaterialState.hovered)) { + return _colors.onSurface.withOpacity(0.08); + } + if (states.contains(MaterialState.focused)) { + return _colors.onSurface.withOpacity(0.12); + } + if (states.contains(MaterialState.pressed)) { + return _colors.onSurface.withOpacity(0.12); + } + } + return null; + }), + surfaceTintColor: const MaterialStatePropertyAll(Colors.transparent), + elevation: const MaterialStatePropertyAll(0), + iconSize: const MaterialStatePropertyAll(18.0), + side: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return BorderSide(color: _colors.onSurface.withOpacity(0.12)); + } + return BorderSide(color: _colors.outline); + }), + shape: const MaterialStatePropertyAll(StadiumBorder()), + ); + } + + @override + Widget? get selectedIcon => const Icon(Icons.check); +} + +// END GENERATED TOKEN PROPERTIES - SegmentedButton diff --git a/packages/flutter/lib/src/material/segmented_button_theme.dart b/packages/flutter/lib/src/material/segmented_button_theme.dart new file mode 100644 index 00000000000..886b457aded --- /dev/null +++ b/packages/flutter/lib/src/material/segmented_button_theme.dart @@ -0,0 +1,172 @@ +// 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/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Overrides the default values of visual properties for descendant +/// [SegmentedButton] widgets. +/// +/// Descendant widgets obtain the current [SegmentedButtonThemeData] object with +/// [SegmentedButtonTheme.of]. Instances of [SegmentedButtonTheme] can +/// be customized with [SegmentedButtonThemeData.copyWith]. +/// +/// Typically a [SegmentedButtonTheme] is specified as part of the overall +/// [Theme] with [ThemeData.segmentedButtonTheme]. +/// +/// All [SegmentedButtonThemeData] properties are null by default. When null, +/// the [SegmentedButton] computes its own default values, typically based on +/// the overall theme's [ThemeData.colorScheme], [ThemeData.textTheme], and +/// [ThemeData.iconTheme]. +@immutable +class SegmentedButtonThemeData with Diagnosticable { + /// Creates a [SegmentedButtonThemeData] that can be used to override default properties + /// in a [SegmentedButtonTheme] widget. + const SegmentedButtonThemeData({ + this.style, + this.selectedIcon, + }); + + /// Overrides the [SegmentedButton]'s default style. + /// + /// Non-null properties or non-null resolved [MaterialStateProperty] + /// values override the default values used by [SegmentedButton]. + /// + /// If [style] is null, then this theme doesn't override anything. + final ButtonStyle? style; + + /// Override for [SegmentedButton.selectedIcon] property. + /// + /// If non-null, then [selectedIcon] will be used instead of default + /// value for [SegmentedButton.selectedIcon]. + final Widget? selectedIcon; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + SegmentedButtonThemeData copyWith({ + ButtonStyle? style, + Widget? selectedIcon, + }) { + return SegmentedButtonThemeData( + style: style ?? this.style, + selectedIcon: selectedIcon ?? this.selectedIcon, + ); + } + + /// Linearly interpolates between two segmented button themes. + static SegmentedButtonThemeData lerp(SegmentedButtonThemeData? a, SegmentedButtonThemeData? b, double t) { + return SegmentedButtonThemeData( + style: ButtonStyle.lerp(a?.style, b?.style, t), + selectedIcon: t < 0.5 ? a?.selectedIcon : b?.selectedIcon, + ); + } + + @override + int get hashCode => Object.hash( + style, + selectedIcon, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is SegmentedButtonThemeData + && other.style == style + && other.selectedIcon == selectedIcon; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('style', style, defaultValue: null)); + properties.add(DiagnosticsProperty('selectedIcon', selectedIcon, defaultValue: null)); + } +} + +/// An inherited widget that defines the visual properties for +/// [SegmentedButton]s in this widget's subtree. +/// +/// Values specified here are used for [SegmentedButton] properties that are not +/// given an explicit non-null value. +class SegmentedButtonTheme extends InheritedTheme { + /// Creates a [SegmentedButtonTheme] that controls visual parameters for + /// descendent [SegmentedButton]s. + const SegmentedButtonTheme({ + super.key, + required this.data, + required super.child, + }) : assert(data != null); + + /// Specifies the visual properties used by descendant [SegmentedButton] + /// widgets. + final SegmentedButtonThemeData data; + + /// The [data] from the closest instance of this class that encloses the given + /// context. + /// + /// If there is no [SegmentedButtonTheme] in scope, this will return + /// [ThemeData.segmentedButtonTheme] from the ambient [Theme]. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// SegmentedButtonThemeData theme = SegmentedButtonTheme.of(context); + /// ``` + /// + /// See also: + /// + /// * [maybeOf], which returns null if it doesn't find a + /// [SegmentedButtonTheme] ancestor. + static SegmentedButtonThemeData of(BuildContext context) { + return maybeOf(context) ?? Theme.of(context).segmentedButtonTheme; + } + + /// The data from the closest instance of this class that encloses the given + /// context, if any. + /// + /// Use this function if you want to allow situations where no + /// [SegmentedButtonTheme] is in scope. Prefer using [SegmentedButtonTheme.of] + /// in situations where a [SegmentedButtonThemeData] is expected to be + /// non-null. + /// + /// If there is no [SegmentedButtonTheme] in scope, then this function will + /// return null. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// SegmentedButtonThemeData? theme = SegmentedButtonTheme.maybeOf(context); + /// if (theme == null) { + /// // Do something else instead. + /// } + /// ``` + /// + /// See also: + /// + /// * [of], which will return [ThemeData.segmentedButtonTheme] if it doesn't + /// find a [SegmentedButtonTheme] ancestor, instead of returning null. + static SegmentedButtonThemeData? maybeOf(BuildContext context) { + assert(context != null); + return context.dependOnInheritedWidgetOfExactType()?.data; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return SegmentedButtonTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(SegmentedButtonTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart index f2d427cf067..a460ca75d56 100644 --- a/packages/flutter/lib/src/material/theme_data.dart +++ b/packages/flutter/lib/src/material/theme_data.dart @@ -48,6 +48,7 @@ import 'popup_menu_theme.dart'; import 'progress_indicator_theme.dart'; import 'radio_theme.dart'; import 'scrollbar_theme.dart'; +import 'segmented_button_theme.dart'; import 'slider_theme.dart'; import 'snack_bar_theme.dart'; import 'switch_theme.dart'; @@ -361,6 +362,7 @@ class ThemeData with Diagnosticable { PopupMenuThemeData? popupMenuTheme, ProgressIndicatorThemeData? progressIndicatorTheme, RadioThemeData? radioTheme, + SegmentedButtonThemeData? segmentedButtonTheme, SliderThemeData? sliderTheme, SnackBarThemeData? snackBarTheme, SwitchThemeData? switchTheme, @@ -613,6 +615,7 @@ class ThemeData with Diagnosticable { popupMenuTheme ??= const PopupMenuThemeData(); progressIndicatorTheme ??= const ProgressIndicatorThemeData(); radioTheme ??= const RadioThemeData(); + segmentedButtonTheme ??= const SegmentedButtonThemeData(); sliderTheme ??= const SliderThemeData(); snackBarTheme ??= const SnackBarThemeData(); switchTheme ??= const SwitchThemeData(); @@ -708,6 +711,7 @@ class ThemeData with Diagnosticable { popupMenuTheme: popupMenuTheme, progressIndicatorTheme: progressIndicatorTheme, radioTheme: radioTheme, + segmentedButtonTheme: segmentedButtonTheme, sliderTheme: sliderTheme, snackBarTheme: snackBarTheme, switchTheme: switchTheme, @@ -819,6 +823,7 @@ class ThemeData with Diagnosticable { required this.popupMenuTheme, required this.progressIndicatorTheme, required this.radioTheme, + required this.segmentedButtonTheme, required this.sliderTheme, required this.snackBarTheme, required this.switchTheme, @@ -988,6 +993,7 @@ class ThemeData with Diagnosticable { assert(popupMenuTheme != null), assert(progressIndicatorTheme != null), assert(radioTheme != null), + assert(segmentedButtonTheme != null), assert(sliderTheme != null), assert(snackBarTheme != null), assert(switchTheme != null), @@ -1252,10 +1258,8 @@ class ThemeData with Diagnosticable { /// A temporary flag used to opt-in to Material 3 features. /// /// If true, then widgets that have been migrated to Material 3 will - /// use new colors, typography and other features of Material 3. A new - /// purple-based [ColorScheme] will be created and applied to the updated - /// widgets, as long as this is set to true. If false, they will use the - /// Material 2 look and feel. + /// use new colors, typography and other features of Material 3. If false, + /// they will use the Material 2 look and feel. /// /// During the migration to Material 3, turning this on may yield /// inconsistent look and feel in your app as some widgets are migrated @@ -1293,10 +1297,11 @@ class ThemeData with Diagnosticable { /// * Typography: `typography` (see table above) /// /// ### Components - /// * Common buttons: [ElevatedButton], [FilledButton], [OutlinedButton], [TextButton], [IconButton] /// * Bottom app bar: [BottomAppBar] - /// * FAB: [FloatingActionButton] - /// * Extended FAB: [FloatingActionButton.extended] + /// * Buttons + /// - Common buttons: [ElevatedButton], [FilledButton], [OutlinedButton], [TextButton], [IconButton] + /// - FAB: [FloatingActionButton], [FloatingActionButton.extended] + /// - Segmented buttons: [SegmentedButton] /// * Cards: [Card] /// * TextFields: [TextField] together with its [InputDecoration] /// * Chips: @@ -1599,6 +1604,9 @@ class ThemeData with Diagnosticable { /// A theme for customizing the appearance and layout of [Radio] widgets. final RadioThemeData radioTheme; + /// A theme for customizing the appearance and layout of [SegmentedButton] widgets. + final SegmentedButtonThemeData segmentedButtonTheme; + /// The colors and shapes used to render [Slider]. /// /// This is the value returned from [SliderTheme.of]. @@ -1880,6 +1888,7 @@ class ThemeData with Diagnosticable { PopupMenuThemeData? popupMenuTheme, ProgressIndicatorThemeData? progressIndicatorTheme, RadioThemeData? radioTheme, + SegmentedButtonThemeData? segmentedButtonTheme, SliderThemeData? sliderTheme, SnackBarThemeData? snackBarTheme, SwitchThemeData? switchTheme, @@ -2042,6 +2051,7 @@ class ThemeData with Diagnosticable { popupMenuTheme: popupMenuTheme ?? this.popupMenuTheme, progressIndicatorTheme: progressIndicatorTheme ?? this.progressIndicatorTheme, radioTheme: radioTheme ?? this.radioTheme, + segmentedButtonTheme: segmentedButtonTheme ?? this.segmentedButtonTheme, sliderTheme: sliderTheme ?? this.sliderTheme, snackBarTheme: snackBarTheme ?? this.snackBarTheme, switchTheme: switchTheme ?? this.switchTheme, @@ -2246,6 +2256,7 @@ class ThemeData with Diagnosticable { popupMenuTheme: PopupMenuThemeData.lerp(a.popupMenuTheme, b.popupMenuTheme, t)!, progressIndicatorTheme: ProgressIndicatorThemeData.lerp(a.progressIndicatorTheme, b.progressIndicatorTheme, t)!, radioTheme: RadioThemeData.lerp(a.radioTheme, b.radioTheme, t), + segmentedButtonTheme: SegmentedButtonThemeData.lerp(a.segmentedButtonTheme, b.segmentedButtonTheme, t), sliderTheme: SliderThemeData.lerp(a.sliderTheme, b.sliderTheme, t), snackBarTheme: SnackBarThemeData.lerp(a.snackBarTheme, b.snackBarTheme, t), switchTheme: SwitchThemeData.lerp(a.switchTheme, b.switchTheme, t), @@ -2352,6 +2363,7 @@ class ThemeData with Diagnosticable { other.popupMenuTheme == popupMenuTheme && other.progressIndicatorTheme == progressIndicatorTheme && other.radioTheme == radioTheme && + other.segmentedButtonTheme == segmentedButtonTheme && other.sliderTheme == sliderTheme && other.snackBarTheme == snackBarTheme && other.switchTheme == switchTheme && @@ -2455,6 +2467,7 @@ class ThemeData with Diagnosticable { popupMenuTheme, progressIndicatorTheme, radioTheme, + segmentedButtonTheme, sliderTheme, snackBarTheme, switchTheme, @@ -2560,6 +2573,7 @@ class ThemeData with Diagnosticable { properties.add(DiagnosticsProperty('popupMenuTheme', popupMenuTheme, defaultValue: defaultData.popupMenuTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('progressIndicatorTheme', progressIndicatorTheme, defaultValue: defaultData.progressIndicatorTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('radioTheme', radioTheme, defaultValue: defaultData.radioTheme, level: DiagnosticLevel.debug)); + properties.add(DiagnosticsProperty('segmentedButtonTheme', segmentedButtonTheme, defaultValue: defaultData.segmentedButtonTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('sliderTheme', sliderTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('snackBarTheme', snackBarTheme, defaultValue: defaultData.snackBarTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('switchTheme', switchTheme, defaultValue: defaultData.switchTheme, level: DiagnosticLevel.debug)); diff --git a/packages/flutter/test/material/segmented_button_test.dart b/packages/flutter/test/material/segmented_button_test.dart new file mode 100644 index 00000000000..c71f5f63672 --- /dev/null +++ b/packages/flutter/test/material/segmented_button_test.dart @@ -0,0 +1,456 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +Widget boilerplate({required Widget child}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center(child: child), + ); +} + +void main() { + + testWidgets('SegmentedButton supports exclusive choice by default', (WidgetTester tester) async { + int callbackCount = 0; + int selectedSegment = 2; + + Widget frameWithSelection(int selected) { + return Material( + child: boilerplate( + child: SegmentedButton( + segments: const >[ + ButtonSegment(value: 1, label: Text('1')), + ButtonSegment(value: 2, label: Text('2')), + ButtonSegment(value: 3, label: Text('3')), + ], + selected: {selected}, + onSelectionChanged: (Set selected) { + assert(selected.length == 1); + selectedSegment = selected.first; + callbackCount += 1; + }, + ), + ), + ); + } + + await tester.pumpWidget(frameWithSelection(selectedSegment)); + expect(selectedSegment, 2); + expect(callbackCount, 0); + + // Tap on segment 1. + await tester.tap(find.text('1')); + await tester.pumpAndSettle(); + expect(callbackCount, 1); + expect(selectedSegment, 1); + + // Update the selection in the widget + await tester.pumpWidget(frameWithSelection(1)); + + // Tap on segment 1 again should do nothing. + await tester.tap(find.text('1')); + await tester.pumpAndSettle(); + expect(callbackCount, 1); + expect(selectedSegment, 1); + + // Tap on segment 3. + await tester.tap(find.text('3')); + await tester.pumpAndSettle(); + expect(callbackCount, 2); + expect(selectedSegment, 3); + }); + + testWidgets('SegmentedButton supports multiple selected segments', (WidgetTester tester) async { + int callbackCount = 0; + Set selection = {1}; + + Widget frameWithSelection(Set selected) { + return Material( + child: boilerplate( + child: SegmentedButton( + multiSelectionEnabled: true, + segments: const >[ + ButtonSegment(value: 1, label: Text('1')), + ButtonSegment(value: 2, label: Text('2')), + ButtonSegment(value: 3, label: Text('3')), + ], + selected: selected, + onSelectionChanged: (Set selected) { + selection = selected; + callbackCount += 1; + }, + ), + ), + ); + } + + await tester.pumpWidget(frameWithSelection(selection)); + expect(selection, {1}); + expect(callbackCount, 0); + + // Tap on segment 2. + await tester.tap(find.text('2')); + await tester.pumpAndSettle(); + expect(callbackCount, 1); + expect(selection, {1, 2}); + + // Update the selection in the widget + await tester.pumpWidget(frameWithSelection({1, 2})); + await tester.pumpAndSettle(); + + // Tap on segment 1 again should remove it from selection. + await tester.tap(find.text('1')); + await tester.pumpAndSettle(); + expect(callbackCount, 2); + expect(selection, {2}); + + // Update the selection in the widget + await tester.pumpWidget(frameWithSelection({2})); + await tester.pumpAndSettle(); + + // Tap on segment 3. + await tester.tap(find.text('3')); + await tester.pumpAndSettle(); + expect(callbackCount, 3); + expect(selection, {2, 3}); + }); + +testWidgets('SegmentedButton allows for empty selection', (WidgetTester tester) async { + int callbackCount = 0; + int? selectedSegment = 1; + + Widget frameWithSelection(int? selected) { + return Material( + child: boilerplate( + child: SegmentedButton( + emptySelectionAllowed: true, + segments: const >[ + ButtonSegment(value: 1, label: Text('1')), + ButtonSegment(value: 2, label: Text('2')), + ButtonSegment(value: 3, label: Text('3')), + ], + selected: {if (selected != null) selected}, + onSelectionChanged: (Set selected) { + selectedSegment = selected.isEmpty ? null : selected.first; + callbackCount += 1; + }, + ), + ), + ); + } + + await tester.pumpWidget(frameWithSelection(selectedSegment)); + expect(selectedSegment,1); + expect(callbackCount, 0); + + // Tap on segment 1 should deselect it and make the selection empty. + await tester.tap(find.text('1')); + await tester.pumpAndSettle(); + expect(callbackCount, 1); + expect(selectedSegment, null); + + // Update the selection in the widget + await tester.pumpWidget(frameWithSelection(null)); + + // Tap on segment 2 should select it. + await tester.tap(find.text('2')); + await tester.pumpAndSettle(); + expect(callbackCount, 2); + expect(selectedSegment, 2); + + // Update the selection in the widget + await tester.pumpWidget(frameWithSelection(2)); + + // Tap on segment 3. + await tester.tap(find.text('3')); + await tester.pumpAndSettle(); + expect(callbackCount, 3); + expect(selectedSegment, 3); + }); + +testWidgets('SegmentedButton shows checkboxes for selected segments', (WidgetTester tester) async { + Widget frameWithSelection(int selected) { + return Material( + child: boilerplate( + child: SegmentedButton( + segments: const >[ + ButtonSegment(value: 1, label: Text('1')), + ButtonSegment(value: 2, label: Text('2')), + ButtonSegment(value: 3, label: Text('3')), + ], + selected: {selected}, + onSelectionChanged: (Set selected) {}, + ), + ), + ); + } + + Finder textHasIcon(String text, IconData icon) { + return find.descendant( + of: find.widgetWithText(Row, text), + matching: find.byIcon(icon) + ); + } + + await tester.pumpWidget(frameWithSelection(1)); + expect(textHasIcon('1', Icons.check), findsOneWidget); + expect(find.byIcon(Icons.check), findsOneWidget); + + await tester.pumpWidget(frameWithSelection(2)); + expect(textHasIcon('2', Icons.check), findsOneWidget); + expect(find.byIcon(Icons.check), findsOneWidget); + + await tester.pumpWidget(frameWithSelection(2)); + expect(textHasIcon('2', Icons.check), findsOneWidget); + expect(find.byIcon(Icons.check), findsOneWidget); + }); + + testWidgets('SegmentedButton shows selected checkboxes in place of icon if it has a label as well', (WidgetTester tester) async { + Widget frameWithSelection(int selected) { + return Material( + child: boilerplate( + child: SegmentedButton( + segments: const >[ + ButtonSegment(value: 1, icon: Icon(Icons.add), label: Text('1')), + ButtonSegment(value: 2, icon: Icon(Icons.add_a_photo), label: Text('2')), + ButtonSegment(value: 3, icon: Icon(Icons.add_alarm), label: Text('3')), + ], + selected: {selected}, + onSelectionChanged: (Set selected) {}, + ), + ), + ); + } + + Finder textHasIcon(String text, IconData icon) { + return find.descendant( + of: find.widgetWithText(Row, text), + matching: find.byIcon(icon) + ); + } + + await tester.pumpWidget(frameWithSelection(1)); + expect(textHasIcon('1', Icons.check), findsOneWidget); + expect(find.byIcon(Icons.add), findsNothing); + expect(textHasIcon('2', Icons.add_a_photo), findsOneWidget); + expect(textHasIcon('3', Icons.add_alarm), findsOneWidget); + + await tester.pumpWidget(frameWithSelection(2)); + expect(textHasIcon('1', Icons.add), findsOneWidget); + expect(textHasIcon('2', Icons.check), findsOneWidget); + expect(find.byIcon(Icons.add_a_photo), findsNothing); + expect(textHasIcon('3', Icons.add_alarm), findsOneWidget); + + await tester.pumpWidget(frameWithSelection(3)); + expect(textHasIcon('1', Icons.add), findsOneWidget); + expect(textHasIcon('2', Icons.add_a_photo), findsOneWidget); + expect(textHasIcon('3', Icons.check), findsOneWidget); + expect(find.byIcon(Icons.add_alarm), findsNothing); + }); + + testWidgets('SegmentedButton shows selected checkboxes next to icon if there is no label', (WidgetTester tester) async { + Widget frameWithSelection(int selected) { + return Material( + child: boilerplate( + child: SegmentedButton( + segments: const >[ + ButtonSegment(value: 1, icon: Icon(Icons.add)), + ButtonSegment(value: 2, icon: Icon(Icons.add_a_photo)), + ButtonSegment(value: 3, icon: Icon(Icons.add_alarm)), + ], + selected: {selected}, + onSelectionChanged: (Set selected) {}, + ), + ), + ); + } + + Finder rowWithIcons(IconData icon1, IconData icon2) { + return find.descendant( + of: find.widgetWithIcon(Row, icon1), + matching: find.byIcon(icon2) + ); + } + + await tester.pumpWidget(frameWithSelection(1)); + expect(rowWithIcons(Icons.add, Icons.check), findsOneWidget); + expect(rowWithIcons(Icons.add_a_photo, Icons.check), findsNothing); + expect(rowWithIcons(Icons.add_alarm, Icons.check), findsNothing); + + await tester.pumpWidget(frameWithSelection(2)); + expect(rowWithIcons(Icons.add, Icons.check), findsNothing); + expect(rowWithIcons(Icons.add_a_photo, Icons.check), findsOneWidget); + expect(rowWithIcons(Icons.add_alarm, Icons.check), findsNothing); + + await tester.pumpWidget(frameWithSelection(3)); + expect(rowWithIcons(Icons.add, Icons.check), findsNothing); + expect(rowWithIcons(Icons.add_a_photo, Icons.check), findsNothing); + expect(rowWithIcons(Icons.add_alarm, Icons.check), findsOneWidget); + + }); + + testWidgets('SegmentedButtons have correct semantics', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: SegmentedButton( + 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) {}, + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + // First is an unselected, enabled button. + TestSemantics( + flags: [ + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.hasEnabledState, + SemanticsFlag.hasCheckedState, + SemanticsFlag.isFocusable, + SemanticsFlag.isInMutuallyExclusiveGroup, + ], + label: '1', + actions: [ + SemanticsAction.tap, + ], + ), + + // Second is a selected, enabled button. + TestSemantics( + flags: [ + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.hasEnabledState, + SemanticsFlag.hasCheckedState, + SemanticsFlag.isChecked, + SemanticsFlag.isFocusable, + SemanticsFlag.isInMutuallyExclusiveGroup, + ], + label: '2', + actions: [ + SemanticsAction.tap, + ], + ), + + // Third is an unselected, disabled button. + TestSemantics( + flags: [ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.hasCheckedState, + SemanticsFlag.isInMutuallyExclusiveGroup, + ], + label: '3', + ), + ], + ), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + + testWidgets('Multi-select SegmentedButtons have correct semantics', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: SegmentedButton( + segments: const >[ + ButtonSegment(value: 1, label: Text('1')), + ButtonSegment(value: 2, label: Text('2')), + ButtonSegment(value: 3, label: Text('3'), enabled: false), + ], + selected: const {1, 3}, + onSelectionChanged: (Set selected) {}, + multiSelectionEnabled: true, + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + // First is selected, enabled button. + TestSemantics( + flags: [ + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.hasEnabledState, + SemanticsFlag.hasCheckedState, + SemanticsFlag.isChecked, + SemanticsFlag.isFocusable, + ], + label: '1', + actions: [ + SemanticsAction.tap, + ], + ), + + // Second is an unselected, enabled button. + TestSemantics( + flags: [ + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.hasEnabledState, + SemanticsFlag.hasCheckedState, + SemanticsFlag.isFocusable, + ], + label: '2', + actions: [ + SemanticsAction.tap, + ], + ), + + // Third is a selected, disabled button. + TestSemantics( + flags: [ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isChecked, + SemanticsFlag.hasCheckedState, + ], + label: '3', + ), + ], + ), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); +} diff --git a/packages/flutter/test/material/segmented_button_theme_test.dart b/packages/flutter/test/material/segmented_button_theme_test.dart new file mode 100644 index 00000000000..34741a9fd22 --- /dev/null +++ b/packages/flutter/test/material/segmented_button_theme_test.dart @@ -0,0 +1,473 @@ +// 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/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + + test('SegmentedButtonThemeData copyWith, ==, hashCode basics', () { + expect(const SegmentedButtonThemeData(), const SegmentedButtonThemeData().copyWith()); + expect(const SegmentedButtonThemeData().hashCode, const SegmentedButtonThemeData().copyWith().hashCode); + + const SegmentedButtonThemeData custom = SegmentedButtonThemeData( + style: ButtonStyle(backgroundColor: MaterialStatePropertyAll(Colors.green)), + selectedIcon: Icon(Icons.error), + ); + final SegmentedButtonThemeData copy = const SegmentedButtonThemeData().copyWith( + style: custom.style, + selectedIcon: custom.selectedIcon, + ); + expect(copy, custom); + }); + + testWidgets('Default SegmentedButtonThemeData debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + const SegmentedButtonThemeData().debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, []); + }); + + testWidgets('With no other configuration, defaults are used', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: true); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: SegmentedButton( + 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) { }, + ), + ), + ), + ), + ); + + // Test first segment, should be enabled + { + final Finder text = find.text('1'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.check)); + final Material material = tester.widget(parent); + expect(material.color, Colors.transparent); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, theme.colorScheme.primary); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsNothing); + } + + // Test second segment, should be enabled and selected + { + final Finder text = find.text('2'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.check)); + final Material material = tester.widget(parent); + expect(material.color, theme.colorScheme.secondaryContainer); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, theme.colorScheme.onSecondaryContainer); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsOneWidget); + } + + // Test last segment, should be disabled + { + final Finder text = find.text('3'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.check)); + final Material material = tester.widget(parent); + expect(material.color, Colors.transparent); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, theme.colorScheme.onSurface.withOpacity(0.38)); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsNothing); + } + }); + + testWidgets('ThemeData.segmentedButtonTheme overrides defaults', (WidgetTester tester) async { + final ThemeData theme = ThemeData( + useMaterial3: true, + segmentedButtonTheme: SegmentedButtonThemeData( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return Colors.blue; + } + if (states.contains(MaterialState.selected)) { + return Colors.purple; + } + return null; + }), + foregroundColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return Colors.yellow; + } + if (states.contains(MaterialState.selected)) { + return Colors.brown; + } else { + return Colors.cyan; + } + }), + ), + selectedIcon: const Icon(Icons.error), + ), + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: SegmentedButton( + 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) { }, + ), + ), + ), + ), + ); + + // Test first segment, should be enabled + { + final Finder text = find.text('1'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.error)); + final Material material = tester.widget(parent); + expect(material.color, Colors.transparent); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, Colors.cyan); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsNothing); + } + + // Test second segment, should be enabled and selected + { + final Finder text = find.text('2'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.error)); + final Material material = tester.widget(parent); + expect(material.color, Colors.purple); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, Colors.brown); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsOneWidget); + } + + // Test last segment, should be disabled + { + final Finder text = find.text('3'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.error)); + final Material material = tester.widget(parent); + expect(material.color, Colors.blue); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, Colors.yellow); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsNothing); + } + }); + + testWidgets('SegmentedButtonTheme overrides ThemeData and defaults', (WidgetTester tester) async { + final SegmentedButtonThemeData global = SegmentedButtonThemeData( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return Colors.blue; + } + if (states.contains(MaterialState.selected)) { + return Colors.purple; + } + return null; + }), + foregroundColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return Colors.yellow; + } + if (states.contains(MaterialState.selected)) { + return Colors.brown; + } else { + return Colors.cyan; + } + }), + ), + selectedIcon: const Icon(Icons.error), + ); + final SegmentedButtonThemeData segmentedTheme = SegmentedButtonThemeData( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return Colors.lightBlue; + } + if (states.contains(MaterialState.selected)) { + return Colors.lightGreen; + } + return null; + }), + foregroundColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return Colors.lime; + } + if (states.contains(MaterialState.selected)) { + return Colors.amber; + } else { + return Colors.deepPurple; + } + }), + ), + selectedIcon: const Icon(Icons.plus_one), + ); + final ThemeData theme = ThemeData( + useMaterial3: true, + segmentedButtonTheme: global, + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: SegmentedButtonTheme( + data: segmentedTheme, + child: Scaffold( + body: Center( + child: SegmentedButton( + 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) { }, + ), + ), + ), + ), + ), + ); + + // Test first segment, should be enabled + { + final Finder text = find.text('1'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.plus_one)); + final Material material = tester.widget(parent); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, Colors.transparent); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, Colors.deepPurple); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsNothing); + } + + // Test second segment, should be enabled and selected + { + final Finder text = find.text('2'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.plus_one)); + final Material material = tester.widget(parent); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, Colors.lightGreen); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, Colors.amber); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsOneWidget); + } + + // Test last segment, should be disabled + { + final Finder text = find.text('3'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.plus_one)); + final Material material = tester.widget(parent); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, Colors.lightBlue); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, Colors.lime); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsNothing); + } + }); + + testWidgets('Widget parameters overrides SegmentedTheme, ThemeData and defaults', (WidgetTester tester) async { + final SegmentedButtonThemeData global = SegmentedButtonThemeData( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return Colors.blue; + } + if (states.contains(MaterialState.selected)) { + return Colors.purple; + } + return null; + }), + foregroundColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return Colors.yellow; + } + if (states.contains(MaterialState.selected)) { + return Colors.brown; + } else { + return Colors.cyan; + } + }), + ), + selectedIcon: const Icon(Icons.error), + ); + final SegmentedButtonThemeData segmentedTheme = SegmentedButtonThemeData( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return Colors.lightBlue; + } + if (states.contains(MaterialState.selected)) { + return Colors.lightGreen; + } + return null; + }), + foregroundColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return Colors.lime; + } + if (states.contains(MaterialState.selected)) { + return Colors.amber; + } else { + return Colors.deepPurple; + } + }), + ), + selectedIcon: const Icon(Icons.plus_one), + ); + final ThemeData theme = ThemeData( + useMaterial3: true, + segmentedButtonTheme: global, + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: SegmentedButtonTheme( + data: segmentedTheme, + child: Scaffold( + body: Center( + child: SegmentedButton( + 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) { }, + style: ButtonStyle( + backgroundColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return Colors.black12; + } + if (states.contains(MaterialState.selected)) { + return Colors.grey; + } + return null; + }), + foregroundColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return Colors.amberAccent; + } + if (states.contains(MaterialState.selected)) { + return Colors.deepOrange; + } else { + return Colors.deepPurpleAccent; + } + }), + ), + selectedIcon: const Icon(Icons.alarm), + ), + ), + ), + ), + ), + ); + + // Test first segment, should be enabled + { + final Finder text = find.text('1'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.alarm)); + final Material material = tester.widget(parent); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, Colors.transparent); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, Colors.deepPurpleAccent); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsNothing); + } + + // Test second segment, should be enabled and selected + { + final Finder text = find.text('2'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.alarm)); + final Material material = tester.widget(parent); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, Colors.grey); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, Colors.deepOrange); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsOneWidget); + } + + // Test last segment, should be disabled + { + final Finder text = find.text('3'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.alarm)); + final Material material = tester.widget(parent); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, Colors.black12); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, Colors.amberAccent); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsNothing); + } + }); +} diff --git a/packages/flutter/test/material/theme_data_test.dart b/packages/flutter/test/material/theme_data_test.dart index 7e8190d2791..525cd83873e 100644 --- a/packages/flutter/test/material/theme_data_test.dart +++ b/packages/flutter/test/material/theme_data_test.dart @@ -799,6 +799,7 @@ void main() { popupMenuTheme: const PopupMenuThemeData(color: Colors.black), progressIndicatorTheme: const ProgressIndicatorThemeData(), radioTheme: const RadioThemeData(), + segmentedButtonTheme: const SegmentedButtonThemeData(), sliderTheme: sliderTheme, snackBarTheme: const SnackBarThemeData(backgroundColor: Colors.black), switchTheme: const SwitchThemeData(), @@ -917,6 +918,7 @@ void main() { popupMenuTheme: const PopupMenuThemeData(color: Colors.white), progressIndicatorTheme: const ProgressIndicatorThemeData(), radioTheme: const RadioThemeData(), + segmentedButtonTheme: const SegmentedButtonThemeData(), sliderTheme: otherSliderTheme, snackBarTheme: const SnackBarThemeData(backgroundColor: Colors.white), switchTheme: const SwitchThemeData(), @@ -1263,6 +1265,7 @@ void main() { 'popupMenuTheme', 'progressIndicatorTheme', 'radioTheme', + 'segmentedButtonTheme', 'sliderTheme', 'snackBarTheme', 'switchTheme',