Unverified Commit 5f17badf authored by Bruno Leroux's avatar Bruno Leroux Committed by GitHub

[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`.

https://github.com/flutter/flutter/pull/139738 adds those buttons for `EditableText` (which is used by `TextField` and `SelectableText` but not by `SelectionArea`).

## Related Issue

Step 5 for https://github.com/flutter/flutter/issues/139361

## Tests

Adds 2 tests.
parent f7f437ce
...@@ -331,6 +331,12 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe ...@@ -331,6 +331,12 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
@visibleForTesting @visibleForTesting
SelectionOverlay? get selectionOverlay => _selectionOverlay; SelectionOverlay? get selectionOverlay => _selectionOverlay;
/// The text processing service used to retrieve the native text processing actions.
final ProcessTextService _processTextService = DefaultProcessTextService();
/// The list of native text processing actions provided by the engine.
final List<ProcessTextAction> _processTextActions = <ProcessTextAction>[];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
...@@ -359,6 +365,14 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe ...@@ -359,6 +365,14 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
instance.onSecondaryTapDown = _handleRightClickDown; instance.onSecondaryTapDown = _handleRightClickDown;
}, },
); );
_initProcessTextActions();
}
/// Query the engine to initialize the list of text processing actions to show
/// in the text selection toolbar.
Future<void> _initProcessTextActions() async {
_processTextActions.clear();
_processTextActions.addAll(await _processTextService.queryTextActions());
} }
@override @override
...@@ -1203,7 +1217,29 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe ...@@ -1203,7 +1217,29 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
hideToolbar(); hideToolbar();
} }
}, },
); )..addAll(_textProcessingActionButtonItems);
}
List<ContextMenuButtonItem> get _textProcessingActionButtonItems {
final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
final SelectedContent? data = _selectable?.getSelectedContent();
if (data == null) {
return buttonItems;
}
for (final ProcessTextAction action in _processTextActions) {
buttonItems.add(ContextMenuButtonItem(
label: action.label,
onPressed: () async {
final String selectedText = data.plainText;
if (selectedText.isNotEmpty) {
await _processTextService.processTextAction(action.id, selectedText, true);
hideToolbar();
}
},
));
}
return buttonItems;
} }
/// The line height at the start of the current selection. /// The line height at the start of the current selection.
......
...@@ -7,8 +7,10 @@ import 'package:flutter/foundation.dart'; ...@@ -7,8 +7,10 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../widgets/process_text_utils.dart';
Offset textOffsetToPosition(RenderParagraph paragraph, int offset) { Offset textOffsetToPosition(RenderParagraph paragraph, int offset) {
const Rect caret = Rect.fromLTWH(0.0, 0.0, 2.0, 20.0); const Rect caret = Rect.fromLTWH(0.0, 0.0, 2.0, 20.0);
...@@ -200,6 +202,54 @@ void main() { ...@@ -200,6 +202,54 @@ void main() {
skip: kIsWeb, // [intended] skip: kIsWeb, // [intended]
); );
testWidgets('Text processing actions are added to the toolbar', (WidgetTester tester) async {
final MockProcessTextHandler mockProcessTextHandler = MockProcessTextHandler();
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.processText, mockProcessTextHandler.handleMethodCall);
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.processText, null));
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: SelectionArea(
focusNode: focusNode,
child: const Text('How are you?'),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(
find.descendant(
of: find.text('How are you?'),
matching: find.byType(RichText),
),
);
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 6)); // at the 'r'
addTearDown(gesture.removePointer);
await tester.pump(const Duration(milliseconds: 500));
// `are` is selected.
expect(paragraph.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
await gesture.up();
await tester.pumpAndSettle();
// The toolbar is visible.
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
// The text processing actions are visible on Android only.
final bool areTextActionsSupported = defaultTargetPlatform == TargetPlatform.android;
expect(find.text(fakeAction1Label), areTextActionsSupported ? findsOneWidget : findsNothing);
expect(find.text(fakeAction2Label), areTextActionsSupported ? findsOneWidget : findsNothing);
},
variant: TargetPlatformVariant.all(),
skip: kIsWeb, // [intended]
);
testWidgets('onSelectionChange is called when the selection changes', (WidgetTester tester) async { testWidgets('onSelectionChange is called when the selection changes', (WidgetTester tester) async {
SelectedContent? content; SelectedContent? content;
......
...@@ -11,6 +11,7 @@ import 'package:flutter_test/flutter_test.dart'; ...@@ -11,6 +11,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'clipboard_utils.dart'; import 'clipboard_utils.dart';
import 'keyboard_utils.dart'; import 'keyboard_utils.dart';
import 'process_text_utils.dart';
import 'semantics_tester.dart'; import 'semantics_tester.dart';
Offset textOffsetToPosition(RenderParagraph paragraph, int offset) { Offset textOffsetToPosition(RenderParagraph paragraph, int offset) {
...@@ -3368,6 +3369,60 @@ void main() { ...@@ -3368,6 +3369,60 @@ void main() {
skip: kIsWeb, // [intended] skip: kIsWeb, // [intended]
); );
testWidgets('Text processing actions are added to the toolbar', (WidgetTester tester) async {
final MockProcessTextHandler mockProcessTextHandler = MockProcessTextHandler();
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.processText, mockProcessTextHandler.handleMethodCall);
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.processText, null));
Set<String?> buttonLabels = <String?>{};
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: focusNode,
selectionControls: materialTextSelectionHandleControls,
contextMenuBuilder: (
BuildContext context,
SelectableRegionState selectableRegionState,
) {
buttonLabels = selectableRegionState.contextMenuButtonItems
.map((ContextMenuButtonItem buttonItem) => buttonItem.label)
.toSet();
return const SizedBox.shrink();
},
child: const Text('How are you?'),
),
),
);
await tester.pumpAndSettle();
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(
find.descendant(
of: find.text('How are you?'),
matching: find.byType(RichText),
),
);
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 6)); // at the 'r'
addTearDown(gesture.removePointer);
await tester.pump(const Duration(milliseconds: 500));
// `are` is selected.
expect(paragraph.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
await gesture.up();
await tester.pumpAndSettle();
// The text processing actions are available on Android only.
final bool areTextActionsSupported = defaultTargetPlatform == TargetPlatform.android;
expect(buttonLabels.contains(fakeAction1Label), areTextActionsSupported);
expect(buttonLabels.contains(fakeAction2Label), areTextActionsSupported);
},
variant: TargetPlatformVariant.all(),
skip: kIsWeb, // [intended]
);
testWidgets('onSelectionChange is called when the selection changes through gestures', (WidgetTester tester) async { testWidgets('onSelectionChange is called when the selection changes through gestures', (WidgetTester tester) async {
SelectedContent? content; SelectedContent? content;
final FocusNode focusNode = FocusNode(); final FocusNode focusNode = FocusNode();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment