Bug 1293790 - Implement Paged TopSites View r=sebastian
authorAndrzej Hunt <ahunt@mozilla.com>
Tue, 30 Aug 2016 09:59:08 -0700
changeset 353215 326bcca7b6b6c7a3a9e3c2c6bb87f3385c92867a
parent 353214 4be070f03d4761aa5d06a8686b4daaef4213b5c1
child 353216 048e2f46f7436a8623257b90178e4a96bf390b67
push id6570
push userraliiev@mozilla.com
push dateMon, 14 Nov 2016 12:26:13 +0000
treeherdermozilla-beta@f455459b2ae5 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerssebastian
bugs1293790
milestone51.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1293790 - Implement Paged TopSites View r=sebastian This uses a ViewPager, with each page containing a grid managed by a separate RecyclerView. One main adapter splits the data into appropriately sized groups for each RecyclerView to handle. MozReview-Commit-ID: 9XGuw0NckD4
mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStream.java
mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamItem.java
mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamRecyclerAdapter.java
mobile/android/base/java/org/mozilla/gecko/home/activitystream/TopSitesRecyclerAdapter.java
mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesCard.java
mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPage.java
mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPageAdapter.java
mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPagerAdapter.java
mobile/android/base/moz.build
mobile/android/base/resources/layout/activity_stream.xml
mobile/android/base/resources/layout/activity_stream_card_top_sites_item.xml
mobile/android/base/resources/layout/activity_stream_main_toppanel.xml
mobile/android/base/resources/layout/activity_stream_topsites_card.xml
mobile/android/base/resources/layout/activity_stream_topsites_page.xml
--- a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStream.java
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStream.java
@@ -17,20 +17,24 @@ import android.widget.FrameLayout;
 
 import org.mozilla.gecko.GeckoProfile;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.animation.PropertyAnimator;
 import org.mozilla.gecko.home.HomeBanner;
 import org.mozilla.gecko.home.HomeFragment;
 import org.mozilla.gecko.home.HomeScreen;
 import org.mozilla.gecko.home.SimpleCursorLoader;
+import org.mozilla.gecko.home.activitystream.topsites.TopSitesPagerAdapter;
 
 public class ActivityStream extends FrameLayout implements HomeScreen {
     private StreamRecyclerAdapter adapter;
 
+    private static final int LOADER_ID_HIGHLIGHTS = 0;
+    private static final int LOADER_ID_TOPSITES = 1;
+
     public ActivityStream(Context context, AttributeSet attrs) {
         super(context, attrs);
 
         inflate(context, R.layout.as_content, this);
     }
 
     @Override
     public boolean isVisible() {
@@ -66,26 +70,32 @@ public class ActivityStream extends Fram
     }
 
     @Override
     public void load(LoaderManager lm, FragmentManager fm, String panelId, Bundle restoreData,
                      PropertyAnimator animator) {
         // Signal to load data from storage as needed, compare with HomePager
         RecyclerView rv = (RecyclerView) findViewById(R.id.activity_stream_main_recyclerview);
 
-        adapter = new StreamRecyclerAdapter();
+        // TODO: we need to retrieve BrowserApp and pass it in as onUrlOpenListener. That will
+        // be simpler once we're a HomeFragment, but isn't so simple while we're still a View.
+        adapter = new StreamRecyclerAdapter(lm, null);
         rv.setAdapter(adapter);
         rv.setLayoutManager(new LinearLayoutManager(getContext()));
         rv.setHasFixedSize(true);
 
-        lm.initLoader(0, null, new CursorLoaderCallbacks());
+        CursorLoaderCallbacks callbacks = new CursorLoaderCallbacks();
+        lm.initLoader(LOADER_ID_HIGHLIGHTS, null, callbacks);
+        lm.initLoader(LOADER_ID_TOPSITES, null, callbacks);
     }
 
     @Override
     public void unload() {
+        adapter.swapHighlightsCursor(null);
+        adapter.swapTopSitesCursor(null);
         // Signal to clear data that has been loaded, compare with HomePager
     }
 
     /**
      * This is a temporary cursor loader. We'll probably need a completely new query for AS,
      * at that time we can switch to the new CursorLoader, as opposed to using our outdated
      * SimpleCursorLoader.
      */
@@ -100,22 +110,37 @@ public class ActivityStream extends Fram
             return GeckoProfile.get(context).getDB()
                     .getRecentHistory(context.getContentResolver(), 10);
         }
     }
 
     private class CursorLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
         @Override
         public Loader<Cursor> onCreateLoader(int id, Bundle args) {
-            return new HistoryLoader(getContext());
+            if (id == LOADER_ID_HIGHLIGHTS) {
+                return new HistoryLoader(getContext());
+            } else if (id == LOADER_ID_TOPSITES) {
+                return GeckoProfile.get(getContext()).getDB().getActivityStreamTopSites(getContext(),
+                        TopSitesPagerAdapter.TOTAL_ITEMS);
+            } else {
+                throw new IllegalArgumentException("Can't handle loader id " + id);
+            }
         }
 
         @Override
         public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
