mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
Add support for iOS UndoManager (#98294)
Add support for iOS UndoManager
This commit is contained in:
parent
a16e620ec2
commit
2a67bf78f0
@ -0,0 +1,81 @@
|
||||
// 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
|
||||
|
131
packages/flutter/lib/src/services/undo_manager.dart
Normal file
131
packages/flutter/lib/src/services/undo_manager.dart
Normal file
@ -0,0 +1,131 @@
|
||||
// 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';
|
||||
import 'text_selection.dart';
|
||||
import 'text_selection_toolbar_anchors.dart';
|
||||
import 'ticker_provider.dart';
|
||||
import 'undo_history.dart';
|
||||
import 'view.dart';
|
||||
import 'widget_span.dart';
|
||||
|
||||
@ -806,10 +807,10 @@ class EditableText extends StatefulWidget {
|
||||
this.contextMenuBuilder,
|
||||
this.spellCheckConfiguration,
|
||||
this.magnifierConfiguration = TextMagnifierConfiguration.disabled,
|
||||
this.undoController,
|
||||
}) : assert(obscuringCharacter.length == 1),
|
||||
smartDashesType = smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled),
|
||||
smartQuotesType = smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled),
|
||||
assert(maxLines == null || maxLines > 0),
|
||||
assert(minLines == null || minLines > 0),
|
||||
assert(
|
||||
(maxLines == null) || (minLines == null) || (maxLines >= minLines),
|
||||
@ -965,6 +966,11 @@ class EditableText extends StatefulWidget {
|
||||
/// The text style to use for the editable text.
|
||||
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}
|
||||
/// The strut style used for the vertical layout.
|
||||
///
|
||||
@ -2032,6 +2038,7 @@ class EditableText extends StatefulWidget {
|
||||
properties.add(DiagnosticsProperty<bool>('scribbleEnabled', scribbleEnabled, defaultValue: true));
|
||||
properties.add(DiagnosticsProperty<bool>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, 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<List<String>>('contentCommitMimeTypes', contentInsertionConfiguration?.allowedMimeTypes ?? const <String>[], defaultValue: contentInsertionConfiguration == null ? const <String>[] : kDefaultContentInsertionMimeTypes));
|
||||
}
|
||||
@ -4488,11 +4495,42 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
cursor: widget.mouseCursor ?? SystemMouseCursors.text,
|
||||
child: Actions(
|
||||
actions: _actions,
|
||||
child: _TextEditingHistory(
|
||||
controller: widget.controller,
|
||||
child: UndoHistory<TextEditingValue>(
|
||||
value: widget.controller,
|
||||
onTriggered: (TextEditingValue value) {
|
||||
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(
|
||||
focusNode: widget.focusNode,
|
||||
includeSemantics: false,
|
||||
@ -5266,286 +5304,6 @@ class _CopySelectionAction extends ContextAction<CopySelectionTextIntent> {
|
||||
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.
|
||||
@immutable
|
||||
class _GlyphHeights {
|
||||
|
483
packages/flutter/lib/src/widgets/undo_history.dart
Normal file
483
packages/flutter/lib/src/widgets/undo_history.dart
Normal file
@ -0,0 +1,483 @@
|
||||
// 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';
|
||||
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';
|
||||
|
67
packages/flutter/test/services/undo_manager_test.dart
Normal file
67
packages/flutter/test/services/undo_manager_test.dart
Normal file
@ -0,0 +1,67 @@
|
||||
// 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
|
||||
|
517
packages/flutter/test/widgets/undo_history_test.dart
Normal file
517
packages/flutter/test/widgets/undo_history_test.dart
Normal file
@ -0,0 +1,517 @@
|
||||
// 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 {
|
||||
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 */},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user