Skip to content

File tree

3 files changed

+112
-3
lines changed

3 files changed

+112
-3
lines changed

packages/flutter/lib/src/cupertino/picker.dart

+56-3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
library;
88

99
import 'package:flutter/foundation.dart';
10+
import 'package:flutter/gestures.dart';
1011
import 'package:flutter/rendering.dart';
1112
import 'package:flutter/services.dart';
1213
import 'package:flutter/widgets.dart';
@@ -24,6 +25,13 @@ const double _kSqueeze = 1.45;
2425
// lens.
2526
const double _kOverAndUnderCenterOpacity = 0.447;
2627

28+
// The duration and curve of the tap-to-scroll gesture's animation when a picker
29+
// item is tapped.
30+
//
31+
// Eyeballed from an iPhone 15 Pro simulator running iOS 17.5.
32+
const Duration _kCupertinoPickerTapToScrollDuration = Duration(milliseconds: 300);
33+
const Curve _kCupertinoPickerTapToScrollCurve = Curves.easeInOut;
34+
2735
/// An iOS-styled picker.
2836
///
2937
/// Displays its children widgets on a wheel for selection and
@@ -258,6 +266,14 @@ class _CupertinoPickerState extends State<CupertinoPicker> {
258266
widget.onSelectedItemChanged?.call(index);
259267
}
260268

