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'; ...@@ -52,3 +52,4 @@ export 'src/services/text_editing_delta.dart';
export 'src/services/text_formatter.dart'; export 'src/services/text_formatter.dart';
export 'src/services/text_input.dart'; export 'src/services/text_input.dart';
export 'src/services/text_layout_metrics.dart'; export 'src/services/text_layout_metrics.dart';
export 'src/services/undo_manager.dart';
...@@ -214,6 +214,7 @@ class CupertinoTextField extends StatefulWidget { ...@@ -214,6 +214,7 @@ class CupertinoTextField extends StatefulWidget {
super.key, super.key,
this.controller, this.controller,
this.focusNode, this.focusNode,
this.undoController,
this.decoration = _kDefaultRoundedBorderDecoration, this.decoration = _kDefaultRoundedBorderDecoration,
this.padding = const EdgeInsets.all(7.0), this.padding = const EdgeInsets.all(7.0),
this.placeholder, this.placeholder,
...@@ -347,6 +348,7 @@ class CupertinoTextField extends StatefulWidget { ...@@ -347,6 +348,7 @@ class CupertinoTextField extends StatefulWidget {
super.key, super.key,
this.controller, this.controller,
this.focusNode, this.focusNode,
this.undoController,
this.decoration, this.decoration,
this.padding = const EdgeInsets.all(7.0), this.padding = const EdgeInsets.all(7.0),
this.placeholder, this.placeholder,
...@@ -780,6 +782,9 @@ class CupertinoTextField extends StatefulWidget { ...@@ -780,6 +782,9 @@ class CupertinoTextField extends StatefulWidget {
decorationStyle: TextDecorationStyle.dotted, decorationStyle: TextDecorationStyle.dotted,
); );
/// {@macro flutter.widgets.undoHistory.controller}
final UndoHistoryController? undoController;
@override @override
State<CupertinoTextField> createState() => _CupertinoTextFieldState(); State<CupertinoTextField> createState() => _CupertinoTextFieldState();
...@@ -788,6 +793,7 @@ class CupertinoTextField extends StatefulWidget { ...@@ -788,6 +793,7 @@ class CupertinoTextField extends StatefulWidget {
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<TextEditingController>('controller', controller, defaultValue: null)); properties.add(DiagnosticsProperty<TextEditingController>('controller', controller, defaultValue: null));
properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, 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<BoxDecoration>('decoration', decoration));
properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding)); properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding));
properties.add(StringProperty('placeholder', placeholder)); properties.add(StringProperty('placeholder', placeholder));
...@@ -1277,6 +1283,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio ...@@ -1277,6 +1283,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
child: EditableText( child: EditableText(
key: editableTextKey, key: editableTextKey,
controller: controller, controller: controller,
undoController: widget.undoController,
readOnly: widget.readOnly, readOnly: widget.readOnly,
toolbarOptions: widget.toolbarOptions, toolbarOptions: widget.toolbarOptions,
showCursor: widget.showCursor, showCursor: widget.showCursor,
......
...@@ -255,6 +255,7 @@ class TextField extends StatefulWidget { ...@@ -255,6 +255,7 @@ class TextField extends StatefulWidget {
super.key, super.key,
this.controller, this.controller,
this.focusNode, this.focusNode,
this.undoController,
this.decoration = const InputDecoration(), this.decoration = const InputDecoration(),
TextInputType? keyboardType, TextInputType? keyboardType,
this.textInputAction, this.textInputAction,
...@@ -774,6 +775,9 @@ class TextField extends StatefulWidget { ...@@ -774,6 +775,9 @@ class TextField extends StatefulWidget {
/// be possible to move the focus to the text field with tab key. /// be possible to move the focus to the text field with tab key.
final bool canRequestFocus; final bool canRequestFocus;
/// {@macro flutter.widgets.undoHistory.controller}
final UndoHistoryController? undoController;
static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) { static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) {
return AdaptiveTextSelectionToolbar.editableText( return AdaptiveTextSelectionToolbar.editableText(
editableTextState: editableTextState, editableTextState: editableTextState,
...@@ -834,6 +838,7 @@ class TextField extends StatefulWidget { ...@@ -834,6 +838,7 @@ class TextField extends StatefulWidget {
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<TextEditingController>('controller', controller, defaultValue: null)); properties.add(DiagnosticsProperty<TextEditingController>('controller', controller, defaultValue: null));
properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, 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<bool>('enabled', enabled, defaultValue: null));
properties.add(DiagnosticsProperty<InputDecoration>('decoration', decoration, defaultValue: const InputDecoration())); properties.add(DiagnosticsProperty<InputDecoration>('decoration', decoration, defaultValue: const InputDecoration()));
properties.add(DiagnosticsProperty<TextInputType>('keyboardType', keyboardType, defaultValue: TextInputType.text)); properties.add(DiagnosticsProperty<TextInputType>('keyboardType', keyboardType, defaultValue: TextInputType.text));
...@@ -1313,6 +1318,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1313,6 +1318,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
showSelectionHandles: _showSelectionHandles, showSelectionHandles: _showSelectionHandles,
controller: controller, controller: controller,
focusNode: focusNode, focusNode: focusNode,
undoController: widget.undoController,
keyboardType: widget.keyboardType, keyboardType: widget.keyboardType,
textInputAction: widget.textInputAction, textInputAction: widget.textInputAction,
textCapitalization: widget.textCapitalization, textCapitalization: widget.textCapitalization,
......
...@@ -244,6 +244,12 @@ class SystemChannels { ...@@ -244,6 +244,12 @@ class SystemChannels {
'flutter/spellcheck', 'flutter/spellcheck',
); );
/// A JSON [MethodChannel] for handling undo events.
static const MethodChannel undoManager = OptionalMethodChannel(
'flutter/undomanager',
JSONMethodCodec(),
);
/// A JSON [BasicMessageChannel] for keyboard events. /// A JSON [BasicMessageChannel] for keyboard events.
/// ///
/// Each incoming message received on this channel (registered using /// 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;
}
...@@ -43,6 +43,7 @@ import 'text_editing_intents.dart'; ...@@ -43,6 +43,7 @@ import 'text_editing_intents.dart';
import 'text_selection.dart'; import 'text_selection.dart';
import 'text_selection_toolbar_anchors.dart'; import 'text_selection_toolbar_anchors.dart';
import 'ticker_provider.dart'; import 'ticker_provider.dart';
import 'undo_history.dart';
import 'view.dart'; import 'view.dart';
import 'widget_span.dart'; import 'widget_span.dart';
...@@ -806,10 +807,10 @@ class EditableText extends StatefulWidget { ...@@ -806,10 +807,10 @@ class EditableText extends StatefulWidget {
this.contextMenuBuilder, this.contextMenuBuilder,
this.spellCheckConfiguration, this.spellCheckConfiguration,
this.magnifierConfiguration = TextMagnifierConfiguration.disabled, this.magnifierConfiguration = TextMagnifierConfiguration.disabled,
this.undoController,
}) : assert(obscuringCharacter.length == 1), }) : assert(obscuringCharacter.length == 1),
smartDashesType = smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), smartDashesType = smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled),
smartQuotesType = smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), smartQuotesType = smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled),
assert(maxLines == null || maxLines > 0),
assert(minLines == null || minLines > 0), assert(minLines == null || minLines > 0),
assert( assert(
(maxLines == null) || (minLines == null) || (maxLines >= minLines), (maxLines == null) || (minLines == null) || (maxLines >= minLines),
...@@ -965,6 +966,11 @@ class EditableText extends StatefulWidget { ...@@ -965,6 +966,11 @@ class EditableText extends StatefulWidget {
/// The text style to use for the editable text. /// The text style to use for the editable text.
final TextStyle style; final TextStyle style;
/// Controls the undo state of the current editable text.
///
/// If null, this widget will create its own [UndoHistoryController].
final UndoHistoryController? undoController;
/// {@template flutter.widgets.editableText.strutStyle} /// {@template flutter.widgets.editableText.strutStyle}
/// The strut style used for the vertical layout. /// The strut style used for the vertical layout.
/// ///
...@@ -2032,6 +2038,7 @@ class EditableText extends StatefulWidget { ...@@ -2032,6 +2038,7 @@ class EditableText extends StatefulWidget {
properties.add(DiagnosticsProperty<bool>('scribbleEnabled', scribbleEnabled, defaultValue: true)); properties.add(DiagnosticsProperty<bool>('scribbleEnabled', scribbleEnabled, defaultValue: true));
properties.add(DiagnosticsProperty<bool>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true)); properties.add(DiagnosticsProperty<bool>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true));
properties.add(DiagnosticsProperty<bool>('enableInteractiveSelection', enableInteractiveSelection, defaultValue: true)); properties.add(DiagnosticsProperty<bool>('enableInteractiveSelection', enableInteractiveSelection, defaultValue: true));
properties.add(DiagnosticsProperty<UndoHistoryController>('undoController', undoController, defaultValue: null));
properties.add(DiagnosticsProperty<SpellCheckConfiguration>('spellCheckConfiguration', spellCheckConfiguration, defaultValue: null)); properties.add(DiagnosticsProperty<SpellCheckConfiguration>('spellCheckConfiguration', spellCheckConfiguration, defaultValue: null));
properties.add(DiagnosticsProperty<List<String>>('contentCommitMimeTypes', contentInsertionConfiguration?.allowedMimeTypes ?? const <String>[], defaultValue: contentInsertionConfiguration == null ? const <String>[] : kDefaultContentInsertionMimeTypes)); properties.add(DiagnosticsProperty<List<String>>('contentCommitMimeTypes', contentInsertionConfiguration?.allowedMimeTypes ?? const <String>[], defaultValue: contentInsertionConfiguration == null ? const <String>[] : kDefaultContentInsertionMimeTypes));
} }
...@@ -4488,11 +4495,42 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -4488,11 +4495,42 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
cursor: widget.mouseCursor ?? SystemMouseCursors.text, cursor: widget.mouseCursor ?? SystemMouseCursors.text,
child: Actions( child: Actions(
actions: _actions, actions: _actions,
child: _TextEditingHistory( child: UndoHistory<TextEditingValue>(
controller: widget.controller, value: widget.controller,
onTriggered: (TextEditingValue value) { onTriggered: (TextEditingValue value) {
userUpdateTextEditingValue(value, SelectionChangedCause.keyboard); userUpdateTextEditingValue(value, SelectionChangedCause.keyboard);
}, },
shouldChangeUndoStack: (TextEditingValue? oldValue, TextEditingValue newValue) {
if (newValue == TextEditingValue.empty) {
return false;
}
if (oldValue == null) {
return true;
}
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
// Composing text is not counted in history coalescing.
if (!widget.controller.value.composing.isCollapsed) {
return false;
}
break;
case TargetPlatform.android:
// Gboard on Android puts non-CJK words in composing regions. Coalesce
// composing text in order to allow the saving of partial words in that
// case.
break;
}
return oldValue.text != newValue.text || oldValue.composing != newValue.composing;
},
focusNode: widget.focusNode,
controller: widget.undoController,
child: Focus( child: Focus(
focusNode: widget.focusNode, focusNode: widget.focusNode,
includeSemantics: false, includeSemantics: false,
...@@ -5266,286 +5304,6 @@ class _CopySelectionAction extends ContextAction<CopySelectionTextIntent> { ...@@ -5266,286 +5304,6 @@ class _CopySelectionAction extends ContextAction<CopySelectionTextIntent> {
bool get isActionEnabled => state._value.selection.isValid && !state._value.selection.isCollapsed; bool get isActionEnabled => state._value.selection.isValid && !state._value.selection.isCollapsed;
} }
/// A void function that takes a [TextEditingValue].
@visibleForTesting
typedef TextEditingValueCallback = void Function(TextEditingValue value);
/// Provides undo/redo capabilities for text editing.
///
/// Listens to [controller] as a [ValueNotifier] and saves relevant values for
/// undoing/redoing. The cadence at which values are saved is a best
/// approximation of the native behaviors of a hardware keyboard on Flutter's
/// desktop platforms, as there are subtle differences between each of these
/// platforms.
///
/// Listens to keyboard undo/redo shortcuts and calls [onTriggered] when a
/// shortcut is triggered that would affect the state of the [controller].
class _TextEditingHistory extends StatefulWidget {
/// Creates an instance of [_TextEditingHistory].
const _TextEditingHistory({
required this.child,
required this.controller,
required this.onTriggered,
});
/// The child widget of [_TextEditingHistory].
final Widget child;
/// The [TextEditingController] to save the state of over time.
final TextEditingController controller;
/// Called when an undo or redo causes a state change.
///
/// If the state would still be the same before and after the undo/redo, this
/// will not be called. For example, receiving a redo when there is nothing
/// to redo will not call this method.
///
/// It is also not called when the controller is changed for reasons other
/// than undo/redo.
final TextEditingValueCallback onTriggered;
@override
State<_TextEditingHistory> createState() => _TextEditingHistoryState();
}
class _TextEditingHistoryState extends State<_TextEditingHistory> {
final _UndoStack<TextEditingValue> _stack = _UndoStack<TextEditingValue>();
late final _Throttled<TextEditingValue> _throttledPush;
Timer? _throttleTimer;
// This is used to prevent a reentrant call to the history (a call to _undo or _redo
// should not call _push to add a new entry in the history).
bool _locked = false;
// This duration was chosen as a best fit for the behavior of Mac, Linux,
// and Windows undo/redo state save durations, but it is not perfect for any
// of them.
static const Duration _kThrottleDuration = Duration(milliseconds: 500);
void _undo(UndoTextIntent intent) {
_update(_stack.undo());
}
void _redo(RedoTextIntent intent) {
_update(_stack.redo());
}
void _update(TextEditingValue? nextValue) {
if (nextValue == null) {
return;
}
if (nextValue.text == widget.controller.text) {
return;
}
_locked = true;
widget.onTriggered(widget.controller.value.copyWith(
text: nextValue.text,
selection: nextValue.selection,
));
_locked = false;
}
void _push() {
// Do not try to push a new state when the change is related to an undo or redo.
if (_locked) {
return;
}
if (widget.controller.value == TextEditingValue.empty) {
return;
}
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
// Composing text is not counted in history coalescing.
if (!widget.controller.value.composing.isCollapsed) {
return;
}
break;
case TargetPlatform.android:
// Gboard on Android puts non-CJK words in composing regions. Coalesce
// composing text in order to allow the saving of partial words in that
// case.
break;
}
_throttleTimer = _throttledPush(widget.controller.value);
}
@override
void initState() {
super.initState();
_throttledPush = _throttle<TextEditingValue>(
duration: _kThrottleDuration,
function: _stack.push,
);
_push();
widget.controller.addListener(_push);
}
@override
void didUpdateWidget(_TextEditingHistory oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.controller != oldWidget.controller) {
_stack.clear();
oldWidget.controller.removeListener(_push);
widget.controller.addListener(_push);
}
}
@override
void dispose() {
widget.controller.removeListener(_push);
_throttleTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Actions(
actions: <Type, Action<Intent>> {
UndoTextIntent: Action<UndoTextIntent>.overridable(context: context, defaultAction: CallbackAction<UndoTextIntent>(onInvoke: _undo)),
RedoTextIntent: Action<RedoTextIntent>.overridable(context: context, defaultAction: CallbackAction<RedoTextIntent>(onInvoke: _redo)),
},
child: widget.child,
);
}
}
/// A data structure representing a chronological list of states that can be
/// undone and redone.
class _UndoStack<T> {
/// Creates an instance of [_UndoStack].
_UndoStack();
final List<T> _list = <T>[];
// The index of the current value, or -1 if the list is empty.
int _index = -1;
/// Returns the current value of the stack.
T? get currentValue => _list.isEmpty ? null : _list[_index];
/// Add a new state change to the stack.
///
/// Pushing identical objects will not create multiple entries.
void push(T value) {
if (_list.isEmpty) {
_index = 0;
_list.add(value);
return;
}
assert(_index < _list.length && _index >= 0);
if (value == currentValue) {
return;
}
// If anything has been undone in this stack, remove those irrelevant states
// before adding the new one.
if (_index != _list.length - 1) {
_list.removeRange(_index + 1, _list.length);
}
_list.add(value);
_index = _list.length - 1;
}
/// Returns the current value after an undo operation.
///
/// An undo operation moves the current value to the previously pushed value,
/// if any.
///
/// Iff the stack is completely empty, then returns null.
T? undo() {
if (_list.isEmpty) {
return null;
}
assert(_index < _list.length && _index >= 0);
if (_index != 0) {
_index = _index - 1;
}
return currentValue;
}
/// Returns the current value after a redo operation.
///
/// A redo operation moves the current value to the value that was last
/// undone, if any.
///
/// Iff the stack is completely empty, then returns null.
T? redo() {
if (_list.isEmpty) {
return null;
}
assert(_index < _list.length && _index >= 0);
if (_index < _list.length - 1) {
_index = _index + 1;
}
return currentValue;
}
/// Remove everything from the stack.
void clear() {
_list.clear();
_index = -1;
}
@override
String toString() {
return '_UndoStack $_list';
}
}
/// A function that can be throttled with the throttle function.
typedef _Throttleable<T> = void Function(T currentArg);
/// A function that has been throttled by [_throttle].
typedef _Throttled<T> = Timer Function(T currentArg);
/// Returns a _Throttled that will call through to the given function only a
/// maximum of once per duration.
///
/// Only works for functions that take exactly one argument and return void.
_Throttled<T> _throttle<T>({
required Duration duration,
required _Throttleable<T> function,
// If true, calls at the start of the timer.
bool leadingEdge = false,
}) {
Timer? timer;
bool calledDuringTimer = false;
late T arg;
return (T currentArg) {
arg = currentArg;
if (timer != null) {
calledDuringTimer = true;
return timer!;
}
if (leadingEdge) {
function(arg);
}
calledDuringTimer = false;
timer = Timer(duration, () {
if (!leadingEdge || calledDuringTimer) {
function(arg);
}
timer = null;
});
return timer!;
};
}
/// The start and end glyph heights of some range of text. /// The start and end glyph heights of some range of text.
@immutable @immutable
class _GlyphHeights { class _GlyphHeights {
......
// 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 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'actions.dart';
import 'focus_manager.dart';
import 'framework.dart';
import 'text_editing_intents.dart';
/// Provides undo/redo capabilities for a [ValueNotifier].
///
/// Listens to [value] and saves relevant values for undoing/redoing. The
/// cadence at which values are saved is a best approximation of the native
/// behaviors of a number of hardware keyboard on Flutter's desktop
/// platforms, as there are subtle differences between each of the platforms.
///
/// Listens to keyboard undo/redo shortcuts and calls [onTriggered] when a
/// shortcut is triggered that would affect the state of the [value].
///
/// The [child] must manage focus on the [focusNode]. For example, using a
/// [TextField] or [Focus] widget.
class UndoHistory<T> extends StatefulWidget {
/// Creates an instance of [UndoHistory].
const UndoHistory({
super.key,
this.shouldChangeUndoStack,
required this.value,
required this.onTriggered,
required this.focusNode,
this.controller,
required this.child,
});
/// The value to track over time.
final ValueNotifier<T> value;
/// Called when checking whether a value change should be pushed onto
/// the undo stack.
final bool Function(T? oldValue, T newValue)? shouldChangeUndoStack;
/// Called when an undo or redo causes a state change.
///
/// If the state would still be the same before and after the undo/redo, this
/// will not be called. For example, receiving a redo when there is nothing
/// to redo will not call this method.
///
/// Changes to the [value] while this method is running will not be recorded
/// on the undo stack. For example, a [TextInputFormatter] may change the value
/// from what was on the undo stack, but this new value will not be recorded,
/// as that would wipe out the redo history.
final void Function(T value) onTriggered;
/// The [FocusNode] that will be used to listen for focus to set the initial
/// undo state for the element.
final FocusNode focusNode;
/// {@template flutter.widgets.undoHistory.controller}
/// Controls the undo state.
///
/// If null, this widget will create its own [UndoHistoryController].
/// {@endtemplate}
final UndoHistoryController? controller;
/// The child widget of [UndoHistory].
final Widget child;
@override
State<UndoHistory<T>> createState() => UndoHistoryState<T>();
}
/// State for a [UndoHistory].
///
/// Provides [undo], [redo], [canUndo], and [canRedo] for programmatic access
/// to the undo state for custom undo and redo UI implementations.
@visibleForTesting
class UndoHistoryState<T> extends State<UndoHistory<T>> with UndoManagerClient {
final _UndoStack<T> _stack = _UndoStack<T>();
late final _Throttled<T> _throttledPush;
Timer? _throttleTimer;
bool _duringTrigger = false;
// This duration was chosen as a best fit for the behavior of Mac, Linux,
// and Windows undo/redo state save durations, but it is not perfect for any
// of them.
static const Duration _kThrottleDuration = Duration(milliseconds: 500);
// Record the last value to prevent pushing multiple
// of the same value in a row onto the undo stack. For example, _push gets
// called both in initState and when the EditableText receives focus.
T? _lastValue;
UndoHistoryController? _controller;
UndoHistoryController get _effectiveController => widget.controller ?? (_controller ??= UndoHistoryController());
@override
void undo() {
_update(_stack.undo());
_updateState();
}
@override
void redo() {
_update(_stack.redo());
_updateState();
}
@override
bool get canUndo => _stack.canUndo;
@override
bool get canRedo => _stack.canRedo;
void _updateState() {
_effectiveController.value = UndoHistoryValue(canUndo: canUndo, canRedo: canRedo);
if (defaultTargetPlatform != TargetPlatform.iOS) {
return;
}
if (UndoManager.client == this) {
UndoManager.setUndoState(canUndo: canUndo, canRedo: canRedo);
}
}
void _undoFromIntent(UndoTextIntent intent) {
undo();
}
void _redoFromIntent(RedoTextIntent intent) {
redo();
}
void _update(T? nextValue) {
if (nextValue == null) {
return;
}
if (nextValue == _lastValue) {
return;
}
_lastValue = nextValue;
_duringTrigger = true;
try {
widget.onTriggered(nextValue);
assert(widget.value.value == nextValue);
} finally {
_duringTrigger = false;
}
}
void _push() {
if (widget.value.value == _lastValue) {
return;
}
if (_duringTrigger) {
return;
}
if (!(widget.shouldChangeUndoStack?.call(_lastValue, widget.value.value) ?? true)) {
return;
}
_lastValue = widget.value.value;
_throttleTimer = _throttledPush(widget.value.value);
}
void _handleFocus() {
if (!widget.focusNode.hasFocus) {
return;
}
UndoManager.client = this;
_updateState();
}
@override
void handlePlatformUndo(UndoDirection direction) {
switch(direction) {
case UndoDirection.undo:
undo();
break;
case UndoDirection.redo:
redo();
break;
}
}
@override
void initState() {
super.initState();
_throttledPush = _throttle<T>(
duration: _kThrottleDuration,
function: (T currentValue) {
_stack.push(currentValue);
_updateState();
},
);
_push();
widget.value.addListener(_push);
_handleFocus();
widget.focusNode.addListener(_handleFocus);
_effectiveController.onUndo.addListener(undo);
_effectiveController.onRedo.addListener(redo);
}
@override
void didUpdateWidget(UndoHistory<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != oldWidget.value) {
_stack.clear();
oldWidget.value.removeListener(_push);
widget.value.addListener(_push);
}
if (widget.focusNode != oldWidget.focusNode) {
oldWidget.focusNode.removeListener(_handleFocus);
widget.focusNode.addListener(_handleFocus);
}
if (widget.controller != oldWidget.controller) {
_effectiveController.onUndo.removeListener(undo);
_effectiveController.onRedo.removeListener(redo);
_controller?.dispose();
_controller = null;
_effectiveController.onUndo.addListener(undo);
_effectiveController.onRedo.addListener(redo);
}
}
@override
void dispose() {
widget.value.removeListener(_push);
widget.focusNode.removeListener(_handleFocus);
_effectiveController.onUndo.removeListener(undo);
_effectiveController.onRedo.removeListener(redo);
_controller?.dispose();
_throttleTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Actions(
actions: <Type, Action<Intent>>{
UndoTextIntent: Action<UndoTextIntent>.overridable(context: context, defaultAction: CallbackAction<UndoTextIntent>(onInvoke: _undoFromIntent)),
RedoTextIntent: Action<RedoTextIntent>.overridable(context: context, defaultAction: CallbackAction<RedoTextIntent>(onInvoke: _redoFromIntent)),
},
child: widget.child,
);
}
}
/// Represents whether the current undo stack can undo or redo.
@immutable
class UndoHistoryValue {
/// Creates a value for whether the current undo stack can undo or redo.
///
/// The [canUndo] and [canRedo] arguments must have a value, but default to
/// false.
const UndoHistoryValue({this.canUndo = false, this.canRedo = false});
/// A value corresponding to an undo stack that can neither undo nor redo.
static const UndoHistoryValue empty = UndoHistoryValue();
/// Whether the current undo stack can perform an undo operation.
final bool canUndo;
/// Whether the current undo stack can perform a redo operation.
final bool canRedo;
@override
String toString() => '${objectRuntimeType(this, 'UndoHistoryValue')}(canUndo: $canUndo, canRedo: $canRedo)';
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is UndoHistoryValue && other.canUndo == canUndo && other.canRedo == canRedo;
}
@override
int get hashCode => Object.hash(
canUndo.hashCode,
canRedo.hashCode,
);
}
/// A controller for the undo history, for example for an editable text field.
///
/// Whenever a change happens to the underlying value that the [UndoHistory]
/// widget tracks, that widget updates the [value] and the controller notifies
/// it's listeners. Listeners can then read the canUndo and canRedo
/// properties of the value to discover whether [undo] or [redo] are possible.
///
/// The controller also has [undo] and [redo] methods to modify the undo
/// history.
///
/// {@tool dartpad}
/// This example creates a [TextField] with an [UndoHistoryController]
/// which provides undo and redo buttons.
///
/// ** See code in examples/api/lib/widgets/undo_history/undo_history_controller.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [EditableText], which uses the [UndoHistory] widget and allows
/// control of the underlying history using an [UndoHistoryController].
class UndoHistoryController extends ValueNotifier<UndoHistoryValue> {
/// Creates a controller for an [UndoHistory] widget.
UndoHistoryController({UndoHistoryValue? value}) : super(value ?? UndoHistoryValue.empty);
/// Notifies listeners that [undo] has been called.
final ChangeNotifier onUndo = ChangeNotifier();
/// Notifies listeners that [redo] has been called.
final ChangeNotifier onRedo = ChangeNotifier();
/// Reverts the value on the stack to the previous value.
void undo() {
if (!value.canUndo) {
return;
}
onUndo.notifyListeners();
}
/// Updates the value on the stack to the next value.
void redo() {
if (!value.canRedo) {
return;
}
onRedo.notifyListeners();
}
@override
void dispose() {
onUndo.dispose();
onRedo.dispose();
super.dispose();
}
}
/// A data structure representing a chronological list of states that can be
/// undone and redone.
class _UndoStack<T> {
/// Creates an instance of [_UndoStack].
_UndoStack();
final List<T> _list = <T>[];
// The index of the current value, or -1 if the list is empty.
int _index = -1;
/// Returns the current value of the stack.
T? get currentValue => _list.isEmpty ? null : _list[_index];
bool get canUndo => _list.isNotEmpty && _index > 0;
bool get canRedo => _list.isNotEmpty && _index < _list.length - 1;
/// Add a new state change to the stack.
///
/// Pushing identical objects will not create multiple entries.
void push(T value) {
if (_list.isEmpty) {
_index = 0;
_list.add(value);
return;
}
assert(_index < _list.length && _index >= 0);
if (value == currentValue) {
return;
}
// If anything has been undone in this stack, remove those irrelevant states
// before adding the new one.
if (_index != _list.length - 1) {
_list.removeRange(_index + 1, _list.length);
}
_list.add(value);
_index = _list.length - 1;
}
/// Returns the current value after an undo operation.
///
/// An undo operation moves the current value to the previously pushed value,
/// if any.
///
/// Iff the stack is completely empty, then returns null.
T? undo() {
if (_list.isEmpty) {
return null;
}
assert(_index < _list.length && _index >= 0);
if (_index != 0) {
_index = _index - 1;
}
return currentValue;
}
/// Returns the current value after a redo operation.
///
/// A redo operation moves the current value to the value that was last
/// undone, if any.
///
/// Iff the stack is completely empty, then returns null.
T? redo() {
if (_list.isEmpty) {
return null;
}
assert(_index < _list.length && _index >= 0);
if (_index < _list.length - 1) {
_index = _index + 1;
}
return currentValue;
}
/// Remove everything from the stack.
void clear() {
_list.clear();
_index = -1;
}
@override
String toString() {
return '_UndoStack $_list';
}
}
/// A function that can be throttled with the throttle function.
typedef _Throttleable<T> = void Function(T currentArg);
/// A function that has been throttled by [_throttle].
typedef _Throttled<T> = Timer Function(T currentArg);
/// Returns a _Throttled that will call through to the given function only a
/// maximum of once per duration.
///
/// Only works for functions that take exactly one argument and return void.
_Throttled<T> _throttle<T>({
required Duration duration,
required _Throttleable<T> function,
// If true, calls at the start of the timer.
bool leadingEdge = false,
}) {
Timer? timer;
bool calledDuringTimer = false;
late T arg;
return (T currentArg) {
arg = currentArg;
if (timer != null) {
calledDuringTimer = true;
return timer!;
}
if (leadingEdge) {
function(arg);
}
calledDuringTimer = false;
timer = Timer(duration, () {
if (!leadingEdge || calledDuringTimer) {
function(arg);
}
timer = null;
});
return timer!;
};
}
...@@ -147,6 +147,7 @@ export 'src/widgets/ticker_provider.dart'; ...@@ -147,6 +147,7 @@ export 'src/widgets/ticker_provider.dart';
export 'src/widgets/title.dart'; export 'src/widgets/title.dart';
export 'src/widgets/transitions.dart'; export 'src/widgets/transitions.dart';
export 'src/widgets/tween_animation_builder.dart'; export 'src/widgets/tween_animation_builder.dart';
export 'src/widgets/undo_history.dart';
export 'src/widgets/unique_widget.dart'; export 'src/widgets/unique_widget.dart';
export 'src/widgets/value_listenable_builder.dart'; export 'src/widgets/value_listenable_builder.dart';
export 'src/widgets/view.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() { ...@@ -6057,68 +6057,6 @@ void main() {
'to come to the aid\n' // 36 + 19 => 55 'to come to the aid\n' // 36 + 19 => 55
'of their country.'; // 55 + 17 => 72 '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 { Future<void> testTextEditing(WidgetTester tester, {required TargetPlatform targetPlatform}) async {
final String targetPlatformString = targetPlatform.toString(); final String targetPlatformString = targetPlatform.toString();
final String platform = targetPlatformString.substring(targetPlatformString.indexOf('.') + 1).toLowerCase(); final String platform = targetPlatformString.substring(targetPlatformString.indexOf('.') + 1).toLowerCase();
...@@ -13066,14 +13004,14 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async ...@@ -13066,14 +13004,14 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
// Undo first insertion. // Undo first insertion.
await sendUndo(tester); 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. // Waiting for the throttling beetween undos should have no effect.
await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500));
// Undo second insertion. // Undo second insertion.
await sendUndo(tester); 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. // On web, these keyboard shortcuts are handled by the browser.
}, variant: TargetPlatformVariant.only(TargetPlatform.android), skip: kIsWeb); // [intended] }, variant: TargetPlatformVariant.only(TargetPlatform.android), skip: kIsWeb); // [intended]
...@@ -13594,6 +13532,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async ...@@ -13594,6 +13532,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
controller.value, controller.value,
const TextEditingValue( const TextEditingValue(
text: '1 nihao', text: '1 nihao',
composing: TextRange(start: 2, end: 7),
selection: TextSelection.collapsed(offset: 7), selection: TextSelection.collapsed(offset: 7),
), ),
); );
...@@ -13603,6 +13542,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async ...@@ -13603,6 +13542,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
controller.value, controller.value,
const TextEditingValue( const TextEditingValue(
text: '1 ni', text: '1 ni',
composing: TextRange(start: 2, end: 4),
selection: TextSelection.collapsed(offset: 4), selection: TextSelection.collapsed(offset: 4),
), ),
); );
...@@ -13620,6 +13560,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async ...@@ -13620,6 +13560,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
controller.value, controller.value,
const TextEditingValue( const TextEditingValue(
text: '1 ni', text: '1 ni',
composing: TextRange(start: 2, end: 4),
selection: TextSelection.collapsed(offset: 4), selection: TextSelection.collapsed(offset: 4),
), ),
); );
...@@ -13628,6 +13569,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async ...@@ -13628,6 +13569,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
controller.value, controller.value,
const TextEditingValue( const TextEditingValue(
text: '1 nihao', text: '1 nihao',
composing: TextRange(start: 2, end: 7),
selection: TextSelection.collapsed(offset: 7), selection: TextSelection.collapsed(offset: 7),
), ),
); );
...@@ -13653,6 +13595,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async ...@@ -13653,6 +13595,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
controller.value, controller.value,
const TextEditingValue( const TextEditingValue(
text: '1 nihao', text: '1 nihao',
composing: TextRange(start: 2, end: 7),
selection: TextSelection.collapsed(offset: 7), selection: TextSelection.collapsed(offset: 7),
), ),
); );
...@@ -13661,6 +13604,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async ...@@ -13661,6 +13604,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
controller.value, controller.value,
const TextEditingValue( const TextEditingValue(
text: '1 ni', text: '1 ni',
composing: TextRange(start: 2, end: 4),
selection: TextSelection.collapsed(offset: 4), selection: TextSelection.collapsed(offset: 4),
), ),
); );
...@@ -13700,6 +13644,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async ...@@ -13700,6 +13644,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
controller.value, controller.value,
const TextEditingValue( const TextEditingValue(
text: '1 ni', text: '1 ni',
composing: TextRange(start: 2, end: 4),
selection: TextSelection.collapsed(offset: 4), selection: TextSelection.collapsed(offset: 4),
), ),
); );
...@@ -13708,6 +13653,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async ...@@ -13708,6 +13653,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
controller.value, controller.value,
const TextEditingValue( const TextEditingValue(
text: '1 nihao', text: '1 nihao',
composing: TextRange(start: 2, end: 7),
selection: TextSelection.collapsed(offset: 7), selection: TextSelection.collapsed(offset: 7),
), ),
); );
...@@ -13829,6 +13775,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async ...@@ -13829,6 +13775,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
controller.value, controller.value,
const TextEditingValue( const TextEditingValue(
text: '1 2 ni', text: '1 2 ni',
composing: TextRange(start: 4, end: 6),
selection: TextSelection.collapsed(offset: 6), selection: TextSelection.collapsed(offset: 6),
), ),
); );
...@@ -13887,6 +13834,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async ...@@ -13887,6 +13834,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
controller.value, controller.value,
const TextEditingValue( const TextEditingValue(
text: '1 2 ni', text: '1 2 ni',
composing: TextRange(start: 4, end: 6),
selection: TextSelection.collapsed(offset: 6), selection: TextSelection.collapsed(offset: 6),
), ),
); );
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.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';
/// On web, the context menu (aka toolbar) is provided by the browser. /// 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}) { ...@@ -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); 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. // Simple controller that builds a WidgetSpan with 100 height.
class OverflowWidgetTextEditingController extends TextEditingController { class OverflowWidgetTextEditingController extends TextEditingController {
@override @override
......
// 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/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'editable_text_utils.dart';
final FocusNode focusNode = FocusNode(debugLabel: 'UndoHistory Node');
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('UndoHistory', () {
Future<void> sendUndoRedo(WidgetTester tester, [bool redo = false]) {
return sendKeys(
tester,
<LogicalKeyboardKey>[
LogicalKeyboardKey.keyZ,
],
shortcutModifier: true,
shift: redo,
targetPlatform: defaultTargetPlatform,
);
}
Future<void> sendUndo(WidgetTester tester) => sendUndoRedo(tester);
Future<void> sendRedo(WidgetTester tester) => sendUndoRedo(tester, true);
testWidgets('allows undo and redo to be called programmatically from the UndoHistoryController', (WidgetTester tester) async {
final ValueNotifier<int> value = ValueNotifier<int>(0);
final UndoHistoryController controller = UndoHistoryController();
await tester.pumpWidget(
MaterialApp(
home: UndoHistory<int>(
value: value,
controller: controller,
onTriggered: (int newValue) {
value.value = newValue;
},
focusNode: focusNode,
child: Container(),
),
),
);
await tester.pump(const Duration(milliseconds: 500));
// Undo/redo have no effect if the value has never changed.
expect(controller.value.canUndo, false);
expect(controller.value.canRedo, false);
controller.undo();
expect(value.value, 0);
controller.redo();
expect(value.value, 0);
focusNode.requestFocus();
await tester.pump();
expect(controller.value.canUndo, false);
expect(controller.value.canRedo, false);
controller.undo();
expect(value.value, 0);
controller.redo();
expect(value.value, 0);
value.value = 1;
// Wait for the throttling.
await tester.pump(const Duration(milliseconds: 500));
// Can undo/redo a single change.
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
controller.undo();
expect(value.value, 0);
expect(controller.value.canUndo, false);
expect(controller.value.canRedo, true);
controller.redo();
expect(value.value, 1);
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
value.value = 2;
await tester.pump(const Duration(milliseconds: 500));
// And can undo/redo multiple changes.
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
controller.undo();
expect(value.value, 1);
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, true);
controller.undo();
expect(value.value, 0);
expect(controller.value.canUndo, false);
expect(controller.value.canRedo, true);
controller.redo();
expect(value.value, 1);
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, true);
controller.redo();
expect(value.value, 2);
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
// Changing the value again clears the redo stack.
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
controller.undo();
expect(value.value, 1);
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, true);
value.value = 3;
await tester.pump(const Duration(milliseconds: 500));
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
}, variant: TargetPlatformVariant.all());
testWidgets('allows undo and redo to be called using the keyboard', (WidgetTester tester) async {
final ValueNotifier<int> value = ValueNotifier<int>(0);
final UndoHistoryController controller = UndoHistoryController();
await tester.pumpWidget(
MaterialApp(
home: UndoHistory<int>(
controller: controller,
value: value,
onTriggered: (int newValue) {
value.value = newValue;
},
focusNode: focusNode,
child: Focus(
focusNode: focusNode,
child: Container(),
),
),
),
);
await tester.pump(const Duration(milliseconds: 500));
// Undo/redo have no effect if the value has never changed.
expect(controller.value.canUndo, false);
expect(controller.value.canRedo, false);
await sendUndo(tester);
expect(value.value, 0);
await sendRedo(tester);
expect(value.value, 0);
focusNode.requestFocus();
await tester.pump();
expect(controller.value.canUndo, false);
expect(controller.value.canRedo, false);
await sendUndo(tester);
expect(value.value, 0);
await sendRedo(tester);
expect(value.value, 0);
value.value = 1;
// Wait for the throttling.
await tester.pump(const Duration(milliseconds: 500));
// Can undo/redo a single change.
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
await sendUndo(tester);
expect(value.value, 0);
expect(controller.value.canUndo, false);
expect(controller.value.canRedo, true);
await sendRedo(tester);
expect(value.value, 1);
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
value.value = 2;
await tester.pump(const Duration(milliseconds: 500));
// And can undo/redo multiple changes.
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
await sendUndo(tester);
expect(value.value, 1);
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, true);
await sendUndo(tester);
expect(value.value, 0);
expect(controller.value.canUndo, false);
expect(controller.value.canRedo, true);
await sendRedo(tester);
expect(value.value, 1);
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, true);
await sendRedo(tester);
expect(value.value, 2);
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
// Changing the value again clears the redo stack.
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
await sendUndo(tester);
expect(value.value, 1);
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, true);
value.value = 3;
await tester.pump(const Duration(milliseconds: 500));
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
}, variant: TargetPlatformVariant.all(), skip: kIsWeb); // [intended]
testWidgets('duplicate changes do not affect the undo history', (WidgetTester tester) async {
final ValueNotifier<int> value = ValueNotifier<int>(0);
final UndoHistoryController controller = UndoHistoryController();
await tester.pumpWidget(
MaterialApp(
home: UndoHistory<int>(
controller: controller,
value: value,
onTriggered: (int newValue) {
value.value = newValue;
},
focusNode: focusNode,
child: Container(),
),
),
);
focusNode.requestFocus();
// Wait for the throttling.
await tester.pump(const Duration(milliseconds: 500));
value.value = 1;
// Wait for the throttling.
await tester.pump(const Duration(milliseconds: 500));
// Can undo/redo a single change.
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
controller.undo();
expect(value.value, 0);
expect(controller.value.canUndo, false);
expect(controller.value.canRedo, true);
controller.redo();
expect(value.value, 1);
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
// Changes that result in the same state won't be saved on the undo stack.
value.value = 1;
await tester.pump(const Duration(milliseconds: 500));
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
controller.undo();
expect(value.value, 0);
expect(controller.value.canUndo, false);
expect(controller.value.canRedo, true);
}, variant: TargetPlatformVariant.all());
testWidgets('ignores value changes pushed during onTriggered', (WidgetTester tester) async {
final ValueNotifier<int> value = ValueNotifier<int>(0);
final UndoHistoryController controller = UndoHistoryController();
int Function(int newValue) valueToUse = (int value) => value;
final GlobalKey<UndoHistoryState<int>> key = GlobalKey<UndoHistoryState<int>>();
await tester.pumpWidget(
MaterialApp(
home: UndoHistory<int>(
key: key,
value: value,
controller: controller,
onTriggered: (int newValue) {
value.value = valueToUse(newValue);
},
focusNode: focusNode,
child: Container(),
),
),
);
await tester.pump(const Duration(milliseconds: 500));
// Undo/redo have no effect if the value has never changed.
expect(controller.value.canUndo, false);
expect(controller.value.canRedo, false);
controller.undo();
expect(value.value, 0);
controller.redo();
expect(value.value, 0);
focusNode.requestFocus();
await tester.pump();
expect(controller.value.canUndo, false);
expect(controller.value.canRedo, false);
controller.undo();
expect(value.value, 0);
controller.redo();
expect(value.value, 0);
value.value = 1;
// Wait for the throttling.
await tester.pump(const Duration(milliseconds: 500));
valueToUse = (int value) => 3;
expect(() => key.currentState!.undo(), throwsAssertionError);
}, variant: TargetPlatformVariant.all());
testWidgets('changes should send setUndoState to the UndoManagerConnection on iOS', (WidgetTester tester) async {
final List<MethodCall> log = <MethodCall>[];
SystemChannels.undoManager.setMockMethodCallHandler((MethodCall methodCall) async {
log.add(methodCall);
});
final FocusNode focusNode = FocusNode();
final ValueNotifier<int> value = ValueNotifier<int>(0);
final UndoHistoryController controller = UndoHistoryController();
await tester.pumpWidget(
MaterialApp(
home: UndoHistory<int>(
controller: controller,
value: value,
onTriggered: (int newValue) {
value.value = newValue;
},
focusNode: focusNode,
child: Focus(
focusNode: focusNode,
child: Container(),
),
),
),
);
await tester.pump();
focusNode.requestFocus();
await tester.pump();
// Wait for the throttling.
await tester.pump(const Duration(milliseconds: 500));
// Undo and redo should both be disabled.
MethodCall methodCall = log.lastWhere((MethodCall m) => m.method == 'UndoManager.setUndoState');
expect(methodCall.method, 'UndoManager.setUndoState');
expect(methodCall.arguments as Map<String, dynamic>, <String, bool>{'canUndo': false, 'canRedo': false});
// Making a change should enable undo.
value.value = 1;
await tester.pump(const Duration(milliseconds: 500));
methodCall = log.lastWhere((MethodCall m) => m.method == 'UndoManager.setUndoState');
expect(methodCall.method, 'UndoManager.setUndoState');
expect(methodCall.arguments as Map<String, dynamic>, <String, bool>{'canUndo': true, 'canRedo': false});
// Undo should remain enabled after another change.
value.value = 2;
await tester.pump(const Duration(milliseconds: 500));
methodCall = log.lastWhere((MethodCall m) => m.method == 'UndoManager.setUndoState');
expect(methodCall.method, 'UndoManager.setUndoState');
expect(methodCall.arguments as Map<String, dynamic>, <String, bool>{'canUndo': true, 'canRedo': false});
// Undo and redo should be enabled after one undo.
controller.undo();
methodCall = log.lastWhere((MethodCall m) => m.method == 'UndoManager.setUndoState');
expect(methodCall.method, 'UndoManager.setUndoState');
expect(methodCall.arguments as Map<String, dynamic>, <String, bool>{'canUndo': true, 'canRedo': true});
// Only redo should be enabled after a second undo.
controller.undo();
methodCall = log.lastWhere((MethodCall m) => m.method == 'UndoManager.setUndoState');
expect(methodCall.method, 'UndoManager.setUndoState');
expect(methodCall.arguments as Map<String, dynamic>, <String, bool>{'canUndo': false, 'canRedo': true});
}, variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), skip: kIsWeb); // [intended]
testWidgets('handlePlatformUndo should undo or redo appropriately on iOS', (WidgetTester tester) async {
final ValueNotifier<int> value = ValueNotifier<int>(0);
final UndoHistoryController controller = UndoHistoryController();
await tester.pumpWidget(
MaterialApp(
home: UndoHistory<int>(
controller: controller,
value: value,
onTriggered: (int newValue) {
value.value = newValue;
},
focusNode: focusNode,
child: Focus(
focusNode: focusNode,
child: Container(),
),
),
),
);
await tester.pump(const Duration(milliseconds: 500));
focusNode.requestFocus();
await tester.pump();
// Undo/redo have no effect if the value has never changed.
expect(controller.value.canUndo, false);
expect(controller.value.canRedo, false);
UndoManager.client!.handlePlatformUndo(UndoDirection.undo);
expect(value.value, 0);
UndoManager.client!.handlePlatformUndo(UndoDirection.redo);
expect(value.value, 0);
value.value = 1;
// Wait for the throttling.
await tester.pump(const Duration(milliseconds: 500));
// Can undo/redo a single change.
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
UndoManager.client!.handlePlatformUndo(UndoDirection.undo);
expect(value.value, 0);
expect(controller.value.canUndo, false);
expect(controller.value.canRedo, true);
UndoManager.client!.handlePlatformUndo(UndoDirection.redo);
expect(value.value, 1);
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
value.value = 2;
await tester.pump(const Duration(milliseconds: 500));
// And can undo/redo multiple changes.
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
UndoManager.client!.handlePlatformUndo(UndoDirection.undo);
expect(value.value, 1);
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, true);
UndoManager.client!.handlePlatformUndo(UndoDirection.undo);
expect(value.value, 0);
expect(controller.value.canUndo, false);
expect(controller.value.canRedo, true);
UndoManager.client!.handlePlatformUndo(UndoDirection.redo);
expect(value.value, 1);
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, true);
UndoManager.client!.handlePlatformUndo(UndoDirection.redo);
expect(value.value, 2);
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
// Changing the value again clears the redo stack.
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
UndoManager.client!.handlePlatformUndo(UndoDirection.undo);
expect(value.value, 1);
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, true);
value.value = 3;
await tester.pump(const Duration(milliseconds: 500));
expect(controller.value.canUndo, true);
expect(controller.value.canRedo, false);
}, variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), skip: kIsWeb); // [intended]
});
group('UndoHistoryController', () {
testWidgets('UndoHistoryController notifies onUndo listeners onUndo', (WidgetTester tester) async {
int calls = 0;
final UndoHistoryController controller = UndoHistoryController();
controller.onUndo.addListener(() {
calls++;
});
// Does not notify the listener if canUndo is false.
controller.undo();
expect(calls, 0);
// Does notify the listener if canUndo is true.
controller.value = const UndoHistoryValue(canUndo: true);
controller.undo();
expect(calls, 1);
});
testWidgets('UndoHistoryController notifies onRedo listeners onRedo', (WidgetTester tester) async {
int calls = 0;
final UndoHistoryController controller = UndoHistoryController();
controller.onRedo.addListener(() {
calls++;
});
// Does not notify the listener if canUndo is false.
controller.redo();
expect(calls, 0);
// Does notify the listener if canRedo is true.
controller.value = const UndoHistoryValue(canRedo: true);
controller.redo();
expect(calls, 1);
});
testWidgets('UndoHistoryController notifies listeners on value change', (WidgetTester tester) async {
int calls = 0;
final UndoHistoryController controller = UndoHistoryController(value: const UndoHistoryValue(canUndo: true));
controller.addListener(() {
calls++;
});
// Does not notify if the value is the same.
controller.value = const UndoHistoryValue(canUndo: true);
expect(calls, 0);
// Does notify if the value has changed.
controller.value = const UndoHistoryValue(canRedo: true);
expect(calls, 1);
});
});
}
...@@ -370,4 +370,16 @@ class TestTextInput { ...@@ -370,4 +370,16 @@ class TestTextInput {
Future<void> handleKeyUpEvent(LogicalKeyboardKey key) async { Future<void> handleKeyUpEvent(LogicalKeyboardKey key) async {
await _keyHandler?.handleKeyUpEvent(key); 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