mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
WIP Commits separated as follows: - Update lints in analysis_options files - Run `dart fix --apply` - Clean up leftover analysis issues - Run `dart format .` in the right places. Local analysis and testing passes. Checking CI now. Part of https://github.com/flutter/flutter/issues/178827 - Adoption of flutter_lints in examples/api coming in a separate change (cc @loic-sharma) ## Pre-launch Checklist - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [ ] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
2134 lines
76 KiB
Dart
2134 lines
76 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 'package:flutter/foundation.dart';
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
|
|
|
|
import 'clipboard_utils.dart';
|
|
import 'editable_text_utils.dart';
|
|
|
|
const int kSingleTapUpTimeout = 500;
|
|
|
|
void main() {
|
|
late int tapCount;
|
|
late int singleTapUpCount;
|
|
late int singleTapCancelCount;
|
|
late int singleLongTapStartCount;
|
|
late int doubleTapDownCount;
|
|
late int tripleTapDownCount;
|
|
late int forcePressStartCount;
|
|
late int forcePressEndCount;
|
|
late int dragStartCount;
|
|
late int dragUpdateCount;
|
|
late int dragEndCount;
|
|
const forcePressOffset = Offset(400.0, 50.0);
|
|
|
|
void handleTapDown(TapDragDownDetails details) {
|
|
tapCount++;
|
|
}
|
|
|
|
void handleSingleTapUp(TapDragUpDetails details) {
|
|
singleTapUpCount++;
|
|
}
|
|
|
|
void handleSingleTapCancel() {
|
|
singleTapCancelCount++;
|
|
}
|
|
|
|
void handleSingleLongTapStart(LongPressStartDetails details) {
|
|
singleLongTapStartCount++;
|
|
}
|
|
|
|
void handleDoubleTapDown(TapDragDownDetails details) {
|
|
doubleTapDownCount++;
|
|
}
|
|
|
|
void handleTripleTapDown(TapDragDownDetails details) {
|
|
tripleTapDownCount++;
|
|
}
|
|
|
|
void handleForcePressStart(ForcePressDetails details) {
|
|
forcePressStartCount++;
|
|
}
|
|
|
|
void handleForcePressEnd(ForcePressDetails details) {
|
|
forcePressEndCount++;
|
|
}
|
|
|
|
void handleDragSelectionStart(TapDragStartDetails details) {
|
|
dragStartCount++;
|
|
}
|
|
|
|
void handleDragSelectionUpdate(TapDragUpdateDetails details) {
|
|
dragUpdateCount++;
|
|
}
|
|
|
|
void handleDragSelectionEnd(TapDragEndDetails details) {
|
|
dragEndCount++;
|
|
}
|
|
|
|
setUp(() {
|
|
tapCount = 0;
|
|
singleTapUpCount = 0;
|
|
singleTapCancelCount = 0;
|
|
singleLongTapStartCount = 0;
|
|
doubleTapDownCount = 0;
|
|
tripleTapDownCount = 0;
|
|
forcePressStartCount = 0;
|
|
forcePressEndCount = 0;
|
|
dragStartCount = 0;
|
|
dragUpdateCount = 0;
|
|
dragEndCount = 0;
|
|
});
|
|
|
|
Future<void> pumpGestureDetector(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
TextSelectionGestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTapDown: handleTapDown,
|
|
onSingleTapUp: handleSingleTapUp,
|
|
onSingleTapCancel: handleSingleTapCancel,
|
|
onSingleLongTapStart: handleSingleLongTapStart,
|
|
onDoubleTapDown: handleDoubleTapDown,
|
|
onTripleTapDown: handleTripleTapDown,
|
|
onForcePressStart: handleForcePressStart,
|
|
onForcePressEnd: handleForcePressEnd,
|
|
onDragSelectionStart: handleDragSelectionStart,
|
|
onDragSelectionUpdate: handleDragSelectionUpdate,
|
|
onDragSelectionEnd: handleDragSelectionEnd,
|
|
child: Container(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> pumpTextSelectionGestureDetectorBuilder(
|
|
WidgetTester tester, {
|
|
bool forcePressEnabled = true,
|
|
bool selectionEnabled = true,
|
|
}) async {
|
|
final editableTextKey = GlobalKey<EditableTextState>();
|
|
final delegate = FakeTextSelectionGestureDetectorBuilderDelegate(
|
|
editableTextKey: editableTextKey,
|
|
forcePressEnabled: forcePressEnabled,
|
|
selectionEnabled: selectionEnabled,
|
|
);
|
|
|
|
final provider = TextSelectionGestureDetectorBuilder(delegate: delegate);
|
|
final controller = TextEditingController();
|
|
addTearDown(controller.dispose);
|
|
final focusNode = FocusNode();
|
|
addTearDown(focusNode.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: provider.buildGestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
child: FakeEditableText(
|
|
key: editableTextKey,
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
testWidgets('a series of taps all call onTaps', (WidgetTester tester) async {
|
|
await pumpGestureDetector(tester);
|
|
await tester.tapAt(const Offset(200, 200));
|
|
await tester.pump(const Duration(milliseconds: 150));
|
|
await tester.tapAt(const Offset(200, 200));
|
|
await tester.pump(const Duration(milliseconds: 150));
|
|
await tester.tapAt(const Offset(200, 200));
|
|
await tester.pump(const Duration(milliseconds: 150));
|
|
await tester.tapAt(const Offset(200, 200));
|
|
await tester.pump(const Duration(milliseconds: 150));
|
|
await tester.tapAt(const Offset(200, 200));
|
|
await tester.pump(const Duration(milliseconds: 150));
|
|
await tester.tapAt(const Offset(200, 200));
|
|
expect(tapCount, 6);
|
|
});
|
|
|
|
testWidgets(
|
|
'in a series of rapid taps, onTapDown, onDoubleTapDown, and onTripleTapDown alternate',
|
|
(WidgetTester tester) async {
|
|
await pumpGestureDetector(tester);
|
|
await tester.tapAt(const Offset(200, 200));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
expect(singleTapUpCount, 1);
|
|
await tester.tapAt(const Offset(200, 200));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
expect(singleTapUpCount, 1);
|
|
expect(doubleTapDownCount, 1);
|
|
await tester.tapAt(const Offset(200, 200));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
expect(singleTapUpCount, 1);
|
|
expect(doubleTapDownCount, 1);
|
|
expect(tripleTapDownCount, 1);
|
|
await tester.tapAt(const Offset(200, 200));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
expect(singleTapUpCount, 2);
|
|
expect(doubleTapDownCount, 1);
|
|
expect(tripleTapDownCount, 1);
|
|
await tester.tapAt(const Offset(200, 200));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
expect(singleTapUpCount, 2);
|
|
expect(doubleTapDownCount, 2);
|
|
expect(tripleTapDownCount, 1);
|
|
await tester.tapAt(const Offset(200, 200));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
expect(singleTapUpCount, 2);
|
|
expect(doubleTapDownCount, 2);
|
|
expect(tripleTapDownCount, 2);
|
|
await tester.tapAt(const Offset(200, 200));
|
|
expect(singleTapUpCount, 3);
|
|
expect(doubleTapDownCount, 2);
|
|
expect(tripleTapDownCount, 2);
|
|
expect(tapCount, 7);
|
|
},
|
|
);
|
|
|
|
testWidgets('quick tap-tap-hold is a double tap down', (WidgetTester tester) async {
|
|
await pumpGestureDetector(tester);
|
|
await tester.tapAt(const Offset(200, 200));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
expect(singleTapUpCount, 1);
|
|
final TestGesture gesture = await tester.startGesture(const Offset(200, 200));
|
|
await tester.pump(const Duration(milliseconds: 200));
|
|
expect(singleTapUpCount, 1);
|
|
// Every down is counted.
|
|
expect(tapCount, 2);
|
|
// No cancels because the second tap of the double tap is a second successful
|
|
// single tap behind the scene.
|
|
expect(singleTapCancelCount, 0);
|
|
expect(doubleTapDownCount, 1);
|
|
// The double tap down hold supersedes the single tap down.
|
|
expect(singleLongTapStartCount, 0);
|
|
|
|
await gesture.up();
|
|
// Nothing else happens on up.
|
|
expect(singleTapUpCount, 1);
|
|
expect(tapCount, 2);
|
|
expect(singleTapCancelCount, 0);
|
|
expect(doubleTapDownCount, 1);
|
|
expect(singleLongTapStartCount, 0);
|
|
});
|
|
|
|
testWidgets('a very quick swipe is ignored', (WidgetTester tester) async {
|
|
await pumpGestureDetector(tester);
|
|
final TestGesture gesture = await tester.startGesture(const Offset(200, 200));
|
|
await tester.pump(const Duration(milliseconds: 20));
|
|
await gesture.moveBy(const Offset(100, 100));
|
|
await tester.pump();
|
|
expect(singleTapUpCount, 0);
|
|
// Before the move to TapAndDragGestureRecognizer the tapCount was 0 because the
|
|
// TapGestureRecognizer rejected itself when the initial pointer moved past a certain
|
|
// threshold. With TapAndDragGestureRecognizer, we have two thresholds, a normal tap
|
|
// threshold, and a drag threshold, so it is possible for the tap count to increase
|
|
// even though the original pointer has moved beyond the tap threshold.
|
|
expect(tapCount, 1);
|
|
expect(singleTapCancelCount, 0);
|
|
expect(doubleTapDownCount, 0);
|
|
expect(singleLongTapStartCount, 0);
|
|
|
|
await gesture.up();
|
|
// Nothing else happens on up.
|
|
expect(singleTapUpCount, 0);
|
|
expect(tapCount, 1);
|
|
expect(singleTapCancelCount, 0);
|
|
expect(doubleTapDownCount, 0);
|
|
expect(singleLongTapStartCount, 0);
|
|
});
|
|
|
|
testWidgets('a slower swipe has a tap down and a canceled tap', (WidgetTester tester) async {
|
|
await pumpGestureDetector(tester);
|
|
final TestGesture gesture = await tester.startGesture(const Offset(200, 200));
|
|
await tester.pump(const Duration(milliseconds: 120));
|
|
await gesture.moveBy(const Offset(100, 100));
|
|
await tester.pump();
|
|
expect(singleTapUpCount, 0);
|
|
expect(tapCount, 1);
|
|
expect(singleTapCancelCount, 0);
|
|
expect(doubleTapDownCount, 0);
|
|
expect(singleLongTapStartCount, 0);
|
|
});
|
|
|
|
testWidgets('a force press initiates a force press', (WidgetTester tester) async {
|
|
await pumpGestureDetector(tester);
|
|
|
|
final int pointerValue = tester.nextPointer;
|
|
|
|
final TestGesture gesture = await tester.createGesture();
|
|
|
|
await gesture.downWithCustomEvent(
|
|
forcePressOffset,
|
|
PointerDownEvent(
|
|
pointer: pointerValue,
|
|
position: forcePressOffset,
|
|
pressure: 0.0,
|
|
pressureMax: 6.0,
|
|
pressureMin: 0.0,
|
|
),
|
|
);
|
|
|
|
await gesture.updateWithCustomEvent(
|
|
PointerMoveEvent(pointer: pointerValue, pressure: 0.5, pressureMin: 0),
|
|
);
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
await gesture.downWithCustomEvent(
|
|
forcePressOffset,
|
|
PointerDownEvent(
|
|
pointer: pointerValue,
|
|
position: forcePressOffset,
|
|
pressure: 0.0,
|
|
pressureMax: 6.0,
|
|
pressureMin: 0.0,
|
|
),
|
|
);
|
|
await gesture.updateWithCustomEvent(
|
|
PointerMoveEvent(pointer: pointerValue, pressure: 0.5, pressureMin: 0),
|
|
);
|
|
await gesture.up();
|
|
await tester.pump(const Duration(milliseconds: 20));
|
|
|
|
await gesture.downWithCustomEvent(
|
|
forcePressOffset,
|
|
PointerDownEvent(
|
|
pointer: pointerValue,
|
|
position: forcePressOffset,
|
|
pressure: 0.0,
|
|
pressureMax: 6.0,
|
|
pressureMin: 0.0,
|
|
),
|
|
);
|
|
await gesture.updateWithCustomEvent(
|
|
PointerMoveEvent(pointer: pointerValue, pressure: 0.5, pressureMin: 0),
|
|
);
|
|
await gesture.up();
|
|
await tester.pump(const Duration(milliseconds: 20));
|
|
|
|
await gesture.downWithCustomEvent(
|
|
forcePressOffset,
|
|
PointerDownEvent(
|
|
pointer: pointerValue,
|
|
position: forcePressOffset,
|
|
pressure: 0.0,
|
|
pressureMax: 6.0,
|
|
pressureMin: 0.0,
|
|
),
|
|
);
|
|
await gesture.updateWithCustomEvent(
|
|
PointerMoveEvent(pointer: pointerValue, pressure: 0.5, pressureMin: 0),
|
|
);
|
|
await gesture.up();
|
|
|
|
expect(forcePressStartCount, 4);
|
|
});
|
|
|
|
testWidgets('a tap and then force press initiates a force press and not a double tap', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await pumpGestureDetector(tester);
|
|
|
|
final int pointerValue = tester.nextPointer;
|
|
final TestGesture gesture = await tester.createGesture();
|
|
await gesture.downWithCustomEvent(
|
|
forcePressOffset,
|
|
PointerDownEvent(
|
|
pointer: pointerValue,
|
|
position: forcePressOffset,
|
|
pressure: 0.0,
|
|
pressureMax: 6.0,
|
|
pressureMin: 0.0,
|
|
),
|
|
);
|
|
// Initiate a quick tap.
|
|
await gesture.updateWithCustomEvent(
|
|
PointerMoveEvent(pointer: pointerValue, pressure: 0.0, pressureMin: 0),
|
|
);
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
await gesture.up();
|
|
|
|
// Initiate a force tap.
|
|
await gesture.downWithCustomEvent(
|
|
forcePressOffset,
|
|
PointerDownEvent(
|
|
pointer: pointerValue,
|
|
position: forcePressOffset,
|
|
pressure: 0.0,
|
|
pressureMax: 6.0,
|
|
pressureMin: 0.0,
|
|
),
|
|
);
|
|
await gesture.updateWithCustomEvent(
|
|
PointerMoveEvent(pointer: pointerValue, pressure: 0.5, pressureMin: 0),
|
|
);
|
|
expect(forcePressStartCount, 1);
|
|
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(forcePressEndCount, 1);
|
|
expect(doubleTapDownCount, 0);
|
|
});
|
|
|
|
testWidgets('a long press from a touch device is recognized as a long single tap', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await pumpGestureDetector(tester);
|
|
|
|
final int pointerValue = tester.nextPointer;
|
|
final TestGesture gesture = await tester.startGesture(
|
|
const Offset(200.0, 200.0),
|
|
pointer: pointerValue,
|
|
);
|
|
await tester.pump(const Duration(seconds: 2));
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(tapCount, 1);
|
|
expect(singleTapUpCount, 0);
|
|
expect(singleLongTapStartCount, 1);
|
|
});
|
|
|
|
testWidgets('a long press from a mouse is just a tap', (WidgetTester tester) async {
|
|
await pumpGestureDetector(tester);
|
|
|
|
final int pointerValue = tester.nextPointer;
|
|
final TestGesture gesture = await tester.startGesture(
|
|
const Offset(200.0, 200.0),
|
|
pointer: pointerValue,
|
|
kind: PointerDeviceKind.mouse,
|
|
);
|
|
await tester.pump(const Duration(seconds: 2));
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(tapCount, 1);
|
|
expect(singleTapUpCount, 1);
|
|
expect(singleLongTapStartCount, 0);
|
|
});
|
|
|
|
testWidgets('a touch drag is recognized for text selection', (WidgetTester tester) async {
|
|
await pumpGestureDetector(tester);
|
|
|
|
final int pointerValue = tester.nextPointer;
|
|
final TestGesture gesture = await tester.startGesture(
|
|
const Offset(200.0, 200.0),
|
|
pointer: pointerValue,
|
|
);
|
|
await tester.pump();
|
|
await gesture.moveBy(const Offset(210.0, 200.0));
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(tapCount, 1);
|
|
expect(singleTapUpCount, 0);
|
|
expect(singleTapCancelCount, 0);
|
|
expect(dragStartCount, 1);
|
|
expect(dragUpdateCount, 1);
|
|
expect(dragEndCount, 1);
|
|
});
|
|
|
|
testWidgets('a mouse drag is recognized for text selection', (WidgetTester tester) async {
|
|
await pumpGestureDetector(tester);
|
|
|
|
final int pointerValue = tester.nextPointer;
|
|
final TestGesture gesture = await tester.startGesture(
|
|
const Offset(200.0, 200.0),
|
|
pointer: pointerValue,
|
|
kind: PointerDeviceKind.mouse,
|
|
);
|
|
await tester.pump();
|
|
await gesture.moveBy(const Offset(210.0, 200.0));
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
// The tap and drag gesture recognizer will detect the tap down, but not the tap up.
|
|
expect(tapCount, 1);
|
|
expect(singleTapCancelCount, 0);
|
|
expect(singleTapUpCount, 0);
|
|
|
|
expect(dragStartCount, 1);
|
|
expect(dragUpdateCount, 1);
|
|
expect(dragEndCount, 1);
|
|
});
|
|
|
|
testWidgets('a slow mouse drag is still recognized for text selection', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await pumpGestureDetector(tester);
|
|
|
|
final int pointerValue = tester.nextPointer;
|
|
final TestGesture gesture = await tester.startGesture(
|
|
const Offset(200.0, 200.0),
|
|
pointer: pointerValue,
|
|
kind: PointerDeviceKind.mouse,
|
|
);
|
|
await tester.pump(const Duration(seconds: 2));
|
|
await gesture.moveBy(const Offset(210.0, 200.0));
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
// The tap and drag gesture recognizer will detect the tap down, but not the tap up.
|
|
expect(tapCount, 1);
|
|
expect(singleTapCancelCount, 0);
|
|
expect(singleTapUpCount, 0);
|
|
|
|
expect(dragStartCount, 1);
|
|
expect(dragUpdateCount, 1);
|
|
expect(dragEndCount, 1);
|
|
});
|
|
|
|
testWidgets(
|
|
'test TextSelectionGestureDetectorBuilder long press on Apple Platforms - focused renderEditable',
|
|
(WidgetTester tester) async {
|
|
await pumpTextSelectionGestureDetectorBuilder(tester);
|
|
final FakeEditableTextState state = tester.state(find.byType(FakeEditableText));
|
|
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
|
|
renderEditable.hasFocus = true;
|
|
|
|
final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0), pointer: 0);
|
|
await tester.pump(const Duration(seconds: 2));
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(state.showToolbarCalled, isTrue);
|
|
expect(renderEditable.selectPositionAtCalled, isTrue);
|
|
expect(renderEditable.lastCause, SelectionChangedCause.longPress);
|
|
},
|
|
variant: const TargetPlatformVariant(<TargetPlatform>{
|
|
TargetPlatform.iOS,
|
|
TargetPlatform.macOS,
|
|
}),
|
|
);
|
|
|
|
testWidgets(
|
|
'test TextSelectionGestureDetectorBuilder long press on iOS - renderEditable not focused',
|
|
(WidgetTester tester) async {
|
|
await pumpTextSelectionGestureDetectorBuilder(tester);
|
|
final FakeEditableTextState state = tester.state(find.byType(FakeEditableText));
|
|
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
|
|
|
|
final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0), pointer: 0);
|
|
await tester.pump(const Duration(seconds: 2));
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(state.showToolbarCalled, isTrue);
|
|
expect(renderEditable.selectWordCalled, isTrue);
|
|
expect(renderEditable.lastCause, SelectionChangedCause.longPress);
|
|
},
|
|
variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}),
|
|
);
|
|
testWidgets(
|
|
'test TextSelectionGestureDetectorBuilder long press on non-Apple Platforms',
|
|
(WidgetTester tester) async {
|
|
await pumpTextSelectionGestureDetectorBuilder(tester);
|
|
final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0), pointer: 0);
|
|
await tester.pump(const Duration(seconds: 2));
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
final FakeEditableTextState state = tester.state(find.byType(FakeEditableText));
|
|
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
|
|
expect(state.showToolbarCalled, isTrue);
|
|
expect(renderEditable.selectWordCalled, isTrue);
|
|
expect(renderEditable.lastCause, SelectionChangedCause.longPress);
|
|
},
|
|
variant: TargetPlatformVariant.all(
|
|
excluding: <TargetPlatform>{TargetPlatform.iOS, TargetPlatform.macOS},
|
|
),
|
|
);
|
|
|
|
testWidgets(
|
|
'does not crash when long press is cancelled after unmounting',
|
|
(WidgetTester tester) async {
|
|
// Regression test for b/425840577.
|
|
final scrollController = ScrollController();
|
|
addTearDown(scrollController.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Material(
|
|
child: CustomScrollView(
|
|
controller: scrollController,
|
|
slivers: <Widget>[
|
|
SliverList(
|
|
delegate: SliverChildBuilderDelegate(
|
|
(_, int index) => index == 0 ? const TextField() : const SizedBox(height: 50),
|
|
childCount: 200,
|
|
addAutomaticKeepAlives: false,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
|
|
// Start a long press, don't release it, and don't completely reach kLongPressTimeout so the
|
|
// gesture is not accepted and is cancelled when the recognizer is disposed.
|
|
await tester.startGesture(tester.getCenter(find.byType(TextField)));
|
|
await tester.pump(const Duration(milliseconds: 200));
|
|
await tester.pumpAndSettle();
|
|
|
|
// While attempting to long press, scroll the TextField out of view
|
|
// to dispose of it and its gesture recognizers.
|
|
scrollController.jumpTo(8000.0);
|
|
await tester.pump();
|
|
expect(state.mounted, isFalse);
|
|
// Should reach the end of the test without any failures.
|
|
},
|
|
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
|
|
);
|
|
|
|
testWidgets(
|
|
'TextSelectionGestureDetectorBuilder right click Apple platforms',
|
|
(WidgetTester tester) async {
|
|
// Regression test for https://github.com/flutter/flutter/issues/80119
|
|
await pumpTextSelectionGestureDetectorBuilder(tester);
|
|
|
|
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
|
|
renderEditable.text = const TextSpan(text: 'one two three four five six seven');
|
|
await tester.pump();
|
|
|
|
final TestGesture gesture = await tester.createGesture(
|
|
pointer: 0,
|
|
kind: PointerDeviceKind.mouse,
|
|
buttons: kSecondaryButton,
|
|
);
|
|
|
|
// Get the location of the 10th character
|
|
final Offset charLocation = renderEditable
|
|
.getLocalRectForCaret(const TextPosition(offset: 10))
|
|
.center;
|
|
final Offset globalCharLocation = charLocation + tester.getTopLeft(find.byType(FakeEditable));
|
|
|
|
// Right clicking on a word should select it
|
|
await gesture.down(globalCharLocation);
|
|
await gesture.up();
|
|
await tester.pump();
|
|
expect(renderEditable.selectWordCalled, isTrue);
|
|
expect(renderEditable.lastCause, SelectionChangedCause.tap);
|
|
|
|
// Right clicking on a word within a selection shouldn't change the selection
|
|
renderEditable.selectWordCalled = false;
|
|
renderEditable.selection = const TextSelection(baseOffset: 3, extentOffset: 20);
|
|
await gesture.down(globalCharLocation);
|
|
await gesture.up();
|
|
await tester.pump();
|
|
expect(renderEditable.selectWordCalled, isFalse);
|
|
|
|
// Right clicking on a word within a reverse (right-to-left) selection shouldn't change the selection
|
|
renderEditable.selectWordCalled = false;
|
|
renderEditable.selection = const TextSelection(baseOffset: 20, extentOffset: 3);
|
|
await gesture.down(globalCharLocation);
|
|
await gesture.up();
|
|
await tester.pump();
|
|
expect(renderEditable.selectWordCalled, isFalse);
|
|
},
|
|
variant: const TargetPlatformVariant(<TargetPlatform>{
|
|
TargetPlatform.iOS,
|
|
TargetPlatform.macOS,
|
|
}),
|
|
);
|
|
|
|
testWidgets(
|
|
'TextSelectionGestureDetectorBuilder right click non-Apple platforms',
|
|
(WidgetTester tester) async {
|
|
// Regression test for https://github.com/flutter/flutter/issues/80119
|
|
await pumpTextSelectionGestureDetectorBuilder(tester);
|
|
|
|
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
|
|
renderEditable.text = const TextSpan(text: 'one two three four five six seven');
|
|
await tester.pump();
|
|
|
|
final TestGesture gesture = await tester.createGesture(
|
|
pointer: 0,
|
|
kind: PointerDeviceKind.mouse,
|
|
buttons: kSecondaryButton,
|
|
);
|
|
|
|
// Get the location of the 10th character
|
|
final Offset charLocation = renderEditable
|
|
.getLocalRectForCaret(const TextPosition(offset: 10))
|
|
.center;
|
|
final Offset globalCharLocation = charLocation + tester.getTopLeft(find.byType(FakeEditable));
|
|
|
|
// Right clicking on an unfocused field should place the cursor, not select
|
|
// the word.
|
|
await gesture.down(globalCharLocation);
|
|
await gesture.up();
|
|
await tester.pump();
|
|
expect(renderEditable.selectWordCalled, isFalse);
|
|
expect(renderEditable.selectPositionCalled, isTrue);
|
|
|
|
// Right clicking on a focused field with selection shouldn't change the
|
|
// selection.
|
|
renderEditable.selectPositionCalled = false;
|
|
renderEditable.selection = const TextSelection(baseOffset: 3, extentOffset: 20);
|
|
renderEditable.hasFocus = true;
|
|
await gesture.down(globalCharLocation);
|
|
await gesture.up();
|
|
await tester.pump();
|
|
expect(renderEditable.selectWordCalled, isFalse);
|
|
expect(renderEditable.selectPositionCalled, isFalse);
|
|
|
|
// Right clicking on a focused field with a reverse (right to left)
|
|
// selection shouldn't change the selection.
|
|
renderEditable.selection = const TextSelection(baseOffset: 20, extentOffset: 3);
|
|
await gesture.down(globalCharLocation);
|
|
await gesture.up();
|
|
await tester.pump();
|
|
expect(renderEditable.selectWordCalled, isFalse);
|
|
expect(renderEditable.selectPositionCalled, isFalse);
|
|
},
|
|
variant: const TargetPlatformVariant(<TargetPlatform>{
|
|
TargetPlatform.android,
|
|
TargetPlatform.fuchsia,
|
|
TargetPlatform.linux,
|
|
TargetPlatform.windows,
|
|
}),
|
|
);
|
|
|
|
testWidgets('test TextSelectionGestureDetectorBuilder tap', (WidgetTester tester) async {
|
|
await pumpTextSelectionGestureDetectorBuilder(tester);
|
|
final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0), pointer: 0);
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
final FakeEditableTextState state = tester.state(find.byType(FakeEditableText));
|
|
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
|
|
expect(state.showToolbarCalled, isFalse);
|
|
|
|
switch (defaultTargetPlatform) {
|
|
case TargetPlatform.iOS:
|
|
expect(renderEditable.selectWordEdgeCalled, isTrue);
|
|
expect(renderEditable.lastCause, SelectionChangedCause.tap);
|
|
case TargetPlatform.macOS:
|
|
case TargetPlatform.android:
|
|
case TargetPlatform.fuchsia:
|
|
case TargetPlatform.linux:
|
|
case TargetPlatform.windows:
|
|
expect(renderEditable.selectPositionAtCalled, isTrue);
|
|
expect(renderEditable.lastCause, SelectionChangedCause.tap);
|
|
}
|
|
}, variant: TargetPlatformVariant.all());
|
|
|
|
testWidgets(
|
|
'test TextSelectionGestureDetectorBuilder toggles toolbar on single tap on previous selection iOS',
|
|
(WidgetTester tester) async {
|
|
await pumpTextSelectionGestureDetectorBuilder(tester);
|
|
|
|
final FakeEditableTextState state = tester.state(find.byType(FakeEditableText));
|
|
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
|
|
expect(state.showToolbarCalled, isFalse);
|
|
expect(state.toggleToolbarCalled, isFalse);
|
|
renderEditable.selection = const TextSelection(baseOffset: 2, extentOffset: 6);
|
|
renderEditable.hasFocus = true;
|
|
|
|
final TestGesture gesture = await tester.startGesture(const Offset(25.0, 200.0), pointer: 0);
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
switch (defaultTargetPlatform) {
|
|
case TargetPlatform.iOS:
|
|
expect(renderEditable.selectWordEdgeCalled, isFalse);
|
|
expect(state.toggleToolbarCalled, isTrue);
|
|
case TargetPlatform.macOS:
|
|
case TargetPlatform.android:
|
|
case TargetPlatform.fuchsia:
|
|
case TargetPlatform.linux:
|
|
case TargetPlatform.windows:
|
|
expect(renderEditable.selectPositionAtCalled, isTrue);
|
|
expect(renderEditable.lastCause, SelectionChangedCause.tap);
|
|
}
|
|
},
|
|
variant: TargetPlatformVariant.all(),
|
|
);
|
|
|
|
testWidgets(
|
|
'test TextSelectionGestureDetectorBuilder shows spell check toolbar on single tap on Android',
|
|
(WidgetTester tester) async {
|
|
await pumpTextSelectionGestureDetectorBuilder(tester);
|
|
|
|
final FakeEditableTextState state = tester.state(find.byType(FakeEditableText));
|
|
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
|
|
expect(state.showSpellCheckSuggestionsToolbarCalled, isFalse);
|
|
renderEditable.selection = const TextSelection(baseOffset: 2, extentOffset: 6);
|
|
renderEditable.hasFocus = true;
|
|
|
|
final TestGesture gesture = await tester.startGesture(const Offset(25.0, 200.0), pointer: 0);
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(state.showSpellCheckSuggestionsToolbarCalled, isTrue);
|
|
},
|
|
variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.android}),
|
|
);
|
|
|
|
testWidgets(
|
|
'test TextSelectionGestureDetectorBuilder shows spell check toolbar on single tap on iOS if word misspelled and text selection toolbar on additional taps',
|
|
(WidgetTester tester) async {
|
|
await pumpTextSelectionGestureDetectorBuilder(tester);
|
|
final FakeEditableTextState state = tester.state(find.byType(FakeEditableText));
|
|
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
|
|
const selection = TextSelection.collapsed(offset: 1);
|
|
state.updateEditingValue(
|
|
const TextEditingValue(text: 'something misspelled', selection: selection),
|
|
);
|
|
|
|
// Mark word to be tapped as misspelled for testing.
|
|
state.markCurrentSelectionAsMisspelled = true;
|
|
await tester.pump();
|
|
|
|
// Test spell check suggestions toolbar is shown on first tap of misspelled word.
|
|
const position = Offset(25.0, 200.0);
|
|
await tester.tapAt(position);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(state.showSpellCheckSuggestionsToolbarCalled, isTrue);
|
|
|
|
// Reset and test text selection toolbar is toggled for additional taps.
|
|
state.showSpellCheckSuggestionsToolbarCalled = false;
|
|
renderEditable.selection = selection;
|
|
await tester.pump(const Duration(milliseconds: kSingleTapUpTimeout));
|
|
|
|
// Test first tap.
|
|
await tester.tapAt(position);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(state.showSpellCheckSuggestionsToolbarCalled, isFalse);
|
|
expect(state.toggleToolbarCalled, isTrue);
|
|
|
|
// Reset and test second tap.
|
|
state.toggleToolbarCalled = false;
|
|
await tester.pump(const Duration(milliseconds: kSingleTapUpTimeout));
|
|
|
|
await tester.tapAt(position);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(state.showSpellCheckSuggestionsToolbarCalled, isFalse);
|
|
expect(state.toggleToolbarCalled, isTrue);
|
|
},
|
|
variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}),
|
|
);
|
|
|
|
testWidgets('test TextSelectionGestureDetectorBuilder double tap', (WidgetTester tester) async {
|
|
await pumpTextSelectionGestureDetectorBuilder(tester);
|
|
final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0), pointer: 0);
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
await gesture.up();
|
|
await gesture.down(const Offset(200.0, 200.0));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
final FakeEditableTextState state = tester.state(find.byType(FakeEditableText));
|
|
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
|
|
expect(state.showToolbarCalled, isTrue);
|
|
expect(renderEditable.selectWordCalled, isTrue);
|
|
expect(renderEditable.lastCause, SelectionChangedCause.doubleTap);
|
|
});
|
|
|
|
testWidgets('test TextSelectionGestureDetectorBuilder forcePress enabled', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await pumpTextSelectionGestureDetectorBuilder(tester);
|
|
final TestGesture gesture = await tester.createGesture();
|
|
await gesture.downWithCustomEvent(
|
|
const Offset(200.0, 200.0),
|
|
const PointerDownEvent(
|
|
position: Offset(200.0, 200.0),
|
|
pressure: 3.0,
|
|
pressureMax: 6.0,
|
|
pressureMin: 0.0,
|
|
),
|
|
);
|
|
await gesture.updateWithCustomEvent(
|
|
const PointerUpEvent(position: Offset(200.0, 200.0), pressureMax: 6.0, pressureMin: 0.0),
|
|
);
|
|
await tester.pump();
|
|
|
|
final FakeEditableTextState state = tester.state(find.byType(FakeEditableText));
|
|
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
|
|
expect(state.showToolbarCalled, isTrue);
|
|
expect(renderEditable.selectWordsInRangeCalled, isTrue);
|
|
});
|
|
|
|
testWidgets('Mouse drag does not show handles nor toolbar', (WidgetTester tester) async {
|
|
// Regression test for https://github.com/flutter/flutter/issues/69001
|
|
await tester.pumpWidget(
|
|
const MaterialApp(home: Scaffold(body: SelectableText('I love Flutter!'))),
|
|
);
|
|
|
|
final Offset textFieldStart = tester.getTopLeft(find.byType(SelectableText));
|
|
|
|
final TestGesture gesture = await tester.startGesture(
|
|
textFieldStart,
|
|
kind: PointerDeviceKind.mouse,
|
|
);
|
|
await tester.pump();
|
|
await gesture.moveTo(textFieldStart + const Offset(50.0, 0));
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
final EditableTextState editableText = tester.state(find.byType(EditableText));
|
|
expect(editableText.selectionOverlay!.handlesAreVisible, isFalse);
|
|
expect(editableText.selectionOverlay!.toolbarIsVisible, isFalse);
|
|
});
|
|
|
|
testWidgets('Mouse drag selects and cannot drag cursor', (WidgetTester tester) async {
|
|
// Regression test for https://github.com/flutter/flutter/issues/102928
|
|
final controller = TextEditingController(text: 'I love flutter!');
|
|
addTearDown(controller.dispose);
|
|
final editableTextKey = GlobalKey<EditableTextState>();
|
|
final delegate = FakeTextSelectionGestureDetectorBuilderDelegate(
|
|
editableTextKey: editableTextKey,
|
|
forcePressEnabled: false,
|
|
selectionEnabled: true,
|
|
);
|
|
final provider = TextSelectionGestureDetectorBuilder(delegate: delegate);
|
|
final focusNode = FocusNode();
|
|
addTearDown(focusNode.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: provider.buildGestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
child: EditableText(
|
|
key: editableTextKey,
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
backgroundCursorColor: Colors.white,
|
|
cursorColor: Colors.white,
|
|
style: const TextStyle(),
|
|
selectionControls: materialTextSelectionControls,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(controller.selection.isCollapsed, isTrue);
|
|
expect(controller.selection.baseOffset, -1);
|
|
|
|
final Offset position = textOffsetToPosition(tester, 4);
|
|
|
|
await tester.tapAt(position);
|
|
// Don't do a double tap drag.
|
|
await tester.pump(const Duration(milliseconds: 300));
|
|
|
|
expect(controller.selection.isCollapsed, isTrue);
|
|
expect(controller.selection.baseOffset, 4);
|
|
|
|
final TestGesture gesture = await tester.startGesture(position, kind: PointerDeviceKind.mouse);
|
|
|
|
// Checking that double-tap was not registered.
|
|
expect(controller.selection.isCollapsed, isTrue);
|
|
expect(controller.selection.baseOffset, 4);
|
|
|
|
addTearDown(gesture.removePointer);
|
|
await tester.pump();
|
|
await gesture.moveTo(textOffsetToPosition(tester, 7));
|
|
await tester.pump();
|
|
await gesture.moveTo(textOffsetToPosition(tester, 10));
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(controller.selection.isCollapsed, isFalse);
|
|
expect(controller.selection.baseOffset, 4);
|
|
expect(controller.selection.extentOffset, 10);
|
|
});
|
|
|
|
testWidgets('Touch drag moves the cursor', (WidgetTester tester) async {
|
|
// Regression test for https://github.com/flutter/flutter/issues/102928
|
|
final controller = TextEditingController(text: 'I love flutter!');
|
|
addTearDown(controller.dispose);
|
|
final editableTextKey = GlobalKey<EditableTextState>();
|
|
final delegate = FakeTextSelectionGestureDetectorBuilderDelegate(
|
|
editableTextKey: editableTextKey,
|
|
forcePressEnabled: false,
|
|
selectionEnabled: true,
|
|
);
|
|
final provider = TextSelectionGestureDetectorBuilder(delegate: delegate);
|
|
final focusNode = FocusNode();
|
|
addTearDown(focusNode.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: provider.buildGestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
child: EditableText(
|
|
key: editableTextKey,
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
backgroundCursorColor: Colors.white,
|
|
cursorColor: Colors.white,
|
|
style: const TextStyle(),
|
|
selectionControls: materialTextSelectionControls,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(controller.selection.isCollapsed, isTrue);
|
|
expect(controller.selection.baseOffset, -1);
|
|
|
|
final Offset position = textOffsetToPosition(tester, 4);
|
|
|
|
await tester.tapAt(position);
|
|
await tester.pump();
|
|
|
|
expect(controller.selection.isCollapsed, isTrue);
|
|
expect(controller.selection.baseOffset, 4);
|
|
|
|
final TestGesture gesture = await tester.startGesture(position);
|
|
addTearDown(gesture.removePointer);
|
|
await tester.pump();
|
|
await gesture.moveTo(textOffsetToPosition(tester, 7));
|
|
await tester.pump();
|
|
await gesture.moveTo(textOffsetToPosition(tester, 10));
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(controller.selection.isCollapsed, isTrue);
|
|
expect(controller.selection.baseOffset, 10);
|
|
});
|
|
|
|
testWidgets('Stylus drag moves the cursor', (WidgetTester tester) async {
|
|
// Regression test for https://github.com/flutter/flutter/issues/102928
|
|
final controller = TextEditingController(text: 'I love flutter!');
|
|
addTearDown(controller.dispose);
|
|
final editableTextKey = GlobalKey<EditableTextState>();
|
|
final delegate = FakeTextSelectionGestureDetectorBuilderDelegate(
|
|
editableTextKey: editableTextKey,
|
|
forcePressEnabled: false,
|
|
selectionEnabled: true,
|
|
);
|
|
final provider = TextSelectionGestureDetectorBuilder(delegate: delegate);
|
|
final focusNode = FocusNode();
|
|
addTearDown(focusNode.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: provider.buildGestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
child: EditableText(
|
|
key: editableTextKey,
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
backgroundCursorColor: Colors.white,
|
|
cursorColor: Colors.white,
|
|
style: const TextStyle(),
|
|
selectionControls: materialTextSelectionControls,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(controller.selection.isCollapsed, isTrue);
|
|
expect(controller.selection.baseOffset, -1);
|
|
|
|
final Offset position = textOffsetToPosition(tester, 4);
|
|
|
|
await tester.tapAt(position);
|
|
await tester.pump();
|
|
|
|
expect(controller.selection.isCollapsed, isTrue);
|
|
expect(controller.selection.baseOffset, 4);
|
|
|
|
final TestGesture gesture = await tester.startGesture(position, kind: PointerDeviceKind.stylus);
|
|
addTearDown(gesture.removePointer);
|
|
await tester.pump();
|
|
await gesture.moveTo(textOffsetToPosition(tester, 7));
|
|
await tester.pump();
|
|
await gesture.moveTo(textOffsetToPosition(tester, 10));
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(controller.selection.isCollapsed, isTrue);
|
|
expect(controller.selection.baseOffset, 10);
|
|
});
|
|
|
|
testWidgets('Drag of unknown type moves the cursor', (WidgetTester tester) async {
|
|
// Regression test for https://github.com/flutter/flutter/issues/102928
|
|
final controller = TextEditingController(text: 'I love flutter!');
|
|
addTearDown(controller.dispose);
|
|
final editableTextKey = GlobalKey<EditableTextState>();
|
|
final delegate = FakeTextSelectionGestureDetectorBuilderDelegate(
|
|
editableTextKey: editableTextKey,
|
|
forcePressEnabled: false,
|
|
selectionEnabled: true,
|
|
);
|
|
final provider = TextSelectionGestureDetectorBuilder(delegate: delegate);
|
|
final focusNode = FocusNode();
|
|
addTearDown(focusNode.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: provider.buildGestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
child: EditableText(
|
|
key: editableTextKey,
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
backgroundCursorColor: Colors.white,
|
|
cursorColor: Colors.white,
|
|
style: const TextStyle(),
|
|
selectionControls: materialTextSelectionControls,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(controller.selection.isCollapsed, isTrue);
|
|
expect(controller.selection.baseOffset, -1);
|
|
|
|
final Offset position = textOffsetToPosition(tester, 4);
|
|
|
|
await tester.tapAt(position);
|
|
await tester.pump();
|
|
|
|
expect(controller.selection.isCollapsed, isTrue);
|
|
expect(controller.selection.baseOffset, 4);
|
|
|
|
final TestGesture gesture = await tester.startGesture(
|
|
position,
|
|
kind: PointerDeviceKind.unknown,
|
|
);
|
|
addTearDown(gesture.removePointer);
|
|
await tester.pump();
|
|
await gesture.moveTo(textOffsetToPosition(tester, 7));
|
|
await tester.pump();
|
|
await gesture.moveTo(textOffsetToPosition(tester, 10));
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(controller.selection.isCollapsed, isTrue);
|
|
expect(controller.selection.baseOffset, 10);
|
|
});
|
|
|
|
testWidgets(
|
|
'test TextSelectionGestureDetectorBuilder drag with RenderEditable viewport offset change',
|
|
(WidgetTester tester) async {
|
|
await pumpTextSelectionGestureDetectorBuilder(tester);
|
|
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
|
|
|
|
// Reconfigure the RenderEditable for multi-line.
|
|
renderEditable.maxLines = null;
|
|
final offset1 = ViewportOffset.fixed(20.0);
|
|
addTearDown(offset1.dispose);
|
|
renderEditable.offset = offset1;
|
|
renderEditable.layout(const BoxConstraints.tightFor(width: 400, height: 300.0));
|
|
await tester.pumpAndSettle();
|
|
|
|
final TestGesture gesture = await tester.startGesture(
|
|
const Offset(200.0, 200.0),
|
|
kind: PointerDeviceKind.mouse,
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(renderEditable.selectPositionAtCalled, isFalse);
|
|
|
|
await gesture.moveTo(const Offset(300.0, 200.0));
|
|
await tester.pumpAndSettle();
|
|
expect(renderEditable.selectPositionAtCalled, isTrue);
|
|
expect(renderEditable.selectPositionAtFrom, const Offset(200.0, 200.0));
|
|
expect(renderEditable.selectPositionAtTo, const Offset(300.0, 200.0));
|
|
|
|
// Move the viewport offset (scroll).
|
|
final offset2 = ViewportOffset.fixed(150.0);
|
|
addTearDown(offset2.dispose);
|
|
renderEditable.offset = offset2;
|
|
renderEditable.layout(const BoxConstraints.tightFor(width: 400, height: 300.0));
|
|
await tester.pumpAndSettle();
|
|
|
|
await gesture.moveTo(const Offset(300.0, 400.0));
|
|
await tester.pumpAndSettle();
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
expect(renderEditable.selectPositionAtCalled, isTrue);
|
|
expect(renderEditable.selectPositionAtFrom, const Offset(200.0, 70.0));
|
|
expect(renderEditable.selectPositionAtTo, const Offset(300.0, 400.0));
|
|
},
|
|
);
|
|
|
|
testWidgets('test TextSelectionGestureDetectorBuilder selection disabled', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await pumpTextSelectionGestureDetectorBuilder(tester, selectionEnabled: false);
|
|
final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0), pointer: 0);
|
|
await tester.pump(const Duration(seconds: 2));
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
final FakeEditableTextState state = tester.state(find.byType(FakeEditableText));
|
|
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
|
|
expect(state.showToolbarCalled, isTrue);
|
|
expect(renderEditable.selectWordsInRangeCalled, isFalse);
|
|
});
|
|
|
|
testWidgets('test TextSelectionGestureDetectorBuilder mouse drag disabled', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await pumpTextSelectionGestureDetectorBuilder(tester, selectionEnabled: false);
|
|
final TestGesture gesture = await tester.startGesture(
|
|
Offset.zero,
|
|
kind: PointerDeviceKind.mouse,
|
|
);
|
|
await tester.pump();
|
|
await gesture.moveTo(const Offset(50.0, 0));
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
|
|
expect(renderEditable.selectPositionAtCalled, isFalse);
|
|
});
|
|
|
|
testWidgets('test TextSelectionGestureDetectorBuilder forcePress disabled', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await pumpTextSelectionGestureDetectorBuilder(tester, forcePressEnabled: false);
|
|
final TestGesture gesture = await tester.createGesture();
|
|
await gesture.downWithCustomEvent(
|
|
const Offset(200.0, 200.0),
|
|
const PointerDownEvent(
|
|
position: Offset(200.0, 200.0),
|
|
pressure: 3.0,
|
|
pressureMax: 6.0,
|
|
pressureMin: 0.0,
|
|
),
|
|
);
|
|
await gesture.up();
|
|
await tester.pump();
|
|
|
|
final FakeEditableTextState state = tester.state(find.byType(FakeEditableText));
|
|
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
|
|
expect(state.showToolbarCalled, isFalse);
|
|
expect(renderEditable.selectWordsInRangeCalled, isFalse);
|
|
});
|
|
|
|
// Regression test for https://github.com/flutter/flutter/issues/37032.
|
|
testWidgets(
|
|
"selection handle's GestureDetector should not cover the entire screen",
|
|
(WidgetTester tester) async {
|
|
final controller = TextEditingController(text: 'a');
|
|
addTearDown(controller.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(body: TextField(autofocus: true, controller: controller)),
|
|
),
|
|
);
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
final Finder gestureDetector = find.descendant(
|
|
of: find.byType(CompositedTransformFollower),
|
|
matching: find.descendant(
|
|
of: find.byType(FadeTransition),
|
|
matching: find.byType(RawGestureDetector),
|
|
),
|
|
);
|
|
|
|
expect(gestureDetector, findsOneWidget);
|
|
// The GestureDetector's size should not exceed that of the TextField.
|
|
final Rect hitRect = tester.getRect(gestureDetector);
|
|
final Rect textFieldRect = tester.getRect(find.byType(TextField));
|
|
|
|
expect(hitRect.size.width, lessThanOrEqualTo(textFieldRect.size.width));
|
|
expect(hitRect.size.height, lessThanOrEqualTo(textFieldRect.size.height));
|
|
},
|
|
variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}),
|
|
);
|
|
|
|
group('SelectionOverlay', () {
|
|
Future<SelectionOverlay> pumpApp(
|
|
WidgetTester tester, {
|
|
ValueChanged<DragStartDetails>? onStartDragStart,
|
|
ValueChanged<DragUpdateDetails>? onStartDragUpdate,
|
|
ValueChanged<DragEndDetails>? onStartDragEnd,
|
|
ValueChanged<DragStartDetails>? onEndDragStart,
|
|
ValueChanged<DragUpdateDetails>? onEndDragUpdate,
|
|
ValueChanged<DragEndDetails>? onEndDragEnd,
|
|
VoidCallback? onSelectionHandleTapped,
|
|
TextSelectionControls? selectionControls,
|
|
TextMagnifierConfiguration? magnifierConfiguration,
|
|
}) async {
|
|
final column = UniqueKey();
|
|
final startHandleLayerLink = LayerLink();
|
|
final endHandleLayerLink = LayerLink();
|
|
final toolbarLayerLink = LayerLink();
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Column(
|
|
key: column,
|
|
children: <Widget>[
|
|
CompositedTransformTarget(
|
|
link: startHandleLayerLink,
|
|
child: const Text('start handle'),
|
|
),
|
|
CompositedTransformTarget(link: endHandleLayerLink, child: const Text('end handle')),
|
|
CompositedTransformTarget(link: toolbarLayerLink, child: const Text('toolbar')),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
final clipboardStatus = FakeClipboardStatusNotifier();
|
|
addTearDown(clipboardStatus.dispose);
|
|
|
|
return SelectionOverlay(
|
|
context: tester.element(find.byKey(column)),
|
|
onSelectionHandleTapped: onSelectionHandleTapped,
|
|
startHandleType: TextSelectionHandleType.collapsed,
|
|
startHandleLayerLink: startHandleLayerLink,
|
|
lineHeightAtStart: 0.0,
|
|
onStartHandleDragStart: onStartDragStart,
|
|
onStartHandleDragUpdate: onStartDragUpdate,
|
|
onStartHandleDragEnd: onStartDragEnd,
|
|
endHandleType: TextSelectionHandleType.collapsed,
|
|
endHandleLayerLink: endHandleLayerLink,
|
|
lineHeightAtEnd: 0.0,
|
|
onEndHandleDragStart: onEndDragStart,
|
|
onEndHandleDragUpdate: onEndDragUpdate,
|
|
onEndHandleDragEnd: onEndDragEnd,
|
|
clipboardStatus: clipboardStatus,
|
|
selectionDelegate: FakeTextSelectionDelegate(),
|
|
selectionControls: selectionControls,
|
|
selectionEndpoints: const <TextSelectionPoint>[],
|
|
toolbarLayerLink: toolbarLayerLink,
|
|
magnifierConfiguration: magnifierConfiguration ?? TextMagnifierConfiguration.disabled,
|
|
);
|
|
}
|
|
|
|
testWidgets('dispatches memory events', (WidgetTester tester) async {
|
|
await expectLater(
|
|
await memoryEvents(() async {
|
|
final SelectionOverlay overlay = await pumpApp(tester);
|
|
overlay.dispose();
|
|
}, SelectionOverlay),
|
|
areCreateAndDispose,
|
|
);
|
|
});
|
|
|
|
testWidgets('can show and hide handles', (WidgetTester tester) async {
|
|
final spy = TextSelectionControlsSpy();
|
|
final SelectionOverlay selectionOverlay = await pumpApp(tester, selectionControls: spy);
|
|
selectionOverlay
|
|
..startHandleType = TextSelectionHandleType.left
|
|
..endHandleType = TextSelectionHandleType.right
|
|
..selectionEndpoints = const <TextSelectionPoint>[
|
|
TextSelectionPoint(Offset(10, 10), TextDirection.ltr),
|
|
TextSelectionPoint(Offset(20, 20), TextDirection.ltr),
|
|
];
|
|
selectionOverlay.showHandles();
|
|
await tester.pump();
|
|
expect(find.byKey(spy.leftHandleKey), findsOneWidget);
|
|
expect(find.byKey(spy.rightHandleKey), findsOneWidget);
|
|
|
|
selectionOverlay.hideHandles();
|
|
await tester.pump();
|
|
expect(find.byKey(spy.leftHandleKey), findsNothing);
|
|
expect(find.byKey(spy.rightHandleKey), findsNothing);
|
|
|
|
selectionOverlay.showToolbar();
|
|
await tester.pump();
|
|
expect(find.byKey(spy.toolBarKey), findsOneWidget);
|
|
|
|
selectionOverlay.hideToolbar();
|
|
await tester.pump();
|
|
expect(find.byKey(spy.toolBarKey), findsNothing);
|
|
|
|
selectionOverlay.showHandles();
|
|
selectionOverlay.showToolbar();
|
|
await tester.pump();
|
|
expect(find.byKey(spy.leftHandleKey), findsOneWidget);
|
|
expect(find.byKey(spy.rightHandleKey), findsOneWidget);
|
|
expect(find.byKey(spy.toolBarKey), findsOneWidget);
|
|
|
|
selectionOverlay.hide();
|
|
await tester.pump();
|
|
expect(find.byKey(spy.leftHandleKey), findsNothing);
|
|
expect(find.byKey(spy.rightHandleKey), findsNothing);
|
|
expect(find.byKey(spy.toolBarKey), findsNothing);
|
|
|
|
selectionOverlay.dispose();
|
|
await tester.pumpAndSettle();
|
|
});
|
|
|
|
testWidgets('only paints one collapsed handle', (WidgetTester tester) async {
|
|
final spy = TextSelectionControlsSpy();
|
|
final SelectionOverlay selectionOverlay = await pumpApp(tester, selectionControls: spy);
|
|
selectionOverlay
|
|
..startHandleType = TextSelectionHandleType.collapsed
|
|
..endHandleType = TextSelectionHandleType.collapsed
|
|
..selectionEndpoints = const <TextSelectionPoint>[
|
|
TextSelectionPoint(Offset(10, 10), TextDirection.ltr),
|
|
TextSelectionPoint(Offset(20, 20), TextDirection.ltr),
|
|
];
|
|
selectionOverlay.showHandles();
|
|
await tester.pump();
|
|
expect(find.byKey(spy.leftHandleKey), findsNothing);
|
|
expect(find.byKey(spy.rightHandleKey), findsNothing);
|
|
expect(find.byKey(spy.collapsedHandleKey), findsOneWidget);
|
|
|
|
selectionOverlay.dispose();
|
|
await tester.pumpAndSettle();
|
|
});
|
|
|
|
testWidgets('can change handle parameter', (WidgetTester tester) async {
|
|
final spy = TextSelectionControlsSpy();
|
|
final SelectionOverlay selectionOverlay = await pumpApp(tester, selectionControls: spy);
|
|
selectionOverlay
|
|
..startHandleType = TextSelectionHandleType.left
|
|
..lineHeightAtStart = 10.0
|
|
..endHandleType = TextSelectionHandleType.right
|
|
..lineHeightAtEnd = 11.0
|
|
..selectionEndpoints = const <TextSelectionPoint>[
|
|
TextSelectionPoint(Offset(10, 10), TextDirection.ltr),
|
|
TextSelectionPoint(Offset(20, 20), TextDirection.ltr),
|
|
];
|
|
selectionOverlay.showHandles();
|
|
await tester.pump();
|
|
var leftHandle = tester.widget(find.byKey(spy.leftHandleKey)) as Text;
|
|
var rightHandle = tester.widget(find.byKey(spy.rightHandleKey)) as Text;
|
|
expect(leftHandle.data, 'height 10');
|
|
expect(rightHandle.data, 'height 11');
|
|
|
|
selectionOverlay
|
|
..startHandleType = TextSelectionHandleType.right
|
|
..lineHeightAtStart = 12.0
|
|
..endHandleType = TextSelectionHandleType.left
|
|
..lineHeightAtEnd = 13.0;
|
|
await tester.pump();
|
|
leftHandle = tester.widget(find.byKey(spy.leftHandleKey)) as Text;
|
|
rightHandle = tester.widget(find.byKey(spy.rightHandleKey)) as Text;
|
|
expect(leftHandle.data, 'height 13');
|
|
expect(rightHandle.data, 'height 12');
|
|
|
|
selectionOverlay.dispose();
|
|
await tester.pumpAndSettle();
|
|
});
|
|
|
|
testWidgets('can trigger selection handle onTap', (WidgetTester tester) async {
|
|
var selectionHandleTapped = false;
|
|
void handleTapped() => selectionHandleTapped = true;
|
|
final spy = TextSelectionControlsSpy();
|
|
final SelectionOverlay selectionOverlay = await pumpApp(
|
|
tester,
|
|
onSelectionHandleTapped: handleTapped,
|
|
selectionControls: spy,
|
|
);
|
|
selectionOverlay
|
|
..startHandleType = TextSelectionHandleType.left
|
|
..lineHeightAtStart = 10.0
|
|
..endHandleType = TextSelectionHandleType.right
|
|
..lineHeightAtEnd = 11.0
|
|
..selectionEndpoints = const <TextSelectionPoint>[
|
|
TextSelectionPoint(Offset(10, 10), TextDirection.ltr),
|
|
TextSelectionPoint(Offset(20, 20), TextDirection.ltr),
|
|
];
|
|
selectionOverlay.showHandles();
|
|
await tester.pump();
|
|
expect(find.byKey(spy.leftHandleKey), findsOneWidget);
|
|
expect(find.byKey(spy.rightHandleKey), findsOneWidget);
|
|
expect(selectionHandleTapped, isFalse);
|
|
|
|
await tester.tap(find.byKey(spy.leftHandleKey));
|
|
expect(selectionHandleTapped, isTrue);
|
|
|
|
selectionHandleTapped = false;
|
|
await tester.tap(find.byKey(spy.rightHandleKey));
|
|
expect(selectionHandleTapped, isTrue);
|
|
|
|
selectionOverlay.dispose();
|
|
await tester.pumpAndSettle();
|
|
});
|
|
|
|
testWidgets('can trigger selection handle drag', (WidgetTester tester) async {
|
|
DragStartDetails? startDragStartDetails;
|
|
DragUpdateDetails? startDragUpdateDetails;
|
|
DragEndDetails? startDragEndDetails;
|
|
DragStartDetails? endDragStartDetails;
|
|
DragUpdateDetails? endDragUpdateDetails;
|
|
DragEndDetails? endDragEndDetails;
|
|
void startDragStart(DragStartDetails details) => startDragStartDetails = details;
|
|
void startDragUpdate(DragUpdateDetails details) => startDragUpdateDetails = details;
|
|
void startDragEnd(DragEndDetails details) => startDragEndDetails = details;
|
|
void endDragStart(DragStartDetails details) => endDragStartDetails = details;
|
|
void endDragUpdate(DragUpdateDetails details) => endDragUpdateDetails = details;
|
|
void endDragEnd(DragEndDetails details) => endDragEndDetails = details;
|
|
final spy = TextSelectionControlsSpy();
|
|
final SelectionOverlay selectionOverlay = await pumpApp(
|
|
tester,
|
|
onStartDragStart: startDragStart,
|
|
onStartDragUpdate: startDragUpdate,
|
|
onStartDragEnd: startDragEnd,
|
|
onEndDragStart: endDragStart,
|
|
onEndDragUpdate: endDragUpdate,
|
|
onEndDragEnd: endDragEnd,
|
|
selectionControls: spy,
|
|
);
|
|
selectionOverlay
|
|
..startHandleType = TextSelectionHandleType.left
|
|
..lineHeightAtStart = 10.0
|
|
..endHandleType = TextSelectionHandleType.right
|
|
..lineHeightAtEnd = 11.0
|
|
..selectionEndpoints = const <TextSelectionPoint>[
|
|
TextSelectionPoint(Offset(10, 10), TextDirection.ltr),
|
|
TextSelectionPoint(Offset(20, 20), TextDirection.ltr),
|
|
];
|
|
selectionOverlay.showHandles();
|
|
await tester.pump();
|
|
expect(find.byKey(spy.leftHandleKey), findsOneWidget);
|
|
expect(find.byKey(spy.rightHandleKey), findsOneWidget);
|
|
expect(startDragStartDetails, isNull);
|
|
expect(startDragUpdateDetails, isNull);
|
|
expect(startDragEndDetails, isNull);
|
|
expect(endDragStartDetails, isNull);
|
|
expect(endDragUpdateDetails, isNull);
|
|
expect(endDragEndDetails, isNull);
|
|
|
|
final TestGesture gesture = await tester.startGesture(
|
|
tester.getCenter(find.byKey(spy.leftHandleKey)),
|
|
);
|
|
await tester.pump(const Duration(milliseconds: 200));
|
|
expect(
|
|
startDragStartDetails!.globalPosition,
|
|
tester.getCenter(find.byKey(spy.leftHandleKey)),
|
|
);
|
|
|
|
const newLocation = Offset(20, 20);
|
|
await gesture.moveTo(newLocation);
|
|
await tester.pump(const Duration(milliseconds: 20));
|
|
expect(startDragUpdateDetails!.globalPosition, newLocation);
|
|
|
|
await gesture.up();
|
|
await tester.pump(const Duration(milliseconds: 20));
|
|
expect(startDragEndDetails, isNotNull);
|
|
|
|
final TestGesture gesture2 = await tester.startGesture(
|
|
tester.getCenter(find.byKey(spy.rightHandleKey)),
|
|
);
|
|
addTearDown(gesture2.removePointer);
|
|
await tester.pump(const Duration(milliseconds: 200));
|
|
expect(endDragStartDetails!.globalPosition, tester.getCenter(find.byKey(spy.rightHandleKey)));
|
|
|
|
await gesture2.moveTo(newLocation);
|
|
await tester.pump(const Duration(milliseconds: 20));
|
|
expect(endDragUpdateDetails!.globalPosition, newLocation);
|
|
|
|
await gesture2.up();
|
|
await tester.pump(const Duration(milliseconds: 20));
|
|
expect(endDragEndDetails, isNotNull);
|
|
|
|
selectionOverlay.dispose();
|
|
await tester.pumpAndSettle();
|
|
});
|
|
|
|
testWidgets('can show magnifier when no handles exist', (WidgetTester tester) async {
|
|
final GlobalKey magnifierKey = GlobalKey();
|
|
Offset? builtGlobalGesturePosition;
|
|
Rect? builtFieldBounds;
|
|
final SelectionOverlay selectionOverlay = await pumpApp(
|
|
tester,
|
|
magnifierConfiguration: TextMagnifierConfiguration(
|
|
shouldDisplayHandlesInMagnifier: false,
|
|
magnifierBuilder:
|
|
(
|
|
BuildContext context,
|
|
MagnifierController controller,
|
|
ValueNotifier<MagnifierInfo>? notifier,
|
|
) {
|
|
builtGlobalGesturePosition = notifier?.value.globalGesturePosition;
|
|
builtFieldBounds = notifier?.value.fieldBounds;
|
|
return SizedBox.shrink(key: magnifierKey);
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(find.byKey(magnifierKey), findsNothing);
|
|
|
|
const globalGesturePosition = Offset(10.0, 10.0);
|
|
final Rect fieldBounds = Offset.zero & const Size(200.0, 50.0);
|
|
final info = MagnifierInfo(
|
|
globalGesturePosition: globalGesturePosition,
|
|
caretRect: Offset.zero & const Size(5.0, 20.0),
|
|
fieldBounds: fieldBounds,
|
|
currentLineBoundaries: Offset.zero & const Size(200.0, 50.0),
|
|
);
|
|
selectionOverlay.showMagnifier(info);
|
|
await tester.pump();
|
|
|
|
expect(tester.takeException(), isNull);
|
|
expect(find.byKey(magnifierKey), findsOneWidget);
|
|
expect(builtFieldBounds, fieldBounds);
|
|
expect(builtGlobalGesturePosition, globalGesturePosition);
|
|
|
|
selectionOverlay.dispose();
|
|
await tester.pumpAndSettle();
|
|
});
|
|
});
|
|
|
|
group('ClipboardStatusNotifier', () {
|
|
group('when Clipboard fails', () {
|
|
setUp(() {
|
|
final mockClipboard = MockClipboard(hasStringsThrows: true);
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
SystemChannels.platform,
|
|
mockClipboard.handleMethodCall,
|
|
);
|
|
});
|
|
|
|
tearDown(() {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
SystemChannels.platform,
|
|
null,
|
|
);
|
|
});
|
|
|
|
test('Clipboard API failure is gracefully recovered from', () async {
|
|
final notifier = ClipboardStatusNotifier();
|
|
expect(notifier.value, ClipboardStatus.unknown);
|
|
|
|
await expectLater(notifier.update(), completes);
|
|
expect(notifier.value, ClipboardStatus.unknown);
|
|
});
|
|
});
|
|
|
|
group('when Clipboard succeeds', () {
|
|
final mockClipboard = MockClipboard();
|
|
|
|
setUp(() {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
SystemChannels.platform,
|
|
mockClipboard.handleMethodCall,
|
|
);
|
|
});
|
|
|
|
tearDown(() {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
SystemChannels.platform,
|
|
null,
|
|
);
|
|
});
|
|
|
|
test('update sets value based on clipboard contents', () async {
|
|
final notifier = ClipboardStatusNotifier();
|
|
expect(notifier.value, ClipboardStatus.unknown);
|
|
|
|
await expectLater(notifier.update(), completes);
|
|
expect(notifier.value, ClipboardStatus.notPasteable);
|
|
|
|
mockClipboard.handleMethodCall(
|
|
const MethodCall('Clipboard.setData', <String, dynamic>{'text': 'pasteablestring'}),
|
|
);
|
|
await expectLater(notifier.update(), completes);
|
|
expect(notifier.value, ClipboardStatus.pasteable);
|
|
});
|
|
});
|
|
});
|
|
|
|
testWidgets('Mouse edge scrolling works in an outer scrollable', (WidgetTester tester) async {
|
|
// Regression test for https://github.com/flutter/flutter/issues/102484
|
|
final controller = TextEditingController(text: 'I love flutter!\n' * 8);
|
|
addTearDown(controller.dispose);
|
|
final editableTextKey = GlobalKey<EditableTextState>();
|
|
final delegate = FakeTextSelectionGestureDetectorBuilderDelegate(
|
|
editableTextKey: editableTextKey,
|
|
forcePressEnabled: false,
|
|
selectionEnabled: true,
|
|
);
|
|
|
|
final scrollController = ScrollController();
|
|
addTearDown(scrollController.dispose);
|
|
const kLineHeight = 16.0;
|
|
final provider = TextSelectionGestureDetectorBuilder(delegate: delegate);
|
|
final focusNode = FocusNode();
|
|
addTearDown(focusNode.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: SizedBox(
|
|
// Only 4 lines visible of 8 given.
|
|
height: kLineHeight * 4,
|
|
child: SingleChildScrollView(
|
|
controller: scrollController,
|
|
child: provider.buildGestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
child: EditableText(
|
|
key: editableTextKey,
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
backgroundCursorColor: Colors.white,
|
|
cursorColor: Colors.white,
|
|
style: const TextStyle(),
|
|
selectionControls: materialTextSelectionControls,
|
|
// EditableText will expand to the full 8 line height and will
|
|
// not scroll itself.
|
|
maxLines: null,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(controller.selection.isCollapsed, isTrue);
|
|
expect(controller.selection.baseOffset, -1);
|
|
expect(scrollController.position.pixels, 0.0);
|
|
|
|
final Offset position = textOffsetToPosition(tester, 4);
|
|
|
|
await tester.tapAt(position);
|
|
await tester.pump();
|
|
|
|
expect(controller.selection.isCollapsed, isTrue);
|
|
expect(controller.selection.baseOffset, 4);
|
|
|
|
// Select all text with the mouse.
|
|
final TestGesture gesture = await tester.startGesture(position, kind: PointerDeviceKind.mouse);
|
|
addTearDown(gesture.removePointer);
|
|
await tester.pump();
|
|
await gesture.moveTo(textOffsetToPosition(tester, (controller.text.length / 2).floor()));
|
|
await tester.pump();
|
|
await gesture.moveTo(textOffsetToPosition(tester, controller.text.length));
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(controller.selection.isCollapsed, isFalse);
|
|
expect(controller.selection.baseOffset, 4);
|
|
expect(controller.selection.extentOffset, controller.text.length);
|
|
expect(scrollController.position.pixels, scrollController.position.maxScrollExtent);
|
|
});
|
|
|
|
testWidgets(
|
|
'Mouse edge scrolling works with both an outer scrollable and scrolling in the EditableText',
|
|
(WidgetTester tester) async {
|
|
// Regression test for https://github.com/flutter/flutter/issues/102484
|
|
final controller = TextEditingController(text: 'I love flutter!\n' * 8);
|
|
addTearDown(controller.dispose);
|
|
final editableTextKey = GlobalKey<EditableTextState>();
|
|
final delegate = FakeTextSelectionGestureDetectorBuilderDelegate(
|
|
editableTextKey: editableTextKey,
|
|
forcePressEnabled: false,
|
|
selectionEnabled: true,
|
|
);
|
|
|
|
final scrollController = ScrollController();
|
|
addTearDown(scrollController.dispose);
|
|
const kLineHeight = 16.0;
|
|
final provider = TextSelectionGestureDetectorBuilder(delegate: delegate);
|
|
final focusNode = FocusNode();
|
|
addTearDown(focusNode.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: SizedBox(
|
|
// Only 4 lines visible of 8 given.
|
|
height: kLineHeight * 4,
|
|
child: SingleChildScrollView(
|
|
controller: scrollController,
|
|
child: provider.buildGestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
child: EditableText(
|
|
key: editableTextKey,
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
backgroundCursorColor: Colors.white,
|
|
cursorColor: Colors.white,
|
|
style: const TextStyle(),
|
|
selectionControls: materialTextSelectionControls,
|
|
// EditableText is taller than the SizedBox but not taller
|
|
// than the text.
|
|
maxLines: 6,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(controller.selection.isCollapsed, isTrue);
|
|
expect(controller.selection.baseOffset, -1);
|
|
expect(scrollController.position.pixels, 0.0);
|
|
|
|
final Offset position = textOffsetToPosition(tester, 4);
|
|
|
|
await tester.tapAt(position);
|
|
await tester.pump();
|
|
|
|
expect(controller.selection.isCollapsed, isTrue);
|
|
expect(controller.selection.baseOffset, 4);
|
|
|
|
// Select all text with the mouse.
|
|
final TestGesture gesture = await tester.startGesture(
|
|
position,
|
|
kind: PointerDeviceKind.mouse,
|
|
);
|
|
addTearDown(gesture.removePointer);
|
|
await tester.pump();
|
|
await gesture.moveTo(textOffsetToPosition(tester, (controller.text.length / 2).floor()));
|
|
await tester.pump();
|
|
await gesture.moveTo(textOffsetToPosition(tester, controller.text.length));
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(controller.selection.isCollapsed, isFalse);
|
|
expect(controller.selection.baseOffset, 4);
|
|
expect(controller.selection.extentOffset, controller.text.length);
|
|
expect(scrollController.position.pixels, scrollController.position.maxScrollExtent);
|
|
},
|
|
);
|
|
|
|
group('TextSelectionOverlay', () {
|
|
Future<TextSelectionOverlay> pumpApp(WidgetTester tester) async {
|
|
final column = UniqueKey();
|
|
final startHandleLayerLink = LayerLink();
|
|
final endHandleLayerLink = LayerLink();
|
|
final toolbarLayerLink = LayerLink();
|
|
|
|
final editableTextKey = UniqueKey();
|
|
final controller = TextEditingController();
|
|
addTearDown(controller.dispose);
|
|
final focusNode = FocusNode();
|
|
addTearDown(focusNode.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Column(
|
|
key: column,
|
|
children: <Widget>[
|
|
FakeEditableText(key: editableTextKey, controller: controller, focusNode: focusNode),
|
|
CompositedTransformTarget(
|
|
link: startHandleLayerLink,
|
|
child: const Text('start handle'),
|
|
),
|
|
CompositedTransformTarget(link: endHandleLayerLink, child: const Text('end handle')),
|
|
CompositedTransformTarget(link: toolbarLayerLink, child: const Text('toolbar')),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
return TextSelectionOverlay(
|
|
value: TextEditingValue.empty,
|
|
renderObject: tester.state<EditableTextState>(find.byKey(editableTextKey)).renderEditable,
|
|
context: tester.element(find.byKey(column)),
|
|
onSelectionHandleTapped: () {},
|
|
startHandleLayerLink: startHandleLayerLink,
|
|
endHandleLayerLink: endHandleLayerLink,
|
|
selectionDelegate: FakeTextSelectionDelegate(),
|
|
toolbarLayerLink: toolbarLayerLink,
|
|
magnifierConfiguration: TextMagnifierConfiguration.disabled,
|
|
);
|
|
}
|
|
|
|
testWidgets('dispatches memory events', (WidgetTester tester) async {
|
|
await expectLater(
|
|
await memoryEvents(() async {
|
|
final TextSelectionOverlay overlay = await pumpApp(tester);
|
|
overlay.dispose();
|
|
}, TextSelectionOverlay),
|
|
areCreateAndDispose,
|
|
);
|
|
});
|
|
});
|
|
|
|
testWidgets('Context menus', (WidgetTester tester) async {
|
|
final controller = TextEditingController(text: 'You make wine from sour grapes');
|
|
addTearDown(controller.dispose);
|
|
final editableTextKey = GlobalKey<EditableTextState>();
|
|
final delegate = FakeTextSelectionGestureDetectorBuilderDelegate(
|
|
editableTextKey: editableTextKey,
|
|
forcePressEnabled: false,
|
|
selectionEnabled: true,
|
|
);
|
|
final provider = TextSelectionGestureDetectorBuilder(delegate: delegate);
|
|
final focusNode = FocusNode();
|
|
addTearDown(focusNode.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: provider.buildGestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
child: EditableText(
|
|
key: editableTextKey,
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
backgroundCursorColor: Colors.white,
|
|
cursorColor: Colors.white,
|
|
style: const TextStyle(),
|
|
contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) {
|
|
return const Placeholder();
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final Offset position = textOffsetToPosition(tester, 0);
|
|
expect(find.byType(Placeholder), findsNothing);
|
|
await tester.tapAt(position, buttons: kSecondaryMouseButton, kind: PointerDeviceKind.mouse);
|
|
await tester.pump();
|
|
expect(find.byType(Placeholder), findsOneWidget);
|
|
}, skip: kIsWeb); // [intended] On web, we use native context menus for text fields.
|
|
}
|
|
|
|
class FakeTextSelectionGestureDetectorBuilderDelegate
|
|
implements TextSelectionGestureDetectorBuilderDelegate {
|
|
FakeTextSelectionGestureDetectorBuilderDelegate({
|
|
required this.editableTextKey,
|
|
required this.forcePressEnabled,
|
|
required this.selectionEnabled,
|
|
});
|
|
|
|
@override
|
|
final GlobalKey<EditableTextState> editableTextKey;
|
|
|
|
@override
|
|
final bool forcePressEnabled;
|
|
|
|
@override
|
|
final bool selectionEnabled;
|
|
}
|
|
|
|
class FakeEditableText extends EditableText {
|
|
FakeEditableText({required super.controller, required super.focusNode, super.key})
|
|
: super(
|
|
backgroundCursorColor: Colors.white,
|
|
cursorColor: Colors.white,
|
|
style: const TextStyle(),
|
|
);
|
|
|
|
@override
|
|
FakeEditableTextState createState() => FakeEditableTextState();
|
|
}
|
|
|
|
class FakeEditableTextState extends EditableTextState {
|
|
final GlobalKey _editableKey = GlobalKey();
|
|
bool showToolbarCalled = false;
|
|
bool toggleToolbarCalled = false;
|
|
bool showSpellCheckSuggestionsToolbarCalled = false;
|
|
bool markCurrentSelectionAsMisspelled = false;
|
|
|
|
@override
|
|
RenderEditable get renderEditable =>
|
|
_editableKey.currentContext!.findRenderObject()! as RenderEditable;
|
|
|
|
@override
|
|
bool showToolbar() {
|
|
showToolbarCalled = true;
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
void toggleToolbar([bool hideHandles = true]) {
|
|
toggleToolbarCalled = true;
|
|
return;
|
|
}
|
|
|
|
@override
|
|
bool showSpellCheckSuggestionsToolbar() {
|
|
showSpellCheckSuggestionsToolbarCalled = true;
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
SuggestionSpan? findSuggestionSpanAtCursorIndex(int cursorIndex) {
|
|
return markCurrentSelectionAsMisspelled
|
|
? const SuggestionSpan(TextRange(start: 7, end: 12), <String>['word', 'world', 'old'])
|
|
: null;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context);
|
|
return FakeEditable(this, key: _editableKey);
|
|
}
|
|
}
|
|
|
|
class FakeEditable extends LeafRenderObjectWidget {
|
|
const FakeEditable(this.delegate, {super.key});
|
|
final EditableTextState delegate;
|
|
|
|
@override
|
|
RenderEditable createRenderObject(BuildContext context) {
|
|
return FakeRenderEditable(delegate);
|
|
}
|
|
}
|
|
|
|
class FakeRenderEditable extends RenderEditable {
|
|
FakeRenderEditable(EditableTextState delegate) : this._(delegate, ViewportOffset.fixed(10.0));
|
|
|
|
FakeRenderEditable._(EditableTextState delegate, this._offset)
|
|
: super(
|
|
text: const TextSpan(style: TextStyle(height: 1.0, fontSize: 10.0), text: 'placeholder'),
|
|
startHandleLayerLink: LayerLink(),
|
|
endHandleLayerLink: LayerLink(),
|
|
ignorePointer: true,
|
|
textAlign: TextAlign.start,
|
|
textDirection: TextDirection.ltr,
|
|
locale: const Locale('en', 'US'),
|
|
offset: _offset,
|
|
textSelectionDelegate: delegate,
|
|
selection: const TextSelection.collapsed(offset: 0),
|
|
);
|
|
|
|
SelectionChangedCause? lastCause;
|
|
|
|
ViewportOffset _offset;
|
|
|
|
bool selectWordsInRangeCalled = false;
|
|
@override
|
|
void selectWordsInRange({
|
|
required Offset from,
|
|
Offset? to,
|
|
required SelectionChangedCause cause,
|
|
}) {
|
|
selectWordsInRangeCalled = true;
|
|
hasFocus = true;
|
|
lastCause = cause;
|
|
}
|
|
|
|
bool selectWordEdgeCalled = false;
|
|
@override
|
|
void selectWordEdge({required SelectionChangedCause cause}) {
|
|
selectWordEdgeCalled = true;
|
|
hasFocus = true;
|
|
lastCause = cause;
|
|
}
|
|
|
|
bool selectPositionAtCalled = false;
|
|
Offset? selectPositionAtFrom;
|
|
Offset? selectPositionAtTo;
|
|
@override
|
|
void selectPositionAt({required Offset from, Offset? to, required SelectionChangedCause cause}) {
|
|
selectPositionAtCalled = true;
|
|
selectPositionAtFrom = from;
|
|
selectPositionAtTo = to;
|
|
hasFocus = true;
|
|
lastCause = cause;
|
|
}
|
|
|
|
bool selectPositionCalled = false;
|
|
@override
|
|
void selectPosition({required SelectionChangedCause cause}) {
|
|
selectPositionCalled = true;
|
|
lastCause = cause;
|
|
return super.selectPosition(cause: cause);
|
|
}
|
|
|
|
bool selectWordCalled = false;
|
|
@override
|
|
void selectWord({required SelectionChangedCause cause}) {
|
|
selectWordCalled = true;
|
|
hasFocus = true;
|
|
lastCause = cause;
|
|
}
|
|
|
|
@override
|
|
bool hasFocus = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_offset.dispose();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
class TextSelectionControlsSpy extends TextSelectionControls {
|
|
UniqueKey leftHandleKey = UniqueKey();
|
|
UniqueKey rightHandleKey = UniqueKey();
|
|
UniqueKey collapsedHandleKey = UniqueKey();
|
|
UniqueKey toolBarKey = UniqueKey();
|
|
|
|
@override
|
|
Widget buildHandle(
|
|
BuildContext context,
|
|
TextSelectionHandleType type,
|
|
double textLineHeight, [
|
|
VoidCallback? onTap,
|
|
]) {
|
|
return ElevatedButton(
|
|
onPressed: onTap,
|
|
child: Text(
|
|
key: switch (type) {
|
|
TextSelectionHandleType.left => leftHandleKey,
|
|
TextSelectionHandleType.right => rightHandleKey,
|
|
TextSelectionHandleType.collapsed => collapsedHandleKey,
|
|
},
|
|
'height ${textLineHeight.toInt()}',
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget buildToolbar(
|
|
BuildContext context,
|
|
Rect globalEditableRegion,
|
|
double textLineHeight,
|
|
Offset position,
|
|
List<TextSelectionPoint> endpoints,
|
|
TextSelectionDelegate delegate,
|
|
ValueListenable<ClipboardStatus>? clipboardStatus,
|
|
Offset? lastSecondaryTapDownPosition,
|
|
) {
|
|
return Text('dummy', key: toolBarKey);
|
|
}
|
|
|
|
@override
|
|
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
|
|
return Offset.zero;
|
|
}
|
|
|
|
@override
|
|
Size getHandleSize(double textLineHeight) {
|
|
return Size(textLineHeight, textLineHeight);
|
|
}
|
|
}
|
|
|
|
class FakeClipboardStatusNotifier extends ClipboardStatusNotifier {
|
|
FakeClipboardStatusNotifier() : super(value: ClipboardStatus.unknown);
|
|
|
|
bool updateCalled = false;
|
|
@override
|
|
Future<void> update() async {
|
|
updateCalled = true;
|
|
}
|
|
}
|
|
|
|
class FakeTextSelectionDelegate extends Fake implements TextSelectionDelegate {
|
|
@override
|
|
void cutSelection(SelectionChangedCause cause) {}
|
|
|
|
@override
|
|
void copySelection(SelectionChangedCause cause) {}
|
|
}
|