When animating between states, animate the view width

Test: add media notification observe no flickery animation
Bug: 154137987
Change-Id: I909dd4427813b34426cc7d7fc08f298761f134bc
diff --git a/packages/SystemUI/src/com/android/systemui/media/LayoutAnimationHelper.kt b/packages/SystemUI/src/com/android/systemui/media/LayoutAnimationHelper.kt
new file mode 100644
index 0000000..a366725
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/LayoutAnimationHelper.kt
@@ -0,0 +1,115 @@
+/*
+ * Copyright (C) 2020 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.systemui.media
+
+import android.graphics.Rect
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewTreeObserver
+import com.android.systemui.statusbar.notification.AnimatableProperty
+import com.android.systemui.statusbar.notification.PropertyAnimator
+import com.android.systemui.statusbar.notification.stack.AnimationProperties
+
+/**
+ * A utility class that helps with animations of bound changes designed for motionlayout which
+ * doesn't work together with regular changeBounds.
+ */
+class LayoutAnimationHelper {
+
+    private val layout: ViewGroup
+    private var sizeAnimationPending = false
+    private val desiredBounds = mutableMapOf<View, Rect>()
+    private val animationProperties = AnimationProperties()
+    private val layoutListener = object : View.OnLayoutChangeListener {
+        override fun onLayoutChange(v: View?, left: Int, top: Int, right: Int, bottom: Int,
+                                    oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) {
+            v?.let {
+                if (v.alpha == 0.0f || v.visibility == View.GONE || oldLeft - oldRight == 0 ||
+                        oldTop - oldBottom == 0) {
+                    return
+                }
+                if (oldLeft != left || oldTop != top || oldBottom != bottom || oldRight != right) {
+                    val rect = desiredBounds.getOrPut(v, { Rect() })
+                    rect.set(left, top, right, bottom)
+                    onDesiredLocationChanged(v, rect)
+                }
+            }
+        }
+    }
+
+    constructor(layout: ViewGroup) {
+        this.layout = layout
+        val childCount = this.layout.childCount
+        for (i in 0 until childCount) {
+            val child = this.layout.getChildAt(i)
+            child.addOnLayoutChangeListener(layoutListener)
+        }
+    }
+
+    private fun onDesiredLocationChanged(v: View, rect: Rect) {
+        if (!sizeAnimationPending) {
+            applyBounds(v, rect, animate = false)
+        }
+        // We need to reapply the current bounds in every frame since the layout may override
+        // the layout bounds making this view jump and not all calls to apply bounds actually
+        // reapply them, for example if there's already an animator to the same target
+        reapplyProperty(v, AnimatableProperty.ABSOLUTE_X);
+        reapplyProperty(v, AnimatableProperty.ABSOLUTE_Y);
+        reapplyProperty(v, AnimatableProperty.WIDTH);
+        reapplyProperty(v, AnimatableProperty.HEIGHT);
+    }
+
+    private fun reapplyProperty(v: View, property: AnimatableProperty) {
+        property.property.set(v, property.property.get(v))
+    }
+
+    private fun applyBounds(v: View, newBounds: Rect, animate: Boolean) {
+        PropertyAnimator.setProperty(v, AnimatableProperty.ABSOLUTE_X, newBounds.left.toFloat(),
+                animationProperties, animate)
+        PropertyAnimator.setProperty(v, AnimatableProperty.ABSOLUTE_Y, newBounds.top.toFloat(),
+                animationProperties, animate)
+        PropertyAnimator.setProperty(v, AnimatableProperty.WIDTH, newBounds.width().toFloat(),
+                animationProperties, animate)
+        PropertyAnimator.setProperty(v, AnimatableProperty.HEIGHT, newBounds.height().toFloat(),
+                animationProperties, animate)
+    }
+
+    private fun startBoundAnimation(v: View) {
+        val target = desiredBounds[v] ?: return
+        applyBounds(v, target, animate = true)
+    }
+
+    fun animatePendingSizeChange(duration: Long, delay: Long) {
+        animationProperties.duration = duration
+        animationProperties.delay = delay
+        if (!sizeAnimationPending) {
+            sizeAnimationPending = true
+            layout.viewTreeObserver.addOnPreDrawListener (
+                    object : ViewTreeObserver.OnPreDrawListener {
+                        override fun onPreDraw(): Boolean {
+                            layout.viewTreeObserver.removeOnPreDrawListener(this)
+                            sizeAnimationPending = false
+                            val childCount = layout.childCount
+                            for (i in 0 until childCount) {
+                                val child = layout.getChildAt(i)
+                                startBoundAnimation(child)
+                            }
+                            return true
+                        }
+                    })
+        }
+    }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
index 5a5cd6d..0b0bfc6 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
@@ -36,6 +36,7 @@
 import android.media.session.MediaSession;
 import android.media.session.PlaybackState;
 import android.service.media.MediaBrowserService;
+import android.util.DisplayMetrics;
 import android.util.Log;
 import android.view.LayoutInflater;
 import android.view.View;
@@ -93,6 +94,7 @@
     private final Executor mForegroundExecutor;
     protected final Executor mBackgroundExecutor;
     private final ActivityStarter mActivityStarter;
+    private final LayoutAnimationHelper mLayoutAnimationHelper;
 
     private Context mContext;
     private MotionLayout mMediaNotifView;
@@ -109,6 +111,7 @@
     private String mKey;
     private int mAlbumArtSize;
     private int mAlbumArtRadius;
+    private int mViewWidth;
 
     public static final String MEDIA_PREFERENCES = "media_control_prefs";
     public static final String MEDIA_PREFERENCE_KEY = "browser_components";
@@ -176,6 +179,7 @@
         LayoutInflater inflater = LayoutInflater.from(mContext);
         mMediaNotifView = (MotionLayout) inflater.inflate(R.layout.qs_media_panel, parent, false);
         mBackground = mMediaNotifView.findViewById(R.id.media_background);
+        mLayoutAnimationHelper = new LayoutAnimationHelper(mMediaNotifView);
         mKeyFrames = mMediaNotifView.getDefinedTransitions().get(0).getKeyFrameList();
         mLocalMediaManager = routeManager;
         mForegroundExecutor = foregroundExecutor;
@@ -732,4 +736,32 @@
      * Called when a player can't be resumed to give it an opportunity to hide or remove itself
      */
     protected void removePlayer() { }
+
+    public void setDimension(int newWidth, int newHeight, 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 (newWidth != mViewWidth) {
+            if (animate) {
+                mLayoutAnimationHelper.animatePendingSizeChange(duration, startDelay);
+            }
+            setViewWidth(newWidth);
+            mMediaNotifView.layout(0, 0, newWidth, mMediaNotifView.getMeasuredHeight());
+        }
+    }
+
+    protected void setViewWidth(int newWidth) {
+        ConstraintSet expandedSet = mMediaNotifView.getConstraintSet(R.id.expanded);
+        ConstraintSet collapsedSet = mMediaNotifView.getConstraintSet(R.id.collapsed);
+        collapsedSet.setGuidelineBegin(R.id.view_width, newWidth);
+        expandedSet.setGuidelineBegin(R.id.view_width, newWidth);
+        DisplayMetrics displayMetrics = getContext().getResources().getDisplayMetrics();
+        int widthSpec = View.MeasureSpec.makeMeasureSpec(displayMetrics.widthPixels,
+                View.MeasureSpec.AT_MOST);
+        int heightSpec = View.MeasureSpec.makeMeasureSpec(displayMetrics.heightPixels,
+                View.MeasureSpec.AT_MOST);
+        mMediaNotifView.setMinimumWidth(displayMetrics.widthPixels);
+        mMediaNotifView.measure(widthSpec, heightSpec);
+        mViewWidth = newWidth;
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt
index 673a543..fc141bf 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt
@@ -24,9 +24,6 @@
 import android.view.View
 import android.view.ViewGroup
 import android.view.ViewGroupOverlay
-import com.android.settingslib.bluetooth.LocalBluetoothManager
-import com.android.settingslib.media.InfoMediaManager
-import com.android.settingslib.media.LocalMediaManager
 import com.android.systemui.Interpolators
 import com.android.systemui.plugins.statusbar.StatusBarStateController
 import com.android.systemui.statusbar.StatusBarState
@@ -167,6 +164,7 @@
             return;
         }
         updateTargetState()
+        var animate = false
         if (isCurrentlyInGuidedTransformation()) {
             applyTargetStateIfNotAnimating()
         } else if (shouldAnimateTransition(currentHost, previousHost)) {
@@ -175,9 +173,12 @@
             animationStartState = currentState.copy()
             adjustAnimatorForTransition(previousLocation, desiredLocation)
             animator.start()
+            animate = true
         } else {
             cancelAnimationAndApplyDesiredState()
         }
+        mediaViewManager.performTransition(targetState, animate, animator.duration,
+                animator.startDelay)
     }
 
     private fun shouldAnimateTransition(currentHost: MediaHost, previousHost: MediaHost): Boolean {
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt
index eeecf2b..636e4e4 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt
@@ -33,7 +33,6 @@
     private val activityStarter: ActivityStarter,
     mediaManager: MediaDataManager
 ) {
-    private var targetState: MediaState? = null
     val mediaCarousel: ViewGroup
     private val mediaContent: ViewGroup
     private val mediaPlayers: MutableMap<String, MediaControlPanel> = mutableMapOf()
@@ -140,4 +139,25 @@
         }
         viewsExpanded = state.expansion > 0;
     }
+
+    /**
+     * @param targetState the target state we're transitioning to
+     * @param animate should this be animated
+     */
+    fun performTransition(targetState: MediaState?, animate: Boolean, duration: Long,
+                          startDelay: Long) {
+        if (targetState == null) {
+            return
+        }
+        val newWidth = targetState.boundsOnScreen.width()
+        val newHeight = targetState.boundsOnScreen.height()
+        remeasureViews(newWidth, newHeight, animate, duration, startDelay)
+    }
+
+    private fun remeasureViews(newWidth: Int, newHeight: Int, animate: Boolean, duration: Long,
+                               startDelay: Long) {
+        for (mediaPlayer in mediaPlayers.values) {
+            mediaPlayer.setDimension(newWidth, newHeight, animate, duration, startDelay)
+        }
+    }
 }
\ No newline at end of file