picker.dart 18.8 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6
// 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/rendering.dart';
7
import 'package:flutter/services.dart';
8 9
import 'package:flutter/widgets.dart';

10
import 'colors.dart';
11 12 13 14 15 16 17
import 'theme.dart';

// Eyeballed values comparing with a native picker to produce the right
// curvatures and densities.
const double _kDefaultDiameterRatio = 1.07;
const double _kDefaultPerspective = 0.003;
const double _kSqueeze = 1.45;
18 19 20 21

// Opacity fraction value that dims the wheel above and below the "magnifier"
// lens.
const double _kOverAndUnderCenterOpacity = 0.447;
22 23 24

/// An iOS-styled picker.
///
25
/// Displays its children widgets on a wheel for selection and
26 27
/// calls back when the currently selected item changes.
///
Dan Field's avatar
Dan Field committed
28
/// By default, the first child in `children` will be the initially selected child.
29 30 31
/// The index of a different child can be specified in [scrollController], to make
/// that child the initially selected child.
///
32
/// Can be used with [showCupertinoModalPopup] to display the picker modally at the
Dan Field's avatar
Dan Field committed
33 34
/// bottom of the screen. When calling [showCupertinoModalPopup], be sure to set
/// `semanticsDismissible` to true to enable dismissing the modal via semantics.
35
///
36 37 38 39 40
/// Sizes itself to its parent. All children are sized to the same size based
/// on [itemExtent].
///
/// By default, descendent texts are shown with [CupertinoTextThemeData.pickerTextStyle].
///
41 42 43 44 45 46
/// See also:
///
///  * [ListWheelScrollView], the generic widget backing this picker without
///    the iOS design specific chrome.
///  * <https://developer.apple.com/ios/human-interface-guidelines/controls/pickers/>
class CupertinoPicker extends StatefulWidget {
47
  /// Creates a picker from a concrete list of children.
48 49 50 51
  ///
  /// The [diameterRatio] and [itemExtent] arguments must not be null. The
  /// [itemExtent] must be greater than zero.
  ///
52 53 54
  /// The [backgroundColor] defaults to null, which disables background painting entirely.
  /// (i.e. the picker is going to have a completely transparent background), to match
  /// the native UIPicker and UIDatePicker. Also, if it has transparency, no gradient
55
  /// effect will be rendered.
56
  ///
57 58 59 60
  /// The [scrollController] argument can be used to specify a custom
  /// [FixedExtentScrollController] for programmatically reading or changing
  /// the current picker index or for selecting an initial index value.
  ///
61 62 63 64 65
  /// The [looping] argument decides whether the child list loops and can be
  /// scrolled infinitely.  If set to true, scrolling past the end of the list
  /// will loop the list back to the beginning.  If set to false, the list will
  /// stop scrolling when you reach the end or the beginning.
  CupertinoPicker({
66
    Key? key,
67
    this.diameterRatio = _kDefaultDiameterRatio,
68
    this.backgroundColor,
69 70 71 72
    this.offAxisFraction = 0.0,
    this.useMagnifier = false,
    this.magnification = 1.0,
    this.scrollController,
73
    this.squeeze = _kSqueeze,
74 75 76
    required this.itemExtent,
    required this.onSelectedItemChanged,
    required List<Widget> children,
77
    this.selectionOverlay = const CupertinoPickerDefaultSelectionOverlay(),
78 79 80 81 82 83 84
    bool looping = false,
  }) : assert(children != null),
       assert(diameterRatio != null),
       assert(diameterRatio > 0.0, RenderListWheelViewport.diameterRatioZeroMessage),
       assert(magnification > 0),
       assert(itemExtent != null),
       assert(itemExtent > 0),
85 86
       assert(squeeze != null),
       assert(squeeze > 0),
87
       childDelegate = looping
88 89
                       ? ListWheelChildLoopingListDelegate(children: children)
                       : ListWheelChildListDelegate(children: children),
90 91 92 93 94 95 96 97 98 99 100 101
       super(key: key);

