Unverified Commit 89f0c69e authored by Bruno Leroux's avatar Bruno Leroux Committed by GitHub

Add custom system-wide text selection toolbar buttons on Android (#139738)

## Description

This PR adds custom system-wide text selection toolbar buttons on Android.
~~This is a WIP until https://github.com/flutter/flutter/pull/139479 is merged (potential conflicts).~~

## Related Issue

Fixes https://github.com/flutter/flutter/issues/139361

## Tests

Adds 5 tests.
parent 6664dfec
......@@ -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.
///
......@@ -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`.
......@@ -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) {
......@@ -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:
......@@ -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
......
// 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/services.dart';
const String fakeAction1Id = 'fakeActivity.fakeAction1';
const String fakeAction2Id = 'fakeActivity.fakeAction2';
const String fakeAction1Label = 'Action1';
const String fakeAction2Label = 'Action2';
class MockProcessTextHandler {
String? lastCalledActionId;
String? lastTextToProcess;
Future<Object?> handleMethodCall(MethodCall call) async {
if (call.method == 'ProcessText.queryTextActions') {
// Simulate that only the Android engine will return a non-null result.
if (defaultTargetPlatform == TargetPlatform.android) {
return <String, String>{
fakeAction1Id: fakeAction1Label,
fakeAction2Id: fakeAction2Label,
};
}
}
if (call.method == 'ProcessText.processTextAction') {
final List<dynamic> args = call.arguments as List<dynamic>;
final String actionId = args[0] as String;
final String textToProcess = args[1] as String;
lastCalledActionId = actionId;
lastTextToProcess = textToProcess;
if (actionId == fakeAction1Id) {
// Simulates an action that returns a transformed text.
return '$textToProcess!!!';
}
// Simulates an action that failed or does not transform text.
return null;
}
return null;
}
}
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