From ff7b453ca8e23b4ef75ba2c3f5becaf511cb07d3 Mon Sep 17 00:00:00 2001 From: Yohei Yukawa Date: Thu, 20 Jan 2022 21:28:34 -0800 Subject: [PATCH] Minimum impl of nav buttons rendered by IME This is the minimum implementation of rendering the back button and the IME switcher button in the InputMethodService process. Note that the feature is still disabled by default behind the flag. Newly added UI logic should be running only when the feature flag is enabled. Bug 215545985: Enable canImeRenderGesturalNavButtons() by default Here is the list of known blockers before enabling the flag. * Bug 215552600: Subscribe gesture mode change. * Bug 215551357: Show IME swicher button only when necessary. * Bug 215549533: Support light navigation bar. * Bug 215550296: Support floating IME. * Bug 215554582: Set "input_method_rounded_corner_content_padding" for each devices. Also, after enabling the flag by default, we start cleaning up newly added code under android.inputmethodservice.navigationbar. at Bug 215443343. Bug: 205803355 Test: Manually done as follows 1. Build aosp_coral-userdebug and flash it 2. Switch to gestural navigation mode. 3. adb root 4. adb shell setprop \ persist.sys.ime.can_render_gestural_nav_buttons true 5. adb reboot 6. Tap any edit field to show AOSP Keyboard. 7. Confirm that the back button on the navbar works. 8. Confirm that the IME switcher button on the navbar works. Change-Id: I3e7e1f83554444131e2765dc159617bb9e2337c7 --- .../InputMethodService.java | 9 + .../NavigationBarController.java | 328 +++++++++++ .../navigationbar/ButtonDispatcher.java | 302 ++++++++++ .../navigationbar/ButtonInterface.java | 29 + .../navigationbar/DeadZone.java | 203 +++++++ .../navigationbar/KeyButtonDrawable.java | 483 ++++++++++++++++ .../navigationbar/KeyButtonRipple.java | 525 ++++++++++++++++++ .../navigationbar/KeyButtonView.java | 370 ++++++++++++ .../navigationbar/NavigationBarConstants.java | 62 +++ .../navigationbar/NavigationBarFrame.java | 62 +++ .../NavigationBarInflaterView.java | 429 ++++++++++++++ .../navigationbar/NavigationBarUtils.java | 42 ++ .../navigationbar/NavigationBarView.java | 380 +++++++++++++ .../navigationbar/NavigationHandle.java | 57 ++ .../navigationbar/ReverseLinearLayout.java | 172 ++++++ core/res/res/drawable/ic_ime_nav_back.xml | 26 + core/res/res/drawable/ic_ime_switcher.xml | 25 + core/res/res/layout/input_method_nav_back.xml | 28 + .../layout/input_method_nav_home_handle.xml | 25 + .../layout/input_method_nav_ime_switcher.xml | 27 + .../layout/input_method_navigation_bar.xml | 32 ++ .../layout/input_method_navigation_layout.xml | 57 ++ core/res/res/values-sw360dp/dimens.xml | 4 + core/res/res/values-sw372dp/dimens.xml | 20 + core/res/res/values-sw600dp/dimens.xml | 5 + core/res/res/values-sw900dp/dimens.xml | 8 + core/res/res/values/dimens.xml | 13 + core/res/res/values/strings.xml | 5 + core/res/res/values/symbols.xml | 18 + 29 files changed, 3746 insertions(+) create mode 100644 core/java/android/inputmethodservice/NavigationBarController.java create mode 100644 core/java/android/inputmethodservice/navigationbar/ButtonDispatcher.java create mode 100644 core/java/android/inputmethodservice/navigationbar/ButtonInterface.java create mode 100644 core/java/android/inputmethodservice/navigationbar/DeadZone.java create mode 100644 core/java/android/inputmethodservice/navigationbar/KeyButtonDrawable.java create mode 100644 core/java/android/inputmethodservice/navigationbar/KeyButtonRipple.java create mode 100644 core/java/android/inputmethodservice/navigationbar/KeyButtonView.java create mode 100644 core/java/android/inputmethodservice/navigationbar/NavigationBarConstants.java create mode 100644 core/java/android/inputmethodservice/navigationbar/NavigationBarFrame.java create mode 100644 core/java/android/inputmethodservice/navigationbar/NavigationBarInflaterView.java create mode 100644 core/java/android/inputmethodservice/navigationbar/NavigationBarUtils.java create mode 100644 core/java/android/inputmethodservice/navigationbar/NavigationBarView.java create mode 100644 core/java/android/inputmethodservice/navigationbar/NavigationHandle.java create mode 100644 core/java/android/inputmethodservice/navigationbar/ReverseLinearLayout.java create mode 100644 core/res/res/drawable/ic_ime_nav_back.xml create mode 100644 core/res/res/drawable/ic_ime_switcher.xml create mode 100644 core/res/res/layout/input_method_nav_back.xml create mode 100644 core/res/res/layout/input_method_nav_home_handle.xml create mode 100644 core/res/res/layout/input_method_nav_ime_switcher.xml create mode 100644 core/res/res/layout/input_method_navigation_bar.xml create mode 100644 core/res/res/layout/input_method_navigation_layout.xml create mode 100644 core/res/res/values-sw372dp/dimens.xml diff --git a/core/java/android/inputmethodservice/InputMethodService.java b/core/java/android/inputmethodservice/InputMethodService.java index 09d50850788be..5d2d8eafb3a8f 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 0000000000000..7295b72c276bb --- /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}. + * + *

