Skip to content

Trigger CupertinoPicker haptics in the middle of the item #169670

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 38 additions & 18 deletions packages/flutter/lib/src/cupertino/picker.dart
Original file line number Diff line number Diff line change
Expand Up @@ -219,12 +219,16 @@ class _CupertinoPickerState extends State<CupertinoPicker> {
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
Expand All @@ -233,42 +237,59 @@ class _CupertinoPickerState extends State<CupertinoPicker> {
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,
Expand Down Expand Up @@ -298,7 +319,6 @@ class _CupertinoPickerState extends State<CupertinoPicker> {
);

assert(RenderListWheelViewport.defaultPerspective == _kDefaultPerspective);
final FixedExtentScrollController controller = widget.scrollController ?? _controller!;
final Widget result = DefaultTextStyle(
style: textStyle.copyWith(
color: CupertinoDynamicColor.maybeResolve(textStyle.color, context),
Expand All @@ -307,9 +327,9 @@ class _CupertinoPickerState extends State<CupertinoPicker> {
children: <Widget>[
Positioned.fill(
child: _CupertinoPickerSemantics(
scrollController: controller,
scrollController: _effectiveController,
child: ListWheelScrollView.useDelegate(
controller: controller,
controller: _effectiveController,
physics: const FixedExtentScrollPhysics(),
diameterRatio: widget.diameterRatio,
offAxisFraction: widget.offAxisFraction,
Expand All @@ -318,11 +338,11 @@ class _CupertinoPickerState extends State<CupertinoPicker> {
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,
),
),
),
Expand Down
19 changes: 15 additions & 4 deletions packages/flutter/test/cupertino/picker_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> selectedItems = <int>[];
final List<MethodCall> systemCalls = <MethodCall>[];
Expand Down Expand Up @@ -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, <int>[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, <int>[1, 0]);
Expand Down Expand Up @@ -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, <int>[1]);
expect(systemCalls, isEmpty);
},
Expand Down