Skip to content

Commit

Permalink
Add custom system-wide text selection toolbar buttons on Android (flu…
Browse files Browse the repository at this point in the history
…tter#139738)

## Description

This PR adds custom system-wide text selection toolbar buttons on Android.
~~This is a WIP until flutter#139479 is merged (potential conflicts).~~

## Related Issue

Fixes flutter#139361

## Tests

Adds 5 tests.
  • Loading branch information
bleroux authored and CoderDake committed Dec 28, 2023
1 parent fe52307 commit 63ee4ca
Show file tree
Hide file tree
Showing 3 changed files with 365 additions and 7 deletions.
64 changes: 57 additions & 7 deletions packages/flutter/lib/src/widgets/editable_text.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2199,6 +2199,12 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien

bool get _spellCheckResultsReceived => spellCheckEnabled && spellCheckResults != null && spellCheckResults!.suggestionSpans.isNotEmpty;

/// 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>[];

/// Whether to create an input connection with the platform for text editing
/// or not.
///
Expand Down Expand Up @@ -2410,14 +2416,14 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
clipboardStatus.update();
}

bool get _allowPaste {
return !widget.readOnly && textEditingValue.selection.isValid;
}

/// Paste text from [Clipboard].
@override
Future<void> pasteText(SelectionChangedCause cause) async {
if (widget.readOnly) {
return;
}
final TextSelection selection = textEditingValue.selection;
if (!selection.isValid) {
if (!_allowPaste) {
return;
}
// Snapshot the input before using `await`.
Expand All @@ -2426,16 +2432,24 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
if (data == null) {
return;
}
_pasteText(cause, data.text!);
}

void _pasteText(SelectionChangedCause cause, String text) {
if (!_allowPaste) {
return;
}

// After the paste, the cursor should be collapsed and located after the
// pasted content.
final TextSelection selection = textEditingValue.selection;
final int lastSelectionIndex = math.max(selection.baseOffset, selection.extentOffset);
final TextEditingValue collapsedTextEditingValue = textEditingValue.copyWith(
selection: TextSelection.collapsed(offset: lastSelectionIndex),
);

userUpdateTextEditingValue(
collapsedTextEditingValue.replaced(selection, data.text!),
collapsedTextEditingValue.replaced(selection, text),
cause,
);
if (cause == SelectionChangedCause.toolbar) {
Expand Down Expand Up @@ -2789,7 +2803,35 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
onLiveTextInput: liveTextInputEnabled
? () => _startLiveTextInput(SelectionChangedCause.toolbar)
: null,
);
)..addAll(_textProcessingActionButtonItems);
}

List<ContextMenuButtonItem> get _textProcessingActionButtonItems {
final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
final TextSelection selection = textEditingValue.selection;
if (widget.obscureText || !selection.isValid || selection.isCollapsed) {
return buttonItems;
}

for (final ProcessTextAction action in _processTextActions) {
buttonItems.add(ContextMenuButtonItem(
label: action.label,
onPressed: () async {
final String selectedText = selection.textInside(textEditingValue.text);
if (selectedText.isNotEmpty) {
final String? processedText = await _processTextService.processTextAction(action.id, selectedText, widget.readOnly);
// If an activity does not return a modified version, just hide the toolbar.
// Otherwise use the result to replace the selected text.
if (processedText != null && _allowPaste) {
_pasteText(SelectionChangedCause.toolbar, processedText);
} else {
hideToolbar();
}
}
},
));
}
return buttonItems;
}

// State lifecycle:
Expand All @@ -2804,6 +2846,14 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_scrollController.addListener(_onEditableScroll);
_cursorVisibilityNotifier.value = widget.showCursor;
_spellCheckConfiguration = _inferSpellCheckConfiguration(widget.spellCheckConfiguration);
_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());
}

// Whether `TickerMode.of(context)` is true and animations (like blinking the
Expand Down
265 changes: 265 additions & 0 deletions packages/flutter/test/material/text_field_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import 'package:flutter_test/flutter_test.dart';
import '../widgets/clipboard_utils.dart';
import '../widgets/editable_text_utils.dart';
import '../widgets/live_text_utils.dart';
import '../widgets/process_text_utils.dart';
import '../widgets/semantics_tester.dart';
import '../widgets/text_selection_toolbar_utils.dart';
import 'feedback_tester.dart';
Expand Down Expand Up @@ -17090,6 +17091,270 @@ void main() {
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }),
);

testWidgets('Text processing actions are added to the toolbar', (WidgetTester tester) async {
const String initialText = 'I love Flutter';
final TextEditingController controller = _textEditingController(text: initialText);
final MockProcessTextHandler mockProcessTextHandler = MockProcessTextHandler();
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.processText, mockProcessTextHandler.handleMethodCall);
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.processText, null));

await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
controller: controller,
),
),
),
);

// Long press to put the cursor after the "F".
final int index = initialText.indexOf('F');
await tester.longPressAt(textOffsetToPosition(tester, index));
await tester.pump();

// Double tap on the same location to select the word around the cursor.
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 7, extentOffset: 14));

// The toolbar is visible and the text processing actions are visible on Android.
final bool areTextActionsSupported = defaultTargetPlatform == TargetPlatform.android;
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
expect(find.text(fakeAction1Label), areTextActionsSupported ? findsOneWidget : findsNothing);
expect(find.text(fakeAction2Label), areTextActionsSupported ? findsOneWidget : findsNothing);
},
variant: TargetPlatformVariant.all(),
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
);

