Desplazar Contenido Con El ScrollView En Android

El ScrollView en Android te permite albergar una jerarquía de views con el fin de desplazar su contenido a lo largo de la pantalla, cuando sus dimensiones exceden el tamaño de la misma.

Aspecto de ScrollView en Android

El usuario hará scroll a través de un gesto de swipe vertical u horizontal para revelar la información limitada por el tamaño del contenedor.

Ejemplo De ScrollView En Android

En este tutorial verás con ejemplos como usar scroll vertical, scroll horizontal y como anidación de scrolls. La siguiente imagen muestra la App de ilustración creada para comprender los conocimientos expuestos:

Descargar el proyecto Android Studio desde siguiente enlace:

Comencemos con el primer caso de scrolling.

Scrolling Vertical

Si la información que deseas proyectar verticalmente es más grande que la orientación de tu pantalla móvil, entonces envuelve el ViewGroup del contenido con un objeto de la clase ScrollView:

ScrollView soporta un solo hijo como contexto de desplazamiento, por lo que la estructura general de tu diseño debe encontrarse en él.

Puedes añadir este elemento desde Android Studio yendo a Palette > Containers > ScrollView:

Añadir ScrollView en Android Studio

O recubre tu ViewGroup principal desde el layout con la etiqueta <ScrollView>. El ejemplo que usamos para representar desplazamiento vertical se ve así:

<?xml version="1.0" encoding="utf-8"?>
<ScrollView 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:id="@+id/scrollView"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="16dp"
        tools:context=".MainActivity">

        <TextView
            android:id="@+id/child_scroll"
            android:layout_width="0dp"
            android:layout_height="256dp"
            android:background="@color/blue2"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/headline"
            android:layout_width="0dp"
            android:layout_height="40dp"
            android:layout_marginTop="16dp"
            android:background="@color/blue3"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/child_scroll" />

        <TextView
            android:id="@+id/indicator_1"
            android:layout_width="0dp"
            android:layout_height="80dp"
            android:layout_marginTop="16dp"
            android:layout_marginEnd="8dp"
            android:layout_marginRight="8dp"
            android:background="@color/orange"
            app:layout_constraintEnd_toStartOf="@+id/indicator_2"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/headline" />

        <TextView
            android:id="@+id/indicator_2"
            android:layout_width="0dp"
            android:layout_height="80dp"
            android:layout_marginTop="16dp"
            android:layout_marginEnd="8dp"
            android:layout_marginRight="8dp"
            android:background="@color/orange2"
            app:layout_constraintEnd_toStartOf="@+id/indicator_3"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toEndOf="@+id/indicator_1"
            app:layout_constraintTop_toBottomOf="@+id/headline" />

        <TextView
            android:id="@+id/indicator_3"
            android:layout_width="0dp"
            android:layout_height="80dp"
            android:layout_marginTop="16dp"
            android:layout_marginEnd="8dp"
            android:layout_marginRight="8dp"
            android:background="@color/orange3"
            app:layout_constraintEnd_toStartOf="@+id/indicator_4"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toEndOf="@+id/indicator_2"
            app:layout_constraintTop_toBottomOf="@+id/headline" />

        <TextView
            android:id="@+id/indicator_4"
            android:layout_width="0dp"
            android:layout_height="80dp"
            android:layout_marginTop="16dp"
            android:background="@color/orange4"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toEndOf="@+id/indicator_3"
            app:layout_constraintTop_toBottomOf="@+id/headline" />

        <TextView
            android:id="@+id/body_text"
            android:layout_width="0dp"
            android:layout_height="512dp"
            android:layout_marginTop="16dp"
            android:background="@color/blue4"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/indicator_1" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

El diseño anterior usa un tamaño de 512dp en el último elemento body_text con el fin de desbordar la capacidad de la pantalla. Al conectar elementos en el editor de layouts en Android Studio verás una zona punteada consciente del exceso:

Zona punteada en editor de layouts

Es necesario que uses wrap_content en android:layout_height del contenedor directo para ajustar la zona al scrolling.

Scrolling Horizontal

El mismo criterio aplica para los contenidos que abruman horizontalmente la pantalla del dispositivo. Usa la clase HorizontalScrollView para proveer desplazamiento entre los límites del contenedor:

HorizontalScrollView en Android

Al igual que ScrollView, HorizontalScrollView hereda de la clase FrameLayout, por lo que la jerarquía a scrollear debe ser parte de un solo nodo.

Para lograr el resultado visto en el ejemplo de la imagen anterior, el layout ha sido recubierto por el desplazador horizontal de la siguiente manera:

