ViewPager cards like Duolingo application

    Doulingo is one of the best application in Android platform, one of features that made it successful is it's UI: beautiful and easy to use. If you have installed this app in your Android device, you will notice that it has an exciting "sliding cards" in the "course screen". This UI look like this:


    As view of an Android programmer, we noticed that it was made by using ViewPager and set each page is a CardView. Today, with this post, I will present the way to create this exciting UI.
    Because of using CardView, a widget of Design Support Library, you must add it's dependency to app-level build.gradle first:
compile 'com.android.support:cardview-v7:24.2.0'
compile 'com.android.support:design:24.2.0'

Designing Layout

Normally, you only need a ViewPager in your activity layout:
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true">

    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="top|center"
        android:layout_marginTop="@dimen/activity_horizontal_margin"
        android:src="@drawable/duolingo" />

    <android.support.v4.view.ViewPager
        android:id="@+id/viewPager"
        android:layout_width="match_parent"
        android:layout_height="330dp"
        android:layout_gravity="bottom"
        android:clipToPadding="false"
        android:overScrollMode="never"
        android:paddingBottom="30dp"
        android:paddingEnd="@dimen/card_padding"
        android:paddingLeft="@dimen/card_padding"
        android:paddingRight="@dimen/card_padding"
        android:paddingStart="@dimen/card_padding" />

</android.support.design.widget.CoordinatorLayout>
    The most important thing here is the attribute clipToPadding in the ViewPager. When this is false, the ViewPager won’t cut off the views that we want to show partially and the current item will still be centered.
    By changing card_padding value in your dimensions resource, you’ll also be modifying the view width. In this sample, I used 60dp to get a result similar to Duolingo.
    Each ViewPager's page is a CardView, so it's layout maybe like this:
item_viewpager.xml
<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/cardView"
    app:cardUseCompatPadding="true"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="center">

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">


        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:orientation="vertical"
            android:padding="24dp">

            <TextView
                android:id="@+id/title"
                style="@style/TextAppearance.AppCompat.Title"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Card title" />

            <TextView
                style="@style/TextAppearance.AppCompat.Body1"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="24dp"
                android:text="@string/lorem_ipsum" />

            <Button
                style="@style/ButtonStyle"
                android:id="@+id/button"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="24dp"
                android:text="Button" />
        </LinearLayout>

        <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="top|end"
            android:layout_marginEnd="8dp"
            android:layout_marginRight="8dp"
            android:layout_marginTop="-5dp"
            android:src="@drawable/ic_bookmark_24dp" />

    </FrameLayout>

</android.support.v7.widget.CardView>
    With this setup, we should have already 3 items visible, 1 one of them centered and 2 of them partially visible. Now we just need to animate the elevation of the cards.

Card View shadow and enlarge the center item

    In order to make your UI better like Duolingo, there are 2 works must be done: creating each item shadow and enlarge current ViewPager's item size(at center of screen).
    Let's created a seperated class named ShadowTransformer that implements OnPageChangeListener and PageTransformer interfaces. When the user scrolls to the next card, it should get higher and the previous card should get lower.
    This is done by using the method setCardElevation() of the CardView with an appropriate factor that depends on the scroll offset. In this sample, the CardView base elevation is the default (2dp) and the card will elevate up to 16dp.
    Source code of this class:
ShadowTransformer.java
package info.devexchanges.viewpagercards;

import android.support.v4.view.ViewPager;
import android.support.v7.widget.CardView;
import android.view.View;

public class ShadowTransformer implements ViewPager.OnPageChangeListener, ViewPager.PageTransformer {

    private ViewPager viewPager;
    private CardAdapter cardAdapter;
    private float lastOffset;
    private boolean scalingEnabled;

    public ShadowTransformer(ViewPager viewPager, CardAdapter adapter) {
        this.viewPager = viewPager;
        viewPager.addOnPageChangeListener(this);
        cardAdapter = adapter;
    }

