diff --git a/packages/SystemUI/res/layout/media_carousel.xml b/packages/SystemUI/res/layout/media_carousel.xml index ee1173be0db9e..8a47a22ff9857 100644 --- a/packages/SystemUI/res/layout/media_carousel.xml +++ b/packages/SystemUI/res/layout/media_carousel.xml @@ -48,5 +48,6 @@ android:layout_height="48dp" android:layout_marginBottom="4dp" android:tint="@color/media_primary_text" + android:forceHasOverlappingRendering="false" /> diff --git a/packages/SystemUI/res/layout/media_view.xml b/packages/SystemUI/res/layout/media_view.xml index 07bbb8f40eb8c..6792c647d6663 100644 --- a/packages/SystemUI/res/layout/media_view.xml +++ b/packages/SystemUI/res/layout/media_view.xml @@ -24,6 +24,7 @@ android:clipChildren="false" android:clipToPadding="false" android:gravity="center_horizontal|fill_vertical" + android:forceHasOverlappingRendering="false" android:background="@drawable/qs_media_background"> diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaCarouselController.kt b/packages/SystemUI/src/com/android/systemui/media/MediaCarouselController.kt index 68625059b2ef6..7c09accae6490 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaCarouselController.kt +++ b/packages/SystemUI/src/com/android/systemui/media/MediaCarouselController.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.content.res.Configuration import android.graphics.Color import android.provider.Settings.ACTION_MEDIA_CONTROLS_SETTINGS +import android.util.MathUtils import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -328,9 +329,32 @@ class MediaCarouselController @Inject constructor( updatePlayerToState(mediaPlayer, immediately) } maybeResetSettingsCog() + updatePageIndicatorAlpha() } } + private fun updatePageIndicatorAlpha() { + val hostStates = mediaHostStatesManager.mediaHostStates + val endIsVisible = hostStates[currentEndLocation]?.visible ?: false + val startIsVisible = hostStates[currentStartLocation]?.visible ?: false + val startAlpha = if (startIsVisible) 1.0f else 0.0f + val endAlpha = if (endIsVisible) 1.0f else 0.0f + var alpha = 1.0f + if (!endIsVisible || !startIsVisible) { + var progress = currentTransitionProgress + if (!endIsVisible) { + progress = 1.0f - progress + } + // Let's fade in quickly at the end where the view is visible + progress = MathUtils.constrain( + MathUtils.map(0.95f, 1.0f, 0.0f, 1.0f, progress), + 0.0f, + 1.0f) + alpha = MathUtils.lerp(startAlpha, endAlpha, progress) + } + pageIndicator.alpha = alpha + } + private fun updatePageIndicatorLocation() { // Update the location of the page indicator, carousel clipping val translationX = if (isRtl) { @@ -352,8 +376,10 @@ class MediaCarouselController @Inject constructor( var height = 0 for (mediaPlayer in mediaPlayers.values) { val controller = mediaPlayer.mediaViewController - width = Math.max(width, controller.currentWidth) - height = Math.max(height, controller.currentHeight) + // When transitioning the view to gone, the view gets smaller, but the translation + // Doesn't, let's add the translation + width = Math.max(width, controller.currentWidth + controller.translationX.toInt()) + height = Math.max(height, controller.currentHeight + controller.translationY.toInt()) } if (width != currentCarouselWidth || height != currentCarouselHeight) { currentCarouselWidth = width diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt index c41e6104833e8..3d2b72d8fd835 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt +++ b/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt @@ -329,7 +329,7 @@ class MediaHierarchyManager @Inject constructor( applyTargetStateIfNotAnimating() } else if (animate) { animator.cancel() - if (currentAttachmentLocation == IN_OVERLAY || + if (currentAttachmentLocation != previousLocation || !previousHost.hostView.isAttachedToWindow) { // Let's animate to the new position, starting from the current position // We also go in here in case the view was detached, since the bounds wouldn't @@ -341,10 +341,12 @@ class MediaHierarchyManager @Inject constructor( animationStartBounds.set(previousHost.currentBounds) } adjustAnimatorForTransition(desiredLocation, previousLocation) - rootView?.let { - // Let's delay the animation start until we finished laying out - animationPending = true - it.postOnAnimation(startAnimation) + if (!animationPending) { + rootView?.let { + // Let's delay the animation start until we finished laying out + animationPending = true + it.postOnAnimation(startAnimation) + } } } else { cancelAnimationAndApplyDesiredState() @@ -403,15 +405,23 @@ class MediaHierarchyManager @Inject constructor( } /** - * Updates the state that the view wants to be in at the end of the animation. + * Updates the bounds that the view wants to be in at the end of the animation. */ private fun updateTargetState() { if (isCurrentlyInGuidedTransformation()) { val progress = getTransformationProgress() - val currentHost = getHost(desiredLocation)!! - val previousHost = getHost(previousLocation)!! - val newBounds = currentHost.currentBounds - val previousBounds = previousHost.currentBounds + var endHost = getHost(desiredLocation)!! + var starthost = getHost(previousLocation)!! + // If either of the hosts are invisible, let's keep them at the other host location to + // have a nicer disappear animation. Otherwise the currentBounds of the state might + // be undefined + if (!endHost.visible) { + endHost = starthost + } else if (!starthost.visible) { + starthost = endHost + } + val newBounds = endHost.currentBounds + val previousBounds = starthost.currentBounds targetBounds = interpolateBounds(previousBounds, newBounds, progress) } else { val bounds = getHost(desiredLocation)?.currentBounds ?: return @@ -462,7 +472,9 @@ class MediaHierarchyManager @Inject constructor( val previousHost = getHost(previousLocation) if (currentHost?.location == LOCATION_QS) { if (previousHost?.location == LOCATION_QQS) { - return qsExpansion + if (previousHost.visible || statusbarState != StatusBarState.KEYGUARD) { + return qsExpansion + } } } return -1.0f diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaHost.kt b/packages/SystemUI/src/com/android/systemui/media/MediaHost.kt index 1ae9d3ff4ca53..07a7e618b301a 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaHost.kt +++ b/packages/SystemUI/src/com/android/systemui/media/MediaHost.kt @@ -5,6 +5,7 @@ import android.graphics.Rect import android.util.ArraySet import android.view.View import android.view.View.OnAttachStateChangeListener +import com.android.systemui.util.animation.DisappearParameters import com.android.systemui.util.animation.MeasurementInput import com.android.systemui.util.animation.MeasurementOutput import com.android.systemui.util.animation.UniqueObjectHostView @@ -128,8 +129,6 @@ class MediaHost @Inject constructor( } class MediaHostStateHolder @Inject constructor() : MediaHostState { - private var gonePivot: PointF = PointF() - override var measurementInput: MeasurementInput? = null set(value) { if (value?.equals(field) != true) { @@ -172,15 +171,18 @@ class MediaHost @Inject constructor( changedListener?.invoke() } - override fun getPivotX(): Float = gonePivot.x - override fun getPivotY(): Float = gonePivot.y - override fun setGonePivot(x: Float, y: Float) { - if (gonePivot.equals(x, y)) { - return + override var disappearParameters: DisappearParameters = DisappearParameters() + set(value) { + val newHash = value.hashCode() + if (lastDisappearHash.equals(newHash)) { + return + } + field = value + lastDisappearHash = newHash + changedListener?.invoke() } - gonePivot.set(x, y) - changedListener?.invoke() - } + + private var lastDisappearHash = disappearParameters.hashCode() /** * A listener for all changes. This won't be copied over when invoking [copy] @@ -196,7 +198,7 @@ class MediaHost @Inject constructor( mediaHostState.showsOnlyActiveMedia = showsOnlyActiveMedia mediaHostState.measurementInput = measurementInput?.copy() mediaHostState.visible = visible - mediaHostState.gonePivot.set(gonePivot) + mediaHostState.disappearParameters = disappearParameters.deepCopy() mediaHostState.falsingProtectionNeeded = falsingProtectionNeeded return mediaHostState } @@ -220,7 +222,7 @@ class MediaHost @Inject constructor( if (falsingProtectionNeeded != other.falsingProtectionNeeded) { return false } - if (!gonePivot.equals(other.getPivotX(), other.getPivotY())) { + if (!disappearParameters.equals(other.disappearParameters)) { return false } return true @@ -232,12 +234,23 @@ class MediaHost @Inject constructor( result = 31 * result + falsingProtectionNeeded.hashCode() result = 31 * result + showsOnlyActiveMedia.hashCode() result = 31 * result + if (visible) 1 else 2 - result = 31 * result + gonePivot.hashCode() + result = 31 * result + disappearParameters.hashCode() return result } } } +/** + * A description of a media host state that describes the behavior whenever the media carousel + * is hosted. The HostState notifies the media players of changes to their properties, who + * in turn will create view states from it. + * When adding a new property to this, make sure to update the listener and notify them + * about the changes. + * In case you need to have a different rendering based on the state, you can add a new + * constraintState to the [MediaViewController]. Otherwise, similar host states will resolve + * to the same viewstate, a behavior that is described in [CacheKey]. Make sure to only update + * that key if the underlying view needs to have a different measurement. + */ interface MediaHostState { /** @@ -268,23 +281,11 @@ interface MediaHostState { var falsingProtectionNeeded: Boolean /** - * Sets the pivot point when clipping the height or width. - * Clipping happens when animating visibility when we're visible in QS but not on QQS, - * for example. + * The parameters how the view disappears from this location when going to a host that's not + * visible. If modified, make sure to set this value again on the host to ensure the values + * are propagated */ - fun setGonePivot(x: Float, y: Float) - - /** - * x position of pivot, from 0 to 1 - * @see [setGonePivot] - */ - fun getPivotX(): Float - - /** - * y position of pivot, from 0 to 1 - * @see [setGonePivot] - */ - fun getPivotY(): Float + var disappearParameters: DisappearParameters /** * Get a copy of this view state, deepcopying all appropriate members diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaViewController.kt b/packages/SystemUI/src/com/android/systemui/media/MediaViewController.kt index fc22c026974ac..033a42a03240a 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaViewController.kt +++ b/packages/SystemUI/src/com/android/systemui/media/MediaViewController.kt @@ -18,7 +18,6 @@ package com.android.systemui.media import android.content.Context import android.content.res.Configuration -import android.graphics.PointF import androidx.constraintlayout.widget.ConstraintSet import com.android.systemui.R import com.android.systemui.statusbar.policy.ConfigurationController @@ -53,7 +52,7 @@ class MediaViewController @Inject constructor( /** * A map containing all viewStates for all locations of this mediaState */ - private val viewStates: MutableMap = mutableMapOf() + private val viewStates: MutableMap = mutableMapOf() /** * The ending location of the view where it ends when all animations and transitions have @@ -80,9 +79,9 @@ class MediaViewController @Inject constructor( private val tmpState = TransitionViewState() /** - * Temporary variable to avoid unnecessary allocations. + * A temporary cache key to be used to look up cache entries */ - private val tmpPoint = PointF() + private val tmpKey = CacheKey() /** * The current width of the player. This might not factor in case the player is animating @@ -95,6 +94,24 @@ class MediaViewController @Inject constructor( */ var currentHeight: Int = 0 + /** + * Get the translationX of the layout + */ + var translationX: Float = 0.0f + private set + get() { + return transitionLayout?.translationX ?: 0.0f + } + + /** + * Get the translationY of the layout + */ + var translationY: Float = 0.0f + private set + get() { + return transitionLayout?.translationY ?: 0.0f + } + /** * A callback for RTL config changes */ @@ -179,15 +196,21 @@ class MediaViewController @Inject constructor( * Obtain a new viewState for a given media state. This usually returns a cached state, but if * it's not available, it will recreate one by measuring, which may be expensive. */ - private fun obtainViewState(state: MediaHostState): TransitionViewState? { - val viewState = viewStates[state] + private fun obtainViewState(state: MediaHostState?): TransitionViewState? { + if (state == null || state.measurementInput == null) { + return null + } + // Only a subset of the state is relevant to get a valid viewState. Let's get the cachekey + var cacheKey = getKey(state, tmpKey) + val viewState = viewStates[cacheKey] if (viewState != null) { // we already have cached this measurement, let's continue return viewState } - + // Copy the key since this might call recursively into it and we're using tmpKey + cacheKey = cacheKey.copy() val result: TransitionViewState? - if (transitionLayout != null && state.measurementInput != null) { + if (transitionLayout != null) { // Let's create a new measurement if (state.expansion == 0.0f || state.expansion == 1.0f) { result = transitionLayout!!.calculateViewState( @@ -198,7 +221,7 @@ class MediaViewController @Inject constructor( // We don't want to cache interpolated or null states as this could quickly fill up // our cache. We only cache the start and the end states since the interpolation // is cheap - viewStates[state.copy()] = result + viewStates[cacheKey] = result } else { // This is an interpolated state val startState = state.copy().also { it.expansion = 0.0f } @@ -208,14 +231,10 @@ class MediaViewController @Inject constructor( val startViewState = obtainViewState(startState) as TransitionViewState val endState = state.copy().also { it.expansion = 1.0f } val endViewState = obtainViewState(endState) as TransitionViewState - tmpPoint.set(startState.getPivotX(), startState.getPivotY()) - result = TransitionViewState() - layoutController.getInterpolatedState( + result = layoutController.getInterpolatedState( startViewState, endViewState, - state.expansion, - tmpPoint, - result) + state.expansion) } } else { result = null @@ -223,6 +242,15 @@ class MediaViewController @Inject constructor( return result } + private fun getKey(state: MediaHostState, result: CacheKey): CacheKey { + result.apply { + heightMeasureSpec = state.measurementInput?.heightMeasureSpec ?: 0 + widthMeasureSpec = state.measurementInput?.widthMeasureSpec ?: 0 + expansion = state.expansion + } + return result + } + /** * Attach a view to this controller. This may perform measurements if it's not available yet * and should therefore be done carefully. @@ -270,65 +298,54 @@ class MediaViewController @Inject constructor( val shouldAnimate = animateNextStateChange && !applyImmediately - var startHostState = mediaHostStatesManager.mediaHostStates[startLocation] - var endHostState = mediaHostStatesManager.mediaHostStates[endLocation] - var swappedStartState = false - var swappedEndState = false - - // if we're going from or to a non visible state, let's grab the visible one and animate - // the view being clipped instead. - if (endHostState?.visible != true) { - endHostState = startHostState - swappedEndState = true - } - if (startHostState?.visible != true) { - startHostState = endHostState - swappedStartState = true - } - if (startHostState == null || endHostState == null) { - return - } - - var endViewState = obtainViewState(endHostState) ?: return - if (swappedEndState) { - endViewState = endViewState.copy() - endViewState.height = 0 - } + val endHostState = mediaHostStatesManager.mediaHostStates[endLocation] ?: return + val startHostState = mediaHostStatesManager.mediaHostStates[startLocation] // Obtain the view state that we'd want to be at the end // The view might not be bound yet or has never been measured and in that case will be // reset once the state is fully available + val endViewState = obtainViewState(endHostState) ?: return + layoutController.setMeasureState(endViewState) - // If the view isn't bound, we can drop the animation, otherwise we'll executute it + // If the view isn't bound, we can drop the animation, otherwise we'll execute it animateNextStateChange = false if (transitionLayout == null) { return } - var startViewState = obtainViewState(startHostState) - if (swappedStartState) { - startViewState = startViewState?.copy() - startViewState?.height = 0 - } + val result: TransitionViewState + val startViewState = obtainViewState(startHostState) - val result: TransitionViewState? - result = if (transitionProgress == 1.0f || startViewState == null) { - endViewState - } else if (transitionProgress == 0.0f) { - startViewState - } else { - if (swappedEndState || swappedStartState) { - tmpPoint.set(startHostState.getPivotX(), startHostState.getPivotY()) + if (!endHostState.visible) { + // Let's handle the case where the end is gone first. In this case we take the + // start viewState and will make it gone + if (startViewState == null || startHostState == null || !startHostState.visible) { + // the start isn't a valid state, let's use the endstate directly + result = endViewState } else { - tmpPoint.set(0.0f, 0.0f) + // Let's get the gone presentation from the start state + result = layoutController.getGoneState(startViewState, + startHostState.disappearParameters, + transitionProgress, + tmpState) } - layoutController.getInterpolatedState(startViewState, endViewState, transitionProgress, - tmpPoint, tmpState) - tmpState + } else if (startHostState != null && !startHostState.visible) { + // We have a start state and it is gone. + // Let's get presentation from the endState + result = layoutController.getGoneState(endViewState, endHostState.disappearParameters, + 1.0f - transitionProgress, + tmpState) + } else if (transitionProgress == 1.0f || startViewState == null) { + // We're at the end. Let's use that state + result = endViewState + } else if (transitionProgress == 0.0f) { + // We're at the start. Let's use that state + result = startViewState + } else { + result = layoutController.getInterpolatedState(startViewState, endViewState, + transitionProgress, tmpState) } - currentWidth = result.width - currentHeight = result.height layoutController.setState(result, applyImmediately, shouldAnimate, animationDuration, animationDelay) } @@ -379,3 +396,12 @@ class MediaViewController @Inject constructor( firstRefresh = false } } + +/** + * An internal key for the cache of mediaViewStates. This is a subset of the full host state. + */ +private data class CacheKey( + var widthMeasureSpec: Int = -1, + var heightMeasureSpec: Int = -1, + var expansion: Float = 0.0f +) diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java b/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java index c8a34f010ae42..ae925d1d6ee6b 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java @@ -25,6 +25,7 @@ import android.content.ComponentName; import android.content.Context; import android.content.res.Configuration; import android.content.res.Resources; +import android.graphics.PointF; import android.metrics.LogMaker; import android.os.Bundle; import android.os.Handler; @@ -60,6 +61,7 @@ import com.android.systemui.statusbar.policy.BrightnessMirrorController; import com.android.systemui.statusbar.policy.BrightnessMirrorController.BrightnessMirrorListener; import com.android.systemui.tuner.TunerService; import com.android.systemui.tuner.TunerService.Tunable; +import com.android.systemui.util.animation.DisappearParameters; import java.io.FileDescriptor; import java.io.PrintWriter; @@ -246,11 +248,40 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne protected void initMediaHostState() { mMediaHost.setExpansion(1.0f); mMediaHost.setShowsOnlyActiveMedia(false); - // Reveal player with some parallax (1.0f would also work) - mMediaHost.setGonePivot(0.0f, 0.8f); + updateMediaDisappearParameters(); mMediaHost.init(MediaHierarchyManager.LOCATION_QS); } + /** + * Update the way the media disappears based on if we're using the horizontal layout + */ + private void updateMediaDisappearParameters() { + if (!mUsingMediaPlayer) { + return; + } + DisappearParameters parameters = mMediaHost.getDisappearParameters(); + if (mUsingHorizontalLayout) { + // Only height remaining + parameters.getDisappearSize().set(0.0f, 0.4f); + // Disappearing on the right side on the bottom + parameters.getGonePivot().set(1.0f, 1.0f); + // translating a bit horizontal + parameters.getContentTranslationFraction().set(0.25f, 1.0f); + parameters.setDisappearEnd(0.6f); + } else { + // Only width remaining + parameters.getDisappearSize().set(1.0f, 0.0f); + // Disappearing on the bottom + parameters.getGonePivot().set(0.0f, 1.0f); + // translating a bit vertical + parameters.getContentTranslationFraction().set(0.0f, 1.05f); + parameters.setDisappearEnd(0.95f); + } + parameters.setFadeStartPosition(0.95f); + parameters.setDisappearStart(0.0f); + mMediaHost.setDisappearParameters(parameters); + } + @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { if (mTileLayout instanceof PagedTileLayout) { @@ -542,6 +573,7 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne updateTileLayoutMargins(); updateFooterMargin(); updateDividerMargin(); + updateMediaDisappearParameters(); updateMediaHostContentMargins(); updateHorizontalLinearLayoutMargins(); updatePadding(); diff --git a/packages/SystemUI/src/com/android/systemui/util/animation/TransitionLayout.kt b/packages/SystemUI/src/com/android/systemui/util/animation/TransitionLayout.kt index 3c0a23aa2eca5..19c6b806bb599 100644 --- a/packages/SystemUI/src/com/android/systemui/util/animation/TransitionLayout.kt +++ b/packages/SystemUI/src/com/android/systemui/util/animation/TransitionLayout.kt @@ -80,6 +80,8 @@ class TransitionLayout @JvmOverloads constructor( */ private fun applyCurrentState() { val childCount = childCount + val contentTranslationX = currentState.contentTranslation.x.toInt() + val contentTranslationY = currentState.contentTranslation.y.toInt() for (i in 0 until childCount) { val child = getChildAt(i) val widgetState = currentState.widgetStates.get(child.id) ?: continue @@ -92,8 +94,8 @@ class TransitionLayout @JvmOverloads constructor( child.measure(measureWidthSpec, measureHeightSpec) child.layout(0, 0, child.measuredWidth, child.measuredHeight) } - val left = widgetState.x.toInt() - val top = widgetState.y.toInt() + val left = widgetState.x.toInt() + contentTranslationX + val top = widgetState.y.toInt() + contentTranslationY child.setLeftTopRightBottom(left, top, left + widgetState.width, top + widgetState.height) child.scaleX = widgetState.scale @@ -109,6 +111,9 @@ class TransitionLayout @JvmOverloads constructor( } } updateBounds() + translationX = currentState.translation.x + translationY = currentState.translation.y + CrossFadeHelper.fadeIn(this, currentState.alpha) } private fun applyCurrentStateOnPredraw() { @@ -161,9 +166,7 @@ class TransitionLayout @JvmOverloads constructor( val layoutTop = top setLeftTopRightBottom(layoutLeft, layoutTop, layoutLeft + currentState.width, layoutTop + currentState.height) - translationX = currentState.translation.x - translationY = currentState.translation.y - boundsRect.set(0, 0, (width + translationX).toInt(), (height + translationY).toInt()) + boundsRect.set(0, 0, width.toInt(), height.toInt()) } /** @@ -247,13 +250,17 @@ class TransitionViewState { var widgetStates: MutableMap = mutableMapOf() var width: Int = 0 var height: Int = 0 + var alpha: Float = 1.0f val translation = PointF() + val contentTranslation = PointF() fun copy(reusedState: TransitionViewState? = null): TransitionViewState { // we need a deep copy of this, so we can't use a data class val copy = reusedState ?: TransitionViewState() copy.width = width copy.height = height + copy.alpha = alpha copy.translation.set(translation.x, translation.y) + copy.contentTranslation.set(contentTranslation.x, contentTranslation.y) for (entry in widgetStates) { copy.widgetStates[entry.key] = entry.value.copy() } @@ -272,6 +279,8 @@ class TransitionViewState { width = transitionLayout.measuredWidth height = transitionLayout.measuredHeight translation.set(0.0f, 0.0f) + contentTranslation.set(0.0f, 0.0f) + alpha = 1.0f } } diff --git a/packages/SystemUI/src/com/android/systemui/util/animation/TransitionLayoutController.kt b/packages/SystemUI/src/com/android/systemui/util/animation/TransitionLayoutController.kt index 5143e429768e1..9638ac1096f77 100644 --- a/packages/SystemUI/src/com/android/systemui/util/animation/TransitionLayoutController.kt +++ b/packages/SystemUI/src/com/android/systemui/util/animation/TransitionLayoutController.kt @@ -19,6 +19,7 @@ package com.android.systemui.util.animation import android.animation.ValueAnimator import android.graphics.PointF import android.util.MathUtils +import com.android.internal.R.attr.width import com.android.systemui.Interpolators /** @@ -44,7 +45,6 @@ open class TransitionLayoutController { private var currentState = TransitionViewState() private var animationStartState: TransitionViewState? = null private var state = TransitionViewState() - private var pivot = PointF() private var animator: ValueAnimator = ValueAnimator.ofFloat(0.0f, 1.0f) private var currentHeight: Int = 0 private var currentWidth: Int = 0 @@ -63,13 +63,11 @@ open class TransitionLayoutController { if (animationStartState == null || !animator.isRunning) { return } - val view = transitionLayout ?: return - getInterpolatedState( + currentState = getInterpolatedState( startState = animationStartState!!, endState = state, progress = animator.animatedFraction, - pivot = pivot, - resultState = currentState) + reusedState = currentState) applyStateToLayout(currentState) } @@ -82,6 +80,49 @@ open class TransitionLayoutController { } } + /** + * Obtain a state that is gone, based on parameters given. + * + * @param viewState the viewState to make gone + * @param disappearParameters parameters that determine how the view should disappear + * @param goneProgress how much is the view gone? 0 for not gone at all and 1 for fully + * disappeared + * @param reusedState optional parameter for state to be reused to avoid allocations + */ + fun getGoneState( + viewState: TransitionViewState, + disappearParameters: DisappearParameters, + goneProgress: Float, + reusedState: TransitionViewState? = null + ): TransitionViewState { + var remappedProgress = MathUtils.map( + disappearParameters.disappearStart, + disappearParameters.disappearEnd, + 0.0f, 1.0f, + goneProgress) + remappedProgress = MathUtils.constrain(remappedProgress, 0.0f, 1.0f) + val result = viewState.copy(reusedState).apply { + width = MathUtils.lerp( + viewState.width.toFloat(), + viewState.width * disappearParameters.disappearSize.x, + remappedProgress).toInt() + height = MathUtils.lerp( + viewState.height.toFloat(), + viewState.height * disappearParameters.disappearSize.y, + remappedProgress).toInt() + translation.x = (viewState.width - width) * disappearParameters.gonePivot.x + translation.y = (viewState.height - height) * disappearParameters.gonePivot.y + contentTranslation.x = (disappearParameters.contentTranslationFraction.x - 1.0f) * + translation.x + contentTranslation.y = (disappearParameters.contentTranslationFraction.y - 1.0f) * + translation.y + val alphaProgress = MathUtils.map( + disappearParameters.fadeStartPosition, 1.0f, 1.0f, 0.0f, remappedProgress) + alpha = MathUtils.constrain(alphaProgress, 0.0f, 1.0f) + } + return result + } + /** * Get an interpolated state between two viewstates. This interpolates all positions for all * widgets as well as it's bounds based on the given input. @@ -90,11 +131,10 @@ open class TransitionLayoutController { startState: TransitionViewState, endState: TransitionViewState, progress: Float, - pivot: PointF, - resultState: TransitionViewState - ) { - this.pivot.set(pivot) - val view = transitionLayout ?: return + reusedState: TransitionViewState? = null + ): TransitionViewState { + val resultState = reusedState ?: TransitionViewState() + val view = transitionLayout ?: return resultState val childCount = view.childCount for (i in 0 until childCount) { val id = view.getChildAt(i).id @@ -195,9 +235,21 @@ open class TransitionLayoutController { progress).toInt() height = MathUtils.lerp(startState.height.toFloat(), endState.height.toFloat(), progress).toInt() - translation.x = (endState.width - width) * pivot.x - translation.y = (endState.height - height) * pivot.y + translation.x = MathUtils.lerp(startState.translation.x, endState.translation.x, + progress) + translation.y = MathUtils.lerp(startState.translation.y, endState.translation.y, + progress) + alpha = MathUtils.lerp(startState.alpha, endState.alpha, progress) + contentTranslation.x = MathUtils.lerp( + startState.contentTranslation.x, + endState.contentTranslation.x, + progress) + contentTranslation.y = MathUtils.lerp( + startState.contentTranslation.y, + endState.contentTranslation.y, + progress) } + return resultState } fun attach(transitionLayout: TransitionLayout) { @@ -250,3 +302,97 @@ open class TransitionLayoutController { transitionLayout?.measureState = state } } + +class DisappearParameters() { + + /** + * The pivot point when clipping view when disappearing, which describes how the content will + * be translated. + * The default value of (0.0f, 1.0f) means that the view will not be translated in horizontally + * and the vertical disappearing will be aligned on the bottom of the view, + */ + var gonePivot = PointF(0.0f, 1.0f) + + /** + * The fraction of the width and height that will remain when disappearing. The default of + * (1.0f, 0.0f) means that 100% of the width, but 0% of the height will remain at the end of + * the transition. + */ + var disappearSize = PointF(1.0f, 0.0f) + + /** + * The fraction of the normal translation, by which the content will be moved during the + * disappearing. The values here can be both negative as well as positive. The default value + * of (0.0f, 0.2f) means that the content doesn't move horizontally but moves 20% of the + * translation imposed by the pivot downwards. 1.0f means that the content will be translated + * in sync with the translation of the bounds + */ + var contentTranslationFraction = PointF(0.0f, 0.8f) + + /** + * The point during the progress from [0.0, 1.0f] where the view is fully appeared. 0.0f + * means that the content will start disappearing immediately, while 0.5f means that it + * starts disappearing half way through the progress. + */ + var disappearStart = 0.0f + + /** + * The point during the progress from [0.0, 1.0f] where the view has fully disappeared. 1.0f + * means that the view will disappear in sync with the progress, while 0.5f means that it + * is fully gone half way through the progress. + */ + var disappearEnd = 1.0f + + /** + * The point during the mapped progress from [0.0, 1.0f] where the view starts fading out. 1.0f + * means that the view doesn't fade at all, while 0.5 means that the content fades starts + * fading at the midpoint between [disappearStart] and [disappearEnd] + */ + var fadeStartPosition = 0.9f + + override fun equals(other: Any?): Boolean { + if (!(other is DisappearParameters)) { + return false + } + if (!disappearSize.equals(other.disappearSize)) { + return false + } + if (!gonePivot.equals(other.gonePivot)) { + return false + } + if (!contentTranslationFraction.equals(other.contentTranslationFraction)) { + return false + } + if (disappearStart != other.disappearStart) { + return false + } + if (disappearEnd != other.disappearEnd) { + return false + } + if (fadeStartPosition != other.fadeStartPosition) { + return false + } + return true + } + + override fun hashCode(): Int { + var result = disappearSize.hashCode() + result = 31 * result + gonePivot.hashCode() + result = 31 * result + contentTranslationFraction.hashCode() + result = 31 * result + disappearStart.hashCode() + result = 31 * result + disappearEnd.hashCode() + result = 31 * result + fadeStartPosition.hashCode() + return result + } + + fun deepCopy(): DisappearParameters { + val result = DisappearParameters() + result.disappearSize.set(disappearSize) + result.gonePivot.set(gonePivot) + result.contentTranslationFraction.set(contentTranslationFraction) + result.disappearStart = disappearStart + result.disappearEnd = disappearEnd + result.fadeStartPosition = fadeStartPosition + return result + } +} diff --git a/packages/SystemUI/src/com/android/systemui/util/animation/UniqueObjectHostView.kt b/packages/SystemUI/src/com/android/systemui/util/animation/UniqueObjectHostView.kt index d6e7a8b28f0cd..47a2d35e57f51 100644 --- a/packages/SystemUI/src/com/android/systemui/util/animation/UniqueObjectHostView.kt +++ b/packages/SystemUI/src/com/android/systemui/util/animation/UniqueObjectHostView.kt @@ -53,20 +53,20 @@ class UniqueObjectHostView( // size. val (cachedWidth, cachedHeight) = measurementManager.onMeasure(measurementInput) - if (!isCurrentHost()) { - // We're not currently the host, let's use the dimension from our cache - // The goal here is that the view will always have a consistent measuring, regardless - // if it's attached or not. - // The behavior is therefore very similar to the view being persistently attached to - // this host, which can prevent flickers. It also makes sure that we always know - // the size of the view during transitions even if it has never been attached here - // before. - setMeasuredDimension(cachedWidth + paddingHorizontal, cachedHeight + paddingVertical) - } else { + if (isCurrentHost()) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) - // Let's update our cache getChildAt(0)?.requiresRemeasuring = false } + // The goal here is that the view will always have a consistent measuring, regardless + // if it's attached or not. + // The behavior is therefore very similar to the view being persistently attached to + // this host, which can prevent flickers. It also makes sure that we always know + // the size of the view during transitions even if it has never been attached here + // before. + // We previously still measured the size when the view was attached, but this doesn't + // work properly because we can set the measuredState while still attached to the + // old host, which will trigger an inconsistency in height + setMeasuredDimension(cachedWidth + paddingHorizontal, cachedHeight + paddingVertical) } override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) { diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelTest.java b/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelTest.java index cbb0711f78f85..cb3a04862eb71 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelTest.java @@ -51,6 +51,7 @@ import com.android.systemui.qs.logging.QSLogger; import com.android.systemui.qs.tileimpl.QSTileImpl; import com.android.systemui.statusbar.notification.NotificationEntryManager; import com.android.systemui.statusbar.policy.SecurityController; +import com.android.systemui.util.animation.DisappearParameters; import com.android.systemui.util.animation.UniqueObjectHostView; import org.junit.Before; @@ -110,6 +111,7 @@ public class QSPanelTest extends SysuiTestCase { mDependency.injectTestDependency(Dependency.BG_LOOPER, mTestableLooper.getLooper()); mContext.addMockSystemService(Context.USER_SERVICE, mock(UserManager.class)); when(mMediaHost.getHostView()).thenReturn(new UniqueObjectHostView(getContext())); + when(mMediaHost.getDisappearParameters()).thenReturn(new DisappearParameters()); mUiEventLogger = new UiEventLoggerFake(); mTestableLooper.runWithLooper(() -> {