RSS link icon

Android : créer un menu en carrousel

Publication : le 16 févr. 2018 - Dernière modification : le 16 févr. 2018

Comment créer un menu de type carrousel avec plusieurs éléments visibles sur la même page.

N'ayant pas trouvé de sources convaincantes sur "l'Internet" (comme diraient nos politiques), j'ai eu du mal à créer un menu fonctionnant comme un carrousel sous Android.

Ce que nous cherchons à avoir

Avant d'aller directement dans le code, définissons ce que nous cherchons à avoir.

Il nous faut un menu avec une action pour chaque élément que nous appellerons par la suite : "carte". Ces cartes doivent être alignées horizontalement. Il faut également pouvoir naviguer dans le menu en faisant défiler les cartes sur l'axe horizontal. Vous l'aurez compris : nous voulons faire un carrousel. Vous me direz alors :

Facile ! Il suffit de mettre un ViewPager !

Si je m'arrêtais, oui, cela suffirait. Il y a plein de tutoriels mieux écrits que les miens sur la toile pour ça. Non, ce que nous recherchons est plus compliqué. La carte courante doit être au centre de l'écran et légèrement plus grande que les autres non-sélectionnées. Au moins 4 ou 5 autres cartes doivent être visibles sur l'écran également. C'est sur cette dernière phrase que ça se complique. Nous verrons ça plus loin.

Voici un petit visuel du rendu attendu :

Image du mockup du carrousel Android

C'est volontairement très basique afin que nous nous concentrions sur l'essentiel.

Codons, codons !

Créer une activity

Pour les besoins de l'article, je suis parti d'un projet vierge. Mais évidemment, vous adaptez à vos besoins. Ainsi, mon carrousel se trouve dans l'activity principale : MainActivity.

Éditons sont layout ; activity_main.xml dans mon cas. Nous virons tout le superflu et nous ajoutons notre titre "Accueil" dans la page : en haut + centré horizontalement. Ce qui donne :

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="fr.brennik.arzh.menucarrouseldemo.MainActivity">

    <TextView
        android:id="@+id/mainactivity_title_textview"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Accueil"
        android:textColor="#000"
        android:textSize="40dp"
        android:textStyle="bold"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>

</android.support.constraint.ConstraintLayout>

Créer le contenu d'une carte du menu

Maintenant, nous allons créer un nouveau fichier layout que je nommerai: carousel_card_fragment.xml. Pourquoi "fragment" à la fin ? Car ce sera un Fragment d'un ViewPager qui l'instancira.

Dans ce dernier, nous architecturons les cartes du menu. Nous les construisons à l'état non-sélectionné. Nous verrons l'état sélectionné plus tard, via java.

La carte doit pouvoir être "focusable" afin qu'on puisse gérer par la suite les deux états précédemment évoqués.

Enfin, nous mettons la carte dans un conteneur, ici un LinearLayout. Comme nous utiliserons un ViewPager, ce conteneur parent sera "resizé" par le pager. Hors, si nous avions mis la carte directement, elle aurait été étirée malgré la largeur et la hauteur fixées. C'est une petite astuce qui a sont importance dans le résultat final.

Voici le code de notre carte :

<?xml version="1.0" encoding="utf-8"?>
<!-- Container that handles resizing without affecting the item size -->
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:gravity="center">
    <!-- Item -->
    <LinearLayout
        android:id="@+id/carouselcard_main_linearlayout"
        android:layout_width="152dp"
        android:layout_height="152dp"
        android:background="@color/colorPrimary"
        android:focusable="true"
        android:focusableInTouchMode="true"
        android:orientation="vertical"
        android:gravity="center">
        <!-- Icon -->
        <ImageView
            android:id="@+id/carouselcard_icon_imageview"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@drawable/ic_home_unselected"/>
        <!-- Title -->
        <TextView
            android:id="@+id/carouselcard_title_textview"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="10dp"
            android:textColor="#FFF"
            android:text="Item #1"
            android:textSize="20dp"
            android:textAllCaps="true"/>
    </LinearLayout>

</LinearLayout>

