RecyclerView.ItemDecoration: aprovechándolo al máximo

Hola, querido lector de Habr. Mi nombre es Oleg Zhilo, durante los últimos 4 años he sido desarrollador de Android en Surf. Durante este tiempo, participé en todo tipo de proyectos interesantes, pero también tuve la oportunidad de trabajar con código heredado.



Estos proyectos tienen al menos una cosa en común: hay una lista de elementos en todas partes. Por ejemplo, una lista de contactos de la agenda o una lista de la configuración de su perfil.



Nuestros proyectos utilizan RecyclerView para listas. No le voy a decir cómo escribir un adaptador para RecyclerView o cómo actualizar correctamente los datos en la lista. En mi artículo, le contaré acerca de otro componente importante y que a menudo se pasa por alto: RecyclerView.ItemDecoration, le mostraré cómo usarlo para el diseño de listas y lo que puede hacer.







Además de los datos de la lista, RecyclerView también contiene elementos decorativos importantes, por ejemplo, separadores de celdas, barras de desplazamiento. Y aquí RecyclerView.ItemDecoration nos ayudará a dibujar toda la decoración y no producir Vistas innecesarias en el diseño de las celdas y la pantalla.



ItemDecoration es una clase abstracta con 3 métodos:



Método para renderizar la decoración antes de renderizar ViewHolder



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


Método para renderizar la decoración después de renderizar ViewHolder



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


Método para sangrar ViewHolder al llenar RecyclerView



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


Mediante la firma de los métodos onDraw *, puede ver que se utilizan 3 componentes principales para dibujar la decoración.



  • Lienzo: para renderizar la decoración necesaria.
  • RecyclerView: para acceder a los parámetros del propio RecyclerVIew
  • RecyclerView.State: contiene información sobre el estado de RecyclerView


Conexión a RecyclerView



Hay dos métodos para conectar una instancia de ItemDecoration a RecyclerView:



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


Todas las instancias de RecyclerView.ItemDecoration conectadas se agregan a una lista y todas se renderizan a la vez.



Además, RecyclerView tiene métodos adicionales para manipular ItemDecoration.

Eliminando ItemDecoration por índice



public void removeItemDecorationAt(int index)


Eliminar una instancia de ItemDecoration



public void removeItemDecoration(@NonNull ItemDecoration decor)


Obtener ItemDecoration por índice



public ItemDecoration getItemDecorationAt(int index)


Obtenga el recuento actual de ItemDecoration conectado en RecyclerView



public int getItemDecorationCount()


Redibujar la lista actual ItemDecoration



public void invalidateItemDecorations()


El SDK ya tiene herederos de RecyclerView.ItemDecoration, por ejemplo, DeviderItemDecoration. Te permite dibujar separadores para celdas.



Funciona de manera muy simple, necesita usar un elemento de diseño y DeviderItemDecoration lo dibujará como un separador de celda.



Creemos 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>


Y conecte DividerItemDeoration a RecyclerView:



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


Obtenemos:





Ideal para ocasiones sencillas.



Todo es elemental bajo el "capó" 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);
}


Para cada llamada a onDraw (...), recorra toda la Vista actual en RecyclerView y dibuje el elemento de dibujo pasado.



Pero la pantalla puede contener elementos de diseño más complejos que una lista de elementos idénticos. La pantalla puede incluir:



a. Varios tipos de células;

segundo. Varios tipos de divisores;

C. Las celdas pueden tener bordes redondeados;

re. Las celdas pueden tener diferentes sangrías verticales y horizontales dependiendo de algunas condiciones;

mi. Todo lo anterior a la vez.



Veamos el punto e. Fijémonos una tarea difícil y consideremos su solución.



Tarea:



  • Hay 3 tipos de células únicas en la pantalla, llamémosles a, b y c .
  • Todas las celdas tienen una sangría de 16 dp horizontalmente.
  • La celda b también tiene un desplazamiento vertical de 8 dp.
  • La celda a tiene bordes redondeados en la parte superior si es la primera celda del grupo y en la parte inferior si es la última celda del grupo.
  • Los divisores se dibujan entre celdas con, PERO no debe haber un divisor después de la última celda del grupo.
  • Se dibuja una imagen con efecto de paralaje sobre el fondo de la celda c .


Debería terminar así:





Consideremos las opciones para resolver:



Llenar la lista con celdas de diferentes tipos.



Puede escribir su propio Adaptador o puede usar su biblioteca favorita.

Me va a utilizar EasyAdapter .



Sangría de celdas.



Hay tres formas:



  1. Establezca paddingStart y paddingEnd para RecyclerView.

    Esta solución no funcionará si no todas las celdas tienen la misma sangría.
  2. Establezca layout_marginStart y layout_marginEnd en la celda.

    Deberá agregar las mismas sangrías a todas las celdas de la lista.
  3. Escriba una implementación de ItemDecoration y anule el método getItemOffsets.

    Ya mejor, la solución será más versátil y reutilizable.


Redondeo de esquinas para grupos de celdas.



La solución parece obvia: quiero agregar inmediatamente alguna enumeración {Inicio, Medio, Fin} y ponerla en la celda junto con los datos. Pero las desventajas aparecen de inmediato:



  • El modelo de datos de la lista se vuelve más complicado.
  • Para tales manipulaciones, deberá calcular de antemano qué enumeración asignar a cada celda.
  • Después de eliminar / agregar un elemento a la lista, tendrá que volver a calcularlo.
  • ItemDecoration. Puede comprender qué celda del grupo es y dibujar correctamente el fondo en el método onDraw * ItemDecoration.


Separadores de dibujo.