All the package-private methods are no-op when + * {@link InputMethodService#canImeRenderGesturalNavButtons()} returns {@code false}.

+ */ +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 0000000000000..3f26fa461097a --- /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 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 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 0000000000000..1c9c86d2a7e56 --- /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 0000000000000..cd857369bc5a4 --- /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 0000000000000..25a443de916bc --- /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 KEY_DRAWABLE_ROTATE = + new FloatProperty("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 KEY_DRAWABLE_TRANSLATE_Y = + new FloatProperty("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 0000000000000..38a63b661ac07 --- /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 mLeftProp; + private CanvasProperty mTopProp; + private CanvasProperty mRightProp; + private CanvasProperty mBottomProp; + private CanvasProperty mRxProp; + private CanvasProperty mRyProp; + private CanvasProperty 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 mRunningAnimations = new HashSet<>(); + private final ArrayList 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 cx = CanvasProperty.createFloat(getBounds().width() / 2); + CanvasProperty cy = CanvasProperty.createFloat(getBounds().height() / 2); + int d = Math.min(getBounds().width(), getBounds().height()); + CanvasProperty 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 prop) { + if (isHorizontal()) { + mLeftProp = prop; + } else { + mTopProp = prop; + } + } + + private CanvasProperty 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 prop) { + if (isHorizontal()) { + mRightProp = prop; + } else { + mBottomProp = prop; + } + } + + private CanvasProperty 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 0000000000000..74d30f8f8806e --- /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 0000000000000..93c54395f972f --- /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 0000000000000..f01173e0fdaee --- /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 0000000000000..d488890b27d44 --- /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 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 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 0000000000000..c6096d7ba0a1b --- /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". + * + *

TODO(b/215443343): Remove this method by migrating DP values from + * {@link NavigationBarConstants} to resource files.

