picker.dart 17.3 KB
Newer Older
1 2 3 4 5 6
// Copyright 2017 The Chromium 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/rendering.dart';
7
import 'package:flutter/services.dart';
8 9
import 'package:flutter/widgets.dart';

10 11
import 'theme.dart';

12
/// Color of the 'magnifier' lens border.
13 14
const Color _kHighlighterBorder = Color(0xFF7F7F7F);
const Color _kDefaultBackground = Color(0xFFD2D4DB);
15 16 17 18 19
// 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;
20 21 22
/// Opacity fraction value that hides the wheel above and below the 'magnifier'
/// lens with the same color as the background.
const double _kForegroundScreenOpacityFraction = 0.7;
23 24 25

/// An iOS-styled picker.
///
26
/// Displays its children widgets on a wheel for selection and
27 28
/// calls back when the currently selected item changes.
///
29 30 31 32
/// By default, the first child in [children] will be the initially selected child.
/// The index of a different child can be specified in [scrollController], to make
/// that child the initially selected child.
///
33
/// Can be used with [showCupertinoModalPopup] to display the picker modally at the
34 35
/// bottom of the screen.
///
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 52 53
  ///
  /// The [diameterRatio] and [itemExtent] arguments must not be null. The
  /// [itemExtent] must be greater than zero.
  ///
  /// The [backgroundColor] defaults to light gray. It can be set to null to
  /// disable the background painting entirely; this is mildly more efficient
54 55
  /// than using [Colors.transparent]. Also, if it has transparency, no gradient
  /// 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 66 67
  /// 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({
    Key key,
    this.diameterRatio = _kDefaultDiameterRatio,
68
    this.backgroundColor = _kDefaultBackground,
69 70 71 72
    this.offAxisFraction = 0.0,
    this.useMagnifier = false,
    this.magnification = 1.0,
    this.scrollController,
73
    this.squeeze = _kSqueeze,
74 75 76 77 78 79 80 81 82 83
    @required this.itemExtent,
    @required this.onSelectedItemChanged,
    @required List<Widget> children,
    bool looping = false,
  }) : assert(children != null),
       assert(diameterRatio != null),
       assert(diameterRatio > 0.0, RenderListWheelViewport.diameterRatioZeroMessage),
       assert(magnification > 0),
       assert(itemExtent != null),
       assert(itemExtent > 0),
84 85
       assert(squeeze != null),
       assert(squeeze > 0),
86
       childDelegate = looping
87 88
                       ? ListWheelChildLoopingListDelegate(children: children)
                       : ListWheelChildListDelegate(children: children),
89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108
       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].
  /// {@macro flutter.widgets.wheelList.childCount}
  ///
  /// The [itemExtent] argument must be non-null and positive.
  ///
  /// The [backgroundColor] defaults to light gray. It can be set to null to
  /// disable the background painting entirely; this is mildly more efficient
  /// than using [Colors.transparent].
  CupertinoPicker.builder({
109
    Key key,
110
    this.diameterRatio = _kDefaultDiameterRatio,
111
    this.backgroundColor = _kDefaultBackground,
112 113 114
    this.offAxisFraction = 0.0,
    this.useMagnifier = false,
    this.magnification = 1.0,
115
    this.scrollController,
116
    this.squeeze = _kSqueeze,
117 118
    @required this.itemExtent,
    @required this.onSelectedItemChanged,
119 120 121 122
    @required IndexedWidgetBuilder itemBuilder,
    int childCount,
  }) : assert(itemBuilder != null),
       assert(diameterRatio != null),
123
       assert(diameterRatio > 0.0, RenderListWheelViewport.diameterRatioZeroMessage),
124
       assert(magnification > 0),
125 126
       assert(itemExtent != null),
       assert(itemExtent > 0),
127 128
       assert(squeeze != null),
       assert(squeeze > 0),
129
       childDelegate = ListWheelChildBuilderDelegate(builder: itemBuilder, childCount: childCount),
130 131 132 133 134 135 136 137 138 139 140 141 142 143
       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.
  ///
  /// Defaults to a gray color in the iOS color palette.
144 145 146
  ///
  /// This can be set to null to disable the background painting entirely; this
  /// is mildly more efficient than using [Colors.transparent].
