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