| // 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_test/flutter_test.dart'; |
| |
| import '../widgets/editable_text_utils.dart' show textOffsetToPosition; |
| |
| // These constants are copied from cupertino/text_selection_toolbar.dart. |
| const double _kArrowScreenPadding = 26.0; |
| const double _kToolbarContentDistance = 8.0; |
| const double _kToolbarHeight = 43.0; |
| |
| // A custom text selection menu that just displays a single custom button. |
| class _CustomCupertinoTextSelectionControls extends CupertinoTextSelectionControls { |
| @override |
| Widget buildToolbar( |
| BuildContext context, |
| Rect globalEditableRegion, |
| double textLineHeight, |
| Offset selectionMidpoint, |
| List<TextSelectionPoint> endpoints, |
| TextSelectionDelegate delegate, |
| ValueNotifier<ClipboardStatus>? clipboardStatus, |
| Offset? lastSecondaryTapDownPosition, |
| ) { |
| final EdgeInsets mediaQueryPadding = MediaQuery.paddingOf(context); |
| final double anchorX = (selectionMidpoint.dx + globalEditableRegion.left).clamp( |
| _kArrowScreenPadding + mediaQueryPadding.left, |
| MediaQuery.sizeOf(context).width - mediaQueryPadding.right - _kArrowScreenPadding, |
| ); |
| final Offset anchorAbove = Offset( |
| anchorX, |
| endpoints.first.point.dy - textLineHeight + globalEditableRegion.top, |
| ); |
| final Offset anchorBelow = Offset( |
| anchorX, |
| endpoints.last.point.dy + globalEditableRegion.top, |
| ); |
| |
| return CupertinoTextSelectionToolbar( |
| anchorAbove: anchorAbove, |
| anchorBelow: anchorBelow, |
| children: <Widget>[ |
| CupertinoTextSelectionToolbarButton( |
| onPressed: () {}, |
| child: const Text('Custom button'), |
| ), |
| ], |
| ); |
| } |
| } |
| |
| class TestBox extends SizedBox { |
| const TestBox({super.key}) : super(width: itemWidth, height: itemHeight); |
| |
| static const double itemHeight = 44.0; |
| static const double itemWidth = 100.0; |
| } |
| |
| const CupertinoDynamicColor _kToolbarBackgroundColor = CupertinoDynamicColor.withBrightness( |
| color: Color(0xEBF7F7F7), |
| darkColor: Color(0xEB202020), |
| ); |
| |
| void main() { |
| TestWidgetsFlutterBinding.ensureInitialized(); |
| |
| // Find by a runtimeType String, including private types. |
| Finder findPrivate(String type) { |
| return find.descendant( |
| of: find.byType(CupertinoApp), |
| matching: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == type), |
| ); |
| } |
| |
| // Finding CupertinoTextSelectionToolbar won't give you the position as the user sees |
| // it because it's a full-sized Stack at the top level. This method finds the |
| // visible part of the toolbar for use in measurements. |
| Finder findToolbar() => findPrivate('_CupertinoTextSelectionToolbarContent'); |
| |
| Finder findOverflowNextButton() => find.text('▶'); |
| Finder findOverflowBackButton() => find.text('◀'); |
| |
| testWidgets('paginates children if they overflow', (WidgetTester tester) async { |
| late StateSetter setState; |
| final List<Widget> children = List<Widget>.generate(7, (int i) => const TestBox()); |
| await tester.pumpWidget( |
| CupertinoApp( |
| home: Center( |
| child: StatefulBuilder( |
| builder: (BuildContext context, StateSetter setter) { |
| setState = setter; |
| return CupertinoTextSelectionToolbar( |
| anchorAbove: const Offset(50.0, 100.0), |
| anchorBelow: const Offset(50.0, 200.0), |
| children: children, |
| ); |
| }, |
| ), |
| ), |
| ), |
| ); |
| |
| // All children fit on the screen, so they are all rendered. |
| expect(find.byType(TestBox), findsNWidgets(children.length)); |
| expect(findOverflowNextButton(), findsNothing); |
| expect(findOverflowBackButton(), findsNothing); |
| |
| // Adding one more child makes the children overflow. |
| setState(() { |
| children.add( |
| const TestBox(), |
| ); |
| }); |
| await tester.pumpAndSettle(); |
| expect(find.byType(TestBox), findsNWidgets(children.length - 1)); |
| expect(findOverflowNextButton(), findsOneWidget); |
| expect(findOverflowBackButton(), findsNothing); |
| |
| // Tap the overflow next button to show the next page of children. |
| await tester.tap(findOverflowNextButton()); |
| await tester.pumpAndSettle(); |
| expect(find.byType(TestBox), findsNWidgets(1)); |
| expect(findOverflowNextButton(), findsOneWidget); |
| expect(findOverflowBackButton(), findsOneWidget); |
| |
| // Tapping the overflow next button again does nothing because it is |
| // disabled and there are no more children to display. |
| await tester.tap(findOverflowNextButton()); |
| await tester.pumpAndSettle(); |
| expect(find.byType(TestBox), findsNWidgets(1)); |
| expect(findOverflowNextButton(), findsOneWidget); |
| expect(findOverflowBackButton(), findsOneWidget); |
| |
| // Tap the overflow back button to go back to the first page. |
| await tester.tap(findOverflowBackButton()); |
| await tester.pumpAndSettle(); |
| expect(find.byType(TestBox), findsNWidgets(7)); |
| expect(findOverflowNextButton(), findsOneWidget); |
| expect(findOverflowBackButton(), findsNothing); |
| |
| // Adding 7 more children overflows onto a third page. |
| setState(() { |
| children.add(const TestBox()); |
| children.add(const TestBox()); |
| children.add(const TestBox()); |
| children.add(const TestBox()); |
| children.add(const TestBox()); |
| children.add(const TestBox()); |
| }); |
| await tester.pumpAndSettle(); |
| expect(find.byType(TestBox), findsNWidgets(7)); |
| expect(findOverflowNextButton(), findsOneWidget); |
| expect(findOverflowBackButton(), findsNothing); |
| |
| // Tap the overflow next button to show the second page of children. |
| await tester.tap(findOverflowNextButton()); |
| await tester.pumpAndSettle(); |
| // With the back button, only six children fit on this page. |
| expect(find.byType(TestBox), findsNWidgets(6)); |
| expect(findOverflowNextButton(), findsOneWidget); |
| expect(findOverflowBackButton(), findsOneWidget); |
| |
| // Tap the overflow next button again to show the third page of children. |
| await tester.tap(findOverflowNextButton()); |
| await tester.pumpAndSettle(); |
| expect(find.byType(TestBox), findsNWidgets(1)); |
| expect(findOverflowNextButton(), findsOneWidget); |
| expect(findOverflowBackButton(), findsOneWidget); |
| |
| // Tap the overflow back button to go back to the second page. |
| await tester.tap(findOverflowBackButton()); |
| await tester.pumpAndSettle(); |
| expect(find.byType(TestBox), findsNWidgets(6)); |
| expect(findOverflowNextButton(), findsOneWidget); |
| expect(findOverflowBackButton(), findsOneWidget); |
| |
| // Tap the overflow back button to go back to the first page. |
| await tester.tap(findOverflowBackButton()); |
| await tester.pumpAndSettle(); |
| expect(find.byType(TestBox), findsNWidgets(7)); |
| expect(findOverflowNextButton(), findsOneWidget); |
| expect(findOverflowBackButton(), findsNothing); |
| }, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web. |
| |
| testWidgets('does not paginate if children fit with zero margin', (WidgetTester tester) async { |
| final List<Widget> children = List<Widget>.generate(7, (int i) => const TestBox()); |
| final double spacerWidth = 1.0 / tester.view.devicePixelRatio; |
| final double dividerWidth = 1.0 / tester.view.devicePixelRatio; |
| const double borderRadius = 8.0; // Should match _kToolbarBorderRadius |
| final double width = 7 * TestBox.itemWidth + 6 * (dividerWidth + 2 * spacerWidth) + 2 * borderRadius; |
| await tester.pumpWidget( |
| CupertinoApp( |
| home: Center( |
| child: SizedBox( |
| width: width, |
| child: CupertinoTextSelectionToolbar( |
| anchorAbove: const Offset(50.0, 100.0), |
| anchorBelow: const Offset(50.0, 200.0), |
| children: children, |
| ), |
| ), |
| ), |
| ), |
| ); |
| |
| // All children fit on the screen, so they are all rendered. |
| expect(find.byType(TestBox), findsNWidgets(children.length)); |
| expect(findOverflowNextButton(), findsNothing); |
| expect(findOverflowBackButton(), findsNothing); |
| }, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web. |
| |
| testWidgets('positions itself at anchorAbove if it fits', (WidgetTester tester) async { |
| late StateSetter setState; |
| const double height = _kToolbarHeight; |
| const double anchorBelowY = 500.0; |
| double anchorAboveY = 0.0; |
| const double paddingAbove = 12.0; |
| |
| await tester.pumpWidget( |
| CupertinoApp( |
| home: Center( |
| child: StatefulBuilder( |
| builder: (BuildContext context, StateSetter setter) { |
| setState = setter; |
| final MediaQueryData data = MediaQuery.of(context); |
| // Add some custom vertical padding to make this test more strict. |
| // By default in the testing environment, _kToolbarContentDistance |
| // and the built-in padding from CupertinoApp can end up canceling |
| // each other out. |
| return MediaQuery( |
| data: data.copyWith( |
| padding: data.viewPadding.copyWith( |
| top: paddingAbove, |
| ), |
| ), |
| child: CupertinoTextSelectionToolbar( |
| anchorAbove: Offset(50.0, anchorAboveY), |
| anchorBelow: const Offset(50.0, anchorBelowY), |
| children: <Widget>[ |
| Container(color: const Color(0xffff0000), width: 50.0, height: height), |
| Container(color: const Color(0xff00ff00), width: 50.0, height: height), |
| Container(color: const Color(0xff0000ff), width: 50.0, height: height), |
| ], |
| ), |
| ); |
| }, |
| ), |
| ), |
| ), |
| ); |
| |
| // When the toolbar doesn't fit above aboveAnchor, it positions itself below |
| // belowAnchor. |
| double toolbarY = tester.getTopLeft(findToolbar()).dy; |
| expect(toolbarY, equals(anchorBelowY + _kToolbarContentDistance)); |
| expect(find.byType(CustomSingleChildLayout), findsOneWidget); |
| final CustomSingleChildLayout layout = tester.widget(find.byType(CustomSingleChildLayout)); |
| final TextSelectionToolbarLayoutDelegate delegate = layout.delegate as TextSelectionToolbarLayoutDelegate; |
| expect(delegate.anchorBelow.dy, anchorBelowY - paddingAbove); |
| |
| // Even when it barely doesn't fit. |
| setState(() { |
| anchorAboveY = 70.0; |
| }); |
| await tester.pump(); |
| toolbarY = tester.getTopLeft(findToolbar()).dy; |
| expect(toolbarY, equals(anchorBelowY + _kToolbarContentDistance)); |
| |
| // When it does fit above aboveAnchor, it positions itself there. |
| setState(() { |
| anchorAboveY = 80.0; |
| }); |
| await tester.pump(); |
| toolbarY = tester.getTopLeft(findToolbar()).dy; |
| expect(toolbarY, equals(anchorAboveY - height - _kToolbarContentDistance)); |
| }, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web. |
| |
| testWidgets('can create and use a custom toolbar', (WidgetTester tester) async { |
| final TextEditingController controller = TextEditingController( |
| text: 'Select me custom menu', |
| ); |
| await tester.pumpWidget( |
| CupertinoApp( |
| home: Center( |
| child: CupertinoTextField( |
| controller: controller, |
| selectionControls: _CustomCupertinoTextSelectionControls(), |
| ), |
| ), |
| ), |
| ); |
| |
| // The selection menu is not initially shown. |
| expect(find.text('Custom button'), findsNothing); |
| |
| // Long press on "custom" to select it. |
| final Offset customPos = textOffsetToPosition(tester, 11); |
| final TestGesture gesture = await tester.startGesture(customPos, pointer: 7); |
| await tester.pump(const Duration(seconds: 2)); |
| await gesture.up(); |
| await tester.pump(); |
| |
| // The custom selection menu is shown. |
| expect(find.text('Custom button'), findsOneWidget); |
| expect(find.text('Cut'), findsNothing); |
| expect(find.text('Copy'), findsNothing); |
| expect(find.text('Paste'), findsNothing); |
| expect(find.text('Select all'), findsNothing); |
| }, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web. |
| |
| for (final Brightness? themeBrightness in <Brightness?>[...Brightness.values, null]) { |
| for (final Brightness? mediaBrightness in <Brightness?>[...Brightness.values, null]) { |
| testWidgets('draws dark buttons in dark mode and light button in light mode when theme is $themeBrightness and MediaQuery is $mediaBrightness', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| CupertinoApp( |
| theme: CupertinoThemeData( |
| brightness: themeBrightness, |
| ), |
| home: Center( |
| child: Builder( |
| builder: (BuildContext context) { |
| return MediaQuery( |
| data: MediaQuery.of(context).copyWith(platformBrightness: mediaBrightness), |
| child: CupertinoTextSelectionToolbar( |
| anchorAbove: const Offset(100.0, 0.0), |
| anchorBelow: const Offset(100.0, 0.0), |
| children: <Widget>[ |
| CupertinoTextSelectionToolbarButton.text( |
| onPressed: () {}, |
| text: 'Button', |
| ), |
| ], |
| ), |
| ); |
| }, |
| ), |
| ), |
| ), |
| ); |
| |
| final Finder buttonFinder = find.byType(CupertinoButton); |
| expect(buttonFinder, findsOneWidget); |
| |
| final Finder decorationFinder = find.descendant( |
| of: find.byType(CupertinoButton), |
| matching: find.byType(DecoratedBox) |
| ); |
| expect(decorationFinder, findsOneWidget); |
| final DecoratedBox decoratedBox = tester.widget(decorationFinder); |
| final BoxDecoration boxDecoration = decoratedBox.decoration as BoxDecoration; |
| |
| // Theme brightness is preferred, otherwise MediaQuery brightness is |
| // used. If both are null, defaults to light. |
| late final Brightness effectiveBrightness; |
| if (themeBrightness != null) { |
| effectiveBrightness = themeBrightness; |
| } else { |
| effectiveBrightness = mediaBrightness ?? Brightness.light; |
| } |
| |
| expect( |
| boxDecoration.color!.value, |
| effectiveBrightness == Brightness.dark |
| ? _kToolbarBackgroundColor.darkColor.value |
| : _kToolbarBackgroundColor.color.value, |
| ); |
| }, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web. |
| } |
| } |
| } |