Dibujar divisores dentro de una celda es una mala práctica, ya que el resultado será un diseño complicado, las pantallas complejas tendrán problemas con la visualización dinámica de los divisores. Y así ItemDecoration gana de nuevo. El DeviderItemDecoration listo para usar del sdk no funcionará para nosotros, ya que dibuja divisores después de cada celda, y esto no se puede resolver de inmediato. Necesita escribir su propia implementación.



Paralaje en el fondo de la celda.



Puede que se le ocurra una idea para poner el RecyclerView OnScrollListener y usar alguna Vista personalizada para representar la imagen. Pero aquí nuevamente ItemDecoration nos ayudará, ya que tiene acceso al Canvas Recycler y todos los parámetros necesarios.



En total, necesitamos escribir al menos 4 implementaciones de ItemDecoration. Es muy bueno que podamos reducir todos los puntos a trabajar solo con ItemDecoration y no tocar el diseño y la lógica comercial de la función. Además, todas las implementaciones de ItemDecoration se pueden reutilizar si tenemos casos similares en la aplicación.



Sin embargo, en los últimos años, las listas complejas han aparecido en nuestros proyectos cada vez con más frecuencia y cada vez tuvimos que escribir un conjunto de ItemDecoration para las necesidades del proyecto. Se necesitaba una solución más universal y flexible para poder reutilizarla en otros proyectos.



¿Qué objetivos querías alcanzar?



  1. Escriba la menor cantidad posible de herederos de ItemDecoration.
  2. Separe la lógica de representación en el lienzo y el relleno.
  3. Obtenga los beneficios de trabajar con los métodos onDraw y onDrawOver.
  4. Hacer que los decoradores sean más flexibles en la personalización (por ejemplo, dibujar divisores por condición, en lugar de todas las celdas).
  5. Tome una decisión sin hacer referencia a Dividers, porque ItemDecoration es capaz de más que dibujar líneas horizontales y verticales.
  6. Esto se puede aprovechar fácilmente mirando el proyecto de muestra.


Como resultado, tenemos una biblioteca decoradora RecyclerView.



La biblioteca tiene una interfaz Builder simple, interfaces separadas para trabajar con Canvas y sangrías, así como la capacidad de trabajar con métodos onDraw y onDrawOver. La implementación de ItemDecoration es solo una.



Volvamos a nuestro problema y veamos cómo resolverlo usando la biblioteca.

El constructor de nuestro decorador parece simple:




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


  • .underlay (...) - necesario para renderizar bajo ViewHolder.
  • .overlay (...) - necesario para dibujar sobre ViewHolder.
  • .offset (...): se utiliza para establecer el desplazamiento de ViewHolder.


Se utilizan 3 interfaces para dibujar decoración y establecer sangrías.



  • RecyclerViewDecor: renderiza la decoración en RecyclerView.
  • ViewHolderDecor: renderiza la decoración en RecyclerView, pero da acceso a ViewHolder.
  • OffsetDecor: se utiliza para establecer sangrías.


Pero eso no es todo. ViewHolderDecor y OffsetDecor se pueden vincular a un ViewHolder específico utilizando viewType, que le permite combinar varios tipos de decoraciones en una lista o incluso en una celda. Si no se pasa viewType, entonces ViewHolderDecor y OffsetDecor se aplicarán a todos los ViewHolders en RecyclerView. RecyclerViewDecor no tiene esa oportunidad, ya que está diseñado para funcionar con RecyclerView en general, y no con ViewHolders. Además, la misma instancia de ViewHolderDecor / RecyclerViewDecor se puede pasar tanto a overlay (...) como a underlay (...).



Empecemos a escribir el código



La biblioteca EasyAdapter usa ItemControllers para crear un ViewHolder. En resumen, son responsables de crear e identificar el ViewHolder. Para nuestro ejemplo, un controlador es suficiente, que puede mostrar diferentes ViewHolders. Lo principal es que viewType es único para cada diseño de celda. Se parece a esto:



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)


Para establecer las sangrías, necesitamos un descendiente 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)
    }
}


Para dibujar esquinas redondeadas, ViewHolder necesita un heredero de ViewHolderDecor. Aquí necesitamos un OutlineProvider para que el estado de prensa también se recorte en los bordes.



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
            }
        }
    }
}


Para dibujar divisores, escribiremos un heredero más 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)
        }
    }
}


Para configurar nuestro divader, usaremos la clase 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
)


Ayudará a ajustar el color, la altura, el relleno horizontal y las reglas de dibujo del



divisor. El último heredero de ViewHolderDecor permanece. Para dibujar una imagen con efecto de paralaje.



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
            )
        }
    }
}


Pongamos todo junto ahora.



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()
    }


Inicializamos RecyclerView, le agregamos nuestro decorador y controladores:



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)
    }


Eso es todo. La decoración de nuestra lista está lista.



Logramos escribir un conjunto de decoradores que se pueden reutilizar fácilmente y personalizar de manera flexible.



Veamos de qué otra forma se pueden aplicar los decoradores.



PageIndicator para RecyclerView horizontal



Mensajes de chat de burbujas y barra de desplazamiento:



Un caso más complejo: dibujar formas, íconos, cambiar el tema sin recargar la pantalla:





Encabezado pegajoso



StickyHeaderDecor.kt


Código fuente con ejemplos



Conclusión



A pesar de la simplicidad de la interfaz ItemDecoration, le permite hacer cosas complejas con la lista sin cambiar el diseño. Espero haber podido demostrar que esta es una herramienta lo suficientemente poderosa y digna de su atención. Y nuestra biblioteca le ayudará a decorar sus listas más fácilmente.



Gracias a todos por su atención, estaré encantado con sus comentarios.



Actualización: 06/08/2020 ejemplo agregado para encabezado fijo



All Articles