Implement tap to scroll to item in CupertinoPicker (#153386)
Finishes the work started by @Kavantix in #110733. Fixes https://github.com/flutter/flutter/issues/24089. Before: https://github.com/user-attachments/assets/7ca0d346-7538-44a0-b0b2-41cf96eee6eb After: https://github.com/user-attachments/assets/8da91188-28a0-4738-a216-e03946f00284 Native picker: https://github.com/user-attachments/assets/8662b3c0-0443-4f8d-818b-cabd97d37de8
This commit is contained in:
parent
ba7d7d5bb1
commit
c631ad91a8
@ -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);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user