Skip to content

Commit 5f17bad

Browse files
authored
[Android] Add custom system-wide text selection toolbar buttons for SelectableRegion (#141103)
## Description This PR adds custom system-wide text selection toolbar buttons on Android for `SelectableRegion` and `SelectionArea`. flutter/flutter#139738 adds those buttons for `EditableText` (which is used by `TextField` and `SelectableText` but not by `SelectionArea`). ## Related Issue Step 5 for flutter/flutter#139361 ## Tests Adds 2 tests.
1 parent f7f437c commit 5f17bad

3 files changed

Lines changed: 142 additions & 1 deletion

File tree

packages/flutter/lib/src/widgets/selectable_region.dart

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,12 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
331331
@visibleForTesting
332332
SelectionOverlay? get selectionOverlay => _selectionOverlay;
333333

334+
/// The text processing service used to retrieve the native text processing actions.
335+
final ProcessTextService _processTextService = DefaultProcessTextService();
336+
337+
/// The list of native text processing actions provided by the engine.
338+
final List<ProcessTextAction> _processTextActions = <ProcessTextAction>[];
339+
334340
@override
335341
void initState() {
336342
super.initState();
@@ -359,6 +365,14 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
359365
instance.onSecondaryTapDown = _handleRightClickDown;
360366
},
361367
);
368+
_initProcessTextActions();
369+
}
370+
371+
/// Query the engine to initialize the list of text processing actions to show
372+
/// in the text selection toolbar.
373+
Future<void> _initProcessTextActions() async {
374+
_processTextActions.clear();
375+
_processTextActions.addAll(await _processTextService.queryTextActions());
362376
}
363377

364378
@override
@@ -1203,7 +1217,29 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
12031217
hideToolbar();
12041218
}
12051219
},
1206-
);
1220+
)..addAll(_textProcessingActionButtonItems);
1221+
}
1222+
1223+
List<ContextMenuButtonItem> get _textProcessingActionButtonItems {
1224+
final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
1225+
final SelectedContent? data = _selectable?.getSelectedContent();
1226+
if (data == null) {
1227+
return buttonItems;
1228+
}
1229+
1230+
for (final ProcessTextAction action in _processTextActions) {
1231+
buttonItems.add(ContextMenuButtonItem(
1232+
label: action.label,
1233+
onPressed: () async {
1234+
final String selectedText = data.plainText;
1235+
if (selectedText.isNotEmpty) {
1236+
await _processTextService.processTextAction(action.id, selectedText, true);
1237+
hideToolbar();
1238+
}
1239+
},
1240+
));
1241+
}
1242+
return buttonItems;
12071243
}
12081244

12091245
/// The line height at the start of the current selection.

packages/flutter/test/material/selection_area_test.dart

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import 'package:flutter/foundation.dart';
77
import 'package:flutter/gestures.dart';
88
import 'package:flutter/material.dart';
99
import 'package:flutter/rendering.dart';
10+
import 'package:flutter/services.dart';
1011
import 'package:flutter_test/flutter_test.dart';
1112

13+
import '../widgets/process_text_utils.dart';
1214