  /// Creates a picker from an [IndexedWidgetBuilder] callback where the builder
  /// is dynamically invoked during layout.
  ///
  /// A child is lazily created when it starts becoming visible in the viewport.
  /// All of the children provided by the builder are cached and reused, so
  /// normally the builder is only called once for each index (except when
  /// rebuilding - the cache is cleared).
  ///
  /// The [itemBuilder] argument must not be null. The [childCount] argument
  /// reflects the number of children that will be provided by the [itemBuilder].
102
  /// {@macro flutter.widgets.ListWheelChildBuilderDelegate.childCount}
103 104 105
  ///
  /// The [itemExtent] argument must be non-null and positive.
  ///
106 107 108
  /// The [backgroundColor] defaults to null, which disables background painting entirely.
  /// (i.e. the picker is going to have a completely transparent background), to match
  /// the native UIPicker and UIDatePicker.
109
  CupertinoPicker.builder({
110
    Key? key,
111
    this.diameterRatio = _kDefaultDiameterRatio,
112
    this.backgroundColor,
113 114 115
    this.offAxisFraction = 0.0,
    this.useMagnifier = false,
    this.magnification = 1.0,
116
    this.scrollController,
117
    this.squeeze = _kSqueeze,
118 119 120 121
    required this.itemExtent,
    required this.onSelectedItemChanged,
    required NullableIndexedWidgetBuilder itemBuilder,
    int? childCount,
122
    this.selectionOverlay = const CupertinoPickerDefaultSelectionOverlay(),
123 124
  }) : assert(itemBuilder != null),
       assert(diameterRatio != null),
125
       assert(diameterRatio > 0.0, RenderListWheelViewport.diameterRatioZeroMessage),
126
       assert(magnification > 0),
127 128
       assert(itemExtent != null),
       assert(itemExtent > 0),
129 130
       assert(squeeze != null),
       assert(squeeze > 0),
131
       childDelegate = ListWheelChildBuilderDelegate(builder: itemBuilder, childCount: childCount),
132 133 134 135 136 137 138 139 140 141 142 143 144
       super(key: key);

  /// Relative ratio between this picker's height and the simulated cylinder's diameter.
  ///
  /// Smaller values creates more pronounced curvatures in the scrollable wheel.
  ///
  /// For more details, see [ListWheelScrollView.diameterRatio].
  ///
  /// Must not be null and defaults to `1.1` to visually mimic iOS.
  final double diameterRatio;

  /// Background color behind the children.
  ///
145 146 147
  /// Defaults to null, which disables background painting entirely.
  /// (i.e. the picker is going to have a completely transparent background), to match
  /// the native UIPicker and UIDatePicker.
148 149 150
  ///
  /// Any alpha value less 255 (fully opaque) will cause the removal of the
  /// wheel list edge fade gradient from rendering of the widget.
151
  final Color? backgroundColor;
152

153
  /// {@macro flutter.rendering.RenderListWheelViewport.offAxisFraction}
154 155
  final double offAxisFraction;

156
  /// {@macro flutter.rendering.RenderListWheelViewport.useMagnifier}
157 158
  final bool useMagnifier;

159
  /// {@macro flutter.rendering.RenderListWheelViewport.magnification}
160 161
  final double magnification;

162 163
  /// A [FixedExtentScrollController] to read and control the current item, and
  /// to set the initial item.
164 165
  ///
  /// If null, an implicit one will be created internally.
166
  final FixedExtentScrollController? scrollController;
167 168 169 170 171 172 173

