Flutter StatefulWidget. Usos

De MediaWiki
Ir a la navegación Ir a la búsqueda

Scroll infinito aplicado a ListView

  • En este punto vamos a aprender como podemos cargar de forma dinámica un número indeterminado de elementos que se van a visualizar en una lista.
Para ello partimos del ejemplo realizado en la UD3 - Componentes => ListView.separated.
En dicho ejemplo disponíamos de un StatelessWidget que visualizaba con un ListView.separated, un conjunto de datos en una lista.
  • Lo que voy a hacer en este ejemplo es hacer crecer la lista de forma dinámica, añadiendo nuevos elementos a la misma, al llegar al final de los elementos cargados actualmente.
Para ello voy a hacer alguna modificación al código.
  • Convertiré el StatelessWidget en un StatefulWidget, ya que al añadir nuevos elementos a la lista, voy a necesitar redibujar el Widget.


En todas las páginas haremos lo mismo:
  • Importamos la librería de 'material' (recordar que existe un snippet que lo hace automático).
  • Creamos una clase (ListasInfinitePage) que derive de StatefulWidget. Se creará (si lo hacéis con un Snippet) una clase asociada que deriva de State.
  • Modificamos la clase State y en su métod build hacemos que devuelva un Scaffold con una Appbar (hacerlo con un Snippet)


Flutter dart scrollinifinito 1.JPG


  • Por lo tanto partimos del siguiente código (el atributo _datos no tiene ningún dato, ya que los iremos cargando dinámicamente):
import 'package:flutter/material.dart';

class ListasInfinitePage extends StatefulWidget {
  const ListasInfinitePage({Key? key}) : super(key: key);

  @override
  _ListasInfinitePageState createState() => _ListasInfinitePageState();
}

class _ListasInfinitePageState extends State<ListasInfinitePage> {
  final _datos = <String>[];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text('Ejemplo de listas con ListView.separated'),
        ),
        body: _obtenerLista()
    );
  }

  Widget _obtenerLista(){

    return ListView.separated(
        itemBuilder: (buildContext, index) => _elementoLista(index),
        separatorBuilder: (buildContext, index) => Divider(thickness: 10,color: Colors.green,),
        itemCount: _datos.length);
  }

  Widget _elementoLista(pos) {
    var elemLista = _datos.elementAt(pos);

    return ListTile(
      title: Text(elemLista),
      subtitle: Text('Texto aclaratorio de $elemLista'),
      leading: Icon(Icons.accessibility),
      trailing: Icon(Icons.more_vert),
      onTap: () {},
    );
  }


}



  • Primero voy a crear un método que permita añadir un número de elementos determinado a la lista. Lógicamente, al añadirse, se tendrá que llamar al método setState para que redibuje el StatefulWidget:
  /**
   * Añade el número de elementos indicados a la lista
   */
  void _addElementosLista(int num_elementos){
    int ultimoElemento = _datos.length;
    for(int cont=ultimoElemento; cont < ultimoElemento+ num_elementos; cont++){
      _datos.add('Elem $cont');
    }

    setState(() {

    });
  }


  • Al iniciarse el StatefulWidget, voy a sobreescribir el método initState() para que cargue datos la lista.
Recordar que ya comenté en la sección de Eventos algo acerca de ciclo de vida de los Widget´s
class _ListasInfinitePageState extends State<ListasInfinitePage> {
  final _datos = <String>[];
  final _numElementosListaAdd = 10;

  @override
  void initState() {
    // TODO: implement initState
    super.initState();
    _addElementosLista(_numElementosListaAdd);
  }
  .............


  • Ahora lo que tengo que hacer es 'controlar' cuando llego al final de la lista, para que en ese caso, cargar otro conjunto de elementos llamando al método.
Para ello hacemos uso del ScrollController.
Los pasos son parecidos a los que ya hicimos con con el TextField, empleando el TextEditingController.
  • Creamos un objeto de la clase ScrollController.
  • Asociamos el controlador a la lista, con la propiedad del constructor controller.
En el método initState, 'nos subscribimos' a los cambios de scroll que haya en la lista.
Si lo dejáramos así, al cerrar la pantalla y volver a abrirla (dentro de nuestra aplicación) estaríamos 'suscribiéndonos' continuamente sin haber liberado previamente los recursos que reservamos al suscribirnos. Por lo tanto debemos de 'desuscribirmos' al cerrar la pantalla. Esto lo hacemos en el método dispose().
class _ListasInfinitePageState extends State<ListasInfinitePage> {
  final _datos = <String>[];
  final _numElementosListaAdd = 10;

  ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    // TODO: implement initState
    super.initState();

