Victor Sanni 2024-08-20 13:22:47 -07:00 committed by GitHub
parent ba7d7d5bb1
commit c631ad91a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 112 additions and 3 deletions

View File

@ -7,6 +7,7 @@
library;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
@ -24,6 +25,13 @@ const double _kSqueeze = 1.45;
// lens.
const double _kOverAndUnderCenterOpacity = 0.447;
// The duration and curve of the tap-to-scroll gesture's animation when a picker
// item is tapped.
//
// Eyeballed from an iPhone 15 Pro simulator running iOS 17.5.
const Duration _kCupertinoPickerTapToScrollDuration = Duration(milliseconds: 300);
const Curve _kCupertinoPickerTapToScrollCurve = Curves.easeInOut;
/// An iOS-styled picker.
///
/// Displays its children widgets on a wheel for selection and
@ -258,6 +266,14 @@ class _CupertinoPickerState extends State<CupertinoPicker> {
widget.onSelectedItemChanged?.call(index);
}
void _handleChildTap(int index, FixedExtentScrollController controller) {
controller.animateToItem(
index,
duration: _kCupertinoPickerTapToScrollDuration,
curve: _kCupertinoPickerTapToScrollCurve,
);
}
/// Draws the selectionOverlay.
Widget _buildSelectionOverlay(Widget selectionOverlay) {
final double height = widget.itemExtent * widget.magnification;
@ -280,15 +296,16 @@ class _CupertinoPickerState extends State<CupertinoPicker> {
final Color? resolvedBackgroundColor = CupertinoDynamicColor.maybeResolve(widget.backgroundColor, context);
assert(RenderListWheelViewport.defaultPerspective == _kDefaultPerspective);
final FixedExtentScrollController controller = widget.scrollController ?? _controller!;
final Widget result = DefaultTextStyle(
style: textStyle.copyWith(color: CupertinoDynamicColor.maybeResolve(textStyle.color, context)),
child: Stack(
children: <Widget>[
Positioned.fill(
child: _CupertinoPickerSemantics(
scrollController: widget.scrollController ?? _controller!,
scrollController: controller,
child: ListWheelScrollView.useDelegate(
controller: widget.scrollController ?? _controller,
controller: controller,
physics: const FixedExtentScrollPhysics(),
diameterRatio: widget.diameterRatio,
offAxisFraction: widget.offAxisFraction,
@ -298,7 +315,11 @@ class _CupertinoPickerState extends State<CupertinoPicker> {
itemExtent: widget.itemExtent,
squeeze: widget.squeeze,
onSelectedItemChanged: _handleSelectedItemChanged,
childDelegate: widget.childDelegate,
dragStartBehavior: DragStartBehavior.down,
childDelegate: _CupertinoPickerListWheelChildDelegateWrapper(
widget.childDelegate,
onTappedChild: (int index) => _handleChildTap(index, controller),
),
),
),
),
@ -512,3 +533,35 @@ class _RenderCupertinoPickerSemantics extends RenderProxyBox {
controller.removeListener(_handleScrollUpdate);
}
}
class _CupertinoPickerListWheelChildDelegateWrapper implements ListWheelChildDelegate {
_CupertinoPickerListWheelChildDelegateWrapper(
this._wrapped, {
required this.onTappedChild,
});
final ListWheelChildDelegate _wrapped;
final void Function(int index) onTappedChild;
@override
Widget? build(BuildContext context, int index) {
final Widget? child = _wrapped.build(context, index);
if (child == null) {
return child;
}
return GestureDetector(
behavior: HitTestBehavior.translucent,
excludeFromSemantics: true,
onTap: () => onTappedChild(index),
child: child,
);
}
@override
int? get estimatedChildCount => _wrapped.estimatedChildCount;
@override
bool shouldRebuild(covariant _CupertinoPickerListWheelChildDelegateWrapper oldDelegate) => _wrapped.shouldRebuild(oldDelegate._wrapped);
@override
int trueIndexOf(int index) => _wrapped.trueIndexOf(index);
}

View File

@ -11,6 +11,7 @@ library;
import 'dart:collection';
import 'dart:math' as math;
import 'package:flutter/gestures.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart';
@ -429,6 +430,7 @@ class _FixedExtentScrollable extends Scrollable {
super.physics,
required this.itemExtent,
required super.viewportBuilder,
required super.dragStartBehavior,
super.restorationId,
super.scrollBehavior,
super.hitTestBehavior,
@ -580,6 +582,7 @@ class ListWheelScrollView extends StatefulWidget {
this.hitTestBehavior = HitTestBehavior.opaque,
this.restorationId,
this.scrollBehavior,
this.dragStartBehavior = DragStartBehavior.start,
required List<Widget> children,
}) : assert(diameterRatio > 0.0, RenderListWheelViewport.diameterRatioZeroMessage),
assert(perspective > 0),
@ -614,6 +617,7 @@ class ListWheelScrollView extends StatefulWidget {
this.hitTestBehavior = HitTestBehavior.opaque,
this.restorationId,
this.scrollBehavior,
this.dragStartBehavior = DragStartBehavior.start,
required this.childDelegate,
}) : assert(diameterRatio > 0.0, RenderListWheelViewport.diameterRatioZeroMessage),
assert(perspective > 0),
@ -717,6 +721,9 @@ class ListWheelScrollView extends StatefulWidget {
/// modified by default to not apply a [Scrollbar].
final ScrollBehavior? scrollBehavior;
/// {@macro flutter.widgets.scrollable.dragStartBehavior}
final DragStartBehavior dragStartBehavior;
@override
State<ListWheelScrollView> createState() => _ListWheelScrollViewState();
}
@ -770,6 +777,7 @@ class _ListWheelScrollViewState extends State<ListWheelScrollView> {
restorationId: widget.restorationId,
hitTestBehavior: widget.hitTestBehavior,
scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith(scrollbars: false),
dragStartBehavior: widget.dragStartBehavior,
viewportBuilder: (BuildContext context, ViewportOffset offset) {
return ListWheelViewport(
diameterRatio: widget.diameterRatio,

View File

@ -589,4 +589,52 @@ void main() {
expect(tappedChildren, const <int>[0, 1]);
});
testWidgets('Tapping on child in a CupertinoPicker selects that child', (WidgetTester tester) async {
int selectedItem = 0;
const Duration tapScrollDuration = Duration(milliseconds: 300);
// The tap animation is set to 300ms, but add an extra 1µs to complete the scroll animation.
const Duration infinitesimalPause = Duration(microseconds: 1);
await tester.pumpWidget(
CupertinoApp(
home: CupertinoPicker(
itemExtent: 10.0,
onSelectedItemChanged: (int i) {
selectedItem = i;
},
children: const <Widget>[
Text('0'),
Text('1'),
Text('2'),
Text('3'),
],
),
),
);
expect(selectedItem, equals(0));
// Tap on the item at index 1.
await tester.tap(find.text('1'));
await tester.pump();
await tester.pump(tapScrollDuration + infinitesimalPause);
expect(selectedItem, equals(1));
// Skip to the item at index 3.
await tester.tap(find.text('3'));
await tester.pump();
await tester.pump(tapScrollDuration + infinitesimalPause);
expect(selectedItem, equals(3));
// Tap on the item at index 0.
await tester.tap(find.text('0'));
await tester.pump();
await tester.pump(tapScrollDuration + infinitesimalPause);
expect(selectedItem, equals(0));
// Skip to the item at index 2.
await tester.tap(find.text('2'));
await tester.pump();
await tester.pump(tapScrollDuration + infinitesimalPause);
expect(selectedItem, equals(2));
});
}