diff --git a/packages/flutter/lib/src/services/binding.dart b/packages/flutter/lib/src/services/binding.dart index b513f18238..29e61fa069 100644 --- a/packages/flutter/lib/src/services/binding.dart +++ b/packages/flutter/lib/src/services/binding.dart @@ -47,6 +47,7 @@ mixin ServicesBinding on BindingBase, SchedulerBinding { SystemChannels.accessibility.setMessageHandler((dynamic message) => _handleAccessibilityMessage(message as Object)); SystemChannels.lifecycle.setMessageHandler(_handleLifecycleMessage); SystemChannels.platform.setMethodCallHandler(_handlePlatformMessage); + platformDispatcher.onViewFocusChange = handleViewFocusChanged; TextInput.ensureInitialized(); readInitialLifecycleStateFromNativeWindow(); initializationComplete(); @@ -355,6 +356,19 @@ mixin ServicesBinding on BindingBase, SchedulerBinding { return; } + /// Called whenever the [PlatformDispatcher] receives a notification that the + /// focus state on a view has changed. + /// + /// The [event] contains the view ID for the view that changed its focus + /// state. + /// + /// See also: + /// + /// * [PlatformDispatcher.onViewFocusChange], which calls this method. + @protected + @mustCallSuper + void handleViewFocusChanged(ui.ViewFocusEvent event) {} + Future _handlePlatformMessage(MethodCall methodCall) async { final String method = methodCall.method; switch (method) { diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart index d0d9382515..3a74bbe3be 100644 --- a/packages/flutter/lib/src/widgets/binding.dart +++ b/packages/flutter/lib/src/widgets/binding.dart @@ -4,7 +4,8 @@ import 'dart:async'; import 'dart:developer' as developer; -import 'dart:ui' show AccessibilityFeatures, AppExitResponse, AppLifecycleState, FrameTiming, Locale, PlatformDispatcher, TimingsCallback; +import 'dart:ui' show AccessibilityFeatures, AppExitResponse, AppLifecycleState, + FrameTiming, Locale, PlatformDispatcher, TimingsCallback, ViewFocusEvent; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; @@ -321,6 +322,18 @@ abstract mixin class WidgetsBindingObserver { /// application lifecycle changes. void didChangeAppLifecycleState(AppLifecycleState state) { } + /// Called whenever the [PlatformDispatcher] receives a notification that the + /// focus state on a view has changed. + /// + /// The [event] contains the view ID for the view that changed its focus + /// state. + /// + /// The view ID of the [FlutterView] in which a particular [BuildContext] + /// resides can be retrieved with `View.of(context).viewId`, so that it may be + /// compared with the view ID in the `event` to see if the event pertains to + /// the given context. + void didChangeViewFocus(ViewFocusEvent event) { } + /// Called when a request is received from the system to exit the application. /// /// If any observer responds with [AppExitResponse.cancel], it will cancel the @@ -951,6 +964,14 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB } } + @override + void handleViewFocusChanged(ViewFocusEvent event) { + super.handleViewFocusChanged(event); + for (final WidgetsBindingObserver observer in List.of(_observers)) { + observer.didChangeViewFocus(event); + } + } + @override void handleMemoryPressure() { super.handleMemoryPressure(); diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart index 24ac44e5d3..002553a11a 100644 --- a/packages/flutter/lib/src/widgets/focus_manager.dart +++ b/packages/flutter/lib/src/widgets/focus_manager.dart @@ -219,8 +219,8 @@ class FocusAttachment { _node._manager?._markDetached(_node); _node._parent?._removeChild(_node); _node._attachment = null; - assert(!_node.hasPrimaryFocus); - assert(_node._manager?._markedForFocus != _node); + assert(!_node.hasPrimaryFocus, 'Node ${_node.debugLabel ?? _node} still has primary focus while being detached.'); + assert(_node._manager?._markedForFocus != _node, 'Node ${_node.debugLabel ?? _node} still marked for focus while being detached.'); } assert(!isAttached); } @@ -1296,8 +1296,10 @@ class FocusScopeNode extends FocusNode { /// /// Returns null if there is no currently focused child. FocusNode? get focusedChild { - assert(_focusedChildren.isEmpty || _focusedChildren.last.enclosingScope == this, 'Focused child does not have the same idea of its enclosing scope as the scope does.'); - return _focusedChildren.isNotEmpty ? _focusedChildren.last : null; + assert(_focusedChildren.isEmpty || _focusedChildren.last.enclosingScope == this, + '$debugLabel: Focused child does not have the same idea of its enclosing scope ' + '(${_focusedChildren.lastOrNull?.enclosingScope}) as the scope does.'); + return _focusedChildren.lastOrNull; } // A stack of the children that have been set as the focusedChild, most recent @@ -1377,11 +1379,20 @@ class FocusScopeNode extends FocusNode { _manager?._markNeedsUpdate(); } + /// Requests that the scope itself receive focus, without trying to find + /// a descendant that should receive focus. + /// + /// This is used only if you want to park the focus on a scope itself. + void requestScopeFocus() { + _doRequestFocus(findFirstFocus: false); + } + @override void _doRequestFocus({required bool findFirstFocus}) { - - // It is possible that a previously focused child is no longer focusable. - while (this.focusedChild != null && !this.focusedChild!.canRequestFocus) { + // It is possible that a previously focused child is no longer focusable, so + // clean out the list if so. + while (_focusedChildren.isNotEmpty && + (!_focusedChildren.last.canRequestFocus || _focusedChildren.last.enclosingScope == null)) { _focusedChildren.removeLast(); } diff --git a/packages/flutter/lib/src/widgets/focus_scope.dart b/packages/flutter/lib/src/widgets/focus_scope.dart index f32a41ba3f..acd9b04f1b 100644 --- a/packages/flutter/lib/src/widgets/focus_scope.dart +++ b/packages/flutter/lib/src/widgets/focus_scope.dart @@ -757,6 +757,9 @@ class FocusScope extends Focus { super.onKeyEvent, super.onKey, super.debugLabel, + super.includeSemantics, + super.descendantsAreFocusable, + super.descendantsAreTraversable, }) : super( focusNode: node, ); @@ -770,6 +773,7 @@ class FocusScope extends Focus { required FocusScopeNode focusScopeNode, FocusNode? parentNode, bool autofocus, + bool includeSemantics, ValueChanged? onFocusChange, }) = _FocusScopeWithExternalFocusNode; @@ -798,6 +802,7 @@ class _FocusScopeWithExternalFocusNode extends FocusScope { required FocusScopeNode focusScopeNode, super.parentNode, super.autofocus, + super.includeSemantics, super.onFocusChange, }) : super( node: focusScopeNode, @@ -834,13 +839,17 @@ class _FocusScopeState extends _FocusState { @override Widget build(BuildContext context) { _focusAttachment!.reparent(parent: widget.parentNode); - return Semantics( - explicitChildNodes: true, - child: _FocusInheritedScope( - node: focusNode, - child: widget.child, - ), + Widget result = _FocusInheritedScope( + node: focusNode, + child: widget.child, ); + if (widget.includeSemantics) { + result = Semantics( + explicitChildNodes: true, + child: result, + ); + } + return result; } } diff --git a/packages/flutter/lib/src/widgets/view.dart b/packages/flutter/lib/src/widgets/view.dart index 728a09d469..d7b6e0fb4e 100644 --- a/packages/flutter/lib/src/widgets/view.dart +++ b/packages/flutter/lib/src/widgets/view.dart @@ -3,11 +3,15 @@ // found in the LICENSE file. import 'dart:collection'; -import 'dart:ui' show FlutterView, SemanticsUpdate; +import 'dart:ui' show FlutterView, SemanticsUpdate, ViewFocusDirection, ViewFocusEvent, ViewFocusState; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; +import 'binding.dart'; +import 'focus_manager.dart'; +import 'focus_scope.dart'; +import 'focus_traversal.dart'; import 'framework.dart'; import 'lookup_boundary.dart'; import 'media_query.dart'; @@ -19,7 +23,7 @@ import 'media_query.dart'; /// rendered into via [View.of] and [View.maybeOf]. /// /// The provided [child] is wrapped in a [MediaQuery] constructed from the given -/// [view]. +/// [view], a [FocusScope], and a [RawView] widget. /// /// For most use cases, using [MediaQuery.of], or its associated "...Of" methods /// are a more appropriate way of obtaining the information that a [FlutterView] @@ -31,33 +35,38 @@ import 'media_query.dart'; /// information to be aware of the context of the widget; e.g. the [Scaffold] /// widget adjusts the values for its various children. /// -/// Each [FlutterView] can be associated with at most one [View] widget in the -/// widget tree. Two or more [View] widgets configured with the same -/// [FlutterView] must never exist within the same widget tree at the same time. -/// This limitation is enforced by a [GlobalObjectKey] that derives its identity -/// from the [view] provided to this widget. +/// Each [FlutterView] can be associated with at most one [View] or [RawView] +/// widget in the widget tree. Two or more [View] or [RawView] widgets +/// configured with the same [FlutterView] must never exist within the same +/// widget tree at the same time. This limitation is enforced by a +/// [GlobalObjectKey] that derives its identity from the [view] provided to this +/// widget. /// -/// Since the [View] widget bootstraps its own independent render tree, neither -/// it nor any of its descendants will insert a [RenderObject] into an existing -/// render tree. Therefore, the [View] widget can only be used in those parts of -/// the widget tree where it is not required to participate in the construction -/// of the surrounding render tree. In other words, the widget may only be used -/// in a non-rendering zone of the widget tree (see [WidgetsBinding] for a -/// definition of rendering and non-rendering zones). +/// Since the [View] widget bootstraps its own independent render tree using its +/// embedded [RawView], neither it nor any of its descendants will insert a +/// [RenderObject] into an existing render tree. Therefore, the [View] widget +/// can only be used in those parts of the widget tree where it is not required +/// to participate in the construction of the surrounding render tree. In other +/// words, the widget may only be used in a non-rendering zone of the widget +/// tree (see [WidgetsBinding] for a definition of rendering and non-rendering +/// zones). /// /// In practical terms, the widget is typically used at the root of the widget -/// tree outside of any other [View] widget, as a child of a [ViewCollection] -/// widget, or in the [ViewAnchor.view] slot of a [ViewAnchor] widget. It is not -/// required to be a direct child, though, since other non-[RenderObjectWidget]s -/// (e.g. [InheritedWidget]s, [Builder]s, or [StatefulWidget]s/[StatelessWidget] -/// that only produce non-[RenderObjectWidget]s) are allowed to be present -/// between those widgets and the [View] widget. +/// tree outside of any other [View] or [RawView] widget, as a child of a +/// [ViewCollection] widget, or in the [ViewAnchor.view] slot of a [ViewAnchor] +/// widget. It is not required to be a direct child, though, since other +/// non-[RenderObjectWidget]s (e.g. [InheritedWidget]s, [Builder]s, or +/// [StatefulWidget]s/[StatelessWidget]s that only produce +/// non-[RenderObjectWidget]s) are allowed to be present between those widgets +/// and the [View] widget. /// /// See also: /// -/// * [Element.debugExpectsRenderObjectForSlot], which defines whether a [View] -/// widget is allowed in a given child slot. -class View extends StatelessWidget { +/// * [RawView], the workhorse that [View] uses to create the render tree, but +/// without the [MediaQuery] and [FocusScope] that [View] adds. +/// * [Element.debugExpectsRenderObjectForSlot], which defines whether a [View] +/// widget is allowed in a given child slot. +class View extends StatefulWidget { /// Create a [View] widget to bootstrap a render tree that is rendered into /// the provided [FlutterView]. /// @@ -105,7 +114,7 @@ class View extends StatelessWidget { /// moved to render into a different [FlutterView] then before). The context /// will not be informed when the _properties_ on the [FlutterView] itself /// change their values. To access the property values of a [FlutterView] it - /// is best practise to use [MediaQuery.maybeOf] instead, which will ensure + /// is best practice to use [MediaQuery.maybeOf] instead, which will ensure /// that the `context` is informed when the view properties change. /// /// See also: @@ -170,9 +179,153 @@ class View extends StatelessWidget { ?? RendererBinding.instance.rootPipelineOwner; } + @override + State createState() => _ViewState(); +} + +class _ViewState extends State with WidgetsBindingObserver { + final FocusScopeNode _scopeNode = FocusScopeNode( + debugLabel: kReleaseMode ? null : 'View Scope', + ); + final FocusTraversalPolicy _policy = ReadingOrderTraversalPolicy(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _scopeNode.dispose(); + super.dispose(); + } + + @override + void didChangeViewFocus(ViewFocusEvent event) { + if (event.viewId != widget.view.viewId) { + // The event is not pertinent to this view. + return; + } + FocusNode nextFocus; + switch (event.state) { + case ViewFocusState.focused: + switch (event.direction) { + case ViewFocusDirection.forward: + nextFocus = _policy.findFirstFocus(_scopeNode, ignoreCurrentFocus: true) ?? _scopeNode; + case ViewFocusDirection.backward: + nextFocus = _policy.findLastFocus(_scopeNode, ignoreCurrentFocus: true); + case ViewFocusDirection.undefined: + nextFocus = _scopeNode; + } + nextFocus.requestFocus(); + case ViewFocusState.unfocused: + // Focusing on the root scope node will "park" the focus, so that no + // descendant node will be given focus, and there's no widget that can + // receive keyboard events. + FocusManager.instance.rootScope.requestScopeFocus(); + } + } + @override Widget build(BuildContext context) { - return _RawView( + return RawView( + view: widget.view, + deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner: widget._deprecatedPipelineOwner, + deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView: widget._deprecatedRenderView, + child: MediaQuery.fromView( + view: widget.view, + child: FocusTraversalGroup( + policy: _policy, + child: FocusScope.withExternalFocusNode( + includeSemantics: false, + focusScopeNode: _scopeNode, + child: widget.child, + ), + ), + ), + ); + } +} + +/// The lower level workhorse widget for [View] that bootstraps a render tree +/// for a view. +/// +/// Typically, the [View] widget is used instead of a [RawView] widget to create +/// a view, since, in addition to creating a view, it also adds some useful +/// widgets, such as a [MediaQuery] and [FocusScope]. The [RawView] widget is +/// only used directly if it is not desirable to have these additional widgets +/// around the resulting widget tree. The [View] widget uses the [RawView] +/// widget internally to manage its [FlutterView]. +/// +/// This widget can be used at the root of the widget tree outside of any other +/// [View] or [RawView] widget, as a child to a [ViewCollection], or in the +/// [ViewAnchor.view] slot of a [ViewAnchor] widget. It is not required to be a +/// direct child of those widgets; other non-[RenderObjectWidget]s may appear in +/// between the two (such as an [InheritedWidget]). +/// +/// Each [FlutterView] can be associated with at most one [View] or [RawView] +/// widget in the widget tree. Two or more [View] or [RawView] widgets +/// configured with the same [FlutterView] must never exist within the same +/// widget tree at the same time. This limitation is enforced by a +/// [GlobalObjectKey] that derives its identity from the [view] provided to this +/// widget. +/// +/// Since the [RawView] widget bootstraps its own independent render tree, +/// neither it nor any of its descendants will insert a [RenderObject] into an +/// existing render tree. Therefore, the [RawView] widget can only be used in +/// those parts of the widget tree where it is not required to participate in +/// the construction of the surrounding render tree. In other words, the widget +/// may only be used in a non-rendering zone of the widget tree (see +/// [WidgetsBinding] for a definition of rendering and non-rendering zones). +/// +/// To find the [FlutterView] associated with a [BuildContext], use [View.of] or +/// [View.maybeOf], even if the view was created using [RawView] instead of +/// [View]. +/// +/// See also: +/// +/// * [View] for a higher level interface that also sets up a [MediaQuery] and +/// [FocusScope] for the view's widget tree. +class RawView extends StatelessWidget { + /// Creates a [RawView] widget. + RawView({ + super.key, + required this.view, + @Deprecated( + 'Do not use. ' + 'This parameter only exists to implement the deprecated RendererBinding.pipelineOwner property until it is removed. ' + 'This feature was deprecated after v3.10.0-12.0.pre.' + ) + PipelineOwner? deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner, + @Deprecated( + 'Do not use. ' + 'This parameter only exists to implement the deprecated RendererBinding.renderView property until it is removed. ' + 'This feature was deprecated after v3.10.0-12.0.pre.' + ) + RenderView? deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView, + required this.child, + }) : _deprecatedPipelineOwner = deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner, + _deprecatedRenderView = deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView, + assert((deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner == null) == (deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView == null)), + assert(deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView == null || deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView.flutterView == view); + + /// The [FlutterView] into which [child] is drawn. + final FlutterView view; + + /// The widget below this widget in the tree, which will be drawn into the + /// [view]. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + final PipelineOwner? _deprecatedPipelineOwner; + final RenderView? _deprecatedRenderView; + + @override + Widget build(BuildContext context) { + return _RawViewInternal( view: view, deprecatedPipelineOwner: _deprecatedPipelineOwner, deprecatedRenderView: _deprecatedRenderView, @@ -181,30 +334,27 @@ class View extends StatelessWidget { view: view, child: _PipelineOwnerScope( pipelineOwner: owner, - child: MediaQuery.fromView( - view: view, - child: child, - ), + child: child, ), ); - } + }, ); } } -/// A builder for the content [Widget] of a [_RawView]. +/// A builder for the content [Widget] of a [_RawViewInternal]. /// /// The widget returned by the builder defines the content that is drawn into -/// the [FlutterView] configured on the [_RawView]. +/// the [FlutterView] configured on the [_RawViewInternal]. /// -/// The builder is given the [PipelineOwner] that the [_RawView] uses to manage -/// its render tree. Typical builder implementations make that pipeline owner -/// available as an attachment point for potential child views. +/// The builder is given the [PipelineOwner] that the [_RawViewInternal] uses to +/// manage its render tree. Typical builder implementations make that pipeline +/// owner available as an attachment point for potential child views. /// -/// Used by [_RawView.builder]. +/// Used by [_RawViewInternal.builder]. typedef _RawViewContentBuilder = Widget Function(BuildContext context, PipelineOwner owner); -/// The workhorse behind the [View] widget that actually bootstraps a render +/// The workhorse behind the [RawView] widget that actually bootstraps a render /// tree. /// /// It instantiates the [RenderView] as the root of that render tree and adds it @@ -213,13 +363,13 @@ typedef _RawViewContentBuilder = Widget Function(BuildContext context, PipelineO /// the surrounding parent [PipelineOwner] obtained with [View.pipelineOwnerOf]. /// This ensures that the render tree bootstrapped by this widget participates /// properly in frame production and hit testing. -class _RawView extends RenderObjectWidget { - /// Create a [RawView] widget to bootstrap a render tree that is rendered into - /// the provided [FlutterView]. +class _RawViewInternal extends RenderObjectWidget { + /// Create a [_RawViewInternal] widget to bootstrap a render tree that is + /// rendered into the provided [FlutterView]. /// /// The content rendered into that [view] is determined by the [Widget] /// returned by [builder]. - _RawView({ + _RawViewInternal({ required this.view, required PipelineOwner? deprecatedPipelineOwner, required RenderView? deprecatedRenderView, @@ -266,7 +416,7 @@ class _RawViewElement extends RenderTreeRootElement { onSemanticsOwnerDisposed: _handleSemanticsOwnerDisposed, ); - PipelineOwner get _effectivePipelineOwner => (widget as _RawView)._deprecatedPipelineOwner ?? _pipelineOwner; + PipelineOwner get _effectivePipelineOwner => (widget as _RawViewInternal)._deprecatedPipelineOwner ?? _pipelineOwner; void _handleSemanticsOwnerCreated() { (_effectivePipelineOwner.rootNode as RenderView?)?.scheduleInitialSemantics(); @@ -277,7 +427,7 @@ class _RawViewElement extends RenderTreeRootElement { } void _handleSemanticsUpdate(SemanticsUpdate update) { - (widget as _RawView).view.updateSemantics(update); + (widget as _RawViewInternal).view.updateSemantics(update); } @override @@ -287,7 +437,7 @@ class _RawViewElement extends RenderTreeRootElement { void _updateChild() { try { - final Widget child = (widget as _RawView).builder(this, _effectivePipelineOwner); + final Widget child = (widget as _RawViewInternal).builder(this, _effectivePipelineOwner); _child = updateChild(_child, child, null); } catch (e, stack) { final FlutterErrorDetails details = FlutterErrorDetails( @@ -373,7 +523,7 @@ class _RawViewElement extends RenderTreeRootElement { } @override - void update(_RawView newWidget) { + void update(_RawViewInternal newWidget) { super.update(newWidget); _updateChild(); } @@ -413,7 +563,7 @@ class _RawViewElement extends RenderTreeRootElement { @override void unmount() { - if (_effectivePipelineOwner != (widget as _RawView)._deprecatedPipelineOwner) { + if (_effectivePipelineOwner != (widget as _RawViewInternal)._deprecatedPipelineOwner) { _effectivePipelineOwner.dispose(); } super.unmount(); diff --git a/packages/flutter/test/material/checkbox_test.dart b/packages/flutter/test/material/checkbox_test.dart index dafa56c2da..82e50d4e33 100644 --- a/packages/flutter/test/material/checkbox_test.dart +++ b/packages/flutter/test/material/checkbox_test.dart @@ -73,7 +73,7 @@ void main() { ), )); - expect(tester.getSemantics(find.byType(Focus)), matchesSemantics( + expect(tester.getSemantics(find.byType(Focus).last), matchesSemantics( hasCheckedState: true, hasEnabledState: true, isEnabled: true, @@ -91,7 +91,7 @@ void main() { ), )); - expect(tester.getSemantics(find.byType(Focus)), matchesSemantics( + expect(tester.getSemantics(find.byType(Focus).last), matchesSemantics( hasCheckedState: true, hasEnabledState: true, isChecked: true, @@ -205,7 +205,7 @@ void main() { ), )); - expect(tester.getSemantics(find.byType(Focus)), matchesSemantics( + expect(tester.getSemantics(find.byType(Focus).last), matchesSemantics( label: 'checkbox', textDirection: TextDirection.ltr, hasCheckedState: true, diff --git a/packages/flutter/test/services/binding_test.dart b/packages/flutter/test/services/binding_test.dart index 3f3439e62a..4636b15971 100644 --- a/packages/flutter/test/services/binding_test.dart +++ b/packages/flutter/test/services/binding_test.dart @@ -41,6 +41,8 @@ $license2 '''; class TestBinding extends BindingBase with SchedulerBinding, ServicesBinding { + ViewFocusEvent? lastFocusEvent; + @override TestDefaultBinaryMessenger get defaultBinaryMessenger => super.defaultBinaryMessenger as TestDefaultBinaryMessenger; @@ -54,6 +56,12 @@ class TestBinding extends BindingBase with SchedulerBinding, ServicesBinding { outboundHandlers: {'flutter/keyboard': keyboardHandler}, ); } + + @override + void handleViewFocusChanged(ViewFocusEvent event) { + super.handleViewFocusChanged(event); + lastFocusEvent = event; + } } void main() { @@ -161,4 +169,13 @@ void main() { expect(physicalKeys.first, const PhysicalKeyboardKey(1)); expect(logicalKeys.first, const LogicalKeyboardKey(1)); }); + + test('Default handleViewFocusChanged propagates event', () async { + const ViewFocusEvent event = ViewFocusEvent( + viewId: 0, + direction: ViewFocusDirection.forward, + state: ViewFocusState.focused); + PlatformDispatcher.instance.onViewFocusChange?.call(event); + expect(binding.lastFocusEvent, equals(event)); + }); } diff --git a/packages/flutter/test/widgets/binding_test.dart b/packages/flutter/test/widgets/binding_test.dart index b85553cc37..40c1cf8d7c 100644 --- a/packages/flutter/test/widgets/binding_test.dart +++ b/packages/flutter/test/widgets/binding_test.dart @@ -27,6 +27,15 @@ class AppLifecycleStateObserver with WidgetsBindingObserver { } } +class ViewFocusObserver with WidgetsBindingObserver { + List accumulatedEvents = []; + + @override + void didChangeViewFocus(ViewFocusEvent state) { + accumulatedEvents.add(state); + } +} + class PushRouteObserver with WidgetsBindingObserver { late String pushedRoute; @@ -76,6 +85,12 @@ class RentrantObserver implements WidgetsBindingObserver { WidgetsBinding.instance.addObserver(this); } + @override + void didChangeViewFocus(ViewFocusEvent event) { + assert(active); + WidgetsBinding.instance.addObserver(this); + } + @override void didChangeLocales(List? locales) { assert(active); @@ -183,6 +198,11 @@ void main() { WidgetsBinding.instance.handlePopRoute(); WidgetsBinding.instance.handlePushRoute('/'); WidgetsBinding.instance.handleRequestAppExit(); + WidgetsBinding.instance.handleViewFocusChanged( + const ViewFocusEvent(viewId: 0, + state: ViewFocusState.focused, + direction: ViewFocusDirection.forward), + ); await tester.idle(); expect(observer.removeSelf(), greaterThan(1)); expect(observer.removeSelf(), 0); @@ -262,6 +282,22 @@ void main() { WidgetsBinding.instance.removeObserver(observer); }); + testWidgets('handleViewFocusChanged callback', (WidgetTester tester) async { + final ViewFocusObserver observer = ViewFocusObserver(); + WidgetsBinding.instance.addObserver(observer); + + const ViewFocusEvent expectedEvent = ViewFocusEvent( + viewId: 0, + state: ViewFocusState.focused, + direction: ViewFocusDirection.forward, + ); + + PlatformDispatcher.instance.onViewFocusChange!.call(expectedEvent); + expect(observer.accumulatedEvents, [expectedEvent]); + + WidgetsBinding.instance.removeObserver(observer); + }); + testWidgets('didPushRoute callback', (WidgetTester tester) async { final PushRouteObserver observer = PushRouteObserver(); WidgetsBinding.instance.addObserver(observer); diff --git a/packages/flutter/test/widgets/container_test.dart b/packages/flutter/test/widgets/container_test.dart index fd5cc476f0..03e33a4b46 100644 --- a/packages/flutter/test/widgets/container_test.dart +++ b/packages/flutter/test/widgets/container_test.dart @@ -153,10 +153,10 @@ void main() { box.toStringDeep(minLevel: DiagnosticLevel.debug), equalsIgnoringHashCodes( 'RenderPadding#00000 relayoutBoundary=up1\n' - ' │ creator: Padding ← Container ← Align ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _PipelineOwnerScope ← _ViewScope ←\n' - ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← View ←\n' - ' │ [root]\n' + ' │ creator: Padding ← Container ← Align ← _FocusInheritedScope ←\n' + ' │ _FocusScopeWithExternalFocusNode ← _FocusInheritedScope ← Focus\n' + ' │ ← FocusTraversalGroup ← MediaQuery ← _MediaQueryFromView ←\n' + ' │ _PipelineOwnerScope ← _ViewScope ← ⋯\n' ' │ parentData: offset=Offset(0.0, 0.0) (can use size)\n' ' │ constraints: BoxConstraints(0.0<=w<=800.0, 0.0<=h<=600.0)\n' ' │ size: Size(63.0, 88.0)\n' @@ -164,9 +164,9 @@ void main() { ' │\n' ' └─child: RenderConstrainedBox#00000 relayoutBoundary=up2\n' ' │ creator: ConstrainedBox ← Padding ← Container ← Align ←\n' - ' │ MediaQuery ← _MediaQueryFromView ← _PipelineOwnerScope ←\n' - ' │ _ViewScope ← _RawView-[_DeprecatedRawViewKey\n' - ' │ TestFlutterView#00000] ← View ← [root]\n' + ' │ _FocusInheritedScope ← _FocusScopeWithExternalFocusNode ←\n' + ' │ _FocusInheritedScope ← Focus ← FocusTraversalGroup ← MediaQuery\n' + ' │ ← _MediaQueryFromView ← _PipelineOwnerScope ← ⋯\n' ' │ parentData: offset=Offset(5.0, 5.0) (can use size)\n' ' │ constraints: BoxConstraints(0.0<=w<=790.0, 0.0<=h<=590.0)\n' ' │ size: Size(53.0, 78.0)\n' @@ -174,9 +174,9 @@ void main() { ' │\n' ' └─child: RenderDecoratedBox#00000\n' ' │ creator: DecoratedBox ← ConstrainedBox ← Padding ← Container ←\n' - ' │ Align ← MediaQuery ← _MediaQueryFromView ← _PipelineOwnerScope\n' - ' │ ← _ViewScope ← _RawView-[_DeprecatedRawViewKey\n' - ' │ TestFlutterView#00000] ← View ← [root]\n' + ' │ Align ← _FocusInheritedScope ← _FocusScopeWithExternalFocusNode\n' + ' │ ← _FocusInheritedScope ← Focus ← FocusTraversalGroup ←\n' + ' │ MediaQuery ← _MediaQueryFromView ← ⋯\n' ' │ parentData: (can use size)\n' ' │ constraints: BoxConstraints(w=53.0, h=78.0)\n' ' │ size: Size(53.0, 78.0)\n' @@ -188,10 +188,9 @@ void main() { ' │\n' ' └─child: _RenderColoredBox#00000\n' ' │ creator: ColoredBox ← DecoratedBox ← ConstrainedBox ← Padding ←\n' - ' │ Container ← Align ← MediaQuery ← _MediaQueryFromView ←\n' - ' │ _PipelineOwnerScope ← _ViewScope ←\n' - ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← View ←\n' - ' │ ⋯\n' + ' │ Container ← Align ← _FocusInheritedScope ←\n' + ' │ _FocusScopeWithExternalFocusNode ← _FocusInheritedScope ← Focus\n' + ' │ ← FocusTraversalGroup ← MediaQuery ← ⋯\n' ' │ parentData: (can use size)\n' ' │ constraints: BoxConstraints(w=53.0, h=78.0)\n' ' │ size: Size(53.0, 78.0)\n' @@ -199,9 +198,9 @@ void main() { ' │\n' ' └─child: RenderPadding#00000\n' ' │ creator: Padding ← ColoredBox ← DecoratedBox ← ConstrainedBox ←\n' - ' │ Padding ← Container ← Align ← MediaQuery ← _MediaQueryFromView\n' - ' │ ← _PipelineOwnerScope ← _ViewScope ←\n' - ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← ⋯\n' + ' │ Padding ← Container ← Align ← _FocusInheritedScope ←\n' + ' │ _FocusScopeWithExternalFocusNode ← _FocusInheritedScope ← Focus\n' + ' │ ← FocusTraversalGroup ← ⋯\n' ' │ parentData: (can use size)\n' ' │ constraints: BoxConstraints(w=53.0, h=78.0)\n' ' │ size: Size(53.0, 78.0)\n' @@ -209,8 +208,9 @@ void main() { ' │\n' ' └─child: RenderPositionedBox#00000\n' ' │ creator: Align ← Padding ← ColoredBox ← DecoratedBox ←\n' - ' │ ConstrainedBox ← Padding ← Container ← Align ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _PipelineOwnerScope ← _ViewScope ← ⋯\n' + ' │ ConstrainedBox ← Padding ← Container ← Align ←\n' + ' │ _FocusInheritedScope ← _FocusScopeWithExternalFocusNode ←\n' + ' │ _FocusInheritedScope ← Focus ← ⋯\n' ' │ parentData: offset=Offset(7.0, 7.0) (can use size)\n' ' │ constraints: BoxConstraints(w=39.0, h=64.0)\n' ' │ size: Size(39.0, 64.0)\n' @@ -220,8 +220,9 @@ void main() { ' │\n' ' └─child: RenderConstrainedBox#00000 relayoutBoundary=up1\n' ' │ creator: SizedBox ← Align ← Padding ← ColoredBox ← DecoratedBox ←\n' - ' │ ConstrainedBox ← Padding ← Container ← Align ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _PipelineOwnerScope ← ⋯\n' + ' │ ConstrainedBox ← Padding ← Container ← Align ←\n' + ' │ _FocusInheritedScope ← _FocusScopeWithExternalFocusNode ←\n' + ' │ _FocusInheritedScope ← ⋯\n' ' │ parentData: offset=Offset(14.0, 31.0) (can use size)\n' ' │ constraints: BoxConstraints(0.0<=w<=39.0, 0.0<=h<=64.0)\n' ' │ size: Size(25.0, 33.0)\n' @@ -230,7 +231,7 @@ void main() { ' └─child: RenderDecoratedBox#00000\n' ' creator: DecoratedBox ← SizedBox ← Align ← Padding ← ColoredBox ←\n' ' DecoratedBox ← ConstrainedBox ← Padding ← Container ← Align ←\n' - ' MediaQuery ← _MediaQueryFromView ← ⋯\n' + ' _FocusInheritedScope ← _FocusScopeWithExternalFocusNode ← ⋯\n' ' parentData: (can use size)\n' ' constraints: BoxConstraints(w=25.0, h=33.0)\n' ' size: Size(25.0, 33.0)\n' @@ -238,7 +239,7 @@ void main() { ' color: Color(0xffffff00)\n' ' configuration: ImageConfiguration(bundle:\n' ' PlatformAssetBundle#00000(), devicePixelRatio: 3.0, platform:\n' - ' android)\n', + ' android)\n' ), ); }); @@ -255,10 +256,10 @@ void main() { box.toStringDeep(minLevel: DiagnosticLevel.fine), equalsIgnoringHashCodes( 'RenderPadding#00000 relayoutBoundary=up1\n' - ' │ creator: Padding ← Container ← Align ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _PipelineOwnerScope ← _ViewScope ←\n' - ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← View ←\n' - ' │ [root]\n' + ' │ creator: Padding ← Container ← Align ← _FocusInheritedScope ←\n' + ' │ _FocusScopeWithExternalFocusNode ← _FocusInheritedScope ← Focus\n' + ' │ ← FocusTraversalGroup ← MediaQuery ← _MediaQueryFromView ←\n' + ' │ _PipelineOwnerScope ← _ViewScope ← ⋯\n' ' │ parentData: offset=Offset(0.0, 0.0) (can use size)\n' ' │ constraints: BoxConstraints(0.0<=w<=800.0, 0.0<=h<=600.0)\n' ' │ layer: null\n' @@ -269,9 +270,9 @@ void main() { ' │\n' ' └─child: RenderConstrainedBox#00000 relayoutBoundary=up2\n' ' │ creator: ConstrainedBox ← Padding ← Container ← Align ←\n' - ' │ MediaQuery ← _MediaQueryFromView ← _PipelineOwnerScope ←\n' - ' │ _ViewScope ← _RawView-[_DeprecatedRawViewKey\n' - ' │ TestFlutterView#00000] ← View ← [root]\n' + ' │ _FocusInheritedScope ← _FocusScopeWithExternalFocusNode ←\n' + ' │ _FocusInheritedScope ← Focus ← FocusTraversalGroup ← MediaQuery\n' + ' │ ← _MediaQueryFromView ← _PipelineOwnerScope ← ⋯\n' ' │ parentData: offset=Offset(5.0, 5.0) (can use size)\n' ' │ constraints: BoxConstraints(0.0<=w<=790.0, 0.0<=h<=590.0)\n' ' │ layer: null\n' @@ -281,9 +282,9 @@ void main() { ' │\n' ' └─child: RenderDecoratedBox#00000\n' ' │ creator: DecoratedBox ← ConstrainedBox ← Padding ← Container ←\n' - ' │ Align ← MediaQuery ← _MediaQueryFromView ← _PipelineOwnerScope\n' - ' │ ← _ViewScope ← _RawView-[_DeprecatedRawViewKey\n' - ' │ TestFlutterView#00000] ← View ← [root]\n' + ' │ Align ← _FocusInheritedScope ← _FocusScopeWithExternalFocusNode\n' + ' │ ← _FocusInheritedScope ← Focus ← FocusTraversalGroup ←\n' + ' │ MediaQuery ← _MediaQueryFromView ← ⋯\n' ' │ parentData: (can use size)\n' ' │ constraints: BoxConstraints(w=53.0, h=78.0)\n' ' │ layer: null\n' @@ -303,10 +304,9 @@ void main() { ' │\n' ' └─child: _RenderColoredBox#00000\n' ' │ creator: ColoredBox ← DecoratedBox ← ConstrainedBox ← Padding ←\n' - ' │ Container ← Align ← MediaQuery ← _MediaQueryFromView ←\n' - ' │ _PipelineOwnerScope ← _ViewScope ←\n' - ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← View ←\n' - ' │ ⋯\n' + ' │ Container ← Align ← _FocusInheritedScope ←\n' + ' │ _FocusScopeWithExternalFocusNode ← _FocusInheritedScope ← Focus\n' + ' │ ← FocusTraversalGroup ← MediaQuery ← ⋯\n' ' │ parentData: (can use size)\n' ' │ constraints: BoxConstraints(w=53.0, h=78.0)\n' ' │ layer: null\n' @@ -316,9 +316,9 @@ void main() { ' │\n' ' └─child: RenderPadding#00000\n' ' │ creator: Padding ← ColoredBox ← DecoratedBox ← ConstrainedBox ←\n' - ' │ Padding ← Container ← Align ← MediaQuery ← _MediaQueryFromView\n' - ' │ ← _PipelineOwnerScope ← _ViewScope ←\n' - ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← ⋯\n' + ' │ Padding ← Container ← Align ← _FocusInheritedScope ←\n' + ' │ _FocusScopeWithExternalFocusNode ← _FocusInheritedScope ← Focus\n' + ' │ ← FocusTraversalGroup ← ⋯\n' ' │ parentData: (can use size)\n' ' │ constraints: BoxConstraints(w=53.0, h=78.0)\n' ' │ layer: null\n' @@ -329,8 +329,9 @@ void main() { ' │\n' ' └─child: RenderPositionedBox#00000\n' ' │ creator: Align ← Padding ← ColoredBox ← DecoratedBox ←\n' - ' │ ConstrainedBox ← Padding ← Container ← Align ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _PipelineOwnerScope ← _ViewScope ← ⋯\n' + ' │ ConstrainedBox ← Padding ← Container ← Align ←\n' + ' │ _FocusInheritedScope ← _FocusScopeWithExternalFocusNode ←\n' + ' │ _FocusInheritedScope ← Focus ← ⋯\n' ' │ parentData: offset=Offset(7.0, 7.0) (can use size)\n' ' │ constraints: BoxConstraints(w=39.0, h=64.0)\n' ' │ layer: null\n' @@ -343,8 +344,9 @@ void main() { ' │\n' ' └─child: RenderConstrainedBox#00000 relayoutBoundary=up1\n' ' │ creator: SizedBox ← Align ← Padding ← ColoredBox ← DecoratedBox ←\n' - ' │ ConstrainedBox ← Padding ← Container ← Align ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _PipelineOwnerScope ← ⋯\n' + ' │ ConstrainedBox ← Padding ← Container ← Align ←\n' + ' │ _FocusInheritedScope ← _FocusScopeWithExternalFocusNode ←\n' + ' │ _FocusInheritedScope ← ⋯\n' ' │ parentData: offset=Offset(14.0, 31.0) (can use size)\n' ' │ constraints: BoxConstraints(0.0<=w<=39.0, 0.0<=h<=64.0)\n' ' │ layer: null\n' @@ -355,7 +357,7 @@ void main() { ' └─child: RenderDecoratedBox#00000\n' ' creator: DecoratedBox ← SizedBox ← Align ← Padding ← ColoredBox ←\n' ' DecoratedBox ← ConstrainedBox ← Padding ← Container ← Align ←\n' - ' MediaQuery ← _MediaQueryFromView ← ⋯\n' + ' _FocusInheritedScope ← _FocusScopeWithExternalFocusNode ← ⋯\n' ' parentData: (can use size)\n' ' constraints: BoxConstraints(w=25.0, h=33.0)\n' ' layer: null\n' @@ -389,10 +391,10 @@ void main() { equalsIgnoringHashCodes( 'RenderPadding#00000 relayoutBoundary=up1\n' ' │ needsCompositing: false\n' - ' │ creator: Padding ← Container ← Align ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _PipelineOwnerScope ← _ViewScope ←\n' - ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← View ←\n' - ' │ [root]\n' + ' │ creator: Padding ← Container ← Align ← _FocusInheritedScope ←\n' + ' │ _FocusScopeWithExternalFocusNode ← _FocusInheritedScope ← Focus\n' + ' │ ← FocusTraversalGroup ← MediaQuery ← _MediaQueryFromView ←\n' + ' │ _PipelineOwnerScope ← _ViewScope ← ⋯\n' ' │ parentData: offset=Offset(0.0, 0.0) (can use size)\n' ' │ constraints: BoxConstraints(0.0<=w<=800.0, 0.0<=h<=600.0)\n' ' │ layer: null\n' @@ -406,9 +408,9 @@ void main() { ' └─child: RenderConstrainedBox#00000 relayoutBoundary=up2\n' ' │ needsCompositing: false\n' ' │ creator: ConstrainedBox ← Padding ← Container ← Align ←\n' - ' │ MediaQuery ← _MediaQueryFromView ← _PipelineOwnerScope ←\n' - ' │ _ViewScope ← _RawView-[_DeprecatedRawViewKey\n' - ' │ TestFlutterView#00000] ← View ← [root]\n' + ' │ _FocusInheritedScope ← _FocusScopeWithExternalFocusNode ←\n' + ' │ _FocusInheritedScope ← Focus ← FocusTraversalGroup ← MediaQuery\n' + ' │ ← _MediaQueryFromView ← _PipelineOwnerScope ← ⋯\n' ' │ parentData: offset=Offset(5.0, 5.0) (can use size)\n' ' │ constraints: BoxConstraints(0.0<=w<=790.0, 0.0<=h<=590.0)\n' ' │ layer: null\n' @@ -421,9 +423,9 @@ void main() { ' └─child: RenderDecoratedBox#00000\n' ' │ needsCompositing: false\n' ' │ creator: DecoratedBox ← ConstrainedBox ← Padding ← Container ←\n' - ' │ Align ← MediaQuery ← _MediaQueryFromView ← _PipelineOwnerScope\n' - ' │ ← _ViewScope ← _RawView-[_DeprecatedRawViewKey\n' - ' │ TestFlutterView#00000] ← View ← [root]\n' + ' │ Align ← _FocusInheritedScope ← _FocusScopeWithExternalFocusNode\n' + ' │ ← _FocusInheritedScope ← Focus ← FocusTraversalGroup ←\n' + ' │ MediaQuery ← _MediaQueryFromView ← ⋯\n' ' │ parentData: (can use size)\n' ' │ constraints: BoxConstraints(w=53.0, h=78.0)\n' ' │ layer: null\n' @@ -446,10 +448,9 @@ void main() { ' └─child: _RenderColoredBox#00000\n' ' │ needsCompositing: false\n' ' │ creator: ColoredBox ← DecoratedBox ← ConstrainedBox ← Padding ←\n' - ' │ Container ← Align ← MediaQuery ← _MediaQueryFromView ←\n' - ' │ _PipelineOwnerScope ← _ViewScope ←\n' - ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← View ←\n' - ' │ ⋯\n' + ' │ Container ← Align ← _FocusInheritedScope ←\n' + ' │ _FocusScopeWithExternalFocusNode ← _FocusInheritedScope ← Focus\n' + ' │ ← FocusTraversalGroup ← MediaQuery ← ⋯\n' ' │ parentData: (can use size)\n' ' │ constraints: BoxConstraints(w=53.0, h=78.0)\n' ' │ layer: null\n' @@ -462,9 +463,9 @@ void main() { ' └─child: RenderPadding#00000\n' ' │ needsCompositing: false\n' ' │ creator: Padding ← ColoredBox ← DecoratedBox ← ConstrainedBox ←\n' - ' │ Padding ← Container ← Align ← MediaQuery ← _MediaQueryFromView\n' - ' │ ← _PipelineOwnerScope ← _ViewScope ←\n' - ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← ⋯\n' + ' │ Padding ← Container ← Align ← _FocusInheritedScope ←\n' + ' │ _FocusScopeWithExternalFocusNode ← _FocusInheritedScope ← Focus\n' + ' │ ← FocusTraversalGroup ← ⋯\n' ' │ parentData: (can use size)\n' ' │ constraints: BoxConstraints(w=53.0, h=78.0)\n' ' │ layer: null\n' @@ -478,8 +479,9 @@ void main() { ' └─child: RenderPositionedBox#00000\n' ' │ needsCompositing: false\n' ' │ creator: Align ← Padding ← ColoredBox ← DecoratedBox ←\n' - ' │ ConstrainedBox ← Padding ← Container ← Align ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _PipelineOwnerScope ← _ViewScope ← ⋯\n' + ' │ ConstrainedBox ← Padding ← Container ← Align ←\n' + ' │ _FocusInheritedScope ← _FocusScopeWithExternalFocusNode ←\n' + ' │ _FocusInheritedScope ← Focus ← ⋯\n' ' │ parentData: offset=Offset(7.0, 7.0) (can use size)\n' ' │ constraints: BoxConstraints(w=39.0, h=64.0)\n' ' │ layer: null\n' @@ -495,8 +497,9 @@ void main() { ' └─child: RenderConstrainedBox#00000 relayoutBoundary=up1\n' ' │ needsCompositing: false\n' ' │ creator: SizedBox ← Align ← Padding ← ColoredBox ← DecoratedBox ←\n' - ' │ ConstrainedBox ← Padding ← Container ← Align ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _PipelineOwnerScope ← ⋯\n' + ' │ ConstrainedBox ← Padding ← Container ← Align ←\n' + ' │ _FocusInheritedScope ← _FocusScopeWithExternalFocusNode ←\n' + ' │ _FocusInheritedScope ← ⋯\n' ' │ parentData: offset=Offset(14.0, 31.0) (can use size)\n' ' │ constraints: BoxConstraints(0.0<=w<=39.0, 0.0<=h<=64.0)\n' ' │ layer: null\n' @@ -510,7 +513,7 @@ void main() { ' needsCompositing: false\n' ' creator: DecoratedBox ← SizedBox ← Align ← Padding ← ColoredBox ←\n' ' DecoratedBox ← ConstrainedBox ← Padding ← Container ← Align ←\n' - ' MediaQuery ← _MediaQueryFromView ← ⋯\n' + ' _FocusInheritedScope ← _FocusScopeWithExternalFocusNode ← ⋯\n' ' parentData: (can use size)\n' ' constraints: BoxConstraints(w=25.0, h=33.0)\n' ' layer: null\n' diff --git a/packages/flutter/test/widgets/custom_multi_child_layout_test.dart b/packages/flutter/test/widgets/custom_multi_child_layout_test.dart index b691c0d656..e4e72a31ea 100644 --- a/packages/flutter/test/widgets/custom_multi_child_layout_test.dart +++ b/packages/flutter/test/widgets/custom_multi_child_layout_test.dart @@ -373,14 +373,13 @@ void main() { ' in its parent data.\n' ' The following child has no ID: RenderConstrainedBox#00000 NEEDS-LAYOUT NEEDS-PAINT:\n' ' creator: ConstrainedBox ← Container ← LayoutWithMissingId ←\n' - ' CustomMultiChildLayout ← Center ← MediaQuery ←\n' - ' _MediaQueryFromView ← _PipelineOwnerScope ← _ViewScope ←\n' - ' _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← View ←\n' - ' [root]\n' + ' CustomMultiChildLayout ← Center ← _FocusInheritedScope ←\n' + ' _FocusScopeWithExternalFocusNode ← _FocusInheritedScope ← Focus\n' + ' ← FocusTraversalGroup ← MediaQuery ← _MediaQueryFromView ← ⋯\n' ' parentData: offset=Offset(0.0, 0.0); id=null\n' ' constraints: MISSING\n' ' size: MISSING\n' - ' additionalConstraints: BoxConstraints(w=100.0, 0.0<=h<=Infinity)\n', + ' additionalConstraints: BoxConstraints(w=100.0, 0.0<=h<=Infinity)\n' ); }); diff --git a/packages/flutter/test/widgets/focus_manager_test.dart b/packages/flutter/test/widgets/focus_manager_test.dart index c5fd810e74..69b2e2a765 100644 --- a/packages/flutter/test/widgets/focus_manager_test.dart +++ b/packages/flutter/test/widgets/focus_manager_test.dart @@ -919,12 +919,50 @@ void main() { child4Attachment.reparent(parent: parent2); child4.requestFocus(); await tester.pump(); + final FocusScopeNode rootScope = tester.binding.focusManager.rootScope; + final List preamble = [ + rootScope.children.first.children.first, // The View Node, + rootScope.children.first, // The FocusTraversal node above the view + ]; expect(child4.ancestors, equals([parent2, scope2, tester.binding.focusManager.rootScope])); - expect(tester.binding.focusManager.rootScope.descendants, equals([child1, child2, parent1, scope1, child3, child4, parent2, scope2])); + expect( + rootScope.descendants, + equals([ + ...preamble, + child1, + child2, + parent1, + scope1, + child3, + child4, + parent2, + scope2, + ])); scope2Attachment.reparent(parent: child2); await tester.pump(); - expect(child4.ancestors, equals([parent2, scope2, child2, parent1, scope1, tester.binding.focusManager.rootScope])); - expect(tester.binding.focusManager.rootScope.descendants, equals([child1, child3, child4, parent2, scope2, child2, parent1, scope1])); + expect( + child4.ancestors, + equals([ + parent2, + scope2, + child2, + parent1, + scope1, + rootScope, + ])); + expect( + tester.binding.focusManager.rootScope.descendants, + equals([ + ...preamble, + child1, + child3, + child4, + parent2, + scope2, + child2, + parent1, + scope1, + ])); }); testWidgets('Can move focus between scopes and keep focus', (WidgetTester tester) async { @@ -1459,6 +1497,36 @@ void main() { expect(FocusManager.instance.highlightMode, equals(FocusHighlightMode.touch)); }); + testWidgets('Scopes can be focused without sending focus to descendants.', (WidgetTester tester) async { + final FocusScopeNode scopeNode = FocusScopeNode(debugLabel: 'Scope1',); + final FocusNode childFocusNode = FocusNode(debugLabel: 'Child1',); + await tester.pumpWidget( + FocusScope.withExternalFocusNode( + focusScopeNode: scopeNode, + child: Focus( + debugLabel: 'Parent1', + child: FocusScope( + debugLabel: 'Scope2', + child: Focus.withExternalFocusNode( + focusNode: childFocusNode, + child: const SizedBox(), + ), + ), + ), + ), + ); + + childFocusNode.requestFocus(); + await tester.pump(); + expect(scopeNode.hasFocus, isTrue); + expect(childFocusNode.hasPrimaryFocus, isTrue); + + scopeNode.requestScopeFocus(); + await tester.pump(); + expect(scopeNode.hasPrimaryFocus, isTrue); + expect(childFocusNode.hasPrimaryFocus, isFalse); + }); + testWidgets('implements debugFillProperties', (WidgetTester tester) async { final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); final FocusScopeNode scope = FocusScopeNode(debugLabel: 'Scope Label'); @@ -1517,16 +1585,25 @@ void main() { equalsIgnoringHashCodes( 'FocusManager#00000\n' ' │ primaryFocus: FocusNode#00000(Child 4 [PRIMARY FOCUS])\n' - ' │ primaryFocusCreator: Container-[GlobalKey#00000] ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _PipelineOwnerScope ← _ViewScope ←\n' - ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← View ←\n' - ' │ [root]\n' + ' │ primaryFocusCreator: Container-[GlobalKey#00000] ←\n' + ' │ _FocusInheritedScope ← _FocusScopeWithExternalFocusNode ←\n' + ' │ _FocusInheritedScope ← Focus ← FocusTraversalGroup ← MediaQuery\n' + ' │ ← _MediaQueryFromView ← _PipelineOwnerScope ← _ViewScope ←\n' + ' │ _RawViewInternal-[_DeprecatedRawViewKey TestFlutterView#00000]\n' + ' │ ← RawView ← View ← [root]\n' ' │\n' ' └─rootScope: FocusScopeNode#00000(Root Focus Scope [IN FOCUS PATH])\n' ' │ IN FOCUS PATH\n' ' │ focusedChildren: FocusScopeNode#00000([IN FOCUS PATH])\n' ' │\n' - ' ├─Child 1: FocusScopeNode#00000(Scope 1)\n' + ' ├─Child 1: _FocusTraversalGroupNode#00000(FocusTraversalGroup)\n' + ' │ │ context: Focus\n' + ' │ │ NOT FOCUSABLE\n' + ' │ │\n' + ' │ └─Child 1: FocusScopeNode#00000(View Scope)\n' + ' │ context: _FocusScopeWithExternalFocusNode\n' + ' │\n' + ' ├─Child 2: FocusScopeNode#00000(Scope 1)\n' ' │ │ context: Container-[GlobalKey#00000]\n' ' │ │\n' ' │ └─Child 1: FocusNode#00000(Parent 1)\n' @@ -1538,7 +1615,7 @@ void main() { ' │ └─Child 2: FocusNode#00000\n' ' │ context: Container-[GlobalKey#00000]\n' ' │\n' - ' └─Child 2: FocusScopeNode#00000([IN FOCUS PATH])\n' + ' └─Child 3: FocusScopeNode#00000([IN FOCUS PATH])\n' ' │ context: Container-[GlobalKey#00000]\n' ' │ IN FOCUS PATH\n' ' │ focusedChildren: FocusNode#00000(Child 4 [PRIMARY FOCUS])\n' @@ -2185,7 +2262,7 @@ void main() { debugPrint = oldDebugPrint; } final String messagesStr = messages.toString(); - expect(messagesStr, contains(RegExp(r' └─Child 1: FocusScopeNode#[a-f0-9]{5}\(parent1 \[PRIMARY FOCUS\]\)'))); + expect(messagesStr, contains(RegExp(r' └─Child \d+: FocusScopeNode#[a-f0-9]{5}\(parent1 \[PRIMARY FOCUS\]\)'))); expect(messagesStr, contains('FOCUS: Notified 2 dirty nodes')); expect(messagesStr, contains(RegExp(r'FOCUS: Scheduling update, current focus is null, next focus will be FocusScopeNode#.*parent1'))); }); diff --git a/packages/flutter/test/widgets/focus_scope_test.dart b/packages/flutter/test/widgets/focus_scope_test.dart index 9203c6a906..13b25fe370 100644 --- a/packages/flutter/test/widgets/focus_scope_test.dart +++ b/packages/flutter/test/widgets/focus_scope_test.dart @@ -182,17 +182,27 @@ void main() { equalsIgnoringHashCodes( 'FocusScopeNode#00000(Root Focus Scope [IN FOCUS PATH])\n' ' │ IN FOCUS PATH\n' - ' │ focusedChildren: FocusScopeNode#00000(Parent Scope Node [IN FOCUS\n' - ' │ PATH])\n' + ' │ focusedChildren: FocusScopeNode#00000(View Scope [IN FOCUS PATH])\n' ' │\n' - ' └─Child 1: FocusScopeNode#00000(Parent Scope Node [IN FOCUS PATH])\n' - ' │ context: FocusScope\n' + ' └─Child 1: _FocusTraversalGroupNode#00000(FocusTraversalGroup [IN FOCUS PATH])\n' + ' │ context: Focus\n' + ' │ NOT FOCUSABLE\n' ' │ IN FOCUS PATH\n' - ' │ focusedChildren: FocusNode#00000(Child [PRIMARY FOCUS])\n' ' │\n' - ' └─Child 1: FocusNode#00000(Child [PRIMARY FOCUS])\n' - ' context: Focus\n' - ' PRIMARY FOCUS\n', + ' └─Child 1: FocusScopeNode#00000(View Scope [IN FOCUS PATH])\n' + ' │ context: _FocusScopeWithExternalFocusNode\n' + ' │ IN FOCUS PATH\n' + ' │ focusedChildren: FocusScopeNode#00000(Parent Scope Node [IN FOCUS\n' + ' │ PATH])\n' + ' │\n' + ' └─Child 1: FocusScopeNode#00000(Parent Scope Node [IN FOCUS PATH])\n' + ' │ context: FocusScope\n' + ' │ IN FOCUS PATH\n' + ' │ focusedChildren: FocusNode#00000(Child [PRIMARY FOCUS])\n' + ' │\n' + ' └─Child 1: FocusNode#00000(Child [PRIMARY FOCUS])\n' + ' context: Focus\n' + ' PRIMARY FOCUS\n' ), ); @@ -730,9 +740,11 @@ void main() { expect(keyB.currentState!.focusNode.hasFocus, isFalse); expect(find.text('b'), findsOneWidget); + expect(FocusManager.instance.rootScope.descendants.length, equals(7)); await tester.pumpWidget(Container()); - - expect(FocusManager.instance.rootScope.children, isEmpty); + expect(FocusManager.instance.rootScope.descendants.length, equals(2)); + expect(FocusManager.instance.rootScope.descendants, isNot(contains(aScope))); + expect(FocusManager.instance.rootScope.descendants, isNot(contains(bScope))); }); // By "pinned", it means kept in the tree by a GlobalKey. @@ -1093,7 +1105,7 @@ void main() { await tester.pump(); expect(rootNode.hasFocus, isTrue); - expect(rootNode, equals(firstElement.owner!.focusManager.rootScope)); + expect(rootNode, equals(FocusManager.instance.rootScope.descendants.toList()[1])); }); testWidgets('Can autofocus a node.', (WidgetTester tester) async { @@ -1278,9 +1290,9 @@ void main() { expect(Focus.maybeOf(element1), isNull); expect(Focus.maybeOf(element2), isNull); expect(Focus.maybeOf(element3), isNull); - expect(Focus.of(element4).parent!.parent, equals(root)); - expect(Focus.of(element5).parent!.parent, equals(root)); - expect(Focus.of(element6).parent!.parent!.parent, equals(root)); + expect(Focus.of(element4).parent!.parent!.parent!.parent, equals(root)); + expect(Focus.of(element5).parent!.parent!.parent!.parent, equals(root)); + expect(Focus.of(element6).parent!.parent!.parent!.parent!.parent, equals(root)); }); testWidgets('Can traverse Focus children.', (WidgetTester tester) async { final GlobalKey key1 = GlobalKey(debugLabel: '1'); @@ -1492,8 +1504,9 @@ void main() { expect(node.hasFocus, isTrue); await tester.pumpWidget(Container()); - - expect(FocusManager.instance.rootScope.descendants, isEmpty); + // Even with no other focusable widgets, there will be the top level focus + // traversal and view focus nodes. + expect(FocusManager.instance.rootScope.descendants, hasLength(2)); }); testWidgets('Focus widgets set Semantics information about focus', (WidgetTester tester) async { diff --git a/packages/flutter/test/widgets/slotted_render_object_widget_test.dart b/packages/flutter/test/widgets/slotted_render_object_widget_test.dart index 796693fefa..f976541962 100644 --- a/packages/flutter/test/widgets/slotted_render_object_widget_test.dart +++ b/packages/flutter/test/widgets/slotted_render_object_widget_test.dart @@ -222,19 +222,19 @@ void main() { tester.renderObject(find.byType(_Diagonal)).toStringDeep(), equalsIgnoringHashCodes( '_RenderDiagonal#00000 relayoutBoundary=up1\n' - ' │ creator: _Diagonal ← Align ← Directionality ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _PipelineOwnerScope ← _ViewScope ←\n' - ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← View ←\n' - ' │ [root]\n' + ' │ creator: _Diagonal ← Align ← Directionality ←\n' + ' │ _FocusInheritedScope ← _FocusScopeWithExternalFocusNode ←\n' + ' │ _FocusInheritedScope ← Focus ← FocusTraversalGroup ← MediaQuery\n' + ' │ ← _MediaQueryFromView ← _PipelineOwnerScope ← _ViewScope ← ⋯\n' ' │ parentData: offset=Offset(0.0, 0.0) (can use size)\n' ' │ constraints: BoxConstraints(0.0<=w<=800.0, 0.0<=h<=600.0)\n' ' │ size: Size(190.0, 220.0)\n' ' │\n' ' ├─topLeft: RenderConstrainedBox#00000 relayoutBoundary=up2\n' ' │ creator: SizedBox ← _Diagonal ← Align ← Directionality ←\n' - ' │ MediaQuery ← _MediaQueryFromView ← _PipelineOwnerScope ←\n' - ' │ _ViewScope ← _RawView-[_DeprecatedRawViewKey\n' - ' │ TestFlutterView#00000] ← View ← [root]\n' + ' │ _FocusInheritedScope ← _FocusScopeWithExternalFocusNode ←\n' + ' │ _FocusInheritedScope ← Focus ← FocusTraversalGroup ← MediaQuery\n' + ' │ ← _MediaQueryFromView ← _PipelineOwnerScope ← ⋯\n' ' │ parentData: offset=Offset(0.0, 0.0) (can use size)\n' ' │ constraints: BoxConstraints(unconstrained)\n' ' │ size: Size(80.0, 100.0)\n' @@ -242,13 +242,13 @@ void main() { ' │\n' ' └─bottomRight: RenderConstrainedBox#00000 relayoutBoundary=up2\n' ' creator: SizedBox ← _Diagonal ← Align ← Directionality ←\n' - ' MediaQuery ← _MediaQueryFromView ← _PipelineOwnerScope ←\n' - ' _ViewScope ← _RawView-[_DeprecatedRawViewKey\n' - ' TestFlutterView#00000] ← View ← [root]\n' + ' _FocusInheritedScope ← _FocusScopeWithExternalFocusNode ←\n' + ' _FocusInheritedScope ← Focus ← FocusTraversalGroup ← MediaQuery\n' + ' ← _MediaQueryFromView ← _PipelineOwnerScope ← ⋯\n' ' parentData: offset=Offset(80.0, 100.0) (can use size)\n' ' constraints: BoxConstraints(unconstrained)\n' ' size: Size(110.0, 120.0)\n' - ' additionalConstraints: BoxConstraints(w=110.0, h=120.0)\n', + ' additionalConstraints: BoxConstraints(w=110.0, h=120.0)\n' ) ); }); diff --git a/packages/flutter/test/widgets/stack_test.dart b/packages/flutter/test/widgets/stack_test.dart index db6e997b0f..d354fe813e 100644 --- a/packages/flutter/test/widgets/stack_test.dart +++ b/packages/flutter/test/widgets/stack_test.dart @@ -880,7 +880,7 @@ void main() { )); expect( exception, endsWith( - '← [root]"\n' // End of ownership chain. + '_ViewScope ← ⋯"\n' // End of ownership chain. 'Typically, the Directionality widget is introduced by the MaterialApp or WidgetsApp widget at the ' 'top of your application widget tree. It determines the ambient reading direction and is used, for ' 'example, to determine how to lay out text, how to interpret "start" and "end" values, and to resolve ' diff --git a/packages/flutter/test/widgets/view_test.dart b/packages/flutter/test/widgets/view_test.dart index 64221196bb..c272761e4a 100644 --- a/packages/flutter/test/widgets/view_test.dart +++ b/packages/flutter/test/widgets/view_test.dart @@ -6,6 +6,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; @@ -514,6 +515,52 @@ void main() { expect(child.debugCanParentUseSize, isTrue); expect(child.size, const Size(100, 200)); }); + + testWidgets('ViewFocusEvents cause unfocusing and refocusing', (WidgetTester tester) async { + late FlutterView view; + late FocusNode focusNode; + await tester.pumpWidget( + Focus( + child: Builder( + builder: (BuildContext context) { + view = View.of(context); + focusNode = Focus.of(context); + return Container(); + }, + ), + ), + ); + + final ViewFocusEvent unfocusEvent = ViewFocusEvent( + viewId: view.viewId, + state: ViewFocusState.unfocused, + direction: ViewFocusDirection.forward, + ); + + final ViewFocusEvent focusEvent = ViewFocusEvent( + viewId: view.viewId, + state: ViewFocusState.focused, + direction: ViewFocusDirection.backward, + ); + + focusNode.requestFocus(); + await tester.pump(); + + expect(focusNode.hasPrimaryFocus, isTrue); + expect(FocusManager.instance.rootScope.hasPrimaryFocus, isFalse); + + ServicesBinding.instance.platformDispatcher.onViewFocusChange?.call(unfocusEvent); + await tester.pump(); + + expect(focusNode.hasPrimaryFocus, isFalse); + expect(FocusManager.instance.rootScope.hasPrimaryFocus, isTrue); + + ServicesBinding.instance.platformDispatcher.onViewFocusChange?.call(focusEvent); + await tester.pump(); + + expect(focusNode.hasPrimaryFocus, isTrue); + expect(FocusManager.instance.rootScope.hasPrimaryFocus, isFalse); + }); } class SpyRenderWidget extends SizedBox { diff --git a/packages/flutter_test/lib/src/window.dart b/packages/flutter_test/lib/src/window.dart index b108c0588e..ccf285e278 100644 --- a/packages/flutter_test/lib/src/window.dart +++ b/packages/flutter_test/lib/src/window.dart @@ -154,6 +154,7 @@ class TestPlatformDispatcher implements PlatformDispatcher { }) : _platformDispatcher = platformDispatcher { _updateViewsAndDisplays(); _platformDispatcher.onMetricsChanged = _handleMetricsChanged; + _platformDispatcher.onViewFocusChange = _handleViewFocusChanged; } /// The [PlatformDispatcher] that is wrapped by this [TestPlatformDispatcher]. @@ -176,12 +177,23 @@ class TestPlatformDispatcher implements PlatformDispatcher { set onMetricsChanged(VoidCallback? callback) { _onMetricsChanged = callback; } - void _handleMetricsChanged() { _updateViewsAndDisplays(); _onMetricsChanged?.call(); } + @override + ViewFocusChangeCallback? get onViewFocusChange => _platformDispatcher.onViewFocusChange; + ViewFocusChangeCallback? _onViewFocusChange; + @override + set onViewFocusChange(ViewFocusChangeCallback? callback) { + _onViewFocusChange = callback; + } + void _handleViewFocusChanged(ViewFocusEvent event) { + _updateViewsAndDisplays(); + _onViewFocusChange?.call(event); + } + @override Locale get locale => _localeTestValue ?? _platformDispatcher.locale; Locale? _localeTestValue; diff --git a/packages/flutter_test/test/platform_dispatcher_test.dart b/packages/flutter_test/test/platform_dispatcher_test.dart index 82544256e4..d48589a314 100644 --- a/packages/flutter_test/test/platform_dispatcher_test.dart +++ b/packages/flutter_test/test/platform_dispatcher_test.dart @@ -2,7 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:ui' show AccessibilityFeatures, Brightness, Display, FlutterView, Locale, PlatformDispatcher, VoidCallback; +import 'dart:ui' show AccessibilityFeatures, Brightness, Display, FlutterView, + Locale, PlatformDispatcher, ViewFocusChangeCallback, VoidCallback; import 'package:flutter/widgets.dart' show WidgetsBinding, WidgetsBindingObserver; import 'package:flutter_test/flutter_test.dart'; @@ -303,4 +304,7 @@ class _FakePlatformDispatcher extends Fake implements PlatformDispatcher { @override VoidCallback? onMetricsChanged; + + @override + ViewFocusChangeCallback? onViewFocusChange; }