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