[ermine] Add support to request focus on child views
This change allows requesting focus on child when:
- upon launch, you don't have click to set keyboard focus.
Keyboard input should immediately route to the launched view.
- when clicked on the view's thumbnail in Overview
- changing focus between views by clicking their title bars.
Some cases still don't work:
- clicking on any surface other than a child view causes the
view to lose focus, even when it's focus ux is still active
- invoking ask bar moves the focus from the view, even when
it's focus ux is still active
These cases will be addressed in subsequent cls.
Bug: 60533, 60530
Test: Add new test for request focus functionality to ErmineStory
Change-Id: I82f70c400f4ca4977d03cd354876bd8fee8a2edc
Reviewed-on: https://fuchsia-review.googlesource.com/c/experiences/+/428355
Commit-Queue: Sanjay Chouksey <sanjayc@google.com>
Reviewed-by: Chase Latta <chaselatta@google.com>
Testability-Review: Chase Latta <chaselatta@google.com>
diff --git a/session_shells/ermine/session/meta/workstation_session.cml b/session_shells/ermine/session/meta/workstation_session.cml
index fa178d5..f99571f 100644
--- a/session_shells/ermine/session/meta/workstation_session.cml
+++ b/session_shells/ermine/session/meta/workstation_session.cml
@@ -32,6 +32,7 @@
"/svc/fuchsia.ui.input3.Keyboard",
"/svc/fuchsia.ui.scenic.Scenic",
"/svc/fuchsia.ui.shortcut.Manager",
+ "/svc/fuchsia.ui.views.ViewRefInstalled",
],
},
],
diff --git a/session_shells/ermine/shell/BUILD.gn b/session_shells/ermine/shell/BUILD.gn
index 852cff0..6bf98a2 100644
--- a/session_shells/ermine/shell/BUILD.gn
+++ b/session_shells/ermine/shell/BUILD.gn
@@ -70,6 +70,7 @@
"src/widgets/story/cluster.dart",
"src/widgets/story/clusters.dart",
"src/widgets/story/fullscreen_story.dart",
+ "src/widgets/story/post_render.dart",
"src/widgets/story/thumbnails.dart",
"src/widgets/story/tile_chrome.dart",
"src/widgets/story/tile_sizer.dart",
diff --git a/session_shells/ermine/shell/lib/src/models/ermine_story.dart b/session_shells/ermine/shell/lib/src/models/ermine_story.dart
index 3830c78..87de475 100644
--- a/session_shells/ermine/shell/lib/src/models/ermine_story.dart
+++ b/session_shells/ermine/shell/lib/src/models/ermine_story.dart
@@ -9,10 +9,13 @@
import 'package:fuchsia_scenic_flutter/child_view_connection.dart';
import 'package:fuchsia_services/services.dart';
import 'package:uuid/uuid.dart';
+import 'package:zircon/zircon.dart';
import '../utils/presenter.dart';
import '../utils/suggestion.dart';
+const double kFrameTime60FpsInMilliseconds = 16.3333;
+
/// A function which can be used to launch the suggestion.
typedef LaunchSuggestion = Future<void> Function(
Suggestion, ElementControllerProxy);
@@ -101,7 +104,17 @@
_elementController?.ctrl?.close();
}
- void focus() => onChange?.call(this..focused = true);
+ /// Sets the focus state on this story.
+ ///
+ /// Also invokes [onChange] callback and request scenic to transfer input
+ /// focus to the associated [viewRef].
+ ///
+ /// Takes an optional [ViewRefInstalledProxy] to allow passing in a mocked
+ /// instance during test.
+ void focus([ViewRefInstalledProxy viewRefInstalled]) {
+ onChange?.call(this..focused = true);
+ requestFocus(viewRefInstalled);
+ }
void maximize() => onChange?.call(this..fullscreen = true);
@@ -139,4 +152,35 @@
viewController = vc;
viewController?.didPresent();
}
+
+ /// Requests focus to be transfered to this view given it's [viewRef].
+ Future<void> requestFocus([ViewRefInstalledProxy viewRefInstalled]) async {
+ // [requestFocus] is called for 'every' post render of ChildView widget,
+ // even when that child view is not focused. Skip focusing those views here.
+ if (childViewConnection == null || !focused) {
+ return;
+ }
+
+ // TODO(60528): Wait until child view is connected to view tree. Until the
+ // view connected event is plumbed to [ChildViewConnection], we use a flaky
+ // delay of 3 frames before attempting to request focus for the child view.
+ await Future.delayed(Duration(
+ milliseconds: (kFrameTime60FpsInMilliseconds * 3).toInt(),
+ ));
+
+ final viewRefService = viewRefInstalled ?? ViewRefInstalledProxy();
+ if (viewRefInstalled == null) {
+ StartupContext.fromStartupInfo()
+ .incoming
+ .connectToService(viewRefService);
+ }
+ try {
+ // Wait for [viewRef] to be attached to the view tree.
+ final eventPair = viewRef.reference.duplicate(ZX.RIGHT_SAME_RIGHTS);
+ assert(eventPair.isValid);
+
+ await viewRefService.watch(ViewRef(reference: eventPair));
+ childViewConnection.requestFocus();
+ } on Error catch (_) {}
+ }
}
diff --git a/session_shells/ermine/shell/lib/src/utils/presenter.dart b/session_shells/ermine/shell/lib/src/utils/presenter.dart
index f388ad0..7daa2f2 100644
--- a/session_shells/ermine/shell/lib/src/utils/presenter.dart
+++ b/session_shells/ermine/shell/lib/src/utils/presenter.dart
@@ -61,8 +61,7 @@
if (viewHolderToken != null) {
final connection = ChildViewConnection(
viewHolderToken,
- onAvailable: (_) {},
- onUnavailable: (_) {},
+ viewRef: viewSpec.viewRef,
);
onPresent(
connection,
diff --git a/session_shells/ermine/shell/lib/src/widgets/story/cluster.dart b/session_shells/ermine/shell/lib/src/widgets/story/cluster.dart
index 97250d7..67e545f 100644
--- a/session_shells/ermine/shell/lib/src/widgets/story/cluster.dart
+++ b/session_shells/ermine/shell/lib/src/widgets/story/cluster.dart
@@ -8,6 +8,7 @@
import '../../models/cluster_model.dart';
import '../../models/ermine_story.dart';
+import 'post_render.dart';
import 'tile_chrome.dart';
import 'tile_sizer.dart';
import 'tile_tab.dart';
@@ -66,7 +67,10 @@
showTitle: !custom,
focused: story.focused,
//TODO(47796) show a placeholder until the view loads
- child: ChildView(connection: story.childViewConnection),
+ child: PostRender(
+ child: ChildView(connection: story.childViewConnection),
+ onRender: story.requestFocus,
+ ),
onTap: story.focus,
onDelete: story.delete,
onFullscreen: story.maximize,
diff --git a/session_shells/ermine/shell/lib/src/widgets/story/fullscreen_story.dart b/session_shells/ermine/shell/lib/src/widgets/story/fullscreen_story.dart
index d5c4fce..3a18ac8 100644
--- a/session_shells/ermine/shell/lib/src/widgets/story/fullscreen_story.dart
+++ b/session_shells/ermine/shell/lib/src/widgets/story/fullscreen_story.dart
@@ -7,6 +7,7 @@
import '../../models/app_model.dart';
import '../../utils/styles.dart';
+import 'post_render.dart';
import 'tile_chrome.dart';
/// Defines a widget to display a story fullscreen.
@@ -34,7 +35,10 @@
name: story.name,
focused: story.focused,
fullscreen: true,
- child: ChildView(connection: story.childViewConnection),
+ child: PostRender(
+ child: ChildView(connection: story.childViewConnection),
+ onRender: story.requestFocus,
+ ),
onDelete: story.delete,
onMinimize: story.restore,
onFullscreen: story.maximize,
diff --git a/session_shells/ermine/shell/lib/src/widgets/story/post_render.dart b/session_shells/ermine/shell/lib/src/widgets/story/post_render.dart
new file mode 100644
index 0000000..440c501
--- /dev/null
+++ b/session_shells/ermine/shell/lib/src/widgets/story/post_render.dart
@@ -0,0 +1,22 @@
+// Copyright 2020 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+
+/// Defines a class to receive a callback when the supplied [child] widget
+/// is rendered.
+///
+/// The [onRender] callback is invoked on subsequent frame after every [build].
+class PostRender extends StatelessWidget {
+ final Widget child;
+ final VoidCallback onRender;
+
+ const PostRender({this.child, this.onRender});
+
+ @override
+ Widget build(BuildContext context) {
+ WidgetsBinding.instance.addPostFrameCallback((_) => onRender?.call());
+ return child;
+ }
+}
diff --git a/session_shells/ermine/shell/meta/ermine.cmx b/session_shells/ermine/shell/meta/ermine.cmx
index c1224c6..693152d 100644
--- a/session_shells/ermine/shell/meta/ermine.cmx
+++ b/session_shells/ermine/shell/meta/ermine.cmx
@@ -33,7 +33,8 @@
"fuchsia.ui.policy.Presentation",
"fuchsia.ui.policy.Presenter",
"fuchsia.ui.scenic.Scenic",
- "fuchsia.ui.shortcut.Registry"
+ "fuchsia.ui.shortcut.Registry",
+ "fuchsia.ui.views.ViewRefInstalled"
]
}
}
diff --git a/session_shells/ermine/shell/test/ermine_story_test.dart b/session_shells/ermine/shell/test/ermine_story_test.dart
index c1d7e92..1a0306f 100644
--- a/session_shells/ermine/shell/test/ermine_story_test.dart
+++ b/session_shells/ermine/shell/test/ermine_story_test.dart
@@ -8,8 +8,11 @@
import 'package:ermine/src/models/ermine_story.dart';
import 'package:ermine/src/utils/presenter.dart';
import 'package:ermine/src/utils/suggestion.dart';
+import 'package:fidl_fuchsia_ui_views/fidl_async.dart';
+import 'package:fuchsia_scenic_flutter/child_view_connection.dart';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
+import 'package:zircon/zircon.dart';
void main() {
test('from suggestion sets id and title', () {
@@ -50,6 +53,58 @@
expect(didCallDelete, isTrue);
verify(viewController.close()).called(1);
});
+
+ test('Focus ErmineStory', () async {
+ final eventPair = MockEventPair();
+ final eventPairDup = MockEventPair();
+ final viewRef = MockViewRef();
+ final viewRefInstalled = MockViewRefInstalled();
+ final childViewConnection = MockChildViewConnection();
+
+ when(viewRef.reference).thenReturn(eventPair);
+ when(eventPair.duplicate(ZX.RIGHT_SAME_RIGHTS)).thenReturn(eventPairDup);
+ when(eventPairDup.isValid).thenReturn(true);
+
+ bool onChangeCalled = false;
+ final completer = Completer<bool>();
+ TestErmineStory(
+ onChange: (_) => onChangeCalled = true,
+ requestFocusCompleter: completer,
+ )
+ ..viewRef = viewRef
+ ..childViewConnectionNotifier.value = childViewConnection
+ ..focus(viewRefInstalled);
+
+ expect(onChangeCalled, true);
+ await completer.future;
+
+ verify(viewRefInstalled.watch(ViewRef(reference: eventPairDup))).called(1);
+ verify(childViewConnection.requestFocus()).called(1);
+ });
+}
+
+class TestErmineStory extends ErmineStory {
+ final Completer<bool> requestFocusCompleter;
+
+ TestErmineStory({
+ void Function(ErmineStory) onChange,
+ this.requestFocusCompleter,
+ }) : super(id: 'id', onChange: onChange);
+
+ @override
+ Future<void> requestFocus([ViewRefInstalledProxy viewRefInstalled]) async {
+ final result = await super.requestFocus(viewRefInstalled);
+ requestFocusCompleter.complete(true);
+ return result;
+ }
}
class MockViewControllerImpl extends Mock implements ViewControllerImpl {}
+
+class MockChildViewConnection extends Mock implements ChildViewConnection {}
+
+class MockViewRefInstalled extends Mock implements ViewRefInstalledProxy {}
+
+class MockViewRef extends Mock implements ViewRef {}
+
+class MockEventPair extends Mock implements EventPair {}