From b71758624adf26c8ecb0616e8f2391e23208aeb9 Mon Sep 17 00:00:00 2001 From: Charlie Anderson Date: Mon, 17 Mar 2025 02:17:39 +0000 Subject: [PATCH] update folder anim for new folder specs and spring animation - Refactors FolderAnimationManager into several classes including new FolderSpringBuilderAnimationManager - Changes Folder animations to use animators resembling SpringAnimation - Split up calculations from creating AnimatorSet Bug: 387543793 Test: tested opening closing folders of different sizes Flag: com.android.launcher3.enable_launcher_icon_shapes Flag: com.android.launcher3.enable_expressive_folder_expansion Change-Id: Idf1f21374f987389979ad45544ade1506b26fc0c --- .../anim/SpringAnimationBuilder.java | 10 +- .../launcher3/folder/ClipRevealData.kt | 102 ++++ .../folder/ClippedFolderIconLayoutRule.java | 76 ++- src/com/android/launcher3/folder/Folder.java | 42 +- .../folder/FolderAnimationCreator.kt | 23 + .../launcher3/folder/FolderAnimationData.kt | 130 +++++ .../folder/FolderAnimationManager.java | 19 +- .../FolderAnimationSpringBuilderManager.kt | 91 ++++ .../FolderOpenCloseAnimationListener.kt | 81 +++ .../folder/FolderScrimAnimationListener.kt | 41 ++ .../folder/FolderSpringAnimatorSet.kt | 504 ++++++++++++++++++ .../launcher3/folder/IconAnimationData.kt | 139 +++++ .../launcher3/folder/PreviewItemManager.java | 7 +- .../launcher3/graphics/ShapeDelegate.kt | 102 +++- 14 files changed, 1316 insertions(+), 51 deletions(-) create mode 100644 src/com/android/launcher3/folder/ClipRevealData.kt create mode 100644 src/com/android/launcher3/folder/FolderAnimationCreator.kt create mode 100644 src/com/android/launcher3/folder/FolderAnimationData.kt create mode 100644 src/com/android/launcher3/folder/FolderAnimationSpringBuilderManager.kt create mode 100644 src/com/android/launcher3/folder/FolderOpenCloseAnimationListener.kt create mode 100644 src/com/android/launcher3/folder/FolderScrimAnimationListener.kt create mode 100644 src/com/android/launcher3/folder/FolderSpringAnimatorSet.kt create mode 100644 src/com/android/launcher3/folder/IconAnimationData.kt diff --git a/src/com/android/launcher3/anim/SpringAnimationBuilder.java b/src/com/android/launcher3/anim/SpringAnimationBuilder.java index bc7b7f00d2..df9f97919c 100644 --- a/src/com/android/launcher3/anim/SpringAnimationBuilder.java +++ b/src/com/android/launcher3/anim/SpringAnimationBuilder.java @@ -194,8 +194,14 @@ public class SpringAnimationBuilder { ValueAnimator animator = ValueAnimator.ofFloat(0, mDuration); animator.setDuration(getDuration()).setInterpolator(LINEAR); - animator.addUpdateListener(anim -> - property.set(target, getInterpolatedValue(anim.getAnimatedFraction()))); + animator.addUpdateListener(anim -> { + float value = getInterpolatedValue(anim.getAnimatedFraction()); + if (Float.isNaN(value)) { + value = 0f; + } + property.set(target, value); + } + ); animator.addListener(new AnimationSuccessListener() { @Override public void onAnimationSuccess(Animator animation) { diff --git a/src/com/android/launcher3/folder/ClipRevealData.kt b/src/com/android/launcher3/folder/ClipRevealData.kt new file mode 100644 index 0000000000..2126045ba0 --- /dev/null +++ b/src/com/android/launcher3/folder/ClipRevealData.kt @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2025 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 android.graphics.Rect +import android.graphics.drawable.GradientDrawable +import com.android.launcher3.Utilities +import com.android.launcher3.graphics.ShapeDelegate + +/** + * Start and End values for revealing [Folder] content and background via Clip Animation. Reveal + * Animator defined in [ShapeDelegate]. + */ +data class ClipRevealData( + /** is folder opening or closing */ + val isOpening: Boolean, + /** shape to clip folder to */ + val shapeDelegate: ShapeDelegate, + /** area to clip background to when folder is closed */ + val backgroundStartRect: Rect, + /** area to clip background to when folder is open */ + val backgroundEndRect: Rect, + /** area to clip content to when folder is closed */ + val contentStart: Rect, + /** area to clip content to when folder is open */ + val contentEnd: Rect, + /** radius of folder when open */ + val finalRadius: Float, +) { + companion object Factory { + const val EXTRA_FOLDER_REVEAL_RADIUS_PERCENTAGE = 0.125f + + /** Calculates start and end values for revealing [Folder] background and content */ + fun Folder.getClipRevealData( + shapeDelegate: ShapeDelegate, + folderAnimationData: FolderAnimationData, + ): ClipRevealData { + val folderBackground = background as GradientDrawable + val deviceProfile = mActivityContext.deviceProfile + + with(folderAnimationData) { + // Setup start and end area for revealing Folder background + val backgroundStartRect = + Rect( + previewOffsetX, + contentOffsetY, + Math.round((previewOffsetX + initialFolderSize)), + Math.round((contentOffsetY + initialFolderSize)), + ) + val backgroundEndRect = Rect(0, 0, layoutParams.width, layoutParams.height) + val finalBackgroundRadius = folderBackground.cornerRadius + + // Get page for revealing Folder Content + var page = if (isOpening) content.currentPage else content.destinationPage + if (Utilities.isRtl(context.resources)) { + page = (content.pageCount - 1) - page + } + val pageStart = page * layoutParams.width + + // Setup start and end area for revealing Folder Content + val extraRadius = + ((deviceProfile.folderIconSizePx / initialFolderScale) * + EXTRA_FOLDER_REVEAL_RADIUS_PERCENTAGE) + .toInt() + val contentStart = + Rect( + (pageStart + (backgroundStartRect.left / initialFolderScale)).toInt() - + extraRadius, + (backgroundStartRect.top / initialFolderScale).toInt() - extraRadius, + (pageStart + (backgroundStartRect.right / initialFolderScale)).toInt() + + extraRadius, + (backgroundStartRect.bottom / initialFolderScale).toInt() + extraRadius, + ) + val contentEnd = + Rect(pageStart, 0, pageStart + layoutParams.width, layoutParams.height) + return ClipRevealData( + isOpening = folderAnimationData.isOpening, + shapeDelegate = shapeDelegate, + backgroundStartRect = backgroundStartRect, + backgroundEndRect = backgroundEndRect, + contentStart = contentStart, + contentEnd = contentEnd, + finalRadius = finalBackgroundRadius, + ) + } + } + } +} diff --git a/src/com/android/launcher3/folder/ClippedFolderIconLayoutRule.java b/src/com/android/launcher3/folder/ClippedFolderIconLayoutRule.java index 4ba35c0cc0..1f03954c5c 100644 --- a/src/com/android/launcher3/folder/ClippedFolderIconLayoutRule.java +++ b/src/com/android/launcher3/folder/ClippedFolderIconLayoutRule.java @@ -7,8 +7,8 @@ public class ClippedFolderIconLayoutRule { public static final int MAX_NUM_ITEMS_IN_PREVIEW = 4; private static final int MIN_NUM_ITEMS_IN_PREVIEW = 2; - private static final float MIN_SCALE = 0.44f; - private static final float MAX_SCALE = 0.51f; + public static final float MIN_SCALE = 0.44f; + public static final float MAX_SCALE = 0.51f; // TODO: figure out exact radius for different icons private static final float MAX_RADIUS_DILATION = 0.25f; @@ -27,22 +27,34 @@ public class ClippedFolderIconLayoutRule { private float mIconSize; private boolean mIsRtl; private float mBaselineIconScale; + private int mNumFolderColumns; - public void init(int availableSpace, float intrinsicIconSize, boolean rtl) { + /** + * initialize the layout rule + */ + public void init(int availableSpace, float intrinsicIconSize, boolean rtl, + int numFolderColumns) { mAvailableSpace = availableSpace; mRadius = ( Flags.enableLauncherIconShapes() - ? ITEM_RADIUS_SCALE_FACTOR_SHAPES - : ITEM_RADIUS_SCALE_FACTOR - ) * availableSpace / 2f; + ? ITEM_RADIUS_SCALE_FACTOR_SHAPES + : ITEM_RADIUS_SCALE_FACTOR) * availableSpace / 2f; mIconSize = intrinsicIconSize; mIsRtl = rtl; mBaselineIconScale = availableSpace / intrinsicIconSize; + mNumFolderColumns = numFolderColumns; } + /** + * Computes positions for icons in Preview. + * + * @param index index of icon in folder + * @param curNumItems current number of preview items + * @param params params to update for icon + */ public PreviewItemDrawingParams computePreviewItemDrawingParams(int index, int curNumItems, PreviewItemDrawingParams params) { - float totalScale = scaleForItem(curNumItems); + float totalScale = scaleForItem(curNumItems, 0); float transX; float transY; @@ -72,12 +84,44 @@ public class ClippedFolderIconLayoutRule { return params; } + /** + * Computes positions for icons in folder as part of spring animation. Here both preview icons + * and the rest of the content icons are animated along the same grid. + * + * @param index index of icon in folder + * @param numItemsInPage current number of items in page + * @param params params to update for icon + */ + public PreviewItemDrawingParams computeSpringAnimationItemParams(int index, int numItemsInPage, + int page, PreviewItemDrawingParams params) { + float totalScale = scaleForItem(numItemsInPage, page); + float transX; + float transY; + + if (numItemsInPage <= MAX_NUM_ITEMS_IN_PREVIEW) { + getPosition(index, numItemsInPage, mTmpPoint); + } else { + getGridPosition(index / mNumFolderColumns, index % mNumFolderColumns, mTmpPoint); + } + + transX = mTmpPoint[0]; + transY = mTmpPoint[1]; + + if (params == null) { + params = new PreviewItemDrawingParams(transX, transY, totalScale); + } else { + params.update(transX, transY, totalScale); + } + return params; + } + + /** * Builds a grid based on the positioning of the items when there are * {@link #MAX_NUM_ITEMS_IN_PREVIEW} in the preview. * * Positions in the grid: 0 1 // 0 is row 0, col 1 - * 2 3 // 3 is row 1, col 1` + * 2 3 // 3 is row 1, col 1` */ private void getGridPosition(int row, int col, float[] result) { // We use position 0 and 3 to calculate the x and y distances between items. @@ -123,13 +167,13 @@ public class ClippedFolderIconLayoutRule { float radius = getRadius(curNumItems); double theta = theta0 + index * (2 * Math.PI / curNumItems) * direction; - float halfIconSize = (mIconSize * scaleForItem(curNumItems)) / 2; + float halfIconSize = (mIconSize * scaleForItem(curNumItems, 0)) / 2; // Map the location along the circle, and offset the coordinates to represent the center // of the icon, and to be based from the top / left of the preview area. The y component // is inverted to match the coordinate system. result[0] = mAvailableSpace / 2 + (float) (radius * Math.cos(theta) / 2) - halfIconSize; - result[1] = mAvailableSpace / 2 + (float) (- radius * Math.sin(theta) / 2) - halfIconSize; + result[1] = mAvailableSpace / 2 + (float) (-radius * Math.sin(theta) / 2) - halfIconSize; } private float getRadius(int numItems) { @@ -145,9 +189,17 @@ public class ClippedFolderIconLayoutRule { } } - public float scaleForItem(int numItems) { + /** + * Calculate Scale for Preview Icons based on current page and number of items in page. + * @param numItems number of items in page + * @param page current page of Folder + * @return scale for icons in Folder + */ + public float scaleForItem(int numItems, int page) { float scale; - if (numItems <= 3) { + if (page > 0) { + scale = MIN_SCALE; + } else if (numItems <= 3) { scale = MAX_SCALE; } else { scale = MIN_SCALE; diff --git a/src/com/android/launcher3/folder/Folder.java b/src/com/android/launcher3/folder/Folder.java index af44391b7b..6aeafe4ac2 100644 --- a/src/com/android/launcher3/folder/Folder.java +++ b/src/com/android/launcher3/folder/Folder.java @@ -76,6 +76,7 @@ import com.android.launcher3.DeviceProfile; import com.android.launcher3.DragSource; import com.android.launcher3.DropTarget; import com.android.launcher3.ExtendedEditText; +import com.android.launcher3.Flags; import com.android.launcher3.Launcher; import com.android.launcher3.OnAlarmListener; import com.android.launcher3.R; @@ -88,6 +89,8 @@ import com.android.launcher3.compat.AccessibilityManagerCompat; import com.android.launcher3.config.FeatureFlags; import com.android.launcher3.dragndrop.DragController.DragListener; import com.android.launcher3.dragndrop.DragOptions; +import com.android.launcher3.graphics.ShapeDelegate; +import com.android.launcher3.graphics.ThemeManager; import com.android.launcher3.logger.LauncherAtom.FromState; import com.android.launcher3.logger.LauncherAtom.ToState; import com.android.launcher3.logging.StatsLogManager; @@ -192,6 +195,7 @@ public class Folder extends AbstractFloatingView implements ClipPathView, DragSo // use mActivityContext. protected LauncherDelegate mLauncherDelegate; protected final ActivityContext mActivityContext; + protected FolderAnimationCreator mOpenCloseAnimationManager; public FolderInfo mInfo; private CharSequence mFromTitle; @@ -534,6 +538,17 @@ public class Folder extends AbstractFloatingView implements ClipPathView, DragSo replaceFolderWithFinalItem(); } }); + boolean shouldUseSpringMotion = Flags.enableLauncherIconShapes() + && Flags.enableExpressiveFolderExpansion(); + if (shouldUseSpringMotion) { + ShapeDelegate shapeDelegate = + ThemeManager.INSTANCE.get(mActivityContext.asContext()).getFolderShape(); + mOpenCloseAnimationManager = new FolderAnimationSpringBuilderManager( + this, shapeDelegate, mLauncherDelegate + ); + } else { + mOpenCloseAnimationManager = new FolderAnimationManager(this); + } } public void reapplyItemInfo() { @@ -718,9 +733,10 @@ public class Folder extends AbstractFloatingView implements ClipPathView, DragSo cancelRunningAnimations(); Log.d("b/383526431", "animateOpen: content child count after cancelling" + " animation: " + mContent.getTotalChildCount()); - FolderAnimationManager fam = new FolderAnimationManager(this, true /* isOpening */); - AnimatorSet anim = fam.getAnimator(); - anim.addListener(new AnimatorListenerAdapter() { + + AnimatorSet animatorSet = mOpenCloseAnimationManager.createAnimatorSet(true); + + animatorSet.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { mFolderIcon.setIconVisible(false); @@ -751,7 +767,7 @@ public class Folder extends AbstractFloatingView implements ClipPathView, DragSo // Do not update the flag if we are in drag mode. The flag will be updated, when we // actually drop the icon. final boolean updateAnimationFlag = !mIsDragInProgress; - anim.addListener(new AnimatorListenerAdapter() { + animatorSet.addListener(new AnimatorListenerAdapter() { @SuppressLint("InlinedApi") @Override @@ -777,12 +793,14 @@ public class Folder extends AbstractFloatingView implements ClipPathView, DragSo // b/282158620 because setCurrentPlayTime() below will start animator, we need to register // {@link AnimatorListener} before it so that {@link AnimatorListener#onAnimationStart} can // be called to register mCurrentAnimator, which will be used to cancel animator - addAnimationStartListeners(anim); + addAnimationStartListeners(animatorSet); // Because t=0 has the folder match the folder icon, we can skip the // first frame and have the same movement one frame earlier. Log.d("b/311077782", "Folder.animateOpen"); - anim.setCurrentPlayTime(Math.min(getSingleFrameMs(getContext()), anim.getTotalDuration())); - anim.start(); + animatorSet.setCurrentPlayTime(Math.min( + getSingleFrameMs(getContext()), animatorSet.getTotalDuration())); + animatorSet.start(); + // Make sure the folder picks up the last drag move even if the finger doesn't move. if (mActivityContext.getDragController().isDragging()) { @@ -873,8 +891,9 @@ public class Folder extends AbstractFloatingView implements ClipPathView, DragSo mContent.snapToPageImmediately(mContent.getDestinationPage()); cancelRunningAnimations(); - AnimatorSet a = new FolderAnimationManager(this, false /* isOpening */).getAnimator(); - a.addListener(new AnimatorListenerAdapter() { + AnimatorSet animatorSet = mOpenCloseAnimationManager.createAnimatorSet(false); + + animatorSet.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { setWindowInsetsAnimationCallback(null); @@ -891,8 +910,8 @@ public class Folder extends AbstractFloatingView implements ClipPathView, DragSo mIsAnimatingClosed = false; } }); - addAnimationStartListeners(a); - a.start(); + addAnimationStartListeners(animatorSet); + animatorSet.start(); } @Override @@ -1841,7 +1860,6 @@ public class Folder extends AbstractFloatingView implements ClipPathView, DragSo mFolderName = value; } - @VisibleForTesting FolderNameEditText getFolderName() { return mFolderName; } diff --git a/src/com/android/launcher3/folder/FolderAnimationCreator.kt b/src/com/android/launcher3/folder/FolderAnimationCreator.kt new file mode 100644 index 0000000000..8c05170707 --- /dev/null +++ b/src/com/android/launcher3/folder/FolderAnimationCreator.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2025 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 android.animation.AnimatorSet + +interface FolderAnimationCreator { + fun createAnimatorSet(isOpening: Boolean): AnimatorSet +} diff --git a/src/com/android/launcher3/folder/FolderAnimationData.kt b/src/com/android/launcher3/folder/FolderAnimationData.kt new file mode 100644 index 0000000000..36efa9f046 --- /dev/null +++ b/src/com/android/launcher3/folder/FolderAnimationData.kt @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2025 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 android.graphics.Rect +import android.view.View +import com.android.launcher3.R +import com.android.launcher3.Utilities +import com.android.launcher3.folder.FolderAnimationSpringBuilderManager.Companion.getBubbleTextView +import com.android.launcher3.folder.FolderAnimationSpringBuilderManager.Companion.getPreviewIconsOnPage +import com.android.launcher3.views.BaseDragLayer + +/** Position and Scale values for animating opened/closed [Folder] */ +data class FolderAnimationData( + /** is folder opening or closing */ + val isOpening: Boolean, + /** scale to set folder to */ + val startScale: Float, + /** ratio of folder scale to drag layer scale */ + val folderScale: Float, + /** x distance to translate folder */ + val xDistance: Float, + /** y distance to translate folder */ + val yDistance: Float, + /** change in content area height from scaling */ + val contentHeightDifference: Float, + /** change in preview background radius from scaling */ + val folderRadiusDifference: Int, + /** initial scale of folder icon before animation */ + val initialFolderScale: Float, + /** initial size of preview background before animation */ + val initialFolderSize: Float, + /** initial x offset of folder content */ + val previewOffsetX: Int, + /** scaled offset X for folder preview */ + val scaledPreviewOffsetX: Int, + /** initial y padding of folder content */ + val contentOffsetY: Int, + /** default duration */ + val defaultDuration: Int, +) { + + companion object Factory { + fun Folder.getAnimationData(isOpening: Boolean): FolderAnimationData { + /** Calculates all values required for Folder Animators. */ + // Position and Scale values + val layoutParams = layoutParams as BaseDragLayer.LayoutParams + val previewBackground = folderIcon.mBackground + + // Get items in Preview and their scaling + val itemsInPreview: List = getPreviewIconsOnPage(this, 0) + val previewScale: Float = folderIcon.layoutRule.scaleForItem(itemsInPreview.size, 0) + val previewSize: Float = folderIcon.layoutRule.iconSize * previewScale + + // Get scale and position of FolderIcon relative to DragLayer + val folderIconWorkspacePosition = Rect() + val scaleRelativeToDragLayer: Float = + mActivityContext.dragLayer.getDescendantRectRelativeToSelf( + folderIcon, + folderIconWorkspacePosition, + ) + val scaledFolderRadius: Int = previewBackground.scaledRadius + val baseIconSize: Float = getBubbleTextView(itemsInPreview[0]).iconSize.toFloat() + val initialFolderSize = (scaledFolderRadius * 2) * scaleRelativeToDragLayer + val initialFolderScale = previewSize / baseIconSize * scaleRelativeToDragLayer + + // Get offsets for Previews and Content + val initialPreviewItemOffsetX = + if (Utilities.isRtl(context.resources)) { + (layoutParams.width * initialFolderScale - initialFolderSize).toInt() + } else 0 + val contentOffsetX = (content.paddingLeft * initialFolderScale).toInt() + val contentOffsetY = (content.paddingTop * initialFolderScale).toInt() + + // Get initial position of folder + val initialX = + ((folderIconWorkspacePosition.left + + paddingLeft + + Math.round(previewBackground.offsetX * scaleRelativeToDragLayer)) - + contentOffsetX - + initialPreviewItemOffsetX) + val initialY = + ((folderIconWorkspacePosition.top + + paddingTop + + Math.round(previewBackground.offsetY * scaleRelativeToDragLayer)) - + contentOffsetY) + + // Get scaled height of content and radius of background + val scaledContentHeight = contentAreaHeight.toFloat() * initialFolderScale + val contentHeightDifference = contentAreaHeight.toFloat() - scaledContentHeight + val folderRadiusDifference = previewBackground.scaledRadius - previewBackground.radius + /** + * Background can have a scaled radius in drag and drop mode, so we need to add the + * difference to keep the preview items centered. + */ + return FolderAnimationData( + isOpening = isOpening, + startScale = if (isOpening) initialFolderScale else 1f, + folderScale = initialFolderScale / scaleRelativeToDragLayer, + xDistance = (initialX - layoutParams.x).toFloat(), + yDistance = (initialY - layoutParams.y).toFloat(), + contentHeightDifference = contentHeightDifference, + folderRadiusDifference = folderRadiusDifference, + initialFolderScale = initialFolderScale, + initialFolderSize = initialFolderSize, + previewOffsetX = initialPreviewItemOffsetX + contentOffsetX, + scaledPreviewOffsetX = + (initialPreviewItemOffsetX / scaleRelativeToDragLayer).toInt() + + folderRadiusDifference, + contentOffsetY = contentOffsetY, + defaultDuration = + content.resources.getInteger(R.integer.config_materialFolderExpandDuration), + ) + } + } +} diff --git a/src/com/android/launcher3/folder/FolderAnimationManager.java b/src/com/android/launcher3/folder/FolderAnimationManager.java index d2354c18e1..e8685d5428 100644 --- a/src/com/android/launcher3/folder/FolderAnimationManager.java +++ b/src/com/android/launcher3/folder/FolderAnimationManager.java @@ -37,6 +37,8 @@ import android.view.View; import android.view.animation.AnimationUtils; import android.view.animation.Interpolator; +import androidx.annotation.NonNull; + import com.android.launcher3.BubbleTextView; import com.android.launcher3.CellLayout; import com.android.launcher3.DeviceProfile; @@ -60,7 +62,7 @@ import java.util.List; * ie. When the user taps on the FolderIcon, we immediately hide the FolderIcon and show the Folder * in its place before starting the animation. */ -public class FolderAnimationManager { +public class FolderAnimationManager implements FolderAnimationCreator { private static final float EXTRA_FOLDER_REVEAL_RADIUS_PERCENTAGE = 0.125F; private static final int FOLDER_NAME_ALPHA_DURATION = 32; @@ -75,7 +77,7 @@ public class FolderAnimationManager { private Context mContext; - private final boolean mIsOpening; + private boolean mIsOpening; private final int mDuration; private final int mDelay; @@ -92,7 +94,7 @@ public class FolderAnimationManager { private DeviceProfile mDeviceProfile; - public FolderAnimationManager(Folder folder, boolean isOpening) { + public FolderAnimationManager(Folder folder) { mFolder = folder; mContent = folder.mContent; mFolderBackground = (GradientDrawable) mFolder.getBackground(); @@ -104,7 +106,7 @@ public class FolderAnimationManager { mDeviceProfile = folder.mActivityContext.getDeviceProfile(); mPreviewVerifier = createFolderGridOrganizer(mDeviceProfile); - mIsOpening = isOpening; + mIsOpening = true; Resources res = mContent.getResources(); mDuration = res.getInteger(R.integer.config_materialFolderExpandDuration); @@ -130,7 +132,10 @@ public class FolderAnimationManager { /** * Prepares the Folder for animating between open / closed states. */ - public AnimatorSet getAnimator() { + @NonNull + @Override + public AnimatorSet createAnimatorSet(boolean isOpening) { + mIsOpening = isOpening; final BaseDragLayer.LayoutParams lp = (BaseDragLayer.LayoutParams) mFolder.getLayoutParams(); mFolderIcon.getPreviewItemManager().recomputePreviewDrawingParams(); @@ -145,7 +150,7 @@ public class FolderAnimationManager { float initialSize = (scaledRadius * 2) * scaleRelativeToDragLayer; // Match size/scale of icons in the preview - float previewScale = rule.scaleForItem(itemsInPreview.size()); + float previewScale = rule.scaleForItem(itemsInPreview.size(), 0); float previewSize = rule.getIconSize() * previewScale; float baseIconSize = getBubbleTextView(itemsInPreview.get(0)).getIconSize(); float initialScale = previewSize / baseIconSize * scaleRelativeToDragLayer; @@ -385,7 +390,7 @@ public class FolderAnimationManager { cwc.setupLp(v); // Match scale of icons in the preview of the items on the first page. - float previewScale = rule.scaleForItem(numItemsInFirstPagePreview); + float previewScale = rule.scaleForItem(numItemsInFirstPagePreview, 0); float previewSize = rule.getIconSize() * previewScale; float baseIconSize = getBubbleTextView(v).getIconSize(); float iconScale = previewSize / baseIconSize; diff --git a/src/com/android/launcher3/folder/FolderAnimationSpringBuilderManager.kt b/src/com/android/launcher3/folder/FolderAnimationSpringBuilderManager.kt new file mode 100644 index 0000000000..f3be3bb21a --- /dev/null +++ b/src/com/android/launcher3/folder/FolderAnimationSpringBuilderManager.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2025 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 android.animation.AnimatorSet +import android.view.View +import com.android.launcher3.BubbleTextView +import com.android.launcher3.Hotseat +import com.android.launcher3.Workspace +import com.android.launcher3.apppairs.AppPairIcon +import com.android.launcher3.folder.ClipRevealData.Factory.getClipRevealData +import com.android.launcher3.folder.FolderAnimationData.Factory.getAnimationData +import com.android.launcher3.folder.FolderGridOrganizer.createFolderGridOrganizer +import com.android.launcher3.folder.IconAnimationData.Factory.getIconAnimationDataList +import com.android.launcher3.graphics.ShapeDelegate + +/** + * Manages the opening and closing animations for a [Folder]. + * + * All of the animations are done in the Folder. ie. When the user taps on the FolderIcon, we + * immediately hide the FolderIcon and show the Folder in its place before starting the animation. + * + * @param folder the [Folder] to animate open or closed + * @param isOpening whether we are opening or closing the [Folder] + */ +class FolderAnimationSpringBuilderManager( + private val folder: Folder, + private val shapeDelegate: ShapeDelegate, + private val launcherDelegate: LauncherDelegate, +) : FolderAnimationCreator { + override fun createAnimatorSet(isOpening: Boolean): AnimatorSet { + resetLauncherScale(launcherDelegate.launcher?.workspace, launcherDelegate.launcher?.hotseat) + val folderAnimData: FolderAnimationData = folder.getAnimationData(isOpening) + val clipRevealData: ClipRevealData = folder.getClipRevealData(shapeDelegate, folderAnimData) + val iconAnimData: List = folder.getIconAnimationDataList(folderAnimData) + return FolderSpringAnimatorSet.build( + folder = folder, + launcherDelegate = launcherDelegate, + folderAnimData = folderAnimData, + clipRevealData = clipRevealData, + iconAnimData = iconAnimData, + ) + .animatorSet + } + + // Folders can exist outside of Launcher (Ex. Transient Taskbar) + // So we only apply Launcher effects when we have a Launcher. + // We need to reset these values before calculating folder positioning. + private fun resetLauncherScale(workspace: Workspace<*>?, hotseat: Hotseat?) { + if (hotseat == null || workspace == null) return + // Used to match the translation of the scaling between hotseat and workspace. + workspace.setPivotToScaleWithSelf(hotseat) + // Since we scale down workspace/hotseat when opening folder, + // need to have initial values to find starting folder icon location + workspace.scaleX = 1f + workspace.scaleY = 1f + hotseat.scaleX = 1f + hotseat.scaleY = 1f + } + + companion object { + /** Returns the list of "preview items" on {@param page}. */ + fun getPreviewIconsOnPage(folder: Folder, page: Int): List { + return createFolderGridOrganizer(folder.mActivityContext.deviceProfile) + .setFolderInfo(folder.mInfo) + .previewItemsForPage(page, folder.iconsInReadingOrder) + } + + /** + * Gets the [BubbleTextView] from an icon. In some cases the BubbleTextView is the whole + * icon itself, while in others it is contained within the view and only serves to store the + * title text. + */ + fun getBubbleTextView(v: View): BubbleTextView { + return if (v is AppPairIcon) v.titleTextView else (v as BubbleTextView) + } + } +} diff --git a/src/com/android/launcher3/folder/FolderOpenCloseAnimationListener.kt b/src/com/android/launcher3/folder/FolderOpenCloseAnimationListener.kt new file mode 100644 index 0000000000..3446749cfc --- /dev/null +++ b/src/com/android/launcher3/folder/FolderOpenCloseAnimationListener.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2025 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 android.animation.Animator +import android.animation.AnimatorListenerAdapter +import com.android.launcher3.CellLayout + +class FolderOpenCloseAnimationListener(val folder: Folder, val isOpening: Boolean) : + AnimatorListenerAdapter() { + // Because {@link #onAnimationStart} and {@link #onAnimationEnd} callbacks are sent to + // message queue and executed on separate frame, we should save states in + // {@link #onAnimationStart} instead of before creating animator, so that cancelling + // animation A and restarting animation B allows A to reset states in + // {@link #onAnimationEnd} before B reads new UI state from {@link #onAnimationStart}. + + private var cellLayout: CellLayout? = null + private var folderClipChildren = false + private var folderClipToPadding = false + private var contentClipChildren = false + private var contentClipToPadding = false + private var cellLayoutClipChildren = false + private var cellLayoutClipPadding = false + + override fun onAnimationStart(animator: Animator) { + super.onAnimationStart(animator) + with(folder) { + folderClipChildren = clipChildren + folderClipToPadding = clipToPadding + contentClipChildren = content.clipChildren + contentClipToPadding = content.clipToPadding + cellLayout = content.currentCellLayout + cellLayoutClipChildren = cellLayout?.clipChildren ?: false + cellLayoutClipPadding = cellLayout?.clipToPadding ?: false + + clipChildren = false + clipToPadding = false + content.clipChildren = false + content.clipToPadding = false + cellLayout?.clipChildren = false + cellLayout?.clipToPadding = false + } + } + + override fun onAnimationEnd(animation: Animator) { + super.onAnimationEnd(animation) + with(folder) { + translationX = 0.0f + translationY = 0.0f + translationZ = 0.0f + content.scaleX = 1f + content.scaleY = 1f + mFooter.scaleX = 1f + mFooter.scaleY = 1f + mFooter.translationX = 0f + folderName.alpha = 1f + content.setClipPath(null) + setClipPath(null) + clipChildren = folderClipChildren + clipToPadding = folderClipToPadding + content.clipChildren = contentClipChildren + content.clipToPadding = contentClipToPadding + cellLayout?.clipChildren = cellLayoutClipChildren + cellLayout?.clipToPadding = cellLayoutClipPadding + } + } +} diff --git a/src/com/android/launcher3/folder/FolderScrimAnimationListener.kt b/src/com/android/launcher3/folder/FolderScrimAnimationListener.kt new file mode 100644 index 0000000000..d82a9936a5 --- /dev/null +++ b/src/com/android/launcher3/folder/FolderScrimAnimationListener.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2025 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 android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.graphics.Color +import com.android.launcher3.views.ScrimView + +class FolderScrimAnimationListener(val scrimView: ScrimView?, val isOpening: Boolean) : + AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + super.onAnimationEnd(animation) + if (!isOpening) { + scrimView?.alpha = 1f + scrimView?.setBackgroundColor(Color.TRANSPARENT) + } + } + + override fun onAnimationCancel(animation: Animator) { + super.onAnimationCancel(animation) + if (!isOpening) { + scrimView?.alpha = 1f + scrimView?.setBackgroundColor(Color.TRANSPARENT) + } + } +} diff --git a/src/com/android/launcher3/folder/FolderSpringAnimatorSet.kt b/src/com/android/launcher3/folder/FolderSpringAnimatorSet.kt new file mode 100644 index 0000000000..3922799337 --- /dev/null +++ b/src/com/android/launcher3/folder/FolderSpringAnimatorSet.kt @@ -0,0 +1,504 @@ +/* + * Copyright (C) 2025 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 android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.util.FloatProperty +import android.util.Property +import android.view.View +import androidx.dynamicanimation.animation.DynamicAnimation.MIN_VISIBLE_CHANGE_ALPHA +import androidx.dynamicanimation.animation.DynamicAnimation.MIN_VISIBLE_CHANGE_PIXELS +import androidx.dynamicanimation.animation.DynamicAnimation.MIN_VISIBLE_CHANGE_SCALE +import com.android.launcher3.BubbleTextView +import com.android.launcher3.LauncherAnimUtils +import com.android.launcher3.LauncherAnimUtils.SCALE_PROPERTY +import com.android.launcher3.R +import com.android.launcher3.Utilities.isDarkTheme +import com.android.launcher3.anim.SpringAnimationBuilder +import com.android.launcher3.apppairs.AppPairIcon +import com.android.launcher3.folder.ClippedFolderIconLayoutRule.MAX_NUM_ITEMS_IN_PREVIEW +import com.android.launcher3.util.Themes + +/** Holder for Animators created from [FolderAnimationSpringBuilderManager] */ +class FolderSpringAnimatorSet(val animatorSet: AnimatorSet) { + + fun start() { + animatorSet.start() + } + + companion object Factory { + private const val LAUNCHER_SCALE = 0.975f + private const val FOLDER_NAME_ALPHA_DURATION = 32 + private const val LARGE_FOLDER_FOOTER_DURATION = 128 + private const val STIFFNESS_SHAPE_POSITION = 380f + private const val DAMPING_SHAPE_POSITION = 0.8f + private const val STIFFNESS_ALPHA = 1600f + private const val DAMPING_ALPHA = 0.9f + private const val STIFFNESS_LAUNCHER_SCRIM = 380f + private const val DAMPING_LAUNCHER_SCRIM = 0.98f + + /** + * Factory method to take data calculated from [FolderAnimationSpringBuilderManager], and + * create a [FolderSpringAnimatorSet] with [SpringAnimationBuilder]. + */ + fun build( + folder: Folder, + launcherDelegate: LauncherDelegate, + folderAnimData: FolderAnimationData, + clipRevealData: ClipRevealData, + iconAnimData: List, + ): FolderSpringAnimatorSet { + val animatorSet = AnimatorSet() + setupFolder(folder, folderAnimData) + addFolderScaleAndTranslateAnimators(folder, animatorSet, folderAnimData) + addClipRevealAnimators(folder, animatorSet, clipRevealData) + addAlphaAndColorAnimators(folder, animatorSet, folderAnimData) + addScrimAnimators( + folder.context, + animatorSet, + folderAnimData.isOpening, + launcherDelegate, + ) + iconAnimData.forEach { addContentIconAnimators(folder.context, animatorSet, it) } + return FolderSpringAnimatorSet(animatorSet) + } + + private fun playSpringAnimation( + context: Context, + animatorSet: AnimatorSet, + isOpening: Boolean, + startDelay: Int, + stiffness: Float, + damping: Float, + startValue: Float, + endValue: Float, + minVisibleChange: Float, + property: Property, + view: View, + ) { + val animatorBuilder = + SpringAnimationBuilder(context) + .setStiffness(stiffness) + .setDampingRatio(damping) + .setStartValue(if (isOpening) startValue else endValue) + .setEndValue(if (isOpening) endValue else startValue) + .setMinimumVisibleChange(minVisibleChange) + + val animator = + animatorBuilder.build(view, property as FloatProperty).apply { + setStartDelay(startDelay.toLong()) + } + animatorSet.play(animator) + } + + private fun setupFolder(folder: Folder, folderAnimationData: FolderAnimationData) { + folder.folderIcon.previewItemManager.recomputePreviewDrawingParams() + folder.apply { + pivotX = 0f + pivotY = 0f + } + folder.content.apply { + scaleX = folderAnimationData.startScale + scaleY = folderAnimationData.startScale + pivotX = 0f + pivotY = 0f + } + folder.mFooter.apply { + scaleX = folderAnimationData.startScale + scaleY = folderAnimationData.startScale + pivotX = 0f + pivotY = 0f + } + } + + private fun addFolderScaleAndTranslateAnimators( + folder: Folder, + animatorSet: AnimatorSet, + animationData: FolderAnimationData, + ) { + val isOpening = animationData.isOpening + playSpringAnimation( + context = folder.context, + animatorSet = animatorSet, + isOpening = isOpening, + startDelay = 0, + stiffness = STIFFNESS_SHAPE_POSITION, + damping = DAMPING_SHAPE_POSITION, + startValue = animationData.xDistance, + endValue = 0f, + minVisibleChange = MIN_VISIBLE_CHANGE_PIXELS, + property = View.TRANSLATION_X, + view = folder, + ) + playSpringAnimation( + context = folder.context, + animatorSet = animatorSet, + isOpening = isOpening, + startDelay = 0, + stiffness = STIFFNESS_SHAPE_POSITION, + damping = DAMPING_SHAPE_POSITION, + startValue = animationData.yDistance, + endValue = 0f, + minVisibleChange = MIN_VISIBLE_CHANGE_PIXELS, + property = View.TRANSLATION_Y, + view = folder, + ) + playSpringAnimation( + context = folder.context, + animatorSet = animatorSet, + isOpening = isOpening, + startDelay = 0, + stiffness = STIFFNESS_SHAPE_POSITION, + damping = DAMPING_SHAPE_POSITION, + startValue = animationData.initialFolderScale, + endValue = 1f, + minVisibleChange = MIN_VISIBLE_CHANGE_SCALE, + property = SCALE_PROPERTY, + view = folder.content, + ) + playSpringAnimation( + context = folder.context, + animatorSet = animatorSet, + isOpening = isOpening, + startDelay = 0, + stiffness = STIFFNESS_SHAPE_POSITION, + damping = DAMPING_SHAPE_POSITION, + startValue = animationData.initialFolderScale, + endValue = 1f, + minVisibleChange = MIN_VISIBLE_CHANGE_SCALE, + property = SCALE_PROPERTY, + view = folder.mFooter, + ) + // Translate the footer so that it tracks the bottom of the content. + playSpringAnimation( + context = folder.context, + animatorSet = animatorSet, + isOpening = isOpening, + startDelay = 0, + stiffness = STIFFNESS_SHAPE_POSITION, + damping = DAMPING_SHAPE_POSITION, + startValue = -(animationData.contentHeightDifference), + endValue = 0f, + minVisibleChange = MIN_VISIBLE_CHANGE_PIXELS, + property = View.TRANSLATION_Y, + view = folder.mFooter, + ) + + // Animate the elevation midway so that the shadow is not noticeable in the background. + val midDuration = animationData.defaultDuration / 2 + playSpringAnimation( + context = folder.context, + animatorSet = animatorSet, + isOpening = isOpening, + startDelay = if (animationData.isOpening) midDuration else 0, + stiffness = STIFFNESS_SHAPE_POSITION, + damping = DAMPING_SHAPE_POSITION, + startValue = -folder.elevation, + endValue = 0f, + minVisibleChange = MIN_VISIBLE_CHANGE_PIXELS, + property = View.TRANSLATION_Z, + view = folder, + ) + // store clip variables + animatorSet.addListener( + FolderOpenCloseAnimationListener(folder, animationData.isOpening) + ) + } + + private fun addAlphaAndColorAnimators( + folder: Folder, + animatorSet: AnimatorSet, + animationData: FolderAnimationData, + ) { + with(folder) { + val folderBackground = folder.background as GradientDrawable + // Set up the Folder background. + val isOpening = animationData.isOpening + val initialColor = Themes.getAttrColor(context, R.attr.folderPreviewColor) + val finalColor = Themes.getAttrColor(context, R.attr.folderBackgroundColor) + folderBackground.mutate() + folderBackground.setColor(if (isOpening) initialColor else finalColor) + // TODO: convert to spring animation? + animatorSet.play( + ObjectAnimator.ofArgb( + folderBackground, + "color", + if (isOpening) initialColor else finalColor, + if (isOpening) finalColor else initialColor, + ) + .apply { duration = animationData.defaultDuration.toLong() } + ) + + val footerAlphaDuration: Int + var footerStartDelay = 0 + val isLargeFolder = folder.itemCount > MAX_NUM_ITEMS_IN_PREVIEW + if (isLargeFolder) { + if (isOpening) { + folder.mFooter.alpha = 0f + footerAlphaDuration = LARGE_FOLDER_FOOTER_DURATION + footerStartDelay = animationData.defaultDuration - footerAlphaDuration + } + } + + playSpringAnimation( + context = folder.context, + animatorSet = animatorSet, + isOpening = isOpening, + startDelay = if (animationData.isOpening) footerStartDelay else 0, + stiffness = STIFFNESS_ALPHA, + damping = DAMPING_ALPHA, + startValue = 0f, + endValue = 1f, + minVisibleChange = MIN_VISIBLE_CHANGE_ALPHA, + property = View.ALPHA, + view = mFooter, + ) + // Fade in the folder name, as the text can overlap the icons when grid size is + // small. + folder.folderName.alpha = if (animationData.isOpening) 0f else 1f + playSpringAnimation( + context = folder.context, + animatorSet = animatorSet, + isOpening = isOpening, + startDelay = if (animationData.isOpening) FOLDER_NAME_ALPHA_DURATION else 0, + stiffness = STIFFNESS_ALPHA, + damping = DAMPING_ALPHA, + startValue = 0f, + endValue = 1f, + minVisibleChange = MIN_VISIBLE_CHANGE_ALPHA, + property = View.ALPHA, + view = folderName, + ) + } + } + + private fun addClipRevealAnimators( + folder: Folder, + animatorSet: AnimatorSet, + revealData: ClipRevealData, + ) { + with(revealData) { + // Create reveal animator for the folder background + animatorSet.play( + shapeDelegate.createRevealAnimator( + folder, + backgroundStartRect, + backgroundEndRect, + finalRadius, + !isOpening, + ) + ) + // animated contents of folder with the folder background + animatorSet.play( + shapeDelegate.createRevealAnimator( + folder.content, + contentStart, + contentEnd, + finalRadius, + !isOpening, + ) + ) + } + } + + private fun addScrimAnimators( + context: Context, + animatorSet: AnimatorSet, + isOpening: Boolean, + launcherDelegate: LauncherDelegate, + ) { + val launcher = launcherDelegate.launcher ?: return + val scrimView = launcher.scrimView + val workspace = launcher.workspace + val hotseat = launcher.hotseat + val finalScrimAlpha = if (isDarkTheme(context)) 0.32f else 0.2f + scrimView.setBackgroundColor(Color.BLACK) + playSpringAnimation( + context = context, + animatorSet = animatorSet, + isOpening = isOpening, + startDelay = 0, + stiffness = STIFFNESS_LAUNCHER_SCRIM, + damping = DAMPING_LAUNCHER_SCRIM, + startValue = 0f, + endValue = finalScrimAlpha, + minVisibleChange = MIN_VISIBLE_CHANGE_ALPHA, + property = LauncherAnimUtils.VIEW_ALPHA, + view = scrimView, + ) + playSpringAnimation( + context = context, + animatorSet = animatorSet, + isOpening = isOpening, + startDelay = 0, + stiffness = STIFFNESS_LAUNCHER_SCRIM, + damping = DAMPING_LAUNCHER_SCRIM, + startValue = 1f, + endValue = LAUNCHER_SCALE, + minVisibleChange = MIN_VISIBLE_CHANGE_SCALE, + property = SCALE_PROPERTY, + view = workspace, + ) + playSpringAnimation( + context = context, + animatorSet = animatorSet, + isOpening = isOpening, + startDelay = 0, + stiffness = STIFFNESS_LAUNCHER_SCRIM, + damping = DAMPING_LAUNCHER_SCRIM, + startValue = 1f, + endValue = LAUNCHER_SCALE, + minVisibleChange = MIN_VISIBLE_CHANGE_SCALE, + property = SCALE_PROPERTY, + view = hotseat, + ) + animatorSet.addListener(FolderScrimAnimationListener(scrimView, isOpening)) + } + + /** + * Gets the [BubbleTextView] from an icon. In some cases the BubbleTextView is the whole + * icon itself, while in others it is contained within the view and only serves to store the + * title text. + */ + private fun getBubbleTextView(v: View): BubbleTextView { + return if (v is AppPairIcon) v.titleTextView else (v as BubbleTextView) + } + + private fun addContentIconAnimators( + context: Context, + animatorSet: AnimatorSet, + iconData: IconAnimationData, + ) { + with(iconData) { + val titleText = getBubbleTextView(icon) + if (isOpening) { + titleText.setTextVisibility(false) + } + val anim = + titleText.createTextAlphaAnimator(isOpening).apply { + startDelay = (if (isOpening) iconDelay + 100 else iconDelay).toLong() + } + animatorSet.play(anim) + if (!itemsInPreview.contains(icon)) { + playSpringAnimation( + context = context, + animatorSet = animatorSet, + isOpening = isOpening, + startDelay = iconDelay, + stiffness = STIFFNESS_ALPHA, + damping = DAMPING_ALPHA, + startValue = 0f, + endValue = 1f, + minVisibleChange = MIN_VISIBLE_CHANGE_ALPHA, + property = View.ALPHA, + view = icon, + ) + } + playSpringAnimation( + context = context, + animatorSet = animatorSet, + isOpening = isOpening, + startDelay = iconDelay, + stiffness = STIFFNESS_SHAPE_POSITION, + damping = DAMPING_SHAPE_POSITION, + startValue = xDistance, + endValue = 0f, + minVisibleChange = MIN_VISIBLE_CHANGE_PIXELS, + property = View.TRANSLATION_X, + view = icon, + ) + playSpringAnimation( + context = context, + animatorSet = animatorSet, + isOpening = isOpening, + startDelay = iconDelay, + stiffness = STIFFNESS_SHAPE_POSITION, + damping = DAMPING_SHAPE_POSITION, + startValue = yDistance, + endValue = 0f, + minVisibleChange = MIN_VISIBLE_CHANGE_PIXELS, + property = View.TRANSLATION_Y, + view = icon, + ) + playSpringAnimation( + context = context, + animatorSet = animatorSet, + isOpening = isOpening, + startDelay = iconDelay, + stiffness = STIFFNESS_SHAPE_POSITION, + damping = DAMPING_SHAPE_POSITION, + startValue = initialIconScale, + endValue = 1f, + minVisibleChange = MIN_VISIBLE_CHANGE_SCALE, + property = SCALE_PROPERTY, + view = icon, + ) + animatorSet.addListener( + getIconAnimatorListener( + icon = icon, + xDistance = xDistance, + yDistance = yDistance, + initialScale = initialIconScale, + itemsInPreview = itemsInPreview, + isOpening = isOpening, + ) + ) + } + } + + private fun getIconAnimatorListener( + icon: View, + xDistance: Float, + yDistance: Float, + initialScale: Float, + itemsInPreview: List, + isOpening: Boolean, + ) = + object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator) { + super.onAnimationStart(animation) + // Necessary to initialize values here because of the start delay. + if (isOpening) { + icon.translationX = xDistance + icon.translationY = yDistance + icon.scaleX = initialScale + icon.scaleY = initialScale + if (!itemsInPreview.contains(icon)) { + icon.alpha = 0f + } + } + } + + override fun onAnimationEnd(animation: Animator) { + super.onAnimationEnd(animation) + icon.translationX = 0.0f + icon.translationY = 0.0f + icon.scaleX = 1f + icon.scaleY = 1f + if (!itemsInPreview.contains(icon)) { + icon.alpha = 1f + } + } + } + } +} diff --git a/src/com/android/launcher3/folder/IconAnimationData.kt b/src/com/android/launcher3/folder/IconAnimationData.kt new file mode 100644 index 0000000000..e1a37a0f3c --- /dev/null +++ b/src/com/android/launcher3/folder/IconAnimationData.kt @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2025 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 android.view.View +import com.android.launcher3.celllayout.CellLayoutLayoutParams +import com.android.launcher3.folder.ClippedFolderIconLayoutRule.MAX_NUM_ITEMS_IN_PREVIEW +import com.android.launcher3.folder.FolderAnimationSpringBuilderManager.Companion.getBubbleTextView +import com.android.launcher3.folder.FolderAnimationSpringBuilderManager.Companion.getPreviewIconsOnPage + +/** Animation Values for animating icons inside Folder Content */ +data class IconAnimationData( + /** icon in folder content to animate */ + val icon: View, + /** delay for animating this icon */ + val iconDelay: Int, + /** icons to display in Folder Preview */ + val itemsInPreview: List, + /** x distance to translate this icon */ + val xDistance: Float, + /** y distance to translate this icon */ + val yDistance: Float, + /** initial scale of this icon when folder is closed */ + val initialIconScale: Float, + /** if folder is opening or closing */ + val isOpening: Boolean, +) { + + companion object Factory { + private const val ICON_DELAY_INCREMENT = 5 + + /** + * Animates the icons within the folder. Icons start at the Preview Icon scale and then are + * translated and scaled to the final folder content icon size. Their animations are also + * delayed one by one from top left to bottom right. + */ + fun Folder.getIconAnimationDataList( + folderAnimationData: FolderAnimationData + ): List { + val isOpening = folderAnimationData.isOpening + // current page data + val page = content.currentPage + val folderItemsOnPage = getItemsOnPage(page) + val numItemsOnPage = folderItemsOnPage.size + // layout and preview data + val itemsInPreview = getPreviewIconsOnPage(this, page) + val shortcutsAndWidgets = content.getPageAt(0)?.shortcutsAndWidgets + val mTmpParams = PreviewItemDrawingParams(0f, 0f, 0f) + val layoutRule = folderIcon.layoutRule + + // We delay the animation of each icon from top left to bottom right + var iconDelay = if (isOpening) 0 else (numItemsOnPage * ICON_DELAY_INCREMENT) + val iconDataList = mutableListOf() + + for (i in 0.. 0) { + // If not on first page, we don't want preview items to position in a + // circle. + MAX_NUM_ITEMS_IN_PREVIEW + } else { + numItemsOnPage + } + // Match positions of the icons in the folder with their positions in the preview + layoutRule.computeSpringAnimationItemParams(i, pageLayoutCount, page, mTmpParams) + + // The PreviewLayoutRule assumes that the icon size takes up the entire width so we + // offset by the actual size. + val iconOffsetX = ((iconLayoutParams.width - baseIconSize) * iconScale).toInt() / 2 + + // Calculate positions for each icon + val iconPositionX = + ((mTmpParams.transX - iconOffsetX + folderAnimationData.scaledPreviewOffsetX) / + folderAnimationData.folderScale) + .toInt() + val paddingTop = currentIcon.paddingTop * iconScale + val iconPositionY = + ((mTmpParams.transY + folderAnimationData.folderRadiusDifference - paddingTop) / + folderAnimationData.folderScale) + .toInt() + val xDistance = (iconPositionX - iconLayoutParams.x).toFloat() + val yDistance = (iconPositionY - iconLayoutParams.y).toFloat() + + iconDataList.add( + IconAnimationData( + icon = currentIcon, + iconDelay = iconDelay, + itemsInPreview = itemsInPreview, + xDistance = xDistance, + yDistance = yDistance, + initialIconScale = initialIconScale, + isOpening = isOpening, + ) + ) + if (isOpening) { + iconDelay += ICON_DELAY_INCREMENT + } else { + iconDelay -= ICON_DELAY_INCREMENT + } + } + return iconDataList + } + } +} diff --git a/src/com/android/launcher3/folder/PreviewItemManager.java b/src/com/android/launcher3/folder/PreviewItemManager.java index e437b225e2..65c2e3d698 100644 --- a/src/com/android/launcher3/folder/PreviewItemManager.java +++ b/src/com/android/launcher3/folder/PreviewItemManager.java @@ -159,8 +159,11 @@ public class PreviewItemManager { mIcon.mBackground.setup(mIcon.getContext(), mIcon.mActivity, mIcon, mTotalWidth, mIcon.getPaddingTop()); - mIcon.mPreviewLayoutRule.init(mIcon.mBackground.previewSize, mIntrinsicIconSize, - Utilities.isRtl(mIcon.getResources())); + mIcon.mPreviewLayoutRule.init( + mIcon.mBackground.previewSize, mIntrinsicIconSize, + Utilities.isRtl(mIcon.getResources()), + mIcon.mActivity.getDeviceProfile().numFolderColumns + ); updatePreviewItems(false); } } diff --git a/src/com/android/launcher3/graphics/ShapeDelegate.kt b/src/com/android/launcher3/graphics/ShapeDelegate.kt index cd21f2a7fc..8e72d8f53b 100644 --- a/src/com/android/launcher3/graphics/ShapeDelegate.kt +++ b/src/com/android/launcher3/graphics/ShapeDelegate.kt @@ -30,11 +30,13 @@ import android.graphics.RectF import android.graphics.Region import android.graphics.drawable.AdaptiveIconDrawable import android.graphics.drawable.ColorDrawable +import android.util.FloatProperty import android.util.Log import android.view.View import android.view.ViewOutlineProvider import androidx.annotation.VisibleForTesting import androidx.core.graphics.PathParser +import androidx.dynamicanimation.animation.DynamicAnimation.MIN_VISIBLE_CHANGE_SCALE import androidx.graphics.shapes.CornerRounding import androidx.graphics.shapes.Morph import androidx.graphics.shapes.RoundedPolygon @@ -42,6 +44,8 @@ import androidx.graphics.shapes.SvgPathParser import androidx.graphics.shapes.rectangle import androidx.graphics.shapes.toPath import androidx.graphics.shapes.transformed +import com.android.launcher3.Flags +import com.android.launcher3.anim.SpringAnimationBuilder import com.android.launcher3.icons.GraphicsUtils import com.android.launcher3.views.ClipPathView @@ -127,19 +131,25 @@ interface ShapeDelegate { isReversed: Boolean, ): ValueAnimator where T : View, T : ClipPathView { val startRadius = (startRect.width() / 2f) * radiusRatio - return ClipAnimBuilder(target) { progress, path -> - val radius = (1 - progress) * startRadius + progress * endRadius - path.addRoundRect( - (1 - progress) * startRect.left + progress * endRect.left, - (1 - progress) * startRect.top + progress * endRect.top, - (1 - progress) * startRect.right + progress * endRect.right, - (1 - progress) * startRect.bottom + progress * endRect.bottom, - radius, - radius, - Path.Direction.CW, - ) - } - .toAnim(isReversed) + val pathProvider = { progress: Float, path: Path -> + val radius = (1 - progress) * startRadius + progress * endRadius + path.addRoundRect( + (1 - progress) * startRect.left + progress * endRect.left, + (1 - progress) * startRect.top + progress * endRect.top, + (1 - progress) * startRect.right + progress * endRect.right, + (1 - progress) * startRect.bottom + progress * endRect.bottom, + radius, + radius, + Path.Direction.CW, + ) + } + val shouldUseSpringAnimation = + Flags.enableLauncherIconShapes() && Flags.enableExpressiveFolderExpansion() + return if (shouldUseSpringAnimation) { + ClipSpringAnimBuilder(target, pathProvider).toAnim(isReversed) + } else { + ClipAnimBuilder(target, pathProvider).toAnim(isReversed) + } } override fun equals(other: Any?) = @@ -222,8 +232,13 @@ interface ShapeDelegate { cornerR = endRadius, ), ) - - return ClipAnimBuilder(target, morph::toPath).toAnim(isReversed) + val shouldUseSpringAnimation = + Flags.enableLauncherIconShapes() && Flags.enableExpressiveFolderExpansion() + return if (shouldUseSpringAnimation) { + ClipSpringAnimBuilder(target, morph::toPath).toAnim(isReversed) + } else { + ClipAnimBuilder(target, morph::toPath).toAnim(isReversed) + } } } @@ -263,11 +278,66 @@ interface ShapeDelegate { } } - companion object { + private class ClipSpringAnimBuilder(val target: T, val pathProvider: (Float, Path) -> Unit) : + AnimatorListenerAdapter() where T : View, T : ClipPathView { + private var oldOutlineProvider: ViewOutlineProvider? = null + val path = Path() + private val animatorBuilder = SpringAnimationBuilder(target.context) + private val progressProperty = + object : FloatProperty>("progress") { + override fun setValue(obj: ClipSpringAnimBuilder, value: Float) { + // Don't want to go below 0 or above 1 for progress. + val clampedValue = minOf(maxOf(value, 0f), 1f) + path.reset() + pathProvider.invoke(clampedValue, path) + target.setClipPath(path) + } + + override fun get(obj: ClipSpringAnimBuilder): Float { + return 0f + } + } + + override fun onAnimationStart(animation: Animator) { + target.apply { + oldOutlineProvider = outlineProvider + outlineProvider = null + translationZ = -target.elevation + } + } + + override fun onAnimationEnd(animation: Animator) { + target.apply { + translationZ = 0f + outlineProvider = oldOutlineProvider + } + } + + fun toAnim(isReversed: Boolean): ValueAnimator { + val mStartValue = if (isReversed) 1f else 0f + val mEndValue = if (isReversed) 0f else 1f + pathProvider.invoke(mStartValue, path) + target.setClipPath(path) + val animator = + animatorBuilder + .setStiffness(SPRING_STIFFNESS_SHAPE_POSITION) + .setDampingRatio(SPRING_DAMPING_SHAPE_POSITION) + .setStartValue(mStartValue) + .setEndValue(mEndValue) + .setMinimumVisibleChange(MIN_VISIBLE_CHANGE_SCALE) + .build(this, progressProperty) + animator.addListener(this) + return animator + } + } + + companion object { const val TAG = "IconShape" const val DEFAULT_PATH_SIZE = 100f const val AREA_CALC_SIZE = 1000 + private const val SPRING_STIFFNESS_SHAPE_POSITION = 380f + private const val SPRING_DAMPING_SHAPE_POSITION = 0.8f // .1% error margin const val AREA_DIFF_THRESHOLD = AREA_CALC_SIZE * AREA_CALC_SIZE / 1000