-            adapter.swapCursor(data);
+            if (loader.getId() == LOADER_ID_HIGHLIGHTS) {
+                adapter.swapHighlightsCursor(data);
+            } else if (loader.getId() == LOADER_ID_TOPSITES) {
+                adapter.swapTopSitesCursor(data);
+            }
         }
 
         @Override
         public void onLoaderReset(Loader<Cursor> loader) {
-            adapter.swapCursor(null);
+            if (loader.getId() == LOADER_ID_HIGHLIGHTS) {
+                adapter.swapHighlightsCursor(null);
+            } else if (loader.getId() == LOADER_ID_TOPSITES) {
+                adapter.swapTopSitesCursor(null);
+            }
         }
     }
 }
--- a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamItem.java
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamItem.java
@@ -1,38 +1,50 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
  * This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 package org.mozilla.gecko.home.activitystream;
 
 import android.database.Cursor;
+import android.support.v4.view.ViewPager;
 import android.support.v7.widget.RecyclerView;
 import android.text.format.DateUtils;
 import android.view.View;
 import android.widget.ImageView;
 import android.widget.TextView;
 
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.home.HomePager;
+import org.mozilla.gecko.home.activitystream.topsites.TopSitesPagerAdapter;
 
 public abstract class StreamItem extends RecyclerView.ViewHolder {
     public StreamItem(View itemView) {
         super(itemView);
     }
 
     public void bind(Cursor cursor) {
         throw new IllegalStateException("Cannot bind " + this.getClass().getSimpleName());
     }
 
     public static class TopPanel extends StreamItem {
         public static final int LAYOUT_ID = R.layout.activity_stream_main_toppanel;
+        private final ViewPager topSitesPager;
 
-        public TopPanel(View itemView) {
+        public TopPanel(View itemView, HomePager.OnUrlOpenListener onUrlOpenListener) {
             super(itemView);
+
+            topSitesPager = (ViewPager) itemView.findViewById(R.id.topsites_pager);
+            topSitesPager.setAdapter(new TopSitesPagerAdapter(itemView.getContext(), onUrlOpenListener));
+        }
+
+        @Override
+        public void bind(Cursor cursor) {
+            ((TopSitesPagerAdapter) topSitesPager.getAdapter()).swapCursor(cursor);
         }
     }
 
     public static class BottomPanel extends StreamItem {
         public static final int LAYOUT_ID = R.layout.activity_stream_main_bottompanel;
 
         public BottomPanel(View itemView) {
             super(itemView);
--- a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamRecyclerAdapter.java
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamRecyclerAdapter.java
@@ -1,26 +1,39 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
  * This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 package org.mozilla.gecko.home.activitystream;
 
 import android.database.Cursor;
+import android.support.v4.app.LoaderManager;
 import android.support.v7.widget.RecyclerView;
 import android.view.LayoutInflater;
 import android.view.ViewGroup;
 
+import org.mozilla.gecko.home.HomePager;
 import org.mozilla.gecko.home.activitystream.StreamItem.BottomPanel;
 import org.mozilla.gecko.home.activitystream.StreamItem.CompactItem;
 import org.mozilla.gecko.home.activitystream.StreamItem.HighlightItem;
 import org.mozilla.gecko.home.activitystream.StreamItem.TopPanel;
 
+import java.lang.ref.WeakReference;
+
 public class StreamRecyclerAdapter extends RecyclerView.Adapter<StreamItem> {
     private Cursor highlightsCursor;
+    private Cursor topSitesCursor;
+
+    private final WeakReference<LoaderManager> loaderManagerWeakReference;
+    private final HomePager.OnUrlOpenListener onUrlOpenListener;
+
+    StreamRecyclerAdapter(LoaderManager lm, HomePager.OnUrlOpenListener onUrlOpenListener) {
+        loaderManagerWeakReference = new WeakReference<>(lm);
+        this.onUrlOpenListener = onUrlOpenListener;
+    }
 
     @Override
     public int getItemViewType(int position) {
         if (position == 0) {
             return TopPanel.LAYOUT_ID;
         } else if (position == getItemCount() - 1) {
             return BottomPanel.LAYOUT_ID;
         } else {
@@ -33,17 +46,17 @@ public class StreamRecyclerAdapter exten
         }
     }
 
     @Override
     public StreamItem onCreateViewHolder(ViewGroup parent, final int type) {
         final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
 
         if (type == TopPanel.LAYOUT_ID) {
-            return new TopPanel(inflater.inflate(type, parent, false));
+            return new TopPanel(inflater.inflate(type, parent, false), onUrlOpenListener);
         } else if (type == BottomPanel.LAYOUT_ID) {
                 return new BottomPanel(inflater.inflate(type, parent, false));
         } else if (type == CompactItem.LAYOUT_ID) {
             return new CompactItem(inflater.inflate(type, parent, false));
         } else if (type == HighlightItem.LAYOUT_ID) {
             return new HighlightItem(inflater.inflate(type, parent, false));
         } else {
             throw new IllegalStateException("Missing inflation for ViewType " + type);
@@ -66,29 +79,37 @@ public class StreamRecyclerAdapter exten
 
         if (type == CompactItem.LAYOUT_ID ||
             type == HighlightItem.LAYOUT_ID) {
 
             final int cursorPosition = translatePositionToCursor(position);
 
             highlightsCursor.moveToPosition(cursorPosition);
             holder.bind(highlightsCursor);
+        } else if (type == TopPanel.LAYOUT_ID) {
+            holder.bind(topSitesCursor);
         }
     }
 
     @Override
     public int getItemCount() {
         final int highlightsCount;
         if (highlightsCursor != null) {
             highlightsCount = highlightsCursor.getCount();
         } else {
             highlightsCount = 0;
         }
 
         return 2 + highlightsCount;
     }
 
-    public void swapCursor(Cursor cursor) {
+    public void swapHighlightsCursor(Cursor cursor) {
         highlightsCursor = cursor;
 
         notifyDataSetChanged();
     }
+
+    public void swapTopSitesCursor(Cursor cursor) {
+        this.topSitesCursor = cursor;
+
+        notifyItemChanged(0);
+    }
 }
deleted file mode 100644
--- a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/TopSitesRecyclerAdapter.java
+++ /dev/null
@@ -1,55 +0,0 @@
-package org.mozilla.gecko.home.activitystream;
-
-import android.content.Context;
-import android.support.v7.widget.RecyclerView;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.TextView;
-
-import org.mozilla.gecko.R;
-
-class TopSitesRecyclerAdapter extends RecyclerView.Adapter<TopSitesRecyclerAdapter.ViewHolder> {
-
-    private final Context context;
-    private final String[] items = {
-            "FastMail",
-            "Firefox",
-            "Mozilla",
-            "Hacker News",
-            "Github",
-            "YouTube",
-            "Google Maps"
-    };
-
-    TopSitesRecyclerAdapter(Context context) {
-        this.context = context;
-    }
-
-    @Override
-    public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
-        View v = LayoutInflater
-                .from(context)
-                .inflate(R.layout.activity_stream_card_top_sites_item, parent, false);
-        return new ViewHolder(v);
-    }
-
-    @Override
-    public void onBindViewHolder(ViewHolder holder, int position) {
-        holder.vLabel.setText(items[position]);
-    }
-
-    @Override
-    public int getItemCount() {
-        return items.length;
-    }
-
-    static class ViewHolder extends RecyclerView.ViewHolder {
-        TextView vLabel;
-        ViewHolder(View itemView) {
-            super(itemView);
-            vLabel = (TextView) itemView.findViewById(R.id.card_row_label);
-        }
-    }
-}
-
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesCard.java
@@ -0,0 +1,53 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.home.activitystream.topsites;
+
+import android.database.Cursor;
+import android.support.v7.widget.CardView;
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import android.widget.TextView;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.favicons.Favicons;
+import org.mozilla.gecko.home.UpdateViewFaviconLoadedListener;
+import org.mozilla.gecko.widget.FaviconView;
+
+import java.util.EnumSet;
+
+class TopSitesCard extends RecyclerView.ViewHolder {
+    private final FaviconView faviconView;
+
+    private final TextView title;
+    private final View menuButton;
+
+    private final UpdateViewFaviconLoadedListener mFaviconListener;
+
+    private String url;
+
+    private int mLoadFaviconJobId = Favicons.NOT_LOADING;
+
+    public TopSitesCard(CardView card) {
+        super(card);
+
+        faviconView = (FaviconView) card.findViewById(R.id.favicon);
+
+        title = (TextView) card.findViewById(R.id.title);
+        menuButton = card.findViewById(R.id.menu);
+
+        mFaviconListener = new UpdateViewFaviconLoadedListener(faviconView);
+    }
+
+    void bind(Cursor cursor) {
+        this.url = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL));
+
+        title.setText(cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.TITLE)));
+
+        Favicons.cancelFaviconLoad(mLoadFaviconJobId);
+
+        mLoadFaviconJobId = Favicons.getSizedFaviconForPageFromLocal(faviconView.getContext(), url, mFaviconListener);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPage.java
@@ -0,0 +1,50 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.home.activitystream.topsites;
+
+import android.content.Context;
+import android.support.annotation.Nullable;
+import android.support.v7.widget.GridLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.util.AttributeSet;
+import android.view.View;
+
+import org.mozilla.gecko.home.HomePager;
+import org.mozilla.gecko.widget.RecyclerViewClickSupport;
+
+import java.util.EnumSet;
+
+public class TopSitesPage
+        extends RecyclerView
+        implements RecyclerViewClickSupport.OnItemClickListener {
+    public TopSitesPage(Context context,
+                        @Nullable AttributeSet attrs) {
+        super(context, attrs);
+
+        setLayoutManager(new GridLayoutManager(context, TopSitesPagerAdapter.GRID_WIDTH));
+
+        RecyclerViewClickSupport.addTo(this)
+                .setOnItemClickListener(this);
+    }
+
+    private HomePager.OnUrlOpenListener onUrlOpenListener = null;
+
+    public TopSitesPageAdapter getAdapter() {
+        return (TopSitesPageAdapter) super.getAdapter();
+    }
+
+    public void setOnUrlOpenListener(HomePager.OnUrlOpenListener onUrlOpenListener) {
+        this.onUrlOpenListener = onUrlOpenListener;
+    }
+
+    @Override
+    public void onItemClicked(RecyclerView recyclerView, int position, View v) {
+        if (onUrlOpenListener != null) {
+            final String url = getAdapter().getURLForPosition(position);
+
+            onUrlOpenListener.onUrlOpen(url, EnumSet.noneOf(HomePager.OnUrlOpenListener.Flags.class));
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPageAdapter.java
@@ -0,0 +1,118 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.home.activitystream.topsites;
+
+import android.database.Cursor;
+import android.database.CursorWrapper;
+import android.support.annotation.UiThread;
+import android.support.v7.widget.CardView;
+import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract;
+
+public class TopSitesPageAdapter extends RecyclerView.Adapter<TopSitesCard> {
+
+    /**
+     * Cursor wrapper that handles the offsets and limits that we expect.
+     * This allows most of our code to completely ignore the fact that we're only touching part
+     * of the cursor.
+     */
+    private static final class SubsetCursor extends CursorWrapper {
+        private final int start;
+        private final int count;
+
+        public SubsetCursor(Cursor cursor, int start, int maxCount) {
+            super(cursor);
+
+            this.start = start;
+
+            if (start + maxCount < cursor.getCount()) {
+                count = maxCount;
+            } else {
+                count = cursor.getCount() - start;
+            }
+        }
+
+        @Override
+        public boolean moveToPosition(int position) {
+            return super.moveToPosition(position + start);
+        }
+
+        @Override
+        public int getCount() {
+            return count;
+        }
+    }
+
+    private Cursor cursor;
+
+    /**
+     *
+     * @param cursor
+     * @param startIndex The first item that this topsites group should show. This item, and the following
+     * 3 items will be displayed by this adapter.
+     */
+    public void swapCursor(Cursor cursor, int startIndex) {
+        if (cursor != null) {
+            if (startIndex >= cursor.getCount()) {
+                throw new IllegalArgumentException("startIndex must be within Cursor range");
+            }
+
+            this.cursor = new SubsetCursor(cursor, startIndex, TopSitesPagerAdapter.ITEMS_PER_PAGE);
+        } else {
+            this.cursor = null;
+        }
+
+        notifyDataSetChanged();
+    }
+
+    @Override
+    public void onBindViewHolder(TopSitesCard holder, int position) {
+        cursor.moveToPosition(position);
+        holder.bind(cursor);
+    }
+
+    public TopSitesPageAdapter() {
+        setHasStableIds(true);
+    }
+
+    @Override
+    public TopSitesCard onCreateViewHolder(ViewGroup parent, int viewType) {
+        final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+
+        final CardView card = (CardView) inflater.inflate(R.layout.activity_stream_topsites_card, parent, false);
+
+        return new TopSitesCard(card);
+    }
+
+    @UiThread
+    public String getURLForPosition(int position) {
+        cursor.moveToPosition(position);
+
+        return cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL));
+    }
+
+    @Override
+    public int getItemCount() {
+        if (cursor != null) {
+            return cursor.getCount();
+        } else {
+            return 0;
+        }
+    }
+
+    @Override
+    @UiThread
+    public long getItemId(int position) {
+        cursor.moveToPosition(position);
+
+        // The Combined View only contains pages that have been visited at least once, i.e. any
+        // page in the TopSites query will contain a HISTORY_ID. _ID however will be 0 for all rows.
+        return cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID));
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPagerAdapter.java
@@ -0,0 +1,111 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.home.activitystream.topsites;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v4.view.PagerAdapter;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.home.HomePager;
+
+import java.util.LinkedList;
+
+/**
+ * The primary / top-level TopSites adapter: it handles the ViewPager, and also handles
+ * all lower-level Adapters that populate the individual topsite items.
+ */
+public class TopSitesPagerAdapter extends PagerAdapter {
+    // Note: because of RecyclerView limitations we need to also adjust the layout height when
+    // GRID_HEIGHT is changed.
+    public static final int GRID_HEIGHT = 1;
+    public static final int GRID_WIDTH = 4;
+    public static final int PAGES = 4;
+
+    public static final int ITEMS_PER_PAGE = GRID_HEIGHT * GRID_WIDTH;
+    public static final int TOTAL_ITEMS = ITEMS_PER_PAGE * PAGES;
+
+    private LinkedList<TopSitesPage> pages = new LinkedList<>();
+
+    private final Context context;
+    private final HomePager.OnUrlOpenListener onUrlOpenListener;
+
+    private int count = 0;
+
+    public TopSitesPagerAdapter(Context context, HomePager.OnUrlOpenListener onUrlOpenListener) {
+        this.context = context;
+        this.onUrlOpenListener = onUrlOpenListener;
+    }
+
+    @Override
+    public int getCount() {
+        return count;
+    }
+
+    @Override
+    public boolean isViewFromObject(View view, Object object) {
+        return view == object;
+    }
+
+    @Override
+    public Object instantiateItem(ViewGroup container, int position) {
+        TopSitesPage page = pages.get(position);
+
+        container.addView(page);
+
+        return page;
+    }
+
+    @Override
+    public void destroyItem(ViewGroup container, int position, Object object) {
+        container.removeView((View) object);
+    }
+
+    public void swapCursor(Cursor cursor) {
+        final int oldPages = getCount();
+
+        // Divide while rounding up: 0 items = 0 pages, 1-ITEMS_PER_PAGE items = 1 page, etc.
+        if (cursor != null) {
+            count = (cursor.getCount() - 1) / ITEMS_PER_PAGE + 1;
+        } else {
+            count = 0;
+        }
+
+        final int pageDelta = count - oldPages;
+
+        if (pageDelta > 0) {
+            final LayoutInflater inflater = LayoutInflater.from(context);
+            for (int i = 0; i < pageDelta; i++) {
+                final TopSitesPage page = (TopSitesPage) inflater.inflate(R.layout.activity_stream_topsites_page, null, false);
+
+                page.setOnUrlOpenListener(onUrlOpenListener);
+                page.setAdapter(new TopSitesPageAdapter());
+                pages.add(page);
+            }
+        } else if (pageDelta < 0) {
+            for (int i = 0; i > pageDelta; i--) {
+                final TopSitesPage page = pages.getLast();
+
+                // Ensure the page doesn't use the old/invalid cursor anymore
+                page.getAdapter().swapCursor(null, 0);
+
+                pages.removeLast();
+            }
+        } else {
+            // do nothing: we will be updating all the pages below
+        }
+
+        int startIndex = 0;
+        for (TopSitesPage page : pages) {
+            page.getAdapter().swapCursor(cursor, startIndex);
+            startIndex += ITEMS_PER_PAGE;
+        }
+
+        notifyDataSetChanged();
+    }
+}
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -428,17 +428,20 @@ gbjar.sources += ['java/org/mozilla/geck
     'GlobalHistory.java',
     'GuestSession.java',
     'health/HealthRecorder.java',
     'health/SessionInformation.java',
     'health/StubbedHealthRecorder.java',
     'home/activitystream/ActivityStream.java',
     'home/activitystream/StreamItem.java',
     'home/activitystream/StreamRecyclerAdapter.java',
-    'home/activitystream/TopSitesRecyclerAdapter.java',
+    'home/activitystream/topsites/TopSitesCard.java',
+    'home/activitystream/topsites/TopSitesPage.java',
+    'home/activitystream/topsites/TopSitesPageAdapter.java',
+    'home/activitystream/topsites/TopSitesPagerAdapter.java',
     'home/BookmarkFolderView.java',
     'home/BookmarkScreenshotRow.java',
     'home/BookmarksListAdapter.java',
     'home/BookmarksListView.java',
     'home/BookmarksPanel.java',
     'home/BrowserSearch.java',
     'home/ClientsAdapter.java',
     'home/CombinedHistoryAdapter.java',
--- a/mobile/android/base/resources/layout/activity_stream.xml
+++ b/mobile/android/base/resources/layout/activity_stream.xml
@@ -1,15 +1,12 @@
 <?xml version="1.0" encoding="utf-8"?>
 <org.mozilla.gecko.home.activitystream.ActivityStream xmlns:android="http://schemas.android.com/apk/res/android"
                                                       android:orientation="vertical"
                                                       android:layout_width="match_parent"
                                                       android:layout_height="match_parent"
                                                       android:background="#FAFAFA">
     <android.support.v7.widget.RecyclerView
         android:id="@+id/activity_stream_main_recyclerview"
-        android:layout_marginTop="12dp"
-        android:layout_marginLeft="12dp"
-        android:layout_marginStart="12dp"
         android:layout_width="match_parent"
         android:layout_height="match_parent"/>
 
 </org.mozilla.gecko.home.activitystream.ActivityStream>
--- a/mobile/android/base/resources/layout/activity_stream_main_toppanel.xml
+++ b/mobile/android/base/resources/layout/activity_stream_main_toppanel.xml
@@ -26,31 +26,31 @@
         android:layout_alignParentRight="true"
         android:textAllCaps="true"
         android:textColor="@android:color/holo_orange_dark"
         android:textSize="14sp"
         android:text="@string/activity_stream_more"
         tools:text="More"
         android:layout_alignBottom="@+id/title_topsites"/>
 
-    <android.support.v7.widget.RecyclerView
+    <android.support.v4.view.ViewPager
         android:layout_width="match_parent"
         android:layout_height="115dp"
-        android:id="@+id/android.support.v7.widget.RecyclerView"
+        android:id="@+id/topsites_pager"
         android:layout_below="@+id/title_topsites"
         android:layout_alignParentLeft="true"
         android:layout_alignParentStart="true"/>
 
     <TextView
         android:id="@+id/title_highlights"
         android:text="@string/activity_stream_highlights"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:textStyle="bold"
-        android:layout_below="@+id/android.support.v7.widget.RecyclerView"
+        android:layout_below="@+id/topsites_pager"
         android:layout_alignParentLeft="true"
         android:layout_alignParentStart="true"
         android:layout_toLeftOf="@+id/more_highlights"
         android:layout_toStartOf="@+id/more_highlights"/>
 
     <TextView
         android:id="@+id/more_highlights"
         android:layout_width="wrap_content"
rename from mobile/android/base/resources/layout/activity_stream_card_top_sites_item.xml
rename to mobile/android/base/resources/layout/activity_stream_topsites_card.xml
--- a/mobile/android/base/resources/layout/activity_stream_card_top_sites_item.xml
+++ b/mobile/android/base/resources/layout/activity_stream_topsites_card.xml
@@ -1,46 +1,51 @@
 <?xml version="1.0" encoding="utf-8"?>
-<android.support.v7.widget.CardView
+<org.mozilla.gecko.widget.FilledCardView
     xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:tools="http://schemas.android.com/tools"
-    android:layout_marginRight="5dp"
-    android:layout_marginEnd="5dp"
-    android:layout_marginTop="10dp"
-    android:layout_marginBottom="10dp"
-    android:orientation="vertical"
-    android:layout_width="90dp"
-    android:layout_height="match_parent">
+    android:layout_width="wrap_content"
+    android:layout_height="115dp"
+    android:layout_margin="1dp">
+
+    <RelativeLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent">
 
-    <LinearLayout
-        android:orientation="vertical"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:baselineAligned="false">
-
-        <FrameLayout
+        <org.mozilla.gecko.widget.FaviconView
+            android:id="@+id/favicon"
             android:layout_width="match_parent"
-            android:background="@color/disabled_grey"
-            android:layout_height="70dp">
+            android:layout_height="wrap_content"
+            android:layout_above="@+id/title"
+            android:layout_alignParentTop="true"
+            android:layout_centerHorizontal="true"
+            android:layout_gravity="center"
+            tools:background="@drawable/favicon_globe"/>
 
-            <ImageView
-                android:src="@drawable/favicon_globe"
-                android:scaleType="fitCenter"
-                android:layout_gravity="center"
-                android:layout_width="40dp"
-                android:layout_height="40dp"/>
-        </FrameLayout>
-
-        <FrameLayout
-            android:layout_width="match_parent"
-            android:layout_height="match_parent">
+        <TextView
+            android:id="@+id/title"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_alignParentBottom="true"
+            android:layout_alignParentLeft="true"
+            android:layout_alignParentStart="true"
+            android:ellipsize="end"
+            android:gravity="center"
+            android:lines="1"
+            android:padding="4dp"
+            android:textColor="@android:color/black"
+            tools:text="Lorem Ipsum here is a title"
+            android:layout_alignParentRight="true"
+            android:layout_alignParentEnd="true"/>
 
-            <TextView
-                android:id="@+id/card_row_label"
-                tools:text="Firefox"
-                android:textSize="10sp"
-                android:textStyle="bold"
-                android:layout_gravity="center"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"/>
-        </FrameLayout>
-    </LinearLayout>
-</android.support.v7.widget.CardView>
\ No newline at end of file
+        <ImageView
+            android:id="@+id/menu_button"
+            android:layout_width="wrap_content"
+            android:layout_height="32dp"
+            android:layout_gravity="right|top"
+            android:padding="6dp"
+            android:src="@drawable/menu"
+            android:layout_alignParentTop="true"
+            android:layout_alignParentRight="true"
+            android:layout_alignParentEnd="true"/>
+
+    </RelativeLayout>
+</org.mozilla.gecko.widget.FilledCardView>
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/resources/layout/activity_stream_topsites_page.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<org.mozilla.gecko.home.activitystream.topsites.TopSitesPage xmlns:android="http://schemas.android.com/apk/res/android"
+              android:orientation="vertical"
+              android:layout_width="match_parent"
+              android:layout_height="match_parent"/>