  /// The uniform height of all children.
  ///
  /// All children will be given the [BoxConstraints] to match this exact
  /// height. Must not be null and must be positive.
  final double itemExtent;

174
  /// {@macro flutter.rendering.RenderListWheelViewport.squeeze}
175
  ///
176
  /// Defaults to `1.45` to visually mimic iOS.
177 178
  final double squeeze;

179 180 181 182 183 184 185
  /// An option callback when the currently centered item changes.
  ///
  /// Value changes when the item closest to the center changes.
  ///
  /// This can be called during scrolls and during ballistic flings. To get the
  /// value only when the scrolling settles, use a [NotificationListener],
  /// listen for [ScrollEndNotification] and read its [FixedExtentMetrics].
186
  final ValueChanged<int>? onSelectedItemChanged;
187

188 189
  /// A delegate that lazily instantiates children.
  final ListWheelChildDelegate childDelegate;
190

191 192 193 194 195 196 197 198 199 200
  /// A widget overlaid on the picker to highlight the currently selected entry.
  ///
  /// The [selectionOverlay] widget drawn above the [CupertinoPicker]'s picker
  /// wheel.
  /// It is vertically centered in the picker and is constrained to have the
  /// same height as the center row.
  ///
  /// If unspecified, it defaults to a [CupertinoPickerDefaultSelectionOverlay]
  /// which is a gray rounded rectangle overlay in iOS 14 style.
  /// This property can be set to null to remove the overlay.
201
  final Widget? selectionOverlay;
202

203
  @override
204
  State<StatefulWidget> createState() => _CupertinoPickerState();
205 206 207
}

class _CupertinoPickerState extends State<CupertinoPicker> {
208 209
  int? _lastHapticIndex;
  FixedExtentScrollController? _controller;
210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234

  @override
  void initState() {
    super.initState();
    if (widget.scrollController == null) {
      _controller = FixedExtentScrollController();
    }
  }

  @override
  void didUpdateWidget(CupertinoPicker oldWidget) {
    if (widget.scrollController != null && oldWidget.scrollController == null) {
      _controller = null;
    } else if (widget.scrollController == null && oldWidget.scrollController != null) {
      assert(_controller == null);
      _controller = FixedExtentScrollController();
    }
    super.didUpdateWidget(oldWidget);
  }

  @override
  void dispose() {
    _controller?.dispose();
    super.dispose();
  }
235 236

  void _handleSelectedItemChanged(int index) {
237 238
    // Only the haptic engine hardware on iOS devices would produce the
    // intended effects.
239
    final bool hasSuitableHapticHardware;
240 241 242 243 244 245
    switch (defaultTargetPlatform) {
      case TargetPlatform.iOS:
        hasSuitableHapticHardware = true;
        break;
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
246
      case TargetPlatform.linux:
247
      case TargetPlatform.macOS:
248
      case TargetPlatform.windows:
249 250 251 252 253
        hasSuitableHapticHardware = false;
        break;
    }
    assert(hasSuitableHapticHardware != null);
    if (hasSuitableHapticHardware && index != _lastHapticIndex) {
254
      _lastHapticIndex = index;
255
      HapticFeedback.selectionClick();
256 257
    }

258
    widget.onSelectedItemChanged?.call(index);
259 260
  }

261 262 263
  /// Draws the selectionOverlay.
  Widget _buildSelectionOverlay(Widget selectionOverlay) {
    final double height = widget.itemExtent * widget.magnification;
264 265

    return IgnorePointer(
266
      child: Center(
267
        child: ConstrainedBox(
268
          constraints: BoxConstraints.expand(
269
            height: height,
270
          ),
271
          child: selectionOverlay,
272 273 274 275 276
        ),
      ),
    );
  }

277 278
  @override
  Widget build(BuildContext context) {
279
    final Color? resolvedBackgroundColor = CupertinoDynamicColor.maybeResolve(widget.backgroundColor, context);
280

281
    assert(RenderListWheelViewport.defaultPerspective == _kDefaultPerspective);
282
    final Widget result = DefaultTextStyle(
283 284 285 286 287
      style: CupertinoTheme.of(context).textTheme.pickerTextStyle,
      child: Stack(
        children: <Widget>[
          Positioned.fill(
            child: _CupertinoPickerSemantics(
288
              scrollController: widget.scrollController ?? _controller!,
289 290 291 292 293 294 295
              child: ListWheelScrollView.useDelegate(
                controller: widget.scrollController ?? _controller,
                physics: const FixedExtentScrollPhysics(),
                diameterRatio: widget.diameterRatio,
                offAxisFraction: widget.offAxisFraction,
                useMagnifier: widget.useMagnifier,
                magnification: widget.magnification,
296
                overAndUnderCenterOpacity: _kOverAndUnderCenterOpacity,
297 298 299 300 301
                itemExtent: widget.itemExtent,
                squeeze: widget.squeeze,
                onSelectedItemChanged: _handleSelectedItemChanged,
                childDelegate: widget.childDelegate,
              ),
302
            ),
303
          ),
304 305
          if (widget.selectionOverlay != null)
            _buildSelectionOverlay(widget.selectionOverlay!),
306 307
        ],
      ),
308
    );
309 310 311 312 313

    return DecoratedBox(
      decoration: BoxDecoration(color: resolvedBackgroundColor),
      child: result,
    );
314 315
  }
}
316