+ * + * @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 0000000000000..42847784dd2b5 --- /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 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 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 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 0000000000000..273cafb7fca77 --- /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 0000000000000..68163c35f7843 --- /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 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); + } + } + } +} diff --git a/core/res/res/drawable/ic_ime_nav_back.xml b/core/res/res/drawable/ic_ime_nav_back.xml new file mode 100644 index 0000000000000..ca329aa64bd44 --- /dev/null +++ b/core/res/res/drawable/ic_ime_nav_back.xml @@ -0,0 +1,26 @@ + + + + + \ No newline at end of file diff --git a/core/res/res/drawable/ic_ime_switcher.xml b/core/res/res/drawable/ic_ime_switcher.xml new file mode 100644 index 0000000000000..6c3b766273233 --- /dev/null +++ b/core/res/res/drawable/ic_ime_switcher.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/core/res/res/layout/input_method_nav_back.xml b/core/res/res/layout/input_method_nav_back.xml new file mode 100644 index 0000000000000..671766aec2819 --- /dev/null +++ b/core/res/res/layout/input_method_nav_back.xml @@ -0,0 +1,28 @@ + + + + diff --git a/core/res/res/layout/input_method_nav_home_handle.xml b/core/res/res/layout/input_method_nav_home_handle.xml new file mode 100644 index 0000000000000..501f5126aff8e --- /dev/null +++ b/core/res/res/layout/input_method_nav_home_handle.xml @@ -0,0 +1,25 @@ + + + diff --git a/core/res/res/layout/input_method_nav_ime_switcher.xml b/core/res/res/layout/input_method_nav_ime_switcher.xml new file mode 100644 index 0000000000000..b571ba9278378 --- /dev/null +++ b/core/res/res/layout/input_method_nav_ime_switcher.xml @@ -0,0 +1,27 @@ + + + diff --git a/core/res/res/layout/input_method_navigation_bar.xml b/core/res/res/layout/input_method_navigation_bar.xml new file mode 100644 index 0000000000000..ce402fbce8917 --- /dev/null +++ b/core/res/res/layout/input_method_navigation_bar.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/core/res/res/layout/input_method_navigation_layout.xml b/core/res/res/layout/input_method_navigation_layout.xml new file mode 100644 index 0000000000000..05e750ad34ff3 --- /dev/null +++ b/core/res/res/layout/input_method_navigation_layout.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + diff --git a/core/res/res/values-sw360dp/dimens.xml b/core/res/res/values-sw360dp/dimens.xml index 4c74264c676af..00de60efbeb25 100644 --- a/core/res/res/values-sw360dp/dimens.xml +++ b/core/res/res/values-sw360dp/dimens.xml @@ -18,4 +18,8 @@ --> 16dp + + + 80dip + diff --git a/core/res/res/values-sw372dp/dimens.xml b/core/res/res/values-sw372dp/dimens.xml new file mode 100644 index 0000000000000..cb29a1944a221 --- /dev/null +++ b/core/res/res/values-sw372dp/dimens.xml @@ -0,0 +1,20 @@ + + + + + 8dp + diff --git a/core/res/res/values-sw600dp/dimens.xml b/core/res/res/values-sw600dp/dimens.xml index e8f15fd022d77..4c70ea32bb5b5 100644 --- a/core/res/res/values-sw600dp/dimens.xml +++ b/core/res/res/values-sw600dp/dimens.xml @@ -41,6 +41,11 @@ 364dp + + 128dp + + 25dp + 430dip diff --git a/core/res/res/values-sw900dp/dimens.xml b/core/res/res/values-sw900dp/dimens.xml index 11092b2cb9e95..9ec420453f6b7 100644 --- a/core/res/res/values-sw900dp/dimens.xml +++ b/core/res/res/values-sw900dp/dimens.xml @@ -24,4 +24,12 @@ the same as @dimen/navigation_bar_height --> 56dp + + 80dp + + 0dp + + 76dp + diff --git a/core/res/res/values/dimens.xml b/core/res/res/values/dimens.xml index a877bd39bceb8..3f08e4b9d9ad0 100644 --- a/core/res/res/values/dimens.xml +++ b/core/res/res/values/dimens.xml @@ -131,6 +131,19 @@ corners. --> 0px + + 70dp + + 0dp + + 0px + + 0px + + 95dp + 48dp diff --git a/core/res/res/values/strings.xml b/core/res/res/values/strings.xml index 6577ebc8cd3f8..1a5d8b7ca8fdf 100644 --- a/core/res/res/values/strings.xml +++ b/core/res/res/values/strings.xml @@ -3277,6 +3277,11 @@ Text actions + + Back + + Switch input method + Storage space running out diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml index 4e77563602ee2..c4a9997cf2b67 100644 --- a/core/res/res/values/symbols.xml +++ b/core/res/res/values/symbols.xml @@ -2438,6 +2438,24 @@ + + + + + + + + + + + + + + + + + +