Note : Vous noterez que j'utilise une nomination pour mes variables très verbeuse. Dans le cas des layouts, voici la structure : nomDuFicher_nomDeLaVariable_typeDeLaVariable. D'expériences, j'ai remarqué que je perdais beaucoup moins de temps à comprendre le sens des variables, à rechercher leurs types, à trouver le bon ID dans tous les ID de l'application, etc. C'est très pratique quand je reprend mon code après une pause où lorsque quelqu'un d'autre doit analyser le code. Cela permet également de rendre l'ID unique. Désolé, si cela vous pique les yeux : les goûts et les couleurs.

Créer la classe du fragment

Maintenant, nous devons créer la classe qui managera le layout que nous venons de créer. Je la nomme : CarouselCardFragment.java : comme le nom du fichier layout. Ainsi pas de doute possible sur leur affiliation.

Cet fragment capture les événements de type "focus" afin de s'agrandir en cas de sélection ou pas. De plus, il capture les événements de type "click". Grâce à une sous-interface CarouselCardFragment.OnClickListener, la classe pourra renvoyer les événements "click" à son listener en se passant elle-même. Cela permettra de savoir sur quel fragment nous avons cliqué.

Par ailleurs, la classe fonctionne en tant que factory d'elle-même. Cela nous simplifiera la vie plus tard.

Voici le code de la classe (sans les imports) :

public class CarouselCardFragment
        extends Fragment
        implements View.OnClickListener, View.OnFocusChangeListener
{
    public interface OnClickListener
    {
        void onClick(CarouselCardFragment fragment);
    }

    private final String ICON_SELECTED_ID = "ICON_SELECTED_ID";
    private final String ICON_UNSELECTED_ID = "ICON_UNSELECTED_ID";
    private final String TITLE_STRING = "TITLE_STRING";

    private LinearLayout rootLinearLayout_;
    private LinearLayout mainLinearLayout_;
    private ImageView iconImageView_;
    private TextView titleTextView_;

    private int iconUnselectedDrawableId_;
    private int iconSelectedDrawableId_;
    private String titleString_;
    private CarouselCardFragment.OnClickListener onClickListener_;

    public static CarouselCardFragment newInstance(int iconSelectedDrawableId,
                                                   int iconUnselectedDrawableId,
                                                   String titleString,
                                                   CarouselCardFragment.OnClickListener onClickListener)
    {
        CarouselCardFragment fragment = new CarouselCardFragment();
        fragment.setIconDrawableIds(iconSelectedDrawableId, iconUnselectedDrawableId);
        fragment.setTitleString(titleString);
        fragment.setOnClickListener(onClickListener);
        return fragment;
    }

    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState)
    {
        rootLinearLayout_ = (LinearLayout) inflater.inflate(R.layout.carousel_card_fragment, container, false);
        mainLinearLayout_ = (LinearLayout) rootLinearLayout_.findViewById(R.id.carouselcard_main_linearlayout);
        iconImageView_ = (ImageView) rootLinearLayout_.findViewById(R.id.carouselcard_icon_imageview);
        titleTextView_ = (TextView) rootLinearLayout_.findViewById(R.id.carouselcard_title_textview);

        // load data if configuration changed
        if (savedInstanceState != null)
        {
            iconSelectedDrawableId_ = savedInstanceState.getInt(ICON_SELECTED_ID);
            iconUnselectedDrawableId_ = savedInstanceState.getInt(ICON_UNSELECTED_ID);
            titleString_ = savedInstanceState.getString(TITLE_STRING);
        }

        this.setIconDrawableIds(iconSelectedDrawableId_, iconUnselectedDrawableId_);
        this.setTitleString(titleString_);

        // We handle focus on the mainLinearLayout to apply upscaling on selection
        mainLinearLayout_.setOnFocusChangeListener(this);
        // We hangle the click action to resent to the specific listener of the class.
        // So, we abstract the fragment as simple View object.
        mainLinearLayout_.setOnClickListener(this);
        return rootLinearLayout_;
    }

    @Override
    public void onClick(View view)
    {
        if (view.equals(mainLinearLayout_))
        {
            if (onClickListener_ != null)
            {
                onClickListener_.onClick(this);
            }
        }
    }

    @Override
    public void onFocusChange(View view, boolean b)
    {
        // We manager the upscaling on focus here
        if (view.equals(mainLinearLayout_))
        {
            if (b)
            {
                ViewGroup.LayoutParams layoutParams = mainLinearLayout_.getLayoutParams();
                layoutParams.width = getContext().getResources().getDimensionPixelSize(R.dimen.card_selected_state_size);
                layoutParams.height = layoutParams.width;
                mainLinearLayout_.setLayoutParams(layoutParams);
                mainLinearLayout_.setBackgroundResource(R.color.colorAccent);
            }
            else
            {
                ViewGroup.LayoutParams layoutParams = mainLinearLayout_.getLayoutParams();
                layoutParams.width = getContext().getResources().getDimensionPixelSize(R.dimen.card_unselected_state_size);
                layoutParams.height = layoutParams.width;
                mainLinearLayout_.setLayoutParams(layoutParams);
                mainLinearLayout_.setBackgroundResource(R.color.colorPrimary);
            }
            this.updateIconState();
        }
    }

    @Override
    public void onSaveInstanceState(Bundle outState)
    {
        // save data when configuration change
        outState.putInt(ICON_SELECTED_ID, iconSelectedDrawableId_);
        outState.putInt(ICON_UNSELECTED_ID, iconUnselectedDrawableId_);
        outState.putString(TITLE_STRING, titleString_);
        super.onSaveInstanceState(outState);
    }

    public boolean requestFocusOnCard()
    {
        if (mainLinearLayout_ != null)
        {
            return mainLinearLayout_.requestFocus();
        }
        return false;
    }

    public void setIconDrawableIds(int iconSelectedDrawableId, int iconUnselectedDrawableId)
    {
        this.iconSelectedDrawableId_ = iconSelectedDrawableId;
        this.iconUnselectedDrawableId_ = iconUnselectedDrawableId;
        if (iconImageView_ != null)
        {
            this.updateIconState();
        }
    }

    public void setTitleString(String titleString)
    {
        this.titleString_ = titleString;
        if (titleTextView_ != null)
        {
            titleTextView_.setText(titleString);
        }
    }

    public void setOnClickListener(CarouselCardFragment.OnClickListener onClickListener)
    {
        this.onClickListener_ = onClickListener;
    }

    private void updateIconState()
    {
        if (mainLinearLayout_.isFocused())
        {
            iconImageView_.setImageResource(iconSelectedDrawableId_);
        }
        else
        {
            iconImageView_.setImageResource(iconUnselectedDrawableId_);
        }
    }
}