    _addElementosLista(_numElementosListaAdd);
    _scrollController.addListener(() {
       print('SCROLL');     
    });

  }
  .................

  Widget _obtenerLista(){

    return ListView.separated(
        controller: _scrollController,
        itemBuilder: (buildContext, index) => _elementoLista(index),
        separatorBuilder: (buildContext, index) => Divider(thickness: 10,color: Colors.green,),
        itemCount: _datos.length);
  }

 ...................


  @override
  void dispose() {
    // TODO: implement dispose

    _scrollController.dispose();
    super.dispose();
  }


En esa clase están definidas muchas propiedades. Una de ellas es pixels que nos devuelve la posición actual en pixeles, y maxScrollExtent que nos devuelve la posición máxima en pixeles. Por tanto, cuando ambos valores sean iguales significa que hemos llegado al final del scroll de la lista y podremos añadir un nuevo grupo de elementos.


Código completo listasinfinite_page.dart:

import 'package:flutter/material.dart';

class ListasInfinitePage extends StatefulWidget {
  const ListasInfinitePage({Key? key}) : super(key: key);

  @override
  _ListasInfinitePageState createState() => _ListasInfinitePageState();
}

class _ListasInfinitePageState extends State<ListasInfinitePage> {
  final _datos = <String>[];
  final _numElementosListaAdd = 10;

  ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    // TODO: implement initState
    super.initState();

    _addElementosLista(_numElementosListaAdd);
    _scrollController.addListener(() {
     if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
       _addElementosLista(_numElementosListaAdd);
     }
    });

  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text('Ejemplo de listas con ListView.separated'),
        ),
        body: _obtenerLista()
    );
  }

  Widget _obtenerLista(){

    return ListView.separated(
        controller: _scrollController,
        itemBuilder: (buildContext, index) => _elementoLista(index),
        separatorBuilder: (buildContext, index) => Divider(thickness: 10,color: Colors.green,),
        itemCount: _datos.length);
  }

  Widget _elementoLista(pos) {
    var elemLista = _datos.elementAt(pos);

    return ListTile(
      title: Text(elemLista),
      subtitle: Text('Texto aclaratorio de $elemLista'),
      leading: Icon(Icons.accessibility),
      trailing: Icon(Icons.more_vert),
      onTap: () {},
    );
  }

  /**
   * Añade el número de elementos indicados a la lista
   */
  void _addElementosLista(int num_elementos){
    int ultimoElemento = _datos.length;
    for(int cont=ultimoElemento; cont < ultimoElemento+ num_elementos; cont++){
      _datos.add('Elem $cont');
    }

    setState(() {

    });
  }


  @override
  void dispose() {
    // TODO: implement dispose

    _scrollController.dispose();
    super.dispose();
  }
}



Datos provenientes de Internet

  • En el ejemplo anterior, disponemos los datos 'localmente' y podemos añadir otros 10 a la lista de forma inmediata.
En la realidad, los datos pueden venir de Internet, por lo que puede haber un cierto retraso en disponer de los mismos para poder ser visualizados en la lista.


  • Vamos a ver como podemos resolver este problema.
La idea es usar un Future para simular que va a haber un retardo en obtener los nuevos datos (por ejemplo, de una consulta a un servicio API REST y que nos devuelva los datos en formato de JSON).
Durante la descarga de los datos, vamos a visualizar un Widget que nos indique que se está procediendo a la descarga. Para ello vamos a usar una variable booleana (_isLoading).


  • Vamos a conseguir que se carguen 'de internet' de forma simulada datos, y haremos que aparezca un indicador de progreso y que al terminar de cargarse los datos, haga un pequeño scroll hacia arriba para indicar que disponemos de nuevos datos.
Flutter dart scrollinifinito 2.JPG



El código hasta este punto es el siguiente:

Código completo listasinfinite_page.dart:

import 'dart:async';

import 'package:flutter/material.dart';

