Merge "Minimum impl of nav buttons rendered by IME"

This commit is contained in:
Yohei Yukawa
2022-01-24 19:00:42 +00:00
committed by Android (Google) Code Review
29 changed files with 3746 additions and 0 deletions

View File

@@ -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() {

View File

@@ -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 + "}";
}
}
}

View File

@@ -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() {
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}
}

View File

@@ -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) {
}
}

View File

@@ -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);
}
}
}
}