Merge "MediaRouter: Implement insertion/deletion animation in MRCD" into mnc-ub-dev
diff --git a/v7/mediarouter/res/layout/mr_controller_material_dialog_b.xml b/v7/mediarouter/res/layout/mr_controller_material_dialog_b.xml
index 091f5f8..94ad82b 100644
--- a/v7/mediarouter/res/layout/mr_controller_material_dialog_b.xml
+++ b/v7/mediarouter/res/layout/mr_controller_material_dialog_b.xml
@@ -82,13 +82,14 @@
                     <include android:id="@+id/mr_volume_control"
                              layout="@layout/mr_volume_control" />
                 </LinearLayout>
-                <ListView android:id="@+id/mr_volume_group_list"
-                          android:layout_width="fill_parent"
-                          android:layout_height="wrap_content"
-                          android:paddingTop="@dimen/mr_controller_volume_group_list_padding_top"
-                          android:scrollbarStyle="outsideOverlay"
-                          android:clipToPadding="false"
-                          android:visibility="gone" />
+                <android.support.v7.app.OverlayListView
+                        android:id="@+id/mr_volume_group_list"
+                        android:layout_width="fill_parent"
+                        android:layout_height="wrap_content"
+                        android:paddingTop="@dimen/mr_controller_volume_group_list_padding_top"
+                        android:scrollbarStyle="outsideOverlay"
+                        android:clipToPadding="false"
+                        android:visibility="gone" />
             </LinearLayout>
         </FrameLayout>
         <include layout="@layout/abc_alert_dialog_button_bar_material" />
diff --git a/v7/mediarouter/res/layout/mr_controller_volume_item.xml b/v7/mediarouter/res/layout/mr_controller_volume_item.xml
index 4d693e2..3e7b39b 100644
--- a/v7/mediarouter/res/layout/mr_controller_volume_item.xml
+++ b/v7/mediarouter/res/layout/mr_controller_volume_item.xml
@@ -16,31 +16,36 @@
 
 <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
               android:layout_width="fill_parent"
-              android:layout_height="@dimen/mr_controller_volume_group_list_item_height"
-              android:paddingLeft="24dp"
-              android:paddingRight="60dp"
-              android:paddingBottom="8dp"
-              android:orientation="vertical" >
-    <TextView android:id="@+id/mr_name"
-              android:layout_width="wrap_content"
-              android:layout_height="wrap_content"
-              android:textAppearance="?attr/mediaRouteControllerSecondaryTextStyle"
-              android:singleLine="true" />
-    <LinearLayout android:layout_width="fill_parent"
+              android:layout_height="wrap_content">
+    <LinearLayout android:id="@+id/volume_item_container"
+                  android:layout_width="fill_parent"
+                  android:layout_height="@dimen/mr_controller_volume_group_list_item_height"
+                  android:paddingLeft="24dp"
+                  android:paddingRight="60dp"
+                  android:paddingBottom="8dp"
+                  android:orientation="vertical" >
+        <TextView android:id="@+id/mr_name"
+                  android:layout_width="wrap_content"
                   android:layout_height="wrap_content"
-                  android:orientation="horizontal"
-                  android:gravity="center_vertical">
-        <ImageView android:id="@+id/mr_volume_item_icon"
-                   android:layout_width="@dimen/mr_controller_volume_group_list_item_icon_size"
-                   android:layout_height="@dimen/mr_controller_volume_group_list_item_icon_size"
-                   android:layout_marginTop="8dp"
-                   android:layout_marginBottom="8dp"
-                   android:scaleType="fitCenter"
-                   android:src="?attr/mediaRouteAudioTrackDrawable" />
-        <android.support.v7.app.MediaRouteVolumeSlider android:id="@+id/mr_volume_slider"
-                 android:layout_width="fill_parent"
-                 android:layout_height="40dp"
-                 android:minHeight="40dp"
-                 android:maxHeight="40dp" />
+                  android:textAppearance="?attr/mediaRouteControllerSecondaryTextStyle"
+                  android:singleLine="true" />
+        <LinearLayout android:layout_width="fill_parent"
+                      android:layout_height="wrap_content"
+                      android:orientation="horizontal"
+                      android:gravity="center_vertical">
+            <ImageView android:id="@+id/mr_volume_item_icon"
+                       android:layout_width="@dimen/mr_controller_volume_group_list_item_icon_size"
+                       android:layout_height="@dimen/mr_controller_volume_group_list_item_icon_size"
+                       android:layout_marginTop="8dp"
+                       android:layout_marginBottom="8dp"
+                       android:scaleType="fitCenter"
+                       android:src="?attr/mediaRouteAudioTrackDrawable" />
+            <android.support.v7.app.MediaRouteVolumeSlider
+                android:id="@+id/mr_volume_slider"
+                android:layout_width="fill_parent"
+                android:layout_height="40dp"
+                android:minHeight="40dp"
+                android:maxHeight="40dp" />
+        </LinearLayout>
     </LinearLayout>
 </LinearLayout>
diff --git a/v7/mediarouter/res/values/dimens.xml b/v7/mediarouter/res/values/dimens.xml
index 10e5751..d41b0b7 100644
--- a/v7/mediarouter/res/values/dimens.xml
+++ b/v7/mediarouter/res/values/dimens.xml
@@ -35,5 +35,9 @@
 
     <dimen name="mr_controller_volume_group_list_padding_top">16dp</dimen>
     <!-- Group list expand/collapse animation duration. -->
-    <integer name="mr_controller_volume_group_list_animation_duration_ms">200</integer>
+    <integer name="mr_controller_volume_group_list_animation_duration_ms">400</integer>
+    <!-- Group list fade in animation duration. -->
+    <integer name="mr_controller_volume_group_list_fade_in_duration_ms">400</integer>
+    <!-- Group list fade out animation duration. -->
+    <integer name="mr_controller_volume_group_list_fade_out_duration_ms">200</integer>
 </resources>
