Android – Maitriser le widget ViewPager afin de réaliser un Coverflow

Partager cet article

Temps estimé pour la lecture de cet article : 44 min

Présentation du widget et du résultat recherché

Aujourd’hui, j’aimerais vous proposer une présentation assez complète d’un widget d’Android appelé ViewPager, mais qu’est-ce donc ? C’est un layout qui va permettre à l’utilisateur de swiper sur son écran vers la gauche ou la droite afin de permettre de rafraichir les données affichées par le widget. Cela permet par exemple de réaliser des galeries d’images ou encore les mini-tuto que proposent certaines applications comme Google, c’est peut-être encore un peu flou, alors voici un example :

Exemple de ViewPager avec le tutoriel de l'application Google Sheets

Exemple de ViewPager avec le tutoriel de l’application Google Sheets

Cet outil est très intéressant et permet d’offrir des interfaces utilisateur poussé. À travers cet article, je vais vous montrer, comment on peut le modifier afin de réaliser un coverflow. Voici rien que pour vous en avant-première un exemple du genre de résultat que vous allez pouvoir obtenir en suivant cet article :

android-viewpager-result

Mise en place du ViewPager

Le ViewPager est bien souvent utilisé conjointement avec des Fragments mais il est tout à fait possible de ne pas en utiliser comme le démontre très bien cet article, pour ma part dans ce tutoriel j’utiliserais des Fragments.

Modifier la taille de la page courante en précisant un pourcentage

Par défaut les pages d’un ViewPager prennent tout l’espace disponible. Pour jouer sur la taille de la page courante tout en la conservant centrer et permettre d’afficher les autres pages, nous allons :

  • Changer la taille de l’élément courant grâce à la méthode setPageWidth, cette méthode prend une valeur en pourcentage
  • Ajouter un padding sur la page principale de la taille désiré
  • Appliquer une marge négative à notre ViewPager grâce à la méthode setPageMargin afin de prendre en compte le padding du dessus, on va également pouvoir ajouter un padding entre chaque élément.

Voici, le code du Fragment que j’ai créé pour notre ViewPager, assez classique, la fonction qui nous intéresse c’est le computePadding, elle va récupérer l’élément courant et une fois que ce dernier est prêt, récupérer sa largeur, calculer les marges souhaiter et appliquer ce que l’on a vu au-dessus. Détaillons un peu le calcul :

  • On récupère la taille de nos marges à savoir : 1 – la taille de la page en pourcentage (carrousel.getPageWidth())
  • On divise par 2 pour obtenir la taille d’une marge
  • On multiplie par la largeur de l’élément courant, afin d’obtenir la taille de cette marge
  • On applique alors le padding
  • Il ne reste plus qu’à ajouter les marges négatives sur le ViewPager
public class CarouselFragment extends Fragment {
    public static Fragment newInstance(Context context, Entity entity, int position, float scale) {
        Bundle b = new Bundle();
        b.putInt("image", entity.imageRes);
        b.putInt("position", position);
        b.putFloat("scale", scale);
        b.putString("title", entity.titleRes);
        b.putString("description", entity.description);
        return Fragment.instantiate(context, CarouselFragment.class.getName(), b);
    }

    @Override
    public View onCreateView(final LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        if (container == null) {
            return null;
        }

        final ScaledFrameLayout root = (ScaledFrameLayout) inflater.inflate(R.layout.item_carousel, container, false);
        root.setScaleBoth(getArguments().getFloat("scale"));
        root.setTag("view" + getArguments().getInt("position"));
        computePadding(root);

        ...

        return root;
    }

    private void computePadding(final ViewGroup rootLayout) {
        rootLayout.post(new Runnable() {
            @Override
            public void run() {
                CarouselViewPager carousel = (CarouselViewPager) getActivity().findViewById(R.id.carousel);
                int width = rootLayout.getWidth();
                int paddingWidth = (int) (width * (1-carousel.getPageWidth())/2);
                rootLayout.setPadding(paddingWidth, 0, paddingWidth, 0);
                carousel.setPageMargin(-(paddingWidth - carousel.getPaddingBetweenItem()) * 2);
            }
        });
    }
}

