Handling multiple players better

Previously the animation was completely broken with multiple players
since they were all laid out at 0 instead of the actual position.
Measuring the full layout is a bit too expansive, so we're
introducing some workarounds to only measure the players.

Test: add multiple players, observe transitions
Bug: 154137987
Change-Id: I1c424c980cf3b64f5a9d63ba058aa7e47f6e4156
This commit is contained in:
Selim Cinek
2020-05-04 17:16:58 -07:00
parent 8081f0940f
commit f418bb0275
7 changed files with 227 additions and 68 deletions

View File

@@ -16,7 +16,7 @@
-->
<!-- Carousel for media controls -->
<HorizontalScrollView
<com.android.systemui.media.UnboundHorizontalScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -34,4 +34,4 @@
>
<!-- QSMediaPlayers will be added here dynamically -->
</LinearLayout>
</HorizontalScrollView>
</com.android.systemui.media.UnboundHorizontalScrollView>

View File

@@ -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);
}
}

View File

@@ -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<MediaHost>(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()
}
}
}

View File

@@ -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(

View File

@@ -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<String, MediaControlPanel> = 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)
}
}
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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 {