• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright 2019 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'package:flutter/foundation.dart';
6import 'package:flutter/material.dart';
7import 'package:flutter/services.dart';
8import 'package:flutter/widgets.dart';
9
10void main() {
11  runApp(const MaterialApp(
12    title: 'Actions Demo',
13    home: FocusDemo(),
14  ));
15}
16
17/// Undoable Actions
18
19/// An [ActionDispatcher] subclass that manages the invocation of undoable
20/// actions.
21class UndoableActionDispatcher extends ActionDispatcher implements Listenable {
22  /// Constructs a new [UndoableActionDispatcher].
23  ///
24  /// The [maxUndoLevels] argument must not be null.
25  UndoableActionDispatcher({
26    int maxUndoLevels = _defaultMaxUndoLevels,
27  })  : assert(maxUndoLevels != null),
28        _maxUndoLevels = maxUndoLevels;
29
30  // A stack of actions that have been performed. The most recent action
31  // performed is at the end of the list.
32  final List<UndoableAction> _completedActions = <UndoableAction>[];
33  // A stack of actions that can be redone. The most recent action performed is
34  // at the end of the list.
35  final List<UndoableAction> _undoneActions = <UndoableAction>[];
36
37  static const int _defaultMaxUndoLevels = 1000;
38
39  /// The maximum number of undo levels allowed.
40  ///
41  /// If this value is set to a value smaller than the number of completed
42  /// actions, then the stack of completed actions is truncated to only include
43  /// the last [maxUndoLevels] actions.
44  int get maxUndoLevels => _maxUndoLevels;
45  int _maxUndoLevels;
46  set maxUndoLevels(int value) {
47    _maxUndoLevels = value;
48    _pruneActions();
49  }
50
51  final Set<VoidCallback> _listeners = <VoidCallback>{};
52
53  @override
54  void addListener(VoidCallback listener) {
55    _listeners.add(listener);
56  }
57
58  @override
59  void removeListener(VoidCallback listener) {
60    _listeners.remove(listener);
61  }
62
63  /// Notifies listeners that the [ActionDispatcher] has changed state.
64  ///
65  /// May only be called by subclasses.
66  @protected
67  void notifyListeners() {
68    for (VoidCallback callback in _listeners) {
69      callback();
70    }
71  }
72
73  @override
74  bool invokeAction(Action action, Intent intent, {FocusNode focusNode}) {
75    final bool result = super.invokeAction(action, intent, focusNode: focusNode);
76    print('Invoking ${action is UndoableAction ? 'undoable ' : ''}$intent as $action: $this ');
77    if (action is UndoableAction) {
78      _completedActions.add(action);
79      _undoneActions.clear();
80      _pruneActions();
81      notifyListeners();
82    }
83    return result;
84  }
85
86  // Enforces undo level limit.
87  void _pruneActions() {
88    while (_completedActions.length > _maxUndoLevels) {
89      _completedActions.removeAt(0);
90    }
91  }
92
93  /// Returns true if there is an action on the stack that can be undone.
94  bool get canUndo {
95    if (_completedActions.isNotEmpty) {
96      final Intent lastIntent = _completedActions.last.invocationIntent;
97      return lastIntent.isEnabled(WidgetsBinding.instance.focusManager.primaryFocus.context);
98    }
99    return false;
100  }
101
102  /// Returns true if an action that has been undone can be re-invoked.
103  bool get canRedo {
104    if (_undoneActions.isNotEmpty) {
105      final Intent lastIntent = _undoneActions.last.invocationIntent;
106      return lastIntent.isEnabled(WidgetsBinding.instance.focusManager.primaryFocus?.context);
107    }
108    return false;
109  }
110
111  /// Undoes the last action executed if possible.
112  ///
113  /// Returns true if the action was successfully undone.
114  bool undo() {
115    print('Undoing. $this');
116    if (!canUndo) {
117      return false;
118    }
119    final UndoableAction action = _completedActions.removeLast();
120    action.undo();
121    _undoneActions.add(action);
122    notifyListeners();
123    return true;
124  }
125
126  /// Re-invokes a previously undone action, if possible.
127  ///
128  /// Returns true if the action was successfully invoked.
129  bool redo() {
130    print('Redoing. $this');
131    if (!canRedo) {
132      return false;
133    }
134    final UndoableAction action = _undoneActions.removeLast();
135    action.invoke(action.invocationNode, action.invocationIntent);
136    _completedActions.add(action);
137    _pruneActions();
138    notifyListeners();
139    return true;
140  }
141
142  @override
143  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
144    super.debugFillProperties(properties);
145    properties.add(IntProperty('undoable items', _completedActions.length));
146    properties.add(IntProperty('redoable items', _undoneActions.length));
147    properties.add(IterableProperty<UndoableAction>('undo stack', _completedActions));
148    properties.add(IterableProperty<UndoableAction>('redo stack', _undoneActions));
149  }
150}
151
152class UndoIntent extends Intent {
153  const UndoIntent() : super(kUndoActionKey);
154
155  @override
156  bool isEnabled(BuildContext context) {
157    final UndoableActionDispatcher manager = Actions.of(context, nullOk: true);
158    return manager.canUndo;
159  }
160}
161
162class RedoIntent extends Intent {
163  const RedoIntent() : super(kRedoActionKey);
164
165  @override
166  bool isEnabled(BuildContext context) {
167    final UndoableActionDispatcher manager = Actions.of(context, nullOk: true);
168    return manager.canRedo;
169  }
170}
171
172const LocalKey kUndoActionKey = ValueKey<String>('Undo');
173const Intent kUndoIntent = UndoIntent();
174final Action kUndoAction = CallbackAction(
175  kUndoActionKey,
176  onInvoke: (FocusNode node, Intent tag) {
177    if (node?.context == null) {
178      return;
179    }
180    final UndoableActionDispatcher manager = Actions.of(node.context, nullOk: true);
181    manager?.undo();
182  },
183);
184
185const LocalKey kRedoActionKey = ValueKey<String>('Redo');
186const Intent kRedoIntent = RedoIntent();
187final Action kRedoAction = CallbackAction(
188  kRedoActionKey,
189  onInvoke: (FocusNode node, Intent tag) {
190    if (node?.context == null) {
191      return;
192    }
193    final UndoableActionDispatcher manager = Actions.of(node.context, nullOk: true);
194    manager?.redo();
195  },
196);
197
198/// An action that can be undone.
199abstract class UndoableAction extends Action {
200  /// A const constructor to [UndoableAction].
201  ///
202  /// The [intentKey] parameter must not be null.
203  UndoableAction(LocalKey intentKey) : super(intentKey);
204
205  /// The node supplied when this command was invoked.
206  FocusNode get invocationNode => _invocationNode;
207  FocusNode _invocationNode;
208
209  @protected
210  set invocationNode(FocusNode value) => _invocationNode = value;
211
212  /// The [Intent] this action was originally invoked with.
213  Intent get invocationIntent => _invocationTag;
214  Intent _invocationTag;
215
216  @protected
217  set invocationIntent(Intent value) => _invocationTag = value;
218
219  /// Returns true if the data model can be returned to the state it was in
220  /// previous to this action being executed.
221  ///
222  /// Default implementation returns true.
223  bool get undoable => true;
224
225  /// Reverts the data model to the state before this command executed.
226  @mustCallSuper
227  void undo();
228
229  @override
230  @mustCallSuper
231  void invoke(FocusNode node, Intent tag) {
232    invocationNode = node;
233    invocationIntent = tag;
234  }
235
236  @override
237  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
238    super.debugFillProperties(properties);
239    properties.add(DiagnosticsProperty<FocusNode>('invocationNode', invocationNode));
240  }
241}
242
243class SetFocusActionBase extends UndoableAction {
244  SetFocusActionBase(LocalKey name) : super(name);
245
246  FocusNode _previousFocus;
247
248  @override
249  void invoke(FocusNode node, Intent tag) {
250    super.invoke(node, tag);
251    _previousFocus = WidgetsBinding.instance.focusManager.primaryFocus;
252    node.requestFocus();
253  }
254
255  @override
256  void undo() {
257    if (_previousFocus == null) {
258      WidgetsBinding.instance.focusManager.primaryFocus?.unfocus();
259      return;
260    }
261    if (_previousFocus is FocusScopeNode) {
262      // The only way a scope can be the _previousFocus is if there was no
263      // focusedChild for the scope when we invoked this action, so we need to
264      // return to that state.
265
266      // Unfocus the current node to remove it from the focused child list of
267      // the scope.
268      WidgetsBinding.instance.focusManager.primaryFocus?.unfocus();
269      // and then let the scope node be focused...
270    }
271    _previousFocus.requestFocus();
272    _previousFocus = null;
273  }
274
275  @override
276  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
277    super.debugFillProperties(properties);
278    properties.add(DiagnosticsProperty<FocusNode>('previous', _previousFocus));
279  }
280}
281
282class SetFocusAction extends SetFocusActionBase {
283  SetFocusAction() : super(key);
284
285  static const LocalKey key = ValueKey<Type>(SetFocusAction);
286
287  @override
288  void invoke(FocusNode node, Intent tag) {
289    super.invoke(node, tag);
290    node.requestFocus();
291  }
292}
293
294/// Actions for manipulating focus.
295class NextFocusAction extends SetFocusActionBase {
296  NextFocusAction() : super(key);
297
298  static const LocalKey key = ValueKey<Type>(NextFocusAction);
299
300  @override
301  void invoke(FocusNode node, Intent tag) {
302    super.invoke(node, tag);
303    node.nextFocus();
304  }
305}
306
307class PreviousFocusAction extends SetFocusActionBase {
308  PreviousFocusAction() : super(key);
309
310  static const LocalKey key = ValueKey<Type>(PreviousFocusAction);
311
312  @override
313  void invoke(FocusNode node, Intent tag) {
314    super.invoke(node, tag);
315    node.previousFocus();
316  }
317}
318
319class DirectionalFocusIntent extends Intent {
320  const DirectionalFocusIntent(this.direction) : super(DirectionalFocusAction.key);
321
322  final TraversalDirection direction;
323}
324
325class DirectionalFocusAction extends SetFocusActionBase {
326  DirectionalFocusAction() : super(key);
327
328  static const LocalKey key = ValueKey<Type>(DirectionalFocusAction);
329
330  TraversalDirection direction;
331
332  @override
333  void invoke(FocusNode node, DirectionalFocusIntent tag) {
334    super.invoke(node, tag);
335    final DirectionalFocusIntent args = tag;
336    node.focusInDirection(args.direction);
337  }
338}
339
340/// A button class that takes focus when clicked.
341class DemoButton extends StatefulWidget {
342  const DemoButton({this.name});
343
344  final String name;
345
346  @override
347  _DemoButtonState createState() => _DemoButtonState();
348}
349
350class _DemoButtonState extends State<DemoButton> {
351  FocusNode _focusNode;
352
353  @override
354  void initState() {
355    super.initState();
356    _focusNode = FocusNode(debugLabel: widget.name);
357  }
358
359  void _handleOnPressed() {
360    print('Button ${widget.name} pressed.');
361    setState(() {
362      Actions.invoke(context, const Intent(SetFocusAction.key), focusNode: _focusNode);
363    });
364  }
365
366  @override
367  void dispose() {
368    super.dispose();
369    _focusNode.dispose();
370  }
371
372  @override
373  Widget build(BuildContext context) {
374    return FlatButton(
375      focusNode: _focusNode,
376      focusColor: Colors.red,
377      hoverColor: Colors.blue,
378      onPressed: () => _handleOnPressed(),
379      child: Text(widget.name),
380    );
381  }
382}
383
384class FocusDemo extends StatefulWidget {
385  const FocusDemo({Key key}) : super(key: key);
386
387  @override
388  _FocusDemoState createState() => _FocusDemoState();
389}
390
391class _FocusDemoState extends State<FocusDemo> {
392  FocusNode outlineFocus;
393  UndoableActionDispatcher dispatcher;
394  bool canUndo;
395  bool canRedo;
396
397  @override
398  void initState() {
399    super.initState();
400    outlineFocus = FocusNode(debugLabel: 'Demo Focus Node');
401    dispatcher = UndoableActionDispatcher();
402    canUndo = dispatcher.canUndo;
403    canRedo = dispatcher.canRedo;
404    dispatcher.addListener(_handleUndoStateChange);
405  }
406
407  void _handleUndoStateChange() {
408    if (dispatcher.canUndo != canUndo) {
409      setState(() {
410        canUndo = dispatcher.canUndo;
411      });
412    }
413    if (dispatcher.canRedo != canRedo) {
414      setState(() {
415        canRedo = dispatcher.canRedo;
416      });
417    }
418  }
419
420  @override
421  void dispose() {
422    dispatcher.removeListener(_handleUndoStateChange);
423    outlineFocus.dispose();
424    super.dispose();
425  }
426
427  @override
428  Widget build(BuildContext context) {
429    final TextTheme textTheme = Theme.of(context).textTheme;
430    return Shortcuts(
431      shortcuts: <LogicalKeySet, Intent>{
432        LogicalKeySet(LogicalKeyboardKey.tab): const Intent(NextFocusAction.key),
433        LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const Intent(PreviousFocusAction.key),
434        LogicalKeySet(LogicalKeyboardKey.arrowUp): const DirectionalFocusIntent(TraversalDirection.up),
435        LogicalKeySet(LogicalKeyboardKey.arrowDown): const DirectionalFocusIntent(TraversalDirection.down),
436        LogicalKeySet(LogicalKeyboardKey.arrowLeft): const DirectionalFocusIntent(TraversalDirection.left),
437        LogicalKeySet(LogicalKeyboardKey.arrowRight): const DirectionalFocusIntent(TraversalDirection.right),
438      },
439      child: Actions(
440        dispatcher: dispatcher,
441        actions: <LocalKey, ActionFactory>{
442          SetFocusAction.key: () => SetFocusAction(),
443          NextFocusAction.key: () => NextFocusAction(),
444          PreviousFocusAction.key: () => PreviousFocusAction(),
445          DirectionalFocusAction.key: () => DirectionalFocusAction(),
446          kUndoActionKey: () => kUndoAction,
447          kRedoActionKey: () => kRedoAction,
448        },
449        child: DefaultFocusTraversal(
450          policy: ReadingOrderTraversalPolicy(),
451          child: Shortcuts(
452            shortcuts: <LogicalKeySet, Intent>{
453              LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.keyZ): kRedoIntent,
454              LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyZ): kUndoIntent,
455            },
456            child: FocusScope(
457              debugLabel: 'Scope',
458              autofocus: true,
459              child: DefaultTextStyle(
460                style: textTheme.display1,
461                child: Scaffold(
462                  appBar: AppBar(
463                    title: const Text('Actions Demo'),
464                  ),
465                  body: Center(
466                    child: Builder(builder: (BuildContext context) {
467                      return Column(
468                        mainAxisAlignment: MainAxisAlignment.center,
469                        children: <Widget>[
470                          Row(
471                            mainAxisAlignment: MainAxisAlignment.center,
472                            children: const <Widget>[
473                              DemoButton(name: 'One'),
474                              DemoButton(name: 'Two'),
475                              DemoButton(name: 'Three'),
476                            ],
477                          ),
478                          Row(
479                            mainAxisAlignment: MainAxisAlignment.center,
480                            children: const <Widget>[
481                              DemoButton(name: 'Four'),
482                              DemoButton(name: 'Five'),
483                              DemoButton(name: 'Six'),
484                            ],
485                          ),
486                          Row(
487                            mainAxisAlignment: MainAxisAlignment.center,
488                            children: const <Widget>[
489                              DemoButton(name: 'Seven'),
490                              DemoButton(name: 'Eight'),
491                              DemoButton(name: 'Nine'),
492                            ],
493                          ),
494                          Row(
495                            mainAxisAlignment: MainAxisAlignment.center,
496                            children: <Widget>[
497                              Padding(
498                                padding: const EdgeInsets.all(8.0),
499                                child: RaisedButton(
500                                  child: const Text('UNDO'),
501                                  onPressed: canUndo
502                                      ? () {
503                                          Actions.invoke(context, kUndoIntent);
504                                        }
505                                      : null,
506                                ),
507                              ),
508                              Padding(
509                                padding: const EdgeInsets.all(8.0),
510                                child: RaisedButton(
511                                  child: const Text('REDO'),
512                                  onPressed: canRedo
513                                      ? () {
514                                          Actions.invoke(context, kRedoIntent);
515                                        }
516                                      : null,
517                                ),
518                              ),
519                            ],
520                          ),
521                        ],
522                      );
523                    }),
524                  ),
525                ),
526              ),
527            ),
528          ),
529        ),
530      ),
531    );
532  }
533}
534