Créer "l'adapter" du ViewPager

Maintenant, nous allons créer une nouvelle classe qui servira "d'adapter" au ViewPager : CarouselViewPagerAdapter. Elle ne fera que récupérer une liste de CarouselCardFragment et les fournira lorsque le "pager" les demandera. Lorsque que le "pager" changera de carte, "l'adapter" demandera le "focus" sur le fragment sélectionné. Cela aura pour effet d'enclencher l'agrandissement de la carte.

Le code :

public class CarouselViewPagerAdapter extends FragmentPagerAdapter implements ViewPager.OnPageChangeListener
{
    private List<CarouselCardFragment> fragments_;

    public CarouselViewPagerAdapter(FragmentManager fm, List<CarouselCardFragment> fragments)
    {
        super(fm);
        this.fragments_ = fragments;
    }

    @Override
    public Fragment getItem(int position)
    {
        return fragments_.get(position);
    }

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

    @Override
    public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels)
    {

    }

    @Override
    public void onPageSelected(int position)
    {
        if (position < fragments_.size())
        {
            fragments_.get(position).requestFocusOnCard();
        }
    }

    @Override
    public void onPageScrollStateChanged(int state)
    {

    }
}

Créer le carrousel en tant que "ViewPager"

Enfin, nous allons créer une nouvelle classe qui hérite de ViewPager. Ce sera notre carrousel. Ainsi, la classe est nommée CarouselCardViewPager.

Afin de s'assurer que les cartes non-sélectionnées soient affichées, nous désactivons le rognage autour de la zone d'intérêt du "pager" : la zone où est la carte sélectionnée. De plus, nous limitons cette zone au stricte contour de la carte sélectionnée.

Enfin, nous prévoyons de pouvoir ajouter des cartes au menu.

Tout ceci dans le code ci-dessous :

