// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import '../rendering/rendering_tester.dart'; import '../widgets/semantics_tester.dart'; class SpyFixedExtentScrollController extends FixedExtentScrollController { /// Override for test visibility only. @override bool get hasListeners => super.hasListeners; } void main() { testWidgets('Picker respects theme styling', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( home: Align( alignment: Alignment.topLeft, child: SizedBox( height: 300.0, width: 300.0, child: CupertinoPicker( itemExtent: 50.0, onSelectedItemChanged: (_) {}, children: List.generate(3, (int index) { return SizedBox(height: 50.0, width: 300.0, child: Text(index.toString())); }), ), ), ), ), ); final RenderParagraph paragraph = tester.renderObject(find.text('1')); expect(paragraph.text.style!.color, isSameColorAs(CupertinoColors.black)); expect( paragraph.text.style!.copyWith(color: CupertinoColors.black), const TextStyle( inherit: false, fontFamily: 'CupertinoSystemDisplay', fontSize: 21.0, fontWeight: FontWeight.w400, letterSpacing: -0.6, color: CupertinoColors.black, ), ); }); testWidgets('Picker semantics', (WidgetTester tester) async { final semantics = SemanticsTester(tester); await tester.pumpWidget( CupertinoApp( home: SizedBox( height: 300.0, width: 300.0, child: CupertinoPicker( itemExtent: 50.0, onSelectedItemChanged: (_) {}, children: List.generate(13, (int index) { return SizedBox(height: 50.0, width: 300.0, child: Text(index.toString())); }), ), ), ), ); expect( semantics, includesNodeWith( value: '0', increasedValue: '1', actions: [SemanticsAction.increase], ), ); final hourListController = tester.widget(find.byType(ListWheelScrollView)).controller! as FixedExtentScrollController; hourListController.jumpToItem(11); await tester.pumpAndSettle(); expect( semantics, includesNodeWith( value: '11', increasedValue: '12', decreasedValue: '10', actions: [SemanticsAction.increase, SemanticsAction.decrease], ), ); semantics.dispose(); }); testWidgets('Picker semantics excludes current item with empty label', ( WidgetTester tester, ) async { // When the current item has an empty label (e.g., wrapped with ExcludeSemantics), // the picker should not set any value, increasedValue, decreasedValue, or actions. final semantics = SemanticsTester(tester); final controller = FixedExtentScrollController(initialItem: 1); addTearDown(controller.dispose); await tester.pumpWidget( CupertinoApp( home: SizedBox( height: 300.0, width: 300.0, child: CupertinoPicker( scrollController: controller, itemExtent: 50.0, onSelectedItemChanged: (_) {}, children: const [ Text('0'), // Item at index 1 is excluded from semantics (simulating a disabled item). ExcludeSemantics(child: Text('1')), Text('2'), ], ), ), ), ); // When the current item (index 1) has an empty label due to ExcludeSemantics, // the picker should not have any value or actions set. expect(semantics, isNot(includesNodeWith(value: '1'))); // Also verify that no increase/decrease actions are set for this item. expect( semantics, isNot(includesNodeWith(actions: [SemanticsAction.increase])), ); expect( semantics, isNot(includesNodeWith(actions: [SemanticsAction.decrease])), ); // Scroll to item 0 which has a valid label. controller.jumpToItem(0); await tester.pumpAndSettle(); // Now the picker should have value '0' but no increase action // because the next item (1) has an empty label. expect(semantics, includesNodeWith(value: '0')); expect( semantics, isNot(includesNodeWith(value: '0', actions: [SemanticsAction.increase])), ); // Scroll to item 2 which has a valid label. controller.jumpToItem(2); await tester.pumpAndSettle(); // Now the picker should have value '2' but no decrease action // because the previous item (1) has an empty label. expect(semantics, includesNodeWith(value: '2')); expect( semantics, isNot(includesNodeWith(value: '2', actions: [SemanticsAction.decrease])), ); semantics.dispose(); }); group('layout', () { // Regression test for https://github.com/flutter/flutter/issues/22999 testWidgets('CupertinoPicker.builder test', (WidgetTester tester) async { Widget buildFrame(int childCount) { return Directionality( textDirection: TextDirection.ltr, child: CupertinoPicker.builder( itemExtent: 50.0, onSelectedItemChanged: (_) {}, itemBuilder: (BuildContext context, int index) { return Text('$index'); }, childCount: childCount, ), ); } await tester.pumpWidget(buildFrame(1)); expect(tester.renderObject(find.text('0')).attached, true); await tester.pumpWidget(buildFrame(2)); expect(tester.renderObject(find.text('0')).attached, true); expect(tester.renderObject(find.text('1')).attached, true); }); testWidgets('selected item is in the middle', (WidgetTester tester) async { final controller = FixedExtentScrollController(initialItem: 1); addTearDown(controller.dispose); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Align( alignment: Alignment.topLeft, child: SizedBox( height: 300.0, width: 300.0, child: CupertinoPicker( scrollController: controller, itemExtent: 50.0, onSelectedItemChanged: (_) {}, children: List.generate(3, (int index) { return SizedBox(height: 50.0, width: 300.0, child: Text(index.toString())); }), ), ), ), ), ); expect(tester.getTopLeft(find.widgetWithText(SizedBox, '1').first), const Offset(0.0, 125.0)); controller.jumpToItem(0); await tester.pump(); expect( tester.getTopLeft(find.widgetWithText(SizedBox, '1').first), offsetMoreOrLessEquals(const Offset(0.0, 170.0), epsilon: 0.5), ); expect(tester.getTopLeft(find.widgetWithText(SizedBox, '0').first), const Offset(0.0, 125.0)); }); }); testWidgets('picker dark mode', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( theme: const CupertinoThemeData(brightness: Brightness.light), home: Align( alignment: Alignment.topLeft, child: SizedBox( height: 300.0, width: 300.0, child: CupertinoPicker( backgroundColor: const CupertinoDynamicColor.withBrightness( color: Color( 0xFF123456, ), // Set alpha channel to FF to disable under magnifier painting. darkColor: Color(0xFF654321), ), itemExtent: 15.0, children: const [Text('1'), Text('1')], onSelectedItemChanged: (int i) {}, ), ), ), ), ); expect( find.byType(CupertinoPicker), paints..rsuperellipse(color: const Color.fromARGB(30, 118, 118, 128)), ); expect(find.byType(CupertinoPicker), paints..rect(color: const Color(0xFF123456))); await tester.pumpWidget( CupertinoApp( theme: const CupertinoThemeData(brightness: Brightness.dark), home: Align( alignment: Alignment.topLeft, child: SizedBox( height: 300.0, width: 300.0, child: CupertinoPicker( backgroundColor: const CupertinoDynamicColor.withBrightness( color: Color(0xFF123456), darkColor: Color(0xFF654321), ), itemExtent: 15.0, children: const [Text('1'), Text('1')], onSelectedItemChanged: (int i) {}, ), ), ), ), ); expect( find.byType(CupertinoPicker), paints..rsuperellipse(color: const Color.fromARGB(61, 118, 118, 128)), ); expect(find.byType(CupertinoPicker), paints..rect(color: const Color(0xFF654321))); }); testWidgets('picker selectionOverlay', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( theme: const CupertinoThemeData(brightness: Brightness.light), home: Align( alignment: Alignment.topLeft, child: SizedBox( height: 300.0, width: 300.0, child: CupertinoPicker( itemExtent: 15.0, onSelectedItemChanged: (int i) {}, selectionOverlay: const CupertinoPickerDefaultSelectionOverlay( background: Color(0x12345678), ), children: const [Text('1'), Text('1')], ), ), ), ), ); expect(find.byType(CupertinoPicker), paints..rsuperellipse(color: const Color(0x12345678))); }); testWidgets('CupertinoPicker.selectionOverlay is nullable', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( theme: const CupertinoThemeData(brightness: Brightness.light), home: Align( alignment: Alignment.topLeft, child: SizedBox( height: 300.0, width: 300.0, child: CupertinoPicker( itemExtent: 15.0, onSelectedItemChanged: (int i) {}, selectionOverlay: null, children: const [Text('1'), Text('1')], ), ), ), ), ); expect(find.byType(CupertinoPicker), isNot(paints..rsuperellipse())); }); group('scroll', () { testWidgets( 'scrolling calls onSelectedItemChanged and triggers haptic feedback when scroll passes middle of item', (WidgetTester tester) async { final selectedItems = []; final systemCalls = []; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( MethodCall methodCall, ) async { systemCalls.add(methodCall); return null; }); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: CupertinoPicker( itemExtent: 100.0, onSelectedItemChanged: (int index) { selectedItems.add(index); }, children: List.generate(100, (int index) { return Center( child: SizedBox(width: 400.0, height: 100.0, child: Text(index.toString())), ); }), ), ), ); // Drag to almost the middle of the next item. await tester.drag( find.text('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, hasLength(2)); // Check that the haptic feedback and ticking sound were triggered. expect( systemCalls[0], isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.selectionClick'), ); expect(systemCalls[1], isMethodCall('SystemSound.play', arguments: 'SystemSoundType.tick')); // Overscroll a little to pass the middle of the item. await tester.drag( find.text('0'), const Offset(0.0, 110.0), warnIfMissed: false, ); // has an IgnorePointer expect(selectedItems, [1, 0]); expect(systemCalls, hasLength(4)); expect( systemCalls[2], isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.selectionClick'), ); expect(systemCalls[3], isMethodCall('SystemSound.play', arguments: 'SystemSoundType.tick')); }, variant: TargetPlatformVariant.only(TargetPlatform.iOS), ); testWidgets('scrolling with new behavior calls onSelectedItemChanged only when scroll ends', ( WidgetTester tester, ) async { final selectedItems = []; final systemCalls = []; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( MethodCall methodCall, ) async { systemCalls.add(methodCall); return null; }); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: CupertinoPicker( itemExtent: 100.0, changeReportingBehavior: ChangeReportingBehavior.onScrollEnd, onSelectedItemChanged: (int index) { selectedItems.add(index); }, children: List.generate(100, (int index) { return Center( child: SizedBox(width: 400.0, height: 100.0, child: Text(index.toString())), ); }), ), ), ); final Offset initialOffset = tester.getTopLeft(find.text('0')); // Drag to almost the middle of the next item. final TestGesture scrollGesture = await tester.startGesture(initialOffset); // Item 0 is still closest to the center. No updates. await scrollGesture.moveBy(const Offset(0.0, -49.0)); expect(selectedItems.isEmpty, true); // Now item 1 is closest to the center. await scrollGesture.moveBy(const Offset(0.0, -1.0)); expect(selectedItems, []); // Now item 1 is still closest to the center for another full itemExtent (100px). await scrollGesture.moveBy(const Offset(0.0, -99.0)); expect(selectedItems, []); await scrollGesture.moveBy(const Offset(0.0, -1.0)); await scrollGesture.up(); await tester.pumpAndSettle(); expect(selectedItems, [2]); await scrollGesture.down(initialOffset); await scrollGesture.moveBy(const Offset(0.0, 100.0)); expect(selectedItems, [2]); await scrollGesture.up(); expect(selectedItems, [2, 1]); }); testWidgets( 'does not trigger haptics or sounds when scrolling by tapping on the item', (WidgetTester tester) async { final selectedItems = []; final systemCalls = []; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( MethodCall methodCall, ) async { systemCalls.add(methodCall); return null; }); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: CupertinoPicker( itemExtent: 100.0, onSelectedItemChanged: (int index) { selectedItems.add(index); }, children: List.generate(100, (int index) { return Center( child: SizedBox(width: 400.0, height: 100.0, child: Text(index.toString())), ); }), ), ), ); await tester.tap(find.text('2'), warnIfMissed: false); // has an IgnorePointer await tester.pumpAndSettle(const Duration(milliseconds: 10)); // Expect that the item changed, but haptics were not triggered. expect(selectedItems, [1, 2]); expect(systemCalls, isEmpty); await tester.drag(find.text('2'), const Offset(0.0, -30.0), warnIfMissed: false); await tester.pumpAndSettle(const Duration(milliseconds: 10)); // Expect that moving within the item does not trigger haptics after animating scroll. expect(selectedItems, [1, 2]); expect(systemCalls, isEmpty); }, variant: TargetPlatformVariant.only(TargetPlatform.iOS), ); testWidgets( 'do not trigger haptic or sounds on non-iOS devices', (WidgetTester tester) async { final selectedItems = []; final systemCalls = []; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( MethodCall methodCall, ) async { systemCalls.add(methodCall); return null; }); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: CupertinoPicker( itemExtent: 100.0, onSelectedItemChanged: (int index) { selectedItems.add(index); }, children: List.generate(100, (int index) { return Center( child: SizedBox(width: 400.0, height: 100.0, child: Text(index.toString())), ); }), ), ), ); await tester.drag( find.text('0'), 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); }, variant: TargetPlatformVariant( TargetPlatform.values .where((TargetPlatform platform) => platform != TargetPlatform.iOS) .toSet(), ), ); testWidgets( 'a drag in between items settles back', (WidgetTester tester) async { final controller = FixedExtentScrollController(initialItem: 10); addTearDown(controller.dispose); final selectedItems = []; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: CupertinoPicker( scrollController: controller, itemExtent: 100.0, onSelectedItemChanged: (int index) { selectedItems.add(index); }, children: List.generate(100, (int index) { return Center( child: SizedBox(width: 400.0, height: 100.0, child: Text(index.toString())), ); }), ), ), ); // Drag it by a bit but not enough to move to the next item. await tester.drag( find.text('10'), const Offset(0.0, 30.0), pointer: 1, touchSlopY: 0.0, warnIfMissed: false, ); // has an IgnorePointer // The item that was in the center now moved a bit. expect(tester.getTopLeft(find.widgetWithText(SizedBox, '10')), const Offset(200.0, 250.0)); await tester.pumpAndSettle(); expect( tester.getTopLeft(find.widgetWithText(SizedBox, '10')).dy, moreOrLessEquals(250.0, epsilon: 0.5), ); expect(selectedItems.isEmpty, true); // Drag it by enough to move to the next item. await tester.drag( find.text('10'), const Offset(0.0, 70.0), pointer: 1, touchSlopY: 0.0, warnIfMissed: false, ); // has an IgnorePointer await tester.pumpAndSettle(); expect( tester.getTopLeft(find.widgetWithText(SizedBox, '10')).dy, // It's down by 100.0 now. moreOrLessEquals(340.0, epsilon: 0.5), ); expect(selectedItems, [9]); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS, }), ); testWidgets( 'a big fling that overscrolls springs back', (WidgetTester tester) async { final controller = FixedExtentScrollController(initialItem: 10); addTearDown(controller.dispose); final selectedItems = []; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: CupertinoPicker( scrollController: controller, itemExtent: 100.0, onSelectedItemChanged: (int index) { selectedItems.add(index); }, children: List.generate(100, (int index) { return Center( child: SizedBox(width: 400.0, height: 100.0, child: Text(index.toString())), ); }), ), ), ); // A wild throw appears. await tester.fling( find.text('10'), const Offset(0.0, 10000.0), 1000.0, warnIfMissed: false, // has an IgnorePointer ); if (debugDefaultTargetPlatformOverride == TargetPlatform.iOS) { // Should have been flung far enough that even the first item goes off // screen and gets removed. expect(find.widgetWithText(SizedBox, '0').evaluate().isEmpty, true); } expect( selectedItems, // This specific throw was fast enough that each scroll update landed // on every second item. [8, 6, 4, 2, 0], ); // Let it spring back. await tester.pumpAndSettle(); expect( tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy, // Should have sprung back to the middle now. moreOrLessEquals(250.0), ); expect( selectedItems, // Falling back to 0 shouldn't produce more callbacks. [8, 6, 4, 2, 0], ); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS, }), ); }); // TODO(justinmc): Don't test Material interactions in Cupertino tests. // https://github.com/flutter/flutter/issues/177028 testWidgets('Picker adapts to MaterialApp dark mode', (WidgetTester tester) async { Widget buildCupertinoPicker(Brightness brightness) { return MaterialApp( theme: ThemeData(brightness: brightness), home: Align( alignment: Alignment.topLeft, child: SizedBox( height: 300.0, width: 300.0, child: CupertinoPicker( itemExtent: 50.0, onSelectedItemChanged: (_) {}, children: List.generate(3, (int index) { return SizedBox(height: 50.0, width: 300.0, child: Text(index.toString())); }), ), ), ), ); } // CupertinoPicker with light theme. await tester.pumpWidget(buildCupertinoPicker(Brightness.light)); RenderParagraph paragraph = tester.renderObject(find.text('1')); expect(paragraph.text.style!.color, CupertinoColors.label); // Text style should not return unresolved color. expect(paragraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); // CupertinoPicker with dark theme. await tester.pumpWidget(buildCupertinoPicker(Brightness.dark)); paragraph = tester.renderObject(find.text('1')); expect(paragraph.text.style!.color, CupertinoColors.label); // Text style should not return unresolved color. expect(paragraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); }); group('CupertinoPickerDefaultSelectionOverlay', () { testWidgets('should be using directional decoration', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( theme: const CupertinoThemeData(brightness: Brightness.light), home: CupertinoPicker( itemExtent: 15.0, onSelectedItemChanged: (int i) {}, selectionOverlay: const CupertinoPickerDefaultSelectionOverlay( background: Color(0x12345678), ), children: const [Text('1'), Text('1')], ), ), ); final Finder selectionContainer = find.byType(Container); final Container container = tester.firstWidget(selectionContainer); final EdgeInsetsGeometry? margin = container.margin; final BorderRadiusGeometry? borderRadius = ((container.decoration as ShapeDecoration?)?.shape as RoundedSuperellipseBorder?) ?.borderRadius; expect(margin, isA()); expect(borderRadius, isA()); }); }); testWidgets('Scroll controller is detached upon dispose', (WidgetTester tester) async { final controller = SpyFixedExtentScrollController(); addTearDown(controller.dispose); expect(controller.hasListeners, false); expect(controller.positions.length, 0); await tester.pumpWidget( CupertinoApp( home: Align( alignment: Alignment.topLeft, child: Center( child: CupertinoPicker( scrollController: controller, itemExtent: 50.0, onSelectedItemChanged: (_) {}, children: List.generate(3, (int index) { return SizedBox(width: 300.0, child: Text(index.toString())); }), ), ), ), ), ); expect(controller.hasListeners, true); expect(controller.positions.length, 1); await tester.pumpWidget(const SizedBox.expand()); expect(controller.hasListeners, false); expect(controller.positions.length, 0); }); testWidgets('Registers taps and does not crash with certain diameterRatio', ( WidgetTester tester, ) async { // Regression test for https://github.com/flutter/flutter/issues/126491 final children = List.generate(100, (int index) => index); final paintedChildren = []; final tappedChildren = {}; await tester.pumpWidget( CupertinoApp( home: Align( alignment: Alignment.topLeft, child: Center( child: SizedBox( height: 120, child: CupertinoPicker( itemExtent: 55, diameterRatio: 0.9, onSelectedItemChanged: (int index) {}, children: children .map( (int index) => GestureDetector( key: ValueKey(index), onTap: () { tappedChildren.add(index); }, child: SizedBox( width: 55, height: 55, child: CustomPaint( painter: TestCallbackPainter( onPaint: () { paintedChildren.add(index); }, ), ), ), ), ) .toList(), ), ), ), ), ), ); // Children are painted two times for whatever reason expect(paintedChildren, [0, 1, 0, 1]); // Expect hitting 0 and 1, which are painted await tester.tap(find.byKey(const ValueKey(0))); expect(tappedChildren, const [0]); await tester.tap(find.byKey(const ValueKey(1))); expect(tappedChildren, const [0, 1]); // The third child is not painted, so is not hit await tester.tap(find.byKey(const ValueKey(2)), warnIfMissed: false); expect(tappedChildren, const [0, 1]); }); testWidgets('Tapping on child in a CupertinoPicker selects that child', ( WidgetTester tester, ) async { var selectedItem = 0; const tapScrollDuration = Duration(milliseconds: 300); // The tap animation is set to 300ms, but add an extra 1µs to complete the scroll animation. const infinitesimalPause = Duration(microseconds: 1); await tester.pumpWidget( CupertinoApp( home: CupertinoPicker( itemExtent: 10.0, onSelectedItemChanged: (int i) { selectedItem = i; }, children: const [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)); }); testWidgets('CupertinoPickerDefaultSelectionOverlay does not crash at zero area', ( WidgetTester tester, ) async { tester.view.physicalSize = Size.zero; addTearDown(tester.view.reset); await tester.pumpWidget( const CupertinoApp(home: Center(child: CupertinoPickerDefaultSelectionOverlay())), ); expect(tester.getSize(find.byType(CupertinoPickerDefaultSelectionOverlay)), Size.zero); }); testWidgets('CupertinoPicker does not crash at zero area', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( home: Center( child: SizedBox.shrink( child: CupertinoPicker( itemExtent: 2.0, onSelectedItemChanged: (_) {}, children: const [Text('X'), Text('Y')], ), ), ), ), ); expect(tester.getSize(find.byType(CupertinoPicker)), Size.zero); }); }