diff --git a/v7/mediarouter/src/android/support/v7/app/MediaRouteControllerDialog.java b/v7/mediarouter/src/android/support/v7/app/MediaRouteControllerDialog.java
index 21d78d8..1fde803 100644
--- a/v7/mediarouter/src/android/support/v7/app/MediaRouteControllerDialog.java
+++ b/v7/mediarouter/src/android/support/v7/app/MediaRouteControllerDialog.java
@@ -34,6 +34,7 @@
 import android.support.v4.media.session.MediaSessionCompat;
 import android.support.v4.media.session.PlaybackStateCompat;
 import android.support.v4.view.accessibility.AccessibilityEventCompat;
+import android.support.v7.app.OverlayListView.OverlayObject;
 import android.support.v7.graphics.Palette;
 import android.support.v7.media.MediaRouteSelector;
 import android.support.v7.media.MediaRouter;
@@ -48,28 +49,35 @@
 import android.view.ViewTreeObserver;
 import android.view.accessibility.AccessibilityEvent;
 import android.view.accessibility.AccessibilityManager;
+import android.view.animation.AccelerateDecelerateInterpolator;
+import android.view.animation.AlphaAnimation;
 import android.view.animation.Animation;
+import android.view.animation.AnimationSet;
+import android.view.animation.AnimationUtils;
+import android.view.animation.Interpolator;
 import android.view.animation.Transformation;
+import android.view.animation.TranslateAnimation;
 import android.widget.ArrayAdapter;
 import android.widget.Button;
 import android.widget.FrameLayout;
 import android.widget.ImageButton;
 import android.widget.ImageView;
 import android.widget.LinearLayout;
-import android.widget.ListView;
 import android.widget.RelativeLayout;
 import android.widget.SeekBar;
 import android.widget.TextView;
 
 import java.io.BufferedInputStream;
-import java.io.InputStream;
 import java.io.IOException;
+import java.io.InputStream;
 import java.net.URL;
 import java.net.URLConnection;
-import java.util.HashMap;
-import java.util.List;
 import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -128,9 +136,12 @@
     private LinearLayout mVolumeControlLayout;
     private View mDividerView;
 
-    private ListView mVolumeGroupList;
+    private OverlayListView mVolumeGroupList;
     private VolumeGroupAdapter mVolumeGroupAdapter;
     private List<MediaRouter.RouteInfo> mGroupMemberRoutes;
+    private Set<MediaRouter.RouteInfo> mGroupMemberRoutesAdded;
+    private Set<MediaRouter.RouteInfo> mGroupMemberRoutesRemoved;
+    private Set<MediaRouter.RouteInfo> mGroupMemberRoutesAnimatingWithBitmap;
     private SeekBar mVolumeSlider;
     private VolumeChangeListener mVolumeChangeListener;
     private MediaRouter.RouteInfo mRouteInVolumeSliderTouched;
@@ -149,11 +160,26 @@
     private Bitmap mArtIconBitmap;
     private Uri mArtIconUri;
     private boolean mIsGroupExpanded;
-    private boolean mIsGroupListAnimationNeeded;
+    private boolean mIsGroupListAnimating;
+    private boolean mIsGroupListAnimationPending;
     private int mGroupListAnimationDurationMs;
+    private int mGroupListFadeInDurationMs;
+    private int mGroupListFadeOutDurationMs;
+
+    private Interpolator mInterpolator;
+    private Interpolator mLinearOutSlowInInterpolator;
+    private Interpolator mFastOutSlowInInterpolator;
+    private Interpolator mAccelerateDecelerateInterpolator;
 
     private final AccessibilityManager mAccessibilityManager;
 
+    private Runnable mGroupListFadeInAnimation = new Runnable() {
+        @Override
+        public void run() {
+            startGroupListFadeInAnimation();
+        }
+    };
+
     public MediaRouteControllerDialog(Context context) {
         this(context, 0);
     }
@@ -171,6 +197,13 @@
                 R.dimen.mr_controller_volume_group_list_padding_top);
         mAccessibilityManager =
                 (AccessibilityManager) mContext.getSystemService(Context.ACCESSIBILITY_SERVICE);
