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