blob: 1cf20adff4e5cb4567a4174e54b4845b0d4b6cad [file] [log] [blame]
// 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/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../widgets/editable_text_utils.dart' show textOffsetToPosition;
const double _kToolbarContentDistance = 8.0;
// A custom text selection menu that just displays a single custom button.
class _CustomMaterialTextSelectionControls extends MaterialTextSelectionControls {
@override
Widget buildToolbar(
BuildContext context,
Rect globalEditableRegion,
double textLineHeight,
Offset selectionMidpoint,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
ValueListenable<ClipboardStatus>? clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) {
final TextSelectionPoint startTextSelectionPoint = endpoints[0];
final TextSelectionPoint endTextSelectionPoint = endpoints.length > 1
? endpoints[1]
: endpoints[0];
final anchorAbove = Offset(
globalEditableRegion.left + selectionMidpoint.dx,
globalEditableRegion.top +
startTextSelectionPoint.point.dy -
textLineHeight -
_kToolbarContentDistance,
);
final anchorBelow = Offset(
globalEditableRegion.left + selectionMidpoint.dx,
globalEditableRegion.top +
endTextSelectionPoint.point.dy +
TextSelectionToolbar.kToolbarContentDistanceBelow,
);
return TextSelectionToolbar(
anchorAbove: anchorAbove,
anchorBelow: anchorBelow,
children: <Widget>[
TextSelectionToolbarTextButton(
padding: TextSelectionToolbarTextButton.getPadding(0, 1),
onPressed: () {},
child: const Text('Custom button'),
),
],
);
}
}
class TestBox extends SizedBox {
const TestBox({super.key, super.child}) : super(width: itemWidth, height: itemHeight);
static const double itemHeight = 44.0;
static const double itemWidth = 100.0;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// Find by a runtimeType String, including private types.
Finder findPrivate(String type) {
return find.descendant(
of: find.byType(MaterialApp),
matching: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == type),
);
}
// Finding TextSelectionToolbar 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('_TextSelectionToolbarOverflowable');
Finder findOverflowButton() => findPrivate('_TextSelectionToolbarOverflowButton');
testWidgets('puts children in an overflow menu if they overflow', (WidgetTester tester) async {
late StateSetter setState;
final children = List<Widget>.generate(7, (int i) => const TestBox());
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return TextSelectionToolbar(
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(findOverflowButton(), 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(findOverflowButton(), findsOneWidget);
// Tap the overflow button to show the overflow menu.
await tester.tap(findOverflowButton());
await tester.pumpAndSettle();
expect(find.byType(TestBox), findsNWidgets(1));
expect(findOverflowButton(), findsOneWidget);
// Tap the overflow button again to hide the overflow menu.
await tester.tap(findOverflowButton());
await tester.pumpAndSettle();
expect(find.byType(TestBox), findsNWidgets(children.length - 1));
expect(findOverflowButton(), findsOneWidget);
});
testWidgets('positions itself at anchorAbove if it fits', (WidgetTester tester) async {
late StateSetter setState;
const height = 44.0;
const anchorBelowY = 500.0;
var anchorAboveY = 0.0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return TextSelectionToolbar(
anchorAbove: Offset(50.0, anchorAboveY),
anchorBelow: const Offset(50.0, anchorBelowY),
children: <Widget>[
Container(color: Colors.red, width: 50.0, height: height),
Container(color: Colors.green, width: 50.0, height: height),
Container(color: Colors.blue, 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 + TextSelectionToolbar.kToolbarContentDistanceBelow));
// Even when it barely doesn't fit.
setState(() {
anchorAboveY = 60.0;
});
await tester.pump();
toolbarY = tester.getTopLeft(findToolbar()).dy;
expect(toolbarY, equals(anchorBelowY + TextSelectionToolbar.kToolbarContentDistanceBelow));
// When it does fit above aboveAnchor, it positions itself there.
setState(() {
anchorAboveY = 70.0;
});
await tester.pump();
toolbarY = tester.getTopLeft(findToolbar()).dy;
expect(toolbarY, equals(anchorAboveY - height - _kToolbarContentDistance));
});
testWidgets('can create and use a custom toolbar', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SelectableText(
'Select me custom menu',
selectionControls: _CustomMaterialTextSelectionControls(),
),
),
),
),
);
// 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 don't show the toolbar on the web.
for (final colorScheme in <ColorScheme>[ThemeData().colorScheme, ThemeData.dark().colorScheme]) {
testWidgets('default background color', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(colorScheme: colorScheme),
home: Scaffold(
body: Center(
child: TextSelectionToolbar(
anchorAbove: Offset.zero,
anchorBelow: Offset.zero,
children: <Widget>[
TextSelectionToolbarTextButton(
padding: TextSelectionToolbarTextButton.getPadding(0, 1),
onPressed: () {},
child: const Text('Custom button'),
),
],
),
),
),
),
);
Finder findToolbarContainer() {
return find.descendant(
of: find.byWidgetPredicate(
(Widget w) => '${w.runtimeType}' == '_TextSelectionToolbarContainer',
),
matching: find.byType(Material),
);
}
expect(findToolbarContainer(), findsAtLeastNWidgets(1));
final Material toolbarContainer = tester.widget(findToolbarContainer().first);
expect(
toolbarContainer.color,
// The default colors are hardcoded and don't take the default value of
// the theme's surface color.
switch (colorScheme.brightness) {
Brightness.light => const Color(0xffffffff),
Brightness.dark => const Color(0xff424242),
},
);
});
testWidgets('custom background color', (WidgetTester tester) async {
const Color customBackgroundColor = Colors.red;
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(colorScheme: colorScheme.copyWith(surface: customBackgroundColor)),
home: Scaffold(
body: Center(
child: TextSelectionToolbar(
anchorAbove: Offset.zero,
anchorBelow: Offset.zero,
children: <Widget>[
TextSelectionToolbarTextButton(
padding: TextSelectionToolbarTextButton.getPadding(0, 1),
onPressed: () {},
child: const Text('Custom button'),
),
],
),
),
),
),
);
Finder findToolbarContainer() {
return find.descendant(
of: find.byWidgetPredicate(
(Widget w) => '${w.runtimeType}' == '_TextSelectionToolbarContainer',
),
matching: find.byType(Material),
);
}
expect(findToolbarContainer(), findsAtLeastNWidgets(1));
final Material toolbarContainer = tester.widget(findToolbarContainer().first);
expect(toolbarContainer.color, customBackgroundColor);
});
}
testWidgets('Overflowed menu expands children horizontally', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/144089.
late StateSetter setState;
final children = List<Widget>.generate(7, (int i) => const TestBox());
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return TextSelectionToolbar(
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(findOverflowButton(), findsNothing);
const short = 'Short';
const medium = 'Medium length';
const long = 'Long label in the overflow menu';
// Adding several children makes the menu overflow.
setState(() {
children.addAll(const <Text>[Text(short), Text(medium), Text(long)]);
});
await tester.pumpAndSettle();
expect(findOverflowButton(), findsOneWidget);
// Tap the overflow button to show the overflow menu.
await tester.tap(findOverflowButton());
await tester.pumpAndSettle();
expect(find.byType(TestBox), findsNothing);
expect(find.byType(Text), findsNWidgets(3));
expect(findOverflowButton(), findsOneWidget);
Finder findToolbarContainer() {
return find.byWidgetPredicate(
(Widget w) => '${w.runtimeType}' == '_TextSelectionToolbarContainer',
);
}
expect(findToolbarContainer(), findsAtLeastNWidgets(1));
// Buttons have their width set to the container width.
final double overflowMenuWidth = tester.getRect(findToolbarContainer()).width;
expect(tester.getRect(find.text(long)).width, overflowMenuWidth);
expect(tester.getRect(find.text(medium)).width, overflowMenuWidth);
expect(tester.getRect(find.text(short)).width, overflowMenuWidth);
});
testWidgets('items are ordered right-to-left in RTL', (WidgetTester tester) async {
const itemCount = 3;
final children = List<Widget>.generate(
itemCount,
(int i) => TestBox(key: ValueKey<String>('item_$i'), child: Text('$i')),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Directionality(
textDirection: TextDirection.rtl,
child: TextSelectionToolbar(
anchorAbove: const Offset(50.0, 100.0),
anchorBelow: const Offset(50.0, 200.0),
children: children,
),
),
),
),
);
// Verify all items are visible.
expect(find.byType(TestBox), findsNWidgets(itemCount));
// Find all text widgets by their content and get their positions.
final textRects = List<Rect>.generate(itemCount, (int i) => tester.getRect(find.text('$i')));
// In RTL, items should be in reverse order (2, 1, 0).
// So item 2 should be leftmost, then 1, then 0.
for (var i = 0; i < itemCount - 1; i++) {
final Rect current = textRects[i];
final Rect next = textRects[i + 1];
// In RTL, each item should be to the left of the previous one.
expect(
next.right,
lessThanOrEqualTo(current.left),
reason: 'In RTL, item ${i + 1} should be to the left of item $i',
);
}
// Verify the visual order by checking the rightmost position.
final List<double> rightEdges = textRects.map((Rect r) => r.right).toList();
final sortedRightEdges = List<double>.from(rightEdges)
..sort((double a, double b) => b.compareTo(a));
expect(
rightEdges,
equals(sortedRightEdges),
reason: 'Items should be ordered right-to-left in RTL',
);
});
testWidgets('puts children in an overflow menu if they overflow in RTL', (
WidgetTester tester,
) async {
late StateSetter setState;
final children = List<Widget>.generate(7, (int i) => const TestBox());
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Directionality(
textDirection: TextDirection.rtl, // this makes the difference.
child: StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return TextSelectionToolbar(
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(findOverflowButton(), 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(findOverflowButton(), findsOneWidget);
// Tap the overflow button to show the overflow menu.
await tester.tap(findOverflowButton());
await tester.pumpAndSettle();
expect(find.byType(TestBox), findsOneWidget); // Only one item in the overflow menu.
expect(findOverflowButton(), findsOneWidget);
// Tap the overflow button again to hide the overflow menu.
await tester.tap(findOverflowButton());
await tester.pumpAndSettle();
expect(find.byType(TestBox), findsNWidgets(children.length - 1));
expect(findOverflowButton(), findsOneWidget);
});
}