<?xml version="1.0" encoding="utf-8"?>
<HorizontalScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/scroll"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:padding="16dp">

        <TextView
            android:id="@+id/piece1"
            android:layout_width="90dp"
            android:layout_height="90dp"
            android:background="@color/blue1"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/block2"
            android:layout_width="180dp"
            android:layout_height="0dp"
            android:layout_marginStart="16dp"
            android:layout_marginLeft="16dp"
            android:background="@color/blue2"
            app:layout_constraintBottom_toBottomOf="@+id/piece1"
            app:layout_constraintStart_toEndOf="@+id/piece1"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/block3"
            android:layout_width="40dp"
            android:layout_height="0dp"
            android:layout_marginStart="16dp"
            android:layout_marginLeft="16dp"
            android:background="@color/blue3"
            app:layout_constraintBottom_toBottomOf="@+id/piece1"
            app:layout_constraintStart_toEndOf="@+id/block2"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/block4"
            android:layout_width="40dp"
            android:layout_height="40dp"
            android:layout_marginStart="16dp"
            android:layout_marginLeft="16dp"
            android:background="@color/orange1"
            app:layout_constraintStart_toEndOf="@+id/block3"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/block5"
            android:layout_width="40dp"
            android:layout_height="0dp"
            android:layout_marginStart="16dp"
            android:layout_marginLeft="16dp"
            android:layout_marginTop="16dp"
            android:background="@color/orange2"
            app:layout_constraintBottom_toBottomOf="@+id/block3"
            app:layout_constraintStart_toEndOf="@+id/block3"
            app:layout_constraintTop_toBottomOf="@+id/block4" />

        <TextView
            android:id="@+id/block6"
            android:layout_width="80dp"
            android:layout_height="0dp"
            android:layout_marginStart="16dp"
            android:layout_marginLeft="16dp"
            android:background="@color/orange3"
            app:layout_constraintBottom_toBottomOf="@+id/block4"
            app:layout_constraintStart_toEndOf="@+id/block4"
            app:layout_constraintTop_toTopOf="@+id/block4"
            app:layout_constraintVertical_bias="0.0" />

        <TextView
            android:id="@+id/block7"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:background="@color/orange4"
            app:layout_constraintBottom_toBottomOf="@+id/block5"
            app:layout_constraintEnd_toEndOf="@+id/block8"
            app:layout_constraintStart_toStartOf="@+id/block6"
            app:layout_constraintTop_toTopOf="@+id/block5" />

        <TextView
            android:id="@+id/block8"
            android:layout_width="80dp"
            android:layout_height="40dp"
            android:layout_marginStart="16dp"
            android:layout_marginLeft="16dp"
            android:layout_marginBottom="5dp"
            android:background="@color/blue3"
            app:layout_constraintBottom_toTopOf="@+id/block7"
            app:layout_constraintStart_toEndOf="@+id/block6"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.0" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</HorizontalScrollView>

El ViewGroup directo del HorizontalScrollView debe asignar wrap_content a su atributo android:layout_width para mantener el dinamismo del deslizamiento interno.

Anidar Scrolls Con NestedScrollView

En el caso que requieras añadir un ScrollView dentro de otro, transiciona directamente al uso de la clase NestedScrollView. En nuestra app de ejemplo proyectamos un scroll vertical secundario que habita en el scroll principal:

NestedScrollView en Android

Este componente puede actuar como padre o hijo en una jerarquía de scrolling. Además de que habilita los patrones de scrolling propuestos en el Material Design.

El siguiente layout muestra la solución con un NestedScrollView haciendo de nodo principal del layout y otro como hijo anidado:

<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/parent_scroll"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:padding="16dp">

        <androidx.core.widget.NestedScrollView
            android:id="@+id/child_scroll"
            android:layout_width="match_parent"
            android:layout_height="180dp"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent">

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:orientation="vertical">

                <TextView
                    android:id="@+id/piece1"
                    android:layout_width="match_parent"
                    android:layout_height="90dp"
                    android:background="@color/blue1" />

                <TextView
                    android:id="@+id/piece2"
                    android:layout_width="match_parent"
                    android:layout_height="90dp"
                    android:layout_marginTop="4dp"
                    android:background="@color/blue2" />

                <TextView
                    android:id="@+id/piece3"
                    android:layout_width="match_parent"
                    android:layout_height="90dp"
                    android:layout_marginTop="4dp"
                    android:background="@color/blue3" />

                <TextView
                    android:id="@+id/piece4"
                    android:layout_width="match_parent"
                    android:layout_height="90dp"
                    android:layout_marginTop="4dp"
                    android:background="@color/orange4" />
            </LinearLayout>
        </androidx.core.widget.NestedScrollView>

        <TextView
            android:id="@+id/headline"
            android:layout_width="0dp"
            android:layout_height="120dp"
            android:layout_marginTop="16dp"
            android:background="@color/orange1"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/child_scroll" />

        <TextView
            android:id="@+id/body_text"
            android:layout_width="0dp"
            android:layout_height="512dp"
            android:layout_marginTop="16dp"
            android:background="@color/orange2"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/headline" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