testWidgets(
'Text processing actions are not added to the toolbar for obscured text',
(WidgetTester tester) async {
const String initialText = 'I love Flutter';
final TextEditingController controller = _textEditingController(text: initialText);
final MockProcessTextHandler mockProcessTextHandler = MockProcessTextHandler();
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.processText, mockProcessTextHandler.handleMethodCall);
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.processText, null));

await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
obscureText: true,
controller: controller,
),
),
),
);

// Long press to put the cursor after the "F".
final int index = initialText.indexOf('F');
await tester.longPressAt(textOffsetToPosition(tester, index));
await tester.pump();

// Double tap on the same location to select the word around the cursor.
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 14));

// The toolbar is visible but does not contain the text processing actions.
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
expect(find.text(fakeAction1Label), findsNothing);
expect(find.text(fakeAction2Label), findsNothing);
},
variant: TargetPlatformVariant.all(),
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
);

testWidgets(
'Text processing actions are not added to the toolbar if selection is collapsed (Android only)',
(WidgetTester tester) async {
const String initialText = 'I love Flutter';
final TextEditingController controller = _textEditingController(text: initialText);
final MockProcessTextHandler mockProcessTextHandler = MockProcessTextHandler();
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.processText, mockProcessTextHandler.handleMethodCall);
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.processText, null));

await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
controller: controller,
),
),
),
);

// Open the text selection toolbar.
await showSelectionMenuAt(tester, controller, initialText.indexOf('F'));
await skipPastScrollingAnimation(tester);

// The toolbar is visible but does not contain the text processing actions.
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
expect(controller.selection.isCollapsed, true);

expect(find.text(fakeAction1Label), findsNothing);
expect(find.text(fakeAction2Label), findsNothing);
},
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
);

testWidgets(
'Invoke a text processing action that does not return a value (Android only)',
(WidgetTester tester) async {
const String initialText = 'I love Flutter';
final TextEditingController controller = _textEditingController(text: initialText);
final MockProcessTextHandler mockProcessTextHandler = MockProcessTextHandler();
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.processText, mockProcessTextHandler.handleMethodCall);
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.processText, null));

await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
controller: controller,
),
),
),
);

// Long press to put the cursor after the "F".
final int index = initialText.indexOf('F');
await tester.longPressAt(textOffsetToPosition(tester, index));
await tester.pump();

// Double tap on the same location to select the word around the cursor.
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 7, extentOffset: 14));

// Run an action that does not return a processed text.
await tester.tap(find.text(fakeAction2Label));
await tester.pump(const Duration(milliseconds: 200));

// The action was correctly called.
expect(mockProcessTextHandler.lastCalledActionId, fakeAction2Id);
expect(mockProcessTextHandler.lastTextToProcess, 'Flutter');

// The text field was not updated.
expect(controller.text, initialText);

// The toolbar is no longer visible.
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
},
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
);

testWidgets(
'Invoking a text processing action that returns a value replaces the selection (Android only)',
(WidgetTester tester) async {
const String initialText = 'I love Flutter';
final TextEditingController controller = _textEditingController(text: initialText);
final MockProcessTextHandler mockProcessTextHandler = MockProcessTextHandler();
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.processText, mockProcessTextHandler.handleMethodCall);
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.processText, null));

await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
controller: controller,
),
),
),
);

// Long press to put the cursor after the "F".
final int index = initialText.indexOf('F');
await tester.longPressAt(textOffsetToPosition(tester, index));
await tester.pump();

// Double tap on the same location to select the word around the cursor.
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 7, extentOffset: 14));

// Run an action that returns a processed text.
await tester.tap(find.text(fakeAction1Label));
await tester.pump(const Duration(milliseconds: 200));

// The action was correctly called.
expect(mockProcessTextHandler.lastCalledActionId, fakeAction1Id);
expect(mockProcessTextHandler.lastTextToProcess, 'Flutter');

// The text field was updated.
expect(controller.text, 'I love Flutter!!!');

// The toolbar is no longer visible.
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
},
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
);

testWidgets(
'Invoking a text processing action that returns a value does not replace the selection of a readOnly text field (Android only)',
(WidgetTester tester) async {
const String initialText = 'I love Flutter';
final TextEditingController controller = _textEditingController(text: initialText);
final MockProcessTextHandler mockProcessTextHandler = MockProcessTextHandler();
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.processText, mockProcessTextHandler.handleMethodCall);
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.processText, null));

await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
readOnly: true,
controller: controller,
),
),
),
);

// Long press to put the cursor after the "F".
final int index = initialText.indexOf('F');
await tester.longPressAt(textOffsetToPosition(tester, index));
await tester.pump();

// Double tap on the same location to select the word around the cursor.
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 7, extentOffset: 14));

// Run an action that returns a processed text.
await tester.tap(find.text(fakeAction1Label));
await tester.pump(const Duration(milliseconds: 200));

// The Action was correctly called.
expect(mockProcessTextHandler.lastCalledActionId, fakeAction1Id);
expect(mockProcessTextHandler.lastTextToProcess, 'Flutter');

// The text field was not updated.
expect(controller.text, initialText);

// The toolbar is no longer visible.
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
},
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
);
}

/// A Simple widget for testing the obscure text.
Expand Down

0 comments on commit 63ee4ca

Please sign in to comment.