class ListasInfinitePage extends StatefulWidget {
  const ListasInfinitePage({Key? key}) : super(key: key);

  @override
  _ListasInfinitePageState createState() => _ListasInfinitePageState();
}

class _ListasInfinitePageState extends State<ListasInfinitePage> {
  final _datos = <String>[];
  final _numElementosListaAdd = 10;
  bool _isLoading = false;     // Usado para saber cuando estamos descargando datos de internet

  ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    // TODO: implement initState
    super.initState();

    _addElementosLista(_numElementosListaAdd);
    _scrollController.addListener(() {
     if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
       //_addElementosLista(_numElementosListaAdd);
       _cargarElementosInternet();
     }
    });

  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text('Ejemplo de listas con ListView.separated'),
        ),
        body: _obtenerLista()
    );
  }

  Widget _obtenerLista(){

    return ListView.separated(
        controller: _scrollController,
        itemBuilder: (buildContext, index) => _elementoLista(index),
        separatorBuilder: (buildContext, index) => Divider(thickness: 10,color: Colors.green,),
        itemCount: _datos.length);
  }

  Widget _elementoLista(pos) {
    var elemLista = _datos.elementAt(pos);

    return ListTile(
      title: Text(elemLista),
      subtitle: Text('Texto aclaratorio de $elemLista'),
      leading: Icon(Icons.accessibility),
      trailing: Icon(Icons.more_vert),
      onTap: () {},
    );
  }

  /**
   * Añade el número de elementos indicados a la lista
   */
  void _addElementosLista(int num_elementos){
    int ultimoElemento = _datos.length;
    for(int cont=ultimoElemento; cont < ultimoElemento+ num_elementos; cont++){
      _datos.add('Elem $cont');
    }

    setState(() {});
  }

  /**
   * Añade datos a la lista de datos pero de forma asíncrona
   */
  Future _cargarElementosInternet()  {
    setState(() {     // Indicamos que estamos cargando para que se dibuje el Widget de carga
      _isLoading = true;
    });

    return Future.delayed(Duration(seconds: 3), vienenDatosInternet); // Aquí simulamos que tardamos un tiempo en cargar los datos
  }

  void vienenDatosInternet(){
    _isLoading = false;
    _addElementosLista(_numElementosListaAdd);
  }


  @override
  void dispose() {
    // TODO: implement dispose

    _scrollController.dispose();
    super.dispose();
  }
}


Podéis probarlo y comprobar como al llegar abajo de todo, al cabo de 3 segundos, la lista se recarga con otros 10 elementos.


  • Ahora vamos a hacer que mientras descarga los datos, aparezca un Widget que indique esa situación.
Para ello vamos a hacer uso de:
  • Widget Stack que permite poner un Widget encima de otro visualmente. Lo que vamos a hacers poner encima de la pantalla el Widget que visualiza un CircularProgressIndicator.
  • Widget CircularProgressIndicator: Hace aparecer un círculo con animación que indica que se está realizando un proceso.
  ..................
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text('Ejemplo de listas con ListView.separated'),
        ),
        body: Stack(children: [
          _obtenerLista(),
          _visualizarWidgetDescarga(),
        ])
    );
  }

  Widget _visualizarWidgetDescarga(){
    if (_isLoading){
      return Center(
        child: Column(
            mainAxisAlignment: MainAxisAlignment.end,
            children:[
              CircularProgressIndicator(color: Colors.red,),
              SizedBox(height: 10,)
            ]
        ),
      );
    }
    else{   // Si no se está cargando debemos devolver un Widget, por lo que devuelvo un contendor vacío
      return Container();
    }

  }

  ....................


  • Ahora como paso final, vamos a hacer que cuando termine de cargar, hago un pequeño scroll para arriba y así el usuario se de cuenta de que ya dispone de nuevos datos...
Para ello debemos hacer uso del ScrollController visto anteriormente, y hacer una pequeña animación, en la que vamos a indicar a qué posición debe moverse (lo sabemos por _scrollController.position.pixel / _scrollController.offset => posición actual a la que tenemos que sumar el desplazamiento que queramos que tenga)
Haré uso de la propiedad animateTo del ScrollController.
  void vienenDatosInternet(){
    _isLoading = false;
    _addElementosLista(_numElementosListaAdd);

    // Ya tenemos los datos, movemos el scroll
    _scrollController.animateTo(
        _scrollController.offset+100, duration: Duration(seconds: 1), curve: Curves.decelerate
    );
  }


