From 5c59a1d16abfdc8b5497cc9ddbdce064d6c25199 Mon Sep 17 00:00:00 2001 From: Emmanuel Garcia Date: Thu, 10 Jun 2021 16:04:01 -0700 Subject: [PATCH] Allow Flutter focus to interop with Android view hierarchies (#26602) --- .../mutatorsstack/FlutterMutatorView.java | 58 ++++++++- .../systemchannels/TextInputChannel.java | 16 ++- .../plugin/editing/TextInputPlugin.java | 64 ++++++---- .../platform/PlatformViewsController.java | 15 +++ .../mutatorsstack/FlutterMutatorViewTest.java | 119 ++++++++++++++++++ 5 files changed, 243 insertions(+), 29 deletions(-) diff --git a/shell/platform/android/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorView.java b/shell/platform/android/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorView.java index 25089bbc4d..7d89c83ada 100644 --- a/shell/platform/android/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorView.java +++ b/shell/platform/android/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorView.java @@ -1,13 +1,20 @@ package io.flutter.embedding.engine.mutatorsstack; +import static android.view.View.OnFocusChangeListener; + import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Canvas; import android.graphics.Matrix; import android.graphics.Path; import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; import android.widget.FrameLayout; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import io.flutter.embedding.android.AndroidTouchProcessor; /** @@ -31,7 +38,7 @@ public class FlutterMutatorView extends FrameLayout { public FlutterMutatorView( @NonNull Context context, float screenDensity, - @NonNull AndroidTouchProcessor androidTouchProcessor) { + @Nullable AndroidTouchProcessor androidTouchProcessor) { super(context, null); this.screenDensity = screenDensity; this.androidTouchProcessor = androidTouchProcessor; @@ -39,9 +46,52 @@ public class FlutterMutatorView extends FrameLayout { /** Initialize the FlutterMutatorView. */ public FlutterMutatorView(@NonNull Context context) { - super(context, null); - this.screenDensity = 1; - this.androidTouchProcessor = null; + this(context, 1, /* androidTouchProcessor=*/ null); + } + + /** + * Determines if the current view or any descendant view has focus. + * + * @param root The root view. + * @return True if the current view or any descendant view has focus. + */ + @VisibleForTesting + public static boolean childHasFocus(@Nullable View root) { + if (root == null) { + return false; + } + if (root.hasFocus()) { + return true; + } + if (root instanceof ViewGroup) { + final ViewGroup viewGroup = (ViewGroup) root; + for (int idx = 0; idx < viewGroup.getChildCount(); idx++) { + if (childHasFocus(viewGroup.getChildAt(idx))) { + return true; + } + } + } + return false; + } + + /** + * Adds a focus change listener that notifies when the current view or any of its descendant views + * have received focus. + * + * @param focusListener The focus listener. + */ + public void addOnFocusChangeListener(@NonNull OnFocusChangeListener focusListener) { + final View mutatorView = this; + final ViewTreeObserver observer = this.getViewTreeObserver(); + if (observer.isAlive()) { + observer.addOnGlobalFocusChangeListener( + new ViewTreeObserver.OnGlobalFocusChangeListener() { + @Override + public void onGlobalFocusChanged(View oldFocus, View newFocus) { + focusListener.onFocusChange(mutatorView, childHasFocus(mutatorView)); + } + }); + } } /** diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java index 7715f3a0c0..ce86f02b11 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java @@ -82,8 +82,16 @@ public class TextInputChannel { result.success(null); break; case "TextInput.setPlatformViewClient": - final int id = (int) args; - textInputMethodHandler.setPlatformViewClient(id); + try { + final JSONObject arguments = (JSONObject) args; + final int platformViewId = arguments.getInt("platformViewId"); + final boolean usesVirtualDisplay = + arguments.optBoolean("usesVirtualDisplay", false); + textInputMethodHandler.setPlatformViewClient(platformViewId, usesVirtualDisplay); + result.success(null); + } catch (JSONException exception) { + result.error("error", exception.getMessage(), null); + } break; case "TextInput.setEditingState": try { @@ -360,8 +368,10 @@ public class TextInputChannel { * different client is set. * * @param id the ID of the platform view to be set as a text input client. + * @param usesVirtualDisplay True if the platform view uses a virtual display, false if it uses + * hybrid composition. */ - void setPlatformViewClient(int id); + void setPlatformViewClient(int id, boolean usesVirtualDisplay); /** * Sets the size and the transform matrix of the current text input client. diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index 06951a8d64..c07f92462c 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -102,7 +102,11 @@ public class TextInputPlugin implements ListenableEditingState.EditingStateWatch @Override public void hide() { - hideTextInput(mView); + if (inputTarget.type == InputTarget.Type.HC_PLATFORM_VIEW) { + notifyViewExited(); + } else { + hideTextInput(mView); + } } @Override @@ -129,8 +133,8 @@ public class TextInputPlugin implements ListenableEditingState.EditingStateWatch } @Override - public void setPlatformViewClient(int platformViewId) { - setPlatformViewTextInputClient(platformViewId); + public void setPlatformViewClient(int platformViewId, boolean usesVirtualDisplay) { + setPlatformViewTextInputClient(platformViewId, usesVirtualDisplay); } @Override @@ -189,7 +193,7 @@ public class TextInputPlugin implements ListenableEditingState.EditingStateWatch * display to another. */ public void lockPlatformViewInputConnection() { - if (inputTarget.type == InputTarget.Type.PLATFORM_VIEW) { + if (inputTarget.type == InputTarget.Type.VD_PLATFORM_VIEW) { isInputConnectionLocked = true; } } @@ -284,7 +288,11 @@ public class TextInputPlugin implements ListenableEditingState.EditingStateWatch return null; } - if (inputTarget.type == InputTarget.Type.PLATFORM_VIEW) { + if (inputTarget.type == InputTarget.Type.HC_PLATFORM_VIEW) { + return null; + } + + if (inputTarget.type == InputTarget.Type.VD_PLATFORM_VIEW) { if (isInputConnectionLocked) { return lastInputConnection; } @@ -342,9 +350,12 @@ public class TextInputPlugin implements ListenableEditingState.EditingStateWatch * input connection. */ public void clearPlatformViewClient(int platformViewId) { - if (inputTarget.type == InputTarget.Type.PLATFORM_VIEW && inputTarget.id == platformViewId) { + if ((inputTarget.type == InputTarget.Type.VD_PLATFORM_VIEW + || inputTarget.type == InputTarget.Type.HC_PLATFORM_VIEW) + && inputTarget.id == platformViewId) { inputTarget = new InputTarget(InputTarget.Type.NO_TARGET, 0); - hideTextInput(mView); + notifyViewExited(); + mImm.hideSoftInputFromWindow(mView.getApplicationWindowToken(), 0); mImm.restartInput(mView); mRestartInputPending = false; } @@ -361,8 +372,8 @@ public class TextInputPlugin implements ListenableEditingState.EditingStateWatch private void hideTextInput(View view) { notifyViewExited(); - // Note: a race condition may lead to us hiding the keyboard here just after a platform view has - // shown it. + // Note: when a virtual display is used, a race condition may lead to us hiding the keyboard + // here just after a platform view has shown it. // This can only potentially happen when switching focus from a Flutter text field to a platform // view's text // field(by text field here I mean anything that keeps the keyboard open). @@ -393,16 +404,20 @@ public class TextInputPlugin implements ListenableEditingState.EditingStateWatch mEditable.addEditingStateListener(this); } - private void setPlatformViewTextInputClient(int platformViewId) { - // We need to make sure that the Flutter view is focused so that no imm operations get short - // circuited. - // Not asking for focus here specifically manifested in a but on API 28 devices where the - // platform view's - // request to show a keyboard was ignored. - mView.requestFocus(); - inputTarget = new InputTarget(InputTarget.Type.PLATFORM_VIEW, platformViewId); - mImm.restartInput(mView); - mRestartInputPending = false; + private void setPlatformViewTextInputClient(int platformViewId, boolean usesVirtualDisplay) { + if (usesVirtualDisplay) { + // We need to make sure that the Flutter view is focused so that no imm operations get short + // circuited. + // Not asking for focus here specifically manifested in a but on API 28 devices where the + // platform view's request to show a keyboard was ignored. + mView.requestFocus(); + inputTarget = new InputTarget(InputTarget.Type.VD_PLATFORM_VIEW, platformViewId); + mImm.restartInput(mView); + mRestartInputPending = false; + } else { + inputTarget = new InputTarget(InputTarget.Type.HC_PLATFORM_VIEW, platformViewId); + lastInputConnection = null; + } } private static boolean composingChanged( @@ -493,7 +508,8 @@ public class TextInputPlugin implements ListenableEditingState.EditingStateWatch @VisibleForTesting void clearTextInputClient() { - if (inputTarget.type == InputTarget.Type.PLATFORM_VIEW) { + if (inputTarget.type == InputTarget.Type.VD_PLATFORM_VIEW) { + // This only applies to platform views that use a virtual display. // Focus changes in the framework tree have no guarantees on the order focus nodes are // notified. A node // that lost focus may be notified before or after a node that gained focus. @@ -530,8 +546,12 @@ public class TextInputPlugin implements ListenableEditingState.EditingStateWatch // InputConnection is managed by the TextInputPlugin, and events are forwarded to the Flutter // framework. FRAMEWORK_CLIENT, - // InputConnection is managed by an embedded platform view. - PLATFORM_VIEW + // InputConnection is managed by an embedded platform view that is backed by a virtual + // display (VD). + VD_PLATFORM_VIEW, + // InputConnection is managed by an embedded platform view that is embeded in the Android view + // hierarchy, and uses hybrid composition (HC). + HC_PLATFORM_VIEW, } public InputTarget(@NonNull Type type, int id) { diff --git a/shell/platform/android/io/flutter/plugin/platform/PlatformViewsController.java b/shell/platform/android/io/flutter/plugin/platform/PlatformViewsController.java index d44d5fb8c5..b60a3b66a5 100644 --- a/shell/platform/android/io/flutter/plugin/platform/PlatformViewsController.java +++ b/shell/platform/android/io/flutter/plugin/platform/PlatformViewsController.java @@ -337,6 +337,11 @@ public class PlatformViewsController implements PlatformViewsAccessibilityDelega @Override public void clearFocus(int viewId) { + final PlatformView platformView = platformViews.get(viewId); + if (platformView != null) { + platformView.getView().clearFocus(); + return; + } ensureValidAndroidVersion(Build.VERSION_CODES.KITKAT_WATCH); View view = vdControllers.get(viewId).getView(); view.clearFocus(); @@ -732,6 +737,16 @@ public class PlatformViewsController implements PlatformViewsAccessibilityDelega final FlutterMutatorView parentView = new FlutterMutatorView( context, context.getResources().getDisplayMetrics().density, androidTouchProcessor); + + parentView.addOnFocusChangeListener( + (view, hasFocus) -> { + if (hasFocus) { + platformViewsChannel.invokeViewFocused(viewId); + } else { + textInputPlugin.clearPlatformViewClient(viewId); + } + }); + platformViewParent.put(viewId, parentView); parentView.addView(platformView.getView()); ((FlutterView) flutterView).addView(parentView); diff --git a/shell/platform/android/test/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorViewTest.java b/shell/platform/android/test/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorViewTest.java index 35f412058b..37b9023e74 100644 --- a/shell/platform/android/test/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorViewTest.java +++ b/shell/platform/android/test/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorViewTest.java @@ -1,10 +1,14 @@ package io.flutter.embedding.engine.mutatorsstack; +import static android.view.View.OnFocusChangeListener; import static junit.framework.TestCase.*; import static org.mockito.Mockito.*; import android.graphics.Matrix; import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; import io.flutter.embedding.android.AndroidTouchProcessor; import org.junit.Test; import org.junit.runner.RunWith; @@ -76,4 +80,119 @@ public class FlutterMutatorViewTest { assertTrue(matrixCaptor.getValue().equals(screenMatrix)); } } + + @Test + public void childHasFocus_rootHasFocus() { + final View rootView = mock(View.class); + when(rootView.hasFocus()).thenReturn(true); + assertTrue(FlutterMutatorView.childHasFocus(rootView)); + } + + @Test + public void childHasFocus_rootDoesNotHaveFocus() { + final View rootView = mock(View.class); + when(rootView.hasFocus()).thenReturn(false); + assertFalse(FlutterMutatorView.childHasFocus(rootView)); + } + + @Test + public void childHasFocus_rootIsNull() { + assertFalse(FlutterMutatorView.childHasFocus(null)); + } + + @Test + public void childHasFocus_childHasFocus() { + final View childView = mock(View.class); + when(childView.hasFocus()).thenReturn(true); + + final ViewGroup rootView = mock(ViewGroup.class); + when(rootView.getChildCount()).thenReturn(1); + when(rootView.getChildAt(0)).thenReturn(childView); + + assertTrue(FlutterMutatorView.childHasFocus(rootView)); + } + + @Test + public void childHasFocus_childDoesNotHaveFocus() { + final View childView = mock(View.class); + when(childView.hasFocus()).thenReturn(false); + + final ViewGroup rootView = mock(ViewGroup.class); + when(rootView.getChildCount()).thenReturn(1); + when(rootView.getChildAt(0)).thenReturn(childView); + + assertFalse(FlutterMutatorView.childHasFocus(rootView)); + } + + @Test + public void focusChangeListener_hasFocus() { + final ViewTreeObserver viewTreeObserver = mock(ViewTreeObserver.class); + when(viewTreeObserver.isAlive()).thenReturn(true); + + final FlutterMutatorView view = + new FlutterMutatorView(RuntimeEnvironment.systemContext) { + @Override + public ViewTreeObserver getViewTreeObserver() { + return viewTreeObserver; + } + + @Override + public boolean hasFocus() { + return true; + } + }; + + final OnFocusChangeListener focusListener = mock(OnFocusChangeListener.class); + view.addOnFocusChangeListener(focusListener); + + final ArgumentCaptor focusListenerCaptor = + ArgumentCaptor.forClass(ViewTreeObserver.OnGlobalFocusChangeListener.class); + verify(viewTreeObserver).addOnGlobalFocusChangeListener(focusListenerCaptor.capture()); + + focusListenerCaptor.getValue().onGlobalFocusChanged(null, null); + verify(focusListener).onFocusChange(view, true); + } + + @Test + public void focusChangeListener_doesNotHaveFocus() { + final ViewTreeObserver viewTreeObserver = mock(ViewTreeObserver.class); + when(viewTreeObserver.isAlive()).thenReturn(true); + + final FlutterMutatorView view = + new FlutterMutatorView(RuntimeEnvironment.systemContext) { + @Override + public ViewTreeObserver getViewTreeObserver() { + return viewTreeObserver; + } + + @Override + public boolean hasFocus() { + return false; + } + }; + + final OnFocusChangeListener focusListener = mock(OnFocusChangeListener.class); + view.addOnFocusChangeListener(focusListener); + + final ArgumentCaptor focusListenerCaptor = + ArgumentCaptor.forClass(ViewTreeObserver.OnGlobalFocusChangeListener.class); + verify(viewTreeObserver).addOnGlobalFocusChangeListener(focusListenerCaptor.capture()); + + focusListenerCaptor.getValue().onGlobalFocusChanged(null, null); + verify(focusListener).onFocusChange(view, false); + } + + @Test + public void focusChangeListener_viewTreeObserverIsAliveFalseDoesNotThrow() { + final FlutterMutatorView view = + new FlutterMutatorView(RuntimeEnvironment.systemContext) { + @Override + public ViewTreeObserver getViewTreeObserver() { + final ViewTreeObserver viewTreeObserver = mock(ViewTreeObserver.class); + when(viewTreeObserver.isAlive()).thenReturn(false); + return viewTreeObserver; + } + }; + view.addOnFocusChangeListener(mock(OnFocusChangeListener.class)); + } } -- GitLab