• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright 2018 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:collection';
6import 'dart:math' as math;
7
8import 'package:flutter/foundation.dart';
9import 'package:flutter/rendering.dart';
10import 'package:flutter/widgets.dart';
11
12import 'theme.dart';
13
14// Minimum padding from edges of the segmented control to edges of
15// encompassing widget.
16const EdgeInsetsGeometry _kHorizontalItemPadding = EdgeInsets.symmetric(horizontal: 16.0);
17
18// Minimum height of the segmented control.
19const double _kMinSegmentedControlHeight = 28.0;
20
21// The duration of the fade animation used to transition when a new widget
22// is selected.
23const Duration _kFadeDuration = Duration(milliseconds: 165);
24
25/// An iOS-style segmented control.
26///
27/// Displays the widgets provided in the [Map] of [children] in a
28/// horizontal list. Used to select between a number of mutually exclusive
29/// options. When one option in the segmented control is selected, the other
30/// options in the segmented control cease to be selected.
31///
32/// A segmented control can feature any [Widget] as one of the values in its
33/// [Map] of [children]. The type T is the type of the keys used
34/// to identify each widget and determine which widget is selected. As
35/// required by the [Map] class, keys must be of consistent types
36/// and must be comparable. The ordering of the keys will determine the order
37/// of the widgets in the segmented control.
38///
39/// When the state of the segmented control changes, the widget calls the
40/// [onValueChanged] callback. The map key associated with the newly selected
41/// widget is returned in the [onValueChanged] callback. Typically, widgets
42/// that use a segmented control will listen for the [onValueChanged] callback
43/// and rebuild the segmented control with a new [groupValue] to update which
44/// option is currently selected.
45///
46/// The [children] will be displayed in the order of the keys in the [Map].
47/// The height of the segmented control is determined by the height of the
48/// tallest widget provided as a value in the [Map] of [children].
49/// The width of each child in the segmented control will be equal to the width
50/// of widest child, unless the combined width of the children is wider than
51/// the available horizontal space. In this case, the available horizontal space
52/// is divided by the number of provided [children] to determine the width of
53/// each widget. The selection area for each of the widgets in the [Map] of
54/// [children] will then be expanded to fill the calculated space, so each
55/// widget will appear to have the same dimensions.
56///
57/// A segmented control may optionally be created with custom colors. The
58/// [unselectedColor], [selectedColor], [borderColor], and [pressedColor]
59/// arguments can be used to override the segmented control's colors from
60/// [CupertinoTheme] defaults.
61///
62/// See also:
63///
64///  * <https://developer.apple.com/design/human-interface-guidelines/ios/controls/segmented-controls/>
65class CupertinoSegmentedControl<T> extends StatefulWidget {
66  /// Creates an iOS-style segmented control bar.
67  ///
68  /// The [children] and [onValueChanged] arguments must not be null. The
69  /// [children] argument must be an ordered [Map] such as a [LinkedHashMap].
70  /// Further, the length of the [children] list must be greater than one.
71  ///
72  /// Each widget value in the map of [children] must have an associated key
73  /// that uniquely identifies this widget. This key is what will be returned
74  /// in the [onValueChanged] callback when a new value from the [children] map
75  /// is selected.
76  ///
77  /// The [groupValue] is the currently selected value for the segmented control.
78  /// If no [groupValue] is provided, or the [groupValue] is null, no widget will
79  /// appear as selected. The [groupValue] must be either null or one of the keys
80  /// in the [children] map.
81  CupertinoSegmentedControl({
82    Key key,
83    @required this.children,
84    @required this.onValueChanged,
85    this.groupValue,
86    this.unselectedColor,
87    this.selectedColor,
88    this.borderColor,
89    this.pressedColor,
90    this.padding,
91  }) : assert(children != null),
92       assert(children.length >= 2),
93       assert(onValueChanged != null),
94       assert(
95         groupValue == null || children.keys.any((T child) => child == groupValue),
96         'The groupValue must be either null or one of the keys in the children map.',
97       ),
98       super(key: key);
99
100  /// The identifying keys and corresponding widget values in the
101  /// segmented control.
102  ///
103  /// The map must have more than one entry.
104  /// This attribute must be an ordered [Map] such as a [LinkedHashMap].
105  final Map<T, Widget> children;
106
107  /// The identifier of the widget that is currently selected.
108  ///
109  /// This must be one of the keys in the [Map] of [children].
110  /// If this attribute is null, no widget will be initially selected.
111  final T groupValue;
112
113  /// The callback that is called when a new option is tapped.
114  ///
115  /// This attribute must not be null.
116  ///
117  /// The segmented control passes the newly selected widget's associated key
118  /// to the callback but does not actually change state until the parent
119  /// widget rebuilds the segmented control with the new [groupValue].
120  ///
121  /// The callback provided to [onValueChanged] should update the state of
122  /// the parent [StatefulWidget] using the [State.setState] method, so that
123  /// the parent gets rebuilt; for example:
124  ///
125  /// {@tool sample}
126  ///
127  /// ```dart
128  /// class SegmentedControlExample extends StatefulWidget {
129  ///   @override
130  ///   State createState() => SegmentedControlExampleState();
131  /// }
132  ///
133  /// class SegmentedControlExampleState extends State<SegmentedControlExample> {
134  ///   final Map<int, Widget> children = const {
135  ///     0: Text('Child 1'),
136  ///     1: Text('Child 2'),
137  ///   };
138  ///
139  ///   int currentValue;
140  ///
141  ///   @override
142  ///   Widget build(BuildContext context) {
143  ///     return Container(
144  ///       child: CupertinoSegmentedControl<int>(
145  ///         children: children,
146  ///         onValueChanged: (int newValue) {
147  ///           setState(() {
148  ///             currentValue = newValue;
149  ///           });
150  ///         },
151  ///         groupValue: currentValue,
152  ///       ),
153  ///     );
154  ///   }
155  /// }
156  /// ```
157  /// {@end-tool}
158  final ValueChanged<T> onValueChanged;
159
160  /// The color used to fill the backgrounds of unselected widgets and as the
161  /// text color of the selected widget.
162  ///
163  /// Defaults to [CupertinoTheme]'s `primaryContrastingColor` if null.
164  final Color unselectedColor;
165
166  /// The color used to fill the background of the selected widget and as the text
167  /// color of unselected widgets.
168  ///
169  /// Defaults to [CupertinoTheme]'s `primaryColor` if null.
170  final Color selectedColor;
171
172  /// The color used as the border around each widget.
173  ///
174  /// Defaults to [CupertinoTheme]'s `primaryColor` if null.
175  final Color borderColor;
176
177  /// The color used to fill the background of the widget the user is
178  /// temporarily interacting with through a long press or drag.
179  ///
180  /// Defaults to the selectedColor at 20% opacity if null.
181  final Color pressedColor;
182
183  /// The CupertinoSegmentedControl will be placed inside this padding
184  ///
185  /// Defaults to EdgeInsets.symmetric(horizontal: 16.0)
186  final EdgeInsetsGeometry padding;
187
188  @override
189  _SegmentedControlState<T> createState() => _SegmentedControlState<T>();
190}
191
192class _SegmentedControlState<T> extends State<CupertinoSegmentedControl<T>>
193    with TickerProviderStateMixin<CupertinoSegmentedControl<T>> {
194  T _pressedKey;
195
196  final List<AnimationController> _selectionControllers = <AnimationController>[];
197  final List<ColorTween> _childTweens = <ColorTween>[];
198
199  ColorTween _forwardBackgroundColorTween;
200  ColorTween _reverseBackgroundColorTween;
201  ColorTween _textColorTween;
202
203  Color _selectedColor;
204  Color _unselectedColor;
205  Color _borderColor;
206  Color _pressedColor;
207
208  AnimationController createAnimationController() {
209    return AnimationController(
210      duration: _kFadeDuration,
211      vsync: this,
212    )..addListener(() {
213      setState(() {
214        // State of background/text colors has changed
215      });
216    });
217  }
218
219  bool _updateColors() {
220    assert(mounted, 'This should only be called after didUpdateDependencies');
221    bool changed = false;
222    final Color selectedColor = widget.selectedColor ?? CupertinoTheme.of(context).primaryColor;
223    if (_selectedColor != selectedColor) {
224      changed = true;
225      _selectedColor = selectedColor;
226    }
227    final Color unselectedColor = widget.unselectedColor ?? CupertinoTheme.of(context).primaryContrastingColor;
228    if (_unselectedColor != unselectedColor) {
229      changed = true;
230      _unselectedColor = unselectedColor;
231    }
232    final Color borderColor = widget.borderColor ?? CupertinoTheme.of(context).primaryColor;
233    if (_borderColor != borderColor) {
234      changed = true;
235      _borderColor = borderColor;
236    }
237    final Color pressedColor = widget.pressedColor ?? CupertinoTheme.of(context).primaryColor.withOpacity(0.2);
238    if (_pressedColor != pressedColor) {
239      changed = true;
240      _pressedColor = pressedColor;
241    }
242
243    _forwardBackgroundColorTween = ColorTween(
244      begin: _pressedColor,
245      end: _selectedColor,
246    );
247    _reverseBackgroundColorTween = ColorTween(
248      begin: _unselectedColor,
249      end: _selectedColor,
250    );
251    _textColorTween = ColorTween(
252      begin: _selectedColor,
253      end: _unselectedColor,
254    );
255    return changed;
256  }
257
258  void _updateAnimationControllers() {
259    assert(mounted, 'This should only be called after didUpdateDependencies');
260    for (AnimationController controller in _selectionControllers) {
261      controller.dispose();
262    }
263    _selectionControllers.clear();
264    _childTweens.clear();
265
266    for (T key in widget.children.keys) {
267      final AnimationController animationController = createAnimationController();
268      if (widget.groupValue == key) {
269        _childTweens.add(_reverseBackgroundColorTween);
270        animationController.value = 1.0;
271      } else {
272        _childTweens.add(_forwardBackgroundColorTween);
273      }
274      _selectionControllers.add(animationController);
275    }
276  }
277
278  @override
279  void didChangeDependencies() {
280    super.didChangeDependencies();
281
282    if (_updateColors()) {
283      _updateAnimationControllers();
284    }
285  }
286
287  @override
288  void didUpdateWidget(CupertinoSegmentedControl<T> oldWidget) {
289    super.didUpdateWidget(oldWidget);
290
291    if (_updateColors() || oldWidget.children.length != widget.children.length) {
292      _updateAnimationControllers();
293    }
294
295    if (oldWidget.groupValue != widget.groupValue) {
296      int index = 0;
297      for (T key in widget.children.keys) {
298        if (widget.groupValue == key) {
299          _childTweens[index] = _forwardBackgroundColorTween;
300          _selectionControllers[index].forward();
301        } else {
302          _childTweens[index] = _reverseBackgroundColorTween;
303          _selectionControllers[index].reverse();
304        }
305        index += 1;
306      }
307    }
308  }
309
310  @override
311  void dispose() {
312    for (AnimationController animationController in _selectionControllers) {
313      animationController.dispose();
314    }
315    super.dispose();
316  }
317
318
319  void _onTapDown(T currentKey) {
320    if (_pressedKey == null && currentKey != widget.groupValue) {
321      setState(() {
322        _pressedKey = currentKey;
323      });
324    }
325  }
326
327  void _onTapCancel() {
328    setState(() {
329      _pressedKey = null;
330    });
331  }
332
333  void _onTap(T currentKey) {
334    if (currentKey != widget.groupValue && currentKey == _pressedKey) {
335      widget.onValueChanged(currentKey);
336      _pressedKey = null;
337    }
338  }
339
340  Color getTextColor(int index, T currentKey) {
341    if (_selectionControllers[index].isAnimating)
342      return _textColorTween.evaluate(_selectionControllers[index]);
343    if (widget.groupValue == currentKey)
344      return _unselectedColor;
345    return _selectedColor;
346  }
347
348  Color getBackgroundColor(int index, T currentKey) {
349    if (_selectionControllers[index].isAnimating)
350      return _childTweens[index].evaluate(_selectionControllers[index]);
351    if (widget.groupValue == currentKey)
352      return _selectedColor;
353    if (_pressedKey == currentKey)
354      return _pressedColor;
355    return _unselectedColor;
356  }
357
358  @override
359  Widget build(BuildContext context) {
360    final List<Widget> _gestureChildren = <Widget>[];
361    final List<Color> _backgroundColors = <Color>[];
362    int index = 0;
363    int selectedIndex;
364    int pressedIndex;
365    for (T currentKey in widget.children.keys) {
366      selectedIndex = (widget.groupValue == currentKey) ? index : selectedIndex;
367      pressedIndex = (_pressedKey == currentKey) ? index : pressedIndex;
368
369      final TextStyle textStyle = DefaultTextStyle.of(context).style.copyWith(
370        color: getTextColor(index, currentKey),
371      );
372      final IconThemeData iconTheme = IconThemeData(
373        color: getTextColor(index, currentKey),
374      );
375
376      Widget child = Center(
377        child: widget.children[currentKey],
378      );
379
380      child = GestureDetector(
381        onTapDown: (TapDownDetails event) {
382          _onTapDown(currentKey);
383        },
384        onTapCancel: _onTapCancel,
385        onTap: () {
386          _onTap(currentKey);
387        },
388        child: IconTheme(
389          data: iconTheme,
390          child: DefaultTextStyle(
391            style: textStyle,
392            child: Semantics(
393              button: true,
394              inMutuallyExclusiveGroup: true,
395              selected: widget.groupValue == currentKey,
396              child: child,
397            ),
398          ),
399        ),
400      );
401
402      _backgroundColors.add(getBackgroundColor(index, currentKey));
403      _gestureChildren.add(child);
404      index += 1;
405    }
406
407    final Widget box = _SegmentedControlRenderWidget<T>(
408      children: _gestureChildren,
409      selectedIndex: selectedIndex,
410      pressedIndex: pressedIndex,
411      backgroundColors: _backgroundColors,
412      borderColor: _borderColor,
413    );
414
415    return Padding(
416      padding: widget.padding ?? _kHorizontalItemPadding,
417      child: UnconstrainedBox(
418        constrainedAxis: Axis.horizontal,
419        child: box,
420      ),
421    );
422  }
423}
424
425class _SegmentedControlRenderWidget<T> extends MultiChildRenderObjectWidget {
426  _SegmentedControlRenderWidget({
427    Key key,
428    List<Widget> children = const <Widget>[],
429    @required this.selectedIndex,
430    @required this.pressedIndex,
431    @required this.backgroundColors,
432    @required this.borderColor,
433  }) : super(
434          key: key,
435          children: children,
436        );
437
438  final int selectedIndex;
439  final int pressedIndex;
440  final List<Color> backgroundColors;
441  final Color borderColor;
442
443  @override
444  RenderObject createRenderObject(BuildContext context) {
445    return _RenderSegmentedControl<T>(
446      textDirection: Directionality.of(context),
447      selectedIndex: selectedIndex,
448      pressedIndex: pressedIndex,
449      backgroundColors: backgroundColors,
450      borderColor: borderColor,
451    );
452  }
453
454  @override
455  void updateRenderObject(BuildContext context, _RenderSegmentedControl<T> renderObject) {
456    renderObject
457      ..textDirection = Directionality.of(context)
458      ..selectedIndex = selectedIndex
459      ..pressedIndex = pressedIndex
460      ..backgroundColors = backgroundColors
461      ..borderColor = borderColor;
462  }
463}
464
465class _SegmentedControlContainerBoxParentData extends ContainerBoxParentData<RenderBox> {
466  RRect surroundingRect;
467}
468
469typedef _NextChild = RenderBox Function(RenderBox child);
470
471class _RenderSegmentedControl<T> extends RenderBox
472    with ContainerRenderObjectMixin<RenderBox, ContainerBoxParentData<RenderBox>>,
473        RenderBoxContainerDefaultsMixin<RenderBox, ContainerBoxParentData<RenderBox>> {
474  _RenderSegmentedControl({
475    List<RenderBox> children,
476    @required int selectedIndex,
477    @required int pressedIndex,
478    @required TextDirection textDirection,
479    @required List<Color> backgroundColors,
480    @required Color borderColor,
481  }) : assert(textDirection != null),
482       _textDirection = textDirection,
483       _selectedIndex = selectedIndex,
484       _pressedIndex = pressedIndex,
485       _backgroundColors = backgroundColors,
486       _borderColor = borderColor {
487    addAll(children);
488  }
489
490  int get selectedIndex => _selectedIndex;
491  int _selectedIndex;
492  set selectedIndex(int value) {
493    if (_selectedIndex == value) {
494      return;
495    }
496    _selectedIndex = value;
497    markNeedsPaint();
498  }
499
500  int get pressedIndex => _pressedIndex;
501  int _pressedIndex;
502  set pressedIndex(int value) {
503    if (_pressedIndex == value) {
504      return;
505    }
506    _pressedIndex = value;
507    markNeedsPaint();
508  }
509
510  TextDirection get textDirection => _textDirection;
511  TextDirection _textDirection;
512  set textDirection(TextDirection value) {
513    if (_textDirection == value) {
514      return;
515    }
516    _textDirection = value;
517    markNeedsLayout();
518  }
519
520  List<Color> get backgroundColors => _backgroundColors;
521  List<Color> _backgroundColors;
522  set backgroundColors(List<Color> value) {
523    if (_backgroundColors == value) {
524      return;
525    }
526    _backgroundColors = value;
527    markNeedsPaint();
528  }
529
530  Color get borderColor => _borderColor;
531  Color _borderColor;
532  set borderColor(Color value) {
533    if (_borderColor == value) {
534      return;
535    }
536    _borderColor = value;
537    markNeedsPaint();
538  }
539
540  @override
541  double computeMinIntrinsicWidth(double height) {
542    RenderBox child = firstChild;
543    double minWidth = 0.0;
544    while (child != null) {
545      final _SegmentedControlContainerBoxParentData childParentData = child.parentData;
546      final double childWidth = child.getMinIntrinsicWidth(height);
547      minWidth = math.max(minWidth, childWidth);
548      child = childParentData.nextSibling;
549    }
550    return minWidth * childCount;
551  }
552
553  @override
554  double computeMaxIntrinsicWidth(double height) {
555    RenderBox child = firstChild;
556    double maxWidth = 0.0;
557    while (child != null) {
558      final _SegmentedControlContainerBoxParentData childParentData = child.parentData;
559      final double childWidth = child.getMaxIntrinsicWidth(height);
560      maxWidth = math.max(maxWidth, childWidth);
561      child = childParentData.nextSibling;
562    }
563    return maxWidth * childCount;
564  }
565
566  @override
567  double computeMinIntrinsicHeight(double width) {
568    RenderBox child = firstChild;
569    double minHeight = 0.0;
570    while (child != null) {
571      final _SegmentedControlContainerBoxParentData childParentData = child.parentData;
572      final double childHeight = child.getMinIntrinsicHeight(width);
573      minHeight = math.max(minHeight, childHeight);
574      child = childParentData.nextSibling;
575    }
576    return minHeight;
577  }
578
579  @override
580  double computeMaxIntrinsicHeight(double width) {
581    RenderBox child = firstChild;
582    double maxHeight = 0.0;
583    while (child != null) {
584      final _SegmentedControlContainerBoxParentData childParentData = child.parentData;
585      final double childHeight = child.getMaxIntrinsicHeight(width);
586      maxHeight = math.max(maxHeight, childHeight);
587      child = childParentData.nextSibling;
588    }
589    return maxHeight;
590  }
591
592  @override
593  double computeDistanceToActualBaseline(TextBaseline baseline) {
594    return defaultComputeDistanceToHighestActualBaseline(baseline);
595  }
596
597  @override
598  void setupParentData(RenderBox child) {
599    if (child.parentData is! _SegmentedControlContainerBoxParentData) {
600      child.parentData = _SegmentedControlContainerBoxParentData();
601    }
602  }
603
604  void _layoutRects(_NextChild nextChild, RenderBox leftChild, RenderBox rightChild) {
605    RenderBox child = leftChild;
606    double start = 0.0;
607    while (child != null) {
608      final _SegmentedControlContainerBoxParentData childParentData = child.parentData;
609      final Offset childOffset = Offset(start, 0.0);
610      childParentData.offset = childOffset;
611      final Rect childRect = Rect.fromLTWH(start, 0.0, child.size.width, child.size.height);
612      RRect rChildRect;
613      if (child == leftChild) {
614        rChildRect = RRect.fromRectAndCorners(childRect, topLeft: const Radius.circular(3.0),
615            bottomLeft: const Radius.circular(3.0));
616      } else if (child == rightChild) {
617        rChildRect = RRect.fromRectAndCorners(childRect, topRight: const Radius.circular(3.0),
618            bottomRight: const Radius.circular(3.0));
619      } else {
620        rChildRect = RRect.fromRectAndCorners(childRect);
621      }
622      childParentData.surroundingRect = rChildRect;
623      start += child.size.width;
624      child = nextChild(child);
625    }
626  }
627
628  @override
629  void performLayout() {
630    double maxHeight = _kMinSegmentedControlHeight;
631
632    double childWidth = constraints.minWidth / childCount;
633    for (RenderBox child in getChildrenAsList()) {
634      childWidth = math.max(childWidth, child.getMaxIntrinsicWidth(double.infinity));
635    }
636    childWidth = math.min(childWidth, constraints.maxWidth / childCount);
637
638    RenderBox child = firstChild;
639    while (child != null) {
640      final double boxHeight = child.getMaxIntrinsicHeight(childWidth);
641      maxHeight = math.max(maxHeight, boxHeight);
642      child = childAfter(child);
643    }
644
645    constraints.constrainHeight(maxHeight);
646
647    final BoxConstraints childConstraints = BoxConstraints.tightFor(
648      width: childWidth,
649      height: maxHeight,
650    );
651
652    child = firstChild;
653    while (child != null) {
654      child.layout(childConstraints, parentUsesSize: true);
655      child = childAfter(child);
656    }
657
658    switch (textDirection) {
659      case TextDirection.rtl:
660        _layoutRects(
661          childBefore,
662          lastChild,
663          firstChild,
664        );
665        break;
666      case TextDirection.ltr:
667        _layoutRects(
668          childAfter,
669          firstChild,
670          lastChild,
671        );
672        break;
673    }
674
675    size = constraints.constrain(Size(childWidth * childCount, maxHeight));
676  }
677
678  @override
679  void paint(PaintingContext context, Offset offset) {
680    RenderBox child = firstChild;
681    int index = 0;
682    while (child != null) {
683      _paintChild(context, offset, child, index);
684      child = childAfter(child);
685      index += 1;
686    }
687  }
688
689  void _paintChild(PaintingContext context, Offset offset, RenderBox child, int childIndex) {
690    assert(child != null);
691
692    final _SegmentedControlContainerBoxParentData childParentData = child.parentData;
693
694    context.canvas.drawRRect(
695      childParentData.surroundingRect.shift(offset),
696      Paint()
697        ..color = backgroundColors[childIndex]
698        ..style = PaintingStyle.fill,
699    );
700    context.canvas.drawRRect(
701      childParentData.surroundingRect.shift(offset),
702      Paint()
703        ..color = borderColor
704        ..strokeWidth = 1.0
705        ..style = PaintingStyle.stroke,
706    );
707
708    context.paintChild(child, childParentData.offset + offset);
709  }
710
711  @override
712  bool hitTestChildren(BoxHitTestResult result, { @required Offset position }) {
713    assert(position != null);
714    RenderBox child = lastChild;
715    while (child != null) {
716      final _SegmentedControlContainerBoxParentData childParentData = child.parentData;
717      if (childParentData.surroundingRect.contains(position)) {
718        final Offset center = (Offset.zero & child.size).center;
719        return result.addWithRawTransform(
720          transform: MatrixUtils.forceToPoint(center),
721          position: center,
722          hitTest: (BoxHitTestResult result, Offset position) {
723            assert(position == center);
724            return child.hitTest(result, position: center);
725          },
726        );
727      }
728      child = childParentData.previousSibling;
729    }
730    return false;
731  }
732}
733