Mettre en avant la page principale avec une mise à l’échelle

Ici, on aimerait pouvoir mettre en avant la page courante du ViewPager pour faire cela, nous allons avoir besoin de deux choses :

  • Un layout custom afin de mettre à l’échelle l’élément courant
  • De surcharger les méthodes getItem et onPageScrolled de notre Adapter

Commençons par le plus simple, notre Layout custom, ici un FrameLayout.

import android.content.Context;
import android.graphics.Canvas;
import android.util.AttributeSet;
import android.widget.FrameLayout;

public class ScaledFrameLayout extends FrameLayout {
    private float scale = 1.0f;

    public ScaledFrameLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public ScaledFrameLayout(Context context) {
        super(context);
    }

    public void setScaleBoth(float scale) {
        this.scale = scale;
        invalidate();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.scale(scale, scale, getWidth() / 2, getHeight() / 2);
    }
}

On crée donc un attribut appelé scale qui par défaut à une valeur de 1. On aura alors plus qu’à appeler le mutateur setScaleBoth, afin de changer la valeur de cet attribut et appeler la fonction invalidate, qui va permettre au layout de se redessiner et donc d’appeler la méthode onDraw, dont la seule action est de scale le canvas. Il nous suffira donc d’appeler le mutateur au bon moment.

public class CarouselAdapter extends FragmentPagerAdapter implements ViewPager.OnPageChangeListener {
    private float scale;
    private CarouselViewPager carousel;

    private Context context;
    private FragmentManager fragmentManager;
    private ArrayList<Entity> entities = new ArrayList<>();
    private ScaledFrameLayout cur = null, next = null;

    public final static float BIG_SCALE = 1.0f, SMALL_SCALE = 0.90f, DIFF_SCALE = BIG_SCALE - SMALL_SCALE;

    public CarouselAdapter(Context context, CarouselViewPager carousel, FragmentManager fragmentManager, ArrayList<Entity> mData) {
        super(fragmentManager);
        this.fragmentManager = fragmentManager;
        this.context = context;
        this.carousel = carousel;
        this.entities = mData;
    }

    @Override
    public Fragment getItem(int position) {
        if (position == 0) {
            scale = BIG_SCALE;
        } else {
            scale = SMALL_SCALE;
        }
        Fragment fragment = CarouselFragment.newInstance(context, entities.get(position), position, scale);
        return fragment;
    }

    @Override
    public int getItemPosition(Object object) {
        return super.getItemPosition(object);
    }

    @Override
    public int getCount() {
        return entities.size();
    }

    @Override
    public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
        if (positionOffset >= 0f && positionOffset <= 1f) {
            cur = getRootView(position);
            cur.setScaleBoth(BIG_SCALE - DIFF_SCALE * positionOffset);

            if (position < entities.size() - 1) {
                next = getRootView(position + 1);
                next.setScaleBoth(SMALL_SCALE + DIFF_SCALE * positionOffset);
            }
        }
    }
}

Régler la vitesse de défilement du ViewPager

Si vous souhaitez modifier la page courante de votre ViewPager, vous pouvez utiliser les deux méthodes suivantes :

  • void setCurrentItem(int item)
  • void setCurrentItem(int item, boolean smoothScroll)

On voit qu’il est possible de changer l’item et même de le faire une animation plus lisse (smooth), oui mais le problème c’est qu’il nous est impossible par défaut de modifier la vitesse de cette animation. Bien sur, il est quand même possible de le faire, il va nous falloir plusieurs étapes:

  • Modifier le Scroller utilisé par défaut par la classe ViewPager
  • Créer notre propre Scroller qu’on pourra alors régler comme l’on désire
  • Proposer un mutateur afin de pouvoir facilement régler la vitesse de défilement

Pour notre ViewPager custom :

