RecyclerView.ItemDecoration: en tirer le meilleur parti

Bonjour, cher lecteur Habr. Je m'appelle Oleg Zhilo, depuis 4 ans je suis développeur Android chez Surf. Pendant ce temps, j'ai participé à toutes sortes de projets sympas, mais j'ai également eu la chance de travailler avec du code hérité.



Ces projets ont au moins un point commun: il y a une liste d'articles partout. Par exemple, une liste de contacts du répertoire ou une liste de vos paramètres de profil.



Nos projets utilisent RecyclerView pour les listes. Je ne vais pas vous dire comment écrire un adaptateur pour RecyclerView ou comment mettre à jour correctement les données de la liste. Dans mon article, je vais vous parler d'un autre composant important et souvent négligé - RecyclerView.ItemDecoration, je vais vous montrer comment l'utiliser pour la mise en page de la liste et ce qu'il peut faire.







En plus des données de la liste, le RecyclerView contient également des éléments décoratifs importants, par exemple des séparateurs de cellules, des barres de défilement. Et ici RecyclerView.ItemDecoration nous aidera à dessiner tout le décor et à ne pas produire de vues inutiles dans la disposition des cellules et de l'écran.



ItemDecoration est une classe abstraite avec 3 méthodes:



Méthode de rendu du décor avant le rendu de ViewHolder



public void onDraw(Canvas c, RecyclerView parent, State state)


Méthode de rendu du décor après le rendu de ViewHolder



public void onDrawOver(Canvas c, RecyclerView parent, State state)


Méthode d'indentation de ViewHolder lors du remplissage de RecyclerView



public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state)


Par la signature des méthodes onDraw *, vous pouvez voir que 3 composants principaux sont utilisés pour dessiner le décor.



  • Toile - pour rendre le décor nécessaire
  • RecyclerView - pour accéder aux paramètres du RecyclerVIew lui-même
  • RecyclerView.State - contient des informations sur l'état de RecyclerView


Connexion à RecyclerView



Il existe deux méthodes pour connecter une instance ItemDecoration à RecyclerView:



public void addItemDecoration(@NonNull ItemDecoration decor)
public void addItemDecoration(@NonNull ItemDecoration decor, int index)


Toutes les instances de RecyclerView.ItemDecoration connectées sont ajoutées à une liste et toutes sont rendues en même temps.



RecyclerView a également des méthodes supplémentaires pour manipuler avec ItemDecoration.

Suppression de la décoration d'objets par index



public void removeItemDecorationAt(int index)


Suppression d'une instance ItemDecoration



public void removeItemDecoration(@NonNull ItemDecoration decor)


Obtenir ItemDecoration par index



public ItemDecoration getItemDecorationAt(int index)


Obtenir le nombre actuel d'objets connectés ItemDecoration dans RecyclerView



public int getItemDecorationCount()


Redessiner la liste ItemDecoration actuelle



public void invalidateItemDecorations()


Le SDK a déjà des héritiers de RecyclerView.ItemDecoration, par exemple DeviderItemDecoration. Il vous permet de dessiner des séparateurs pour les cellules.



Cela fonctionne très simplement, vous devez utiliser un dessinable et DeviderItemDecoration le dessinera comme séparateur de cellules.



Créons divider_drawable.xml:



<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <size android:height="1dp" />
    <solid android:color="@color/gray_A700" />
</shape>


Et connectez le DividerItemDeoration au RecyclerView:



val dividerItemDecoration = DividerItemDecoration(this, RecyclerView.VERTICAL)
dividerItemDecoration.setDrawable(resources.getDrawable(R.drawable.divider_drawable))
recycler_view.addItemDecoration(dividerItemDecoration)


On a:





Idéal pour les occasions simples.



Tout est élémentaire sous le "capot" de DeviderItemDecoration:




final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
     final View child = parent.getChildAt(i);
     parent.getDecoratedBoundsWithMargins(child, mBounds);
     final int bottom = mBounds.bottom + Math.round(child.getTranslationY());
     final int top = bottom - mDivider.getIntrinsicHeight();
     mDivider.setBounds(left, top, right, bottom);
     mDivider.draw(canvas);
}


Pour chaque appel onDraw (...), parcourez toute la vue actuelle dans RecyclerView et dessinez le dessinable passé.



Mais l'écran peut contenir des éléments de mise en page plus complexes qu'une liste d'éléments identiques. L'écran peut inclure:



a. Plusieurs types de cellules;

b. Plusieurs types de diviseurs;

c. Les cellules peuvent avoir des bords arrondis;

