| // Copyright 2019 The Chromium 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:io'; |
| |
| import 'package:flutter/foundation.dart'; |
| import 'package:flutter/material.dart'; |
| import 'package:flutter/services.dart'; |
| import 'package:flutter/widgets.dart'; |
| |
| void main() { |
| if (Platform.isMacOS) { |
| // TODO(gspencergoog): Update this when TargetPlatform includes macOS. https://github.com/flutter/flutter/issues/31366 |
| // See https://github.com/flutter/flutter/wiki/Desktop-shells#target-platform-override |
| debugDefaultTargetPlatformOverride = TargetPlatform.fuchsia; |
| } |
| |
| 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 (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(WidgetsBinding.instance.focusManager.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(WidgetsBinding.instance.focusManager.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); |
| return manager.canUndo; |
| } |
| } |
| |
| class RedoIntent extends Intent { |
| const RedoIntent() : super(kRedoActionKey); |
| |
| @override |
| bool isEnabled(BuildContext context) { |
| final UndoableActionDispatcher manager = Actions.of(context, nullOk: true); |
| 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); |
| 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); |
| 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 SetFocusActionBase extends UndoableAction { |
| SetFocusActionBase(LocalKey name) : super(name); |
| |
| FocusNode _previousFocus; |
| |
| @override |
| void invoke(FocusNode node, Intent intent) { |
| super.invoke(node, intent); |
| _previousFocus = WidgetsBinding.instance.focusManager.primaryFocus; |
| node.requestFocus(); |
| } |
| |
| @override |
| void undo() { |
| if (_previousFocus == null) { |
| WidgetsBinding.instance.focusManager.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. |
| WidgetsBinding.instance.focusManager.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 SetFocusAction extends SetFocusActionBase { |
| SetFocusAction() : super(key); |
| |
| static const LocalKey key = ValueKey<Type>(SetFocusAction); |
| |
| @override |
| void invoke(FocusNode node, Intent intent) { |
| super.invoke(node, intent); |
| node.requestFocus(); |
| } |
| } |
| |
| /// Actions for manipulating focus. |
| class NextFocusAction extends SetFocusActionBase { |
| NextFocusAction() : super(key); |
| |
| static const LocalKey key = ValueKey<Type>(NextFocusAction); |
| |
| @override |
| void invoke(FocusNode node, Intent intent) { |
| super.invoke(node, intent); |
| node.nextFocus(); |
| } |
| } |
| |
| class PreviousFocusAction extends SetFocusActionBase { |
| PreviousFocusAction() : super(key); |
| |
| static const LocalKey key = ValueKey<Type>(PreviousFocusAction); |
| |
| @override |
| void invoke(FocusNode node, Intent intent) { |
| super.invoke(node, intent); |
| node.previousFocus(); |
| } |
| } |
| |
| class DirectionalFocusIntent extends Intent { |
| const DirectionalFocusIntent(this.direction) : super(DirectionalFocusAction.key); |
| |
| final TraversalDirection direction; |
| } |
| |
| class DirectionalFocusAction extends SetFocusActionBase { |
| DirectionalFocusAction() : super(key); |
| |
| static const LocalKey key = ValueKey<Type>(DirectionalFocusAction); |
| |
| 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(SetFocusAction.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 Shortcuts( |
| shortcuts: <LogicalKeySet, Intent>{ |
| LogicalKeySet(LogicalKeyboardKey.tab): const Intent(NextFocusAction.key), |
| LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const Intent(PreviousFocusAction.key), |
| LogicalKeySet(LogicalKeyboardKey.arrowUp): const DirectionalFocusIntent(TraversalDirection.up), |
| LogicalKeySet(LogicalKeyboardKey.arrowDown): const DirectionalFocusIntent(TraversalDirection.down), |
| LogicalKeySet(LogicalKeyboardKey.arrowLeft): const DirectionalFocusIntent(TraversalDirection.left), |
| LogicalKeySet(LogicalKeyboardKey.arrowRight): const DirectionalFocusIntent(TraversalDirection.right), |
| }, |
| child: Actions( |
| dispatcher: dispatcher, |
| actions: <LocalKey, ActionFactory>{ |
| SetFocusAction.key: () => SetFocusAction(), |
| NextFocusAction.key: () => NextFocusAction(), |
| PreviousFocusAction.key: () => PreviousFocusAction(), |
| DirectionalFocusAction.key: () => DirectionalFocusAction(), |
| kUndoActionKey: () => kUndoAction, |
| kRedoActionKey: () => kRedoAction, |
| }, |
| child: DefaultFocusTraversal( |
| 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.display1, |
| 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, |
| ), |
| ), |
| ], |
| ), |
| ], |
| ); |
| }), |
| ), |
| ), |
| ), |
| ), |
| ), |
| ), |
| ), |
| ); |
| } |
| } |