...
    public CarouselViewPager(Context context) {
        super(context);
        postInitViewPager();
    }

    public CarouselViewPager(Context context, AttributeSet attrs) {
        super(context, attrs);
        postInitViewPager();
    }

    private void postInitViewPager() {
        try {
            Class<?> viewpager = ViewPager.class;
            Field scroller = viewpager.getDeclaredField("mScroller");
            scroller.setAccessible(true);
            Field interpolator = viewpager.getDeclaredField("sInterpolator");
            interpolator.setAccessible(true);

            mScroller = new SpeedScroller(getContext(), (Interpolator) interpolator.get(null));
            scroller.set(this, mScroller);
        } catch (Exception e) {
            Log.e("postInitViewPager", e.getMessage());
        }
    }

    public void setScrollDurationFactor(double scrollFactor) {
        mScroller.setScrollDurationFactor(scrollFactor);
    }
...

Ici, c’est un peu particulier, on réalise ce qu’on appelle de la reflexion en Java. Cela va nous permettre d’accéder aux attributs de la classe ViewPager qui nous était inaccessible à cause de leur portée. Nous permettant alors de modifier le scroller par défaut, par notre classe custom que voici :

import android.content.Context;
import android.view.animation.Interpolator;
import android.widget.Scroller;

public class SpeedScroller extends Scroller {
    private double mScrollFactor = 1;

    public SpeedScroller(Context context) {
        super(context);
    }

    public SpeedScroller(Context context, Interpolator interpolator) {
        super(context, interpolator);
    }

    public SpeedScroller(Context context, Interpolator interpolator, boolean flywheel) {
        super(context, interpolator, flywheel);
    }

    public void setScrollDurationFactor(double scrollFactor) {
        mScrollFactor = scrollFactor;
    }

    @Override
    public void startScroll(int startX, int startY, int dx, int dy, int duration) {
        super.startScroll(startX, startY, dx, dy, (int) (duration * mScrollFactor));
    }
}

Comme vous pouvez le voir ici, on ne réalise pas de grands changements, on va simplement surcharger la méthode startScroll() afin de pouvoir multiplier la durée par le facteur que l’on désire. Cela va ainsi nous permettre de contrôler assez précisément la durée de l’animation. Après, pour pouvoir régler l’animation de défilement, il suffira d’appeler la méthode setScrollDurationFactor, comme ceci :

carousel.setScrollDurationFactor(1.5f);

Ajouter une animation au lancement de l’activité

Grâce à toutes nos modifications vous devriez avoir quelque chose d’assez sympathique mais si on allait encore plus loin en ajoutant une modification, lorsque vous lancerez l’activité avec votre ViewPager, regardons ça !

Nous allons donc réaliser une animation qui vient de la droite et s’arrête sur l’élément sélectionné en conservant la mise en avant, ce qui nous donne quelque chose du genre :

android-viewpager-animation

Pour ce faire, nous allons créer une nouvelle classe, qu’on va appeler ScrollToAnimation :

public class ScrollToAnimation extends Animation {
    private int currentIndex = 0, nbChilds = -1, deltaT = 0;
    private float fromX, toX;
    private long animationStart;
    private CarouselViewPager viewpager;
    private boolean beginAnimation;

    public ScrollToAnimation(final CarouselViewPager viewpager, boolean beginAnimation, float fromX, float toX, int duration) {
        this.viewpager = viewpager;
        this.fromX = fromX;
        this.toX = toX;
        this.beginAnimation = beginAnimation;

        nbChilds = viewpager.getChildCount();
        deltaT = duration / nbChilds;

        setDuration(duration);
        animationStart = Calendar.getInstance().getTimeInMillis();
        setInterpolator(new LinearInterpolator());
    }

    @Override
    protected void applyTransformation(float interpolatedTime, Transformation t) {
        super.applyTransformation(interpolatedTime, t);

        int offset = (beginAnimation) ? (int) (-toX * interpolatedTime) : (int) (-fromX * interpolatedTime + fromX);

        if(!beginAnimation) {
            long animationProgression = Calendar.getInstance().getTimeInMillis() - animationStart;
            currentIndex = (int) (animationProgression/deltaT);
            if(currentIndex != viewpager.getCurrentItem()) {
                viewpager.setCurrentItemWhitoutScrolling(offset);
            }
        }
        viewpager.scrollingToPage(offset);
        viewpager.scrollTo(offset, 0);
    }
}