ré. Les cellules peuvent avoir des retraits verticaux et horizontaux différents selon certaines conditions;

e. Tout ce qui précède à la fois.



Regardons le point e. Fixons-nous une tâche difficile et réfléchissons à sa solution.



Tâche:



  • Il y a 3 types de cellules uniques à l'écran, appelons-les a, b et c .
  • Toutes les cellules sont indentées de 16 dpi horizontalement.
  • La cellule b a également un décalage vertical de 8dp.
  • La cellule a a des bords arrondis en haut s'il s'agit de la première cellule du groupe et en bas s'il s'agit de la dernière cellule du groupe.
  • Les diviseurs sont dessinés entre les cellules avec, MAIS il ne devrait pas y avoir de diviseur après la dernière cellule du groupe.
  • Une image avec un effet de parallaxe est dessinée sur le fond de la cellule c .


Cela devrait finir comme ceci:





Considérez les options de résolution:



Remplir la liste avec des cellules de différents types.



Vous pouvez écrire votre propre adaptateur ou utiliser votre bibliothèque préférée.

J'utiliserai EasyAdapter .



Indentation des cellules.



Il existe trois façons:



  1. Définissez paddingStart et paddingEnd pour RecyclerView.

    Cette solution ne fonctionnera pas si toutes les cellules n'ont pas la même indentation.
  2. Définissez layout_marginStart et layout_marginEnd sur la cellule.

    Vous devrez ajouter les mêmes retraits à toutes les cellules de la liste.
  3. Écrivez une implémentation ItemDecoration et remplacez la méthode getItemOffsets.

    Mieux encore, la solution sera plus polyvalente et réutilisable.


Arrondir les coins pour les groupes de cellules.



La solution semble évidente: je veux ajouter immédiatement une énumération {Début, Milieu, Fin} et la mettre dans la cellule avec les données. Mais les inconvénients apparaissent immédiatement:



  • Le modèle de données de la liste se complique.
  • Pour de telles manipulations, vous devrez calculer à l'avance quelle énumération attribuer à chaque cellule.
  • Après avoir supprimé / ajouté un élément à la liste, vous devrez le recalculer.
  • ItemDecoration. Vous pouvez comprendre quelle cellule du groupe est et dessiner correctement l'arrière-plan dans la méthode onDraw * ItemDecoration.


Dessin diviseurs.



Dessiner des diviseurs à l'intérieur d'une cellule est une mauvaise pratique, car le résultat sera une mise en page compliquée, les écrans complexes auront des problèmes avec l'affichage dynamique des diviseurs. Et donc ItemDecoration gagne à nouveau. Le DeviderItemDecoration prêt à l'emploi du sdk ne fonctionnera pas pour nous, car il dessine des diviseurs après chaque cellule, et cela ne peut pas être résolu immédiatement. Vous devez écrire votre propre implémentation.



Parallaxe sur le fond de la cellule.



Une idée peut venir à l'esprit pour mettre le RecyclerView OnScrollListener et utiliser une vue personnalisée pour rendre l'image. Mais là encore, ItemDecoration nous aidera, car il a accès au Canvas Recycler et à tous les paramètres nécessaires.



Au total, nous devons écrire au moins 4 implémentations ItemDecoration. Il est très bien que nous puissions réduire tous les points à ne travailler qu'avec ItemDecoration et ne pas toucher à la mise en page et à la logique métier de la fonctionnalité. De plus, toutes les implémentations ItemDecoration peuvent être réutilisées si nous avons des cas similaires dans l'application.



Cependant, au cours des dernières années, des listes complexes sont apparues de plus en plus souvent dans nos projets et à chaque fois nous devions écrire un set ItemDecoration pour les besoins du projet. Une solution plus universelle et plus flexible était nécessaire afin qu'elle puisse être réutilisée sur d'autres projets.



Quels objectifs vouliez-vous atteindre:



  1. Écrivez aussi peu d'héritiers ItemDecoration que possible.
  2. Séparez la logique de rendu sur le canevas et le remplissage.
  3. Bénéficiez des avantages de l'utilisation des méthodes onDraw et onDrawOver.
  4. Rendre les décorateurs plus flexibles dans la personnalisation (par exemple, dessiner des diviseurs par condition, pas toutes les cellules).
  5. Prenez une décision sans faire référence aux diviseurs, car ItemDecoration est capable de plus que de dessiner des lignes horizontales et verticales.
  6. Cela peut être facilement exploité en regardant l'exemple de projet.


En conséquence, nous avons une bibliothèque de décorateurs RecyclerView.



