// 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/widgets.dart'; void main() { runApp(const MaterialApp( title: 'Actions Demo', home: FocusDemo(), )); } /// 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 List<UndoableAction> _completedActions = <UndoableAction>[]; // A stack of actions that can be redone. The most recent action performed is // at the end of the list. final List<UndoableAction> _undoneActions = <UndoableAction>[]; 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 bool invokeAction(Action action, Intent intent, {FocusNode focusNode}) { final bool result = super.invokeAction(action, intent, focusNode: focusNode); print('Invoking ${action is UndoableAction ? 'undoable ' : ''}$intent as $action: $this '); if (action is UndoableAction) { _completedActions.add(action); _undoneActions.clear(); _pruneActions(); notifyListeners(); } return result; } // Enforces undo level limit. void _pruneActions() { while (_completedActions.length > _maxUndoLevels) { _completedActions.removeAt(0); } } /// Returns true if there is an action on the stack that can be undone. bool get canUndo { if (_completedActions.isNotEmpty) { final Intent lastIntent = _completedActions.last.invocationIntent; return lastIntent.isEnabled(primaryFocus.context); } return false; } /// Returns true if an action that has been undone can be re-invoked. bool get canRedo { if (_undoneActions.isNotEmpty) { final Intent lastIntent = _undoneActions.last.invocationIntent; return lastIntent.isEnabled(primaryFocus?.context); } 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 UndoableAction action = _completedActions.removeLast(); action.undo(); _undoneActions.add(action); 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 UndoableAction action = _undoneActions.removeLast(); action.invoke(action.invocationNode, action.invocationIntent); _completedActions.add(action); _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<UndoableAction>('undo stack', _completedActions)); properties.add(IterableProperty<UndoableAction>('redo stack', _undoneActions)); } } class UndoIntent extends Intent { const UndoIntent() : super(kUndoActionKey); @override bool isEnabled(BuildContext context) { final UndoableActionDispatcher manager = Actions.of(context, nullOk: true) as UndoableActionDispatcher; return manager.canUndo; } } class RedoIntent extends Intent { const RedoIntent() : super(kRedoActionKey); @override bool isEnabled(BuildContext context) { final UndoableActionDispatcher manager = Actions.of(context, nullOk: true) as UndoableActionDispatcher; return manager.canRedo; } } const LocalKey kUndoActionKey = ValueKey<String>('Undo'); const Intent kUndoIntent = UndoIntent(); final Action kUndoAction = CallbackAction( kUndoActionKey, onInvoke: (FocusNode node, Intent tag) { if (node?.context == null) { return; } final UndoableActionDispatcher manager = Actions.of(node.context, nullOk: true) as UndoableActionDispatcher; manager?.undo(); }, ); const LocalKey kRedoActionKey = ValueKey<String>('Redo'); const Intent kRedoIntent = RedoIntent(); final Action kRedoAction = CallbackAction( kRedoActionKey, onInvoke: (FocusNode node, Intent tag) { if (node?.context == null) { return; } final UndoableActionDispatcher manager = Actions.of(node.context, nullOk: true) as UndoableActionDispatcher; manager?.redo(); }, ); /// An action that can be undone. abstract class UndoableAction extends Action { /// A const constructor to [UndoableAction]. /// /// The [intentKey] parameter must not be null. UndoableAction(LocalKey intentKey) : super(intentKey); /// The node supplied when this command was invoked. FocusNode get invocationNode => _invocationNode; FocusNode _invocationNode; @protected set invocationNode(FocusNode value) => _invocationNode = value; /// The [Intent] this action was originally invoked with. Intent get invocationIntent => _invocationTag; Intent _invocationTag; @protected set invocationIntent(Intent value) => _invocationTag = value; /// Returns true if the data model can be returned to the state it was in /// previous to this action being executed. /// /// Default implementation returns true. bool get undoable => true; /// Reverts the data model to the state before this command executed. @mustCallSuper void undo(); @override @mustCallSuper void invoke(FocusNode node, Intent intent) { invocationNode = node; invocationIntent = intent; } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(DiagnosticsProperty<FocusNode>('invocationNode', invocationNode)); } } class UndoableFocusActionBase extends UndoableAction { UndoableFocusActionBase(LocalKey name) : super(name); FocusNode _previousFocus; @override void invoke(FocusNode node, Intent intent) { super.invoke(node, intent); _previousFocus = primaryFocus; node.requestFocus(); } @override void undo() { if (_previousFocus == null) { primaryFocus?.unfocus(); return; } if (_previousFocus is FocusScopeNode) { // The only way a scope can be the _previousFocus is if there was no // focusedChild for the scope when we invoked this action, so we need to // return to that state. // Unfocus the current node to remove it from the focused child list of // the scope. primaryFocus?.unfocus(); // and then let the scope node be focused... } _previousFocus.requestFocus(); _previousFocus = null; } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(DiagnosticsProperty<FocusNode>('previous', _previousFocus)); } } class UndoableRequestFocusAction extends UndoableFocusActionBase { UndoableRequestFocusAction() : super(RequestFocusAction.key); @override void invoke(FocusNode node, Intent intent) { super.invoke(node, intent); node.requestFocus(); } } /// Actions for manipulating focus. class UndoableNextFocusAction extends UndoableFocusActionBase { UndoableNextFocusAction() : super(NextFocusAction.key); @override void invoke(FocusNode node, Intent intent) { super.invoke(node, intent); node.nextFocus(); } } class UndoablePreviousFocusAction extends UndoableFocusActionBase { UndoablePreviousFocusAction() : super(PreviousFocusAction.key); @override void invoke(FocusNode node, Intent intent) { super.invoke(node, intent); node.previousFocus(); } } class UndoableDirectionalFocusAction extends UndoableFocusActionBase { UndoableDirectionalFocusAction() : super(DirectionalFocusAction.key); TraversalDirection direction; @override void invoke(FocusNode node, DirectionalFocusIntent intent) { super.invoke(node, intent); final DirectionalFocusIntent args = intent; node.focusInDirection(args.direction); } } /// 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; @override void initState() { super.initState(); _focusNode = FocusNode(debugLabel: widget.name); } void _handleOnPressed() { print('Button ${widget.name} pressed.'); setState(() { Actions.invoke(context, const Intent(RequestFocusAction.key), focusNode: _focusNode); }); } @override void dispose() { super.dispose(); _focusNode.dispose(); } @override Widget build(BuildContext context) { return FlatButton( focusNode: _focusNode, focusColor: Colors.red, hoverColor: Colors.blue, onPressed: () => _handleOnPressed(), child: Text(widget.name), ); } } class FocusDemo extends StatefulWidget { const FocusDemo({Key key}) : super(key: key); @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: <LocalKey, ActionFactory>{ RequestFocusAction.key: () => UndoableRequestFocusAction(), NextFocusAction.key: () => UndoableNextFocusAction(), PreviousFocusAction.key: () => UndoablePreviousFocusAction(), DirectionalFocusAction.key: () => UndoableDirectionalFocusAction(), kUndoActionKey: () => kUndoAction, kRedoActionKey: () => kRedoAction, }, child: FocusTraversalGroup( policy: ReadingOrderTraversalPolicy(), child: Shortcuts( shortcuts: <LogicalKeySet, Intent>{ LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.keyZ): kRedoIntent, LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyZ): kUndoIntent, }, child: FocusScope( 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: RaisedButton( child: const Text('UNDO'), onPressed: canUndo ? () { Actions.invoke(context, kUndoIntent); } : null, ), ), Padding( padding: const EdgeInsets.all(8.0), child: RaisedButton( child: const Text('REDO'), onPressed: canRedo ? () { Actions.invoke(context, kRedoIntent); } : null, ), ), ], ), ], ); }), ), ), ), ), ), ), ); } }