147 148 149
  ///
  /// Any alpha value less 255 (fully opaque) will cause the removal of the
  /// wheel list edge fade gradient from rendering of the widget.
150 151
  final Color backgroundColor;

152 153 154 155 156 157 158 159 160
  /// {@macro flutter.rendering.wheelList.offAxisFraction}
  final double offAxisFraction;

  /// {@macro flutter.rendering.wheelList.useMagnifier}
  final bool useMagnifier;

  /// {@macro flutter.rendering.wheelList.magnification}
  final double magnification;

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

  /// 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;

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

178 179 180 181 182 183 184 185 186
  /// 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].
  final ValueChanged<int> onSelectedItemChanged;

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

  @override
191
  State<StatefulWidget> createState() => _CupertinoPickerState();
192 193 194 195
}

class _CupertinoPickerState extends State<CupertinoPicker> {
  int _lastHapticIndex;
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221
  FixedExtentScrollController _controller;

  @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();
  }
222 223

  void _handleSelectedItemChanged(int index) {
224 225
    // Only the haptic engine hardware on iOS devices would produce the
    // intended effects.
226 227 228 229 230 231 232 233 234 235 236 237
    bool hasSuitableHapticHardware;
    switch (defaultTargetPlatform) {
      case TargetPlatform.iOS:
        hasSuitableHapticHardware = true;
        break;
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
        hasSuitableHapticHardware = false;
        break;
    }
    assert(hasSuitableHapticHardware != null);
    if (hasSuitableHapticHardware && index != _lastHapticIndex) {
238
      _lastHapticIndex = index;
239
      HapticFeedback.selectionClick();
240 241 242 243 244 245 246
    }

    if (widget.onSelectedItemChanged != null) {
      widget.onSelectedItemChanged(index);
    }
  }

