flutter/packages/flutter/test/widgets/scrollable_selection_test.dart
chunhtai cc9ffd3f3b
SelectionContainer's listeners can remove itself during listener call… (#124624)
When swapping out delegate of  selectioncontainer, if the newly passed in delegate doesn't have any selectable content(which is usually the case), the selectioncontainerstate will notify all of the listeners. One of the listener would be SelectionRegistrant._updateSelectionRegistrarSubscription, and since it doesn't have content, it would remove itself from the listener which causes concurrent modification
2023-04-17 23:19:11 +00:00

1031 lines
48 KiB
Dart

// 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 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'clipboard_utils.dart';
import 'keyboard_utils.dart';
Offset textOffsetToPosition(RenderParagraph paragraph, int offset) {
const Rect caret = Rect.fromLTWH(0.0, 0.0, 2.0, 20.0);
final Offset localOffset = paragraph.getOffsetForCaret(TextPosition(offset: offset), caret);
return paragraph.localToGlobal(localOffset);
}
Offset globalize(Offset point, RenderBox box) {
return box.localToGlobal(point);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final MockClipboard mockClipboard = MockClipboard();
setUp(() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, mockClipboard.handleMethodCall);
await Clipboard.setData(const ClipboardData(text: 'empty'));
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null);
});
testWidgets('mouse can select multiple widgets', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
selectionControls: materialTextSelectionControls,
child: ListView.builder(
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.moveTo(textOffsetToPosition(paragraph1, 4));
await tester.pump();
expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 4));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph2, 5));
// Should select the rest of paragraph 1.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 6));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5));
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 3'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph3, 3));
expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 6));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
await gesture.up();
});
testWidgets('mouse can select multiple widgets - horizontal', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
selectionControls: materialTextSelectionControls,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.moveTo(textOffsetToPosition(paragraph1, 4));
await tester.pump();
expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 4));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph2, 5) + const Offset(0, 5));
// Should select the rest of paragraph 1.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 6));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5));
await gesture.up();
});
testWidgets('select to scroll forward', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
selectionControls: materialTextSelectionControls,
child: ListView.builder(
controller: controller,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
expect(controller.offset, 0.0);
double previousOffset = controller.offset;
// Scrollable only auto scroll if the drag passes the boundary.
await gesture.moveTo(tester.getBottomRight(find.byType(ListView)) + const Offset(0, 20));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(controller.offset > previousOffset, isTrue);
previousOffset = controller.offset;
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(controller.offset > previousOffset, isTrue);
// Scroll to the end.
await tester.pumpAndSettle(const Duration(seconds: 1));
expect(controller.offset, 4200.0);
final RenderParagraph paragraph99 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 99'), matching: find.byType(RichText)));
final RenderParagraph paragraph98 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 98'), matching: find.byType(RichText)));
final RenderParagraph paragraph97 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 97'), matching: find.byType(RichText)));
final RenderParagraph paragraph96 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 96'), matching: find.byType(RichText)));
expect(paragraph99.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7));
expect(paragraph98.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7));
expect(paragraph97.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7));
expect(paragraph96.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7));
await gesture.up();
});
testWidgets('select to scroll works for small scrollable', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
selectionControls: materialTextSelectionControls,
child: Scaffold(
body: SizedBox(
height: 10,
child: ListView.builder(
controller: controller,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
),
),
));
await tester.pumpAndSettle();
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
expect(controller.offset, 0.0);
double previousOffset = controller.offset;
// Scrollable only auto scroll if the drag passes the boundary
await gesture.moveTo(tester.getBottomRight(find.byType(ListView)) + const Offset(0, 20));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(controller.offset > previousOffset, isTrue);
previousOffset = controller.offset;
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(controller.offset > previousOffset, isTrue);
await gesture.up();
// Shouldn't be stuck if gesture is up.
await tester.pumpAndSettle(const Duration(seconds: 1));
expect(tester.takeException(), isNull);
});
testWidgets('select to scroll backward', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
selectionControls: materialTextSelectionControls,
child: ListView.builder(
controller: controller,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
controller.jumpTo(4000);
await tester.pumpAndSettle();
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(ListView)), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
expect(controller.offset, 4000);
double previousOffset = controller.offset;
await gesture.moveTo(tester.getTopLeft(find.byType(ListView)) + const Offset(0, -20));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(controller.offset < previousOffset, isTrue);
previousOffset = controller.offset;
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(controller.offset < previousOffset, isTrue);
// Scroll to the beginning.
await tester.pumpAndSettle(const Duration(seconds: 1));
expect(controller.offset, 0.0);
final RenderParagraph paragraph0 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)));
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 2'), matching: find.byType(RichText)));
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 3'), matching: find.byType(RichText)));
expect(paragraph0.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0));
expect(paragraph1.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0));
expect(paragraph3.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0));
});
testWidgets('select to scroll forward - horizontal', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
selectionControls: materialTextSelectionControls,
child: ListView.builder(
scrollDirection: Axis.horizontal,
controller: controller,
itemCount: 10,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
expect(controller.offset, 0.0);
double previousOffset = controller.offset;
// Scrollable only auto scroll if the drag passes the boundary
await gesture.moveTo(tester.getBottomRight(find.byType(ListView)) + const Offset(20, 0));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(controller.offset > previousOffset, isTrue);
previousOffset = controller.offset;
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(controller.offset > previousOffset, isTrue);
// Scroll to the end.
await tester.pumpAndSettle(const Duration(seconds: 1));
expect(controller.offset, 2080.0);
final RenderParagraph paragraph9 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 9'), matching: find.byType(RichText)));
final RenderParagraph paragraph8 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 8'), matching: find.byType(RichText)));
final RenderParagraph paragraph7 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 7'), matching: find.byType(RichText)));
expect(paragraph9.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
expect(paragraph8.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
expect(paragraph7.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
await gesture.up();
});
testWidgets('select to scroll backward - horizontal', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
selectionControls: materialTextSelectionControls,
child: ListView.builder(
scrollDirection: Axis.horizontal,
controller: controller,
itemCount: 10,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
controller.jumpTo(2080);
await tester.pumpAndSettle();
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(ListView)), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
expect(controller.offset, 2080);
double previousOffset = controller.offset;
await gesture.moveTo(tester.getTopLeft(find.byType(ListView)) + const Offset(-10, 0));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(controller.offset < previousOffset, isTrue);
previousOffset = controller.offset;
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(controller.offset < previousOffset, isTrue);
// Scroll to the beginning.
await tester.pumpAndSettle(const Duration(seconds: 1));
expect(controller.offset, 0.0);
final RenderParagraph paragraph0 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)));
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 2'), matching: find.byType(RichText)));
expect(paragraph0.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0));
expect(paragraph1.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0));
await gesture.up();
});
testWidgets('preserve selection when out of view.', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
selectionControls: materialTextSelectionControls,
child: ListView.builder(
controller: controller,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
controller.jumpTo(2000);
await tester.pumpAndSettle();
expect(find.text('Item 50'), findsOneWidget);
RenderParagraph paragraph50 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 50'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph50, 2), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.moveTo(textOffsetToPosition(paragraph50, 4));
await gesture.up();
expect(paragraph50.selections[0], const TextSelection(baseOffset: 2, extentOffset: 4));
controller.jumpTo(0);
await tester.pumpAndSettle();
expect(find.text('Item 50'), findsNothing);
controller.jumpTo(2000);
await tester.pumpAndSettle();
expect(find.text('Item 50'), findsOneWidget);
paragraph50 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 50'), matching: find.byType(RichText)));
expect(paragraph50.selections[0], const TextSelection(baseOffset: 2, extentOffset: 4));
controller.jumpTo(4000);
await tester.pumpAndSettle();
expect(find.text('Item 50'), findsNothing);
controller.jumpTo(2000);
await tester.pumpAndSettle();
expect(find.text('Item 50'), findsOneWidget);
paragraph50 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 50'), matching: find.byType(RichText)));
expect(paragraph50.selections[0], const TextSelection(baseOffset: 2, extentOffset: 4));
});
testWidgets('can select all non-Apple', (WidgetTester tester) async {
final FocusNode node = FocusNode();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
focusNode: node,
selectionControls: materialTextSelectionControls,
child: ListView.builder(
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
node.requestFocus();
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyA, control: true));
await tester.pump();
for (int i = 0; i < 13; i += 1) {
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item $i'), matching: find.byType(RichText)));
expect(paragraph.selections[0], TextSelection(baseOffset: 0, extentOffset: 'Item $i'.length));
}
expect(find.text('Item 13'), findsNothing);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia }));
testWidgets('can select all - Apple', (WidgetTester tester) async {
final FocusNode node = FocusNode();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
focusNode: node,
selectionControls: materialTextSelectionControls,
child: ListView.builder(
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
node.requestFocus();
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyA, meta: true));
await tester.pump();
for (int i = 0; i < 13; i += 1) {
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item $i'), matching: find.byType(RichText)));
expect(paragraph.selections[0], TextSelection(baseOffset: 0, extentOffset: 'Item $i'.length));
}
expect(find.text('Item 13'), findsNothing);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('select to scroll by dragging selection handles forward', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
selectionControls: materialTextSelectionControls,
child: ListView.builder(
controller: controller,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
// Long press to bring up the selection handles.
final RenderParagraph paragraph0 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph0, 2));
addTearDown(gesture.removePointer);
await tester.pump(const Duration(milliseconds: 500));
await gesture.up();
expect(paragraph0.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4));
final List<TextBox> boxes = paragraph0.getBoxesForSelection(paragraph0.selections[0]);
expect(boxes.length, 1);
// Find end handle.
final Offset handlePos = globalize(boxes[0].toRect().bottomRight, paragraph0);
await gesture.down(handlePos);
expect(controller.offset, 0.0);
double previousOffset = controller.offset;
// Scrollable only auto scroll if the drag passes the boundary
await gesture.moveTo(tester.getBottomRight(find.byType(ListView)) + const Offset(0, 40));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(controller.offset > previousOffset, isTrue);
previousOffset = controller.offset;
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(controller.offset > previousOffset, isTrue);
// Scroll to the end.
await tester.pumpAndSettle(const Duration(seconds: 1));
expect(controller.offset, 4200.0);
final RenderParagraph paragraph99 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 99'), matching: find.byType(RichText)));
final RenderParagraph paragraph98 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 98'), matching: find.byType(RichText)));
final RenderParagraph paragraph97 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 97'), matching: find.byType(RichText)));
final RenderParagraph paragraph96 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 96'), matching: find.byType(RichText)));
expect(paragraph99.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7));
expect(paragraph98.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7));
expect(paragraph97.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7));
expect(paragraph96.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7));
await gesture.up();
});
testWidgets('select to scroll by dragging start selection handle stops scroll when released', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
selectionControls: materialTextSelectionControls,
child: ListView.builder(
controller: controller,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
// Long press to bring up the selection handles.
final RenderParagraph paragraph0 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph0, 2));
addTearDown(gesture.removePointer);
await tester.pump(const Duration(milliseconds: 500));
await gesture.up();
expect(paragraph0.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4));
final List<TextBox> boxes = paragraph0.getBoxesForSelection(paragraph0.selections[0]);
expect(boxes.length, 1);
// Find start handle.
final Offset handlePos = globalize(boxes[0].toRect().bottomLeft, paragraph0);
await gesture.down(handlePos);
expect(controller.offset, 0.0);
double previousOffset = controller.offset;
// Scrollable only auto scroll if the drag passes the boundary.
await gesture.moveTo(tester.getBottomRight(find.byType(ListView)) + const Offset(0, 40));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(controller.offset > previousOffset, isTrue);
previousOffset = controller.offset;
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(controller.offset > previousOffset, isTrue);
previousOffset = controller.offset;
// Release handle should stop scrolling.
await gesture.up();
// Last scheduled scroll.
await tester.pump();
await tester.pump(const Duration(seconds: 1));
previousOffset = controller.offset;
await tester.pumpAndSettle();
expect(controller.offset, previousOffset);
});
testWidgets('select to scroll by dragging end selection handle stops scroll when released', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
selectionControls: materialTextSelectionControls,
child: ListView.builder(
controller: controller,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
// Long press to bring up the selection handles.
final RenderParagraph paragraph0 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph0, 2));
addTearDown(gesture.removePointer);
await tester.pump(const Duration(milliseconds: 500));
await gesture.up();
expect(paragraph0.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4));
final List<TextBox> boxes = paragraph0.getBoxesForSelection(paragraph0.selections[0]);
expect(boxes.length, 1);
final Offset handlePos = globalize(boxes[0].toRect().bottomRight, paragraph0);
await gesture.down(handlePos);
expect(controller.offset, 0.0);
double previousOffset = controller.offset;
// Scrollable only auto scroll if the drag passes the boundary
await gesture.moveTo(tester.getBottomRight(find.byType(ListView)) + const Offset(0, 40));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(controller.offset > previousOffset, isTrue);
previousOffset = controller.offset;
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(controller.offset > previousOffset, isTrue);
previousOffset = controller.offset;
// Release handle should stop scrolling.
await gesture.up();
// Last scheduled scroll.
await tester.pump();
await tester.pump(const Duration(seconds: 1));
previousOffset = controller.offset;
await tester.pumpAndSettle();
expect(controller.offset, previousOffset);
});
testWidgets('keyboard selection should auto scroll - vertical', (WidgetTester tester) async {
final FocusNode node = FocusNode();
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
focusNode: node,
selectionControls: materialTextSelectionControls,
child: ListView.builder(
controller: controller,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
final RenderParagraph paragraph9 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 9'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph9, 2), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(textOffsetToPosition(paragraph9, 4) + const Offset(0, 5));
await tester.pumpAndSettle();
await gesture.up();
await tester.pump();
expect(paragraph9.selections.length, 1);
expect(paragraph9.selections[0].start, 2);
expect(paragraph9.selections[0].end, 4);
expect(controller.offset, 0.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true));
await tester.pump();
final RenderParagraph paragraph10 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 10'), matching: find.byType(RichText)));
expect(paragraph10.selections.length, 1);
expect(paragraph10.selections[0].start, 0);
expect(paragraph10.selections[0].end, 4);
expect(controller.offset, 0.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true));
await tester.pump();
final RenderParagraph paragraph11 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 11'), matching: find.byType(RichText)));
expect(paragraph11.selections.length, 1);
expect(paragraph11.selections[0].start, 0);
expect(paragraph11.selections[0].end, 4);
expect(controller.offset, 0.0);
// Should start scrolling.
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true));
await tester.pump();
final RenderParagraph paragraph12 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 12'), matching: find.byType(RichText)));
expect(paragraph12.selections.length, 1);
expect(paragraph12.selections[0].start, 0);
expect(paragraph12.selections[0].end, 4);
expect(controller.offset, 24.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true));
await tester.pump();
final RenderParagraph paragraph13 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 13'), matching: find.byType(RichText)));
expect(paragraph13.selections.length, 1);
expect(paragraph13.selections[0].start, 0);
expect(paragraph13.selections[0].end, 4);
expect(controller.offset, 72.0);
}, variant: TargetPlatformVariant.all());
testWidgets('keyboard selection should auto scroll - vertical reversed', (WidgetTester tester) async {
final FocusNode node = FocusNode();
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
focusNode: node,
selectionControls: materialTextSelectionControls,
child: ListView.builder(
controller: controller,
reverse: true,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
final RenderParagraph paragraph9 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 9'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph9, 2), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(textOffsetToPosition(paragraph9, 4) + const Offset(0, 5));
await tester.pumpAndSettle();
await gesture.up();
await tester.pump();
expect(paragraph9.selections.length, 1);
expect(paragraph9.selections[0].start, 2);
expect(paragraph9.selections[0].end, 4);
expect(controller.offset, 0.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true));
await tester.pump();
final RenderParagraph paragraph10 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 10'), matching: find.byType(RichText)));
expect(paragraph10.selections.length, 1);
expect(paragraph10.selections[0].start, 2);
expect(paragraph10.selections[0].end, 7);
expect(controller.offset, 0.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true));
await tester.pump();
final RenderParagraph paragraph11 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 11'), matching: find.byType(RichText)));
expect(paragraph11.selections.length, 1);
expect(paragraph11.selections[0].start, 2);
expect(paragraph11.selections[0].end, 7);
expect(controller.offset, 0.0);
// Should start scrolling.
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true));
await tester.pump();
final RenderParagraph paragraph12 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 12'), matching: find.byType(RichText)));
expect(paragraph12.selections.length, 1);
expect(paragraph12.selections[0].start, 2);
expect(paragraph12.selections[0].end, 7);
expect(controller.offset, 24.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true));
await tester.pump();
final RenderParagraph paragraph13 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 13'), matching: find.byType(RichText)));
expect(paragraph13.selections.length, 1);
expect(paragraph13.selections[0].start, 2);
expect(paragraph13.selections[0].end, 7);
expect(controller.offset, 72.0);
}, variant: TargetPlatformVariant.all());
testWidgets('keyboard selection should auto scroll - horizontal', (WidgetTester tester) async {
final FocusNode node = FocusNode();
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
focusNode: node,
selectionControls: materialTextSelectionControls,
child: ListView.builder(
controller: controller,
scrollDirection: Axis.horizontal,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 2'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph2, 0), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(textOffsetToPosition(paragraph2, 1) + const Offset(0, 5));
await tester.pumpAndSettle();
await gesture.up();
await tester.pump();
expect(paragraph2.selections.length, 1);
expect(paragraph2.selections[0].start, 0);
expect(paragraph2.selections[0].end, 1);
expect(controller.offset, 0.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true));
await tester.pump();
expect(paragraph2.selections.length, 1);
expect(paragraph2.selections[0].start, 0);
expect(paragraph2.selections[0].end, 6);
expect(controller.offset, 64.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true));
await tester.pump();
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 3'), matching: find.byType(RichText)));
expect(paragraph3.selections.length, 1);
expect(paragraph3.selections[0].start, 0);
expect(paragraph3.selections[0].end, 6);
expect(controller.offset, 352.0);
}, variant: TargetPlatformVariant.all());
testWidgets('keyboard selection should auto scroll - horizontal reversed', (WidgetTester tester) async {
final FocusNode node = FocusNode();
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
focusNode: node,
selectionControls: materialTextSelectionControls,
child: ListView.builder(
controller: controller,
scrollDirection: Axis.horizontal,
reverse: true,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 5) + const Offset(0, 5), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(textOffsetToPosition(paragraph1, 4) + const Offset(0, 5));
await tester.pumpAndSettle();
await gesture.up();
await tester.pumpAndSettle();
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 4);
expect(paragraph1.selections[0].end, 5);
expect(controller.offset, 0.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true));
await tester.pump();
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 0);
expect(paragraph1.selections[0].end, 5);
expect(controller.offset, 0.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true));
await tester.pump();
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 2'), matching: find.byType(RichText)));
expect(paragraph2.selections.length, 1);
expect(paragraph2.selections[0].start, 0);
expect(paragraph2.selections[0].end, 6);
expect(controller.offset, 64.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true));
await tester.pump();
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 3'), matching: find.byType(RichText)));
expect(paragraph3.selections.length, 1);
expect(paragraph3.selections[0].start, 0);
expect(paragraph3.selections[0].end, 6);
expect(controller.offset, 352.0);
}, variant: TargetPlatformVariant.all());
group('Complex cases', () {
testWidgets('selection starts outside of the scrollable', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
selectionControls: materialTextSelectionControls,
child: Column(
children: <Widget>[
const Text('Item 0'),
SizedBox(
height: 400,
child: ListView.builder(
controller: controller,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Inner item $index');
},
),
),
const Text('Item 1'),
],
),
),
));
await tester.pumpAndSettle();
controller.jumpTo(1000);
await tester.pumpAndSettle();
final RenderParagraph paragraph0 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph0, 2), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph1, 2) + const Offset(0, 5));
await tester.pumpAndSettle();
await gesture.up();
// The entire scrollable should be selected.
expect(paragraph0.selections[0], const TextSelection(baseOffset: 2, extentOffset: 6));
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 2));
final RenderParagraph innerParagraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Inner item 20'), matching: find.byType(RichText)));
expect(innerParagraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 13));
// Should not scroll the inner scrollable.
expect(controller.offset, 1000.0);
});
testWidgets('nested scrollables keep selection alive', (WidgetTester tester) async {
final ScrollController outerController = ScrollController();
final ScrollController innerController = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
selectionControls: materialTextSelectionControls,
child: ListView.builder(
controller: outerController,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
if (index == 2) {
return SizedBox(
height: 700,
child: ListView.builder(
controller: innerController,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Iteminner $index');
},
),
);
}
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
innerController.jumpTo(1000);
await tester.pumpAndSettle();
RenderParagraph innerParagraph23 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Iteminner 23'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(innerParagraph23, 2) + const Offset(0, 5), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
RenderParagraph innerParagraph24 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Iteminner 24'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(innerParagraph24, 2) + const Offset(0, 5));
await tester.pumpAndSettle();
await gesture.up();
expect(innerParagraph23.selections[0], const TextSelection(baseOffset: 2, extentOffset: 12));
expect(innerParagraph24.selections[0], const TextSelection(baseOffset: 0, extentOffset: 2));
innerController.jumpTo(2000);
await tester.pumpAndSettle();
expect(find.descendant(of: find.text('Iteminner 23'), matching: find.byType(RichText)), findsNothing);
outerController.jumpTo(2000);
await tester.pumpAndSettle();
expect(find.descendant(of: find.text('Iteminner 23'), matching: find.byType(RichText)), findsNothing);
// Selected item is still kept alive.
expect(find.descendant(of: find.text('Iteminner 23'), matching: find.byType(RichText), skipOffstage: false), findsNothing);
// Selection stays the same after scrolling back.
outerController.jumpTo(0);
await tester.pumpAndSettle();
expect(innerController.offset, 2000.0);
innerController.jumpTo(1000);
await tester.pumpAndSettle();
innerParagraph23 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Iteminner 23'), matching: find.byType(RichText)));
innerParagraph24 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Iteminner 24'), matching: find.byType(RichText)));
expect(innerParagraph23.selections[0], const TextSelection(baseOffset: 2, extentOffset: 12));
expect(innerParagraph24.selections[0], const TextSelection(baseOffset: 0, extentOffset: 2));
});
testWidgets('can copy off screen selection - Apple', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
final FocusNode focusNode = FocusNode();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
focusNode: focusNode,
selectionControls: materialTextSelectionControls,
child: ListView.builder(
controller: controller,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
focusNode.requestFocus();
await tester.pumpAndSettle();
final RenderParagraph paragraph0 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph0, 2) + const Offset(0, 5), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph1, 2) + const Offset(0, 5));
await tester.pumpAndSettle();
await gesture.up();
expect(paragraph0.selections[0], const TextSelection(baseOffset: 2, extentOffset: 6));
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 2));
// Scroll the selected text out off the screen.
controller.jumpTo(1000);
await tester.pumpAndSettle();
expect(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), findsNothing);
expect(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)), findsNothing);
// Start copying.
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyC, meta: true));
final Map<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
expect(clipboardData['text'], 'em 0It');
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('can copy off screen selection - non-Apple', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
final FocusNode focusNode = FocusNode();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
focusNode: focusNode,
selectionControls: materialTextSelectionControls,
child: ListView.builder(
controller: controller,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
focusNode.requestFocus();
await tester.pumpAndSettle();
final RenderParagraph paragraph0 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph0, 2) + const Offset(0, 5), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph1, 2) + const Offset(0, 5));
await tester.pumpAndSettle();
await gesture.up();
expect(paragraph0.selections[0], const TextSelection(baseOffset: 2, extentOffset: 6));
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 2));
// Scroll the selected text out off the screen.
controller.jumpTo(1000);
await tester.pumpAndSettle();
expect(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), findsNothing);
expect(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)), findsNothing);
// Start copying.
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyC, control: true));
final Map<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
expect(clipboardData['text'], 'em 0It');
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia }));
});
}