269+
void _handleChildTap(int index, FixedExtentScrollController controller) {
270+
controller.animateToItem(
271+
index,
272+
duration: _kCupertinoPickerTapToScrollDuration,
273+
curve: _kCupertinoPickerTapToScrollCurve,
274+
);
275+
}
276+
261277
/// Draws the selectionOverlay.
262278
Widget _buildSelectionOverlay(Widget selectionOverlay) {
263279
final double height = widget.itemExtent * widget.magnification;
@@ -280,15 +296,16 @@ class _CupertinoPickerState extends State<CupertinoPicker> {
280296
final Color? resolvedBackgroundColor = CupertinoDynamicColor.maybeResolve(widget.backgroundColor, context);
281297

282298
assert(RenderListWheelViewport.defaultPerspective == _kDefaultPerspective);
299+
final FixedExtentScrollController controller = widget.scrollController ?? _controller!;
283300
final Widget result = DefaultTextStyle(
284301
style: textStyle.copyWith(color: CupertinoDynamicColor.maybeResolve(textStyle.color, context)),
285302
child: Stack(
286303
children: <Widget>[
287304
Positioned.fill(
288305
child: _CupertinoPickerSemantics(
289-
scrollController: widget.scrollController ?? _controller!,
306+
scrollController: controller,
290307
child: ListWheelScrollView.useDelegate(
291-
controller: widget.scrollController ?? _controller,
308+
controller: controller,
292309
physics: const FixedExtentScrollPhysics(),
293310
diameterRatio: widget.diameterRatio,
294311
offAxisFraction: widget.offAxisFraction,
@@ -298,7 +315,11 @@ class _CupertinoPickerState extends State<CupertinoPicker> {
298315
itemExtent: widget.itemExtent,
299316
squeeze: widget.squeeze,
300317
onSelectedItemChanged: _handleSelectedItemChanged,
301-
childDelegate: widget.childDelegate,
318+
dragStartBehavior: DragStartBehavior.down,
319+
childDelegate: _CupertinoPickerListWheelChildDelegateWrapper(
320+
widget.childDelegate,
321+
onTappedChild: (int index) => _handleChildTap(index, controller),
322+
),
302323
),
303324
),
304325
),
@@ -512,3 +533,35 @@ class _RenderCupertinoPickerSemantics extends RenderProxyBox {
512533
controller.removeListener(_handleScrollUpdate);
513534
}
514535
}
536+
537+
class _CupertinoPickerListWheelChildDelegateWrapper implements ListWheelChildDelegate {
538+
_CupertinoPickerListWheelChildDelegateWrapper(
539+
this._wrapped, {
540+
required this.onTappedChild,
541+
});
542+
final ListWheelChildDelegate _wrapped;
543+
final void Function(int index) onTappedChild;
544+
545+
@override
546+
Widget? build(BuildContext context, int index) {
547+
final Widget? child = _wrapped.build(context, index);
548+
if (child == null) {
549+
return child;
550+
}
551+
return GestureDetector(
552+
behavior: HitTestBehavior.translucent,
553+
excludeFromSemantics: true,
554+
onTap: () => onTappedChild(index),
555+
child: child,
556+
);
557+
}
558+
559+
@override
560+
int? get estimatedChildCount => _wrapped.estimatedChildCount;
561+
562+
@override
563+
bool shouldRebuild(covariant _CupertinoPickerListWheelChildDelegateWrapper oldDelegate) => _wrapped.shouldRebuild(oldDelegate._wrapped);
564+
565+
@override
566+
int trueIndexOf(int index) => _wrapped.trueIndexOf(index);
567+
}

packages/flutter/lib/src/widgets/list_wheel_scroll_view.dart

+8
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ library;
1111
import 'dart:collection';
1212
import 'dart:math' as math;
1313

14+
import 'package:flutter/gestures.dart';
1415
import 'package:flutter/physics.dart';
1516
import 'package:flutter/rendering.dart';
1617

@@ -429,6 +430,7 @@ class _FixedExtentScrollable extends Scrollable {
429430
super.physics,
430431
required this.itemExtent,
431432
required super.viewportBuilder,
433+
required super.dragStartBehavior,
432434
super.restorationId,
433435
super.scrollBehavior,
434436
super.hitTestBehavior,
@@ -580,6 +582,7 @@ class ListWheelScrollView extends StatefulWidget {
580582
this.hitTestBehavior = HitTestBehavior.opaque,
581583
this.restorationId,
582584
this.scrollBehavior,
585+
this.dragStartBehavior = DragStartBehavior.start,
583586
required List<Widget> children,
584587
}) : assert(diameterRatio > 0.0, RenderListWheelViewport.diameterRatioZeroMessage),
585588
assert(perspective > 0),
@@ -614,6 +617,7 @@ class ListWheelScrollView extends StatefulWidget {
614617
this.hitTestBehavior = HitTestBehavior.opaque,
615618
this.restorationId,
616619
this.scrollBehavior,
620+
this.dragStartBehavior = DragStartBehavior.start,
617621
required this.childDelegate,
618622
}) : assert(diameterRatio > 0.0, RenderListWheelViewport.diameterRatioZeroMessage),
619623
assert(perspective > 0),
@@ -717,6 +721,9 @@ class ListWheelScrollView extends StatefulWidget {
717721
/// modified by default to not apply a [Scrollbar].
718722
final ScrollBehavior? scrollBehavior;
719723

724+
/// {@macro flutter.widgets.scrollable.dragStartBehavior}
725+
final DragStartBehavior dragStartBehavior;
726+
720727
@override
721728
State<ListWheelScrollView> createState() => _ListWheelScrollViewState();
722729
}
@@ -770,6 +777,7 @@ class _ListWheelScrollViewState extends State<ListWheelScrollView> {
770777
restorationId: widget.restorationId,
771778
hitTestBehavior: widget.hitTestBehavior,
772779
scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith(scrollbars: false),
780+
dragStartBehavior: widget.dragStartBehavior,
773781
viewportBuilder: (BuildContext context, ViewportOffset offset) {
774782
return ListWheelViewport(
775783
diameterRatio: widget.diameterRatio,

packages/flutter/test/cupertino/picker_test.dart

+48
Original file line numberDiff line numberDiff line change
@@ -589,4 +589,52 @@ void main() {
589589
expect(tappedChildren, const <int>[0, 1]);
590590
});
591591

592+
testWidgets('Tapping on child in a CupertinoPicker selects that child', (WidgetTester tester) async {
593+
int selectedItem = 0;
594+
const Duration tapScrollDuration = Duration(milliseconds: 300);
595+
// The tap animation is set to 300ms, but add an extra 1µs to complete the scroll animation.
596+
const Duration infinitesimalPause = Duration(microseconds: 1);
597+
598+
await tester.pumpWidget(
599+
CupertinoApp(
600+
home: CupertinoPicker(
601+
itemExtent: 10.0,
602+
onSelectedItemChanged: (int i) {
603+
selectedItem = i;
604+
},
605+
children: const <Widget>[
606+
Text('0'),
607+
Text('1'),
608+
Text('2'),
609+
Text('3'),
610+
],
611+
),
612+
),
613+
);
614+
615+
expect(selectedItem, equals(0));
616+
// Tap on the item at index 1.
617+
await tester.tap(find.text('1'));
618+
await tester.pump();
619+
await tester.pump(tapScrollDuration + infinitesimalPause);
620+
expect(selectedItem, equals(1));
621+
622+
// Skip to the item at index 3.
623+
await tester.tap(find.text('3'));
624+
await tester.pump();
625+
await tester.pump(tapScrollDuration + infinitesimalPause);
626+
expect(selectedItem, equals(3));
627+
628+
// Tap on the item at index 0.
629+
await tester.tap(find.text('0'));
630+
await tester.pump();
631+
await tester.pump(tapScrollDuration + infinitesimalPause);
632+
expect(selectedItem, equals(0));
633+
634+
// Skip to the item at index 2.
635+
await tester.tap(find.text('2'));
636+
await tester.pump();
637+
await tester.pump(tapScrollDuration + infinitesimalPause);
638+
expect(selectedItem, equals(2));
639+
});
592640
}

0 commit comments

Comments
 (0)