La bibliothèque dispose d'une interface Builder simple, d'interfaces séparées pour travailler avec Canvas et les retraits, ainsi que la possibilité de travailler avec les méthodes onDraw et onDrawOver. L'implémentation ItemDecoration n'en est qu'une.



Revenons à notre problème et voyons comment le résoudre à l'aide de la bibliothèque.

Le constructeur de notre décorateur a l'air simple:




Decorator.Builder()
            .underlay()
            ...
            .overlay()
            ...
            .offset()
            ...
            .build()


  • .underlay (...) - nécessaire pour le rendu sous ViewHolder.
  • .overlay (...) - nécessaire pour dessiner sur le ViewHolder.
  • .offset (...) - utilisé pour définir le décalage du ViewHolder.


Il existe 3 interfaces utilisées pour dessiner un décor et définir des retraits.



  • RecyclerViewDecor - Rend le décor dans RecyclerView.
  • ViewHolderDecor - Rend le décor dans RecyclerView, mais donne accès à ViewHolder.
  • OffsetDecor - utilisé pour définir des retraits.


Mais ce n'est pas tout. ViewHolderDecor et OffsetDecor peuvent être liés à un ViewHolder spécifique à l'aide de viewType, qui vous permet de combiner plusieurs types de décors sur une liste ou même une cellule. Si le viewType n'est pas passé, les ViewHolderDecor et OffsetDecor s'appliqueront à tous les ViewHolders du RecyclerView. RecyclerViewDecor n'a pas une telle opportunité, car il est conçu pour fonctionner avec RecyclerView en général, et non avec ViewHolders. De plus, la même instance de ViewHolderDecor / RecyclerViewDecor peut être transmise à la fois à la superposition (...) ou à la sous-couche (...).



Commençons à écrire le code



La bibliothèque EasyAdapter utilise ItemControllers pour créer un ViewHolder. En bref, ils sont responsables de la création et de l'identification du ViewHolder. Pour notre exemple, un contrôleur suffit, qui peut afficher différents ViewHolders. L'essentiel est que le viewType soit unique pour chaque disposition de cellule. Cela ressemble à ceci:



private val shortCardController = Controller(R.layout.item_controller_short_card)
private val longCardController = Controller(R.layout.item_controller_long_card)
private val spaceController = Controller(R.layout.item_controller_space)


Pour définir les retraits, nous avons besoin d'un descendant de OffsetDecor:



class SimpleOffsetDrawer(
    private val left: Int = 0,
    private val top: Int = 0,
    private val right: Int = 0,
    private val bottom: Int = 0
) : Decorator.OffsetDecor {

    constructor(offset: Int) : this(offset, offset, offset, offset)

    override fun getItemOffsets(
        outRect: Rect,
        view: View,
        recyclerView: RecyclerView,
        state: RecyclerView.State
    ) {
        outRect.set(left, top, right, bottom)
    }
}


Pour dessiner des coins arrondis, ViewHolder a besoin d'un héritier de ViewHolderDecor. Ici, nous avons besoin d'un OutlineProvider pour que l'état de la presse soit également coupé sur les bords.



class RoundDecor(
    private val cornerRadius: Float,
    private val roundPolitic: RoundPolitic = RoundPolitic.Every(RoundMode.ALL)
) : Decorator.ViewHolderDecor {

    override fun draw(
        canvas: Canvas,
        view: View,
        recyclerView: RecyclerView,
        state: RecyclerView.State
    ) {

        val viewHolder = recyclerView.getChildViewHolder(view)
        val nextViewHolder =
            recyclerView.findViewHolderForAdapterPosition(viewHolder.adapterPosition + 1)
        val previousChildViewHolder =
            recyclerView.findViewHolderForAdapterPosition(viewHolder.adapterPosition - 1)

        if (cornerRadius.compareTo(0f) != 0) {
            val roundMode = getRoundMode(previousChildViewHolder, viewHolder, nextViewHolder)
            val outlineProvider = view.outlineProvider
            if (outlineProvider is RoundOutlineProvider) {
                outlineProvider.roundMode = roundMode
                view.invalidateOutline()
            } else {
                view.outlineProvider = RoundOutlineProvider(cornerRadius, roundMode)
                view.clipToOutline = true
            }
        }
    }
}


Pour dessiner des diviseurs, nous écrirons un autre héritier de ViewHolderDecor:



class LinearDividerDrawer(private val gap: Gap) : Decorator.ViewHolderDecor {