317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396
/// A default selection overlay for [CupertinoPicker]s.
///
/// It draws a gray rounded rectangle to match the picker visuals introduced in
/// iOS 14.
///
/// This widget is typically only used in [CupertinoPicker.selectionOverlay].
/// In an iOS 14 multi-column picker, the selection overlay is a single rounded
/// rectangle that spans the entire multi-column picker.
/// To achieve the same effect using [CupertinoPickerDefaultSelectionOverlay],
/// the additional margin and corner radii on the left or the right side can be
/// disabled by turning off [capLeftEdge] and [capRightEdge], so this selection
/// overlay visually connects with selection overlays of adjoining
/// [CupertinoPicker]s (i.e., other "column"s).
///
/// See also:
///
///  * [CupertinoPicker], which uses this widget as its default [CupertinoPicker.selectionOverlay].
class CupertinoPickerDefaultSelectionOverlay extends StatelessWidget {

  /// Creates an iOS 14 style selection overlay that highlights the magnified
  /// area (or the currently selected item, depending on how you described it
  /// elsewhere) of a [CupertinoPicker].
  ///
  /// The [background] argument default value is [CupertinoColors.tertiarySystemFill].
  /// It must be non-null.
  ///
  /// The [capLeftEdge] and [capRightEdge] arguments decide whether to add a
  /// default margin and use rounded corners on the left and right side of the
  /// rectangular overlay.
  /// Default to true and must not be null.
  const CupertinoPickerDefaultSelectionOverlay({
    Key? key,
    this.background = CupertinoColors.tertiarySystemFill,
    this.capLeftEdge = true,
    this.capRightEdge = true,
  }) : assert(background != null),
       assert(capLeftEdge != null),
       assert(capRightEdge != null),
       super(key: key);

  /// Whether to use the default use rounded corners and margin on the left side.
  final bool capLeftEdge;

  /// Whether to use the default use rounded corners and margin on the right side.
  final bool capRightEdge;

  /// The color to fill in the background of the [CupertinoPickerDefaultSelectionOverlay].
  /// It Support for use [CupertinoDynamicColor].
  ///
  /// Typically this should not be set to a fully opaque color, as the currently
  /// selected item of the underlying [CupertinoPicker] should remain visible.
  /// Defaults to [CupertinoColors.tertiarySystemFill].
  final Color background;

  /// Default margin of the 'SelectionOverlay'.
  static const double _defaultSelectionOverlayHorizontalMargin = 9;

  /// Default radius of the 'SelectionOverlay'.
  static const double _defaultSelectionOverlayRadius = 8;

  @override
  Widget build(BuildContext context) {
    const Radius radius = Radius.circular(_defaultSelectionOverlayRadius);

    return Container(
      margin: EdgeInsets.only(
        left: capLeftEdge ? _defaultSelectionOverlayHorizontalMargin : 0,
        right: capRightEdge ? _defaultSelectionOverlayHorizontalMargin : 0,
      ),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.horizontal(
          left: capLeftEdge ? radius : Radius.zero,
          right: capRightEdge ? radius : Radius.zero,
        ),
        color: CupertinoDynamicColor.resolve(background, context),
      ),
    );
  }
}

