diff --git a/packages/SystemUI/res/layout/media_carousel.xml b/packages/SystemUI/res/layout/media_carousel.xml index f888ca11b5fba..03e74676f03e4 100644 --- a/packages/SystemUI/res/layout/media_carousel.xml +++ b/packages/SystemUI/res/layout/media_carousel.xml @@ -16,7 +16,7 @@ --> - - + diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java index 72282714365b7..60c2ed2fa2be8 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java +++ b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java @@ -47,6 +47,7 @@ import android.widget.SeekBar; import android.widget.TextView; import androidx.annotation.Nullable; +import androidx.annotation.UiThread; import androidx.constraintlayout.motion.widget.Key; import androidx.constraintlayout.motion.widget.KeyAttributes; import androidx.constraintlayout.motion.widget.KeyFrames; @@ -111,7 +112,6 @@ public class MediaControlPanel { private int mAlbumArtSize; private int mAlbumArtRadius; private int mViewWidth; - private MediaMeasurementInput mLastMeasureInput; public static final String MEDIA_PREFERENCES = "media_control_prefs"; public static final String MEDIA_PREFERENCE_KEY = "browser_components"; @@ -188,7 +188,6 @@ public class MediaControlPanel { mActivityStarter = activityStarter; mSeekBarViewModel = new SeekBarViewModel(backgroundExecutor); mSeekBarObserver = new SeekBarObserver(getView()); - // TODO: we should pause this whenever the screen is off / panel is collapsed etc. mSeekBarViewModel.getProgress().observeForever(mSeekBarObserver); SeekBar bar = getView().findViewById(R.id.media_progress_bar); bar.setOnSeekBarChangeListener(mSeekBarViewModel.getSeekBarListener()); @@ -261,7 +260,7 @@ public class MediaControlPanel { // Try to find a browser service component for this app // TODO also check for a media button receiver intended for restarting (b/154127084) // Only check if we haven't tried yet or the session token changed - final String pkgName = mController.getPackageName(); + final String pkgName = data.getPackageName(); if (mServiceComponent == null && !mCheckedForResumption) { Log.d(TAG, "Checking for service component"); PackageManager pm = mContext.getPackageManager(); @@ -301,8 +300,7 @@ public class MediaControlPanel { // App icon ImageView appIcon = mMediaNotifView.requireViewById(R.id.icon); - // TODO: look at iconDrawable - Drawable iconDrawable = data.getAppIcon(); + Drawable iconDrawable = data.getAppIcon().mutate(); iconDrawable.setTint(mForegroundColor); appIcon.setImageDrawable(iconDrawable); @@ -389,7 +387,7 @@ public class MediaControlPanel { } // Seek Bar - final MediaController controller = new MediaController(getContext(), data.getToken()); + final MediaController controller = getController(); mBackgroundExecutor.execute( () -> mSeekBarViewModel.updateController(controller, data.getForegroundColor())); @@ -397,10 +395,13 @@ public class MediaControlPanel { // TODO: b/156036025 bring back media guts makeActive(); + + // Update both constraint sets to regenerate the animation. mMediaNotifView.updateState(R.id.collapsed, collapsedSet); mMediaNotifView.updateState(R.id.expanded, expandedSet); } + @UiThread private Drawable createRoundedBitmap(Icon icon) { if (icon == null) { return null; @@ -746,27 +747,14 @@ public class MediaControlPanel { */ protected void removePlayer() { } - public void remeasure(@Nullable MediaMeasurementInput input, boolean animate, long duration, - long startDelay) { - // Let's remeasure if our width changed. Our height is dependent on the expansion, so we - // won't animate if it changed - if (input != null && !input.sameAs(mLastMeasureInput)) { - mLastMeasureInput = input; - if (animate) { - mLayoutAnimationHelper.animatePendingSizeChange(duration, startDelay); - } - remeasureInternal(input); - mMediaNotifView.layout(0, 0, mMediaNotifView.getMeasuredWidth(), - mMediaNotifView.getMeasuredHeight()); + public void measure(@Nullable MediaMeasurementInput input) { + if (input != null) { + int width = input.getWidth(); + setPlayerWidth(width); + mMediaNotifView.measure(input.getWidthMeasureSpec(), input.getHeightMeasureSpec()); } } - private void remeasureInternal(MediaMeasurementInput input) { - int width = input.getWidth(); - setPlayerWidth(width); - mMediaNotifView.measure(input.getWidthMeasureSpec(), input.getHeightMeasureSpec()); - } - public void setPlayerWidth(int width) { ConstraintSet expandedSet = mMediaNotifView.getConstraintSet(R.id.expanded); ConstraintSet collapsedSet = mMediaNotifView.getConstraintSet(R.id.collapsed); @@ -775,4 +763,8 @@ public class MediaControlPanel { mMediaNotifView.updateState(R.id.collapsed, collapsedSet); mMediaNotifView.updateState(R.id.expanded, expandedSet); } + + public void animatePendingSizeChange(long duration, long startDelay) { + mLayoutAnimationHelper.animatePendingSizeChange(duration, startDelay); + } } diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt index b4c0c334773e8..6b1c520db7b10 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt +++ b/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt @@ -48,6 +48,11 @@ class MediaHierarchyManager @Inject constructor( private val mediaViewManager: MediaViewManager, private val mediaMeasurementProvider: MediaMeasurementManager ) { + /** + * The root overlay of the hierarchy. This is where the media notification is attached to + * whenever the view is transitioning from one host to another. It also make sure that the + * view is always in its final state when it is attached to a view host. + */ private var rootOverlay: ViewGroupOverlay? = null private lateinit var currentState: MediaState private val mediaCarousel @@ -79,9 +84,24 @@ class MediaHierarchyManager @Inject constructor( } private var targetState: MediaState? = null private val mediaHosts = arrayOfNulls(LOCATION_LOCKSCREEN + 1) - private var previousLocation = -1 - private var desiredLocation = -1 - private var currentAttachmentLocation = -1 + + /** + * The last location where this view was at before going to the desired location. This is + * useful for guided transitions. + */ + @MediaLocation private var previousLocation = -1 + + /** + * The desired location where the view will be at the end of the transition. + */ + @MediaLocation private var desiredLocation = -1 + + /** + * The current attachment location where the view is currently attached. + * Usually this matches the desired location except for animations whenever a view moves + * to the new desired location, during which it is in [IN_OVERLAY]. + */ + @MediaLocation private var currentAttachmentLocation = -1 var qsExpansion: Float = 0.0f set(value) { @@ -135,16 +155,12 @@ class MediaHierarchyManager @Inject constructor( private fun createUniqueObjectHost(host: MediaHost): UniqueObjectHostView { val viewHost = UniqueObjectHostView(context) viewHost.measurementCache = mediaMeasurementProvider.obtainCache(host) - viewHost.firstMeasureListener = { input -> - if (host.location == currentAttachmentLocation) { - // The first measurement of the attached view is happening, Let's make - // sure the player width is updated + viewHost.onMeasureListener = { input -> + if (host.location == desiredLocation) { + // Measurement of the currently active player is happening, Let's make + // sure the player width is up to date val measuringInput = host.getMeasuringInput(input) - mediaViewManager.remeasureAllPlayers( - measuringInput, - animate = false, - duration = 0, - startDelay = 0) + mediaViewManager.setPlayerWidth(measuringInput.width) } } @@ -162,6 +178,10 @@ class MediaHierarchyManager @Inject constructor( return viewHost } + /** + * Updates the location that the view should be in. If it changes, an animation may be triggered + * going from the old desired location to the new one. + */ private fun updateDesiredLocation() { val desiredLocation = calculateLocation() if (desiredLocation != this.desiredLocation) { @@ -173,7 +193,7 @@ class MediaHierarchyManager @Inject constructor( // Let's perform a transition val animate = shouldAnimateTransition(desiredLocation, previousLocation) val (animDuration, delay) = getAnimationParams(previousLocation, desiredLocation) - mediaViewManager.onDesiredStateChanged(getHost(desiredLocation)?.currentState, + mediaViewManager.onDesiredLocationChanged(getHost(desiredLocation)?.currentState, animate, animDuration, delay) performTransitionToNewLocation(isNewView, animate) } @@ -262,6 +282,9 @@ class MediaHierarchyManager @Inject constructor( } } + /** + * Updates the state that the view wants to be in at the end of the animation. + */ private fun updateTargetState() { if (isCurrentlyInGuidedTransformation()) { val progress = getTransformationProgress() @@ -320,17 +343,17 @@ class MediaHierarchyManager @Inject constructor( } private fun applyState(state: MediaState) { + currentState = state.copy() + mediaViewManager.setCurrentState(currentState) updateHostAttachment() - val boundsOnScreen = state.boundsOnScreen if (currentAttachmentLocation == IN_OVERLAY) { + val boundsOnScreen = state.boundsOnScreen mediaCarousel.setLeftTopRightBottom( boundsOnScreen.left, boundsOnScreen.top, boundsOnScreen.right, boundsOnScreen.bottom) } - currentState = state.copy() - mediaViewManager.setCurrentState(currentState) } private fun updateHostAttachment() { @@ -348,6 +371,7 @@ class MediaHierarchyManager @Inject constructor( rootOverlay!!.add(mediaCarousel) } else { targetHost.addView(mediaCarousel) + mediaViewManager.onViewReattached() } } } diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaHost.kt b/packages/SystemUI/src/com/android/systemui/media/MediaHost.kt index e1500ac09dc96..6e7b6bcb75029 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaHost.kt +++ b/packages/SystemUI/src/com/android/systemui/media/MediaHost.kt @@ -7,7 +7,6 @@ import android.view.View.OnAttachStateChangeListener import android.view.ViewGroup import com.android.systemui.media.MediaHierarchyManager.MediaLocation import com.android.systemui.util.animation.MeasurementInput -import com.android.systemui.util.animation.MeasurementInputData import javax.inject.Inject class MediaHost @Inject constructor( diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt index a364e6b73e60a..49d2d8860a2fa 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt +++ b/packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt @@ -2,7 +2,9 @@ package com.android.systemui.media import android.content.Context import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup +import android.widget.HorizontalScrollView import android.widget.LinearLayout import com.android.settingslib.bluetooth.LocalBluetoothManager import com.android.settingslib.media.InfoMediaManager @@ -33,12 +35,16 @@ class MediaViewManager @Inject constructor( private val activityStarter: ActivityStarter, mediaManager: MediaDataManager ) { + private var playerWidth: Int = 0 + private var playerWidthPlusPadding: Int = 0 private var desiredState: MediaHost.MediaHostState? = null private var currentState: MediaState? = null - val mediaCarousel: ViewGroup + val mediaCarousel: HorizontalScrollView private val mediaContent: ViewGroup private val mediaPlayers: MutableMap = mutableMapOf() private val visualStabilityCallback = ::reorderAllPlayers + private var activeMediaIndex: Int = 0 + private var scrollIntoCurrentMedia: Int = 0 private var currentlyExpanded = true set(value) { @@ -49,29 +55,50 @@ class MediaViewManager @Inject constructor( } } } + private val scrollChangedListener = object : View.OnScrollChangeListener { + override fun onScrollChange(v: View?, scrollX: Int, scrollY: Int, oldScrollX: Int, + oldScrollY: Int) { + if (playerWidthPlusPadding == 0) { + return + } + onMediaScrollingChanged(scrollX / playerWidthPlusPadding, + scrollX % playerWidthPlusPadding) + } + } init { mediaCarousel = inflateMediaCarousel() + mediaCarousel.setOnScrollChangeListener(scrollChangedListener) mediaContent = mediaCarousel.requireViewById(R.id.media_carousel) mediaManager.addListener(object : MediaDataManager.Listener { override fun onMediaDataLoaded(key: String, data: MediaData) { updateView(key, data) + updatePlayerVisibilities() } override fun onMediaDataRemoved(key: String) { val removed = mediaPlayers.remove(key) removed?.apply { + val beforeActive = mediaContent.indexOfChild(removed.view) <= activeMediaIndex mediaContent.removeView(removed.view) removed.onDestroy() updateMediaPaddings() + if (beforeActive) { + // also update the index here since the scroll below might not always lead + // to a scrolling changed + activeMediaIndex = Math.max(0, activeMediaIndex - 1) + mediaCarousel.scrollX = Math.max(mediaCarousel.scrollX + - playerWidthPlusPadding, 0) + } + updatePlayerVisibilities() } } }) } - private fun inflateMediaCarousel(): ViewGroup { - return LayoutInflater.from(context).inflate( - R.layout.media_carousel, UniqueObjectHostView(context), false) as ViewGroup + private fun inflateMediaCarousel(): HorizontalScrollView { + return LayoutInflater.from(context).inflate(R.layout.media_carousel, + UniqueObjectHostView(context), false) as HorizontalScrollView } private fun reorderAllPlayers() { @@ -83,6 +110,26 @@ class MediaViewManager @Inject constructor( } } updateMediaPaddings() + updatePlayerVisibilities() + } + + private fun onMediaScrollingChanged(newIndex: Int, scrollInAmount: Int) { + val wasScrolledIn = scrollIntoCurrentMedia != 0 + scrollIntoCurrentMedia = scrollInAmount + val nowScrolledIn = scrollIntoCurrentMedia != 0 + if (newIndex != activeMediaIndex || wasScrolledIn != nowScrolledIn) { + activeMediaIndex = newIndex + updatePlayerVisibilities() + } + } + + private fun updatePlayerVisibilities() { + val scrolledIn = scrollIntoCurrentMedia != 0 + for (i in 0 until mediaContent.childCount) { + val view = mediaContent.getChildAt(i) + val visible = (i == activeMediaIndex) || ((i == (activeMediaIndex + 1)) && scrolledIn) + view.visibility = if (visible) View.VISIBLE else View.INVISIBLE + } } private fun updateView(key: String, data: MediaData) { @@ -127,8 +174,7 @@ class MediaViewManager @Inject constructor( private fun updatePlayerToCurrentState(existingPlayer: MediaControlPanel) { if (desiredState != null && desiredState!!.measurementInput != null) { // make sure the player width is set to the current state - val measurementInput = desiredState!!.measurementInput!! - existingPlayer.setPlayerWidth(measurementInput.width) + existingPlayer.setPlayerWidth(playerWidth) } } @@ -147,6 +193,10 @@ class MediaViewManager @Inject constructor( } + /** + * Set the current state of a view. This is updated often during animations and we shouldn't + * do anything expensive. + */ fun setCurrentState(state: MediaState) { currentState = state currentlyExpanded = state.expansion > 0 @@ -157,23 +207,58 @@ class MediaViewManager @Inject constructor( } /** - * @param targetState the target state we're transitioning to + * The desired location of this view has changed. We should remeasure the view to match + * the new bounds and kick off bounds animations if necessary. + * If an animation is happening, an animation is kicked of externally, which sets a new + * current state until we reach the targetState. + * + * @param desiredState the target state we're transitioning to * @param animate should this be animated */ - fun onDesiredStateChanged(targetState: MediaState?, animate: Boolean, duration: Long, - startDelay: Long) { - if (targetState is MediaHost.MediaHostState) { + fun onDesiredLocationChanged(desiredState: MediaState?, animate: Boolean, duration: Long, + startDelay: Long) { + if (desiredState is MediaHost.MediaHostState) { // This is a hosting view, let's remeasure our players - desiredState = targetState - val measurementInput = targetState.measurementInput - remeasureAllPlayers(measurementInput, animate, duration, startDelay) + this.desiredState = desiredState + val width = desiredState.boundsOnScreen.width() + if (playerWidth != width) { + setPlayerWidth(width) + for (mediaPlayer in mediaPlayers.values) { + if (animate && mediaPlayer.view.visibility == View.VISIBLE) { + mediaPlayer.animatePendingSizeChange(duration, startDelay) + } + } + val widthSpec = desiredState.measurementInput?.widthMeasureSpec ?: 0 + val heightSpec = desiredState.measurementInput?.heightMeasureSpec ?: 0 + var left = 0 + for (i in 0 until mediaContent.childCount) { + val view = mediaContent.getChildAt(i) + view.measure(widthSpec, heightSpec) + view.layout(left, 0, left + width, view.measuredHeight) + left = left + playerWidthPlusPadding + } + } } } - fun remeasureAllPlayers(measurementInput: MediaMeasurementInput?, - animate: Boolean, duration: Long, startDelay: Long) { - for (mediaPlayer in mediaPlayers.values) { - mediaPlayer.remeasure(measurementInput, animate, duration, startDelay) + fun setPlayerWidth(width: Int) { + if (width != playerWidth) { + playerWidth = width + playerWidthPlusPadding = playerWidth + context.resources.getDimensionPixelSize( + R.dimen.qs_media_padding) + for (mediaPlayer in mediaPlayers.values) { + mediaPlayer.setPlayerWidth(width) + } + // The player width has changed, let's update the scroll position to make sure + // it's still at the same place + var newScroll = activeMediaIndex * playerWidthPlusPadding + if (scrollIntoCurrentMedia > playerWidthPlusPadding) { + newScroll += playerWidthPlusPadding + - (scrollIntoCurrentMedia - playerWidthPlusPadding) + } else { + newScroll += scrollIntoCurrentMedia + } + mediaCarousel.scrollX = newScroll } } @@ -185,15 +270,33 @@ class MediaViewManager @Inject constructor( val firstPlayer = mediaPlayers.values.firstOrNull() ?: return null // Let's measure the size of the first player and return its height val previousProgress = firstPlayer.view.progress + val previousRight = firstPlayer.view.right + val previousBottom = firstPlayer.view.bottom firstPlayer.view.progress = input.expansion - firstPlayer.remeasure(input, false /* animate */, 0, 0) + firstPlayer.measure(input) + // Relayouting is necessary in motionlayout to obtain its size properly .... + firstPlayer.view.layout(0, 0, firstPlayer.view.measuredWidth, + firstPlayer.view.measuredHeight) val result = MeasurementOutput(firstPlayer.view.measuredWidth, firstPlayer.view.measuredHeight) firstPlayer.view.progress = previousProgress if (desiredState != null) { // remeasure it to the old size again! - firstPlayer.remeasure(desiredState!!.measurementInput, false, 0, 0) + firstPlayer.measure(desiredState!!.measurementInput) + firstPlayer.view.layout(0, 0, previousRight, previousBottom) } return result } + + fun onViewReattached() { + if (desiredState is MediaHost.MediaHostState) { + // HACK: MotionLayout doesn't always properly reevalate the state, let's kick of + // a measure to force it. + val widthSpec = desiredState!!.measurementInput?.widthMeasureSpec ?: 0 + val heightSpec = desiredState!!.measurementInput?.heightMeasureSpec ?: 0 + for (mediaPlayer in mediaPlayers.values) { + mediaPlayer.view.measure(widthSpec, heightSpec) + } + } + } } \ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/media/UnboundHorizontalScrollView.kt b/packages/SystemUI/src/com/android/systemui/media/UnboundHorizontalScrollView.kt new file mode 100644 index 0000000000000..8efc9549068a4 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/UnboundHorizontalScrollView.kt @@ -0,0 +1,31 @@ +package com.android.systemui.media + +import android.content.Context +import android.util.AttributeSet +import android.widget.HorizontalScrollView + +/** + * A Horizontal scrollview that doesn't limit itself to the childs bounds. This is useful + * when only measuring children but not the parent, when trying to apply a new scroll position + */ +class UnboundHorizontalScrollView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) + : HorizontalScrollView(context, attrs, defStyleAttr) { + + /** + * Allow all scrolls to go through, use base implementation + */ + override fun scrollTo(x: Int, y: Int) { + if (mScrollX != x || mScrollY != y) { + val oldX: Int = mScrollX + val oldY: Int = mScrollY + mScrollX = x + mScrollY = y + invalidateParentCaches() + onScrollChanged(mScrollX, mScrollY, oldX, oldY) + if (!awakenScrollBars()) { + postInvalidateOnAnimation() + } + } + } +} \ No newline at end of file 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 376d09110d20e..bf94c5d36ff74 100644 --- a/packages/SystemUI/src/com/android/systemui/util/animation/UniqueObjectHostView.kt +++ b/packages/SystemUI/src/com/android/systemui/util/animation/UniqueObjectHostView.kt @@ -22,15 +22,20 @@ import android.view.View import android.widget.FrameLayout /** - * A special view that hosts a unique object, which only exists once, but can transition between - * different hosts. If the view currently hosts the unique object, it's measuring it normally, - * but if it's not attached, it will obtain the size by requesting a measure. + * A special view that is designed to host a single "unique object". The unique object is + * dynamically added and removed from this view and may transition to other UniqueObjectHostViews + * available in the system. + * This is useful to share a singular instance of a view that can transition between completely + * independent parts of the view hierarchy. + * If the view currently hosts the unique object, it's measuring it normally, + * but if it's not attached, it will obtain the size by requesting a measure, as if it were + * always attached. */ class UniqueObjectHostView( context: Context ) : FrameLayout(context) { lateinit var measurementCache : GuaranteedMeasurementCache - var firstMeasureListener: ((MeasurementInput) -> Unit)? = null + var onMeasureListener: ((MeasurementInput) -> Unit)? = null @SuppressLint("DrawAllocation") override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -41,13 +46,18 @@ class UniqueObjectHostView( val height = MeasureSpec.getSize(heightMeasureSpec) - paddingVertical val heightSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.getMode(heightMeasureSpec)) val measurementInput = MeasurementInputData(widthSpec, heightSpec) - firstMeasureListener?.apply { + onMeasureListener?.apply { invoke(measurementInput) - firstMeasureListener = null } if (!isCurrentHost()) { // We're not currently the host, let's get the dimension from our cache (this might // perform a measuring if the cache doesn't have it yet) + // 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. val (cachedWidth, cachedHeight) = measurementCache.obtainMeasurement(measurementInput) setMeasuredDimension(cachedWidth + paddingHorizontal, cachedHeight + paddingVertical) } else {