Factor out RawView, make View listen to engine generated view focus events (#143259)

## Description

This factors out a separate `RawView` that doesn't add a `MediaQuery` or a `FocusScope`. This PR also adds a new method `WidgetsBindingObserver.didChangeViewFocus` which allows the observer to know when the `FlutterView` that has focus has changed.

It also makes the `View` widget a stateful widget that contains a `FocusScope` and ` FocusTraversalGroup` so that it can respond to changes in the focus of the view.

I've also added a new function to `FocusScopeNode` that will allow the scope node itself to be focused, without looking for descendants that could take the focus. This lets the focus be "parked" at the `FocusManager.instance.rootScope` so that nothing else appears to have focus.

## Tests
 - Added tests for the new functionality.
This commit is contained in:
Greg Spencer 2024-05-20 10:17:55 -07:00 committed by GitHub
parent 72f06d2bf3
commit 333c076e53
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 588 additions and 175 deletions

View File

@ -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<dynamic> _handlePlatformMessage(MethodCall methodCall) async {
final String method = methodCall.method;
switch (method) {

View File

@ -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<WidgetsBindingObserver>.of(_observers)) {
observer.didChangeViewFocus(event);
}
}
@override
void handleMemoryPressure() {
super.handleMemoryPressure();

View File

@ -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();
}

View File

@ -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<bool>? 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;
}
}

View File

@ -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<View> createState() => _ViewState();
}
class _ViewState extends State<View> 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();

View File

@ -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,

View File

@ -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: <String, MessageHandler>{'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));
});
}

View File

@ -27,6 +27,15 @@ class AppLifecycleStateObserver with WidgetsBindingObserver {
}
}
class ViewFocusObserver with WidgetsBindingObserver {
List<ViewFocusEvent> accumulatedEvents = <ViewFocusEvent>[];
@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<Locale>? 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, <ViewFocusEvent>[expectedEvent]);
WidgetsBinding.instance.removeObserver(observer);
});
testWidgets('didPushRoute callback', (WidgetTester tester) async {
final PushRouteObserver observer = PushRouteObserver();
WidgetsBinding.instance.addObserver(observer);

View File

@ -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: <none> (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: <none> (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: <none> (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: <none> (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: <none> (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: <none> (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: <none> (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: <none> (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: <none> (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: <none> (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: <none> (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: <none> (can use size)\n'
' constraints: BoxConstraints(w=25.0, h=33.0)\n'
' layer: null\n'

View File

@ -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'
);
});

View File

@ -919,12 +919,50 @@ void main() {
child4Attachment.reparent(parent: parent2);
child4.requestFocus();
await tester.pump();
final FocusScopeNode rootScope = tester.binding.focusManager.rootScope;
final List<FocusNode> preamble = <FocusNode>[
rootScope.children.first.children.first, // The View Node,
rootScope.children.first, // The FocusTraversal node above the view
];
expect(child4.ancestors, equals(<FocusNode>[parent2, scope2, tester.binding.focusManager.rootScope]));
expect(tester.binding.focusManager.rootScope.descendants, equals(<FocusNode>[child1, child2, parent1, scope1, child3, child4, parent2, scope2]));
expect(
rootScope.descendants,
equals(<FocusNode>[
...preamble,
child1,
child2,
parent1,
scope1,
child3,
child4,
parent2,
scope2,
]));
scope2Attachment.reparent(parent: child2);
await tester.pump();
expect(child4.ancestors, equals(<FocusNode>[parent2, scope2, child2, parent1, scope1, tester.binding.focusManager.rootScope]));
expect(tester.binding.focusManager.rootScope.descendants, equals(<FocusNode>[child1, child3, child4, parent2, scope2, child2, parent1, scope1]));
expect(
child4.ancestors,
equals(<FocusNode>[
parent2,
scope2,
child2,
parent1,
scope1,
rootScope,
]));
expect(
tester.binding.focusManager.rootScope.descendants,
equals(<FocusNode>[
...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')));
});

View File

@ -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 {

View File

@ -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'
)
);
});

View File

@ -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 '

View File

@ -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 {

View File

@ -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;

View File

@ -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;
}