On va commencer par regarder le nombre de pages présents dans le ViewPager, on calcule le temps passé sur un élément donné : deltaT. Grâce à cette variable on va pouvoir ainsi, connaître l’élement actuellement sélectionné, ensuite on va devoir encore utiliser de la reflexion afin de pouvoir modifier l’élément courant sans lancer d’animation, c’est la méthode setCurrentItemWhitoutScrolling() qui s’en chargera. On laissera ensuite le ViewPager réaliser le défilement grâce au scrollingToPage, le scale se fera bien car on aura bien modifié l’élément courant ! Un peu complexe mais fonctionnel.

Regardons maintenant comment modifier le ViewPager :

private Animation animation;
private int animationDuration = 2500;
private boolean animationStarted = true;

... 

public void startAnimation(boolean arrived, Animation.AnimationListener listener) {
    animationStarted = false;
    int desiredPosition = (int) (getChildAt(0).getWidth()/1.5f*(getChildCount()));
    if(arrived) {
        animation  = new ScrollToAnimation(this, arrived, 0, desiredPosition, animationDuration);
    } else {
        animation = new ScrollToAnimation(this, arrived, desiredPosition, 0, animationDuration);
    }
    animation.setAnimationListener(listener);
    invalidate();
}

private Canvas enterAnimation(final Canvas c) {
    animationStarted = true;
    startAnimation(animation);
    return c;
}

@Override
protected void onDraw(Canvas canvas) {
    if (!animationStarted) {
        canvas = enterAnimation(canvas);
    }
    super.onDraw(canvas);
}


public void setCurrentItemWhitoutScrolling(int item) {
    try {
        Field mCurItem = ViewPager.class.getDeclaredField("mCurItem");
        mCurItem.setAccessible(true);
        mCurItem.setInt(this, item);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

public void scrollingToPage(int offset)  {
    try {
        Method pageScrolled = ViewPager.class.getDeclaredMethod("pageScrolled", int.class);
        pageScrolled.setAccessible(true);
        pageScrolled.invoke(this, offset);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

Pour lancer l’animation, il suffit donc de :

  • Surcharger la méthode onDraw et lancer l’animation dès que désiré
  • On crée la méthode enterAnimation, qui va être charger de créer et lancer l’animation (grâce au boolean présent)
  • Il nous restera plus qu’a appeler la méthode précédente lorsqu’on le désire

On pourrait par exemple la lancer lorsque l’activité désirée a le focus comme ceci :

public class MainActivity extends AppCompatActivity {
    private CarouselViewPager carousel;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        carousel = (CarouselViewPager) findViewById(R.id.carousel);
        ArrayList<Entity> entities = buildData();
        CarouselAdapter carouselAdapter = new CarouselAdapter(this, carousel, getSupportFragmentManager(), entities);

        carousel.setAdapter(carouselAdapter);
        carousel.addOnPageChangeListener(carouselAdapter);
        carousel.setOffscreenPageLimit(entities.size());
        carousel.setClipToPadding(false);
        carousel.setAlpha(0.0f);
    }

    @Override
    public void onWindowFocusChanged (boolean hasFocus) {
        super.onWindowFocusChanged(hasFocus);
        if (hasFocus) {
            carousel.startAnimation(false, new Animation.AnimationListener() {
                @Override
                public void onAnimationStart(Animation animation) {
                    carousel.setAlpha(1.0f);
                }

                @Override
                public void onAnimationEnd(Animation animation) { }

                @Override
                public void onAnimationRepeat(Animation animation) { }
            });
        }
    }
}

On s’assure juste de ne pas afficher le ViewPager au lancement grâce à l’alpha. Dès que l’animation est lancée, on affiche le carrousel.

Quelques pistes d’améliorations

Cet outil est vraiment puissant et en cherchant bien vous allez pouvoir réaliser des effets vraiment sympathiques, voici quelques liens qui peuvent être intéressant :

Et vous pouvez retrouver l’intégralité du code et le CarouselViewPager prêt à être utilisé sur mon dépôt github.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *