From acbca7018170990f2658832c707a5968dabf8f02 Mon Sep 17 00:00:00 2001 From: Alex Medinsh Date: Thu, 29 May 2025 17:13:36 +0300 Subject: [PATCH] trigger haptics in the middle --- .../flutter/lib/src/cupertino/picker.dart | 56 +++++++++++++------ .../flutter/test/cupertino/picker_test.dart | 19 +++++-- 2 files changed, 53 insertions(+), 22 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/picker.dart b/packages/flutter/lib/src/cupertino/picker.dart index 03f867fc9391a..0a4345b288a8c 100644 --- a/packages/flutter/lib/src/cupertino/picker.dart +++ b/packages/flutter/lib/src/cupertino/picker.dart @@ -219,12 +219,16 @@ class _CupertinoPickerState extends State { int? _lastHapticIndex; FixedExtentScrollController? _controller; + FixedExtentScrollController get _effectiveController => widget.scrollController ?? _controller!; + @override void initState() { super.initState(); if (widget.scrollController == null) { _controller = FixedExtentScrollController(); } + + _effectiveController.addListener(_handleScroll); } @override @@ -233,42 +237,59 @@ class _CupertinoPickerState extends State { if (widget.scrollController != null && oldWidget.scrollController == null) { _controller?.dispose(); _controller = null; + widget.scrollController!.addListener(_handleScroll); } else if (widget.scrollController == null && oldWidget.scrollController != null) { assert(_controller == null); + oldWidget.scrollController!.removeListener(_handleScroll); _controller = FixedExtentScrollController(); + _controller!.addListener(_handleScroll); } } @override void dispose() { _controller?.dispose(); + if (widget.scrollController != null) { + widget.scrollController!.removeListener(_handleScroll); + } super.dispose(); } - void _handleSelectedItemChanged(int index) { - // Only the haptic engine hardware on iOS devices would produce the - // intended effects. - final bool hasSuitableHapticHardware; + void _handleHapticFeedback(int index) { + // Skip haptic feedback on the first item. + if (_lastHapticIndex == null) { + _lastHapticIndex = index; + return; + } switch (defaultTargetPlatform) { case TargetPlatform.iOS: - hasSuitableHapticHardware = true; + if (index != _lastHapticIndex) { + _lastHapticIndex = index; + HapticFeedback.selectionClick(); + } case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.macOS: case TargetPlatform.windows: - hasSuitableHapticHardware = false; - } - if (hasSuitableHapticHardware && index != _lastHapticIndex) { - _lastHapticIndex = index; - HapticFeedback.selectionClick(); + // No haptic feedback on these platforms. + return; } + } - widget.onSelectedItemChanged?.call(index); + void _handleScroll() { + final int index = _effectiveController.selectedItem; + + final double currentItemOffset = _effectiveController.offset / widget.itemExtent - index; + // Check if the current scroll offset is passing through the center point of an item + // This happens when the fractional part is very close to 0.0. + if (currentItemOffset.abs() <= 0.1) { + _handleHapticFeedback(index); + } } - void _handleChildTap(int index, FixedExtentScrollController controller) { - controller.animateToItem( + void _handleChildTap(int index) { + _effectiveController.animateToItem( index, duration: _kCupertinoPickerTapToScrollDuration, curve: _kCupertinoPickerTapToScrollCurve, @@ -298,7 +319,6 @@ class _CupertinoPickerState extends State { ); assert(RenderListWheelViewport.defaultPerspective == _kDefaultPerspective); - final FixedExtentScrollController controller = widget.scrollController ?? _controller!; final Widget result = DefaultTextStyle( style: textStyle.copyWith( color: CupertinoDynamicColor.maybeResolve(textStyle.color, context), @@ -307,9 +327,9 @@ class _CupertinoPickerState extends State { children: [ Positioned.fill( child: _CupertinoPickerSemantics( - scrollController: controller, + scrollController: _effectiveController, child: ListWheelScrollView.useDelegate( - controller: controller, + controller: _effectiveController, physics: const FixedExtentScrollPhysics(), diameterRatio: widget.diameterRatio, offAxisFraction: widget.offAxisFraction, @@ -318,11 +338,11 @@ class _CupertinoPickerState extends State { overAndUnderCenterOpacity: _kOverAndUnderCenterOpacity, itemExtent: widget.itemExtent, squeeze: widget.squeeze, - onSelectedItemChanged: _handleSelectedItemChanged, + onSelectedItemChanged: widget.onSelectedItemChanged, dragStartBehavior: DragStartBehavior.down, childDelegate: _CupertinoPickerListWheelChildDelegateWrapper( widget.childDelegate, - onTappedChild: (int index) => _handleChildTap(index, controller), + onTappedChild: _handleChildTap, ), ), ), diff --git a/packages/flutter/test/cupertino/picker_test.dart b/packages/flutter/test/cupertino/picker_test.dart index f670f3c566697..cedac304d9afb 100644 --- a/packages/flutter/test/cupertino/picker_test.dart +++ b/packages/flutter/test/cupertino/picker_test.dart @@ -272,7 +272,7 @@ void main() { group('scroll', () { testWidgets( - 'scrolling calls onSelectedItemChanged and triggers haptic feedback', + 'scrolling calls onSelectedItemChanged and triggers haptic feedback when scroll passes middle of item', (WidgetTester tester) async { final List selectedItems = []; final List systemCalls = []; @@ -300,21 +300,28 @@ void main() { ), ), ); - + // Drag to almost the middle of the next item. await tester.drag( find.text('0'), - const Offset(0.0, -100.0), + const Offset(0.0, -90.0), warnIfMissed: false, ); // has an IgnorePointer + // Expect that the item changed, but haptics were not triggered yet, + // since we are not in the middle of the item. expect(selectedItems, [1]); + expect(systemCalls, isEmpty); + + // Let the scroll settle and end up in the middle of the item. + await tester.pumpAndSettle(); expect( systemCalls.single, isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.selectionClick'), ); + // Overscroll a little to pass the middle of the item. await tester.drag( find.text('0'), - const Offset(0.0, 100.0), + const Offset(0.0, 110.0), warnIfMissed: false, ); // has an IgnorePointer expect(selectedItems, [1, 0]); @@ -362,6 +369,10 @@ void main() { const Offset(0.0, -100.0), warnIfMissed: false, ); // has an IgnorePointer + + // Allow the scroll to settle in the middle of the item. + await tester.pumpAndSettle(); + expect(selectedItems, [1]); expect(systemCalls, isEmpty); },