+        if (android.os.Build.VERSION.SDK_INT >= 21) {
+            mLinearOutSlowInInterpolator = AnimationUtils.loadInterpolator(context,
+                    R.interpolator.mr_linear_out_slow_in);
+            mFastOutSlowInInterpolator = AnimationUtils.loadInterpolator(context,
+                    R.interpolator.mr_fast_out_slow_in);
+        }
+        mAccelerateDecelerateInterpolator = new AccelerateDecelerateInterpolator();
     }
 
     /**
@@ -219,6 +252,7 @@
             mVolumeControlEnabled = enable;
             if (mCreated) {
                 updateVolumeControlLayout();
+                updateLayoutHeight(false);
             }
         }
     }
@@ -261,7 +295,7 @@
                 : mMediaController.getMetadata();
         mDescription = metadata == null ? null : metadata.getDescription();
         mState = mMediaController == null ? null : mMediaController.getPlaybackState();
-        update();
+        update(false);
     }
 
     /**
@@ -353,11 +387,11 @@
         mVolumeChangeListener = new VolumeChangeListener();
         mVolumeSlider.setOnSeekBarChangeListener(mVolumeChangeListener);
 
-        mVolumeGroupList = (ListView) findViewById(R.id.mr_volume_group_list);
+        mVolumeGroupList = (OverlayListView) findViewById(R.id.mr_volume_group_list);
         mGroupMemberRoutes = new ArrayList<MediaRouter.RouteInfo>();
         mVolumeGroupAdapter = new VolumeGroupAdapter(mContext, mGroupMemberRoutes);
         mVolumeGroupList.setAdapter(mVolumeGroupAdapter);
-        updateVolumeGroupList();
+        mGroupMemberRoutesAnimatingWithBitmap = new HashSet<>();
 
         MediaRouterThemeHelper.setMediaControlsBackgroundColor(mContext,
                 mMediaMainControlLayout, mVolumeGroupList, getGroup() != null);
@@ -375,14 +409,17 @@
                 if (mIsGroupExpanded) {
                     mVolumeGroupList.setVisibility(View.VISIBLE);
                 }
-                // Request layout to update UI
-                mDefaultControlLayout.requestLayout();
-                mIsGroupListAnimationNeeded = true;
-                updateLayoutHeight();
+                loadInterpolator();
+                updateLayoutHeight(true);
             }
         });
+        loadInterpolator();
         mGroupListAnimationDurationMs = mContext.getResources().getInteger(
                 R.integer.mr_controller_volume_group_list_animation_duration_ms);
+        mGroupListFadeInDurationMs = mContext.getResources().getInteger(
+                R.integer.mr_controller_volume_group_list_fade_in_duration_ms);
+        mGroupListFadeOutDurationMs = mContext.getResources().getInteger(
+                R.integer.mr_controller_volume_group_list_fade_out_duration_ms);
 
         mCustomControlView = onCreateMediaControlView(savedInstanceState);
         if (mCustomControlView != null) {
@@ -414,7 +451,7 @@
         // Ensure the mArtView is updated.
         mArtIconBitmap = null;
         mArtIconUri = null;
-        update();
+        update(false);
     }
 
     @Override
@@ -454,7 +491,7 @@
         return super.onKeyUp(keyCode, event);
     }
 
-    private void update() {
+    private void update(boolean animate) {
         if (!mRoute.isSelected() || mRoute.isDefault()) {
             dismiss();
             return;
@@ -475,6 +512,7 @@
         }
         updateVolumeControlLayout();
         updatePlaybackControlLayout();
+        updateLayoutHeight(animate);
     }
 
     private boolean canShowPlaybackControlLayout() {
@@ -512,16 +550,21 @@
                 && !canShowPlaybackControlLayout) ? View.GONE : View.VISIBLE);
     }
 
-    private void updateLayoutHeight() {
+    private void updateLayoutHeight(final boolean animate) {
         // We need to defer the update until the first layout has occurred, as we don't yet know the
         // overall visible display size in which the window this view is attached to has been
         // positioned in.
+        mDefaultControlLayout.requestLayout();
         ViewTreeObserver observer = mDefaultControlLayout.getViewTreeObserver();
         observer.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
             @Override
             public void onGlobalLayout() {
                 mDefaultControlLayout.getViewTreeObserver().removeGlobalOnLayoutListener(this);
-                updateLayoutHeightInternal();
+                if (mIsGroupListAnimating) {
+                    mIsGroupListAnimationPending = true;
+                } else {
+                    updateLayoutHeightInternal(animate);
+                }
             }
         });
     }
@@ -529,7 +572,7 @@
     /**
      * Updates the height of views and hide artwork or metadata if space is limited.
      */
-    private void updateLayoutHeightInternal() {
+    private void updateLayoutHeightInternal(boolean animate) {
         // Measure the size of widgets and get the height of main components.
         int oldHeight = getLayoutHeight(mMediaMainControlLayout);
         setLayoutHeight(mMediaMainControlLayout, ViewGroup.LayoutParams.FILL_PARENT);
@@ -551,10 +594,8 @@
         int mainControllerHeight = getMainControllerHeight(canShowPlaybackControlLayout());
         int volumeGroupListCount = mGroupMemberRoutes.size();
         // Scale down volume group list items in landscape mode.
-        for (int i = 0; i < mVolumeGroupList.getChildCount(); i++) {
-            updateVolumeGroupItemHeight(mVolumeGroupList.getChildAt(i));
-        }
-        int expandedGroupListHeight = mVolumeGroupListItemHeight * volumeGroupListCount;
+        int expandedGroupListHeight = getGroup() == null ? 0 :
+                mVolumeGroupListItemHeight * getGroup().getRoutes().size();
         if (volumeGroupListCount > 0) {
             expandedGroupListHeight += mVolumeGroupListPaddingTop;
         }
@@ -608,7 +649,7 @@
         mMediaMainControlLayout.clearAnimation();
         mVolumeGroupList.clearAnimation();
         mDefaultControlLayout.clearAnimation();
-        if (mIsGroupListAnimationNeeded) {
+        if (animate) {
             animateLayoutHeight(mMediaMainControlLayout, mainControllerHeight);
             animateLayoutHeight(mVolumeGroupList, visibleGroupListHeight);
             animateLayoutHeight(mDefaultControlLayout, desiredControlLayoutHeight);
@@ -617,13 +658,14 @@
             setLayoutHeight(mVolumeGroupList, visibleGroupListHeight);
             setLayoutHeight(mDefaultControlLayout, desiredControlLayoutHeight);
         }
-        mIsGroupListAnimationNeeded = false;
         // Maximize the window size with a transparent layout in advance for smooth animation.
         setLayoutHeight(mExpandableAreaLayout, visibleRect.height());
+        rebuildVolumeGroupList(animate);
     }
 
     private void updateVolumeGroupItemHeight(View item) {
-        setLayoutHeight(item, mVolumeGroupListItemHeight);
+        LinearLayout container = (LinearLayout) item.findViewById(R.id.volume_item_container);
+        setLayoutHeight(container, mVolumeGroupListItemHeight);
         View icon = item.findViewById(R.id.mr_volume_item_icon);
         ViewGroup.LayoutParams lp = icon.getLayoutParams();
         lp.width = mVolumeGroupListItemIconSize;
@@ -643,53 +685,261 @@
         };
         anim.setDuration(mGroupListAnimationDurationMs);
         if (android.os.Build.VERSION.SDK_INT >= 21) {
-            anim.setInterpolator(mContext, mIsGroupExpanded ? R.interpolator.mr_linear_out_slow_in
-                    : R.interpolator.mr_fast_out_slow_in);
-        }
-        if (view == mVolumeGroupList) {
-            anim.setAnimationListener(new Animation.AnimationListener() {
-                @Override
-                public void onAnimationStart(Animation animation) {
-                    mVolumeGroupList.setTranscriptMode(ListView.TRANSCRIPT_MODE_ALWAYS_SCROLL);
-                }
-
-                @Override
-                public void onAnimationEnd(Animation animation) {
-                    mVolumeGroupList.setTranscriptMode(ListView.TRANSCRIPT_MODE_DISABLED);
-                }
-
-                @Override
-                public void onAnimationRepeat(Animation animation) { }
-            });
+            anim.setInterpolator(mInterpolator);
         }
         view.startAnimation(anim);
     }
 
+    private void loadInterpolator() {
+        if (android.os.Build.VERSION.SDK_INT >= 21) {
+            mInterpolator = mIsGroupExpanded ? mLinearOutSlowInInterpolator
+                    : mFastOutSlowInInterpolator;
+        } else {
+            mInterpolator = mAccelerateDecelerateInterpolator;
+        }
+    }
+
     private void updateVolumeControlLayout() {
         if (isVolumeControlAvailable(mRoute)) {
             if (mVolumeControlLayout.getVisibility() == View.GONE) {
                 mVolumeControlLayout.setVisibility(View.VISIBLE);
                 mVolumeSlider.setMax(mRoute.getVolumeMax());
                 mVolumeSlider.setProgress(mRoute.getVolume());
-                if (getGroup() == null) {
-                    mGroupExpandCollapseButton.setVisibility(View.GONE);
-                } else {
-                    mGroupExpandCollapseButton.setVisibility(View.VISIBLE);
-                }
+                mGroupExpandCollapseButton.setVisibility(getGroup() == null ? View.GONE
+                        : View.VISIBLE);
             }
-            updateVolumeGroupList();
         } else {
             mVolumeControlLayout.setVisibility(View.GONE);
         }
-        updateLayoutHeight();
     }
 
-    private void updateVolumeGroupList() {
-        mGroupMemberRoutes.clear();
-        if (getGroup() != null) {
-            mGroupMemberRoutes.addAll(getGroup().getRoutes());
+    private void rebuildVolumeGroupList(boolean animate) {
+        List<MediaRouter.RouteInfo> routes = getGroup() == null ? null : getGroup().getRoutes();
+        if (routes == null) {
+            mGroupMemberRoutes.clear();
+            mVolumeGroupAdapter.notifyDataSetChanged();
+        } else if (MediaRouteDialogHelper.listUnorderedEquals(mGroupMemberRoutes, routes)) {
+            mVolumeGroupAdapter.notifyDataSetChanged();
+        } else {
+            HashMap<MediaRouter.RouteInfo, Rect> previousRouteBoundMap = animate
+                    ? MediaRouteDialogHelper.getItemBoundMap(mVolumeGroupList, mVolumeGroupAdapter)
+                    : null;
+            HashMap<MediaRouter.RouteInfo, BitmapDrawable> previousRouteBitmapMap = animate
+                    ? MediaRouteDialogHelper.getItemBitmapMap(mContext, mVolumeGroupList,
+                            mVolumeGroupAdapter) : null;
+            mGroupMemberRoutesAdded =
+                    MediaRouteDialogHelper.getItemsAdded(mGroupMemberRoutes, routes);
+            mGroupMemberRoutesRemoved = MediaRouteDialogHelper.getItemsRemoved(mGroupMemberRoutes,
+                    routes);
+            mGroupMemberRoutes.addAll(0, mGroupMemberRoutesAdded);
+            mGroupMemberRoutes.removeAll(mGroupMemberRoutesRemoved);
+            mVolumeGroupAdapter.notifyDataSetChanged();
+            if (animate && mIsGroupExpanded
+                    && mGroupMemberRoutesAdded.size() + mGroupMemberRoutesRemoved.size() > 0) {
+                animateGroupListItems(previousRouteBoundMap, previousRouteBitmapMap);
+            } else {
+                mGroupMemberRoutesAdded = null;
+                mGroupMemberRoutesRemoved = null;
+            }
         }
-        mVolumeGroupAdapter.notifyDataSetChanged();
+    }
+
+    private void animateGroupListItems(final Map<MediaRouter.RouteInfo, Rect> previousRouteBoundMap,
+            final Map<MediaRouter.RouteInfo, BitmapDrawable> previousRouteBitmapMap) {
+        mVolumeGroupList.setEnabled(false);
+        mVolumeGroupList.requestLayout();
+        mIsGroupListAnimating = true;
+        ViewTreeObserver observer = mVolumeGroupList.getViewTreeObserver();
+        observer.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
+            @Override
+            public void onGlobalLayout() {
+                mVolumeGroupList.getViewTreeObserver().removeGlobalOnLayoutListener(this);
+                animateGroupListItemsInternal(previousRouteBoundMap, previousRouteBitmapMap);
+            }
+        });
+    }
+
+    private void animateGroupListItemsInternal(
+            Map<MediaRouter.RouteInfo, Rect> previousRouteBoundMap,
+            Map<MediaRouter.RouteInfo, BitmapDrawable> previousRouteBitmapMap) {
+        if (mGroupMemberRoutesAdded == null || mGroupMemberRoutesRemoved == null) {
+            return;
+        }
+        int groupSizeDelta = mGroupMemberRoutesAdded.size() - mGroupMemberRoutesRemoved.size();
+        boolean listenerRegistered = false;
+        Animation.AnimationListener listener = new Animation.AnimationListener() {
+            @Override
+            public void onAnimationStart(Animation animation) {
+                mVolumeGroupList.startAnimationAll();
+                mVolumeGroupList.postDelayed(mGroupListFadeInAnimation,
+                        mGroupListAnimationDurationMs);
+            }
+
+            @Override
+            public void onAnimationEnd(Animation animation) { }
+
+            @Override
+            public void onAnimationRepeat(Animation animation) { }
+        };
+
+        // Animate visible items from previous positions to current positions except routes added
+        // just before. Added routes will remain hidden until translate animation finishes.
+        int first = mVolumeGroupList.getFirstVisiblePosition();
+        for (int i = 0; i < mVolumeGroupList.getChildCount(); ++i) {
+            View view = mVolumeGroupList.getChildAt(i);
+            int position = first + i;
+            MediaRouter.RouteInfo route = mVolumeGroupAdapter.getItem(position);
+            Rect previousBounds = previousRouteBoundMap.get(route);
+            int currentTop = view.getTop();
+            int previousTop = previousBounds != null ? previousBounds.top
+                    : (currentTop + mVolumeGroupListItemHeight * groupSizeDelta);
+            AnimationSet animSet = new AnimationSet(true);
+            if (mGroupMemberRoutesAdded != null && mGroupMemberRoutesAdded.contains(route)) {
+                previousTop = currentTop;
+                Animation alphaAnim = new AlphaAnimation(0.0f, 0.0f);
+                alphaAnim.setDuration(mGroupListFadeInDurationMs);
+                animSet.addAnimation(alphaAnim);
+            }
+            Animation translationAnim = new TranslateAnimation(0, 0, previousTop - currentTop, 0);
+            translationAnim.setDuration(mGroupListAnimationDurationMs);
+            animSet.addAnimation(translationAnim);
+            animSet.setFillAfter(true);
+            animSet.setFillEnabled(true);
+            animSet.setInterpolator(mInterpolator);
+            if (!listenerRegistered) {
+                listenerRegistered = true;
+                animSet.setAnimationListener(listener);
+            }
+            view.clearAnimation();
+            view.startAnimation(animSet);
+            previousRouteBoundMap.remove(route);
+            previousRouteBitmapMap.remove(route);
+        }
+
+        // If a member route doesn't exist any longer, it can be either removed or moved out of the
+        // ListView layout boundary. In this case, use the previously captured bitmaps for
+        // animation.
+        for (Map.Entry<MediaRouter.RouteInfo, BitmapDrawable> item
+                : previousRouteBitmapMap.entrySet()) {
+            final MediaRouter.RouteInfo route = item.getKey();
+            final BitmapDrawable bitmap = item.getValue();
+            final Rect bounds = previousRouteBoundMap.get(route);
+            OverlayObject object = null;
+            if (mGroupMemberRoutesRemoved.contains(route)) {
+                object = new OverlayObject(bitmap, bounds).setAlphaAnimation(1.0f, 0.0f)
+                        .setDuration(mGroupListFadeOutDurationMs)
+                        .setInterpolator(mInterpolator);
+            } else {
+                int deltaY = groupSizeDelta * mVolumeGroupListItemHeight;
+                object = new OverlayObject(bitmap, bounds).setTranslateYAnimation(deltaY)
+                        .setDuration(mGroupListAnimationDurationMs)
+                        .setInterpolator(mInterpolator)
+                        .setAnimationEndListener(new OverlayObject.OnAnimationEndListener() {
+                            @Override
+                            public void onAnimationEnd() {
+                                mGroupMemberRoutesAnimatingWithBitmap.remove(route);
+                                mVolumeGroupAdapter.notifyDataSetChanged();
+                            }
+                        });
+                mGroupMemberRoutesAnimatingWithBitmap.add(route);
+            }
+            mVolumeGroupList.addOverlayObject(object);
+        }
+    }
+
+    private void startGroupListFadeInAnimation() {
+        clearGroupListAnimation(true);
+        mVolumeGroupList.requestLayout();
+        ViewTreeObserver observer = mVolumeGroupList.getViewTreeObserver();
+        observer.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
+            @Override
+            public void onGlobalLayout() {
+                mVolumeGroupList.getViewTreeObserver().removeGlobalOnLayoutListener(this);
+                startGroupListFadeInAnimationInternal();
+            }
+        });
+    }
+
+    private void startGroupListFadeInAnimationInternal() {
+        if (mGroupMemberRoutesAdded != null && mGroupMemberRoutesAdded.size() != 0) {
+            fadeInAddedRoutes();
+        } else {
+            finishAnimation(true);
+        }
+    }
+
+    private void finishAnimation(boolean animate) {
+        mGroupMemberRoutesAdded = null;
+        mGroupMemberRoutesRemoved = null;
+        mIsGroupListAnimating = false;
+        if (mIsGroupListAnimationPending) {
+            mIsGroupListAnimationPending = false;
+            updateLayoutHeight(animate);
+        }
+        mVolumeGroupList.setEnabled(true);
+    }
+
+    private void fadeInAddedRoutes() {
+        Animation.AnimationListener listener = new Animation.AnimationListener() {
+            @Override
+            public void onAnimationStart(Animation animation) { }
+
+            @Override
+            public void onAnimationEnd(Animation animation) {
+                finishAnimation(true);
+            }
+
+            @Override
+            public void onAnimationRepeat(Animation animation) { }
+        };
+        boolean listenerRegistered = false;
+        int first = mVolumeGroupList.getFirstVisiblePosition();
+        for (int i = 0; i < mVolumeGroupList.getChildCount(); ++i) {
+            View view = mVolumeGroupList.getChildAt(i);
+            int position = first + i;
+            MediaRouter.RouteInfo route = mVolumeGroupAdapter.getItem(position);
+            if (mGroupMemberRoutesAdded.contains(route)) {
+                Animation alphaAnim = new AlphaAnimation(0.0f, 1.0f);
+                alphaAnim.setDuration(mGroupListFadeInDurationMs);
+                alphaAnim.setFillEnabled(true);
+                alphaAnim.setFillAfter(true);
+                if (!listenerRegistered) {
+                    listenerRegistered = true;
+                    alphaAnim.setAnimationListener(listener);
+                }
+                view.clearAnimation();
+                view.startAnimation(alphaAnim);
+            }
+        }
+    }
+
+    void clearGroupListAnimation(boolean exceptAddedRoutes) {
+        int first = mVolumeGroupList.getFirstVisiblePosition();
+        for (int i = 0; i < mVolumeGroupList.getChildCount(); ++i) {
+            View view = mVolumeGroupList.getChildAt(i);
+            int position = first + i;
+            MediaRouter.RouteInfo route = mVolumeGroupAdapter.getItem(position);
+            if (exceptAddedRoutes && mGroupMemberRoutesAdded != null
+                    && mGroupMemberRoutesAdded.contains(route)) {
+                continue;
+            }
+            LinearLayout container = (LinearLayout) view.findViewById(R.id.volume_item_container);
+            container.setVisibility(View.VISIBLE);
+            AnimationSet animSet = new AnimationSet(true);
+            Animation alphaAnim = new AlphaAnimation(1.0f, 1.0f);
+            alphaAnim.setDuration(0);
+            animSet.addAnimation(alphaAnim);
+            Animation translationAnim = new TranslateAnimation(0, 0, 0, 0);
+            translationAnim.setDuration(0);
+            animSet.setFillAfter(true);
+            animSet.setFillEnabled(true);
+            view.clearAnimation();
+            view.startAnimation(animSet);
+        }
+        mVolumeGroupList.stopAnimationAll();
+        if (!exceptAddedRoutes) {
+            finishAnimation(false);
+        }
     }
 
     private void updatePlaybackControlLayout() {
@@ -754,7 +1004,6 @@
                 }
             }
         }
-        updateLayoutHeight();
     }
 
     private boolean isVolumeControlAvailable(MediaRouter.RouteInfo route) {
@@ -796,12 +1045,12 @@
     private final class MediaRouterCallback extends MediaRouter.Callback {
         @Override
         public void onRouteUnselected(MediaRouter router, MediaRouter.RouteInfo route) {
-            update();
+            update(false);
         }
 
         @Override
         public void onRouteChanged(MediaRouter router, MediaRouter.RouteInfo route) {
-            update();
+            update(true);
         }
 
         @Override
@@ -825,13 +1074,13 @@
         @Override
         public void onPlaybackStateChanged(PlaybackStateCompat state) {
             mState = state;
-            update();
+            update(false);
         }
 
         @Override
         public void onMetadataChanged(MediaMetadataCompat metadata) {
             mDescription = metadata == null ? null : metadata.getDescription();
-            update();
+            update(false);
         }
     }
 
@@ -960,6 +1209,22 @@
                 ImageView volumeItemIcon =
                         (ImageView) v.findViewById(R.id.mr_volume_item_icon);
                 volumeItemIcon.setAlpha(isEnabled ? 0xFF : (int) (0xFF * mDisabledAlpha));
+
+                // If overlay bitmap exists, real view should remain hidden until
+                // the animation ends.
+                LinearLayout container = (LinearLayout) v.findViewById(R.id.volume_item_container);
+                container.setVisibility(mGroupMemberRoutesAnimatingWithBitmap.contains(route)
+                        ? View.INVISIBLE : View.VISIBLE);
+
+                // Routes which are being added will be invisible until animation ends.
+                if (mGroupMemberRoutesAdded != null && mGroupMemberRoutesAdded.contains(route)) {
+                    Animation alphaAnim = new AlphaAnimation(0.0f, 0.0f);
+                    alphaAnim.setDuration(0);
+                    alphaAnim.setFillEnabled(true);
+                    alphaAnim.setFillAfter(true);
+                    v.clearAnimation();
+                    v.startAnimation(alphaAnim);
+                }
             }
             return v;
         }
@@ -1056,7 +1321,7 @@
 
                 mArtView.setImageBitmap(art);
                 mArtView.setBackgroundColor(mBackgroundColor);
-                updateLayoutHeight();
+                updateLayoutHeight(true);
             }
         }
 
diff --git a/v7/mediarouter/src/android/support/v7/app/MediaRouteControllerDialogFragment.java b/v7/mediarouter/src/android/support/v7/app/MediaRouteControllerDialogFragment.java
index 6cadb9a..a3d750c 100644
--- a/v7/mediarouter/src/android/support/v7/app/MediaRouteControllerDialogFragment.java
+++ b/v7/mediarouter/src/android/support/v7/app/MediaRouteControllerDialogFragment.java
@@ -59,6 +59,14 @@
     }
 
     @Override
+    public void onStop() {
+        super.onStop();
+        if (mDialog != null) {
+            mDialog.clearGroupListAnimation(false);
+        }
+    }
+
+    @Override
     public void onConfigurationChanged(Configuration newConfig) {
         super.onConfigurationChanged(newConfig);
         if (mDialog != null) {
diff --git a/v7/mediarouter/src/android/support/v7/app/MediaRouteDialogHelper.java b/v7/mediarouter/src/android/support/v7/app/MediaRouteDialogHelper.java
index 99d414f..78f550b 100644
--- a/v7/mediarouter/src/android/support/v7/app/MediaRouteDialogHelper.java
+++ b/v7/mediarouter/src/android/support/v7/app/MediaRouteDialogHelper.java
@@ -17,10 +17,22 @@
 package android.support.v7.app;
 
 import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.BitmapDrawable;
 import android.support.v7.mediarouter.R;
 import android.util.DisplayMetrics;
 import android.util.TypedValue;
+import android.view.View;
 import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.ListView;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
 
 final class MediaRouteDialogHelper {
     /**
@@ -41,4 +53,99 @@
         }
         return ViewGroup.LayoutParams.WRAP_CONTENT;
     }
+
+    /**
+     * Compares two lists regardless of order.
+     *
+     * @param list1 A list
+     * @param list2 A list to be compared with {@code list1}
+     * @return True if two lists have exactly same items regardless of order, false otherwise.
+     */
+    public static <E> boolean listUnorderedEquals(List<E> list1, List<E> list2) {
+        HashSet<E> set1 = new HashSet<>(list1);
+        HashSet<E> set2 = new HashSet<>(list2);
+        return set1.equals(set2);
+    }
+
+    /**
+     * Compares two lists and returns a set of items which exist
+     * after-list but before-list, which means newly added items.
+     *
+     * @param before A list
+     * @param after A list to be compared with {@code before}
+     * @return A set of items which contains newly added items while
+     * comparing {@code after} to {@code before}.
+     */
+    public static <E> Set<E> getItemsAdded(List<E> before, List<E> after) {
+        HashSet<E> set = new HashSet<>(after);
+        set.removeAll(before);
+        return set;
+    }
+
+    /**
+     * Compares two lists and returns a set of items which exist
+     * before-list but after-list, which means removed items.
+     *
+     * @param before A list
+     * @param after A list to be compared with {@code before}
+     * @return A set of items which contains removed items while
+     * comparing {@code after} to {@code before}.
+     */
+    public static <E> Set<E> getItemsRemoved(List<E> before, List<E> after) {
+        HashSet<E> set = new HashSet<>(before);
+        set.removeAll(after);
+        return set;
+    }
+
+    /**
+     * Generates an item-Rect map which indicates where member
+     * items are located in the given ListView.
+     *
+     * @param listView A list view
+     * @param adapter An array adapter which contains an array of items.
+     * @return A map of items and bounds of their views located in the given list view.
+     */
+    public static <E> HashMap<E, Rect> getItemBoundMap(ListView listView,
+            ArrayAdapter<E> adapter) {
+        HashMap<E, Rect> itemBoundMap = new HashMap<>();
+        int firstVisiblePosition = listView.getFirstVisiblePosition();
+        for (int i = 0; i < listView.getChildCount(); ++i) {
+            int position = firstVisiblePosition + i;
+            E item = adapter.getItem(position);
+            View view = listView.getChildAt(i);
+            itemBoundMap.put(item,
+                    new Rect(view.getLeft(), view.getTop(), view.getRight(), view.getBottom()));
+        }
+        return itemBoundMap;
+    }
+
+    /**
+     * Generates an item-BitmapDrawable map which stores snapshots
+     * of member items in the given ListView.
+     *
+     * @param context A context
+     * @param listView A list view
+     * @param adapter An array adapter which contains an array of items.
+     * @return A map of items and snapshots of their views in the given list view.
+     */
+    public static <E> HashMap<E, BitmapDrawable> getItemBitmapMap(Context context,
+            ListView listView, ArrayAdapter<E> adapter) {
+        HashMap<E, BitmapDrawable> itemBitmapMap = new HashMap<>();
+        int firstVisiblePosition = listView.getFirstVisiblePosition();
+        for (int i = 0; i < listView.getChildCount(); ++i) {
+            int position = firstVisiblePosition + i;
+            E item = adapter.getItem(position);
+            View view = listView.getChildAt(i);
+            itemBitmapMap.put(item, getViewBitmap(context, view));
+        }
+        return itemBitmapMap;
+    }
+
+    private static BitmapDrawable getViewBitmap(Context context, View view) {
+        Bitmap bitmap = Bitmap.createBitmap(view.getWidth(), view.getHeight(),
+                Bitmap.Config.ARGB_8888);
+        Canvas canvas = new Canvas(bitmap);
+        view.draw(canvas);
+        return new BitmapDrawable(context.getResources(), bitmap);
+    }
 }
diff --git a/v7/mediarouter/src/android/support/v7/app/OverlayListView.java b/v7/mediarouter/src/android/support/v7/app/OverlayListView.java
new file mode 100644
index 0000000..ef322fd
--- /dev/null
+++ b/v7/mediarouter/src/android/support/v7/app/OverlayListView.java
@@ -0,0 +1,265 @@
+/*
+ * Copyright (C) 2016 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 android.support.v7.app;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.BitmapDrawable;
+import android.util.AttributeSet;
+import android.view.animation.Interpolator;
+import android.widget.ListView;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * A ListView which has an additional overlay layer. {@link BitmapDrawable}
+ * can be added to the layer and can be animated.
+ */
+final class OverlayListView extends ListView {
+    private final List<OverlayObject> mOverlayObjects = new ArrayList<>();
+
+    public OverlayListView(Context context) {
+        super(context);
+    }
+
+    public OverlayListView(Context context, AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    public OverlayListView(Context context, AttributeSet attrs, int defStyleAttr) {
+        super(context, attrs, defStyleAttr);
+    }
+
+    /**
+     * Adds an object to the overlay layer.
+     *
+     * @param object An object to be added.
+     */
+    public void addOverlayObject(OverlayObject object) {
+        mOverlayObjects.add(object);
+    }
+
+    /**
+     * Starts all animations of objects in the overlay layer.
+     */
+    public void startAnimationAll() {
+        for (OverlayObject object : mOverlayObjects) {
+            if (!object.isAnimationStarted()) {
+                object.startAnimation(getDrawingTime());
+            }
+        }
+    }
+
+    /**
+     * Stops all animations of objects in the overlay layer.
+     */
+    public void stopAnimationAll() {
+        for (OverlayObject object : mOverlayObjects) {
+            object.stopAnimation();
+        }
+    }
+
+    @Override
+    public void onDraw(Canvas canvas) {
+        super.onDraw(canvas);
+        if (mOverlayObjects.size() > 0) {
+            Iterator<OverlayObject> it = mOverlayObjects.iterator();
+            while (it.hasNext()) {
+                OverlayObject object = it.next();
+                BitmapDrawable bitmap = object.getBitmapDrawable();
+                if (bitmap != null) {
+                    bitmap.draw(canvas);
+                }
+                if (!object.update(getDrawingTime())) {
+                    it.remove();
+                }
+            }
+        }
+    }
+
+    /**
+     * A class that represents an object to be shown in the overlay layer.
+     */
+    public static class OverlayObject {
+        private BitmapDrawable mBitmap;
+        private float mCurrentAlpha = 1.0f;
+        private Rect mCurrentBounds;
+        private Interpolator mInterpolator;
+        private long mDuration;
+        private Rect mStartRect;
+        private int mDeltaY;
+        private float mStartAlpha = 1.0f;
+        private float mEndAlpha = 1.0f;
+        private long mStartTime;
+        private boolean mIsAnimationStarted;
+        private boolean mIsAnimationEnded;
+        private OnAnimationEndListener mListener;
+
+        public OverlayObject(BitmapDrawable bitmap, Rect startRect) {
+            mBitmap = bitmap;
+            mStartRect = startRect;
+            mCurrentBounds = new Rect(startRect);
+            if (mBitmap != null && mCurrentBounds != null) {
+                mBitmap.setAlpha((int) (mCurrentAlpha * 255));
+                mBitmap.setBounds(mCurrentBounds);
+            }
+        }
+
+        /**
+         * Returns the bitmap that this object represents.
+         *
+         * @return BitmapDrawable that this object has.
+         */
+        public BitmapDrawable getBitmapDrawable() {
+            return mBitmap;
+        }
+
+        /**
+         * Returns the started status of the animation.
+         *
+         * @return True if the animation has started, false otherwise.
+         */
+        public boolean isAnimationStarted() {
+            return mIsAnimationStarted;
+        }
+
+        /**
+         * Sets animation for varying alpha.
+         *
+         * @param startAlpha Starting alpha value for the animation, where 1.0 means
+         * fully opaque and 0.0 means fully transparent.
+         * @param endAlpha Ending alpha value for the animation.
+         * @return This OverlayObject to allow for chaining of calls.
+         */
+        public OverlayObject setAlphaAnimation(float startAlpha, float endAlpha) {
+            mStartAlpha = startAlpha;
+            mEndAlpha = endAlpha;
+            return this;
+        }
+
+        /**
+         * Sets animation for moving objects vertically.
+         *
+         * @param deltaY Distance to move in pixels.
+         * @return This OverlayObject to allow for chaining of calls.
+         */
+        public OverlayObject setTranslateYAnimation(int deltaY) {
+            mDeltaY = deltaY;
+            return this;
+        }
+
+        /**
+         * Sets how long the animation will last.
+         *
+         * @param duration Duration in milliseconds
+         * @return This OverlayObject to allow for chaining of calls.
+         */
+        public OverlayObject setDuration(long duration) {
+            mDuration = duration;
+            return this;
+        }
+
+        /**
+         * Sets the acceleration curve for this animation.
+         *
+         * @param interpolator The interpolator which defines the acceleration curve
+         * @return This OverlayObject to allow for chaining of calls.
+         */
+        public OverlayObject setInterpolator(Interpolator interpolator) {
+            mInterpolator = interpolator;
+            return this;
+        }
+
+        /**
+         * Binds an animation end listener to the animation.
+         *
+         * @param listener the animation end listener to be notified.
+         * @return This OverlayObject to allow for chaining of calls.
+         */
+        public OverlayObject setAnimationEndListener(OnAnimationEndListener listener) {
+            mListener = listener;
+            return this;
+        }
+
+        /**
+         * Starts the animation and sets the start time.
+         *
+         * @param startTime Start time to be set in Millis
+         */
+        public void startAnimation(long startTime) {
+            mStartTime = startTime;
+            mIsAnimationStarted = true;
+        }
+
+        /**
+         * Stops the animation.
+         */
+        public void stopAnimation() {
+            mIsAnimationStarted = true;
+            mIsAnimationEnded = true;
+            if (mListener != null) {
+                mListener.onAnimationEnd();
+            }
+        }
+
+        /**
+         * Calculates and updates current bounds and alpha value.
+         *
+         * @param currentTime Current time.in millis
+         */
+        public boolean update(long currentTime) {
+            if (mIsAnimationEnded) {
+                return false;
+            }
+            float normalizedTime = (currentTime - mStartTime) / (float) mDuration;
+            normalizedTime = Math.max(0.0f, Math.min(1.0f, normalizedTime));
+            if (!mIsAnimationStarted) {
+                normalizedTime = 0.0f;
+            }
+            float interpolatedTime = (mInterpolator == null) ? normalizedTime
+                    : mInterpolator.getInterpolation(normalizedTime);
+            int deltaY = (int) (mDeltaY * interpolatedTime);
+            mCurrentBounds.top = mStartRect.top + deltaY;
+            mCurrentBounds.bottom = mStartRect.bottom + deltaY;
+            mCurrentAlpha = mStartAlpha + (mEndAlpha - mStartAlpha) * interpolatedTime;
+            if (mBitmap != null && mCurrentBounds != null) {
+                mBitmap.setAlpha((int) (mCurrentAlpha * 255));
+                mBitmap.setBounds(mCurrentBounds);
+            }
+            if (mIsAnimationStarted && normalizedTime >= 1.0f) {
+                mIsAnimationEnded = true;
+                if (mListener != null) {
+                    mListener.onAnimationEnd();
+                }
+            }
+            return !mIsAnimationEnded;
+        }
+
+        /**
+         * An animation listener that receives notifications when the animation ends.
+         */
+        public interface OnAnimationEndListener {
+            /**
+             * Notifies the end of the animation.
+             */
+            public void onAnimationEnd();
+        }
+    }
+}