    public void enableScaling(boolean enable) {
        if (scalingEnabled && !enable) {
            // shrink main card
            CardView currentCard = cardAdapter.getCardViewAt(viewPager.getCurrentItem());
            if (currentCard != null) {
                currentCard.animate().scaleY(1);
                currentCard.animate().scaleX(1);
            }
        }else if(!scalingEnabled && enable){
            // grow main card
            CardView currentCard = cardAdapter.getCardViewAt(viewPager.getCurrentItem());
            if (currentCard != null) {
                //enlarge the current item
                currentCard.animate().scaleY(1.1f);
                currentCard.animate().scaleX(1.1f);
            }
        }
        scalingEnabled = enable;
    }

    @Override
    public void transformPage(View page, float position) {

    }

    @Override
    public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
        int realCurrentPosition;
        int nextPosition;
        float baseElevation = cardAdapter.getBaseElevation();
        float realOffset;
        boolean goingLeft = lastOffset > positionOffset;

        // If we're going backwards, onPageScrolled receives the last position
        // instead of the current one
        if (goingLeft) {
            realCurrentPosition = position + 1;
            nextPosition = position;
            realOffset = 1 - positionOffset;
        } else {
            nextPosition = position + 1;
            realCurrentPosition = position;
            realOffset = positionOffset;
        }

        // Avoid crash on overscroll
        if (nextPosition > cardAdapter.getCount() - 1
                || realCurrentPosition > cardAdapter.getCount() - 1) {
            return;
        }

        CardView currentCard = cardAdapter.getCardViewAt(realCurrentPosition);

        // This might be null if a fragment is being used
        // and the views weren't created yet
        if (currentCard != null) {
            if (scalingEnabled) {
                currentCard.setScaleX((float) (1 + 0.1 * (1 - realOffset)));
                currentCard.setScaleY((float) (1 + 0.1 * (1 - realOffset)));
            }
            currentCard.setCardElevation((baseElevation + baseElevation
                    * (CardAdapter.MAX_ELEVATION_FACTOR - 1) * (1 - realOffset)));
        }

        CardView nextCard = cardAdapter.getCardViewAt(nextPosition);

        // We might be scrolling fast enough so that the next (or previous) card
        // was already destroyed or a fragment might not have been created yet
        if (nextCard != null) {
            if (scalingEnabled) {
                nextCard.setScaleX((float) (1 + 0.1 * (realOffset)));
                nextCard.setScaleY((float) (1 + 0.1 * (realOffset)));
            }
            nextCard.setCardElevation((baseElevation + baseElevation
                    * (CardAdapter.MAX_ELEVATION_FACTOR - 1) * (realOffset)));
        }

        lastOffset = positionOffset;
    }

    @Override
    public void onPageSelected(int position) {

    }

    @Override
    public void onPageScrollStateChanged(int state) {

    }
}
    In the main activity, the most important work is create a ShadowTransformer instance and attach it to ViewPager:
MainActivity.java
package info.devexchanges.viewpagercards;

import android.content.Context;
import android.os.Bundle;
import android.support.v4.view.ViewPager;
import android.support.v7.app.AppCompatActivity;

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ViewPager viewPager = (ViewPager) findViewById(R.id.viewPager);

        CardFragmentPagerAdapter pagerAdapter = new CardFragmentPagerAdapter(getSupportFragmentManager(), dpToPixels(2, this));
        ShadowTransformer fragmentCardShadowTransformer = new ShadowTransformer(viewPager, pagerAdapter);
        fragmentCardShadowTransformer.enableScaling(true);

        viewPager.setAdapter(pagerAdapter);
        viewPager.setPageTransformer(false, fragmentCardShadowTransformer);
        viewPager.setOffscreenPageLimit(3);
    }

    /**
     * Change value in dp to pixels
     * @param dp
     * @param context
     */
    public static float dpToPixels(int dp, Context context) {
        return dp * (context.getResources().getDisplayMetrics().density);
    }
}
Running this activity, we'll have this output:

Conclusions

    Looks pretty cool, right? Now we have completed making the interesting UI like Duolingo team did through handling ViewPager scrolling event. Hope this can help you to build one more exciting UI. There are some reference links you should read to get deep understanding about ViewPager (the official widget to build screen slides in Android):

Share


Previous post
« Prev Post
Next post
Next Post »