public class CarouselCardViewPager extends ViewPager
{
    public CarouselCardViewPager(Context context)
    {
        super(context);
        this.init();
    }

    public CarouselCardViewPager(Context context, AttributeSet attrs)
    {
        super(context, attrs);
        this.init();
    }

    private void init()
    {
        // Display unselected cards
        this.setClipToPadding(false);
        // Set that the ROI* is around the selected card with the same size
        // *ROI: region of interest.
        DisplayMetrics metrics = new DisplayMetrics();
        ((Activity) getContext()).getWindowManager().getDefaultDisplay().getMetrics(metrics);
        int maximumSizeOfCards = getContext().getResources().getDimensionPixelSize(R.dimen.card_selected_state_size);
        int paddingWidth = (metrics.widthPixels - maximumSizeOfCards) / 2;
        this.setPadding(paddingWidth, 0, paddingWidth, 0);
    }

    public void setCards(FragmentManager fragmentManager,
                         List<CarouselCardFragment> fragments)
    {
        CarouselViewPagerAdapter carouselViewPagerAdapter
                = new CarouselViewPagerAdapter(fragmentManager, fragments);
        this.setAdapter(carouselViewPagerAdapter);
        this.addOnPageChangeListener(carouselViewPagerAdapter);
    }
}

Ajouter le carrousel dans l'activity principale

Ajoutons le carrousel dans le layout de l'activity principale : activity_main.xml. Telle que :

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="fr.brennik.arzh.menucarrouseldemo.MainActivity">

    <TextView
        android:id="@+id/mainactivity_title_textview"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Accueil"
        android:textColor="#000"
        android:textSize="40dp"
        android:textStyle="bold"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>

    <!-- Our custom viewpager: carousel -->
    <fr.brennik.arzh.menucarrouseldemo.CarouselCardViewPager
        android:id="@+id/mainactivity_carouselcardviewpager"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toBottomOf="@id/mainactivity_title_textview"
        app:layout_constraintBottom_toBottomOf="parent"
        />

</android.support.constraint.ConstraintLayout>

Ajoutons des cartes dans le carrousel

Enfin, voici comment nous allons créer et remplir le carrousel dans la classe MainActivity :

public class MainActivity extends AppCompatActivity
    implements CarouselCardFragment.OnClickListener
{
    private CarouselCardViewPager carouselCardViewPager_;
    private List<CarouselCardFragment> cardFragments_;
    // For example purpose, we create 6 cards
    private final String[] CARD_TITLES = new String[]{
            "Item #1",
            "Item #2",
            "Item #3",
            "Item #4",
            "Item #5",
            "Item #6"
    };

    @Override
    protected void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        carouselCardViewPager_ = (CarouselCardViewPager) this.findViewById(R.id.mainactivity_carouselcardviewpager);

        cardFragments_ = new ArrayList<CarouselCardFragment>();
        for (int i = 0; i < CARD_TITLES.length; ++i)
        {
            CarouselCardFragment card = CarouselCardFragment.newInstance(R.drawable.ic_home_selected, // icon used when focused
                    R.drawable.ic_home_unselected, // icon used when not focused
                    CARD_TITLES[i], // title
                    this); // onClickListener
            this.cardFragments_.add(card);
        }
        carouselCardViewPager_.setCards(getSupportFragmentManager(), this.cardFragments_);
    }

    @Override
    public void onClick(CarouselCardFragment fragment)
    {
        if (fragment.equals(cardFragments_.get(0)))
        {
            // when the first card is clicked
        }
        else if (fragment.equals(cardFragments_.get(1)))
        {
            // when the second card is clicked
        }
        else if (fragment.equals(cardFragments_.get(2)))
        {
            // when the third card is clicked
        }
        else if (fragment.equals(cardFragments_.get(3)))
        {
            // when the fourth card is clicked
        }
        else if (fragment.equals(cardFragments_.get(4)))
        {
            // when the fifth card is clicked
        }
        else if (fragment.equals(cardFragments_.get(5)))
        {
            // whe the sixth card is clicked
        }
    }
}

Résultats

Voici quelques petites captures d'écrans :

  • Orientation portrait :

Screenshot du carrousel Android en portrait

  • Orientation Paysage :

Screenshot du carrousel Android en paysage