From cc0d8919ee6423fc5bf459ced7d926271c098837 Mon Sep 17 00:00:00 2001 From: Pat Manning Date: Thu, 24 Aug 2023 14:15:04 +0100 Subject: [PATCH] Scale Launcher folders on hover. Bug: 243191650 Test: PreviewBackgroundTest Flag: ENABLE_CURSOR_HOVER_STATES Change-Id: I8035501203af5c2d97b62cc79e576303e57f8b99 --- .../android/launcher3/folder/FolderIcon.java | 11 +- .../launcher3/folder/PreviewBackground.java | 88 +++-- .../folder/PreviewBackgroundTest.java | 323 ++++++++++++++++++ 3 files changed, 388 insertions(+), 34 deletions(-) create mode 100644 tests/src/com/android/launcher3/folder/PreviewBackgroundTest.java diff --git a/src/com/android/launcher3/folder/FolderIcon.java b/src/com/android/launcher3/folder/FolderIcon.java index d78bfbafb7..53d0efbe85 100644 --- a/src/com/android/launcher3/folder/FolderIcon.java +++ b/src/com/android/launcher3/folder/FolderIcon.java @@ -16,6 +16,7 @@ package com.android.launcher3.folder; +import static com.android.launcher3.config.FeatureFlags.ENABLE_CURSOR_HOVER_STATES; import static com.android.launcher3.folder.ClippedFolderIconLayoutRule.ICON_OVERLAP_FACTOR; import static com.android.launcher3.folder.ClippedFolderIconLayoutRule.MAX_NUM_ITEMS_IN_PREVIEW; import static com.android.launcher3.folder.PreviewItemManager.INITIAL_ITEM_ANIMATION_DURATION; @@ -627,7 +628,7 @@ public class FolderIcon extends FrameLayout implements FolderListener, IconLabel Utilities.scaleRectAboutCenter(iconBounds, iconScale); // If we are animating to the accepting state, animate the dot out. - mDotParams.scale = Math.max(0, mDotScale - mBackground.getScaleProgress()); + mDotParams.scale = Math.max(0, mDotScale - mBackground.getAcceptScaleProgress()); mDotParams.dotColor = mBackground.getDotColor(); mDotRenderer.draw(canvas, mDotParams); } @@ -801,6 +802,14 @@ public class FolderIcon extends FrameLayout implements FolderListener, IconLabel } } + @Override + public void onHoverChanged(boolean hovered) { + super.onHoverChanged(hovered); + if (ENABLE_CURSOR_HOVER_STATES.get()) { + mBackground.setHovered(hovered); + } + } + /** * Interface that provides callbacks to a parent ViewGroup that hosts this FolderIcon. */ diff --git a/src/com/android/launcher3/folder/PreviewBackground.java b/src/com/android/launcher3/folder/PreviewBackground.java index 406955c417..b320cebcfc 100644 --- a/src/com/android/launcher3/folder/PreviewBackground.java +++ b/src/com/android/launcher3/folder/PreviewBackground.java @@ -16,6 +16,8 @@ package com.android.launcher3.folder; +import static com.android.app.animation.Interpolators.ACCELERATE_DECELERATE; +import static com.android.app.animation.Interpolators.EMPHASIZED_DECELERATE; import static com.android.launcher3.folder.ClippedFolderIconLayoutRule.ICON_OVERLAP_FACTOR; import static com.android.launcher3.graphics.IconShape.getShape; import static com.android.launcher3.icons.GraphicsUtils.setColorAlphaBound; @@ -39,6 +41,9 @@ import android.graphics.Region; import android.graphics.Shader; import android.util.Property; import android.view.View; +import android.view.animation.Interpolator; + +import androidx.annotation.VisibleForTesting; import com.android.launcher3.CellLayout; import com.android.launcher3.DeviceProfile; @@ -55,7 +60,10 @@ public class PreviewBackground extends CellLayout.DelegatedCellDrawing { private static final boolean DRAW_SHADOW = false; private static final boolean DRAW_STROKE = false; - private static final int CONSUMPTION_ANIMATION_DURATION = 100; + @VisibleForTesting protected static final int CONSUMPTION_ANIMATION_DURATION = 100; + + @VisibleForTesting protected static final float HOVER_SCALE = 1.1f; + @VisibleForTesting protected static final int HOVER_ANIMATION_DURATION = 300; private final PorterDuffXfermode mShadowPorterDuffXfermode = new PorterDuffXfermode(PorterDuff.Mode.DST_OUT); @@ -86,17 +94,21 @@ public class PreviewBackground extends CellLayout.DelegatedCellDrawing { public boolean isClipping = true; // Drawing / animation configurations - private static final float ACCEPT_SCALE_FACTOR = 1.20f; + @VisibleForTesting protected static final float ACCEPT_SCALE_FACTOR = 1.20f; // Expressed on a scale from 0 to 255. private static final int BG_OPACITY = 255; private static final int MAX_BG_OPACITY = 255; private static final int SHADOW_OPACITY = 40; - private ValueAnimator mScaleAnimator; + @VisibleForTesting protected ValueAnimator mScaleAnimator; private ObjectAnimator mStrokeAlphaAnimator; private ObjectAnimator mShadowAnimator; + @VisibleForTesting protected boolean mIsAccepting; + @VisibleForTesting protected boolean mIsHovered; + @VisibleForTesting protected boolean mIsHoveredOrAnimating; + private static final Property STROKE_ALPHA = new Property(Integer.class, "strokeAlpha") { @Override @@ -203,11 +215,11 @@ public class PreviewBackground extends CellLayout.DelegatedCellDrawing { } /** - * Returns the progress of the scale animation, where 0 means the scale is at 1f - * and 1 means the scale is at ACCEPT_SCALE_FACTOR. + * Returns the progress of the scale animation to accept state, where 0 means the scale is at + * 1f and 1 means the scale is at ACCEPT_SCALE_FACTOR. Returns 0 when scaled due to hover. */ - float getScaleProgress() { - return (mScale - 1f) / (ACCEPT_SCALE_FACTOR - 1f); + float getAcceptScaleProgress() { + return mIsHoveredOrAnimating ? 0 : (mScale - 1f) / (ACCEPT_SCALE_FACTOR - 1f); } void invalidate() { @@ -385,60 +397,70 @@ public class PreviewBackground extends CellLayout.DelegatedCellDrawing { return mDrawingDelegate != null; } - private void animateScale(float finalScale, final Runnable onStart, final Runnable onEnd) { - final float scale0 = mScale; - final float scale1 = finalScale; - + protected void animateScale(boolean isAccepting, boolean isHovered) { if (mScaleAnimator != null) { mScaleAnimator.cancel(); } - mScaleAnimator = ValueAnimator.ofFloat(0f, 1.0f); - - mScaleAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { - @Override - public void onAnimationUpdate(ValueAnimator animation) { - float prog = animation.getAnimatedFraction(); - mScale = prog * scale1 + (1 - prog) * scale0; - invalidate(); + final float startScale = mScale; + final float endScale = isAccepting ? ACCEPT_SCALE_FACTOR : (isHovered ? HOVER_SCALE : 1f); + Interpolator interpolator = + isAccepting != mIsAccepting ? ACCELERATE_DECELERATE : EMPHASIZED_DECELERATE; + int duration = isAccepting != mIsAccepting ? CONSUMPTION_ANIMATION_DURATION + : HOVER_ANIMATION_DURATION; + mIsAccepting = isAccepting; + mIsHovered = isHovered; + if (startScale == endScale) { + if (!mIsAccepting) { + clearDrawingDelegate(); } + mIsHoveredOrAnimating = mIsHovered; + return; + } + + + mScaleAnimator = ValueAnimator.ofFloat(0f, 1.0f); + mScaleAnimator.addUpdateListener(animation -> { + float prog = animation.getAnimatedFraction(); + mScale = prog * endScale + (1 - prog) * startScale; + invalidate(); }); mScaleAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { - if (onStart != null) { - onStart.run(); + if (mIsHovered) { + mIsHoveredOrAnimating = true; } } @Override public void onAnimationEnd(Animator animation) { - if (onEnd != null) { - onEnd.run(); + if (!mIsAccepting) { + clearDrawingDelegate(); } + mIsHoveredOrAnimating = mIsHovered; mScaleAnimator = null; } }); - - mScaleAnimator.setDuration(CONSUMPTION_ANIMATION_DURATION); + mScaleAnimator.setInterpolator(interpolator); + mScaleAnimator.setDuration(duration); mScaleAnimator.start(); } public void animateToAccept(CellLayout cl, int cellX, int cellY) { - animateScale(ACCEPT_SCALE_FACTOR, () -> delegateDrawing(cl, cellX, cellY), null); + delegateDrawing(cl, cellX, cellY); + animateScale(/* isAccepting= */ true, mIsHovered); } public void animateToRest() { - // This can be called multiple times -- we need to make sure the drawing delegate - // is saved and restored at the beginning of the animation, since cancelling the - // existing animation can clear the delgate. - CellLayout cl = mDrawingDelegate; - int cellX = mDelegateCellX; - int cellY = mDelegateCellY; - animateScale(1f, () -> delegateDrawing(cl, cellX, cellY), this::clearDrawingDelegate); + animateScale(/* isAccepting= */ false, mIsHovered); } public float getStrokeWidth() { return mStrokeWidth; } + + protected void setHovered(boolean hovered) { + animateScale(mIsAccepting, /* isHovered= */ hovered); + } } diff --git a/tests/src/com/android/launcher3/folder/PreviewBackgroundTest.java b/tests/src/com/android/launcher3/folder/PreviewBackgroundTest.java new file mode 100644 index 0000000000..715a1f8c70 --- /dev/null +++ b/tests/src/com/android/launcher3/folder/PreviewBackgroundTest.java @@ -0,0 +1,323 @@ +/* + * Copyright (C) 2023 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.folder; + +import static com.android.launcher3.folder.PreviewBackground.ACCEPT_SCALE_FACTOR; +import static com.android.launcher3.folder.PreviewBackground.CONSUMPTION_ANIMATION_DURATION; +import static com.android.launcher3.folder.PreviewBackground.HOVER_ANIMATION_DURATION; +import static com.android.launcher3.folder.PreviewBackground.HOVER_SCALE; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import android.testing.AndroidTestingRunner; +import android.testing.TestableLooper; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.animation.PathInterpolator; + +import androidx.test.filters.SmallTest; + +import com.android.launcher3.CellLayout; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +@SmallTest +@RunWith(AndroidTestingRunner.class) +@TestableLooper.RunWithLooper(setAsMainLooper = true) +public class PreviewBackgroundTest { + + private static final float REST_SCALE = 1f; + private static final float EPSILON = 0.00001f; + + @Mock + CellLayout mCellLayout; + + private final PreviewBackground mPreviewBackground = new PreviewBackground(); + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mPreviewBackground.mScale = REST_SCALE; + mPreviewBackground.mIsAccepting = false; + mPreviewBackground.mIsHovered = false; + mPreviewBackground.mIsHoveredOrAnimating = false; + mPreviewBackground.invalidate(); + } + + @Test + public void testAnimateScale_restToHovered() { + mPreviewBackground.setHovered(true); + runAnimationToFraction(1f); + + assertEquals("Scale not changed.", mPreviewBackground.mScale, HOVER_SCALE, EPSILON); + assertEquals("Duration not correct.", mPreviewBackground.mScaleAnimator.getDuration(), + HOVER_ANIMATION_DURATION); + assertTrue("Wrong interpolator used.", + mPreviewBackground.mScaleAnimator.getInterpolator() instanceof PathInterpolator); + endAnimation(); + assertEquals("Scale progress not 0.", mPreviewBackground.getAcceptScaleProgress(), 0, + EPSILON); + } + + @Test + public void testAnimateScale_restToNotHovered() { + mPreviewBackground.setHovered(false); + + assertEquals("Scale changed.", mPreviewBackground.mScale, REST_SCALE, EPSILON); + assertNull("Animator not null.", mPreviewBackground.mScaleAnimator); + assertEquals("Scale progress not 0.", mPreviewBackground.getAcceptScaleProgress(), 0, + EPSILON); + } + + @Test + public void testAnimateScale_hoveredToHovered() { + mPreviewBackground.mScale = HOVER_SCALE; + mPreviewBackground.mIsHovered = true; + mPreviewBackground.mIsHoveredOrAnimating = true; + mPreviewBackground.invalidate(); + + mPreviewBackground.setHovered(true); + + assertEquals("Scale changed.", mPreviewBackground.mScale, HOVER_SCALE, EPSILON); + assertNull("Animator not null.", mPreviewBackground.mScaleAnimator); + assertEquals("Scale progress not 0.", mPreviewBackground.getAcceptScaleProgress(), 0, + EPSILON); + } + + @Test + public void testAnimateScale_hoveredToRest() { + mPreviewBackground.mScale = HOVER_SCALE; + mPreviewBackground.mIsHovered = true; + mPreviewBackground.mIsHoveredOrAnimating = true; + mPreviewBackground.invalidate(); + + mPreviewBackground.setHovered(false); + runAnimationToFraction(1f); + + assertEquals("Scale not changed.", mPreviewBackground.mScale, REST_SCALE, EPSILON); + assertEquals("Duration not correct.", mPreviewBackground.mScaleAnimator.getDuration(), + HOVER_ANIMATION_DURATION); + assertTrue("Wrong interpolator used.", + mPreviewBackground.mScaleAnimator.getInterpolator() instanceof PathInterpolator); + endAnimation(); + assertEquals("Scale progress not 0.", mPreviewBackground.getAcceptScaleProgress(), 0, + EPSILON); + } + + @Test + public void testAnimateScale_restToAccept() { + mPreviewBackground.animateToAccept(mCellLayout, 0, 0); + runAnimationToFraction(1f); + + assertEquals("Scale changed.", mPreviewBackground.mScale, ACCEPT_SCALE_FACTOR, EPSILON); + assertEquals("Duration not correct.", mPreviewBackground.mScaleAnimator.getDuration(), + CONSUMPTION_ANIMATION_DURATION); + assertTrue("Wrong interpolator used.", + mPreviewBackground.mScaleAnimator.getInterpolator() + instanceof AccelerateDecelerateInterpolator); + endAnimation(); + assertEquals("Scale progress not 1.", mPreviewBackground.getAcceptScaleProgress(), 1, + EPSILON); + } + + @Test + public void testAnimateScale_restToRest() { + mPreviewBackground.animateToRest(); + + assertEquals("Scale changed.", mPreviewBackground.mScale, REST_SCALE, EPSILON); + assertNull("Animator not null.", mPreviewBackground.mScaleAnimator); + assertEquals("Scale progress not 0.", mPreviewBackground.getAcceptScaleProgress(), 0, + EPSILON); + } + + @Test + public void testAnimateScale_acceptToRest() { + mPreviewBackground.mScale = ACCEPT_SCALE_FACTOR; + mPreviewBackground.mIsAccepting = true; + mPreviewBackground.invalidate(); + + mPreviewBackground.animateToRest(); + runAnimationToFraction(1f); + + assertEquals("Scale not changed.", mPreviewBackground.mScale, REST_SCALE, EPSILON); + assertEquals("Duration not correct.", mPreviewBackground.mScaleAnimator.getDuration(), + CONSUMPTION_ANIMATION_DURATION); + assertTrue("Wrong interpolator used.", + mPreviewBackground.mScaleAnimator.getInterpolator() + instanceof AccelerateDecelerateInterpolator); + endAnimation(); + assertEquals("Scale progress not 0.", mPreviewBackground.getAcceptScaleProgress(), 0, + EPSILON); + } + + @Test + public void testAnimateScale_acceptToHover() { + mPreviewBackground.mScale = ACCEPT_SCALE_FACTOR; + mPreviewBackground.mIsAccepting = true; + mPreviewBackground.invalidate(); + + mPreviewBackground.mIsAccepting = false; + mPreviewBackground.setHovered(true); + runAnimationToFraction(1f); + + assertEquals("Scale not changed.", mPreviewBackground.mScale, HOVER_SCALE, EPSILON); + assertEquals("Duration not correct.", mPreviewBackground.mScaleAnimator.getDuration(), + HOVER_ANIMATION_DURATION); + assertTrue("Wrong interpolator used.", + mPreviewBackground.mScaleAnimator.getInterpolator() instanceof PathInterpolator); + endAnimation(); + assertEquals("Scale progress not 0.", mPreviewBackground.getAcceptScaleProgress(), 0, + EPSILON); + } + + @Test + public void testAnimateScale_hoverToAccept() { + mPreviewBackground.mScale = HOVER_SCALE; + mPreviewBackground.mIsHovered = true; + mPreviewBackground.mIsHoveredOrAnimating = true; + mPreviewBackground.invalidate(); + + mPreviewBackground.animateToAccept(mCellLayout, 0, 0); + runAnimationToFraction(1f); + + assertEquals("Scale not changed.", mPreviewBackground.mScale, ACCEPT_SCALE_FACTOR, EPSILON); + assertEquals("Duration not correct.", mPreviewBackground.mScaleAnimator.getDuration(), + CONSUMPTION_ANIMATION_DURATION); + assertTrue("Wrong interpolator used.", + mPreviewBackground.mScaleAnimator.getInterpolator() + instanceof AccelerateDecelerateInterpolator); + mPreviewBackground.mIsHovered = false; + endAnimation(); + assertEquals("Scale progress not 1.", mPreviewBackground.getAcceptScaleProgress(), 1, + EPSILON); + } + + @Test + public void testAnimateScale_midwayToHoverToAccept() { + mPreviewBackground.setHovered(true); + runAnimationToFraction(0.5f); + assertTrue("Scale not changed.", + mPreviewBackground.mScale > REST_SCALE && mPreviewBackground.mScale < HOVER_SCALE); + assertEquals("Scale progress not 0.", mPreviewBackground.getAcceptScaleProgress(), 0, + EPSILON); + + mPreviewBackground.animateToAccept(mCellLayout, 0, 0); + runAnimationToFraction(1f); + + assertEquals("Scale not changed.", mPreviewBackground.mScale, ACCEPT_SCALE_FACTOR, EPSILON); + assertEquals("Duration not correct.", mPreviewBackground.mScaleAnimator.getDuration(), + CONSUMPTION_ANIMATION_DURATION); + assertTrue("Wrong interpolator used.", + mPreviewBackground.mScaleAnimator.getInterpolator() + instanceof AccelerateDecelerateInterpolator); + mPreviewBackground.mIsHovered = false; + endAnimation(); + assertEquals("Scale progress not 1.", mPreviewBackground.getAcceptScaleProgress(), 1, + EPSILON); + assertNull("Animator not null.", mPreviewBackground.mScaleAnimator); + } + + @Test + public void testAnimateScale_partWayToAcceptToHover() { + mPreviewBackground.animateToAccept(mCellLayout, 0, 0); + runAnimationToFraction(0.25f); + assertTrue("Scale not changed part way.", mPreviewBackground.mScale > REST_SCALE + && mPreviewBackground.mScale < ACCEPT_SCALE_FACTOR); + + mPreviewBackground.mIsAccepting = false; + mPreviewBackground.setHovered(true); + runAnimationToFraction(1f); + + assertEquals("Scale not changed.", mPreviewBackground.mScale, HOVER_SCALE, EPSILON); + assertEquals("Duration not correct.", mPreviewBackground.mScaleAnimator.getDuration(), + HOVER_ANIMATION_DURATION); + assertTrue("Wrong interpolator used.", + mPreviewBackground.mScaleAnimator.getInterpolator() instanceof PathInterpolator); + endAnimation(); + assertEquals("Scale progress not 0.", mPreviewBackground.getAcceptScaleProgress(), 0, + EPSILON); + } + + @Test + public void testAnimateScale_midwayToAcceptEqualsHover() { + mPreviewBackground.animateToAccept(mCellLayout, 0, 0); + runAnimationToFraction(0.5f); + assertEquals("Scale not changed.", mPreviewBackground.mScale, HOVER_SCALE, EPSILON); + mPreviewBackground.mIsAccepting = false; + + mPreviewBackground.setHovered(true); + + assertEquals("Scale changed.", mPreviewBackground.mScale, HOVER_SCALE, EPSILON); + assertNull("Animator not null.", mPreviewBackground.mScaleAnimator); + assertEquals("Scale progress not 0.", mPreviewBackground.getAcceptScaleProgress(), 0, + EPSILON); + } + + @Test + public void testAnimateScale_midwayToHoverToRest() { + mPreviewBackground.setHovered(true); + runAnimationToFraction(0.5f); + assertTrue("Scale not changed midway.", + mPreviewBackground.mScale > REST_SCALE && mPreviewBackground.mScale < HOVER_SCALE); + + mPreviewBackground.mIsHovered = false; + mPreviewBackground.animateToRest(); + runAnimationToFraction(1f); + + assertEquals("Scale not changed.", mPreviewBackground.mScale, REST_SCALE, EPSILON); + assertEquals("Duration not correct.", mPreviewBackground.mScaleAnimator.getDuration(), + HOVER_ANIMATION_DURATION); + assertTrue("Wrong interpolator used.", + mPreviewBackground.mScaleAnimator.getInterpolator() instanceof PathInterpolator); + endAnimation(); + assertEquals("Scale progress not 0.", mPreviewBackground.getAcceptScaleProgress(), 0, + EPSILON); + } + + @Test + public void testAnimateScale_midwayToAcceptToRest() { + mPreviewBackground.animateToAccept(mCellLayout, 0, 0); + runAnimationToFraction(0.5f); + assertTrue("Scale not changed.", mPreviewBackground.mScale > REST_SCALE + && mPreviewBackground.mScale < ACCEPT_SCALE_FACTOR); + + mPreviewBackground.animateToRest(); + runAnimationToFraction(1f); + + assertEquals("Scale not changed.", mPreviewBackground.mScale, REST_SCALE, EPSILON); + assertEquals("Duration not correct.", mPreviewBackground.mScaleAnimator.getDuration(), + CONSUMPTION_ANIMATION_DURATION); + assertTrue("Wrong interpolator used.", + mPreviewBackground.mScaleAnimator.getInterpolator() + instanceof AccelerateDecelerateInterpolator); + endAnimation(); + assertEquals("Scale progress not 0.", mPreviewBackground.getAcceptScaleProgress(), 0, + EPSILON); + } + + private void runAnimationToFraction(float animationFraction) { + mPreviewBackground.mScaleAnimator.setCurrentFraction(animationFraction); + } + + private void endAnimation() { + mPreviewBackground.mScaleAnimator.end(); + } +}