397 398 399 400 401 402 403 404
// Turns the scroll semantics of the ListView into a single adjustable semantics
// node. This is done by removing all of the child semantics of the scroll
// wheel and using the scroll indexes to look up the current, previous, and
// next semantic label. This label is then turned into the value of a new
// adjustable semantic node, with adjustment callbacks wired to move the
// scroll controller.
class _CupertinoPickerSemantics extends SingleChildRenderObjectWidget {
  const _CupertinoPickerSemantics({
405 406 407
    Key? key,
    Widget? child,
    required this.scrollController,
408 409 410 411 412
  }) : super(key: key, child: child);

  final FixedExtentScrollController scrollController;

  @override
413 414
  RenderObject createRenderObject(BuildContext context) {
    assert(debugCheckHasDirectionality(context));
415
    return _RenderCupertinoPickerSemantics(scrollController, Directionality.of(context));
416
  }
417 418 419

  @override
  void updateRenderObject(BuildContext context, covariant _RenderCupertinoPickerSemantics renderObject) {
420
    assert(debugCheckHasDirectionality(context));
421
    renderObject
422
      ..textDirection = Directionality.of(context)
423 424 425 426 427 428
      ..controller = scrollController;
  }
}

class _RenderCupertinoPickerSemantics extends RenderProxyBox {
  _RenderCupertinoPickerSemantics(FixedExtentScrollController controller, this._textDirection) {
429
    _updateController(null, controller);
430 431 432
  }

  FixedExtentScrollController get controller => _controller;
433 434 435
  late FixedExtentScrollController _controller;
  set controller(FixedExtentScrollController value) => _updateController(_controller, value);

436
  // This method exists to allow controller to be non-null. It is only called with a null oldValue from constructor.
437 438
  void _updateController(FixedExtentScrollController? oldValue, FixedExtentScrollController value) {
    if (value == oldValue)
439
      return;
440 441
    if (oldValue != null)
      oldValue.removeListener(_handleScrollUpdate);
442
    else
443
      _currentIndex = value.initialItem;
444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466
    value.addListener(_handleScrollUpdate);
    _controller = value;
  }

  TextDirection get textDirection => _textDirection;
  TextDirection _textDirection;
  set textDirection(TextDirection value) {
    if (textDirection == value)
      return;
    _textDirection = value;
    markNeedsSemanticsUpdate();
  }

  int _currentIndex = 0;

  void _handleIncrease() {
    controller.jumpToItem(_currentIndex + 1);
  }

  void _handleDecrease() {
    if (_currentIndex == 0)
      return;
    controller.jumpToItem(_currentIndex - 1);
467
  }
468 469 470 471 472 473 474

  void _handleScrollUpdate() {
    if (controller.selectedItem == _currentIndex)
      return;
    _currentIndex = controller.selectedItem;
    markNeedsSemanticsUpdate();
  }
475
  @override
476 477 478 479 480 481 482 483 484 485 486 487 488 489
  void describeSemanticsConfiguration(SemanticsConfiguration config) {
    super.describeSemanticsConfiguration(config);
    config.isSemanticBoundary = true;
    config.textDirection = textDirection;
  }

  @override
  void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable<SemanticsNode> children) {
    if (children.isEmpty)
      return super.assembleSemanticsNode(node, config, children);
    final SemanticsNode scrollable = children.first;
    final Map<int, SemanticsNode> indexedChildren = <int, SemanticsNode>{};
    scrollable.visitChildren((SemanticsNode child) {
      assert(child.indexInParent != null);
490
      indexedChildren[child.indexInParent!] = child;
491 492 493 494 495
      return true;
    });
    if (indexedChildren[_currentIndex] == null) {
      return node.updateWith(config: config);
    }
496 497 498
    config.value = indexedChildren[_currentIndex]!.label;
    final SemanticsNode? previousChild = indexedChildren[_currentIndex - 1];
    final SemanticsNode? nextChild = indexedChildren[_currentIndex + 1];
499 500 501 502 503 504 505 506 507 508 509
    if (nextChild != null) {
      config.increasedValue = nextChild.label;
      config.onIncrease = _handleIncrease;
    }
    if (previousChild != null) {
      config.decreasedValue = previousChild.label;
      config.onDecrease = _handleDecrease;
    }
    node.updateWith(config: config);
  }
}