1315
Offset textOffsetToPosition(RenderParagraph paragraph, int offset) {
1416
const Rect caret = Rect.fromLTWH(0.0, 0.0, 2.0, 20.0);
@@ -200,6 +202,54 @@ void main() {
200202
skip: kIsWeb, // [intended]
201203
);
202204

205+
testWidgets('Text processing actions are added to the toolbar', (WidgetTester tester) async {
206+
final MockProcessTextHandler mockProcessTextHandler = MockProcessTextHandler();
207+
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger
208+
.setMockMethodCallHandler(SystemChannels.processText, mockProcessTextHandler.handleMethodCall);
209+
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.processText, null));
210+
211+
final FocusNode focusNode = FocusNode();
212+
addTearDown(focusNode.dispose);
213+
214+
await tester.pumpWidget(
215+
MaterialApp(
216+
home: SelectionArea(
217+
focusNode: focusNode,
218+
child: const Text('How are you?'),
219+
),
220+
),
221+
);
222+
await tester.pumpAndSettle();
223+
224+
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
225+
226+
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(
227+
find.descendant(
228+
of: find.text('How are you?'),
229+
matching: find.byType(RichText),
230+
),
231+
);
232+
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 6)); // at the 'r'
233+
addTearDown(gesture.removePointer);
234+
await tester.pump(const Duration(milliseconds: 500));
235+
// `are` is selected.
236+
expect(paragraph.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
237+
238+
await gesture.up();
239+
await tester.pumpAndSettle();
240+
241+
// The toolbar is visible.
242+
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
243+
244+
// The text processing actions are visible on Android only.
245+
final bool areTextActionsSupported = defaultTargetPlatform == TargetPlatform.android;
246+
expect(find.text(fakeAction1Label), areTextActionsSupported ? findsOneWidget : findsNothing);
247+
expect(find.text(fakeAction2Label), areTextActionsSupported ? findsOneWidget : findsNothing);
248+
},
249+
variant: TargetPlatformVariant.all(),
250+
skip: kIsWeb, // [intended]
251+
);
252+
203253
testWidgets('onSelectionChange is called when the selection changes', (WidgetTester tester) async {
204254
SelectedContent? content;
205255

packages/flutter/test/widgets/selectable_region_test.dart

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'package:flutter_test/flutter_test.dart';
1111

1212
import 'clipboard_utils.dart';
1313
import 'keyboard_utils.dart';
14+
import 'process_text_utils.dart';
1415
import 'semantics_tester.dart';
1516

1617
Offset textOffsetToPosition(RenderParagraph paragraph, int offset) {
@@ -3368,6 +3369,60 @@ void main() {
33683369
skip: kIsWeb, // [intended]
33693370
);
33703371

3372+
testWidgets('Text processing actions are added to the toolbar', (WidgetTester tester) async {
3373+
final MockProcessTextHandler mockProcessTextHandler = MockProcessTextHandler();
3374+
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger
3375+
.setMockMethodCallHandler(SystemChannels.processText, mockProcessTextHandler.handleMethodCall);
3376+
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.processText, null));
3377+
3378+
Set<String?> buttonLabels = <String?>{};
3379+
final FocusNode focusNode = FocusNode();
3380+
addTearDown(focusNode.dispose);
3381+
3382+
await tester.pumpWidget(
3383+
MaterialApp(
3384+
home: SelectableRegion(
3385+
focusNode: focusNode,
3386+
selectionControls: materialTextSelectionHandleControls,
3387+
contextMenuBuilder: (
3388+
BuildContext context,
3389+
SelectableRegionState selectableRegionState,
3390+
) {
3391+
buttonLabels = selectableRegionState.contextMenuButtonItems
3392+
.map((ContextMenuButtonItem buttonItem) => buttonItem.label)
3393+
.toSet();
3394+
return const SizedBox.shrink();
3395+
},
3396+
child: const Text('How are you?'),
3397+
),
3398+
),
3399+
);
3400+
await tester.pumpAndSettle();
3401+
3402+
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(
3403+
find.descendant(
3404+
of: find.text('How are you?'),
3405+
matching: find.byType(RichText),
3406+
),
3407+
);
3408+
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 6)); // at the 'r'
3409+
addTearDown(gesture.removePointer);
3410+
await tester.pump(const Duration(milliseconds: 500));
3411+
// `are` is selected.
3412+
expect(paragraph.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
3413+
3414+
await gesture.up();
3415+
await tester.pumpAndSettle();
3416+
3417+
// The text processing actions are available on Android only.
3418+
final bool areTextActionsSupported = defaultTargetPlatform == TargetPlatform.android;
3419+
expect(buttonLabels.contains(fakeAction1Label), areTextActionsSupported);
3420+
expect(buttonLabels.contains(fakeAction2Label), areTextActionsSupported);
3421+
},
3422+
variant: TargetPlatformVariant.all(),
3423+
skip: kIsWeb, // [intended]
3424+
);
3425+
33713426
testWidgets('onSelectionChange is called when the selection changes through gestures', (WidgetTester tester) async {
33723427
SelectedContent? content;
33733428
final FocusNode focusNode = FocusNode();

0 commit comments

Comments
 (0)