Unverified Commit 2a67bf78 authored by Jami Couch's avatar Jami Couch Committed by GitHub

Add support for iOS UndoManager (#98294)

Add support for iOS UndoManager
parent a16e620e
// 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.
// Flutter code sample for UndoHistoryController.
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
static const String _title = 'Flutter Code Sample';
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: _title,
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final TextEditingController _controller = TextEditingController();
final FocusNode _focusNode = FocusNode();
final UndoHistoryController _undoController = UndoHistoryController();
TextStyle? get enabledStyle => Theme.of(context).textTheme.bodyMedium;
TextStyle? get disabledStyle => Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey);
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
TextField(
maxLines: 4,
controller: _controller,
focusNode: _focusNode,
undoController: _undoController,
),
ValueListenableBuilder<UndoHistoryValue>(
valueListenable: _undoController,
builder: (BuildContext context, UndoHistoryValue value, Widget? child) {
return Row(
children: <Widget>[
TextButton(
child: Text('Undo', style: value.canUndo ? enabledStyle : disabledStyle),
onPressed: () {
_undoController.undo();
},
),
TextButton(
child: Text('Redo', style: value.canRedo ? enabledStyle : disabledStyle),
onPressed: () {
_undoController.redo();
},
),
],
);
},
),
],
),
),
);
}
}
......@@ -52,3 +52,4 @@ export 'src/services/text_editing_delta.dart';
export 'src/services/text_formatter.dart';
export 'src/services/text_input.dart';
export 'src/services/text_layout_metrics.dart';
export 'src/services/undo_manager.dart';
......@@ -214,6 +214,7 @@ class CupertinoTextField extends StatefulWidget {
super.key,
this.controller,
this.focusNode,
this.undoController,
this.decoration = _kDefaultRoundedBorderDecoration,
this.padding = const EdgeInsets.all(7.0),
this.placeholder,
......@@ -347,6 +348,7 @@ class CupertinoTextField extends StatefulWidget {
super.key,
this.controller,
this.focusNode,
this.undoController,
this.decoration,
this.padding = const EdgeInsets.all(7.0),
this.placeholder,
......@@ -780,6 +782,9 @@ class CupertinoTextField extends StatefulWidget {
decorationStyle: TextDecorationStyle.dotted,
);
/// {@macro flutter.widgets.undoHistory.controller}
final UndoHistoryController? undoController;
@override
State<CupertinoTextField> createState() => _CupertinoTextFieldState();
......@@ -788,6 +793,7 @@ class CupertinoTextField extends StatefulWidget {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<TextEditingController>('controller', controller, defaultValue: null));
properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null));
properties.add(DiagnosticsProperty<UndoHistoryController>('undoController', undoController, defaultValue: null));
properties.add(DiagnosticsProperty<BoxDecoration>('decoration', decoration));
properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding));
properties.add(StringProperty('placeholder', placeholder));
......@@ -1277,6 +1283,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
child: EditableText(
key: editableTextKey,
controller: controller,
undoController: widget.undoController,
readOnly: widget.readOnly,
toolbarOptions: widget.toolbarOptions,
showCursor: widget.showCursor,
......
......@@ -255,6 +255,7 @@ class TextField extends StatefulWidget {
super.key,
this.controller,
this.focusNode,
this.undoController,
this.decoration = const InputDecoration(),
TextInputType? keyboardType,
this.textInputAction,
......@@ -774,6 +775,9 @@ class TextField extends StatefulWidget {
/// be possible to move the focus to the text field with tab key.
final bool canRequestFocus;
/// {@macro flutter.widgets.undoHistory.controller}
final UndoHistoryController? undoController;
static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) {
return AdaptiveTextSelectionToolbar.editableText(
editableTextState: editableTextState,
......@@ -834,6 +838,7 @@ class TextField extends StatefulWidget {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<TextEditingController>('controller', controller, defaultValue: null));
properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null));
properties.add(DiagnosticsProperty<UndoHistoryController>('undoController', undoController, defaultValue: null));
properties.add(DiagnosticsProperty<bool>('enabled', enabled, defaultValue: null));
properties.add(DiagnosticsProperty<InputDecoration>('decoration', decoration, defaultValue: const InputDecoration()));
properties.add(DiagnosticsProperty<TextInputType>('keyboardType', keyboardType, defaultValue: TextInputType.text));
......@@ -1313,6 +1318,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
showSelectionHandles: _showSelectionHandles,
controller: controller,
focusNode: focusNode,
undoController: widget.undoController,
keyboardType: widget.keyboardType,
textInputAction: widget.textInputAction,
textCapitalization: widget.textCapitalization,
......
......@@ -244,6 +244,12 @@ class SystemChannels {
'flutter/spellcheck',
);
/// A JSON [MethodChannel] for handling undo events.
static const MethodChannel undoManager = OptionalMethodChannel(
'flutter/undomanager',
JSONMethodCodec(),
);
/// A JSON [BasicMessageChannel] for keyboard events.
///
/// Each incoming message received on this channel (registered using
......
// 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 '../../services.dart';
/// The direction in which an undo action should be performed, whether undo or redo.
enum UndoDirection {
/// Perform an undo action.
undo,
/// Perform a redo action.
redo
}
/// A low-level interface to the system's undo manager.
///
/// To receive events from the system undo manager, create an
/// [UndoManagerClient] and set it as the [client] on [UndoManager].
///
/// The [setUndoState] method can be used to update the system's undo manager
/// using the [canUndo] and [canRedo] parameters.
///
/// When the system undo or redo button is tapped, the current
/// [UndoManagerClient] will receive [UndoManagerClient.handlePlatformUndo]
/// with an [UndoDirection] representing whether the event is "undo" or "redo".
///
/// Currently, only iOS has an UndoManagerPlugin implemented on the engine side.
/// On iOS, this can be used to listen to the keyboard undo/redo buttons and the
/// undo/redo gestures.
///
/// See also:
///
/// * [NSUndoManager](https://developer.apple.com/documentation/foundation/nsundomanager)
class UndoManager {
UndoManager._() {
_channel = SystemChannels.undoManager;
_channel.setMethodCallHandler(_handleUndoManagerInvocation);
}
/// Set the [MethodChannel] used to communicate with the system's undo manager.
///
/// This is only meant for testing within the Flutter SDK. Changing this
/// will break the ability to set the undo status or receive undo and redo
/// events from the system. This has no effect if asserts are disabled.
@visibleForTesting
static void setChannel(MethodChannel newChannel) {
assert(() {
_instance._channel = newChannel..setMethodCallHandler(_instance._handleUndoManagerInvocation);
return true;
}());
}
static final UndoManager _instance = UndoManager._();
/// Receive undo and redo events from the system's [UndoManager].
///
/// Setting the [client] will cause [UndoManagerClient.handlePlatformUndo]
/// to be called when a system undo or redo is triggered, such as by tapping
/// the undo/redo keyboard buttons or using the 3-finger swipe gestures.
static set client(UndoManagerClient? client) {
_instance._currentClient = client;
}
/// Return the current [UndoManagerClient].
static UndoManagerClient? get client => _instance._currentClient;
/// Set the current state of the system UndoManager. [canUndo] and [canRedo]
/// control the respective "undo" and "redo" buttons of the system UndoManager.
static void setUndoState({bool canUndo = false, bool canRedo = false}) {
_instance._setUndoState(canUndo: canUndo, canRedo: canRedo);
}
late MethodChannel _channel;
UndoManagerClient? _currentClient;
Future<dynamic> _handleUndoManagerInvocation(MethodCall methodCall) async {
final String method = methodCall.method;
final List<dynamic> args = methodCall.arguments as List<dynamic>;
if (method == 'UndoManagerClient.handleUndo') {
assert(_currentClient != null, 'There must be a current UndoManagerClient.');
_currentClient!.handlePlatformUndo(_toUndoDirection(args[0] as String));
return;
}
throw MissingPluginException();
}
void _setUndoState({bool canUndo = false, bool canRedo = false}) {
_channel.invokeMethod<void>(
'UndoManager.setUndoState',
<String, bool>{'canUndo': canUndo, 'canRedo': canRedo}
);
}
UndoDirection _toUndoDirection(String direction) {
switch (direction) {
case 'undo':
return UndoDirection.undo;
case 'redo':
return UndoDirection.redo;
}
throw FlutterError.fromParts(<DiagnosticsNode>[ErrorSummary('Unknown undo direction: $direction')]);
}
}
/// An interface to receive events from a native UndoManager.
mixin UndoManagerClient {
/// Requests that the client perform an undo or redo operation.
///
/// Currently only used on iOS 9+ when the undo or redo methods are invoked
/// by the platform. For example, when using three-finger swipe gestures,
/// the iPad keyboard, or voice control.
void handlePlatformUndo(UndoDirection direction);
/// Reverts the value on the stack to the previous value.
void undo();
/// Updates the value on the stack to the next value.
void redo();
/// Will be true if there are past values on the stack.
bool get canUndo;
/// Will be true if there are future values on the stack.
bool get canRedo;
}
This diff is collapsed.
......@@ -147,6 +147,7 @@ export 'src/widgets/ticker_provider.dart';
export 'src/widgets/title.dart';
export 'src/widgets/transitions.dart';
export 'src/widgets/tween_animation_builder.dart';
export 'src/widgets/undo_history.dart';
export 'src/widgets/unique_widget.dart';
export 'src/widgets/value_listenable_builder.dart';
export 'src/widgets/view.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/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('Undo Interactions', () {
test('UndoManagerClient handleUndo', () async {
// Assemble an UndoManagerClient so we can verify its change in state.
final _FakeUndoManagerClient client = _FakeUndoManagerClient();
UndoManager.client = client;
expect(client.latestMethodCall, isEmpty);
// Send handleUndo message with "undo" as the direction.
ByteData? messageBytes = const JSONMessageCodec().encodeMessage(<String, dynamic>{
'args': <dynamic>['undo'],
'method': 'UndoManagerClient.handleUndo',
});
await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage(
'flutter/undomanager',
messageBytes,
null,
);
expect(client.latestMethodCall, 'handlePlatformUndo(${UndoDirection.undo})');
// Send handleUndo message with "undo" as the direction.
messageBytes = const JSONMessageCodec().encodeMessage(<String, dynamic>{
'args': <dynamic>['redo'],
'method': 'UndoManagerClient.handleUndo',
});
await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage(
'flutter/undomanager',
messageBytes,
(ByteData? _) {},
);
expect(client.latestMethodCall, 'handlePlatformUndo(${UndoDirection.redo})');
});
});
}
class _FakeUndoManagerClient with UndoManagerClient {
String latestMethodCall = '';
@override
void undo() {}
@override
void redo() {}
@override
bool get canUndo => false;
@override
bool get canRedo => false;
@override
void handlePlatformUndo(UndoDirection direction) {
latestMethodCall = 'handlePlatformUndo($direction)';
}
}
......@@ -6057,68 +6057,6 @@ void main() {
'to come to the aid\n' // 36 + 19 => 55
'of their country.'; // 55 + 17 => 72
Future<void> sendKeys(
WidgetTester tester,
List<LogicalKeyboardKey> keys, {
bool shift = false,
bool wordModifier = false,
bool lineModifier = false,
bool shortcutModifier = false,
required TargetPlatform targetPlatform,
}) async {
final String targetPlatformString = targetPlatform.toString();
final String platform = targetPlatformString.substring(targetPlatformString.indexOf('.') + 1).toLowerCase();
if (shift) {
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft, platform: platform);
}
if (shortcutModifier) {
await tester.sendKeyDownEvent(
platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.metaLeft : LogicalKeyboardKey.controlLeft,
platform: platform,
);
}
if (wordModifier) {
await tester.sendKeyDownEvent(
platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.altLeft : LogicalKeyboardKey.controlLeft,
platform: platform,
);
}
if (lineModifier) {
await tester.sendKeyDownEvent(
platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.metaLeft : LogicalKeyboardKey.altLeft,
platform: platform,
);
}
for (final LogicalKeyboardKey key in keys) {
await tester.sendKeyEvent(key, platform: platform);
await tester.pump();
}
if (lineModifier) {
await tester.sendKeyUpEvent(
platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.metaLeft : LogicalKeyboardKey.altLeft,
platform: platform,
);
}
if (wordModifier) {
await tester.sendKeyUpEvent(
platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.altLeft : LogicalKeyboardKey.controlLeft,
platform: platform,
);
}
if (shortcutModifier) {
await tester.sendKeyUpEvent(
platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.metaLeft : LogicalKeyboardKey.controlLeft,
platform: platform,
);
}
if (shift) {
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft, platform: platform);
}
if (shift || wordModifier || lineModifier) {
await tester.pump();
}
}
Future<void> testTextEditing(WidgetTester tester, {required TargetPlatform targetPlatform}) async {
final String targetPlatformString = targetPlatform.toString();
final String platform = targetPlatformString.substring(targetPlatformString.indexOf('.') + 1).toLowerCase();
......@@ -13066,14 +13004,14 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
// Undo first insertion.
await sendUndo(tester);
expect(controller.value, composingStep2.copyWith(composing: TextRange.empty));
expect(controller.value, composingStep2);
// Waiting for the throttling beetween undos should have no effect.
await tester.pump(const Duration(milliseconds: 500));
// Undo second insertion.
await sendUndo(tester);
expect(controller.value, composingStep1.copyWith(composing: TextRange.empty));
expect(controller.value, composingStep1);
// On web, these keyboard shortcuts are handled by the browser.
}, variant: TargetPlatformVariant.only(TargetPlatform.android), skip: kIsWeb); // [intended]
......@@ -13594,6 +13532,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
controller.value,
const TextEditingValue(
text: '1 nihao',
composing: TextRange(start: 2, end: 7),
selection: TextSelection.collapsed(offset: 7),
),
);
......@@ -13603,6 +13542,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
controller.value,
const TextEditingValue(
text: '1 ni',
composing: TextRange(start: 2, end: 4),
selection: TextSelection.collapsed(offset: 4),
),
);
......@@ -13620,6 +13560,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
controller.value,
const TextEditingValue(
text: '1 ni',
composing: TextRange(start: 2, end: 4),
selection: TextSelection.collapsed(offset: 4),
),
);
......@@ -13628,6 +13569,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
controller.value,
const TextEditingValue(
text: '1 nihao',
composing: TextRange(start: 2, end: 7),
selection: TextSelection.collapsed(offset: 7),
),
);
......@@ -13653,6 +13595,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
controller.value,
const TextEditingValue(
text: '1 nihao',
composing: TextRange(start: 2, end: 7),
selection: TextSelection.collapsed(offset: 7),
),
);
......@@ -13661,6 +13604,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
controller.value,
const TextEditingValue(
text: '1 ni',
composing: TextRange(start: 2, end: 4),
selection: TextSelection.collapsed(offset: 4),
),
);
......@@ -13700,6 +13644,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
controller.value,
const TextEditingValue(
text: '1 ni',
composing: TextRange(start: 2, end: 4),
selection: TextSelection.collapsed(offset: 4),
),
);
......@@ -13708,6 +13653,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
controller.value,
const TextEditingValue(
text: '1 nihao',
composing: TextRange(start: 2, end: 7),
selection: TextSelection.collapsed(offset: 7),
),
);
......@@ -13829,6 +13775,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
controller.value,
const TextEditingValue(
text: '1 2 ni',
composing: TextRange(start: 4, end: 6),
selection: TextSelection.collapsed(offset: 6),
),
);
......@@ -13887,6 +13834,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
controller.value,
const TextEditingValue(
text: '1 2 ni',
composing: TextRange(start: 4, end: 6),
selection: TextSelection.collapsed(offset: 6),
),
);
......
......@@ -5,6 +5,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// On web, the context menu (aka toolbar) is provided by the browser.
......@@ -49,6 +50,69 @@ Offset textOffsetToPosition(WidgetTester tester, int offset, {int index = 0}) {
return endpoints[0].point + const Offset(kIsWeb? 1.0 : 0.0, -2.0);
}
/// Mimic key press events by sending key down and key up events via the [tester].
Future<void> sendKeys(
WidgetTester tester,
List<LogicalKeyboardKey> keys, {
bool shift = false,
bool wordModifier = false,
bool lineModifier = false,
bool shortcutModifier = false,
required TargetPlatform targetPlatform,
}) async {
final String targetPlatformString = targetPlatform.toString();
final String platform = targetPlatformString.substring(targetPlatformString.indexOf('.') + 1).toLowerCase();
if (shift) {
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft, platform: platform);
}
if (shortcutModifier) {
await tester.sendKeyDownEvent(
platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.metaLeft : LogicalKeyboardKey.controlLeft,
platform: platform,
);
}
if (wordModifier) {
await tester.sendKeyDownEvent(
platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.altLeft : LogicalKeyboardKey.controlLeft,
platform: platform,
);
}
if (lineModifier) {
await tester.sendKeyDownEvent(
platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.metaLeft : LogicalKeyboardKey.altLeft,
platform: platform,
);
}
for (final LogicalKeyboardKey key in keys) {
await tester.sendKeyEvent(key, platform: platform);
await tester.pump();
}
if (lineModifier) {
await tester.sendKeyUpEvent(
platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.metaLeft : LogicalKeyboardKey.altLeft,
platform: platform,
);
}
if (wordModifier) {
await tester.sendKeyUpEvent(
platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.altLeft : LogicalKeyboardKey.controlLeft,
platform: platform,
);
}
if (shortcutModifier) {
await tester.sendKeyUpEvent(
platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.metaLeft : LogicalKeyboardKey.controlLeft,
platform: platform,
);
}
if (shift) {
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft, platform: platform);
}
if (shift || wordModifier || lineModifier) {
await tester.pump();
}
}
// Simple controller that builds a WidgetSpan with 100 height.
class OverflowWidgetTextEditingController extends TextEditingController {
@override
......
This diff is collapsed.
......@@ -370,4 +370,16 @@ class TestTextInput {
Future<void> handleKeyUpEvent(LogicalKeyboardKey key) async {
await _keyHandler?.handleKeyUpEvent(key);
}
/// Simulates iOS responding to an undo or redo gesture or button.
Future<void> handleKeyboardUndo(String direction) async {
assert(isRegistered);
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage(
SystemChannels.textInput.name,
SystemChannels.textInput.codec.encodeMethodCall(
MethodCall('TextInputClient.handleUndo', <dynamic>[direction]),
),
(ByteData? data) {/* response from framework is discarded */},
);
}
}
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