Código completo listasinfinite_page.dart:

import 'dart:async';

import 'package:flutter/material.dart';

class ListasInfinitePage extends StatefulWidget {
  const ListasInfinitePage({Key? key}) : super(key: key);

  @override
  _ListasInfinitePageState createState() => _ListasInfinitePageState();
}

class _ListasInfinitePageState extends State<ListasInfinitePage> {
  final _datos = <String>[];
  final _numElementosListaAdd = 10;
  bool _isLoading = false;     // Usado para saber cuando estamos descargando datos de internet

  ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    // TODO: implement initState
    super.initState();

    _addElementosLista(_numElementosListaAdd);
    _scrollController.addListener(() {
     if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
       //_addElementosLista(_numElementosListaAdd);
       _cargarElementosInternet();
     }
    });

  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text('Ejemplo de listas con ListView.separated'),
        ),
        body: Stack(children: [
          _obtenerLista(),
          _visualizarWidgetDescarga(),
        ])
    );
  }

  Widget _visualizarWidgetDescarga(){
    if (_isLoading){
      return Center(
        child: Column(
            mainAxisAlignment: MainAxisAlignment.end,
            children:[
              CircularProgressIndicator(color: Colors.red,),
              SizedBox(height: 10,)
            ]
        ),
      );
    }
    else{   // Si no se está cargando debemos devolver un Widget, por lo que devuelvo un contendor vacío
      return Container();
    }

  }

  Widget _obtenerLista(){

    return ListView.separated(
        controller: _scrollController,
        itemBuilder: (buildContext, index) => _elementoLista(index),
        separatorBuilder: (buildContext, index) => Divider(thickness: 10,color: Colors.green,),
        itemCount: _datos.length);
  }

  Widget _elementoLista(pos) {
    var elemLista = _datos.elementAt(pos);

    return ListTile(
      title: Text(elemLista),
      subtitle: Text('Texto aclaratorio de $elemLista'),
      leading: Icon(Icons.accessibility),
      trailing: Icon(Icons.more_vert),
      onTap: () {},
    );
  }

  /**
   * Añade el número de elementos indicados a la lista
   */
  void _addElementosLista(int num_elementos){
    int ultimoElemento = _datos.length;
    for(int cont=ultimoElemento; cont < ultimoElemento+ num_elementos; cont++){
      _datos.add('Elem $cont');
    }

    setState(() {});
  }

  /**
   * Añade datos a la lista de datos pero de forma asíncrona
   */
  Future _cargarElementosInternet()  {
    setState(() {     // Indicamos que estamos cargando para que se dibuje el Widget de carga
      _isLoading = true;
    });

    return Future.delayed(Duration(seconds: 2), vienenDatosInternet); // Aquí simulamos que tardamos un tiempo en cargar los datos
  }

  void vienenDatosInternet(){
    _isLoading = false;
    _addElementosLista(_numElementosListaAdd);

    // Ya tenemos los datos, movemos el scroll
    _scrollController.animateTo(
        _scrollController.offset+100, duration: Duration(seconds: 1), curve: Curves.decelerate
    );
  }

  @override
  void dispose() {
    // TODO: implement dispose

    _scrollController.dispose();
    super.dispose();
  }

}



Pull

  • Lo que voy a hacer ahora es que si hacemos el scroll en sentido contrario (desplanzado el dedo de arriba-abajo) aparezca un indicador de progreso y los datos se inicialicen o cargar nuevos datos pero borrando todos los anteriores, para que la lista no crezca de forma indefinida.
Este es un ejemplo de uso, pero existen otros, como cuando buscamos correo en la aplicación del teléfono y al hacer esta operación (pull) aparecen los correos nuevos en la parte superior, desplanzando los antiguos hacia abajo.


  • Para ello sólo tengo que 'envolver' un Widget que tenga un scroll vertical con un Widget RefreshIndicator.
El widget RefreshIndicator tiene entre sus propiedades, la propiedad onRefresh la cual espera recibir un Future (que no devuelve nada). Cuando el Future acaba, el indicador de progreso que aparece en la parte superior desaparece.
Fijarse que esto podríamos aplicarlo para el ejemplo anterior, pero entonces los datos no se cargarían cuando llegamos al final de la lista, sólo al hacer un scroll en sentido contrario...
Flutter dart scrollinifinito 3.JPG