247 248 249 250 251 252 253 254
  /// Makes the fade to [CupertinoPicker.backgroundColor] edge gradients.
  Widget _buildGradientScreen() {
    // Because BlendMode.dstOut doesn't work correctly with BoxDecoration we
    // have to just do a color blend. And a due to the way we are layering
    // the magnifier and the gradient on the background, using a transparent
    // background color makes the picker look odd.
    if (widget.backgroundColor != null && widget.backgroundColor.alpha < 255)
      return Container();
255

256 257 258
    final Color widgetBackgroundColor = widget.backgroundColor ?? const Color(0xFFFFFFFF);
    return Positioned.fill(
      child: IgnorePointer(
259 260
        child: Container(
          decoration: BoxDecoration(
261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307
            gradient: LinearGradient(
              colors: <Color>[
                widgetBackgroundColor,
                widgetBackgroundColor.withAlpha(0xF2),
                widgetBackgroundColor.withAlpha(0xDD),
                widgetBackgroundColor.withAlpha(0),
                widgetBackgroundColor.withAlpha(0),
                widgetBackgroundColor.withAlpha(0xDD),
                widgetBackgroundColor.withAlpha(0xF2),
                widgetBackgroundColor,
              ],
              stops: const <double>[
                0.0, 0.05, 0.09, 0.22, 0.78, 0.91, 0.95, 1.0,
              ],
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
            ),
          ),
        ),
      ),
    );
  }

  /// Makes the magnifier lens look so that the colors are normal through
  /// the lens and partially grayed out around it.
  Widget _buildMagnifierScreen() {
    final Color foreground = widget.backgroundColor?.withAlpha(
      (widget.backgroundColor.alpha * _kForegroundScreenOpacityFraction).toInt()
    );

    return IgnorePointer(
      child: Column(
        children: <Widget>[
          Expanded(
            child: Container(
              color: foreground,
            ),
          ),
          Container(
            decoration: const BoxDecoration(
              border: Border(
                top: BorderSide(width: 0.0, color: _kHighlighterBorder),
                bottom: BorderSide(width: 0.0, color: _kHighlighterBorder),
              ),
            ),
            constraints: BoxConstraints.expand(
                height: widget.itemExtent * widget.magnification,
308 309
            ),
          ),
310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329
          Expanded(
            child: Container(
              color: foreground,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildUnderMagnifierScreen() {
    final Color foreground = widget.backgroundColor?.withAlpha(
        (widget.backgroundColor.alpha * _kForegroundScreenOpacityFraction).toInt()
    );

    return Column(
      children: <Widget>[
        Expanded(child: Container()),
        Container(
          color: foreground,
330 331 332 333
          constraints: BoxConstraints.expand(
            height: widget.itemExtent * widget.magnification,
          ),
        ),
334 335 336 337 338 339 340 341 342
        Expanded(child: Container()),
      ],
    );
  }

  Widget _addBackgroundToChild(Widget child) {
    return DecoratedBox(
      decoration: BoxDecoration(
        color: widget.backgroundColor,
343
      ),
344
      child: child,
345 346 347
    );
  }

348 349
  @override
  Widget build(BuildContext context) {
350
    Widget result = DefaultTextStyle(
351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369
      style: CupertinoTheme.of(context).textTheme.pickerTextStyle,
      child: Stack(
        children: <Widget>[
          Positioned.fill(
            child: _CupertinoPickerSemantics(
              scrollController: widget.scrollController ?? _controller,
              child: ListWheelScrollView.useDelegate(
                controller: widget.scrollController ?? _controller,
                physics: const FixedExtentScrollPhysics(),
                diameterRatio: widget.diameterRatio,
                perspective: _kDefaultPerspective,
                offAxisFraction: widget.offAxisFraction,
                useMagnifier: widget.useMagnifier,
                magnification: widget.magnification,
                itemExtent: widget.itemExtent,
                squeeze: widget.squeeze,
                onSelectedItemChanged: _handleSelectedItemChanged,
                childDelegate: widget.childDelegate,
              ),
370
            ),
371
          ),
372
          _buildGradientScreen(),
373 374 375
          _buildMagnifierScreen(),
        ],
      ),
376
    );
377 378 379 380 381 382 383 384 385 386 387 388 389
    // Adds the appropriate opacity under the magnifier if the background
    // color is transparent.
    if (widget.backgroundColor != null && widget.backgroundColor.alpha < 255) {
      result = Stack(
        children: <Widget> [
          _buildUnderMagnifierScreen(),
          _addBackgroundToChild(result),
        ],
      );
    } else {
      result = _addBackgroundToChild(result);
    }
    return result;
390 391
  }
}
392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455

// 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({
    Key key,
    Widget child,
    @required this.scrollController,
  }) : super(key: key, child: child);

  final FixedExtentScrollController scrollController;

  @override
  RenderObject createRenderObject(BuildContext context) => _RenderCupertinoPickerSemantics(scrollController, Directionality.of(context));

  @override
  void updateRenderObject(BuildContext context, covariant _RenderCupertinoPickerSemantics renderObject) {
    renderObject
      ..textDirection = Directionality.of(context)
      ..controller = scrollController;
  }
}

class _RenderCupertinoPickerSemantics extends RenderProxyBox {
  _RenderCupertinoPickerSemantics(FixedExtentScrollController controller, this._textDirection) {
    this.controller = controller;
  }

  FixedExtentScrollController get controller => _controller;
  FixedExtentScrollController _controller;
  set controller(FixedExtentScrollController value) {
    if (value == _controller)
      return;
    if (_controller != null)
      _controller.removeListener(_handleScrollUpdate);
    else
      _currentIndex = value.initialItem ?? 0;
    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);
456
  }
457 458 459 460 461 462 463

  void _handleScrollUpdate() {
    if (controller.selectedItem == _currentIndex)
      return;
    _currentIndex = controller.selectedItem;
    markNeedsSemanticsUpdate();
  }
464
  @override
465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498
  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);
      indexedChildren[child.indexInParent] = child;
      return true;
    });
    if (indexedChildren[_currentIndex] == null) {
      return node.updateWith(config: config);
    }
    config.value = indexedChildren[_currentIndex].label;
    final SemanticsNode previousChild = indexedChildren[_currentIndex - 1];
    final SemanticsNode nextChild = indexedChildren[_currentIndex + 1];
    if (nextChild != null) {
      config.increasedValue = nextChild.label;
      config.onIncrease = _handleIncrease;
    }
    if (previousChild != null) {
      config.decreasedValue = previousChild.label;
      config.onDecrease = _handleDecrease;
    }
    node.updateWith(config: config);
  }
}