| // 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:collection'; | |
| import 'dart:io'; | |
| import 'package:flutter/foundation.dart'; | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/services.dart'; | |
| import 'package:flutter/widgets.dart'; | |
| void main() { | |
| runApp(const MaterialApp( | |
| title: 'Actions Demo', | |
| home: FocusDemo(), | |
| )); | |
| } | |
| /// A class that can hold invocation information that an [UndoableAction] can | |
| /// use to undo/redo itself. | |
| /// | |
| /// Instances of this class are returned from [UndoableAction]s and placed on | |
| /// the undo stack when they are invoked. | |
| class Memento extends Object with Diagnosticable { | |
| const Memento({ | |
| @required this.name, | |
| @required this.undo, | |
| @required this.redo, | |
| }); | |
| /// Returns true if this Memento can be used to undo. | |
| /// | |
| /// Subclasses could override to provide their own conditions when a command is | |
| /// undoable. | |
| bool get canUndo => true; | |
| /// Returns true if this Memento can be used to redo. | |
| /// | |
| /// Subclasses could override to provide their own conditions when a command is | |
| /// redoable. | |
| bool get canRedo => true; | |
| final String name; | |
| final VoidCallback undo; | |
| final ValueGetter<Memento> redo; | |
| @override | |
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { | |
| super.debugFillProperties(properties); | |
| properties.add(StringProperty('name', name)); | |
| properties.add(FlagProperty('undo', value: undo != null, ifTrue: 'undo')); | |
| properties.add(FlagProperty('redo', value: redo != null, ifTrue: 'redo')); | |
| } | |
| } | |
| /// Undoable Actions | |
| /// An [ActionDispatcher] subclass that manages the invocation of undoable | |
| /// actions. | |
| class UndoableActionDispatcher extends ActionDispatcher implements Listenable { | |
| /// Constructs a new [UndoableActionDispatcher]. | |
| /// | |
| /// The [maxUndoLevels] argument must not be null. | |
| UndoableActionDispatcher({ | |
| int maxUndoLevels = _defaultMaxUndoLevels, | |
| }) : assert(maxUndoLevels != null), | |
| _maxUndoLevels = maxUndoLevels; | |
| // A stack of actions that have been performed. The most recent action | |
| // performed is at the end of the list. | |
| final DoubleLinkedQueue<Memento> _completedActions = DoubleLinkedQueue<Memento>(); | |
| // A stack of actions that can be redone. The most recent action performed is | |
| // at the end of the list. | |
| final List<Memento> _undoneActions = <Memento>[]; | |
| static const int _defaultMaxUndoLevels = 1000; | |
| /// The maximum number of undo levels allowed. | |
| /// | |
| /// If this value is set to a value smaller than the number of completed | |
| /// actions, then the stack of completed actions is truncated to only include | |
| /// the last [maxUndoLevels] actions. | |
| int get maxUndoLevels => _maxUndoLevels; | |
| int _maxUndoLevels; | |
| set maxUndoLevels(int value) { | |
| _maxUndoLevels = value; | |
| _pruneActions(); | |
| } | |
| final Set<VoidCallback> _listeners = <VoidCallback>{}; | |
| @override | |
| void addListener(VoidCallback listener) { | |
| _listeners.add(listener); | |
| } | |
| @override | |
| void removeListener(VoidCallback listener) { | |
| _listeners.remove(listener); | |
| } | |
| /// Notifies listeners that the [ActionDispatcher] has changed state. | |
| /// | |
| /// May only be called by subclasses. | |
| @protected | |
| void notifyListeners() { | |
| for (final VoidCallback callback in _listeners) { | |
| callback(); | |
| } | |
| } | |
| @override | |
| Object invokeAction(Action<Intent> action, Intent intent, [BuildContext context]) { | |
| final Object result = super.invokeAction(action, intent, context); | |
| print('Invoking ${action is UndoableAction ? 'undoable ' : ''}$intent as $action: $this '); | |
| if (action is UndoableAction) { | |
| _completedActions.addLast(result as Memento); | |
| _undoneActions.clear(); | |
| _pruneActions(); | |
| notifyListeners(); | |
| } | |
| return result; | |
| } | |
| // Enforces undo level limit. | |
| void _pruneActions() { | |
| while (_completedActions.length > _maxUndoLevels) { | |
| _completedActions.removeFirst(); | |
| } | |
| } | |
| /// Returns true if there is an action on the stack that can be undone. | |
| bool get canUndo { | |
| if (_completedActions.isNotEmpty) { | |
| return _completedActions.first.canUndo; | |
| } | |
| return false; | |
| } | |
| /// Returns true if an action that has been undone can be re-invoked. | |
| bool get canRedo { | |
| if (_undoneActions.isNotEmpty) { | |
| return _undoneActions.first.canRedo; | |
| } | |
| return false; | |
| } | |
| /// Undoes the last action executed if possible. | |
| /// | |
| /// Returns true if the action was successfully undone. | |
| bool undo() { | |
| print('Undoing. $this'); | |
| if (!canUndo) { | |
| return false; | |
| } | |
| final Memento memento = _completedActions.removeLast(); | |
| memento.undo(); | |
| _undoneActions.add(memento); | |
| notifyListeners(); | |
| return true; | |
| } | |
| /// Re-invokes a previously undone action, if possible. | |
| /// | |
| /// Returns true if the action was successfully invoked. | |
| bool redo() { | |
| print('Redoing. $this'); | |
| if (!canRedo) { | |
| return false; | |
| } | |
| final Memento memento = _undoneActions.removeLast(); | |
| final Memento replacement = memento.redo(); | |
| _completedActions.add(replacement); | |
| _pruneActions(); | |
| notifyListeners(); | |
| return true; | |
| } | |
| @override | |
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { | |
| super.debugFillProperties(properties); | |
| properties.add(IntProperty('undoable items', _completedActions.length)); | |
| properties.add(IntProperty('redoable items', _undoneActions.length)); | |
| properties.add(IterableProperty<Memento>('undo stack', _completedActions)); | |
| properties.add(IterableProperty<Memento>('redo stack', _undoneActions)); | |
| } | |
| } | |
| class UndoIntent extends Intent { | |
| const UndoIntent(); | |
| } | |
| class UndoAction extends Action<UndoIntent> { | |
| @override | |
| bool isEnabled(UndoIntent intent) { | |
| final UndoableActionDispatcher manager = Actions.of(primaryFocus?.context ?? FocusDemo.appKey.currentContext) as UndoableActionDispatcher; | |
| return manager.canUndo; | |
| } | |
| @override | |
| void invoke(UndoIntent intent) { | |
| final UndoableActionDispatcher manager = Actions.of(primaryFocus?.context ?? FocusDemo.appKey.currentContext) as UndoableActionDispatcher; | |
| manager?.undo(); | |
| } | |
| } | |
| class RedoIntent extends Intent { | |
| const RedoIntent(); | |
| } | |
| class RedoAction extends Action<RedoIntent> { | |
| @override | |
| bool isEnabled(RedoIntent intent) { | |
| final UndoableActionDispatcher manager = Actions.of(primaryFocus.context) as UndoableActionDispatcher; | |
| return manager.canRedo; | |
| } | |
| @override | |
| RedoAction invoke(RedoIntent intent) { | |
| final UndoableActionDispatcher manager = Actions.of(primaryFocus.context) as UndoableActionDispatcher; | |
| manager?.redo(); | |
| return this; | |
| } | |
| } | |
| /// An action that can be undone. | |
| abstract class UndoableAction<T extends Intent> extends Action<T> { | |
| /// The [Intent] this action was originally invoked with. | |
| Intent get invocationIntent => _invocationTag; | |
| Intent _invocationTag; | |
| @protected | |
| set invocationIntent(Intent value) => _invocationTag = value; | |
| @override | |
| @mustCallSuper | |
| void invoke(T intent) { | |
| invocationIntent = intent; | |
| } | |
| } | |
| class UndoableFocusActionBase<T extends Intent> extends UndoableAction<T> { | |
| @override | |
| @mustCallSuper | |
| Memento invoke(T intent) { | |
| super.invoke(intent); | |
| final FocusNode previousFocus = primaryFocus; | |
| return Memento(name: previousFocus.debugLabel, undo: () { | |
| previousFocus.requestFocus(); | |
| }, redo: () { | |
| return invoke(intent); | |
| }); | |
| } | |
| } | |
| class UndoableRequestFocusAction extends UndoableFocusActionBase<RequestFocusIntent> { | |
| @override | |
| Memento invoke(RequestFocusIntent intent) { | |
| final Memento memento = super.invoke(intent); | |
| intent.focusNode.requestFocus(); | |
| return memento; | |
| } | |
| } | |
| /// Actions for manipulating focus. | |
| class UndoableNextFocusAction extends UndoableFocusActionBase<NextFocusIntent> { | |
| @override | |
| Memento invoke(NextFocusIntent intent) { | |
| final Memento memento = super.invoke(intent); | |
| primaryFocus.nextFocus(); | |
| return memento; | |
| } | |
| } | |
| class UndoablePreviousFocusAction extends UndoableFocusActionBase<PreviousFocusIntent> { | |
| @override | |
| Memento invoke(PreviousFocusIntent intent) { | |
| final Memento memento = super.invoke(intent); | |
| primaryFocus.previousFocus(); | |
| return memento; | |
| } | |
| } | |
| class UndoableDirectionalFocusAction extends UndoableFocusActionBase<DirectionalFocusIntent> { | |
| TraversalDirection direction; | |
| @override | |
| Memento invoke(DirectionalFocusIntent intent) { | |
| final Memento memento = super.invoke(intent); | |
| primaryFocus.focusInDirection(intent.direction); | |
| return memento; | |
| } | |
| } | |
| /// A button class that takes focus when clicked. | |
| class DemoButton extends StatefulWidget { | |
| const DemoButton({this.name}); | |
| final String name; | |
| @override | |
| _DemoButtonState createState() => _DemoButtonState(); | |
| } | |
| class _DemoButtonState extends State<DemoButton> { | |
| FocusNode _focusNode; | |
| final GlobalKey _nameKey = GlobalKey(); | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _focusNode = FocusNode(debugLabel: widget.name); | |
| } | |
| void _handleOnPressed() { | |
| print('Button ${widget.name} pressed.'); | |
| setState(() { | |
| Actions.invoke(_nameKey.currentContext, RequestFocusIntent(_focusNode)); | |
| }); | |
| } | |
| @override | |
| void dispose() { | |
| super.dispose(); | |
| _focusNode.dispose(); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return TextButton( | |
| focusNode: _focusNode, | |
| style: ButtonStyle( | |
| foregroundColor: MaterialStateProperty.all<Color>(Colors.black), | |
| overlayColor: MaterialStateProperty.resolveWith<Color>((Set<MaterialState> states) { | |
| if (states.contains(MaterialState.focused)) | |
| return Colors.red; | |
| if (states.contains(MaterialState.hovered)) | |
| return Colors.blue; | |
| return null; | |
| }), | |
| ), | |
| onPressed: () => _handleOnPressed(), | |
| child: Text(widget.name, key: _nameKey), | |
| ); | |
| } | |
| } | |
| class FocusDemo extends StatefulWidget { | |
| const FocusDemo({Key key}) : super(key: key); | |
| static GlobalKey appKey = GlobalKey(); | |
| @override | |
| _FocusDemoState createState() => _FocusDemoState(); | |
| } | |
| class _FocusDemoState extends State<FocusDemo> { | |
| FocusNode outlineFocus; | |
| UndoableActionDispatcher dispatcher; | |
| bool canUndo; | |
| bool canRedo; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| outlineFocus = FocusNode(debugLabel: 'Demo Focus Node'); | |
| dispatcher = UndoableActionDispatcher(); | |
| canUndo = dispatcher.canUndo; | |
| canRedo = dispatcher.canRedo; | |
| dispatcher.addListener(_handleUndoStateChange); | |
| } | |
| void _handleUndoStateChange() { | |
| if (dispatcher.canUndo != canUndo) { | |
| setState(() { | |
| canUndo = dispatcher.canUndo; | |
| }); | |
| } | |
| if (dispatcher.canRedo != canRedo) { | |
| setState(() { | |
| canRedo = dispatcher.canRedo; | |
| }); | |
| } | |
| } | |
| @override | |
| void dispose() { | |
| dispatcher.removeListener(_handleUndoStateChange); | |
| outlineFocus.dispose(); | |
| super.dispose(); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| final TextTheme textTheme = Theme.of(context).textTheme; | |
| return Actions( | |
| dispatcher: dispatcher, | |
| actions: <Type, Action<Intent>>{ | |
| RequestFocusIntent: UndoableRequestFocusAction(), | |
| NextFocusIntent: UndoableNextFocusAction(), | |
| PreviousFocusIntent: UndoablePreviousFocusAction(), | |
| DirectionalFocusIntent: UndoableDirectionalFocusAction(), | |
| UndoIntent: UndoAction(), | |
| RedoIntent: RedoAction(), | |
| }, | |
| child: FocusTraversalGroup( | |
| policy: ReadingOrderTraversalPolicy(), | |
| child: Shortcuts( | |
| shortcuts: <LogicalKeySet, Intent>{ | |
| LogicalKeySet(Platform.isMacOS ? LogicalKeyboardKey.meta : LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.keyZ): const RedoIntent(), | |
| LogicalKeySet(Platform.isMacOS ? LogicalKeyboardKey.meta : LogicalKeyboardKey.control, LogicalKeyboardKey.keyZ): const UndoIntent(), | |
| }, | |
| child: FocusScope( | |
| key: FocusDemo.appKey, | |
| debugLabel: 'Scope', | |
| autofocus: true, | |
| child: DefaultTextStyle( | |
| style: textTheme.headline4, | |
| child: Scaffold( | |
| appBar: AppBar( | |
| title: const Text('Actions Demo'), | |
| ), | |
| body: Center( | |
| child: Builder(builder: (BuildContext context) { | |
| return Column( | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: <Widget>[ | |
| Row( | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: const <Widget>[ | |
| DemoButton(name: 'One'), | |
| DemoButton(name: 'Two'), | |
| DemoButton(name: 'Three'), | |
| ], | |
| ), | |
| Row( | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: const <Widget>[ | |
| DemoButton(name: 'Four'), | |
| DemoButton(name: 'Five'), | |
| DemoButton(name: 'Six'), | |
| ], | |
| ), | |
| Row( | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: const <Widget>[ | |
| DemoButton(name: 'Seven'), | |
| DemoButton(name: 'Eight'), | |
| DemoButton(name: 'Nine'), | |
| ], | |
| ), | |
| Row( | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: <Widget>[ | |
| Padding( | |
| padding: const EdgeInsets.all(8.0), | |
| child: ElevatedButton( | |
| child: const Text('UNDO'), | |
| onPressed: canUndo | |
| ? () { | |
| Actions.invoke(context, const UndoIntent()); | |
| } | |
| : null, | |
| ), | |
| ), | |
| Padding( | |
| padding: const EdgeInsets.all(8.0), | |
| child: ElevatedButton( | |
| child: const Text('REDO'), | |
| onPressed: canRedo | |
| ? () { | |
| Actions.invoke(context, const RedoIntent()); | |
| } | |
| : null, | |
| ), | |
| ), | |
| ], | |
| ), | |
| ], | |
| ); | |
| }), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } |