Adding animation post-installing a shortcut.

Change-Id: I63bb3b713fab28a43e61333dd331dbf2d211faa7
diff --git a/src/com/android/launcher2/InstallShortcutReceiver.java b/src/com/android/launcher2/InstallShortcutReceiver.java
index e04ce64..4c0974f 100644
--- a/src/com/android/launcher2/InstallShortcutReceiver.java
+++ b/src/com/android/launcher2/InstallShortcutReceiver.java
@@ -17,9 +17,9 @@
 package com.android.launcher2;
 
 import android.content.BroadcastReceiver;
-import android.content.ComponentName;
 import android.content.Context;
 import android.content.Intent;
+import android.content.SharedPreferences;
 import android.content.pm.ActivityInfo;
 import android.content.pm.PackageManager;
 import android.widget.Toast;
@@ -27,10 +27,21 @@
 import com.android.launcher.R;
 
 import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Set;
 
 public class InstallShortcutReceiver extends BroadcastReceiver {
     public static final String ACTION_INSTALL_SHORTCUT =
             "com.android.launcher.action.INSTALL_SHORTCUT";
+    public static final String NEW_APPS_PAGE_KEY = "apps.new.page";
+    public static final String NEW_APPS_LIST_KEY = "apps.new.list";
+
+    public static final int NEW_SHORTCUT_BOUNCE_DURATION = 450;
+    public static final int NEW_SHORTCUT_STAGGER_DELAY = 75;
+
+    private static final int INSTALL_SHORTCUT_SUCCESSFUL = 0;
+    private static final int INSTALL_SHORTCUT_IS_DUPLICATE = -1;
+    private static final int INSTALL_SHORTCUT_NO_SPACE = -2;
 
     // A mime-type representing shortcut data
     public static final String SHORTCUT_MIMETYPE =
@@ -42,6 +53,8 @@
         if (!ACTION_INSTALL_SHORTCUT.equals(data.getAction())) {
             return;
         }
+        String spKey = LauncherApplication.getSharedPreferencesKey();
+        SharedPreferences sp = context.getSharedPreferences(spKey, Context.MODE_PRIVATE);
 
         final int screen = Launcher.getScreen();
         final Intent intent = data.getParcelableExtra(Intent.EXTRA_SHORTCUT_INTENT);
@@ -62,26 +75,35 @@
         }
 
         final ArrayList<ItemInfo> items = LauncherModel.getItemsInLocalCoordinates(context);
-        final boolean shortcutExists = LauncherModel.shortcutExists(context, name, intent);
-        final String[] errorMsgs = {""};
+        final boolean exists = LauncherModel.shortcutExists(context, name, intent);
+        final int[] result = {INSTALL_SHORTCUT_SUCCESSFUL};
 
-        if (!installShortcut(context, data, items, name, intent, screen, shortcutExists,
-                errorMsgs)) {
-            // The target screen is full, let's try the other screens
-            for (int i = 0; i < Launcher.SCREEN_COUNT; i++) {
-                if (i != screen && installShortcut(context, data, items, name, intent, i,
-                        shortcutExists, errorMsgs)) break;
+        // Try adding the target to the workspace screens incrementally, starting at the current
+        // screen and alternating between +1, -1, +2, -2, etc. (using ~ ceil(i/2f)*(-1)^(i-1))
+        boolean found = false;
+        for (int i = 0; i < (2 * Launcher.SCREEN_COUNT) + 1 && !found; ++i) {
+            int si = screen + (int) ((i / 2f) + 0.5f) * ((i % 2 == 1) ? 1 : -1);
+            if (0 <= si && si < Launcher.SCREEN_COUNT) {
+                found = installShortcut(context, data, items, name, intent, si, exists, sp, result);
             }
         }
 
-        if (!errorMsgs[0].isEmpty()) {
-            Toast.makeText(context, errorMsgs[0],
-                    Toast.LENGTH_SHORT).show();
+        // We only report error messages (duplicate shortcut or out of space) as the add-animation
+        // will provide feedback otherwise
+        if (!found) {
+            if (result[0] == INSTALL_SHORTCUT_NO_SPACE) {
+                Toast.makeText(context, context.getString(R.string.out_of_space),
+                        Toast.LENGTH_SHORT).show();
+            } else if (result[0] == INSTALL_SHORTCUT_IS_DUPLICATE) {
+                Toast.makeText(context, context.getString(R.string.shortcut_duplicate, name),
+                        Toast.LENGTH_SHORT).show();
+            }
         }
     }
 
     private boolean installShortcut(Context context, Intent data, ArrayList<ItemInfo> items,
-            String name, Intent intent, int screen, boolean shortcutExists, String[] errorMsgs) {
+            String name, Intent intent, int screen, boolean shortcutExists,
+            SharedPreferences sharedPrefs, int[] result) {
         if (findEmptyCell(context, items, mCoordinates, screen)) {
             if (intent != null) {
                 if (intent.getAction() == null) {
@@ -92,23 +114,35 @@
                 // different places)
                 boolean duplicate = data.getBooleanExtra(Launcher.EXTRA_SHORTCUT_DUPLICATE, true);
                 if (duplicate || !shortcutExists) {
+                    // If the new app is going to fall into the same page as before, then just
+                    // continue adding to the current page
+                    int newAppsScreen = sharedPrefs.getInt(NEW_APPS_PAGE_KEY, screen);
+                    Set<String> newApps = new HashSet<String>();
+                    if (newAppsScreen == screen) {
+                        newApps = sharedPrefs.getStringSet(NEW_APPS_LIST_KEY, newApps);
+                    }
+                    newApps.add(intent.toUri(0).toString());
+                    sharedPrefs.edit()
+                               .putInt(NEW_APPS_PAGE_KEY, screen)
+                               .putStringSet(NEW_APPS_LIST_KEY, newApps)
+                               .commit();
+
+                    // Update the Launcher db
                     LauncherApplication app = (LauncherApplication) context.getApplicationContext();
                     ShortcutInfo info = app.getModel().addShortcut(context, data,
-                            LauncherSettings.Favorites.CONTAINER_DESKTOP, screen, mCoordinates[0],
-                            mCoordinates[1], true);
-                    if (info != null) {
-                        errorMsgs[0] = context.getString(R.string.shortcut_installed, name);
-                    } else {
+                            LauncherSettings.Favorites.CONTAINER_DESKTOP, screen,
+                            mCoordinates[0], mCoordinates[1], true);
+                    if (info == null) {
                         return false;
                     }
                 } else {
-                    errorMsgs[0] = context.getString(R.string.shortcut_duplicate, name);
+                    result[0] = INSTALL_SHORTCUT_IS_DUPLICATE;
                 }
 
                 return true;
             }
         } else {
-            errorMsgs[0] = context.getString(R.string.out_of_space);
+            result[0] = INSTALL_SHORTCUT_NO_SPACE;
         }
 
         return false;
diff --git a/src/com/android/launcher2/Launcher.java b/src/com/android/launcher2/Launcher.java
index 0c1b76f..9494d27 100644
--- a/src/com/android/launcher2/Launcher.java
+++ b/src/com/android/launcher2/Launcher.java
@@ -55,6 +55,7 @@
 import android.net.Uri;
 import android.os.AsyncTask;
 import android.os.Bundle;
+import android.os.Debug;
 import android.os.Environment;
 import android.os.Handler;
 import android.os.Message;
@@ -84,6 +85,7 @@
 import android.view.accessibility.AccessibilityEvent;
 import android.view.animation.AccelerateDecelerateInterpolator;
 import android.view.animation.AccelerateInterpolator;
+import android.view.animation.BounceInterpolator;
 import android.view.animation.DecelerateInterpolator;
 import android.view.inputmethod.InputMethodManager;
 import android.widget.Advanceable;
@@ -103,7 +105,12 @@
 import java.io.IOException;
 import java.io.PrintWriter;
 import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Set;
 
 /**
  * Default launcher application.
@@ -253,6 +260,10 @@
     // it from the context.
     private SharedPreferences mSharedPrefs;
 
+    // Holds the page that we need to animate to, and the icon views that we need to animate up
+    // when we scroll to that page on resume.
+    private int mNewShortcutAnimatePage = -1;
+    private ArrayList<View> mNewShortcutAnimateViews = new ArrayList<View>();
 
     private BubbleTextView mWaitingForResume;
 
@@ -280,7 +291,8 @@
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         LauncherApplication app = ((LauncherApplication)getApplication());
-        mSharedPrefs = getSharedPreferences(PREFS_KEY, Context.MODE_PRIVATE);
+        mSharedPrefs = getSharedPreferences(LauncherApplication.getSharedPreferencesKey(),
+                Context.MODE_PRIVATE);
         mModel = app.setLauncher(this);
         mIconCache = app.getIconCache();
         mDragController = new DragController(this);
@@ -317,7 +329,7 @@
         }
 
         if (!mRestoring) {
-            mModel.startLoader(this, true);
+            mModel.startLoader(true);
         }
 
         if (!mModel.isAllAppsLoaded()) {
@@ -593,10 +605,11 @@
     @Override
     protected void onResume() {
         super.onResume();
+
         mPaused = false;
         if (mRestoring || mOnResumeNeedsLoad) {
             mWorkspaceLoading = true;
-            mModel.startLoader(this, true);
+            mModel.startLoader(true);
             mRestoring = false;
             mOnResumeNeedsLoad = false;
         }
@@ -725,7 +738,7 @@
             showAllApps(false);
         }
 
-        final int currentScreen = savedState.getInt(RUNTIME_STATE_CURRENT_SCREEN, -1);
+        int currentScreen = savedState.getInt(RUNTIME_STATE_CURRENT_SCREEN, -1);
         if (currentScreen > -1) {
             mWorkspace.setCurrentPage(currentScreen);
         }
@@ -2184,7 +2197,7 @@
 
                 if (mWorkspaceLoading) {
                     lockAllApps();
-                    mModel.startLoader(Launcher.this, false);
+                    mModel.startLoader(false);
                 } else {
                     final FolderIcon folderIcon = (FolderIcon)
                             mWorkspace.getViewForTag(mFolderInfo);
@@ -2196,7 +2209,7 @@
                     } else {
                         lockAllApps();
                         mWorkspaceLoading = true;
-                        mModel.startLoader(Launcher.this, false);
+                        mModel.startLoader(false);
                     }
                 }
             }
@@ -3076,7 +3089,6 @@
         }
     }
 
-
     /**
      * Refreshes the shortcuts shown on the workspace.
      *
@@ -3085,6 +3097,8 @@
     public void startBinding() {
         final Workspace workspace = mWorkspace;
 
+        mNewShortcutAnimatePage = -1;
+        mNewShortcutAnimateViews.clear();
         mWorkspace.clearDropTargets();
         int count = workspace.getChildCount();
         for (int i = 0; i < count; i++) {
@@ -3106,8 +3120,12 @@
     public void bindItems(ArrayList<ItemInfo> shortcuts, int start, int end) {
         setLoadOnResume();
 
-        final Workspace workspace = mWorkspace;
-        for (int i=start; i<end; i++) {
+        // Get the list of added shortcuts and intersect them with the set of shortcuts here
+        Set<String> newApps = new HashSet<String>();
+        newApps = mSharedPrefs.getStringSet(InstallShortcutReceiver.NEW_APPS_LIST_KEY, newApps);
+
+        Workspace workspace = mWorkspace;
+        for (int i = start; i < end; i++) {
             final ItemInfo item = shortcuts.get(i);
 
             // Short circuit if we are loading dock items for a configuration which has no dock
@@ -3119,9 +3137,23 @@
             switch (item.itemType) {
                 case LauncherSettings.Favorites.ITEM_TYPE_APPLICATION:
                 case LauncherSettings.Favorites.ITEM_TYPE_SHORTCUT:
-                    View shortcut = createShortcut((ShortcutInfo)item);
+                    ShortcutInfo info = (ShortcutInfo) item;
+                    String uri = info.intent.toUri(0).toString();
+                    View shortcut = createShortcut(info);
                     workspace.addInScreen(shortcut, item.container, item.screen, item.cellX,
                             item.cellY, 1, 1, false);
+                    if (newApps.contains(uri)) {
+                        newApps.remove(uri);
+
+                        // Prepare the view to be animated up
+                        shortcut.setAlpha(0f);
+                        shortcut.setScaleX(0f);
+                        shortcut.setScaleY(0f);
+                        mNewShortcutAnimatePage = item.screen;
+                        if (!mNewShortcutAnimateViews.contains(shortcut)) {
+                            mNewShortcutAnimateViews.add(shortcut);
+                        }
+                    }
                     break;
                 case LauncherSettings.Favorites.ITEM_TYPE_FOLDER:
                     FolderIcon newFolder = FolderIcon.fromXml(R.layout.folder_icon, this,
@@ -3132,6 +3164,7 @@
                     break;
             }
         }
+
         workspace.requestLayout();
     }
 
@@ -3169,11 +3202,6 @@
         item.hostView.setAppWidget(appWidgetId, appWidgetInfo);
         item.hostView.setTag(item);
 
-        // We need to load the minimum span and embed it into the item info
-        int[] minSpan = getMinSpanForWidget(appWidgetInfo, null);
-        item.minSpanX = minSpan[0];
-        item.minSpanY = minSpan[1];
-
         workspace.addInScreen(item.hostView, item.container, item.screen, item.cellX,
                 item.cellY, item.spanX, item.spanY, false);
 
@@ -3207,8 +3235,6 @@
             mSavedInstanceState = null;
         }
 
-        mWorkspaceLoading = false;
-
         // If we received the result of any pending adds while the loader was running (e.g. the
         // widget configuration forced an orientation change), process them now.
         for (int i = 0; i < sPendingAddList.size(); i++) {
@@ -3220,7 +3246,72 @@
         // package changes in bindSearchablesChanged()
         updateAppMarketIcon();
 
-        mWorkspace.postDelayed(mBuildLayersRunnable, 500);
+        // Animate up any icons as necessary
+        if (mVisible || mWorkspaceLoading) {
+            Runnable newAppsRunnable = new Runnable() {
+                @Override
+                public void run() {
+                    runNewAppsAnimation();
+                }
+            };
+            if (mNewShortcutAnimatePage > -1 &&
+                    mNewShortcutAnimatePage != mWorkspace.getCurrentPage()) {
+                mWorkspace.snapToPage(mNewShortcutAnimatePage, newAppsRunnable);
+            } else {
+                newAppsRunnable.run();
+            }
+        }
+
+        mWorkspaceLoading = false;
+    }
+
+    /**
+     * Runs a new animation that scales up icons that were added while Launcher was in the
+     * background.
+     */
+    private void runNewAppsAnimation() {
+        AnimatorSet anim = new AnimatorSet();
+        Collection<Animator> bounceAnims = new ArrayList<Animator>();
+        Collections.sort(mNewShortcutAnimateViews, new Comparator<View>() {
+            @Override
+            public int compare(View a, View b) {
+                CellLayout.LayoutParams alp = (CellLayout.LayoutParams) a.getLayoutParams();
+                CellLayout.LayoutParams blp = (CellLayout.LayoutParams) b.getLayoutParams();
+                int cellCountX = LauncherModel.getCellCountX();
+                return (alp.cellY * cellCountX + alp.cellX) - (blp.cellY * cellCountX + blp.cellX);
+            }
+        });
+        for (int i = 0; i < mNewShortcutAnimateViews.size(); ++i) {
+            View v = mNewShortcutAnimateViews.get(i);
+            ValueAnimator bounceAnim = ObjectAnimator.ofPropertyValuesHolder(v,
+                    PropertyValuesHolder.ofFloat("alpha", 1f),
+                    PropertyValuesHolder.ofFloat("scaleX", 1f),
+                    PropertyValuesHolder.ofFloat("scaleY", 1f));
+            bounceAnim.setDuration(InstallShortcutReceiver.NEW_SHORTCUT_BOUNCE_DURATION);
+            bounceAnim.setStartDelay(i * InstallShortcutReceiver.NEW_SHORTCUT_STAGGER_DELAY);
+            bounceAnim.setInterpolator(new SmoothPagedView.OvershootInterpolator());
+            bounceAnims.add(bounceAnim);
+        }
+        anim.playTogether(bounceAnims);
+        anim.addListener(new AnimatorListenerAdapter() {
+            @Override
+            public void onAnimationEnd(Animator animation) {
+                mWorkspace.postDelayed(mBuildLayersRunnable, 500);
+            }
+        });
+        anim.start();
+
+        // Clean up
+        mNewShortcutAnimatePage = -1;
+        mNewShortcutAnimateViews.clear();
+        new Thread("clearNewAppsThread") {
+            public void run() {
+                mSharedPrefs.edit()
+                            .putInt(InstallShortcutReceiver.NEW_APPS_PAGE_KEY, -1)
+                            .putStringSet(InstallShortcutReceiver.NEW_APPS_LIST_KEY, null)
+                            .commit();
+            }
+        }.start();
     }
 
     @Override
@@ -3363,7 +3454,6 @@
     }
 
     /* Cling related */
-    private static final String PREFS_KEY = "com.android.launcher2.prefs";
     private boolean isClingsEnabled() {
         // disable clings when running in a test harness
         if(ActivityManager.isRunningInTestHarness()) return false;
diff --git a/src/com/android/launcher2/LauncherApplication.java b/src/com/android/launcher2/LauncherApplication.java
index 47ce0b7..ef1eb5f 100644
--- a/src/com/android/launcher2/LauncherApplication.java
+++ b/src/com/android/launcher2/LauncherApplication.java
@@ -37,6 +37,7 @@
     private static boolean sIsScreenLarge;
     private static float sScreenDensity;
     private static int sLongPressTimeout = 300;
+    private static final String sSharedPreferencesKey = "com.android.launcher2.prefs";
     WeakReference<LauncherProvider> mLauncherProvider;
 
     @Override
@@ -94,7 +95,10 @@
     private final ContentObserver mFavoritesObserver = new ContentObserver(new Handler()) {
         @Override
         public void onChange(boolean selfChange) {
-            mModel.startLoader(LauncherApplication.this, false);
+            // If the database has ever changed, then we really need to force a reload of the
+            // workspace on the next load
+            mModel.resetLoadedState(false, true);
+            mModel.startLoaderFromBackground();
         }
     };
 
@@ -119,6 +123,10 @@
         return mLauncherProvider.get();
     }
 
+    public static String getSharedPreferencesKey() {
+        return sSharedPreferencesKey;
+    }
+
     public static boolean isScreenLarge() {
         return sIsScreenLarge;
     }
diff --git a/src/com/android/launcher2/LauncherModel.java b/src/com/android/launcher2/LauncherModel.java
index 159ddb0..30eb86c 100644
--- a/src/com/android/launcher2/LauncherModel.java
+++ b/src/com/android/launcher2/LauncherModel.java
@@ -649,19 +649,24 @@
     }
 
     private void forceReload() {
-        synchronized (mLock) {
-            // Stop any existing loaders first, so they don't set mAllAppsLoaded or
-            // mWorkspaceLoaded to true later
-            stopLoaderLocked();
-            mAllAppsLoaded = false;
-            mWorkspaceLoaded = false;
-        }
+        resetLoadedState(true, true);
+
         // Do this here because if the launcher activity is running it will be restarted.
         // If it's not running startLoaderFromBackground will merely tell it that it needs
         // to reload.
         startLoaderFromBackground();
     }
 
+    public void resetLoadedState(boolean resetAllAppsLoaded, boolean resetWorkspaceLoaded) {
+        synchronized (mLock) {
+            // Stop any existing loaders first, so they don't set mAllAppsLoaded or
+            // mWorkspaceLoaded to true later
+            stopLoaderLocked();
+            if (resetAllAppsLoaded) mAllAppsLoaded = false;
+            if (resetWorkspaceLoaded) mWorkspaceLoaded = false;
+        }
+    }
+
     /**
      * When the launcher is in the background, it's possible for it to miss paired
      * configuration changes.  So whenever we trigger the loader from the background
@@ -680,7 +685,7 @@
             }
         }
         if (runLoader) {
-            startLoader(mApp, false);
+            startLoader(false);
         }
     }
 
@@ -698,7 +703,7 @@
         return isLaunching;
     }
 
-    public void startLoader(Context context, boolean isLaunching) {
+    public void startLoader(boolean isLaunching) {
         synchronized (mLock) {
             if (DEBUG_LOADERS) {
                 Log.d(TAG, "startLoader isLaunching=" + isLaunching);
@@ -709,7 +714,7 @@
                 // If there is already one running, tell it to stop.
                 // also, don't downgrade isLaunching if we're already running
                 isLaunching = isLaunching || stopLoaderLocked();
-                mLoaderTask = new LoaderTask(context, isLaunching);
+                mLoaderTask = new LoaderTask(mApp, isLaunching);
                 sWorkerThread.setPriority(Thread.NORM_PRIORITY);
                 sWorker.post(mLoaderTask);
             }
diff --git a/src/com/android/launcher2/PagedView.java b/src/com/android/launcher2/PagedView.java
index 0854508..5434704 100644
--- a/src/com/android/launcher2/PagedView.java
+++ b/src/com/android/launcher2/PagedView.java
@@ -62,7 +62,8 @@
     // the min drag distance for a fling to register, to prevent random page shifts
     private static final int MIN_LENGTH_FOR_FLING = 25;
 
-    private static final int PAGE_SNAP_ANIMATION_DURATION = 550;
+    protected static final int PAGE_SNAP_ANIMATION_DURATION = 550;
+    protected static final int SLOW_PAGE_SNAP_ANIMATION_DURATION = 950;
     protected static final float NANOTIME_DIV = 1000000000.0f;
 
     private static final float OVERSCROLL_ACCELERATE_FACTOR = 2;
diff --git a/src/com/android/launcher2/SmoothPagedView.java b/src/com/android/launcher2/SmoothPagedView.java
index fe763f5..e6414d9 100644
--- a/src/com/android/launcher2/SmoothPagedView.java
+++ b/src/com/android/launcher2/SmoothPagedView.java
@@ -35,11 +35,11 @@
 
     private Interpolator mScrollInterpolator;
 
-    private static class WorkspaceOvershootInterpolator implements Interpolator {
+    public static class OvershootInterpolator implements Interpolator {
         private static final float DEFAULT_TENSION = 1.3f;
         private float mTension;
 
-        public WorkspaceOvershootInterpolator() {
+        public OvershootInterpolator() {
             mTension = DEFAULT_TENSION;
         }
 
@@ -101,7 +101,7 @@
         if (mScrollMode == DEFAULT_MODE) {
             mBaseLineFlingVelocity = 2500.0f;
             mFlingVelocityInfluence = 0.4f;
-            mScrollInterpolator = new WorkspaceOvershootInterpolator();
+            mScrollInterpolator = new OvershootInterpolator();
             mScroller = new Scroller(getContext(), mScrollInterpolator);
         }
     }
@@ -139,9 +139,9 @@
         }
 
         if (settle) {
-            ((WorkspaceOvershootInterpolator) mScrollInterpolator).setDistance(screenDelta);
+            ((OvershootInterpolator) mScrollInterpolator).setDistance(screenDelta);
         } else {
-            ((WorkspaceOvershootInterpolator) mScrollInterpolator).disableSettle();
+            ((OvershootInterpolator) mScrollInterpolator).disableSettle();
         }
 
         velocity = Math.abs(velocity);
diff --git a/src/com/android/launcher2/Workspace.java b/src/com/android/launcher2/Workspace.java
index 8f11612..cec47c7 100644
--- a/src/com/android/launcher2/Workspace.java
+++ b/src/com/android/launcher2/Workspace.java
@@ -183,6 +183,7 @@
     WallpaperOffsetInterpolator mWallpaperOffset;
     boolean mUpdateWallpaperOffsetImmediately = false;
     private Runnable mDelayedResizeRunnable;
+    private Runnable mDelayedSnapToPageRunnable;
     private int mDisplayWidth;
     private int mDisplayHeight;
     private boolean mIsStaticWallpaper;
@@ -765,6 +766,11 @@
             mDelayedResizeRunnable.run();
             mDelayedResizeRunnable = null;
         }
+
+        if (mDelayedSnapToPageRunnable != null) {
+            mDelayedSnapToPageRunnable.run();
+            mDelayedSnapToPageRunnable = null;
+        }
     }
 
     @Override
@@ -906,6 +912,20 @@
         computeWallpaperScrollRatio(whichPage);
     }
 
+    @Override
+    protected void snapToPage(int whichPage, int duration) {
+        super.snapToPage(whichPage, duration);
+        computeWallpaperScrollRatio(whichPage);
+    }
+
+    protected void snapToPage(int whichPage, Runnable r) {
+        if (mDelayedSnapToPageRunnable != null) {
+            mDelayedSnapToPageRunnable.run();
+        }
+        mDelayedSnapToPageRunnable = r;
+        snapToPage(whichPage, SLOW_PAGE_SNAP_ANIMATION_DURATION);
+    }
+
     private void computeWallpaperScrollRatio(int page) {
         // Here, we determine what the desired scroll would be with and without a layout scale,
         // and compute a ratio between the two. This allows us to adjust the wallpaper offset