/* * Copyright (C) 2008 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 com.android.launcher3.dragndrop; import static android.view.View.MeasureSpec.EXACTLY; import static android.view.View.MeasureSpec.makeMeasureSpec; import static com.android.launcher3.LauncherAnimUtils.VIEW_ALPHA; import static com.android.launcher3.Utilities.getBadge; import static com.android.launcher3.icons.FastBitmapDrawable.getDisabledColorFilter; import static com.android.launcher3.util.Executors.MODEL_EXECUTOR; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.animation.ValueAnimator; import android.animation.ValueAnimator.AnimatorUpdateListener; import android.annotation.TargetApi; import android.content.Context; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.ColorFilter; import android.graphics.Path; import android.graphics.Picture; import android.graphics.Rect; import android.graphics.drawable.AdaptiveIconDrawable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.PictureDrawable; import android.os.Build; import android.os.Handler; import android.os.Looper; import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; import android.widget.ImageView; import androidx.annotation.Nullable; import androidx.dynamicanimation.animation.FloatPropertyCompat; import androidx.dynamicanimation.animation.SpringAnimation; import androidx.dynamicanimation.animation.SpringForce; import com.android.launcher3.R; import com.android.launcher3.Utilities; import com.android.launcher3.anim.Interpolators; import com.android.launcher3.icons.FastBitmapDrawable; import com.android.launcher3.icons.LauncherIcons; import com.android.launcher3.model.data.ItemInfo; import com.android.launcher3.util.RunnableList; import com.android.launcher3.views.ActivityContext; import com.android.launcher3.views.BaseDragLayer; /** A custom view for rendering an icon, folder, shortcut or widget during drag-n-drop. */ public abstract class DragView extends FrameLayout { public static final int VIEW_ZOOM_DURATION = 150; private final View mContent; // The following are only used for rendering mContent directly during drag-n-drop. @Nullable private ViewGroup.LayoutParams mContentViewLayoutParams; @Nullable private ViewGroup mContentViewParent; private int mContentViewInParentViewIndex = -1; private final int mWidth; private final int mHeight; private final int mBlurSizeOutline; protected final int mRegistrationX; protected final int mRegistrationY; private final float mInitialScale; private final float mEndScale; protected final float mScaleOnDrop; protected final int[] mTempLoc = new int[2]; private final RunnableList mOnDragStartCallback = new RunnableList(); private boolean mHasDragOffset; private Rect mDragRegion = null; protected final T mActivity; private final BaseDragLayer mDragLayer; private boolean mHasDrawn = false; final ValueAnimator mScaleAnim; final ValueAnimator mShiftAnim; // Whether mAnim has started. Unlike mAnim.isStarted(), this is true even after mAnim ends. private boolean mScaleAnimStarted; private Runnable mOnAnimEndCallback = null; private int mLastTouchX; private int mLastTouchY; private int mAnimatedShiftX; private int mAnimatedShiftY; // Below variable only needed IF FeatureFlags.LAUNCHER3_SPRING_ICONS is {@code true} private Drawable mBgSpringDrawable, mFgSpringDrawable; private SpringFloatValue mTranslateX, mTranslateY; private Path mScaledMaskPath; private Drawable mBadge; public DragView(T launcher, Drawable drawable, int registrationX, int registrationY, final float initialScale, final float scaleOnDrop, final float finalScaleDps) { this(launcher, getViewFromDrawable(launcher, drawable), drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), registrationX, registrationY, initialScale, scaleOnDrop, finalScaleDps); } /** * Construct the drag view. *

* The registration point is the point inside our view that the touch events should * be centered upon. * @param activity The Launcher instance/ActivityContext this DragView is in. * @param content the view content that is attached to the drag view. * @param width the width of the dragView * @param height the height of the dragView * @param initialScale The view that we're dragging around. We scale it up when we draw it. * @param registrationX The x coordinate of the registration point. * @param registrationY The y coordinate of the registration point. * @param scaleOnDrop the scale used in the drop animation. * @param finalScaleDps the scale used in the zoom out animation when the drag view is shown. */ public DragView(T activity, View content, int width, int height, int registrationX, int registrationY, final float initialScale, final float scaleOnDrop, final float finalScaleDps) { super(activity); mActivity = activity; mDragLayer = activity.getDragLayer(); mContent = content; mWidth = width; mHeight = height; mContentViewLayoutParams = mContent.getLayoutParams(); if (mContent.getParent() instanceof ViewGroup) { mContentViewParent = (ViewGroup) mContent.getParent(); mContentViewInParentViewIndex = mContentViewParent.indexOfChild(mContent); mContentViewParent.removeView(mContent); } addView(content, new LayoutParams(width, height)); // If there is already a scale set on the content, we don't want to clip the children. if (content.getScaleX() != 1 || content.getScaleY() != 1) { setClipChildren(false); setClipToPadding(false); } mEndScale = (width + finalScaleDps) / width; // Set the initial scale to avoid any jumps setScaleX(initialScale); setScaleY(initialScale); // Animate the view into the correct position mScaleAnim = ValueAnimator.ofFloat(0f, 1f); mScaleAnim.setDuration(VIEW_ZOOM_DURATION); mScaleAnim.addUpdateListener(animation -> { final float value = (Float) animation.getAnimatedValue(); setScaleX(Utilities.mapRange(value, initialScale, mEndScale)); setScaleY(Utilities.mapRange(value, initialScale, mEndScale)); if (!isAttachedToWindow()) { animation.cancel(); } }); mScaleAnim.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { mScaleAnimStarted = true; } @Override public void onAnimationEnd(Animator animation) { super.onAnimationEnd(animation); if (mOnAnimEndCallback != null) { mOnAnimEndCallback.run(); } } }); // Set up the shift animator. mShiftAnim = ValueAnimator.ofFloat(0f, 1f); setDragRegion(new Rect(0, 0, width, height)); // The point in our scaled bitmap that the touch events are located mRegistrationX = registrationX; mRegistrationY = registrationY; mInitialScale = initialScale; mScaleOnDrop = scaleOnDrop; // Force a measure, because Workspace uses getMeasuredHeight() before the layout pass measure(makeMeasureSpec(width, EXACTLY), makeMeasureSpec(height, EXACTLY)); mBlurSizeOutline = getResources().getDimensionPixelSize(R.dimen.blur_size_medium_outline); setElevation(getResources().getDimension(R.dimen.drag_elevation)); setWillNotDraw(false); } public void setOnAnimationEndCallback(Runnable callback) { mOnAnimEndCallback = callback; } /** * Initialize {@code #mIconDrawable} if the item can be represented using * an {@link AdaptiveIconDrawable} or {@link FolderAdaptiveIcon}. */ @TargetApi(Build.VERSION_CODES.O) public void setItemInfo(final ItemInfo info) { // Load the adaptive icon on a background thread and add the view in ui thread. MODEL_EXECUTOR.getHandler().postAtFrontOfQueue(() -> { Object[] outObj = new Object[1]; int w = mWidth; int h = mHeight; Drawable dr = Utilities.getFullDrawable(mActivity, info, w, h, true /* shouldThemeIcon */, outObj); if (dr instanceof AdaptiveIconDrawable) { int blurMargin = (int) mActivity.getResources() .getDimension(R.dimen.blur_size_medium_outline) / 2; Rect bounds = new Rect(0, 0, w, h); bounds.inset(blurMargin, blurMargin); // Badge is applied after icon normalization so the bounds for badge should not // be scaled down due to icon normalization. mBadge = getBadge(mActivity, info, outObj[0]); FastBitmapDrawable.setBadgeBounds(mBadge, bounds); // Do not draw the background in case of folder as its translucent final boolean shouldDrawBackground = !(dr instanceof FolderAdaptiveIcon); try (LauncherIcons li = LauncherIcons.obtain(mActivity)) { Drawable nDr; // drawable to be normalized if (shouldDrawBackground) { nDr = dr; } else { // Since we just want the scale, avoid heavy drawing operations nDr = new AdaptiveIconDrawable(new ColorDrawable(Color.BLACK), null); } Utilities.scaleRectAboutCenter(bounds, li.getNormalizer().getScale(nDr, null, null, null)); } AdaptiveIconDrawable adaptiveIcon = (AdaptiveIconDrawable) dr; // Shrink very tiny bit so that the clip path is smaller than the original bitmap // that has anti aliased edges and shadows. Rect shrunkBounds = new Rect(bounds); Utilities.scaleRectAboutCenter(shrunkBounds, 0.98f); adaptiveIcon.setBounds(shrunkBounds); final Path mask = adaptiveIcon.getIconMask(); mTranslateX = new SpringFloatValue(DragView.this, w * AdaptiveIconDrawable.getExtraInsetFraction()); mTranslateY = new SpringFloatValue(DragView.this, h * AdaptiveIconDrawable.getExtraInsetFraction()); bounds.inset( (int) (-bounds.width() * AdaptiveIconDrawable.getExtraInsetFraction()), (int) (-bounds.height() * AdaptiveIconDrawable.getExtraInsetFraction()) ); mBgSpringDrawable = adaptiveIcon.getBackground(); if (mBgSpringDrawable == null) { mBgSpringDrawable = new ColorDrawable(Color.TRANSPARENT); } mBgSpringDrawable.setBounds(bounds); mFgSpringDrawable = adaptiveIcon.getForeground(); if (mFgSpringDrawable == null) { mFgSpringDrawable = new ColorDrawable(Color.TRANSPARENT); } mFgSpringDrawable.setBounds(bounds); new Handler(Looper.getMainLooper()).post(() -> mOnDragStartCallback.add(() -> { // TODO: Consider fade-in animation // Assign the variable on the UI thread to avoid race conditions. mScaledMaskPath = mask; // Avoid relayout as we do not care about children affecting layout removeAllViewsInLayout(); if (info.isDisabled()) { ColorFilter filter = getDisabledColorFilter(); mBgSpringDrawable.setColorFilter(filter); mFgSpringDrawable.setColorFilter(filter); mBadge.setColorFilter(filter); } invalidate(); })); } }); } /** * Called when pre-drag finishes for an icon */ public void onDragStart() { mOnDragStartCallback.executeAllAndDestroy(); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(makeMeasureSpec(mWidth, EXACTLY), makeMeasureSpec(mHeight, EXACTLY)); } public int getDragRegionWidth() { return mDragRegion.width(); } public int getDragRegionHeight() { return mDragRegion.height(); } public void setHasDragOffset(boolean hasDragOffset) { mHasDragOffset = hasDragOffset; } public boolean getHasDragOffset() { return mHasDragOffset; } public void setDragRegion(Rect r) { mDragRegion = r; } public Rect getDragRegion() { return mDragRegion; } @Override public void draw(Canvas canvas) { super.draw(canvas); // Draw after the content mHasDrawn = true; if (mScaledMaskPath != null) { int cnt = canvas.save(); canvas.clipPath(mScaledMaskPath); mBgSpringDrawable.draw(canvas); canvas.translate(mTranslateX.mValue, mTranslateY.mValue); mFgSpringDrawable.draw(canvas); canvas.restoreToCount(cnt); mBadge.draw(canvas); } } public void crossFadeContent(Drawable crossFadeDrawable, int duration) { if (mContent.getParent() == null) { // If the content is already removed, ignore return; } ImageView newContent = getViewFromDrawable(getContext(), crossFadeDrawable); // We need to fill the ImageView with the content, otherwise the shapes of the final view // and the drag view might not match exactly newContent.setScaleType(ImageView.ScaleType.FIT_XY); newContent.measure(makeMeasureSpec(mWidth, EXACTLY), makeMeasureSpec(mHeight, EXACTLY)); newContent.layout(0, 0, mWidth, mHeight); addViewInLayout(newContent, 0, new LayoutParams(mWidth, mHeight)); AnimatorSet anim = new AnimatorSet(); anim.play(ObjectAnimator.ofFloat(newContent, VIEW_ALPHA, 0, 1)); anim.play(ObjectAnimator.ofFloat(mContent, VIEW_ALPHA, 0)); anim.setDuration(duration).setInterpolator(Interpolators.DEACCEL_1_5); anim.start(); } public boolean hasDrawn() { return mHasDrawn; } /** * Create a window containing this view and show it. * * @param touchX the x coordinate the user touched in DragLayer coordinates * @param touchY the y coordinate the user touched in DragLayer coordinates */ public void show(int touchX, int touchY) { mDragLayer.addView(this); // Start the pick-up animation BaseDragLayer.LayoutParams lp = new BaseDragLayer.LayoutParams(mWidth, mHeight); lp.customPosition = true; setLayoutParams(lp); if (mContent != null) { // At the drag start, the source view visibility is set to invisible. if (getHasDragOffset()) { // If there is any dragOffset, this means the content will show away of the original // icon location, otherwise it's fine since original content would just show at the // same spot. mContent.setVisibility(INVISIBLE); } else { mContent.setVisibility(VISIBLE); } } move(touchX, touchY); // Post the animation to skip other expensive work happening on the first frame post(mScaleAnim::start); } public void cancelAnimation() { if (mScaleAnim != null && mScaleAnim.isRunning()) { mScaleAnim.cancel(); } } public boolean isScaleAnimationFinished() { return mScaleAnimStarted && !mScaleAnim.isRunning(); } /** * Move the window containing this view. * * @param touchX the x coordinate the user touched in DragLayer coordinates * @param touchY the y coordinate the user touched in DragLayer coordinates */ public void move(int touchX, int touchY) { if (touchX > 0 && touchY > 0 && mLastTouchX > 0 && mLastTouchY > 0 && mScaledMaskPath != null) { mTranslateX.animateToPos(mLastTouchX - touchX); mTranslateY.animateToPos(mLastTouchY - touchY); } mLastTouchX = touchX; mLastTouchY = touchY; applyTranslation(); } /** * Animate this DragView to the given DragLayer coordinates and then remove it. */ public abstract void animateTo(int toTouchX, int toTouchY, Runnable onCompleteRunnable, int duration); public void animateShift(final int shiftX, final int shiftY) { if (mShiftAnim.isStarted()) return; // Set mContent visibility to visible to show icon regardless in case it is INVISIBLE. if (mContent != null) mContent.setVisibility(VISIBLE); mAnimatedShiftX = shiftX; mAnimatedShiftY = shiftY; applyTranslation(); mShiftAnim.addUpdateListener(new AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float fraction = 1 - animation.getAnimatedFraction(); mAnimatedShiftX = (int) (fraction * shiftX); mAnimatedShiftY = (int) (fraction * shiftY); applyTranslation(); } }); mShiftAnim.start(); } private void applyTranslation() { setTranslationX(mLastTouchX - mRegistrationX + mAnimatedShiftX); setTranslationY(mLastTouchY - mRegistrationY + mAnimatedShiftY); } /** * Detaches {@link #mContent}, if previously attached, from this view. * *

In the case of no change in the drop position, sets {@code reattachToPreviousParent} to * {@code true} to attach the {@link #mContent} back to its previous parent. */ public void detachContentView(boolean reattachToPreviousParent) { if (mContent != null && mContentViewParent != null && mContentViewInParentViewIndex >= 0) { Picture picture = new Picture(); mContent.draw(picture.beginRecording(mWidth, mHeight)); picture.endRecording(); View view = new View(mActivity); view.setBackground(new PictureDrawable(picture)); view.measure(makeMeasureSpec(mWidth, EXACTLY), makeMeasureSpec(mHeight, EXACTLY)); view.layout(mContent.getLeft(), mContent.getTop(), mContent.getRight(), mContent.getBottom()); setClipToOutline(mContent.getClipToOutline()); setOutlineProvider(mContent.getOutlineProvider()); addViewInLayout(view, indexOfChild(mContent), mContent.getLayoutParams(), true); removeViewInLayout(mContent); mContent.setVisibility(INVISIBLE); mContent.setLayoutParams(mContentViewLayoutParams); if (reattachToPreviousParent) { mContentViewParent.addView(mContent, mContentViewInParentViewIndex); } mContentViewParent = null; mContentViewInParentViewIndex = -1; } } /** * Removes this view from the {@link DragLayer}. * *

If the drag content is a {@link #mContent}, this call doesn't reattach the * {@link #mContent} back to its previous parent. To reattach to previous parent, the caller * should call {@link #detachContentView} with {@code reattachToPreviousParent} sets to true * before this call. */ public void remove() { if (getParent() != null) { mDragLayer.removeView(DragView.this); } } public int getBlurSizeOutline() { return mBlurSizeOutline; } public float getInitialScale() { return mInitialScale; } public float getEndScale() { return mEndScale; } @Override public boolean hasOverlappingRendering() { return false; } /** Returns the current content view that is rendered in the drag view. */ public View getContentView() { return mContent; } /** * Returns the previous {@link ViewGroup} parent of the {@link #mContent} before the drag * content is attached to this view. */ @Nullable public ViewGroup getContentViewParent() { return mContentViewParent; } private static class SpringFloatValue { private static final FloatPropertyCompat VALUE = new FloatPropertyCompat("value") { @Override public float getValue(SpringFloatValue object) { return object.mValue; } @Override public void setValue(SpringFloatValue object, float value) { object.mValue = value; object.mView.invalidate(); } }; // Following three values are fine tuned with motion ux designer private static final int STIFFNESS = 4000; private static final float DAMPENING_RATIO = 1f; private static final int PARALLAX_MAX_IN_DP = 8; private final View mView; private final SpringAnimation mSpring; private final float mDelta; private float mValue; public SpringFloatValue(View view, float range) { mView = view; mSpring = new SpringAnimation(this, VALUE, 0) .setMinValue(-range).setMaxValue(range) .setSpring(new SpringForce(0) .setDampingRatio(DAMPENING_RATIO) .setStiffness(STIFFNESS)); mDelta = Math.min( range, view.getResources().getDisplayMetrics().density * PARALLAX_MAX_IN_DP); } public void animateToPos(float value) { mSpring.animateToFinalPosition(Utilities.boundToRange(value, -mDelta, mDelta)); } } private static ImageView getViewFromDrawable(Context context, Drawable drawable) { ImageView iv = new ImageView(context); iv.setImageDrawable(drawable); return iv; } /** * Removes any stray DragView from the DragLayer. */ public static void removeAllViews(ActivityContext activity) { BaseDragLayer dragLayer = activity.getDragLayer(); // Iterate in reverse order. DragView is added later to the dragLayer, // and will be one of the last views. for (int i = dragLayer.getChildCount() - 1; i >= 0; i--) { View child = dragLayer.getChildAt(i); if (child instanceof DragView) { dragLayer.removeView(child); } } } }