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 :
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 :
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 centré 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
- Appliquer une marge négative à notre ViewPager grâce à la méthode setPageMargin
Voici, le code du Fragment que j’utilise pour notre ViewPager.
public class CarouselFragment extends Fragment { public static Fragment newInstance(Context context, Entity entity, int position, float scale) { Bundle b = new Bundle(); ... 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); } }); } }
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 (grâce au post), récupérer sa largeur et calculer les marges souhaitées.
- 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
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
Notre custom layout: ScaledFrameLayout
Commençons par le plus simple, notre Layout custom, ici un 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.
Notre custom FragmentPagerAdapter: CarouselAdapter
public class CarouselAdapter extends FragmentPagerAdapter implements ViewPager.OnPageChangeListener { ... @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.
Notre custom Scroller: SpeedScroller
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 :
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.
Mettre à jour notre ViewPager
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
Exemple de lancement de l’animation depuis une Activity
On pourrait par exemple lancer l’animation lorsque l’activité désirée à 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 :
- Ajouter un effet de parallaxe entre chaque page
- Un ViewPager avec des indications sur la page courante
- Un ViewPager infini avec des transitions bien sympathique
Et vous pouvez retrouver l’intégralité du code et le CarouselViewPager prêt à être utilisé sur mon dépôt github.