diff --git a/lib/ui/platform_dispatcher.dart b/lib/ui/platform_dispatcher.dart index e47ae8d57a3ff..250ea04d6aa74 100644 --- a/lib/ui/platform_dispatcher.dart +++ b/lib/ui/platform_dispatcher.dart @@ -308,6 +308,72 @@ class PlatformDispatcher { _invoke(onMetricsChanged, _onMetricsChangedZone); } + /// A callback invoked immediately after the focus is transitioned across [FlutterView]s. + /// + /// When the platform moves the focus from one [FlutterView] to another, this + /// callback is invoked indicating the new view that has focus and the direction + /// in which focus was received. For example, if focus is moved to the [FlutterView] + /// with ID 2 in the forward direction (could be the result of pressing tab) + /// the callback receives a [ViewFocusEvent] with [ViewFocusState.focused] and + /// [ViewFocusDirection.forward]. + /// + /// Typically, receivers of this event respond by moving the focus to the first + /// focusable widget inside the [FlutterView] with ID 2. If a view receives + /// focus in the backwards direction (could be the result of pressing shift + tab), + /// typically the last focusable widget inside that view is focused. + /// + /// The platform may remove focus from a [FlutterView]. For example, on the web, + /// the browser can move focus to another element, or to the browser's built-in UI. + /// On desktop, the operating system can switch to another window (e.g. using Alt + Tab on Windows). + /// In scenarios like these, [onViewFocusChange] will be invoked with [ViewFocusState.unfocused] and + /// [ViewFocusDirection.undefined]. + /// + /// Receivers typically respond to this event by removing all focus indications + /// from the app. + /// + /// Apps can also programmatically request to move the focus to a desired + /// [FlutterView] by calling [requestViewFocusChange]. + /// + /// The callback is invoked in the same zone in which the callback was set. + /// + /// See also: + /// + /// * [requestViewFocusChange] to programmatically instruct the platform to move focus to a different [FlutterView]. + /// * [ViewFocusState] for a list of allowed focus transitions. + /// * [ViewFocusDirection] for a list of allowed focus directions. + /// * [ViewFocusEvent], which is the event object provided to the callback. + ViewFocusChangeCallback? get onViewFocusChange => _onViewFocusChange; + ViewFocusChangeCallback? _onViewFocusChange; + // ignore: unused_field, field will be used when platforms other than web use these focus APIs. + Zone _onViewFocusChangeZone = Zone.root; + set onViewFocusChange(ViewFocusChangeCallback? callback) { + _onViewFocusChange = callback; + _onViewFocusChangeZone = Zone.current; + } + + /// Requests a focus change of the [FlutterView] with ID [viewId]. + /// + /// If an app would like to request the engine to move focus, in forward direction, + /// to the [FlutterView] with ID 1 it should call this method with [ViewFocusState.focused] + /// and [ViewFocusDirection.forward]. + /// + /// There is no need to call this method if the view in question already has + /// focus as it won't have any effect. + /// + /// A call to this method will lead to the engine calling [onViewFocusChange] + /// if the request is successfully fulfilled. + /// + /// See also: + /// + /// * [onViewFocusChange], a callback to subscribe to view focus change events. + void requestViewFocusChange({ + required int viewId, + required ViewFocusState state, + required ViewFocusDirection direction, + }) { + // TODO(tugorez): implement this method. At the moment will be a no op call. + } + /// A callback invoked when any view begins a frame. /// /// A callback that is invoked to notify the application that it is an @@ -2552,3 +2618,80 @@ class SemanticsActionEvent { ); } } + +/// Signature for [PlatformDispatcher.onViewFocusChange]. +typedef ViewFocusChangeCallback = void Function(ViewFocusEvent viewFocusEvent); + +/// An event for the engine to communicate view focus changes to the app. +/// +/// This value will be typically passed to the [PlatformDispatcher.onViewFocusChange] +/// callback. +final class ViewFocusEvent { + /// Creates a [ViewFocusChange]. + const ViewFocusEvent({ + required this.viewId, + required this.state, + required this.direction, + }); + + /// The ID of the [FlutterView] that experienced a focus change. + final int viewId; + + /// The state focus changed to. + final ViewFocusState state; + + /// The direction focus changed to. + final ViewFocusDirection direction; + + @override + String toString() { + return 'ViewFocusEvent(viewId: $viewId, state: $state, direction: $direction)'; + } +} + +/// Represents the focus state of a given [FlutterView]. +/// +/// When focus is lost, the view's focus state changes to [ViewFocusState.unfocused]. +/// +/// When focus is gained, the view's focus state changes to [ViewFocusState.focused]. +/// +/// Valid transitions within a view are: +/// +/// - [ViewFocusState.focused] to [ViewFocusState.unfocused]. +/// - [ViewFocusState.unfocused] to [ViewFocusState.focused]. +/// +/// See also: +/// +/// * [ViewFocusDirection], that specifies the focus direction. +/// * [ViewFocusEvent], that conveys information about a [FlutterView] focus change. +enum ViewFocusState { + /// Specifies that a view does not have platform focus. + unfocused, + + /// Specifies that a view has platform focus. + focused, +} + +/// Represents the direction in which the focus transitioned across [FlutterView]s. +/// +/// See also: +/// +/// * [ViewFocusState], that specifies the current focus state of a [FlutterView]. +/// * [ViewFocusEvent], that conveys information about a [FlutterView] focus change. +enum ViewFocusDirection { + /// Indicates the focus transition did not have a direction. + /// + /// This is typically associated with focus being programmatically requested or + /// when focus is lost. + undefined, + + /// Indicates the focus transition was performed in a forward direction. + /// + /// This is typically result of the user pressing tab. + forward, + + /// Indicates the focus transition was performed in a backwards direction. + /// + /// This is typically result of the user pressing shift + tab. + backwards, +} diff --git a/lib/web_ui/lib/platform_dispatcher.dart b/lib/web_ui/lib/platform_dispatcher.dart index 7f0d66f7fc173..cf9e15661446d 100644 --- a/lib/web_ui/lib/platform_dispatcher.dart +++ b/lib/web_ui/lib/platform_dispatcher.dart @@ -5,6 +5,7 @@ part of ui; typedef VoidCallback = void Function(); +typedef ViewFocusChangeCallback = void Function(ViewFocusEvent viewFocusEvent); typedef FrameCallback = void Function(Duration duration); typedef TimingsCallback = void Function(List timings); typedef PointerDataPacketCallback = void Function(PointerDataPacket packet); @@ -40,6 +41,15 @@ abstract class PlatformDispatcher { VoidCallback? get onMetricsChanged; set onMetricsChanged(VoidCallback? callback); + ViewFocusChangeCallback? get onViewFocusChange; + set onViewFocusChange(ViewFocusChangeCallback? callback); + + void requestViewFocusChange({ + required int viewId, + required ViewFocusState state, + required ViewFocusDirection direction, + }); + FrameCallback? get onBeginFrame; set onBeginFrame(FrameCallback? callback); @@ -549,3 +559,33 @@ class SemanticsActionEvent { @override String toString() => 'SemanticsActionEvent($type, view: $viewId, node: $nodeId)'; } + +final class ViewFocusEvent { + const ViewFocusEvent({ + required this.viewId, + required this.state, + required this.direction, + }); + + final int viewId; + + final ViewFocusState state; + + final ViewFocusDirection direction; + + @override + String toString() { + return 'ViewFocusEvent(viewId: $viewId, state: $state, direction: $direction)'; + } +} + +enum ViewFocusState { + unfocused, + focused, +} + +enum ViewFocusDirection { + undefined, + forward, + backwards, +} diff --git a/lib/web_ui/lib/src/engine/platform_dispatcher.dart b/lib/web_ui/lib/src/engine/platform_dispatcher.dart index 2513f9813d93b..5dac8c3a0250d 100644 --- a/lib/web_ui/lib/src/engine/platform_dispatcher.dart +++ b/lib/web_ui/lib/src/engine/platform_dispatcher.dart @@ -217,6 +217,37 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher { } } + @override + ui.ViewFocusChangeCallback? get onViewFocusChange => _onViewFocusChange; + ui.ViewFocusChangeCallback? _onViewFocusChange; + Zone? _onViewFocusChangeZone; + @override + set onViewFocusChange(ui.ViewFocusChangeCallback? callback) { + _onViewFocusChange = callback; + _onViewFocusChangeZone = Zone.current; + } + + // Engine code should use this method instead of the callback directly. + // Otherwise zones won't work properly. + void invokeOnViewFocusChange(ui.ViewFocusEvent viewFocusEvent) { + invoke1( + _onViewFocusChange, + _onViewFocusChangeZone, + viewFocusEvent, + ); + } + + + @override + void requestViewFocusChange({ + required int viewId, + required ui.ViewFocusState state, + required ui.ViewFocusDirection direction, + }) { + // TODO(tugorez): implement this method. At the moment will be a no op call. + } + + /// A set of views which have rendered in the current `onBeginFrame` or /// `onDrawFrame` scope. Set? _viewsRenderedInCurrentFrame; diff --git a/lib/web_ui/test/engine/platform_dispatcher/platform_dispatcher_test.dart b/lib/web_ui/test/engine/platform_dispatcher/platform_dispatcher_test.dart index e55fab725e908..22db57156ca14 100644 --- a/lib/web_ui/test/engine/platform_dispatcher/platform_dispatcher_test.dart +++ b/lib/web_ui/test/engine/platform_dispatcher/platform_dispatcher_test.dart @@ -365,6 +365,43 @@ void testMain() { expect(onMetricsChangedCalled, isFalse); expect(view1.isDisposed, isTrue); }); + + test('invokeOnViewFocusChange calls onViewFocusChange', () { + final EnginePlatformDispatcher dispatcher = EnginePlatformDispatcher(); + final List dispatchedViewFocusEvents = []; + const ui.ViewFocusEvent viewFocusEvent = ui.ViewFocusEvent( + viewId: 0, + state: ui.ViewFocusState.focused, + direction: ui.ViewFocusDirection.undefined, + ); + + dispatcher.onViewFocusChange = dispatchedViewFocusEvents.add; + dispatcher.invokeOnViewFocusChange(viewFocusEvent); + + expect(dispatchedViewFocusEvents, hasLength(1)); + expect(dispatchedViewFocusEvents.single, viewFocusEvent); + }); + + test('invokeOnViewFocusChange preserves the zone', () { + final EnginePlatformDispatcher dispatcher = EnginePlatformDispatcher(); + final Zone zone1 = Zone.current.fork(); + final Zone zone2 = Zone.current.fork(); + const ui.ViewFocusEvent viewFocusEvent = ui.ViewFocusEvent( + viewId: 0, + state: ui.ViewFocusState.focused, + direction: ui.ViewFocusDirection.undefined, + ); + + zone1.runGuarded(() { + dispatcher.onViewFocusChange = (_) { + expect(Zone.current, zone1); + }; + }); + + zone2.runGuarded(() { + dispatcher.invokeOnViewFocusChange(viewFocusEvent); + }); + }); }); }