Código completo listasinfinite_page.dart:

import 'dart:async';

import 'package:flutter/material.dart';

class ListasInfinitePage extends StatefulWidget {
  const ListasInfinitePage({Key? key}) : super(key: key);

  @override
  _ListasInfinitePageState createState() => _ListasInfinitePageState();
}

class _ListasInfinitePageState extends State<ListasInfinitePage> {
  final _datos = <String>[];
  final _numElementosListaAdd = 10;
  bool _isLoading = false;     // Usado para saber cuando estamos descargando datos de internet

  ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    // TODO: implement initState
    super.initState();

    _addElementosLista(_numElementosListaAdd);
    _scrollController.addListener(() {
     if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
       //_addElementosLista(_numElementosListaAdd);
       _cargarElementosInternet();
     }
    });

  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text('Ejemplo de listas con ListView.separated'),
        ),
        body: Stack(children: [
          _obtenerLista(),
          _visualizarWidgetDescarga(),
        ])
    );
  }

  Widget _visualizarWidgetDescarga(){
    if (_isLoading){
      return Center(
        child: Column(
            mainAxisAlignment: MainAxisAlignment.end,
            children:[
              CircularProgressIndicator(color: Colors.red,),
              SizedBox(height: 10,)
            ]
        ),
      );
    }
    else{   // Si no se está cargando debemos devolver un Widget, por lo que devuelvo un contendor vacío
      return Container();
    }

  }

  Widget _obtenerLista(){

    return RefreshIndicator(
            onRefresh: _cargarElementosInternet2,
            child: ListView.separated(
                      controller: _scrollController,
                      itemBuilder: (buildContext, index) => _elementoLista(index),
                      separatorBuilder: (buildContext, index) => Divider(thickness: 10,color: Colors.green,),
                      itemCount: _datos.length),
          );
  }

  Future _cargarElementosInternet2(){
    setState(() {
      _datos.clear();   // Borramos todo
    });
    
    return Future.delayed(Duration(seconds: 2), vienenDatosInternet); // Aquí simulamos que tardamos un tiempo en cargar los datos
  }

  /**
   * Devuelve el Widget que va a visualizar la lista en la posición indicada
   */
  Widget _elementoLista(pos) {
    var elemLista = _datos.elementAt(pos);

    return ListTile(
      title: Text(elemLista),
      subtitle: Text('Texto aclaratorio de $elemLista'),
      leading: Icon(Icons.accessibility),
      trailing: Icon(Icons.more_vert),
      onTap: () {},
    );
  }

  /**
   * Añade el número de elementos indicados a la lista
   */
  void _addElementosLista(int num_elementos){
    int ultimoElemento = _datos.length;
    for(int cont=ultimoElemento; cont < ultimoElemento+ num_elementos; cont++){
      _datos.add('Elem $cont');
    }

    setState(() {});
  }

  /**
   * Añade datos a la lista de datos pero de forma asíncrona
   */
  Future _cargarElementosInternet()  {
    setState(() {     // Indicamos que estamos cargando para que se dibuje el Widget de carga
      _isLoading = true;
    });

    return Future.delayed(Duration(seconds: 2), vienenDatosInternet); // Aquí simulamos que tardamos un tiempo en cargar los datos
  }

  void vienenDatosInternet(){
    _isLoading = false;
    _addElementosLista(_numElementosListaAdd);

    // Ya tenemos los datos, movemos el scroll
    if (_datos.length > _numElementosListaAdd){
      _scrollController.animateTo(
          _scrollController.offset+100, duration: Duration(seconds: 1), curve: Curves.decelerate
      );

    }
  }

  @override
  void dispose() {
    // TODO: implement dispose
    super.dispose();
    _scrollController.dispose();
  }

}




Visualizar mensajes

  • Más información en:


  • Cuando queremos informar al usuario de algo podemos hacer uso de los SnackBars, que son mensajes emergentes que aparecen en la parte inferior y que al cabo de unos segundos desaparecen.




Paso de datos entre Widgets








Enlace a la página principal de la UD4

Enlace a la página principal del curso




-- Ángel D. Fernández González -- (2021).