Multiple-View ViewPager Options
On Friday, I tweeted what I thought was a shot in the dark:
Has anyone seen any forks of
ViewPager
that show multiple children at once? Thinking 2-3 at a time for landscape tablets. Thx!
Juhani Lehtimaeki chimed in with what I was thinking:
It would make responsive design on UIs like that very easy.
After all, if the objective of using fragments is to be able to use
them individually on smaller screens and in aggregate on larger
screens, it stands to reason that we might want to do the same sort
of thing with the contents of a ViewPager
.
I received a number of responses, including three separate implementations
of what I requested. Curiously, none of those implementations bore
much resemblance to each other, beyond the basics of having multiple
visible pages in a ViewPager
.
In this post, I will examine each of the three approaches, so you can see how they work and what visual results you get.
The approach that most closely met what I had in mind was pointed out
in tweets from Lucio Maciel,
and Hello, Android’s
Ed Burnette,
and to use getPageWidth()
on PagerAdapter
.
getPageWidth()
returns a floating-point number, between 0 and 1,
representing the portion of the width of the ViewPager
that a given
page should take up. By default, the page width is 1, but by overriding
this, you can have multiple pages on the screen simultaneously.
I have uploaded a project that demonstrates this to a GitHub repo, but let’s take a look at the important bits here.
The key, of course, is to override getPageWidth()
in your PagerAdapter
:
@Override
public float getPageWidth(int position) {
return(0.5f);
}
It is probably also a good idea to call setOffscreenPageLimit()
on the ViewPager
. By default (and at minimum), ViewPager
will
cache three pages: the one presently visible, and one on either
side. However, if you are showing more than one at a time, you should
bump the limit to be 3 times the number of simultaneous pages.
For a page width of 0.5f
— meaning two pages at a time —
you would want to call setOffscreenPageLimit(6)
, to make sure that
you had enough pages cached for both the current visible contents
and one full swipe to either side.
This will give you two pages at a time:
ViewPager
even handles “partial swipes” — a careful swipe can
slide the right-hand page into the left-hand position and slide in
a new right-hand page. And ViewPager
stops when you run out of pages,
so the last page will always be on the right, no matter how many
pages at a time and how many total pages you happen to have.
The biggest downside to this approach is that it will not work well
with the current crop of indicators. PagerTitleStrip
and
PagerTabStrip
(and, possibly, Jake Wharton’s ViewPagerIndicator
library, though I have not tried that here) assume that there is a
single selected page. While the indicator will adjust properly,
the visual representation shows that the left-hand page is the one
selected (e.g., the tab with the highlight), even though two or more
pages are visible. You can probably overcome this with a custom
indicator (e.g., highlight the selected tab and the one to its right).
Also note that this approach collides a bit with setPageMargin()
on ViewPager
. setPageMargin()
indicates an amount of whitespace
that should go in a gutter between pages. In principle, this would work
great with showing multiple simultaneous pages in a ViewPager
. However,
ViewPager
does not take the gutter into account when interpreting
the getPageWidth()
value. For example, suppose getPageWidth()
returns 0.5f
and we setPageMargin(20)
. On a 480-pixel-wide
ViewPager
, we will actually use 500 pixels: 240 for the left page,
240 for the right page, and 20 for the gutter. As a result, 20 pixels
of our right-hand page are off the edge of the pager. Ideally, ViewPager
would subtract out the page margin before applying the page width.
One workaround is for you to derive the right getPageWidth()
value based upon the ViewPager
size and gutter yourself, rather than
hard-coding a value. Or, build in your gutter into your page
contents (e.g., using android:layout_marginLeft
and
android:layout_marginRight
) and skip setPageMargin()
entirely.
The second approach comes courtesy of
Nicolas Klein,
who advocated managing the sub-pages within a page yourself. While
his code sample seemed to be written
for perhaps a ListView
(it uses a ListAdapter
-style getView()
),
the concept can certainly be translated for ViewPager
.
UPDATE: Nicolas tweeted the following:
the reason why my adapter is different is because I use a different implementatoon of ViewPager which use a normal Adapter :)
Once again, I have uploaded a project that demonstrates this to a GitHub repo.
With Nicolas’ approach, you put two children in each “page” layout:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:id="@+id/text"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:textAppearance="?android:attr/textAppearanceLarge"/>
<TextView
android:id="@+id/text2"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:textAppearance="?android:attr/textAppearanceLarge"/>
</LinearLayout>
In your PagerAdapter
instantiateItem()
logic, you need to convert
positions in ViewPager
pages to positions in sub-pages, and update
both of your sub-pages in the inflated layout, handling the case where
there is an odd number of sub-pages, and so you could have only
a left-hand sub-page:
@Override
public Object instantiateItem(ViewGroup container, int position) {
View page=
getLayoutInflater().inflate(R.layout.page, container, false);
TextView tv=(TextView)page.findViewById(R.id.text);
position=position * 2;
populateTextView(tv, position);
position++;
tv=(TextView)page.findViewById(R.id.text2);
if (position < getRealCount()) {
populateTextView(tv, position);
tv.setVisibility(View.VISIBLE);
}
else {
tv.setVisibility(View.INVISIBLE);
}
container.addView(page);
return(page);
}
Visually, this looks very similar to the first approach. However:
-
You cannot swipe by sub-page, only by full page. This may be a feature or a bug, depending upon your objectives.
-
It is more code.
-
It would be easier to adapt to indicators (e.g.,
PagerTitleStrip
), as really the indicators would be indicating the page, not the set of sub-pages, and you could style and title those indicators as needed.
The third approach comes from Dave Smith, co-author of the well-regarded book Android Recipes. He went in a very different direction, using a custom container that disabled children clipping to show more than one page at a time.
His published sample code
shows the whole thing in action. His container
(com.example.pagercontainer.PagerContainer
) wraps the ViewPager
and calls setClipChildren(false);
on itself, so even though the
ViewPager
is focused on one selected page, other pages that have
coordinates beyond the ViewPager
bounds are still visible, so long as
they fit within the PagerContainer
. By sizing the ViewPager
to be
smaller than the PagerContainer
, the ViewPager
can size its pages
to that size, leaving room for other pages to be seen. PagerContainer
,
though, needs to help out a bit with touch events, as ViewPager
will only handle swipe events on its own visible bounds, ignoring
any pages visible to the sides.
Visually, his results depart significantly from the first two:
While this was not what I had in mind from my Twitter inquiry, Dave’s
approach does solve a different problem: how does one really replace
Gallery
, now that it is deprecated? Dave’s code mimics Gallery
’s look
fairly closely, minus selection events (which could probably be added
without a ton of difficulty).
Many thanks to all who responded to my tweet!