| /* |
| * 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); |
| } |
| }; |
| |
| 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; |
| |
| 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; |
| } |
| } |