summaryrefslogtreecommitdiff
path: root/core/java/android/inputmethodservice
diff options
context:
space:
mode:
authorYohei Yukawa <yukawa@google.com>2022-01-24 19:00:42 +0000
committerAndroid (Google) Code Review <android-gerrit@google.com>2022-01-24 19:00:42 +0000
commitdcd02645845f4b174ecd0892010eaed0728e3e27 (patch)
treeb7b1938ae82d51d1105cff38ec8deedb5412a47e /core/java/android/inputmethodservice
parent2b4f585211452c56ec5496c032b7bb9d586f29a6 (diff)
parentff7b453ca8e23b4ef75ba2c3f5becaf511cb07d3 (diff)
Merge "Minimum impl of nav buttons rendered by IME"
Diffstat (limited to 'core/java/android/inputmethodservice')
-rw-r--r--core/java/android/inputmethodservice/InputMethodService.java9
-rw-r--r--core/java/android/inputmethodservice/NavigationBarController.java328
-rw-r--r--core/java/android/inputmethodservice/navigationbar/ButtonDispatcher.java302
-rw-r--r--core/java/android/inputmethodservice/navigationbar/ButtonInterface.java29
-rw-r--r--core/java/android/inputmethodservice/navigationbar/DeadZone.java203
-rw-r--r--core/java/android/inputmethodservice/navigationbar/KeyButtonDrawable.java483
-rw-r--r--core/java/android/inputmethodservice/navigationbar/KeyButtonRipple.java525
-rw-r--r--core/java/android/inputmethodservice/navigationbar/KeyButtonView.java370
-rw-r--r--core/java/android/inputmethodservice/navigationbar/NavigationBarConstants.java62
-rw-r--r--core/java/android/inputmethodservice/navigationbar/NavigationBarFrame.java62
-rw-r--r--core/java/android/inputmethodservice/navigationbar/NavigationBarInflaterView.java429
-rw-r--r--core/java/android/inputmethodservice/navigationbar/NavigationBarUtils.java42
-rw-r--r--core/java/android/inputmethodservice/navigationbar/NavigationBarView.java380
-rw-r--r--core/java/android/inputmethodservice/navigationbar/NavigationHandle.java57
-rw-r--r--core/java/android/inputmethodservice/navigationbar/ReverseLinearLayout.java172
15 files changed, 3453 insertions, 0 deletions
diff --git a/core/java/android/inputmethodservice/InputMethodService.java b/core/java/android/inputmethodservice/InputMethodService.java
index 09d50850788b..5d2d8eafb3a8 100644
--- a/core/java/android/inputmethodservice/InputMethodService.java
+++ b/core/java/android/inputmethodservice/InputMethodService.java
@@ -469,6 +469,10 @@ public class InputMethodService extends AbstractInputMethodService {
InputMethodManager mImm;
private InputMethodPrivilegedOperations mPrivOps = new InputMethodPrivilegedOperations();
+ @NonNull
+ private final NavigationBarController mNavigationBarController =
+ new NavigationBarController(this);
+
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023)
int mTheme = 0;
@@ -611,6 +615,7 @@ public class InputMethodService extends AbstractInputMethodService {
info.touchableRegion.set(mTmpInsets.touchableRegion);
info.setTouchableInsets(mTmpInsets.touchableInsets);
}
+ mNavigationBarController.updateTouchableInsets(mTmpInsets, info);
if (mInputFrame != null) {
setImeExclusionRect(mTmpInsets.visibleTopInsets);
@@ -1534,6 +1539,7 @@ public class InputMethodService extends AbstractInputMethodService {
mCandidatesVisibility = getCandidatesHiddenVisibility();
mCandidatesFrame.setVisibility(mCandidatesVisibility);
mInputFrame.setVisibility(View.GONE);
+ mNavigationBarController.onViewInitialized();
Trace.traceEnd(TRACE_TAG_WINDOW_MANAGER);
}
@@ -1543,6 +1549,7 @@ public class InputMethodService extends AbstractInputMethodService {
mRootView.getViewTreeObserver().removeOnComputeInternalInsetsListener(
mInsetsComputer);
doFinishInput();
+ mNavigationBarController.onDestroy();
mWindow.dismissForDestroyIfNecessary();
if (mSettingsObserver != null) {
mSettingsObserver.unregister();
@@ -2451,6 +2458,7 @@ public class InputMethodService extends AbstractInputMethodService {
setImeWindowStatus(nextImeWindowStatus, mBackDisposition);
}
+ mNavigationBarController.onWindowShown();
// compute visibility
onWindowShown();
mWindowVisible = true;
@@ -3656,6 +3664,7 @@ public class InputMethodService extends AbstractInputMethodService {
+ " touchableInsets=" + mTmpInsets.touchableInsets
+ " touchableRegion=" + mTmpInsets.touchableRegion);
p.println(" mSettingsObserver=" + mSettingsObserver);
+ p.println(" mNavigationBarController=" + mNavigationBarController.toDebugString());
}
private final ImeTracing.ServiceDumper mDumper = new ImeTracing.ServiceDumper() {
diff --git a/core/java/android/inputmethodservice/NavigationBarController.java b/core/java/android/inputmethodservice/NavigationBarController.java
new file mode 100644
index 000000000000..7295b72c276b
--- /dev/null
+++ b/core/java/android/inputmethodservice/NavigationBarController.java
@@ -0,0 +1,328 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.inputmethodservice;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.app.StatusBarManager;
+import android.content.res.Resources;
+import android.graphics.Insets;
+import android.graphics.Rect;
+import android.graphics.Region;
+import android.inputmethodservice.navigationbar.NavigationBarFrame;
+import android.inputmethodservice.navigationbar.NavigationBarView;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
+import android.view.Window;
+import android.view.WindowInsets;
+import android.view.WindowManagerPolicyConstants;
+import android.widget.FrameLayout;
+
+import java.util.Objects;
+
+/**
+ * This class hides details behind {@link InputMethodService#canImeRenderGesturalNavButtons()} from
+ * {@link InputMethodService}.
+ *
+ * <p>All the package-private methods are no-op when
+ * {@link InputMethodService#canImeRenderGesturalNavButtons()} returns {@code false}.</p>
+ */
+final class NavigationBarController {
+
+ private interface Callback {
+ default void updateTouchableInsets(@NonNull InputMethodService.Insets originalInsets,
+ @NonNull ViewTreeObserver.InternalInsetsInfo dest) {
+ }
+
+ default void onViewInitialized() {
+ }
+
+ default void onWindowShown() {
+ }
+
+ default void onDestroy() {
+ }
+
+ default String toDebugString() {
+ return "No-op implementation";
+ }
+
+ Callback NOOP = new Callback() {
+ };
+ }
+
+ private final Callback mImpl;
+
+ NavigationBarController(@NonNull InputMethodService inputMethodService) {
+ mImpl = InputMethodService.canImeRenderGesturalNavButtons()
+ ? new Impl(inputMethodService) : Callback.NOOP;
+ }
+
+ void updateTouchableInsets(@NonNull InputMethodService.Insets originalInsets,
+ @NonNull ViewTreeObserver.InternalInsetsInfo dest) {
+ mImpl.updateTouchableInsets(originalInsets, dest);
+ }
+
+ void onViewInitialized() {
+ mImpl.onViewInitialized();
+ }
+
+ void onWindowShown() {
+ mImpl.onWindowShown();
+ }
+
+ void onDestroy() {
+ mImpl.onDestroy();
+ }
+
+ String toDebugString() {
+ return mImpl.toDebugString();
+ }
+
+ private static final class Impl implements Callback {
+ @NonNull
+ private final InputMethodService mService;
+
+ private boolean mDestroyed = false;
+
+ private boolean mRenderGesturalNavButtons;
+
+ @Nullable
+ private NavigationBarFrame mNavigationBarFrame;
+ @Nullable
+ Insets mLastInsets;
+
+ Impl(@NonNull InputMethodService inputMethodService) {
+ mService = inputMethodService;
+ }
+
+ @Nullable
+ private Insets getSystemInsets() {
+ if (mService.mWindow == null) {
+ return null;
+ }
+ final View decorView = mService.mWindow.getWindow().getDecorView();
+ if (decorView == null) {
+ return null;
+ }
+ final WindowInsets windowInsets = decorView.getRootWindowInsets();
+ if (windowInsets == null) {
+ return null;
+ }
+ final Insets stableBarInsets =
+ windowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars());
+ return Insets.min(windowInsets.getInsets(WindowInsets.Type.systemBars()
+ | WindowInsets.Type.displayCutout()), stableBarInsets);
+ }
+
+ private void installNavigationBarFrameIfNecessary() {
+ if (!mRenderGesturalNavButtons) {
+ return;
+ }
+ final View rawDecorView = mService.mWindow.getWindow().getDecorView();
+ if (!(rawDecorView instanceof ViewGroup)) {
+ return;
+ }
+ final ViewGroup decorView = (ViewGroup) rawDecorView;
+ mNavigationBarFrame = decorView.findViewByPredicate(
+ NavigationBarFrame.class::isInstance);
+ final Insets systemInsets = getSystemInsets();
+ if (mNavigationBarFrame == null) {
+ mNavigationBarFrame = new NavigationBarFrame(mService);
+ LayoutInflater.from(mService).inflate(
+ com.android.internal.R.layout.input_method_navigation_bar,
+ mNavigationBarFrame);
+ if (systemInsets != null) {
+ decorView.addView(mNavigationBarFrame, new FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ systemInsets.bottom, Gravity.BOTTOM));
+ mLastInsets = systemInsets;
+ } else {
+ decorView.addView(mNavigationBarFrame);
+ }
+ final NavigationBarView navigationBarView = mNavigationBarFrame.findViewByPredicate(
+ NavigationBarView.class::isInstance);
+ if (navigationBarView != null) {
+ // TODO(b/213337792): Support InputMethodService#setBackDisposition().
+ // TODO(b/213337792): Set NAVIGATION_HINT_IME_SHOWN only when necessary.
+ final int hints = StatusBarManager.NAVIGATION_HINT_BACK_ALT
+ | StatusBarManager.NAVIGATION_HINT_IME_SHOWN;
+ navigationBarView.setNavigationIconHints(hints);
+ }
+ } else {
+ mNavigationBarFrame.setLayoutParams(new FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, systemInsets.bottom, Gravity.BOTTOM));
+ mLastInsets = systemInsets;
+ }
+
+ mNavigationBarFrame.setBackground(null);
+ }
+
+ @Override
+ public void updateTouchableInsets(@NonNull InputMethodService.Insets originalInsets,
+ @NonNull ViewTreeObserver.InternalInsetsInfo dest) {
+ if (!mRenderGesturalNavButtons || mNavigationBarFrame == null
+ || mService.isExtractViewShown()) {
+ return;
+ }
+
+ final Insets systemInsets = getSystemInsets();
+ if (systemInsets != null) {
+ final Window window = mService.mWindow.getWindow();
+ final View decor = window.getDecorView();
+ Region touchableRegion = null;
+ final View inputFrame = mService.mInputFrame;
+ switch (originalInsets.touchableInsets) {
+ case ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_FRAME:
+ if (inputFrame.getVisibility() == View.VISIBLE) {
+ touchableRegion = new Region(inputFrame.getLeft(),
+ inputFrame.getTop(), inputFrame.getRight(),
+ inputFrame.getBottom());
+ }
+ break;
+ case ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_CONTENT:
+ if (inputFrame.getVisibility() == View.VISIBLE) {
+ touchableRegion = new Region(inputFrame.getLeft(),
+ originalInsets.contentTopInsets, inputFrame.getRight(),
+ inputFrame.getBottom());
+ }
+ break;
+ case ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_VISIBLE:
+ if (inputFrame.getVisibility() == View.VISIBLE) {
+ touchableRegion = new Region(inputFrame.getLeft(),
+ originalInsets.visibleTopInsets, inputFrame.getRight(),
+ inputFrame.getBottom());
+ }
+ break;
+ case ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION:
+ touchableRegion = new Region();
+ touchableRegion.set(originalInsets.touchableRegion);
+ break;
+ }
+ final Rect navBarRect = new Rect(decor.getLeft(),
+ decor.getBottom() - systemInsets.bottom,
+ decor.getRight(), decor.getBottom());
+ if (touchableRegion == null) {
+ touchableRegion = new Region(navBarRect);
+ } else {
+ touchableRegion.union(navBarRect);
+ }
+
+ dest.touchableRegion.set(touchableRegion);
+ dest.setTouchableInsets(
+ ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION);
+
+ // TODO(b/205803355): See if we can use View#OnLayoutChangeListener().
+ // TODO(b/205803355): See if we can replace DecorView#mNavigationColorViewState.view
+ boolean zOrderChanged = false;
+ if (decor instanceof ViewGroup) {
+ ViewGroup decorGroup = (ViewGroup) decor;
+ final View navbarBackgroundView = window.getNavigationBarBackgroundView();
+ zOrderChanged = navbarBackgroundView != null
+ && decorGroup.indexOfChild(navbarBackgroundView)
+ > decorGroup.indexOfChild(mNavigationBarFrame);
+ }
+ final boolean insetChanged = !Objects.equals(systemInsets, mLastInsets);
+ if (zOrderChanged || insetChanged) {
+ final NavigationBarFrame that = mNavigationBarFrame;
+ that.post(() -> {
+ if (!that.isAttachedToWindow()) {
+ return;
+ }
+ final Insets currentSystemInsets = getSystemInsets();
+ if (!Objects.equals(currentSystemInsets, mLastInsets)) {
+ that.setLayoutParams(
+ new FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ currentSystemInsets.bottom, Gravity.BOTTOM));
+ mLastInsets = currentSystemInsets;
+ }
+ if (decor instanceof ViewGroup) {
+ ViewGroup decorGroup = (ViewGroup) decor;
+ final View navbarBackgroundView =
+ window.getNavigationBarBackgroundView();
+ if (navbarBackgroundView != null
+ && decorGroup.indexOfChild(navbarBackgroundView)
+ > decorGroup.indexOfChild(that)) {
+ decorGroup.bringChildToFront(that);
+ }
+ }
+ });
+ }
+ }
+ }
+
+ private boolean isGesturalNavigationEnabled() {
+ final Resources resources = mService.getResources();
+ if (resources == null) {
+ return false;
+ }
+ return resources.getInteger(com.android.internal.R.integer.config_navBarInteractionMode)
+ == WindowManagerPolicyConstants.NAV_BAR_MODE_GESTURAL;
+ }
+
+ @Override
+ public void onViewInitialized() {
+ if (mDestroyed) {
+ return;
+ }
+ mRenderGesturalNavButtons = isGesturalNavigationEnabled();
+ installNavigationBarFrameIfNecessary();
+ }
+
+ @Override
+ public void onDestroy() {
+ mDestroyed = true;
+ }
+
+ @Override
+ public void onWindowShown() {
+ if (mDestroyed || !mRenderGesturalNavButtons || mNavigationBarFrame == null) {
+ return;
+ }
+ final Insets systemInsets = getSystemInsets();
+ if (systemInsets != null) {
+ if (!Objects.equals(systemInsets, mLastInsets)) {
+ mNavigationBarFrame.setLayoutParams(new NavigationBarFrame.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ systemInsets.bottom, Gravity.BOTTOM));
+ mLastInsets = systemInsets;
+ }
+ final Window window = mService.mWindow.getWindow();
+ View rawDecorView = window.getDecorView();
+ if (rawDecorView instanceof ViewGroup) {
+ final ViewGroup decor = (ViewGroup) rawDecorView;
+ final View navbarBackgroundView = window.getNavigationBarBackgroundView();
+ if (navbarBackgroundView != null
+ && decor.indexOfChild(navbarBackgroundView)
+ > decor.indexOfChild(mNavigationBarFrame)) {
+ decor.bringChildToFront(mNavigationBarFrame);
+ }
+ }
+ mNavigationBarFrame.setVisibility(View.VISIBLE);
+ }
+ }
+
+ @Override
+ public String toDebugString() {
+ return "{mRenderGesturalNavButtons=" + mRenderGesturalNavButtons + "}";
+ }
+ }
+}
diff --git a/core/java/android/inputmethodservice/navigationbar/ButtonDispatcher.java b/core/java/android/inputmethodservice/navigationbar/ButtonDispatcher.java
new file mode 100644
index 000000000000..3f26fa461097
--- /dev/null
+++ b/core/java/android/inputmethodservice/navigationbar/ButtonDispatcher.java
@@ -0,0 +1,302 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.inputmethodservice.navigationbar;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ValueAnimator;
+import android.view.View;
+import android.view.View.AccessibilityDelegate;
+import android.view.animation.Interpolator;
+import android.view.animation.LinearInterpolator;
+
+import java.util.ArrayList;
+
+/**
+ * Dispatches common view calls to multiple views. This is used to handle
+ * multiples of the same nav bar icon appearing.
+ */
+final class ButtonDispatcher {
+ private static final int FADE_DURATION_IN = 150;
+ private static final int FADE_DURATION_OUT = 250;
+ public static final Interpolator LINEAR = new LinearInterpolator();
+
+ private final ArrayList<View> mViews = new ArrayList<>();
+
+ private final int mId;
+
+ private View.OnClickListener mClickListener;
+ private View.OnTouchListener mTouchListener;
+ private View.OnLongClickListener mLongClickListener;
+ private View.OnHoverListener mOnHoverListener;
+ private Boolean mLongClickable;
+ private float mAlpha = 1.0f;
+ private Float mDarkIntensity;
+ private int mVisibility = View.VISIBLE;
+ private Boolean mDelayTouchFeedback;
+ private KeyButtonDrawable mImageDrawable;
+ private View mCurrentView;
+ private ValueAnimator mFadeAnimator;
+ private AccessibilityDelegate mAccessibilityDelegate;
+
+ private final ValueAnimator.AnimatorUpdateListener mAlphaListener = animation ->
+ setAlpha(
+ (float) animation.getAnimatedValue(),
+ false /* animate */,
+ false /* cancelAnimator */);
+
+ private final AnimatorListenerAdapter mFadeListener = new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ mFadeAnimator = null;
+ setVisibility(getAlpha() == 1 ? View.VISIBLE : View.INVISIBLE);
+ }
+ };
+
+ public ButtonDispatcher(int id) {
+ mId = id;
+ }
+
+ public void clear() {
+ mViews.clear();
+ }
+
+ public void addView(View view) {
+ mViews.add(view);
+ view.setOnClickListener(mClickListener);
+ view.setOnTouchListener(mTouchListener);
+ view.setOnLongClickListener(mLongClickListener);
+ view.setOnHoverListener(mOnHoverListener);
+ if (mLongClickable != null) {
+ view.setLongClickable(mLongClickable);
+ }
+ view.setAlpha(mAlpha);
+ view.setVisibility(mVisibility);
+ if (mAccessibilityDelegate != null) {
+ view.setAccessibilityDelegate(mAccessibilityDelegate);
+ }
+ if (view instanceof ButtonInterface) {
+ final ButtonInterface button = (ButtonInterface) view;
+ if (mDarkIntensity != null) {
+ button.setDarkIntensity(mDarkIntensity);
+ }
+ if (mImageDrawable != null) {
+ button.setImageDrawable(mImageDrawable);
+ }
+ if (mDelayTouchFeedback != null) {
+ button.setDelayTouchFeedback(mDelayTouchFeedback);
+ }
+ }
+ }
+
+ public int getId() {
+ return mId;
+ }
+
+ public int getVisibility() {
+ return mVisibility;
+ }
+
+ public boolean isVisible() {
+ return getVisibility() == View.VISIBLE;
+ }
+
+ public float getAlpha() {
+ return mAlpha;
+ }
+
+ public KeyButtonDrawable getImageDrawable() {
+ return mImageDrawable;
+ }
+
+ public void setImageDrawable(KeyButtonDrawable drawable) {
+ mImageDrawable = drawable;
+ final int N = mViews.size();
+ for (int i = 0; i < N; i++) {
+ if (mViews.get(i) instanceof ButtonInterface) {
+ ((ButtonInterface) mViews.get(i)).setImageDrawable(mImageDrawable);
+ }
+ }
+ if (mImageDrawable != null) {
+ mImageDrawable.setCallback(mCurrentView);
+ }
+ }
+
+ public void setVisibility(int visibility) {
+ if (mVisibility == visibility) return;
+ if (mFadeAnimator != null) {
+ mFadeAnimator.cancel();
+ }
+
+ mVisibility = visibility;
+ final int N = mViews.size();
+ for (int i = 0; i < N; i++) {
+ mViews.get(i).setVisibility(mVisibility);
+ }
+ }
+
+ public void setAlpha(float alpha) {
+ setAlpha(alpha, false /* animate */);
+ }
+
+ public void setAlpha(float alpha, boolean animate) {
+ setAlpha(alpha, animate, true /* cancelAnimator */);
+ }
+
+ public void setAlpha(float alpha, boolean animate, long duration) {
+ setAlpha(alpha, animate, duration, true /* cancelAnimator */);
+ }
+
+ public void setAlpha(float alpha, boolean animate, boolean cancelAnimator) {
+ setAlpha(
+ alpha,
+ animate,
+ (getAlpha() < alpha) ? FADE_DURATION_IN : FADE_DURATION_OUT,
+ cancelAnimator);
+ }
+
+ public void setAlpha(float alpha, boolean animate, long duration, boolean cancelAnimator) {
+ if (mFadeAnimator != null && (cancelAnimator || animate)) {
+ mFadeAnimator.cancel();
+ }
+ if (animate) {
+ setVisibility(View.VISIBLE);
+ mFadeAnimator = ValueAnimator.ofFloat(getAlpha(), alpha);
+ mFadeAnimator.setDuration(duration);
+ mFadeAnimator.setInterpolator(LINEAR);
+ mFadeAnimator.addListener(mFadeListener);
+ mFadeAnimator.addUpdateListener(mAlphaListener);
+ mFadeAnimator.start();
+ } else {
+ // Discretize the alpha updates to prevent too frequent updates when there is a long
+ // alpha animation
+ int prevAlpha = (int) (getAlpha() * 255);
+ int nextAlpha = (int) (alpha * 255);
+ if (prevAlpha != nextAlpha) {
+ mAlpha = nextAlpha / 255f;
+ final int N = mViews.size();
+ for (int i = 0; i < N; i++) {
+ mViews.get(i).setAlpha(mAlpha);
+ }
+ }
+ }
+ }
+
+ public void setDarkIntensity(float darkIntensity) {
+ mDarkIntensity = darkIntensity;
+ final int N = mViews.size();
+ for (int i = 0; i < N; i++) {
+ if (mViews.get(i) instanceof ButtonInterface) {
+ ((ButtonInterface) mViews.get(i)).setDarkIntensity(darkIntensity);
+ }
+ }
+ }
+
+ public void setDelayTouchFeedback(boolean delay) {
+ mDelayTouchFeedback = delay;
+ final int N = mViews.size();
+ for (int i = 0; i < N; i++) {
+ if (mViews.get(i) instanceof ButtonInterface) {
+ ((ButtonInterface) mViews.get(i)).setDelayTouchFeedback(delay);
+ }
+ }
+ }
+
+ public void setOnClickListener(View.OnClickListener clickListener) {
+ mClickListener = clickListener;
+ final int N = mViews.size();
+ for (int i = 0; i < N; i++) {
+ mViews.get(i).setOnClickListener(mClickListener);
+ }
+ }
+
+ public void setOnTouchListener(View.OnTouchListener touchListener) {
+ mTouchListener = touchListener;
+ final int N = mViews.size();
+ for (int i = 0; i < N; i++) {
+ mViews.get(i).setOnTouchListener(mTouchListener);
+ }
+ }
+
+ public void setLongClickable(boolean isLongClickable) {
+ mLongClickable = isLongClickable;
+ final int N = mViews.size();
+ for (int i = 0; i < N; i++) {
+ mViews.get(i).setLongClickable(mLongClickable);
+ }
+ }
+
+ public void setOnLongClickListener(View.OnLongClickListener longClickListener) {
+ mLongClickListener = longClickListener;
+ final int N = mViews.size();
+ for (int i = 0; i < N; i++) {
+ mViews.get(i).setOnLongClickListener(mLongClickListener);
+ }
+ }
+
+ public void setOnHoverListener(View.OnHoverListener hoverListener) {
+ mOnHoverListener = hoverListener;
+ final int N = mViews.size();
+ for (int i = 0; i < N; i++) {
+ mViews.get(i).setOnHoverListener(mOnHoverListener);
+ }
+ }
+
+ public void setAccessibilityDelegate(AccessibilityDelegate delegate) {
+ mAccessibilityDelegate = delegate;
+ final int N = mViews.size();
+ for (int i = 0; i < N; i++) {
+ mViews.get(i).setAccessibilityDelegate(delegate);
+ }
+ }
+
+ public void setTranslation(int x, int y, int z) {
+ final int N = mViews.size();
+ for (int i = 0; i < N; i++) {
+ final View view = mViews.get(i);
+ view.setTranslationX(x);
+ view.setTranslationY(y);
+ view.setTranslationZ(z);
+ }
+ }
+
+ public ArrayList<View> getViews() {
+ return mViews;
+ }
+
+ public View getCurrentView() {
+ return mCurrentView;
+ }
+
+ public void setCurrentView(View currentView) {
+ mCurrentView = currentView.findViewById(mId);
+ if (mImageDrawable != null) {
+ mImageDrawable.setCallback(mCurrentView);
+ }
+ if (mCurrentView != null) {
+ mCurrentView.setTranslationX(0);
+ mCurrentView.setTranslationY(0);
+ mCurrentView.setTranslationZ(0);
+ }
+ }
+
+ /**
+ * Executes when button is detached from window.
+ */
+ public void onDestroy() {
+ }
+}
diff --git a/core/java/android/inputmethodservice/navigationbar/ButtonInterface.java b/core/java/android/inputmethodservice/navigationbar/ButtonInterface.java
new file mode 100644
index 000000000000..1c9c86d2a7e5
--- /dev/null
+++ b/core/java/android/inputmethodservice/navigationbar/ButtonInterface.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.inputmethodservice.navigationbar;
+
+import android.annotation.Nullable;
+import android.graphics.drawable.Drawable;
+
+interface ButtonInterface {
+
+ void setImageDrawable(@Nullable Drawable drawable);
+
+ void setDarkIntensity(float intensity);
+
+ void setDelayTouchFeedback(boolean shouldDelay);
+}
diff --git a/core/java/android/inputmethodservice/navigationbar/DeadZone.java b/core/java/android/inputmethodservice/navigationbar/DeadZone.java
new file mode 100644
index 000000000000..cd857369bc5a
--- /dev/null
+++ b/core/java/android/inputmethodservice/navigationbar/DeadZone.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.inputmethodservice.navigationbar;
+
+import static android.inputmethodservice.navigationbar.NavigationBarConstants.NAVIGATION_BAR_DEADZONE_DECAY;
+import static android.inputmethodservice.navigationbar.NavigationBarConstants.NAVIGATION_BAR_DEADZONE_HOLD;
+import static android.inputmethodservice.navigationbar.NavigationBarConstants.NAVIGATION_BAR_DEADZONE_SIZE;
+import static android.inputmethodservice.navigationbar.NavigationBarConstants.NAVIGATION_BAR_DEADZONE_SIZE_MAX;
+import static android.inputmethodservice.navigationbar.NavigationBarUtils.dpToPx;
+
+import android.animation.ObjectAnimator;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.graphics.Canvas;
+import android.os.SystemClock;
+import android.util.Log;
+import android.view.MotionEvent;
+import android.view.Surface;
+
+/**
+ * The "dead zone" consumes unintentional taps along the top edge of the navigation bar.
+ * When users are typing quickly on an IME they may attempt to hit the space bar, overshoot, and
+ * accidentally hit the home button. The DeadZone expands temporarily after each tap in the UI
+ * outside the navigation bar (since this is when accidental taps are more likely), then contracts
+ * back over time (since a later tap might be intended for the top of the bar).
+ */
+final class DeadZone {
+ public static final String TAG = "DeadZone";
+
+ public static final boolean DEBUG = false;
+ public static final int HORIZONTAL = 0; // Consume taps along the top edge.
+ public static final int VERTICAL = 1; // Consume taps along the left edge.
+
+ private static final boolean CHATTY = true; // print to logcat when we eat a click
+ private final NavigationBarView mNavigationBarView;
+
+ private boolean mShouldFlash;
+ private float mFlashFrac = 0f;
+
+ private int mSizeMax;
+ private int mSizeMin;
+ // Upon activity elsewhere in the UI, the dead zone will hold steady for
+ // mHold ms, then move back over the course of mDecay ms
+ private int mHold, mDecay;
+ private boolean mVertical;
+ private long mLastPokeTime;
+ private int mDisplayRotation;
+
+ private final Runnable mDebugFlash = new Runnable() {
+ @Override
+ public void run() {
+ ObjectAnimator.ofFloat(DeadZone.this, "flash", 1f, 0f).setDuration(150).start();
+ }
+ };
+
+ public DeadZone(NavigationBarView view) {
+ mNavigationBarView = view;
+ onConfigurationChanged(Surface.ROTATION_0);
+ }
+
+ static float lerp(float a, float b, float f) {
+ return (b - a) * f + a;
+ }
+
+ private float getSize(long now) {
+ if (mSizeMax == 0)
+ return 0;
+ long dt = (now - mLastPokeTime);
+ if (dt > mHold + mDecay)
+ return mSizeMin;
+ if (dt < mHold)
+ return mSizeMax;
+ return (int) lerp(mSizeMax, mSizeMin, (float) (dt - mHold) / mDecay);
+ }
+
+ public void setFlashOnTouchCapture(boolean dbg) {
+ mShouldFlash = dbg;
+ mFlashFrac = 0f;
+ mNavigationBarView.postInvalidate();
+ }
+
+ public void onConfigurationChanged(int rotation) {
+ mDisplayRotation = rotation;
+
+ final Resources res = mNavigationBarView.getResources();
+ mHold = NAVIGATION_BAR_DEADZONE_HOLD;
+ mDecay = NAVIGATION_BAR_DEADZONE_DECAY;
+
+ mSizeMin = dpToPx(NAVIGATION_BAR_DEADZONE_SIZE, res);
+ mSizeMax = dpToPx(NAVIGATION_BAR_DEADZONE_SIZE_MAX, res);
+ mVertical = (res.getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE);
+
+ if (DEBUG) {
+ Log.v(TAG, this + " size=[" + mSizeMin + "-" + mSizeMax + "] hold=" + mHold
+ + (mVertical ? " vertical" : " horizontal"));
+ }
+ setFlashOnTouchCapture(false); // hard-coded from "bool/config_dead_zone_flash"
+ }
+
+ // I made you a touch event...
+ public boolean onTouchEvent(MotionEvent event) {
+ if (DEBUG) {
+ Log.v(TAG, this + " onTouch: " + MotionEvent.actionToString(event.getAction()));
+ }
+
+ // Don't consume events for high precision pointing devices. For this purpose a stylus is
+ // considered low precision (like a finger), so its events may be consumed.
+ if (event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) {
+ return false;
+ }
+
+ final int action = event.getAction();
+ if (action == MotionEvent.ACTION_OUTSIDE) {
+ poke(event);
+ return true;
+ } else if (action == MotionEvent.ACTION_DOWN) {
+ if (DEBUG) {
+ Log.v(TAG, this + " ACTION_DOWN: " + event.getX() + "," + event.getY());
+ }
+ //TODO(b/205803355): call mNavBarController.touchAutoDim(mDisplayId); here
+ int size = (int) getSize(event.getEventTime());
+ // In the vertical orientation consume taps along the left edge.
+ // In horizontal orientation consume taps along the top edge.
+ final boolean consumeEvent;
+ if (mVertical) {
+ if (mDisplayRotation == Surface.ROTATION_270) {
+ consumeEvent = event.getX() > mNavigationBarView.getWidth() - size;
+ } else {
+ consumeEvent = event.getX() < size;
+ }
+ } else {
+ consumeEvent = event.getY() < size;
+ }
+ if (consumeEvent) {
+ if (CHATTY) {
+ Log.v(TAG, "consuming errant click: (" + event.getX() + ","
+ + event.getY() + ")");
+ }
+ if (mShouldFlash) {
+ mNavigationBarView.post(mDebugFlash);
+ mNavigationBarView.postInvalidate();
+ }
+ return true; // ...but I eated it
+ }
+ }
+ return false;
+ }
+
+ private void poke(MotionEvent event) {
+ mLastPokeTime = event.getEventTime();
+ if (DEBUG)
+ Log.v(TAG, "poked! size=" + getSize(mLastPokeTime));
+ if (mShouldFlash) mNavigationBarView.postInvalidate();
+ }
+
+ public void setFlash(float f) {
+ mFlashFrac = f;
+ mNavigationBarView.postInvalidate();
+ }
+
+ public float getFlash() {
+ return mFlashFrac;
+ }
+
+ public void onDraw(Canvas can) {
+ if (!mShouldFlash || mFlashFrac <= 0f) {
+ return;
+ }
+
+ final int size = (int) getSize(SystemClock.uptimeMillis());
+ if (mVertical) {
+ if (mDisplayRotation == Surface.ROTATION_270) {
+ can.clipRect(can.getWidth() - size, 0, can.getWidth(), can.getHeight());
+ } else {
+ can.clipRect(0, 0, size, can.getHeight());
+ }
+ } else {
+ can.clipRect(0, 0, can.getWidth(), size);
+ }
+
+ final float frac = DEBUG ? (mFlashFrac - 0.5f) + 0.5f : mFlashFrac;
+ can.drawARGB((int) (frac * 0xFF), 0xDD, 0xEE, 0xAA);
+
+ if (DEBUG && size > mSizeMin) {
+ // Very aggressive redrawing here, for debugging only
+ mNavigationBarView.postInvalidateDelayed(100);
+ }
+ }
+}
diff --git a/core/java/android/inputmethodservice/navigationbar/KeyButtonDrawable.java b/core/java/android/inputmethodservice/navigationbar/KeyButtonDrawable.java
new file mode 100644
index 000000000000..25a443de916b
--- /dev/null
+++ b/core/java/android/inputmethodservice/navigationbar/KeyButtonDrawable.java
@@ -0,0 +1,483 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.inputmethodservice.navigationbar;
+
+import static android.inputmethodservice.navigationbar.NavigationBarConstants.NAV_KEY_BUTTON_SHADOW_COLOR;
+import static android.inputmethodservice.navigationbar.NavigationBarConstants.NAV_KEY_BUTTON_SHADOW_OFFSET_X;
+import static android.inputmethodservice.navigationbar.NavigationBarConstants.NAV_KEY_BUTTON_SHADOW_OFFSET_Y;
+import static android.inputmethodservice.navigationbar.NavigationBarConstants.NAV_KEY_BUTTON_SHADOW_RADIUS;
+import static android.inputmethodservice.navigationbar.NavigationBarUtils.dpToPx;
+
+import android.animation.ArgbEvaluator;
+import android.annotation.ColorInt;
+import android.annotation.DrawableRes;
+import android.annotation.NonNull;
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.BlurMaskFilter;
+import android.graphics.BlurMaskFilter.Blur;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.ColorFilter;
+import android.graphics.Paint;
+import android.graphics.PixelFormat;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuff.Mode;
+import android.graphics.PorterDuffColorFilter;
+import android.graphics.Rect;
+import android.graphics.drawable.AnimatedVectorDrawable;
+import android.graphics.drawable.Drawable;
+import android.util.FloatProperty;
+import android.view.View;
+
+
+/**
+ * Drawable for {@link KeyButtonView}s that supports tinting between two colors, rotation and shows
+ * a shadow. AnimatedVectorDrawable will only support tinting from intensities but has no support
+ * for shadows nor rotations.
+ */
+final class KeyButtonDrawable extends Drawable {
+
+ public static final FloatProperty<KeyButtonDrawable> KEY_DRAWABLE_ROTATE =
+ new FloatProperty<KeyButtonDrawable>("KeyButtonRotation") {
+ @Override
+ public void setValue(KeyButtonDrawable drawable, float degree) {
+ drawable.setRotation(degree);
+ }
+
+ @Override
+ public Float get(KeyButtonDrawable drawable) {
+ return drawable.getRotation();
+ }
+ };
+
+ public static final FloatProperty<KeyButtonDrawable> KEY_DRAWABLE_TRANSLATE_Y =
+ new FloatProperty<KeyButtonDrawable>("KeyButtonTranslateY") {
+ @Override
+ public void setValue(KeyButtonDrawable drawable, float y) {
+ drawable.setTranslationY(y);
+ }
+
+ @Override
+ public Float get(KeyButtonDrawable drawable) {
+ return drawable.getTranslationY();
+ }
+ };
+
+ private final Paint mIconPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
+ private final Paint mShadowPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
+ private final ShadowDrawableState mState;
+ private AnimatedVectorDrawable mAnimatedDrawable;
+ private final Callback mAnimatedDrawableCallback = new Callback() {
+ @Override
+ public void invalidateDrawable(@NonNull Drawable who) {
+ invalidateSelf();
+ }
+
+ @Override
+ public void scheduleDrawable(@NonNull Drawable who, @NonNull Runnable what, long when) {
+ scheduleSelf(what, when);
+ }
+
+ @Override
+ public void unscheduleDrawable(@NonNull Drawable who, @NonNull Runnable what) {
+ unscheduleSelf(what);
+ }
+ };
+
+ public KeyButtonDrawable(Drawable d, @ColorInt int lightColor, @ColorInt int darkColor,
+ boolean horizontalFlip, Color ovalBackgroundColor) {
+ this(d, new ShadowDrawableState(lightColor, darkColor,
+ d instanceof AnimatedVectorDrawable, horizontalFlip, ovalBackgroundColor));
+ }
+
+ private KeyButtonDrawable(Drawable d, ShadowDrawableState state) {
+ mState = state;
+ if (d != null) {
+ mState.mBaseHeight = d.getIntrinsicHeight();
+ mState.mBaseWidth = d.getIntrinsicWidth();
+ mState.mChangingConfigurations = d.getChangingConfigurations();
+ mState.mChildState = d.getConstantState();
+ }
+ if (canAnimate()) {
+ mAnimatedDrawable = (AnimatedVectorDrawable) mState.mChildState.newDrawable().mutate();
+ mAnimatedDrawable.setCallback(mAnimatedDrawableCallback);
+ setDrawableBounds(mAnimatedDrawable);
+ }
+ }
+
+ public void setDarkIntensity(float intensity) {
+ mState.mDarkIntensity = intensity;
+ final int color = (int) ArgbEvaluator.getInstance()
+ .evaluate(intensity, mState.mLightColor, mState.mDarkColor);
+ updateShadowAlpha();
+ setColorFilter(new PorterDuffColorFilter(color, Mode.SRC_ATOP));
+ }
+
+ public void setRotation(float degrees) {
+ if (canAnimate()) {
+ // AnimatedVectorDrawables will not support rotation
+ return;
+ }
+ if (mState.mRotateDegrees != degrees) {
+ mState.mRotateDegrees = degrees;
+ invalidateSelf();
+ }
+ }
+
+ public void setTranslationX(float x) {
+ setTranslation(x, mState.mTranslationY);
+ }
+
+ public void setTranslationY(float y) {
+ setTranslation(mState.mTranslationX, y);
+ }
+
+ public void setTranslation(float x, float y) {
+ if (mState.mTranslationX != x || mState.mTranslationY != y) {
+ mState.mTranslationX = x;
+ mState.mTranslationY = y;
+ invalidateSelf();
+ }
+ }
+
+ public void setShadowProperties(int x, int y, int size, int color) {
+ if (canAnimate()) {
+ // AnimatedVectorDrawables will not support shadows
+ return;
+ }
+ if (mState.mShadowOffsetX != x || mState.mShadowOffsetY != y
+ || mState.mShadowSize != size || mState.mShadowColor != color) {
+ mState.mShadowOffsetX = x;
+ mState.mShadowOffsetY = y;
+ mState.mShadowSize = size;
+ mState.mShadowColor = color;
+ mShadowPaint.setColorFilter(
+ new PorterDuffColorFilter(mState.mShadowColor, Mode.SRC_ATOP));
+ updateShadowAlpha();
+ invalidateSelf();
+ }
+ }
+
+ @Override
+ public boolean setVisible(boolean visible, boolean restart) {
+ boolean changed = super.setVisible(visible, restart);
+ if (changed) {
+ // End any existing animations when the visibility changes
+ jumpToCurrentState();
+ }
+ return changed;
+ }
+
+ @Override
+ public void jumpToCurrentState() {
+ super.jumpToCurrentState();
+ if (mAnimatedDrawable != null) {
+ mAnimatedDrawable.jumpToCurrentState();
+ }
+ }
+
+ @Override
+ public void setAlpha(int alpha) {
+ mState.mAlpha = alpha;
+ mIconPaint.setAlpha(alpha);
+ updateShadowAlpha();
+ invalidateSelf();
+ }
+
+ @Override
+ public void setColorFilter(ColorFilter colorFilter) {
+ mIconPaint.setColorFilter(colorFilter);
+ if (mAnimatedDrawable != null) {
+ if (hasOvalBg()) {
+ mAnimatedDrawable.setColorFilter(
+ new PorterDuffColorFilter(mState.mLightColor, PorterDuff.Mode.SRC_IN));
+ } else {
+ mAnimatedDrawable.setColorFilter(colorFilter);
+ }
+ }
+ invalidateSelf();
+ }
+
+ public float getDarkIntensity() {
+ return mState.mDarkIntensity;
+ }
+
+ public float getRotation() {
+ return mState.mRotateDegrees;
+ }
+
+ public float getTranslationX() {
+ return mState.mTranslationX;
+ }
+
+ public float getTranslationY() {
+ return mState.mTranslationY;
+ }
+
+ @Override
+ public ConstantState getConstantState() {
+ return mState;
+ }
+
+ @Override
+ public int getOpacity() {
+ return PixelFormat.TRANSLUCENT;
+ }
+
+ @Override
+ public int getIntrinsicHeight() {
+ return mState.mBaseHeight + (mState.mShadowSize + Math.abs(mState.mShadowOffsetY)) * 2;
+ }
+
+ @Override
+ public int getIntrinsicWidth() {
+ return mState.mBaseWidth + (mState.mShadowSize + Math.abs(mState.mShadowOffsetX)) * 2;
+ }
+
+ public boolean canAnimate() {
+ return mState.mSupportsAnimation;
+ }
+
+ public void startAnimation() {
+ if (mAnimatedDrawable != null) {
+ mAnimatedDrawable.start();
+ }
+ }
+
+ public void resetAnimation() {
+ if (mAnimatedDrawable != null) {
+ mAnimatedDrawable.reset();
+ }
+ }
+
+ public void clearAnimationCallbacks() {
+ if (mAnimatedDrawable != null) {
+ mAnimatedDrawable.clearAnimationCallbacks();
+ }
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ Rect bounds = getBounds();
+ if (bounds.isEmpty()) {
+ return;
+ }
+
+ if (mAnimatedDrawable != null) {
+ mAnimatedDrawable.draw(canvas);
+ } else {
+ // If no cache or previous cached bitmap is hardware/software acceleration does not
+ // match the current canvas on draw then regenerate
+ boolean hwBitmapChanged = mState.mIsHardwareBitmap != canvas.isHardwareAccelerated();
+ if (hwBitmapChanged) {
+ mState.mIsHardwareBitmap = canvas.isHardwareAccelerated();
+ }
+ if (mState.mLastDrawnIcon == null || hwBitmapChanged) {
+ regenerateBitmapIconCache();
+ }
+ canvas.save();
+ canvas.translate(mState.mTranslationX, mState.mTranslationY);
+ canvas.rotate(mState.mRotateDegrees, getIntrinsicWidth() / 2, getIntrinsicHeight() / 2);
+
+ if (mState.mShadowSize > 0) {
+ if (mState.mLastDrawnShadow == null || hwBitmapChanged) {
+ regenerateBitmapShadowCache();
+ }
+
+ // Translate (with rotation offset) before drawing the shadow
+ final float radians = (float) (mState.mRotateDegrees * Math.PI / 180);
+ final float shadowOffsetX = (float) (Math.sin(radians) * mState.mShadowOffsetY
+ + Math.cos(radians) * mState.mShadowOffsetX) - mState.mTranslationX;
+ final float shadowOffsetY = (float) (Math.cos(radians) * mState.mShadowOffsetY
+ - Math.sin(radians) * mState.mShadowOffsetX) - mState.mTranslationY;
+ canvas.drawBitmap(mState.mLastDrawnShadow, shadowOffsetX, shadowOffsetY,
+ mShadowPaint);
+ }
+ canvas.drawBitmap(mState.mLastDrawnIcon, null, bounds, mIconPaint);
+ canvas.restore();
+ }
+ }
+
+ @Override
+ public boolean canApplyTheme() {
+ return mState.canApplyTheme();
+ }
+
+ @ColorInt int getDrawableBackgroundColor() {
+ return mState.mOvalBackgroundColor.toArgb();
+ }
+
+ boolean hasOvalBg() {
+ return mState.mOvalBackgroundColor != null;
+ }
+
+ private void regenerateBitmapIconCache() {
+ final int width = getIntrinsicWidth();
+ final int height = getIntrinsicHeight();
+ Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ final Canvas canvas = new Canvas(bitmap);
+
+ // Call mutate, so that the pixel allocation by the underlying vector drawable is cleared.
+ final Drawable d = mState.mChildState.newDrawable().mutate();
+ setDrawableBounds(d);
+ canvas.save();
+ if (mState.mHorizontalFlip) {
+ canvas.scale(-1f, 1f, width * 0.5f, height * 0.5f);
+ }
+ d.draw(canvas);
+ canvas.restore();
+
+ if (mState.mIsHardwareBitmap) {
+ bitmap = bitmap.copy(Bitmap.Config.HARDWARE, false);
+ }
+ mState.mLastDrawnIcon = bitmap;
+ }
+
+ private void regenerateBitmapShadowCache() {
+ if (mState.mShadowSize == 0) {
+ // No shadow
+ mState.mLastDrawnIcon = null;
+ return;
+ }
+
+ final int width = getIntrinsicWidth();
+ final int height = getIntrinsicHeight();
+ Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(bitmap);
+
+ // Call mutate, so that the pixel allocation by the underlying vector drawable is cleared.
+ final Drawable d = mState.mChildState.newDrawable().mutate();
+ setDrawableBounds(d);
+ canvas.save();
+ if (mState.mHorizontalFlip) {
+ canvas.scale(-1f, 1f, width * 0.5f, height * 0.5f);
+ }
+ d.draw(canvas);
+ canvas.restore();
+
+ // Draws the shadow from original drawable
+ Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
+ paint.setMaskFilter(new BlurMaskFilter(mState.mShadowSize, Blur.NORMAL));
+ int[] offset = new int[2];
+ final Bitmap shadow = bitmap.extractAlpha(paint, offset);
+ paint.setMaskFilter(null);
+ bitmap.eraseColor(Color.TRANSPARENT);
+ canvas.drawBitmap(shadow, offset[0], offset[1], paint);
+
+ if (mState.mIsHardwareBitmap) {
+ bitmap = bitmap.copy(Bitmap.Config.HARDWARE, false);
+ }
+ mState.mLastDrawnShadow = bitmap;
+ }
+
+ /**
+ * Set the alpha of the shadow. As dark intensity increases, drop the alpha of the shadow since
+ * dark color and shadow should not be visible at the same time.
+ */
+ private void updateShadowAlpha() {
+ // Update the color from the original color's alpha as the max
+ int alpha = Color.alpha(mState.mShadowColor);
+ mShadowPaint.setAlpha(
+ Math.round(alpha * (mState.mAlpha / 255f) * (1 - mState.mDarkIntensity)));
+ }
+
+ /**
+ * Prevent shadow clipping by offsetting the drawable bounds by the shadow and its offset
+ * @param d the drawable to set the bounds
+ */
+ private void setDrawableBounds(Drawable d) {
+ final int offsetX = mState.mShadowSize + Math.abs(mState.mShadowOffsetX);
+ final int offsetY = mState.mShadowSize + Math.abs(mState.mShadowOffsetY);
+ d.setBounds(offsetX, offsetY, getIntrinsicWidth() - offsetX,
+ getIntrinsicHeight() - offsetY);
+ }
+
+ private static class ShadowDrawableState extends ConstantState {
+ int mChangingConfigurations;
+ int mBaseWidth;
+ int mBaseHeight;
+ float mRotateDegrees;
+ float mTranslationX;
+ float mTranslationY;
+ int mShadowOffsetX;
+ int mShadowOffsetY;
+ int mShadowSize;
+ int mShadowColor;
+ float mDarkIntensity;
+ int mAlpha;
+ boolean mHorizontalFlip;
+
+ boolean mIsHardwareBitmap;
+ Bitmap mLastDrawnIcon;
+ Bitmap mLastDrawnShadow;
+ ConstantState mChildState;
+
+ final int mLightColor;
+ final int mDarkColor;
+ final boolean mSupportsAnimation;
+ final Color mOvalBackgroundColor;
+
+ public ShadowDrawableState(@ColorInt int lightColor, @ColorInt int darkColor,
+ boolean animated, boolean horizontalFlip, Color ovalBackgroundColor) {
+ mLightColor = lightColor;
+ mDarkColor = darkColor;
+ mSupportsAnimation = animated;
+ mAlpha = 255;
+ mHorizontalFlip = horizontalFlip;
+ mOvalBackgroundColor = ovalBackgroundColor;
+ }
+
+ @Override
+ public Drawable newDrawable() {
+ return new KeyButtonDrawable(null, this);
+ }
+
+ @Override
+ public int getChangingConfigurations() {
+ return mChangingConfigurations;
+ }
+
+ @Override
+ public boolean canApplyTheme() {
+ return true;
+ }
+ }
+
+ /**
+ * Creates a KeyButtonDrawable with a shadow given its icon. For more information, see
+ * {@link #create(Context, int, boolean, boolean)}.
+ */
+ public static KeyButtonDrawable create(Context context, @ColorInt int lightColor,
+ @ColorInt int darkColor, @DrawableRes int iconResId, boolean hasShadow,
+ Color ovalBackgroundColor) {
+ final Resources res = context.getResources();
+ boolean isRtl = res.getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
+ Drawable d = context.getDrawable(iconResId);
+ final KeyButtonDrawable drawable = new KeyButtonDrawable(d, lightColor, darkColor,
+ isRtl && d.isAutoMirrored(), ovalBackgroundColor);
+ if (hasShadow) {
+ int offsetX = dpToPx(NAV_KEY_BUTTON_SHADOW_OFFSET_X, res);
+ int offsetY = dpToPx(NAV_KEY_BUTTON_SHADOW_OFFSET_Y, res);
+ int radius = dpToPx(NAV_KEY_BUTTON_SHADOW_RADIUS, res);
+ int color = NAV_KEY_BUTTON_SHADOW_COLOR;
+ drawable.setShadowProperties(offsetX, offsetY, radius, color);
+ }
+ return drawable;
+ }
+}
diff --git a/core/java/android/inputmethodservice/navigationbar/KeyButtonRipple.java b/core/java/android/inputmethodservice/navigationbar/KeyButtonRipple.java
new file mode 100644
index 000000000000..38a63b661ac0
--- /dev/null
+++ b/core/java/android/inputmethodservice/navigationbar/KeyButtonRipple.java
@@ -0,0 +1,525 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.inputmethodservice.navigationbar;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ObjectAnimator;
+import android.annotation.DimenRes;
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.CanvasProperty;
+import android.graphics.ColorFilter;
+import android.graphics.Paint;
+import android.graphics.PixelFormat;
+import android.graphics.RecordingCanvas;
+import android.graphics.drawable.Drawable;
+import android.os.Handler;
+import android.os.Trace;
+import android.view.RenderNodeAnimator;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.animation.Interpolator;
+import android.view.animation.PathInterpolator;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+
+final class KeyButtonRipple extends Drawable {
+
+ private static final float GLOW_MAX_SCALE_FACTOR = 1.35f;
+ private static final float GLOW_MAX_ALPHA = 0.2f;
+ private static final float GLOW_MAX_ALPHA_DARK = 0.1f;
+ private static final int ANIMATION_DURATION_SCALE = 350;
+ private static final int ANIMATION_DURATION_FADE = 450;
+ private static final Interpolator ALPHA_OUT_INTERPOLATOR =
+ new PathInterpolator(0f, 0f, 0.8f, 1f);
+
+ @DimenRes
+ private final int mMaxWidthResource;
+
+ private Paint mRipplePaint;
+ private CanvasProperty<Float> mLeftProp;
+ private CanvasProperty<Float> mTopProp;
+ private CanvasProperty<Float> mRightProp;
+ private CanvasProperty<Float> mBottomProp;
+ private CanvasProperty<Float> mRxProp;
+ private CanvasProperty<Float> mRyProp;
+ private CanvasProperty<Paint> mPaintProp;
+ private float mGlowAlpha = 0f;
+ private float mGlowScale = 1f;
+ private boolean mPressed;
+ private boolean mVisible;
+ private boolean mDrawingHardwareGlow;
+ private int mMaxWidth;
+ private boolean mLastDark;
+ private boolean mDark;
+ private boolean mDelayTouchFeedback;
+
+ private final Interpolator mInterpolator = new LogInterpolator();
+ private boolean mSupportHardware;
+ private final View mTargetView;
+ private final Handler mHandler = new Handler();
+
+ private final HashSet<Animator> mRunningAnimations = new HashSet<>();
+ private final ArrayList<Animator> mTmpArray = new ArrayList<>();
+
+ private final TraceAnimatorListener mExitHwTraceAnimator =
+ new TraceAnimatorListener("exitHardware");
+ private final TraceAnimatorListener mEnterHwTraceAnimator =
+ new TraceAnimatorListener("enterHardware");
+
+ public enum Type {
+ OVAL,
+ ROUNDED_RECT
+ }
+
+ private Type mType = Type.ROUNDED_RECT;
+
+ public KeyButtonRipple(Context ctx, View targetView, @DimenRes int maxWidthResource) {
+ mMaxWidthResource = maxWidthResource;
+ mMaxWidth = ctx.getResources().getDimensionPixelSize(maxWidthResource);
+ mTargetView = targetView;
+ }
+
+ public void updateResources() {
+ mMaxWidth = mTargetView.getContext().getResources()
+ .getDimensionPixelSize(mMaxWidthResource);
+ invalidateSelf();
+ }
+
+ public void setDarkIntensity(float darkIntensity) {
+ mDark = darkIntensity >= 0.5f;
+ }
+
+ public void setDelayTouchFeedback(boolean delay) {
+ mDelayTouchFeedback = delay;
+ }
+
+ public void setType(Type type) {
+ mType = type;
+ }
+
+ private Paint getRipplePaint() {
+ if (mRipplePaint == null) {
+ mRipplePaint = new Paint();
+ mRipplePaint.setAntiAlias(true);
+ mRipplePaint.setColor(mLastDark ? 0xff000000 : 0xffffffff);
+ }
+ return mRipplePaint;
+ }
+
+ private void drawSoftware(Canvas canvas) {
+ if (mGlowAlpha > 0f) {
+ final Paint p = getRipplePaint();
+ p.setAlpha((int)(mGlowAlpha * 255f));
+
+ final float w = getBounds().width();
+ final float h = getBounds().height();
+ final boolean horizontal = w > h;
+ final float diameter = getRippleSize() * mGlowScale;
+ final float radius = diameter * .5f;
+ final float cx = w * .5f;
+ final float cy = h * .5f;
+ final float rx = horizontal ? radius : cx;
+ final float ry = horizontal ? cy : radius;
+ final float corner = horizontal ? cy : cx;
+
+ if (mType == Type.ROUNDED_RECT) {
+ canvas.drawRoundRect(cx - rx, cy - ry, cx + rx, cy + ry, corner, corner, p);
+ } else {
+ canvas.save();
+ canvas.translate(cx, cy);
+ float r = Math.min(rx, ry);
+ canvas.drawOval(-r, -r, r, r, p);
+ canvas.restore();
+ }
+ }
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ mSupportHardware = canvas.isHardwareAccelerated();
+ if (mSupportHardware) {
+ drawHardware((RecordingCanvas) canvas);
+ } else {
+ drawSoftware(canvas);
+ }
+ }
+
+ @Override
+ public void setAlpha(int alpha) {
+ // Not supported.
+ }
+
+ @Override
+ public void setColorFilter(ColorFilter colorFilter) {
+ // Not supported.
+ }
+
+ @Override
+ public int getOpacity() {
+ return PixelFormat.TRANSLUCENT;
+ }
+
+ private boolean isHorizontal() {
+ return getBounds().width() > getBounds().height();
+ }
+
+ private void drawHardware(RecordingCanvas c) {
+ if (mDrawingHardwareGlow) {
+ if (mType == Type.ROUNDED_RECT) {
+ c.drawRoundRect(mLeftProp, mTopProp, mRightProp, mBottomProp, mRxProp, mRyProp,
+ mPaintProp);
+ } else {
+ CanvasProperty<Float> cx = CanvasProperty.createFloat(getBounds().width() / 2);
+ CanvasProperty<Float> cy = CanvasProperty.createFloat(getBounds().height() / 2);
+ int d = Math.min(getBounds().width(), getBounds().height());
+ CanvasProperty<Float> r = CanvasProperty.createFloat(1.0f * d / 2);
+ c.drawCircle(cx, cy, r, mPaintProp);
+ }
+ }
+ }
+
+ /** Gets the glow alpha, used by {@link android.animation.ObjectAnimator} via reflection. */
+ public float getGlowAlpha() {
+ return mGlowAlpha;
+ }
+
+ /** Sets the glow alpha, used by {@link android.animation.ObjectAnimator} via reflection. */
+ public void setGlowAlpha(float x) {
+ mGlowAlpha = x;
+ invalidateSelf();
+ }
+
+ /** Gets the glow scale, used by {@link android.animation.ObjectAnimator} via reflection. */
+ public float getGlowScale() {
+ return mGlowScale;
+ }
+
+ /** Sets the glow scale, used by {@link android.animation.ObjectAnimator} via reflection. */
+ public void setGlowScale(float x) {
+ mGlowScale = x;
+ invalidateSelf();
+ }
+
+ private float getMaxGlowAlpha() {
+ return mLastDark ? GLOW_MAX_ALPHA_DARK : GLOW_MAX_ALPHA;
+ }
+
+ @Override
+ protected boolean onStateChange(int[] state) {
+ boolean pressed = false;
+ for (int i = 0; i < state.length; i++) {
+ if (state[i] == android.R.attr.state_pressed) {
+ pressed = true;
+ break;
+ }
+ }
+ if (pressed != mPressed) {
+ setPressed(pressed);
+ mPressed = pressed;
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ public boolean setVisible(boolean visible, boolean restart) {
+ boolean changed = super.setVisible(visible, restart);
+ if (changed) {
+ // End any existing animations when the visibility changes
+ jumpToCurrentState();
+ }
+ return changed;
+ }
+
+ @Override
+ public void jumpToCurrentState() {
+ endAnimations("jumpToCurrentState", false /* cancel */);
+ }
+
+ @Override
+ public boolean isStateful() {
+ return true;
+ }
+
+ @Override
+ public boolean hasFocusStateSpecified() {
+ return true;
+ }
+
+ public void setPressed(boolean pressed) {
+ if (mDark != mLastDark && pressed) {
+ mRipplePaint = null;
+ mLastDark = mDark;
+ }
+ if (mSupportHardware) {
+ setPressedHardware(pressed);
+ } else {
+ setPressedSoftware(pressed);
+ }
+ }
+
+ /**
+ * Abort the ripple while it is delayed and before shown used only when setShouldDelayStartTouch
+ * is enabled.
+ */
+ public void abortDelayedRipple() {
+ mHandler.removeCallbacksAndMessages(null);
+ }
+
+ private void endAnimations(String reason, boolean cancel) {
+ Trace.beginSection("KeyButtonRipple.endAnim: reason=" + reason + " cancel=" + cancel);
+ Trace.endSection();
+ mVisible = false;
+ mTmpArray.addAll(mRunningAnimations);
+ int size = mTmpArray.size();
+ for (int i = 0; i < size; i++) {
+ Animator a = mTmpArray.get(i);
+ if (cancel) {
+ a.cancel();
+ } else {
+ a.end();
+ }
+ }
+ mTmpArray.clear();
+ mRunningAnimations.clear();
+ mHandler.removeCallbacksAndMessages(null);
+ }
+
+ private void setPressedSoftware(boolean pressed) {
+ if (pressed) {
+ if (mDelayTouchFeedback) {
+ if (mRunningAnimations.isEmpty()) {
+ mHandler.removeCallbacksAndMessages(null);
+ mHandler.postDelayed(this::enterSoftware, ViewConfiguration.getTapTimeout());
+ } else if (mVisible) {
+ enterSoftware();
+ }
+ } else {
+ enterSoftware();
+ }
+ } else {
+ exitSoftware();
+ }
+ }
+
+ private void enterSoftware() {
+ endAnimations("enterSoftware", true /* cancel */);
+ mVisible = true;
+ mGlowAlpha = getMaxGlowAlpha();
+ ObjectAnimator scaleAnimator = ObjectAnimator.ofFloat(this, "glowScale",
+ 0f, GLOW_MAX_SCALE_FACTOR);
+ scaleAnimator.setInterpolator(mInterpolator);
+ scaleAnimator.setDuration(ANIMATION_DURATION_SCALE);
+ scaleAnimator.addListener(mAnimatorListener);
+ scaleAnimator.start();
+ mRunningAnimations.add(scaleAnimator);
+
+ // With the delay, it could eventually animate the enter animation with no pressed state,
+ // then immediately show the exit animation. If this is skipped there will be no ripple.
+ if (mDelayTouchFeedback && !mPressed) {
+ exitSoftware();
+ }
+ }
+
+ private void exitSoftware() {
+ ObjectAnimator alphaAnimator = ObjectAnimator.ofFloat(this, "glowAlpha", mGlowAlpha, 0f);
+ alphaAnimator.setInterpolator(ALPHA_OUT_INTERPOLATOR);
+ alphaAnimator.setDuration(ANIMATION_DURATION_FADE);
+ alphaAnimator.addListener(mAnimatorListener);
+ alphaAnimator.start();
+ mRunningAnimations.add(alphaAnimator);
+ }
+
+ private void setPressedHardware(boolean pressed) {
+ if (pressed) {
+ if (mDelayTouchFeedback) {
+ if (mRunningAnimations.isEmpty()) {
+ mHandler.removeCallbacksAndMessages(null);
+ mHandler.postDelayed(this::enterHardware, ViewConfiguration.getTapTimeout());
+ } else if (mVisible) {
+ enterHardware();
+ }
+ } else {
+ enterHardware();
+ }
+ } else {
+ exitHardware();
+ }
+ }
+
+ /**
+ * Sets the left/top property for the round rect to {@code prop} depending on whether we are
+ * horizontal or vertical mode.
+ */
+ private void setExtendStart(CanvasProperty<Float> prop) {
+ if (isHorizontal()) {
+ mLeftProp = prop;
+ } else {
+ mTopProp = prop;
+ }
+ }
+
+ private CanvasProperty<Float> getExtendStart() {
+ return isHorizontal() ? mLeftProp : mTopProp;
+ }
+
+ /**
+ * Sets the right/bottom property for the round rect to {@code prop} depending on whether we are
+ * horizontal or vertical mode.
+ */
+ private void setExtendEnd(CanvasProperty<Float> prop) {
+ if (isHorizontal()) {
+ mRightProp = prop;
+ } else {
+ mBottomProp = prop;
+ }
+ }
+
+ private CanvasProperty<Float> getExtendEnd() {
+ return isHorizontal() ? mRightProp : mBottomProp;
+ }
+
+ private int getExtendSize() {
+ return isHorizontal() ? getBounds().width() : getBounds().height();
+ }
+
+ private int getRippleSize() {
+ int size = isHorizontal() ? getBounds().width() : getBounds().height();
+ return Math.min(size, mMaxWidth);
+ }
+
+ private void enterHardware() {
+ endAnimations("enterHardware", true /* cancel */);
+ mVisible = true;
+ mDrawingHardwareGlow = true;
+ setExtendStart(CanvasProperty.createFloat(getExtendSize() / 2));
+ final RenderNodeAnimator startAnim = new RenderNodeAnimator(getExtendStart(),
+ getExtendSize()/2 - GLOW_MAX_SCALE_FACTOR * getRippleSize()/2);
+ startAnim.setDuration(ANIMATION_DURATION_SCALE);
+ startAnim.setInterpolator(mInterpolator);
+ startAnim.addListener(mAnimatorListener);
+ startAnim.setTarget(mTargetView);
+
+ setExtendEnd(CanvasProperty.createFloat(getExtendSize() / 2));
+ final RenderNodeAnimator endAnim = new RenderNodeAnimator(getExtendEnd(),
+ getExtendSize()/2 + GLOW_MAX_SCALE_FACTOR * getRippleSize()/2);
+ endAnim.setDuration(ANIMATION_DURATION_SCALE);
+ endAnim.setInterpolator(mInterpolator);
+ endAnim.addListener(mAnimatorListener);
+ endAnim.addListener(mEnterHwTraceAnimator);
+ endAnim.setTarget(mTargetView);
+
+ if (isHorizontal()) {
+ mTopProp = CanvasProperty.createFloat(0f);
+ mBottomProp = CanvasProperty.createFloat(getBounds().height());
+ mRxProp = CanvasProperty.createFloat(getBounds().height()/2);
+ mRyProp = CanvasProperty.createFloat(getBounds().height()/2);
+ } else {
+ mLeftProp = CanvasProperty.createFloat(0f);
+ mRightProp = CanvasProperty.createFloat(getBounds().width());
+ mRxProp = CanvasProperty.createFloat(getBounds().width()/2);
+ mRyProp = CanvasProperty.createFloat(getBounds().width()/2);
+ }
+
+ mGlowScale = GLOW_MAX_SCALE_FACTOR;
+ mGlowAlpha = getMaxGlowAlpha();
+ mRipplePaint = getRipplePaint();
+ mRipplePaint.setAlpha((int) (mGlowAlpha * 255));
+ mPaintProp = CanvasProperty.createPaint(mRipplePaint);
+
+ startAnim.start();
+ endAnim.start();
+ mRunningAnimations.add(startAnim);
+ mRunningAnimations.add(endAnim);
+
+ invalidateSelf();
+
+ // With the delay, it could eventually animate the enter animation with no pressed state,
+ // then immediately show the exit animation. If this is skipped there will be no ripple.
+ if (mDelayTouchFeedback && !mPressed) {
+ exitHardware();
+ }
+ }
+
+ private void exitHardware() {
+ mPaintProp = CanvasProperty.createPaint(getRipplePaint());
+ final RenderNodeAnimator opacityAnim = new RenderNodeAnimator(mPaintProp,
+ RenderNodeAnimator.PAINT_ALPHA, 0);
+ opacityAnim.setDuration(ANIMATION_DURATION_FADE);
+ opacityAnim.setInterpolator(ALPHA_OUT_INTERPOLATOR);
+ opacityAnim.addListener(mAnimatorListener);
+ opacityAnim.addListener(mExitHwTraceAnimator);
+ opacityAnim.setTarget(mTargetView);
+
+ opacityAnim.start();
+ mRunningAnimations.add(opacityAnim);
+
+ invalidateSelf();
+ }
+
+ private final AnimatorListenerAdapter mAnimatorListener =
+ new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ mRunningAnimations.remove(animation);
+ if (mRunningAnimations.isEmpty() && !mPressed) {
+ mVisible = false;
+ mDrawingHardwareGlow = false;
+ invalidateSelf();
+ }
+ }
+ };
+
+ private static final class TraceAnimatorListener extends AnimatorListenerAdapter {
+ private final String mName;
+ TraceAnimatorListener(String name) {
+ mName = name;
+ }
+
+ @Override
+ public void onAnimationStart(Animator animation) {
+ Trace.beginSection("KeyButtonRipple.start." + mName);
+ Trace.endSection();
+ }
+
+ @Override
+ public void onAnimationCancel(Animator animation) {
+ Trace.beginSection("KeyButtonRipple.cancel." + mName);
+ Trace.endSection();
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ Trace.beginSection("KeyButtonRipple.end." + mName);
+ Trace.endSection();
+ }
+ }
+
+ /**
+ * Interpolator with a smooth log deceleration
+ */
+ private static final class LogInterpolator implements Interpolator {
+ @Override
+ public float getInterpolation(float input) {
+ return 1 - (float) Math.pow(400, -input * 1.4);
+ }
+ }
+}
diff --git a/core/java/android/inputmethodservice/navigationbar/KeyButtonView.java b/core/java/android/inputmethodservice/navigationbar/KeyButtonView.java
new file mode 100644
index 000000000000..74d30f8f8806
--- /dev/null
+++ b/core/java/android/inputmethodservice/navigationbar/KeyButtonView.java
@@ -0,0 +1,370 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.inputmethodservice.navigationbar;
+
+import static android.view.Display.INVALID_DISPLAY;
+import static android.view.KeyEvent.KEYCODE_BACK;
+import static android.view.KeyEvent.KEYCODE_UNKNOWN;
+import static android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK;
+import static android.view.accessibility.AccessibilityNodeInfo.ACTION_LONG_CLICK;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.drawable.Drawable;
+import android.inputmethodservice.InputMethodService;
+import android.media.AudioManager;
+import android.os.Bundle;
+import android.os.SystemClock;
+import android.util.AttributeSet;
+import android.view.HapticFeedbackConstants;
+import android.view.InputDevice;
+import android.view.KeyCharacterMap;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.SoundEffectConstants;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.inputmethod.InputConnection;
+import android.widget.ImageView;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+/**
+ * @hide
+ */
+public class KeyButtonView extends ImageView implements ButtonInterface {
+ private static final String TAG = KeyButtonView.class.getSimpleName();
+
+ private final boolean mPlaySounds;
+ private long mDownTime;
+ private boolean mTracking;
+ private int mCode;
+ private int mTouchDownX;
+ private int mTouchDownY;
+ private AudioManager mAudioManager;
+ private boolean mGestureAborted;
+ @VisibleForTesting boolean mLongClicked;
+ private OnClickListener mOnClickListener;
+ private final KeyButtonRipple mRipple;
+ private final Paint mOvalBgPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
+ private float mDarkIntensity;
+ private boolean mHasOvalBg = false;
+
+ private final Runnable mCheckLongPress = new Runnable() {
+ public void run() {
+ if (isPressed()) {
+ // Log.d("KeyButtonView", "longpressed: " + this);
+ if (isLongClickable()) {
+ // Just an old-fashioned ImageView
+ performLongClick();
+ mLongClicked = true;
+ } else {
+ if (mCode != KEYCODE_UNKNOWN) {
+ sendEvent(KeyEvent.ACTION_DOWN, KeyEvent.FLAG_LONG_PRESS);
+ sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_LONG_CLICKED);
+ }
+ mLongClicked = true;
+ }
+ }
+ }
+ };
+
+ public KeyButtonView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ // TODO(b/205803355): Figure out better place to set this.
+ switch (getId()) {
+ case com.android.internal.R.id.input_method_nav_back:
+ mCode = KEYCODE_BACK;
+ break;
+ default:
+ mCode = KEYCODE_UNKNOWN;
+ break;
+ }
+
+ mPlaySounds = true;
+
+ setClickable(true);
+ mAudioManager = context.getSystemService(AudioManager.class);
+
+ mRipple = new KeyButtonRipple(context, this,
+ com.android.internal.R.dimen.input_method_nav_key_button_ripple_max_width);
+ setBackground(mRipple);
+ setWillNotDraw(false);
+ forceHasOverlappingRendering(false);
+ }
+
+ @Override
+ public boolean isClickable() {
+ return mCode != KEYCODE_UNKNOWN || super.isClickable();
+ }
+
+ public void setCode(int code) {
+ mCode = code;
+ }
+
+ @Override
+ public void setOnClickListener(OnClickListener onClickListener) {
+ super.setOnClickListener(onClickListener);
+ mOnClickListener = onClickListener;
+ }
+
+ @Override
+ public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
+ super.onInitializeAccessibilityNodeInfo(info);
+ if (mCode != KEYCODE_UNKNOWN) {
+ info.addAction(new AccessibilityNodeInfo.AccessibilityAction(ACTION_CLICK, null));
+ if (isLongClickable()) {
+ info.addAction(
+ new AccessibilityNodeInfo.AccessibilityAction(ACTION_LONG_CLICK, null));
+ }
+ }
+ }
+
+ @Override
+ protected void onWindowVisibilityChanged(int visibility) {
+ super.onWindowVisibilityChanged(visibility);
+ if (visibility != View.VISIBLE) {
+ jumpDrawablesToCurrentState();
+ }
+ }
+
+ @Override
+ public boolean performAccessibilityActionInternal(int action, Bundle arguments) {
+ if (action == ACTION_CLICK && mCode != KEYCODE_UNKNOWN) {
+ sendEvent(KeyEvent.ACTION_DOWN, 0, SystemClock.uptimeMillis());
+ sendEvent(KeyEvent.ACTION_UP, mTracking ? KeyEvent.FLAG_TRACKING : 0);
+ mTracking = false;
+ sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
+ playSoundEffect(SoundEffectConstants.CLICK);
+ return true;
+ } else if (action == ACTION_LONG_CLICK && mCode != KEYCODE_UNKNOWN) {
+ sendEvent(KeyEvent.ACTION_DOWN, KeyEvent.FLAG_LONG_PRESS);
+ sendEvent(KeyEvent.ACTION_UP, mTracking ? KeyEvent.FLAG_TRACKING : 0);
+ mTracking = false;
+ sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_LONG_CLICKED);
+ return true;
+ }
+ return super.performAccessibilityActionInternal(action, arguments);
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent ev) {
+ final boolean showSwipeUI = false; // mOverviewProxyService.shouldShowSwipeUpUI();
+ final int action = ev.getAction();
+ int x, y;
+ if (action == MotionEvent.ACTION_DOWN) {
+ mGestureAborted = false;
+ }
+ if (mGestureAborted) {
+ setPressed(false);
+ return false;
+ }
+
+ switch (action) {
+ case MotionEvent.ACTION_DOWN:
+ mDownTime = SystemClock.uptimeMillis();
+ mLongClicked = false;
+ setPressed(true);
+
+ // Use raw X and Y to detect gestures in case a parent changes the x and y values
+ mTouchDownX = (int) ev.getRawX();
+ mTouchDownY = (int) ev.getRawY();
+ if (mCode != KEYCODE_UNKNOWN) {
+ sendEvent(KeyEvent.ACTION_DOWN, 0, mDownTime);
+ } else {
+ // Provide the same haptic feedback that the system offers for virtual keys.
+ performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
+ }
+ if (!showSwipeUI) {
+ playSoundEffect(SoundEffectConstants.CLICK);
+ }
+ removeCallbacks(mCheckLongPress);
+ postDelayed(mCheckLongPress, ViewConfiguration.getLongPressTimeout());
+ break;
+ case MotionEvent.ACTION_MOVE:
+ x = (int)ev.getRawX();
+ y = (int)ev.getRawY();
+
+ float slop = getQuickStepTouchSlopPx(getContext());
+ if (Math.abs(x - mTouchDownX) > slop || Math.abs(y - mTouchDownY) > slop) {
+ // When quick step is enabled, prevent animating the ripple triggered by
+ // setPressed and decide to run it on touch up
+ setPressed(false);
+ removeCallbacks(mCheckLongPress);
+ }
+ break;
+ case MotionEvent.ACTION_CANCEL:
+ setPressed(false);
+ if (mCode != KEYCODE_UNKNOWN) {
+ sendEvent(KeyEvent.ACTION_UP, KeyEvent.FLAG_CANCELED);
+ }
+ removeCallbacks(mCheckLongPress);
+ break;
+ case MotionEvent.ACTION_UP:
+ final boolean doIt = isPressed() && !mLongClicked;
+ setPressed(false);
+ final boolean doHapticFeedback = (SystemClock.uptimeMillis() - mDownTime) > 150;
+ if (showSwipeUI) {
+ if (doIt) {
+ // Apply haptic feedback on touch up since there is none on touch down
+ performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
+ playSoundEffect(SoundEffectConstants.CLICK);
+ }
+ } else if (doHapticFeedback && !mLongClicked) {
+ // Always send a release ourselves because it doesn't seem to be sent elsewhere
+ // and it feels weird to sometimes get a release haptic and other times not.
+ performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY_RELEASE);
+ }
+ if (mCode != KEYCODE_UNKNOWN) {
+ if (doIt) {
+ sendEvent(KeyEvent.ACTION_UP, mTracking ? KeyEvent.FLAG_TRACKING : 0);
+ mTracking = false;
+ sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
+ } else {
+ sendEvent(KeyEvent.ACTION_UP, KeyEvent.FLAG_CANCELED);
+ }
+ } else {
+ // no key code, just a regular ImageView
+ if (doIt && mOnClickListener != null) {
+ mOnClickListener.onClick(this);
+ sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
+ }
+ }
+ removeCallbacks(mCheckLongPress);
+ break;
+ }
+
+ return true;
+ }
+
+ @Override
+ public void setImageDrawable(Drawable drawable) {
+ super.setImageDrawable(drawable);
+
+ if (drawable == null) {
+ return;
+ }
+ KeyButtonDrawable keyButtonDrawable = (KeyButtonDrawable) drawable;
+ keyButtonDrawable.setDarkIntensity(mDarkIntensity);
+ mHasOvalBg = keyButtonDrawable.hasOvalBg();
+ if (mHasOvalBg) {
+ mOvalBgPaint.setColor(keyButtonDrawable.getDrawableBackgroundColor());
+ }
+ mRipple.setType(keyButtonDrawable.hasOvalBg() ? KeyButtonRipple.Type.OVAL
+ : KeyButtonRipple.Type.ROUNDED_RECT);
+ }
+
+ public void playSoundEffect(int soundConstant) {
+ if (!mPlaySounds) return;
+ mAudioManager.playSoundEffect(soundConstant);
+ }
+
+ public void sendEvent(int action, int flags) {
+ sendEvent(action, flags, SystemClock.uptimeMillis());
+ }
+
+ private void sendEvent(int action, int flags, long when) {
+ if (mCode == KeyEvent.KEYCODE_BACK && flags != KeyEvent.FLAG_LONG_PRESS) {
+ if (action == MotionEvent.ACTION_UP) {
+ // TODO(b/205803355): Implement notifyBackAction();
+ }
+ }
+
+ // TODO(b/205803355): Consolidate this logic to somewhere else.
+ if (mContext instanceof InputMethodService) {
+ final int repeatCount = (flags & KeyEvent.FLAG_LONG_PRESS) != 0 ? 1 : 0;
+ final KeyEvent ev = new KeyEvent(mDownTime, when, action, mCode, repeatCount,
+ 0, KeyCharacterMap.VIRTUAL_KEYBOARD, 0,
+ flags | KeyEvent.FLAG_SOFT_KEYBOARD | KeyEvent.FLAG_VIRTUAL_HARD_KEY,
+ InputDevice.SOURCE_KEYBOARD);
+ int displayId = INVALID_DISPLAY;
+
+ // Make KeyEvent work on multi-display environment
+ if (getDisplay() != null) {
+ displayId = getDisplay().getDisplayId();
+ }
+ if (displayId != INVALID_DISPLAY) {
+ ev.setDisplayId(displayId);
+ }
+ final InputMethodService ims = (InputMethodService) mContext;
+ final boolean handled;
+ switch (action) {
+ case KeyEvent.ACTION_DOWN:
+ handled = ims.onKeyDown(ev.getKeyCode(), ev);
+ mTracking = handled && ev.getRepeatCount() == 0 &&
+ (ev.getFlags() & KeyEvent.FLAG_START_TRACKING) != 0;
+ break;
+ case KeyEvent.ACTION_UP:
+ handled = ims.onKeyUp(ev.getKeyCode(), ev);
+ break;
+ default:
+ handled = false;
+ break;
+ }
+ if (!handled) {
+ final InputConnection ic = ims.getCurrentInputConnection();
+ if (ic != null) {
+ ic.sendKeyEvent(ev);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void setDarkIntensity(float darkIntensity) {
+ mDarkIntensity = darkIntensity;
+
+ Drawable drawable = getDrawable();
+ if (drawable != null) {
+ ((KeyButtonDrawable) drawable).setDarkIntensity(darkIntensity);
+ // Since we reuse the same drawable for multiple views, we need to invalidate the view
+ // manually.
+ invalidate();
+ }
+ mRipple.setDarkIntensity(darkIntensity);
+ }
+
+ @Override
+ public void setDelayTouchFeedback(boolean shouldDelay) {
+ mRipple.setDelayTouchFeedback(shouldDelay);
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ if (mHasOvalBg) {
+ int d = Math.min(getWidth(), getHeight());
+ canvas.drawOval(0, 0, d, d, mOvalBgPaint);
+ }
+ super.draw(canvas);
+ }
+
+ /**
+ * Ratio of quickstep touch slop (when system takes over the touch) to view touch slop
+ */
+ public static final float QUICKSTEP_TOUCH_SLOP_RATIO = 3;
+
+ /**
+ * Touch slop for quickstep gesture
+ */
+ private static float getQuickStepTouchSlopPx(Context context) {
+ return QUICKSTEP_TOUCH_SLOP_RATIO * ViewConfiguration.get(context).getScaledTouchSlop();
+ }
+}
diff --git a/core/java/android/inputmethodservice/navigationbar/NavigationBarConstants.java b/core/java/android/inputmethodservice/navigationbar/NavigationBarConstants.java
new file mode 100644
index 000000000000..93c54395f972
--- /dev/null
+++ b/core/java/android/inputmethodservice/navigationbar/NavigationBarConstants.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.inputmethodservice.navigationbar;
+
+import android.annotation.ColorInt;
+
+final class NavigationBarConstants {
+ private NavigationBarConstants() {
+ // Not intended to be instantiated.
+ }
+
+ // Copied from "navbar_back_button_ime_offset"
+ // TODO(b/215443343): Handle this in the drawable then remove this constant.
+ static final float NAVBAR_BACK_BUTTON_IME_OFFSET = 2.0f;
+
+ // Copied from "light_mode_icon_color_single_tone" at packages/SettingsLib/res/values/colors.xml
+ @ColorInt
+ static final int LIGHT_MODE_ICON_COLOR_SINGLE_TONE = 0xffffffff;
+
+ // Copied from "dark_mode_icon_color_single_tone" at packages/SettingsLib/res/values/colors.xml
+ @ColorInt
+ static final int DARK_MODE_ICON_COLOR_SINGLE_TONE = 0x99000000;
+
+ // Copied from "navigation_bar_deadzone_hold"
+ static final int NAVIGATION_BAR_DEADZONE_HOLD = 333;
+
+ // Copied from "navigation_bar_deadzone_hold"
+ static final int NAVIGATION_BAR_DEADZONE_DECAY = 333;
+
+ // Copied from "navigation_bar_deadzone_size"
+ static final float NAVIGATION_BAR_DEADZONE_SIZE = 12.0f;
+
+ // Copied from "navigation_bar_deadzone_size_max"
+ static final float NAVIGATION_BAR_DEADZONE_SIZE_MAX = 32.0f;
+
+ // Copied from "nav_key_button_shadow_offset_x"
+ static final float NAV_KEY_BUTTON_SHADOW_OFFSET_X = 0.0f;
+
+ // Copied from "nav_key_button_shadow_offset_y"
+ static final float NAV_KEY_BUTTON_SHADOW_OFFSET_Y = 1.0f;
+
+ // Copied from "nav_key_button_shadow_radius"
+ static final float NAV_KEY_BUTTON_SHADOW_RADIUS = 0.5f;
+
+ // Copied from "nav_key_button_shadow_color"
+ @ColorInt
+ static final int NAV_KEY_BUTTON_SHADOW_COLOR = 0x30000000;
+}
diff --git a/core/java/android/inputmethodservice/navigationbar/NavigationBarFrame.java b/core/java/android/inputmethodservice/navigationbar/NavigationBarFrame.java
new file mode 100644
index 000000000000..f01173e0fdae
--- /dev/null
+++ b/core/java/android/inputmethodservice/navigationbar/NavigationBarFrame.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.inputmethodservice.navigationbar;
+
+import static android.view.MotionEvent.ACTION_OUTSIDE;
+
+import android.annotation.AttrRes;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.widget.FrameLayout;
+
+/**
+ * @hide
+ */
+public final class NavigationBarFrame extends FrameLayout {
+
+ private DeadZone mDeadZone = null;
+
+ public NavigationBarFrame(@NonNull Context context) {
+ super(context);
+ }
+
+ public NavigationBarFrame(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public NavigationBarFrame(@NonNull Context context, @Nullable AttributeSet attrs,
+ @AttrRes int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public void setDeadZone(@NonNull DeadZone deadZone) {
+ mDeadZone = deadZone;
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent event) {
+ if (event.getAction() == ACTION_OUTSIDE) {
+ if (mDeadZone != null) {
+ return mDeadZone.onTouchEvent(event);
+ }
+ }
+ return super.dispatchTouchEvent(event);
+ }
+} \ No newline at end of file
diff --git a/core/java/android/inputmethodservice/navigationbar/NavigationBarInflaterView.java b/core/java/android/inputmethodservice/navigationbar/NavigationBarInflaterView.java
new file mode 100644
index 000000000000..d488890b27d4
--- /dev/null
+++ b/core/java/android/inputmethodservice/navigationbar/NavigationBarInflaterView.java
@@ -0,0 +1,429 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.inputmethodservice.navigationbar;
+
+import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
+
+import android.annotation.Nullable;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.inputmethodservice.navigationbar.ReverseLinearLayout.ReverseRelativeLayout;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.util.SparseArray;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.Space;
+
+/**
+ * @hide
+ */
+public final class NavigationBarInflaterView extends FrameLayout {
+
+ private static final String TAG = "NavBarInflater";
+
+ public static final String NAV_BAR_VIEWS = "sysui_nav_bar";
+ public static final String NAV_BAR_LEFT = "sysui_nav_bar_left";
+ public static final String NAV_BAR_RIGHT = "sysui_nav_bar_right";
+
+ public static final String MENU_IME_ROTATE = "menu_ime";
+ public static final String BACK = "back";
+ public static final String HOME = "home";
+ public static final String RECENT = "recent";
+ public static final String NAVSPACE = "space";
+ public static final String CLIPBOARD = "clipboard";
+ public static final String HOME_HANDLE = "home_handle";
+ public static final String KEY = "key";
+ public static final String LEFT = "left";
+ public static final String RIGHT = "right";
+ public static final String CONTEXTUAL = "contextual";
+ public static final String IME_SWITCHER = "ime_switcher";
+
+ public static final String GRAVITY_SEPARATOR = ";";
+ public static final String BUTTON_SEPARATOR = ",";
+
+ public static final String SIZE_MOD_START = "[";
+ public static final String SIZE_MOD_END = "]";
+
+ public static final String KEY_CODE_START = "(";
+ public static final String KEY_IMAGE_DELIM = ":";
+ public static final String KEY_CODE_END = ")";
+ private static final String WEIGHT_SUFFIX = "W";
+ private static final String WEIGHT_CENTERED_SUFFIX = "WC";
+ private static final String ABSOLUTE_SUFFIX = "A";
+ private static final String ABSOLUTE_VERTICAL_CENTERED_SUFFIX = "C";
+
+ // Copied from "config_navBarLayoutHandle:
+ private static final String CONFIG_NAV_BAR_LAYOUT_HANDLE =
+ "back[70AC];home_handle;ime_switcher[70AC]";
+
+ protected LayoutInflater mLayoutInflater;
+ protected LayoutInflater mLandscapeInflater;
+
+ protected FrameLayout mHorizontal;
+
+ SparseArray<ButtonDispatcher> mButtonDispatchers;
+
+ private View mLastPortrait;
+ private View mLastLandscape;
+
+ private boolean mAlternativeOrder;
+
+ public NavigationBarInflaterView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ createInflaters();
+ }
+
+ void createInflaters() {
+ mLayoutInflater = LayoutInflater.from(mContext);
+ Configuration landscape = new Configuration();
+ landscape.setTo(mContext.getResources().getConfiguration());
+ landscape.orientation = Configuration.ORIENTATION_LANDSCAPE;
+ mLandscapeInflater = LayoutInflater.from(mContext.createConfigurationContext(landscape));
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ inflateChildren();
+ clearViews();
+ inflateLayout(getDefaultLayout());
+ }
+
+ private void inflateChildren() {
+ removeAllViews();
+ mHorizontal = (FrameLayout) mLayoutInflater.inflate(
+ com.android.internal.R.layout.input_method_navigation_layout,
+ this /* root */, false /* attachToRoot */);
+ addView(mHorizontal);
+ updateAlternativeOrder();
+ }
+
+ String getDefaultLayout() {
+ return CONFIG_NAV_BAR_LAYOUT_HANDLE;
+ }
+
+ public void setButtonDispatchers(SparseArray<ButtonDispatcher> buttonDispatchers) {
+ mButtonDispatchers = buttonDispatchers;
+ for (int i = 0; i < buttonDispatchers.size(); i++) {
+ initiallyFill(buttonDispatchers.valueAt(i));
+ }
+ }
+
+ void updateButtonDispatchersCurrentView() {
+ if (mButtonDispatchers != null) {
+ View view = mHorizontal;
+ for (int i = 0; i < mButtonDispatchers.size(); i++) {
+ final ButtonDispatcher dispatcher = mButtonDispatchers.valueAt(i);
+ dispatcher.setCurrentView(view);
+ }
+ }
+ }
+
+ void setAlternativeOrder(boolean alternativeOrder) {
+ if (alternativeOrder != mAlternativeOrder) {
+ mAlternativeOrder = alternativeOrder;
+ updateAlternativeOrder();
+ }
+ }
+
+ private void updateAlternativeOrder() {
+ updateAlternativeOrder(mHorizontal.findViewById(
+ com.android.internal.R.id.input_method_nav_ends_group));
+ updateAlternativeOrder(mHorizontal.findViewById(
+ com.android.internal.R.id.input_method_nav_center_group));
+ }
+
+ private void updateAlternativeOrder(View v) {
+ if (v instanceof ReverseLinearLayout) {
+ ((ReverseLinearLayout) v).setAlternativeOrder(mAlternativeOrder);
+ }
+ }
+
+ private void initiallyFill(
+ ButtonDispatcher buttonDispatcher) {
+ addAll(buttonDispatcher, mHorizontal.findViewById(
+ com.android.internal.R.id.input_method_nav_ends_group));
+ addAll(buttonDispatcher, mHorizontal.findViewById(
+ com.android.internal.R.id.input_method_nav_center_group));
+ }
+
+ private void addAll(ButtonDispatcher buttonDispatcher, ViewGroup parent) {
+ for (int i = 0; i < parent.getChildCount(); i++) {
+ // Need to manually search for each id, just in case each group has more than one
+ // of a single id. It probably mostly a waste of time, but shouldn't take long
+ // and will only happen once.
+ if (parent.getChildAt(i).getId() == buttonDispatcher.getId()) {
+ buttonDispatcher.addView(parent.getChildAt(i));
+ }
+ if (parent.getChildAt(i) instanceof ViewGroup) {
+ addAll(buttonDispatcher, (ViewGroup) parent.getChildAt(i));
+ }
+ }
+ }
+
+ protected void inflateLayout(String newLayout) {
+ if (newLayout == null) {
+ newLayout = getDefaultLayout();
+ }
+ String[] sets = newLayout.split(GRAVITY_SEPARATOR, 3);
+ if (sets.length != 3) {
+ Log.d(TAG, "Invalid layout.");
+ newLayout = getDefaultLayout();
+ sets = newLayout.split(GRAVITY_SEPARATOR, 3);
+ }
+ String[] start = sets[0].split(BUTTON_SEPARATOR);
+ String[] center = sets[1].split(BUTTON_SEPARATOR);
+ String[] end = sets[2].split(BUTTON_SEPARATOR);
+ // Inflate these in start to end order or accessibility traversal will be messed up.
+ inflateButtons(start, mHorizontal.findViewById(
+ com.android.internal.R.id.input_method_nav_ends_group),
+ false /* landscape */, true /* start */);
+
+ inflateButtons(center, mHorizontal.findViewById(
+ com.android.internal.R.id.input_method_nav_center_group),
+ false /* landscape */, false /* start */);
+
+ addGravitySpacer(mHorizontal.findViewById(
+ com.android.internal.R.id.input_method_nav_ends_group));
+
+ inflateButtons(end, mHorizontal.findViewById(
+ com.android.internal.R.id.input_method_nav_ends_group),
+ false /* landscape */, false /* start */);
+
+ updateButtonDispatchersCurrentView();
+ }
+
+ private void addGravitySpacer(LinearLayout layout) {
+ layout.addView(new Space(mContext), new LinearLayout.LayoutParams(0, 0, 1));
+ }
+
+ private void inflateButtons(String[] buttons, ViewGroup parent, boolean landscape,
+ boolean start) {
+ for (int i = 0; i < buttons.length; i++) {
+ inflateButton(buttons[i], parent, landscape, start);
+ }
+ }
+
+ private ViewGroup.LayoutParams copy(ViewGroup.LayoutParams layoutParams) {
+ if (layoutParams instanceof LinearLayout.LayoutParams) {
+ return new LinearLayout.LayoutParams(layoutParams.width, layoutParams.height,
+ ((LinearLayout.LayoutParams) layoutParams).weight);
+ }
+ return new LayoutParams(layoutParams.width, layoutParams.height);
+ }
+
+ @Nullable
+ protected View inflateButton(String buttonSpec, ViewGroup parent, boolean landscape,
+ boolean start) {
+ LayoutInflater inflater = landscape ? mLandscapeInflater : mLayoutInflater;
+ View v = createView(buttonSpec, parent, inflater);
+ if (v == null) return null;
+
+ v = applySize(v, buttonSpec, landscape, start);
+ parent.addView(v);
+ addToDispatchers(v);
+ View lastView = landscape ? mLastLandscape : mLastPortrait;
+ View accessibilityView = v;
+ if (v instanceof ReverseRelativeLayout) {
+ accessibilityView = ((ReverseRelativeLayout) v).getChildAt(0);
+ }
+ if (lastView != null) {
+ accessibilityView.setAccessibilityTraversalAfter(lastView.getId());
+ }
+ if (landscape) {
+ mLastLandscape = accessibilityView;
+ } else {
+ mLastPortrait = accessibilityView;
+ }
+ return v;
+ }
+
+ private View applySize(View v, String buttonSpec, boolean landscape, boolean start) {
+ String sizeStr = extractSize(buttonSpec);
+ if (sizeStr == null) return v;
+
+ if (sizeStr.contains(WEIGHT_SUFFIX) || sizeStr.contains(ABSOLUTE_SUFFIX)) {
+ // To support gravity, wrap in RelativeLayout and apply gravity to it.
+ // Children wanting to use gravity must be smaller than the frame.
+ ReverseRelativeLayout frame = new ReverseRelativeLayout(mContext);
+ LayoutParams childParams = new LayoutParams(v.getLayoutParams());
+
+ // Compute gravity to apply
+ int gravity = (landscape) ? (start ? Gravity.TOP : Gravity.BOTTOM)
+ : (start ? Gravity.START : Gravity.END);
+ if (sizeStr.endsWith(WEIGHT_CENTERED_SUFFIX)) {
+ gravity = Gravity.CENTER;
+ } else if (sizeStr.endsWith(ABSOLUTE_VERTICAL_CENTERED_SUFFIX)) {
+ gravity = Gravity.CENTER_VERTICAL;
+ }
+
+ // Set default gravity, flipped if needed in reversed layouts (270 RTL and 90 LTR)
+ frame.setDefaultGravity(gravity);
+ frame.setGravity(gravity); // Apply gravity to root
+
+ frame.addView(v, childParams);
+
+ if (sizeStr.contains(WEIGHT_SUFFIX)) {
+ // Use weighting to set the width of the frame
+ float weight = Float.parseFloat(
+ sizeStr.substring(0, sizeStr.indexOf(WEIGHT_SUFFIX)));
+ frame.setLayoutParams(new LinearLayout.LayoutParams(0, MATCH_PARENT, weight));
+ } else {
+ int width = (int) convertDpToPx(mContext,
+ Float.parseFloat(sizeStr.substring(0, sizeStr.indexOf(ABSOLUTE_SUFFIX))));
+ frame.setLayoutParams(new LinearLayout.LayoutParams(width, MATCH_PARENT));
+ }
+
+ // Ensure ripples can be drawn outside bounds
+ frame.setClipChildren(false);
+ frame.setClipToPadding(false);
+
+ return frame;
+ }
+
+ float size = Float.parseFloat(sizeStr);
+ ViewGroup.LayoutParams params = v.getLayoutParams();
+ params.width = (int) (params.width * size);
+ return v;
+ }
+
+ View createView(String buttonSpec, ViewGroup parent, LayoutInflater inflater) {
+ View v = null;
+ String button = extractButton(buttonSpec);
+ if (LEFT.equals(button)) {
+ button = extractButton(NAVSPACE);
+ } else if (RIGHT.equals(button)) {
+ button = extractButton(MENU_IME_ROTATE);
+ }
+ if (HOME.equals(button)) {
+ //v = inflater.inflate(R.layout.home, parent, false);
+ } else if (BACK.equals(button)) {
+ v = inflater.inflate(com.android.internal.R.layout.input_method_nav_back, parent,
+ false);
+ } else if (RECENT.equals(button)) {
+ //v = inflater.inflate(R.layout.recent_apps, parent, false);
+ } else if (MENU_IME_ROTATE.equals(button)) {
+ //v = inflater.inflate(R.layout.menu_ime, parent, false);
+ } else if (NAVSPACE.equals(button)) {
+ //v = inflater.inflate(R.layout.nav_key_space, parent, false);
+ } else if (CLIPBOARD.equals(button)) {
+ //v = inflater.inflate(R.layout.clipboard, parent, false);
+ } else if (CONTEXTUAL.equals(button)) {
+ //v = inflater.inflate(R.layout.contextual, parent, false);
+ } else if (HOME_HANDLE.equals(button)) {
+ v = inflater.inflate(com.android.internal.R.layout.input_method_nav_home_handle,
+ parent, false);
+ } else if (IME_SWITCHER.equals(button)) {
+ v = inflater.inflate(com.android.internal.R.layout.input_method_nav_ime_switcher,
+ parent, false);
+ } else if (button.startsWith(KEY)) {
+ /*
+ String uri = extractImage(button);
+ int code = extractKeycode(button);
+ v = inflater.inflate(R.layout.custom_key, parent, false);
+ ((KeyButtonView) v).setCode(code);
+ if (uri != null) {
+ if (uri.contains(":")) {
+ ((KeyButtonView) v).loadAsync(Icon.createWithContentUri(uri));
+ } else if (uri.contains("/")) {
+ int index = uri.indexOf('/');
+ String pkg = uri.substring(0, index);
+ int id = Integer.parseInt(uri.substring(index + 1));
+ ((KeyButtonView) v).loadAsync(Icon.createWithResource(pkg, id));
+ }
+ }
+ */
+ }
+ return v;
+ }
+
+ /*
+ public static String extractImage(String buttonSpec) {
+ if (!buttonSpec.contains(KEY_IMAGE_DELIM)) {
+ return null;
+ }
+ final int start = buttonSpec.indexOf(KEY_IMAGE_DELIM);
+ String subStr = buttonSpec.substring(start + 1, buttonSpec.indexOf(KEY_CODE_END));
+ return subStr;
+ }
+
+ public static int extractKeycode(String buttonSpec) {
+ if (!buttonSpec.contains(KEY_CODE_START)) {
+ return 1;
+ }
+ final int start = buttonSpec.indexOf(KEY_CODE_START);
+ String subStr = buttonSpec.substring(start + 1, buttonSpec.indexOf(KEY_IMAGE_DELIM));
+ return Integer.parseInt(subStr);
+ }
+ */
+
+ public static String extractSize(String buttonSpec) {
+ if (!buttonSpec.contains(SIZE_MOD_START)) {
+ return null;
+ }
+ final int sizeStart = buttonSpec.indexOf(SIZE_MOD_START);
+ return buttonSpec.substring(sizeStart + 1, buttonSpec.indexOf(SIZE_MOD_END));
+ }
+
+ public static String extractButton(String buttonSpec) {
+ if (!buttonSpec.contains(SIZE_MOD_START)) {
+ return buttonSpec;
+ }
+ return buttonSpec.substring(0, buttonSpec.indexOf(SIZE_MOD_START));
+ }
+
+ private void addToDispatchers(View v) {
+ if (mButtonDispatchers != null) {
+ final int indexOfKey = mButtonDispatchers.indexOfKey(v.getId());
+ if (indexOfKey >= 0) {
+ mButtonDispatchers.valueAt(indexOfKey).addView(v);
+ }
+ if (v instanceof ViewGroup) {
+ final ViewGroup viewGroup = (ViewGroup)v;
+ final int N = viewGroup.getChildCount();
+ for (int i = 0; i < N; i++) {
+ addToDispatchers(viewGroup.getChildAt(i));
+ }
+ }
+ }
+ }
+
+ private void clearViews() {
+ if (mButtonDispatchers != null) {
+ for (int i = 0; i < mButtonDispatchers.size(); i++) {
+ mButtonDispatchers.valueAt(i).clear();
+ }
+ }
+ clearAllChildren(mHorizontal.findViewById(
+ com.android.internal.R.id.input_method_nav_buttons));
+ }
+
+ private void clearAllChildren(ViewGroup group) {
+ for (int i = 0; i < group.getChildCount(); i++) {
+ ((ViewGroup) group.getChildAt(i)).removeAllViews();
+ }
+ }
+
+ private static float convertDpToPx(Context context, float dp) {
+ return dp * context.getResources().getDisplayMetrics().density;
+ }
+}
diff --git a/core/java/android/inputmethodservice/navigationbar/NavigationBarUtils.java b/core/java/android/inputmethodservice/navigationbar/NavigationBarUtils.java
new file mode 100644
index 000000000000..c6096d7ba0a1
--- /dev/null
+++ b/core/java/android/inputmethodservice/navigationbar/NavigationBarUtils.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.inputmethodservice.navigationbar;
+
+import static android.util.TypedValue.COMPLEX_UNIT_DIP;
+
+import android.content.res.Resources;
+import android.util.TypedValue;
+
+final class NavigationBarUtils {
+ private NavigationBarUtils() {
+ // Not intended to be instantiated.
+ }
+
+ /**
+ * A utility method to convert "dp" to "pixel".
+ *
+ * <p>TODO(b/215443343): Remove this method by migrating DP values from
+ * {@link NavigationBarConstants} to resource files.</p>
+ *
+ * @param dpValue "dp" value to be converted to "pixel"
+ * @param res {@link Resources} to be used when dealing with "dp".
+ * @return the pixels for a given dp value.
+ */
+ static int dpToPx(float dpValue, Resources res) {
+ return (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, dpValue, res.getDisplayMetrics());
+ }
+}
diff --git a/core/java/android/inputmethodservice/navigationbar/NavigationBarView.java b/core/java/android/inputmethodservice/navigationbar/NavigationBarView.java
new file mode 100644
index 000000000000..42847784dd2b
--- /dev/null
+++ b/core/java/android/inputmethodservice/navigationbar/NavigationBarView.java
@@ -0,0 +1,380 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.inputmethodservice.navigationbar;
+
+import static android.inputmethodservice.navigationbar.NavigationBarConstants.DARK_MODE_ICON_COLOR_SINGLE_TONE;
+import static android.inputmethodservice.navigationbar.NavigationBarConstants.LIGHT_MODE_ICON_COLOR_SINGLE_TONE;
+import static android.inputmethodservice.navigationbar.NavigationBarConstants.NAVBAR_BACK_BUTTON_IME_OFFSET;
+import static android.inputmethodservice.navigationbar.NavigationBarUtils.dpToPx;
+import static android.view.WindowManagerPolicyConstants.NAV_BAR_MODE_GESTURAL;
+
+import android.animation.ObjectAnimator;
+import android.animation.PropertyValuesHolder;
+import android.annotation.DrawableRes;
+import android.annotation.FloatRange;
+import android.app.StatusBarManager;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.graphics.Canvas;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.util.SparseArray;
+import android.view.Display;
+import android.view.MotionEvent;
+import android.view.Surface;
+import android.view.View;
+import android.view.animation.Interpolator;
+import android.view.animation.PathInterpolator;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.FrameLayout;
+
+import java.util.function.Consumer;
+
+/**
+ * @hide
+ */
+public final class NavigationBarView extends FrameLayout {
+ final static boolean DEBUG = false;
+ final static String TAG = "NavBarView";
+
+ // Copied from com.android.systemui.animation.Interpolators#FAST_OUT_SLOW_IN
+ private static final Interpolator FAST_OUT_SLOW_IN = new PathInterpolator(0.4f, 0f, 0.2f, 1f);
+
+ // The current view is always mHorizontal.
+ View mCurrentView = null;
+ private View mHorizontal;
+
+ private int mCurrentRotation = -1;
+
+ int mDisabledFlags = 0;
+ int mNavigationIconHints = StatusBarManager.NAVIGATION_HINT_BACK_ALT;
+ private final int mNavBarMode = NAV_BAR_MODE_GESTURAL;
+
+ private KeyButtonDrawable mBackIcon;
+ private KeyButtonDrawable mImeSwitcherIcon;
+ private Context mLightContext;
+ private final int mLightIconColor;
+ private final int mDarkIconColor;
+
+ private final android.inputmethodservice.navigationbar.DeadZone mDeadZone;
+ private boolean mDeadZoneConsuming = false;
+
+ private final SparseArray<ButtonDispatcher> mButtonDispatchers = new SparseArray<>();
+ private Configuration mConfiguration;
+ private Configuration mTmpLastConfiguration;
+
+ private NavigationBarInflaterView mNavigationInflaterView;
+
+ public NavigationBarView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ mLightContext = context;
+ mLightIconColor = LIGHT_MODE_ICON_COLOR_SINGLE_TONE;
+ mDarkIconColor = DARK_MODE_ICON_COLOR_SINGLE_TONE;
+
+ mConfiguration = new Configuration();
+ mTmpLastConfiguration = new Configuration();
+ mConfiguration.updateFrom(context.getResources().getConfiguration());
+
+ mButtonDispatchers.put(com.android.internal.R.id.input_method_nav_back,
+ new ButtonDispatcher(com.android.internal.R.id.input_method_nav_back));
+ mButtonDispatchers.put(com.android.internal.R.id.input_method_nav_ime_switcher,
+ new ButtonDispatcher(com.android.internal.R.id.input_method_nav_ime_switcher));
+ mButtonDispatchers.put(com.android.internal.R.id.input_method_nav_home_handle,
+ new ButtonDispatcher(com.android.internal.R.id.input_method_nav_home_handle));
+
+ mDeadZone = new android.inputmethodservice.navigationbar.DeadZone(this);
+
+ getImeSwitchButton().setOnClickListener(view -> view.getContext()
+ .getSystemService(InputMethodManager.class).showInputMethodPicker());
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(MotionEvent event) {
+ return shouldDeadZoneConsumeTouchEvents(event) || super.onInterceptTouchEvent(event);
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ shouldDeadZoneConsumeTouchEvents(event);
+ return super.onTouchEvent(event);
+ }
+
+ private boolean shouldDeadZoneConsumeTouchEvents(MotionEvent event) {
+ int action = event.getActionMasked();
+ if (action == MotionEvent.ACTION_DOWN) {
+ mDeadZoneConsuming = false;
+ }
+ if (mDeadZone.onTouchEvent(event) || mDeadZoneConsuming) {
+ switch (action) {
+ case MotionEvent.ACTION_DOWN:
+ mDeadZoneConsuming = true;
+ break;
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP:
+ mDeadZoneConsuming = false;
+ break;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ public View getCurrentView() {
+ return mCurrentView;
+ }
+
+ /**
+ * Applies {@param consumer} to each of the nav bar views.
+ */
+ public void forEachView(Consumer<View> consumer) {
+ if (mHorizontal != null) {
+ consumer.accept(mHorizontal);
+ }
+ }
+
+ public ButtonDispatcher getBackButton() {
+ return mButtonDispatchers.get(com.android.internal.R.id.input_method_nav_back);
+ }
+
+ public ButtonDispatcher getImeSwitchButton() {
+ return mButtonDispatchers.get(com.android.internal.R.id.input_method_nav_ime_switcher);
+ }
+
+ public ButtonDispatcher getHomeHandle() {
+ return mButtonDispatchers.get(com.android.internal.R.id.input_method_nav_home_handle);
+ }
+
+ public SparseArray<ButtonDispatcher> getButtonDispatchers() {
+ return mButtonDispatchers;
+ }
+
+ private void reloadNavIcons() {
+ updateIcons(Configuration.EMPTY);
+ }
+
+ private void updateIcons(Configuration oldConfig) {
+ final boolean orientationChange = oldConfig.orientation != mConfiguration.orientation;
+ final boolean densityChange = oldConfig.densityDpi != mConfiguration.densityDpi;
+ final boolean dirChange =
+ oldConfig.getLayoutDirection() != mConfiguration.getLayoutDirection();
+
+ if (densityChange || dirChange) {
+ mImeSwitcherIcon = getDrawable(com.android.internal.R.drawable.ic_ime_switcher);
+ }
+ if (orientationChange || densityChange || dirChange) {
+ mBackIcon = getBackDrawable();
+ }
+ }
+
+ public KeyButtonDrawable getBackDrawable() {
+ KeyButtonDrawable drawable = getDrawable(com.android.internal.R.drawable.ic_ime_nav_back);
+ orientBackButton(drawable);
+ return drawable;
+ }
+
+ /**
+ * @return whether this nav bar mode is edge to edge
+ */
+ public static boolean isGesturalMode(int mode) {
+ return mode == NAV_BAR_MODE_GESTURAL;
+ }
+
+ private void orientBackButton(KeyButtonDrawable drawable) {
+ final boolean useAltBack =
+ (mNavigationIconHints & StatusBarManager.NAVIGATION_HINT_BACK_ALT) != 0;
+ final boolean isRtl = mConfiguration.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
+ float degrees = useAltBack ? (isRtl ? 90 : -90) : 0;
+ if (drawable.getRotation() == degrees) {
+ return;
+ }
+
+ if (isGesturalMode(mNavBarMode)) {
+ drawable.setRotation(degrees);
+ return;
+ }
+
+ // Animate the back button's rotation to the new degrees and only in portrait move up the
+ // back button to line up with the other buttons
+ float targetY = useAltBack
+ ? - dpToPx(NAVBAR_BACK_BUTTON_IME_OFFSET, getResources())
+ : 0;
+ ObjectAnimator navBarAnimator = ObjectAnimator.ofPropertyValuesHolder(drawable,
+ PropertyValuesHolder.ofFloat(KeyButtonDrawable.KEY_DRAWABLE_ROTATE, degrees),
+ PropertyValuesHolder.ofFloat(KeyButtonDrawable.KEY_DRAWABLE_TRANSLATE_Y, targetY));
+ navBarAnimator.setInterpolator(FAST_OUT_SLOW_IN);
+ navBarAnimator.setDuration(200);
+ navBarAnimator.start();
+ }
+
+ private KeyButtonDrawable getDrawable(@DrawableRes int icon) {
+ return KeyButtonDrawable.create(mLightContext, mLightIconColor, mDarkIconColor, icon,
+ true /* hasShadow */, null /* ovalBackgroundColor */);
+ }
+
+ @Override
+ public void setLayoutDirection(int layoutDirection) {
+ reloadNavIcons();
+
+ super.setLayoutDirection(layoutDirection);
+ }
+
+ public void setNavigationIconHints(int hints) {
+ if (hints == mNavigationIconHints) return;
+ final boolean newBackAlt = (hints & StatusBarManager.NAVIGATION_HINT_BACK_ALT) != 0;
+ final boolean oldBackAlt =
+ (mNavigationIconHints & StatusBarManager.NAVIGATION_HINT_BACK_ALT) != 0;
+ if (newBackAlt != oldBackAlt) {
+ //onImeVisibilityChanged(newBackAlt);
+ }
+
+ if (DEBUG) {
+ android.widget.Toast.makeText(getContext(), "Navigation icon hints = " + hints, 500)
+ .show();
+ }
+ mNavigationIconHints = hints;
+ updateNavButtonIcons();
+ }
+
+ public void setDisabledFlags(int disabledFlags) {
+ if (mDisabledFlags == disabledFlags) return;
+
+ mDisabledFlags = disabledFlags;
+
+ updateNavButtonIcons();
+ }
+
+ public void updateNavButtonIcons() {
+ // We have to replace or restore the back and home button icons when exiting or entering
+ // carmode, respectively. Recents are not available in CarMode in nav bar so change
+ // to recent icon is not required.
+ KeyButtonDrawable backIcon = mBackIcon;
+ orientBackButton(backIcon);
+ getBackButton().setImageDrawable(backIcon);
+
+ getImeSwitchButton().setImageDrawable(mImeSwitcherIcon);
+
+ // Update IME button visibility, a11y and rotate button always overrides the appearance
+ final boolean imeSwitcherVisible =
+ (mNavigationIconHints & StatusBarManager.NAVIGATION_HINT_IME_SHOWN) != 0;
+ getImeSwitchButton().setVisibility(imeSwitcherVisible ? View.VISIBLE : View.INVISIBLE);
+
+ getBackButton().setVisibility(View.VISIBLE);
+ getHomeHandle().setVisibility(View.INVISIBLE);
+
+ // We used to be reporting the touch regions via notifyActiveTouchRegions() here.
+ // TODO(b/215593010): Consider taking care of this in the Launcher side.
+ }
+
+ private Display getContextDisplay() {
+ return getContext().getDisplay();
+ }
+
+ @Override
+ public void onFinishInflate() {
+ super.onFinishInflate();
+ mNavigationInflaterView = findViewById(com.android.internal.R.id.input_method_nav_inflater);
+ mNavigationInflaterView.setButtonDispatchers(mButtonDispatchers);
+
+ updateOrientationViews();
+ reloadNavIcons();
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ mDeadZone.onDraw(canvas);
+ super.onDraw(canvas);
+ }
+
+ private void updateOrientationViews() {
+ mHorizontal = findViewById(com.android.internal.R.id.input_method_nav_horizontal);
+
+ updateCurrentView();
+ }
+
+ private void updateCurrentView() {
+ resetViews();
+ mCurrentView = mHorizontal;
+ mCurrentView.setVisibility(View.VISIBLE);
+ mCurrentRotation = getContextDisplay().getRotation();
+ mNavigationInflaterView.setAlternativeOrder(mCurrentRotation == Surface.ROTATION_90);
+ mNavigationInflaterView.updateButtonDispatchersCurrentView();
+ }
+
+ private void resetViews() {
+ mHorizontal.setVisibility(View.GONE);
+ }
+
+ public void reorient() {
+ updateCurrentView();
+
+ final android.inputmethodservice.navigationbar.NavigationBarFrame frame =
+ getRootView().findViewByPredicate(view -> view instanceof NavigationBarFrame);
+ frame.setDeadZone(mDeadZone);
+ mDeadZone.onConfigurationChanged(mCurrentRotation);
+
+ if (DEBUG) {
+ Log.d(TAG, "reorient(): rot=" + mCurrentRotation);
+ }
+
+ // Resolve layout direction if not resolved since components changing layout direction such
+ // as changing languages will recreate this view and the direction will be resolved later
+ if (!isLayoutDirectionResolved()) {
+ resolveLayoutDirection();
+ }
+ updateNavButtonIcons();
+ }
+
+ @Override
+ protected void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+ mTmpLastConfiguration.updateFrom(mConfiguration);
+ final int changes = mConfiguration.updateFrom(newConfig);
+
+ updateIcons(mTmpLastConfiguration);
+ if (mTmpLastConfiguration.densityDpi != mConfiguration.densityDpi
+ || mTmpLastConfiguration.getLayoutDirection()
+ != mConfiguration.getLayoutDirection()) {
+ // If car mode or density changes, we need to reset the icons.
+ updateNavButtonIcons();
+ }
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ // This needs to happen first as it can changed the enabled state which can affect whether
+ // the back button is visible
+ requestApplyInsets();
+ reorient();
+ updateNavButtonIcons();
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ for (int i = 0; i < mButtonDispatchers.size(); ++i) {
+ mButtonDispatchers.valueAt(i).onDestroy();
+ }
+ }
+
+ public void setDarkIntensity(@FloatRange(from = 0.0f, to = 1.0f) float intensity) {
+ for (int i = 0; i < mButtonDispatchers.size(); ++i) {
+ mButtonDispatchers.valueAt(i).setDarkIntensity(intensity);
+ }
+ }
+}
diff --git a/core/java/android/inputmethodservice/navigationbar/NavigationHandle.java b/core/java/android/inputmethodservice/navigationbar/NavigationHandle.java
new file mode 100644
index 000000000000..273cafb7fca7
--- /dev/null
+++ b/core/java/android/inputmethodservice/navigationbar/NavigationHandle.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.inputmethodservice.navigationbar;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+
+/**
+ * TODO(b/215443343): Remove this file, as IME actually doesn't use this.
+ *
+ * @hide
+ */
+public class NavigationHandle extends View implements ButtonInterface {
+
+ public NavigationHandle(Context context) {
+ this(context, null);
+ }
+
+ public NavigationHandle(Context context, AttributeSet attr) {
+ super(context, attr);
+ setFocusable(false);
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent event) {
+ return false;
+ }
+
+ @Override
+ public void setImageDrawable(Drawable drawable) {
+ }
+
+ @Override
+ public void setDarkIntensity(float intensity) {
+ }
+
+ @Override
+ public void setDelayTouchFeedback(boolean shouldDelay) {
+ }
+}
diff --git a/core/java/android/inputmethodservice/navigationbar/ReverseLinearLayout.java b/core/java/android/inputmethodservice/navigationbar/ReverseLinearLayout.java
new file mode 100644
index 000000000000..68163c35f784
--- /dev/null
+++ b/core/java/android/inputmethodservice/navigationbar/ReverseLinearLayout.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.inputmethodservice.navigationbar;
+
+import android.annotation.Nullable;
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+import android.widget.RelativeLayout;
+
+import java.util.ArrayList;
+
+/**
+ * Automatically reverses the order of children as they are added.
+ * Also reverse the width and height values of layout params
+ * @hide
+ */
+public class ReverseLinearLayout extends LinearLayout {
+
+ /** If true, the layout is reversed vs. a regular linear layout */
+ private boolean mIsLayoutReverse;
+
+ /** If true, the layout is opposite to it's natural reversity from the layout direction */
+ private boolean mIsAlternativeOrder;
+
+ public ReverseLinearLayout(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ updateOrder();
+ }
+
+ @Override
+ public void addView(View child) {
+ reverseParams(child.getLayoutParams(), child, mIsLayoutReverse);
+ if (mIsLayoutReverse) {
+ super.addView(child, 0);
+ } else {
+ super.addView(child);
+ }
+ }
+
+ @Override
+ public void addView(View child, ViewGroup.LayoutParams params) {
+ reverseParams(params, child, mIsLayoutReverse);
+ if (mIsLayoutReverse) {
+ super.addView(child, 0, params);
+ } else {
+ super.addView(child, params);
+ }
+ }
+
+ @Override
+ public void onRtlPropertiesChanged(int layoutDirection) {
+ super.onRtlPropertiesChanged(layoutDirection);
+ updateOrder();
+ }
+
+ public void setAlternativeOrder(boolean alternative) {
+ mIsAlternativeOrder = alternative;
+ updateOrder();
+ }
+
+ /**
+ * In landscape, the LinearLayout is not auto mirrored since it is vertical. Therefore we
+ * have to do it manually
+ */
+ private void updateOrder() {
+ boolean isLayoutRtl = getLayoutDirection() == LAYOUT_DIRECTION_RTL;
+ boolean isLayoutReverse = isLayoutRtl ^ mIsAlternativeOrder;
+
+ if (mIsLayoutReverse != isLayoutReverse) {
+ // reversity changed, swap the order of all views.
+ int childCount = getChildCount();
+ ArrayList<View> childList = new ArrayList<>(childCount);
+ for (int i = 0; i < childCount; i++) {
+ childList.add(getChildAt(i));
+ }
+ removeAllViews();
+ for (int i = childCount - 1; i >= 0; i--) {
+ final View child = childList.get(i);
+ super.addView(child);
+ }
+ mIsLayoutReverse = isLayoutReverse;
+ }
+ }
+
+ private static void reverseParams(ViewGroup.LayoutParams params, View child,
+ boolean isLayoutReverse) {
+ if (child instanceof Reversible) {
+ ((Reversible) child).reverse(isLayoutReverse);
+ }
+ if (child.getPaddingLeft() == child.getPaddingRight()
+ && child.getPaddingTop() == child.getPaddingBottom()) {
+ child.setPadding(child.getPaddingTop(), child.getPaddingLeft(),
+ child.getPaddingTop(), child.getPaddingLeft());
+ }
+ if (params == null) {
+ return;
+ }
+ int width = params.width;
+ params.width = params.height;
+ params.height = width;
+ }
+
+ interface Reversible {
+ void reverse(boolean isLayoutReverse);
+ }
+
+ public static class ReverseRelativeLayout extends RelativeLayout implements Reversible {
+
+ public ReverseRelativeLayout(Context context) {
+ super(context);
+ }
+
+ @Override
+ public void reverse(boolean isLayoutReverse) {
+ updateGravity(isLayoutReverse);
+ reverseGroup(this, isLayoutReverse);
+ }
+
+ private int mDefaultGravity = Gravity.NO_GRAVITY;
+ public void setDefaultGravity(int gravity) {
+ mDefaultGravity = gravity;
+ }
+
+ public void updateGravity(boolean isLayoutReverse) {
+ // Flip gravity if top of bottom is used
+ if (mDefaultGravity != Gravity.TOP && mDefaultGravity != Gravity.BOTTOM) return;
+
+ // Use the default (intended for 270 LTR and 90 RTL) unless layout is otherwise
+ int gravityToApply = mDefaultGravity;
+ if (isLayoutReverse) {
+ gravityToApply = mDefaultGravity == Gravity.TOP ? Gravity.BOTTOM : Gravity.TOP;
+ }
+
+ if (getGravity() != gravityToApply) setGravity(gravityToApply);
+ }
+ }
+
+ private static void reverseGroup(ViewGroup group, boolean isLayoutReverse) {
+ for (int i = 0; i < group.getChildCount(); i++) {
+ final View child = group.getChildAt(i);
+ reverseParams(child.getLayoutParams(), child, isLayoutReverse);
+
+ // Recursively reverse all children
+ if (child instanceof ViewGroup) {
+ reverseGroup((ViewGroup) child, isLayoutReverse);
+ }
+ }
+ }
+}