| // 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'; |
| |
| 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 BuildContext? buildContext = primaryFocus?.context ?? FocusDemo.appKey.currentContext; |
| if (buildContext == null) { |
| return false; |
| } |
| final UndoableActionDispatcher manager = Actions.of(buildContext) as UndoableActionDispatcher; |
| return manager.canUndo; |
| } |
| |
| @override |
| void invoke(UndoIntent intent) { |
| final BuildContext? buildContext = primaryFocus?.context ?? FocusDemo.appKey.currentContext; |
| if (buildContext == null) { |
| return; |
| } |
| 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 BuildContext? buildContext = primaryFocus?.context ?? FocusDemo.appKey.currentContext; |
| if (buildContext == null) { |
| return false; |
| } |
| final UndoableActionDispatcher manager = Actions.of(buildContext) as UndoableActionDispatcher; |
| return manager.canRedo; |
| } |
| |
| @override |
| RedoAction invoke(RedoIntent intent) { |
| final BuildContext? buildContext = primaryFocus?.context ?? FocusDemo.appKey.currentContext; |
| if (buildContext == null) { |
| return this; |
| } |
| final UndoableActionDispatcher manager = Actions.of(buildContext) 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({super.key, required this.name}); |
| |
| final String name; |
| |
| @override |
| State<DemoButton> createState() => _DemoButtonState(); |
| } |
| |
| class _DemoButtonState extends State<DemoButton> { |
| late final FocusNode _focusNode = FocusNode(debugLabel: widget.name); |
| final GlobalKey _nameKey = GlobalKey(); |
| |
| 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: const MaterialStatePropertyAll<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 Colors.transparent; |
| }), |
| ), |
| onPressed: () => _handleOnPressed(), |
| child: Text(widget.name, key: _nameKey), |
| ); |
| } |
| } |
| |
| class FocusDemo extends StatefulWidget { |
| const FocusDemo({super.key}); |
| |
| static GlobalKey appKey = GlobalKey(); |
| |
| @override |
| State<FocusDemo> createState() => _FocusDemoState(); |
| } |
| |
| class _FocusDemoState extends State<FocusDemo> { |
| final FocusNode outlineFocus = FocusNode(debugLabel: 'Demo Focus Node'); |
| late final UndoableActionDispatcher dispatcher = UndoableActionDispatcher(); |
| bool canUndo = false; |
| bool canRedo = false; |
| |
| @override |
| void initState() { |
| super.initState(); |
| 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: <ShortcutActivator, Intent>{ |
| SingleActivator(LogicalKeyboardKey.keyZ, meta: Platform.isMacOS, control: !Platform.isMacOS, shift: true): const RedoIntent(), |
| SingleActivator(LogicalKeyboardKey.keyZ, meta: Platform.isMacOS, control: !Platform.isMacOS): const UndoIntent(), |
| }, |
| child: FocusScope( |
| key: FocusDemo.appKey, |
| debugLabel: 'Scope', |
| autofocus: true, |
| child: DefaultTextStyle( |
| style: textTheme.headlineMedium!, |
| 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( |
| onPressed: canUndo |
| ? () { |
| Actions.invoke(context, const UndoIntent()); |
| } |
| : null, |
| child: const Text('UNDO'), |
| ), |
| ), |
| Padding( |
| padding: const EdgeInsets.all(8.0), |
| child: ElevatedButton( |
| onPressed: canRedo |
| ? () { |
| Actions.invoke(context, const RedoIntent()); |
| } |
| : null, |
| child: const Text('REDO'), |
| ), |
| ), |
| ], |
| ), |
| ], |
| ); |
| }), |
| ), |
| ), |
| ), |
| ), |
| ), |
| ), |
| ); |
| } |
| } |