Como ves, el hijo directo del scroll padre es un ConstraintLayout. Y el del child_scroll es un LineaLayout que expone cuatro piezas verticales.

Jerarquía con NestedScrollViews anidados

Detectar Cambios De Scroll

El NestedScrollView permite procesar los eventos de cambio de posición de los views que hacen parte del contenido desplazable. Por ejemplo, si deseamos cambiar el título de la Toolbar con las coordenadas y desplegar un Toast en el momento en que se llega al final del desplazamiento:

OnScrollChangeListener en NestedScrollView

Este resultado se consigue con usando un observador OnScrollChangeListener sobre el NestedScrollView. Sobrescribe el método onScrollChange() con las acciones a realizar cuando se detecte el cambio. Los parámetros que recibe son:

  • v: NestedScrollView al que le cambio la posición de scroll
  • scrollX, scrollY: Valores actuales de x e y
  • oldScrollX, oldScrollY: Valores previos en x e y

Teniendo esto claro, actualizar el título de la Toolbar lo conseguirnos modificando la propiedad title de la actividad con una plantilla de string que contenga a scrollX y scrollY:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val parentScroll: NestedScrollView = findViewById(R.id.parent_scroll)
        parentScroll.setOnScrollChangeListener(
            OnScrollChangeListener { v, scrollX, scrollY, _, _ ->
                title = "Scroll ($scrollX, $scrollY)"

                if (!v.canScrollVertically(1))
                    Toast.makeText(this, "Final", Toast.LENGTH_SHORT).show()
            })
    }
}

Por otro lado, podemos usar canScrollVertically() para averiguar si el NestedScrollView puede seguir desplazando el contenido hacia abajo (pasa un entero positivo) o arriba (pasa un entero negativo).

Como necesitamos determinar el final, entonces negamos la salida del método y construimos el Toast al cumplirse.

Scrollear Hasta Una Posición Específica

Supongamos que deseamos desplazar lentamente alguno de nuestros layouts de ejemplos sin que el usuario realice alguna acción:

smoothScrollBy() en ScrollView

¿Cómo lograrlo?

Usa el método smoothScrollBy() para indicar el desplazamiento en pixeles que se aplicarán programáticamente:

class MainActivity : AppCompatActivity() {
    private val scope = MainScope()
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val scrollView: ScrollView = findViewById(R.id.scrollView)

        scrollView.doOnAttach {
            beginToScroll(scrollView)
        }
    }

    private fun beginToScroll(scrollView: ScrollView) {
        scope.launch {
            val scrollUnits = 50
            while (scrollView.canScrollVertically(1)) {
                scrollView.smoothScrollBy(0, scrollUnits)
                delay(100)
            }
        }
    }
}

Luego de obtener la referencia del ScrollView, usamos la función de extensión doOnAttach() del framework. Esta operación nos permite iniciar una acción sobre el scroll view cuando esté preparado.

En este caso será la ejecución del método beginToScroll(), el cual ejecuta una corrutina cuyo fin es desplazar el control hasta que ya no sea posible (canScrollVertically()). Obviamente usamos delay() para para que el efecto sea visible.

Nota: También existe el método smoothScrollTo() para asignar la posición absoluta en vez del incremento. A su vez, existen variaciones de estos métodos sin «smooth», las cuales desplazan de inmediato.

Ocultar Barras De Scroll

Al desplazar el contenido de un widget de scroll se presentan barras de color gris para indicar el estado y avance del scroll:

Barras de scroll activadas por defecto

Para ocultarlas usa las propiedades mutables isHorizontalScrollBarEnabled y isVerticalScrollBarEnabled en false para deshabilitarlas.

class MainActivity : AppCompatActivity() {
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val scrollView: HorizontalScrollView = findViewById(R.id.scroll)

        scrollView.isHorizontalScrollBarEnabled = false
    }
}

O el atributo XML android:scrollbars con none:

<HorizontalScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/scroll"
    android:layout_width="match_parent"
    android:scrollbars="none"
    android:layout_height="wrap_content">

Más Contenidos Android

Únete Al Discord De Develou

Si tienes problemas con el código de este tutorial, preguntas, recomendaciones o solo deseas discutir sobre desarrollo Android conmigo y otros desarrolladores, únete a la comunidad de Discord de Develou y siéntete libre de participar como gustes. ¡Te espero!