    private val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG)
    private val alpha = dividerPaint.alpha

    init {
        dividerPaint.color = gap.color
        dividerPaint.strokeWidth = gap.height.toFloat()
    }

    override fun draw(
        canvas: Canvas,
        view: View,
        recyclerView: RecyclerView,
        state: RecyclerView.State
    ) {
        val viewHolder = recyclerView.getChildViewHolder(view)
        val nextViewHolder = recyclerView.findViewHolderForAdapterPosition(viewHolder.adapterPosition + 1)

        val startX = recyclerView.paddingLeft + gap.paddingStart
        val startY = view.bottom + view.translationY
        val stopX = recyclerView.width - recyclerView.paddingRight - gap.paddingEnd
        val stopY = startY

        dividerPaint.alpha = (view.alpha * alpha).toInt()

        val areSameHolders =
            viewHolder.itemViewType == nextViewHolder?.itemViewType ?: UNDEFINE_VIEW_HOLDER

        val drawMiddleDivider = Rules.checkMiddleRule(gap.rule) && areSameHolders
        val drawEndDivider = Rules.checkEndRule(gap.rule) && areSameHolders.not()

        if (drawMiddleDivider) {
            canvas.drawLine(startX.toFloat(), startY, stopX.toFloat(), stopY, dividerPaint)
        } else if (drawEndDivider) {
            canvas.drawLine(startX.toFloat(), startY, stopX.toFloat(), stopY, dividerPaint)
        }
    }
}


Pour configurer notre divader, nous utiliserons la classe Gap.kt:



class Gap(
    @ColorInt val color: Int = Color.TRANSPARENT,
    val height: Int = 0,
    val paddingStart: Int = 0,
    val paddingEnd: Int = 0,
    @DividerRule val rule: Int = MIDDLE or END
)


Cela aidera à ajuster la couleur, la hauteur, le remplissage horizontal et les règles de dessin du



séparateur. Le dernier héritier de ViewHolderDecor reste. Pour dessiner une image avec un effet de parallaxe.



class ParallaxDecor(
    context: Context,
    @DrawableRes resId: Int
) : Decorator.ViewHolderDecor {

    private val image: Bitmap? = AppCompatResources.getDrawable(context, resId)?.toBitmap()

    override fun draw(
        canvas: Canvas,
        view: View,
        recyclerView: RecyclerView,
        state: RecyclerView.State
    ) {

        val offset = view.top / 3
        image?.let { btm ->
            canvas.drawBitmap(
                btm,
                Rect(0, offset, btm.width, view.height + offset),
                Rect(view.left, view.top, view.right, view.bottom),
                null
            )
        }
    }
}


Mettons tout ensemble maintenant.



private val decorator by lazy {
        Decorator.Builder()
            .underlay(longCardController.viewType() to roundDecor)
            .underlay(spaceController.viewType() to paralaxDecor)
            .overlay(shortCardController.viewType() to dividerDrawer2Dp)
            .offset(longCardController.viewType() to horizontalOffsetDecor)
            .offset(shortCardController.viewType() to horizontalOffsetDecor)
            .offset(spaceController.viewType() to horizontalAndVerticalOffsetDecor)
            .build()
    }


Nous initialisons le RecyclerView, y ajoutons notre décorateur et nos contrôleurs:



private fun init() {
        with(recycler_view) {
            layoutManager = LinearLayoutManager(this@LinearDecoratorActivityView)
            adapter = easyAdapter
            addItemDecoration(decorator)
            setPadding(0, 16.px, 0, 16.px)
        }

        ItemList.create()
            .apply {
                repeat(3) {
                    add(longCardController)
                }
                add(spaceController)
                repeat(5) {
                    add(shortCardController)
                }
            }
            .also(easyAdapter::setItems)
    }


C'est tout. Le décor sur notre liste est prêt.



Nous avons réussi à écrire un ensemble de décorateurs qui peuvent être facilement réutilisés et personnalisés de manière flexible.



Voyons comment les décorateurs peuvent être appliqués.



PageIndicator pour RecyclerView horizontal



Messages de chat à bulles et barre de défilement:



Un cas plus complexe - dessiner des formes, des icônes, changer de thème sans recharger l'écran:





En-tête collant



StickyHeaderDecor.kt


Code source avec des exemples



Conclusion



Malgré la simplicité de l'interface ItemDecoration, elle vous permet de faire des choses complexes avec la liste sans changer la mise en page. J'espère avoir pu montrer que c'est un outil suffisamment puissant et digne de votre attention. Et notre bibliothèque vous aidera à décorer vos listes plus facilement.



Merci à tous pour votre attention, je serai heureux de vos commentaires.



UPD: 08/06/2020 ajout d'un exemple pour l'en-tête collant



All Articles