Aplicacions amb Flutter, Dart i Flame

Tutorial Flutter Flame Projectes   Recursos CITCEA
Exemples Dart Dades pràctiques     Inici

Galeria d'imatges

En aquest exemple farem una aplicació molt senzilla que només té una pantalla i el mínim de fitxers de programa (tres, en concret). L'aplicació mostrarà unes imatges en carrusel, inicialment se'n mostrarà una i amb un botó es podrà passar a les següents.

Preparació inicial

Si no l'havíem creat prèviament, crearem una carpeta al disc dur del nostre ordinador (millor si és a l'arrel) i li posarem un nom; per exemple development, aquí serà on guardarem tots els nostres projectes d'aplicacions.

Començarem creant un nou projecte en Flutter. Obrirem el programa Visual Studio Code i veurem un espai de cerca (Search) a la part superior. Allà començarem a escriure  >flutter  i aviat ens sortirà un menú d'opcions on triarem Flutter New Project. Ens sortiran diferents opcions, algunes de les quals ens donen ja part de la feina feta. Atès que volem veure tot el procés, triarem l'opció Empty Application. Ens preguntarà la carpeta on guardarem el projecte, li indicarem la carpeta que havíem creat abans (development). Llavors ens demanarà el nom que volem posar al projecte; el podem anomenar, per exemple, roda_fotos.

Un cop fet això, ja tindrem definit l'entorn amb un petit programa creat, que podem veure anant al menú lateral, obrint la carpeta lib i fent doble clic sobre main.dart.

A partir d'aquest punt, cada cop que haguem completat una modificació del programa podrem provar-lo amb l'emulador; així anem veient com ens acostem a la solució final i per què anem fent els diferents canvis.

Quan estem en el programa main.dart, a la part inferior dreta de la pantalla hi tenim una indicació de quin és l'emulador seleccionat. El més probable és que no n'hi hagi cap (No Device) o que hi hagi el de Windows; És possible, en cas que ja ho haguéssim fet abans, que ens surti un emulador d'Android.

Si no en tenim un d'Android, picant sobre el lloc on hi ha l'emulador seleccionat podrem triar quin farem servir. Si a la llista no hi ha cap emulador d'Android, haurem de picar l'opció de crear-ne un de nou. Si triem aquesta opció, passats uns segons, se'ns obrirà l'emulador creat. Si ja estava creat, només cal seleccionar-lo.

Picant sobre el botó d'executar (Simulador), que es troba a la part superior dreta de la finestra, passat un temps, veurem una imatge del resultat de l'aplicació. Cal tenir present que aquest botó només està disponible quan estem visualizant la pestanya main.dart.

Simulador

De moment, el resultat de l'aplicació no s'assembla de res al que volem aconseguir, però ja funciona. En aquest cas, escriu un text al centre de la pantalla. Al matgeix temps, a la pestanya Debug Console de la part inferior de la pantalla de Visual Studio Code ens surten informacions sobre la compilació; que ens seran útils en cas que hi hagi algun problema.

Si tenim aquesta barra d'eines, el botó del llamp groc permet guardar el programa i actualitzar l'aplicació; a més, entre altres, tenim botons per pausar, aturar o recarregar l'aplicació. Aquest darrer ens serà útil si ens sembla que l'emulador s'ha quedat penjat.

Barra d'eines

Paràmetres generals

De moment, només tenim el fitxer main.dart amb el contingut per defecte.

main.dart
import 'package:flutter/material.dart';
void main() {
  runApp(const MainApp());
}
class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: Center(
          child: Text('Hello World!'),
        ),
      ),
    );
  }
}

L'aplicació es pot anar creant en qualsevol ordre; l'única cosa important és que, si volem anar-la provant sobre la marxa, cal anar completant etapes. Això és important perquè si hi ha trossos incomplets ens donarà errors i l'emulador no funcionarà. En aquest cas, crearem l'aplicació començant per les coses més senzilles.

Encara que això es pot fer al final, podem començar per posar la descripció del nostre projecte. En el menú lateral, hem d'obrir el fitxer pubspec.yaml. Cap a la part superior trobarem el títol description seguit d'un text entre cometes. Aquí és on posarem la descripció de la nostra aplicació. Per exemple:

name: roda_fotos
description: "Galeria d'imatges"
publish_to: 'none'
version: 0.1.0
...

La resta de paràmetres els deixarem igual. Evidentment, un cop l'aplicació estigui acabada, anirem canviant el número de versió cada cop que fem canvis.

Primera versió

Comencem per canviar el color del fons. També li podem posar la barra de l'aplicació amb un títol. El primer que hem de fer és eliminar la paraula const de la línia del return. Llavors ja podem afegir la barra:

...
class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text("Galeria d'imatges"),),
        backgroundColor: Colors.blueGrey,
        body: Center(child: Text('Hello World!')),
      ),
    );
  }
}
...

Aquesta barra, però, té els colors per defecte; probablement unes lletres d'un color gris molt fosc sobre un fons pràcticament blanc. Seria convenient posar-hi uns colors que s'adeqüin més al fons que ja tenim.

...
      home: Scaffold(
        appBar: AppBar(
          title: Text("Galeria d'imatges"),
          backgroundColor: Colors.indigo,
          foregroundColor: Colors.white,
        ),
        backgroundColor: Colors.blueGrey,
        body: Center(child: Text('Hello World!')),
      ),
...

Divisió en fitxers

No és recomanable fer tota l'aplicació en la classe MainApp, perquè hi ha coses que necessiten estar dins un giny. La nostra aplicació només tindrà una pantalla; crearem un giny per a la pantalla i un giny per al contingut. Per començar, dins de la carpeta lib crearem els fitxers pant_principal.dart i contingut.dart. En aquests fitxers hi posarem un giny amb estat; que podem anomenar, respectivament, PantPrincipal i Contingut. Calen ginys amb estat perquè hi haurà elements que podran canviar durant el funcionament de l'aplicació. De moment, tindrem això:

main.dart
import 'package:flutter/material.dart';
import 'package:roda_fotos/pant_principal.dart';
void main() {
  runApp(const MainApp());
}
class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text("Galeria d'imatges"),
          backgroundColor: Colors.indigo,
          foregroundColor: Colors.white,
        ),
        backgroundColor: Colors.blueGrey,
        body: PantPrincipal()
      ),
    );
  }
}

pant_principal.dart
import 'package:flutter/material.dart';
import 'package:roda_fotos/contingut.dart';
class PantPrincipal extends StatefulWidget {
  const PantPrincipal({super.key});

  @override
  State<PantPrincipal> createState() => _PantPrincipalState();
}

class _PantPrincipalState extends State<PantPrincipal> {
  @override
  Widget build(BuildContext context) {
    return Contingut();
  }
}

contingut.dart
import 'package:flutter/material.dart';
class Contingut extends StatefulWidget {
  const Contingut({super.key});

  @override
  State<Contingut> createState() => ContingutState();
}

class ContingutState extends State<Contingut> {
  @override
  Widget build(BuildContext context) {
    return Center(child: Text('Hello World!'));
  }
}

Primera imatge

Començarem per mostrar una sola imatge; per tant, ens caldrà una foto. Nosaltres farem servir la que es mostra a continuació que podem descarregar aquí (aquesta imatge és pública i es pot trobar a UPCommons).

Barra d'eines

En aquest cas farem servir el mètode asset per mostrar la imatge; per tant, cal crear una carpeta anomenada assets en l'arrel del projecte i una carpeta images dins d'assets. Per crear la primera carpeta, posarem el cursor en la zona buida que hi ha sota de README.md, picarem amb el botó dret i triarem New Folder...; per crear la segona només cal que ens posem sobre la primera i piquem el botó de nova carpeta que hi ha a la part superior del menú lateral.

Ara haurem d'arrossegar la imatge des de la carpeta de l'ordinador fins deixar-la sobre la carpeta images que acabem de crear; així la tindrem disponible en el nostre projecte. El mateix faríem amb la resta d'imatges que ens facin falta.

També cal indicar a quina carpeta trobarà la imatge. Això ho farem editant el fitxer pubspec.yaml, afegint-li dues línies a la part de Flutter, sota les ja existents.

flutter:
  uses-material-design: true
  assets:
    - assets/images/

En aquest fitxer és molt important la sintaxi. Fixem-nos que assets està alineat amb uses. A l'inici de la línia següent hi ha un sagnat, un guió i un espai, després ja ve la ruta.

Un cop tenim carregada la imatge i configurada la ruta, ja podem mostrar-la a la pantalla. De moment, mantindrem el text i, per tant, farem servir una columna per situar una cosa sota l'altra. A la imatge li diem que ocupi l'amplada disponible mantenint les proporcions (fitWidth).

...
class ContingutState extends State<Contingut> {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Center(child: Text('Hello World!')),
        Image.asset("assets/images/etseib1990.jpg", fit: BoxFit.fitWidth,),
      ],
    );
  }
}

No queda massa bé que la imatge s'enganxi a les vores de la pantalla. És convenient posar la columna dins d'un farciment, per deixar-li marge.

...
    return Padding(
      padding: const EdgeInsets.all(20.0),
      child: Column(
        children: [
          Center(child: Text('Hello World!')),
          Image.asset("assets/images/etseib1990.jpg", fit: BoxFit.fitWidth,),
        ],
      ),
    );
...

Imatge aleatòria

Ara afegirem dues imatges més. Nosaltres farem servir les que es mostren a continuació que podem descarregar aquí i aquí (aquestes imatges són públiques i es poden trobar a UPCommons).

Barra d'eines       Barra d'eines

I farem que l'aplicació mostri aleatòriament una de les tres. Aquí cal tenir en compte diverses coses.

La generació del nombre aleatori l'haurem de fer a la pantalla principal. Ara és igual, però en versions posteriors ens donaria problemes si ho féssim al giny Contingut. Això és el que fa necessari tenir tres fitxers, ja que algunes de les coses que farem a la pantalla principal no es poden posar al programa principal (main). També serà necessari passar la ruta de la foto al giny Contingut.

pant_principal.dart;
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:roda_fotos/contingut.dart';
...
class _PantPrincipalState extends State<PantPrincipal> {
  List<String> fotos = ["etseib1990.jpg", "etseib_diag.jpg", "etseib_bib.jpg"];
  @override
  Widget build(BuildContext context) {
    int ind = Random().nextInt(fotos.length);
    return Contingut(
      foto: "assets/images/${fotos[ind]}"
    );
  }
}

contingut.dart;
import 'dart:math';
import 'package:flutter/material.dart';
class Contingut extends StatefulWidget {
  final String foto;
  const Contingut({super.key, required this.foto});

  @override
  State<Contingut> createState() => ContingutState();
}

class ContingutState extends State<Contingut> {
  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(20.0),
      child: Column(
        children: [
          Center(child: Text('Hello World!')),
          Image.asset(widget.foto, fit: BoxFit.fitWidth),
        ],
      ),
    );
  }
}

Carrusel amb botons

Ara crearem dos botons que ens permetin passar a les fotografies anterior i següent. Volem els botons de costat i, per tant, els posarem dins d'una filera. Aprofitarem també per eliminar el text, que és del tot innecessari.

...
    return Padding(
      padding: const EdgeInsets.all(20.0),
      child: Column(
        children: [
          Image.asset(widget.foto, fit: BoxFit.fitWidth),
          Row(
            children: [
              IconButton(onPressed: () {}, icon: Icon(Icons.arrow_back)),
              IconButton(onPressed: () {}, icon: Icon(Icons.arrow_forward)),
            ],
          ),
        ],
      ),
    );
...

Aquests botons ens queden a l'esquerra perquè la filera que hem creat té la mida mínima per contenir els dos botons. També ens queden molt enganxats a la imatge. Per solucionar-ho, podem posar la filera dins d'un farciment que deixi espai per la part superior i posar espaiadors a banda i banda dels botons.

...
    return Padding(
      padding: const EdgeInsets.all(20.0),
      child: Column(
        children: [
          Image.asset(widget.foto, fit: BoxFit.fitWidth),
          Padding(
            padding: const EdgeInsets.only(top: 20.0),
            child: Row(
              children: [
                Spacer(),
                IconButton(onPressed: () {}, icon: Icon(Icons.arrow_back)),
                Spacer(),
                IconButton(onPressed: () {}, icon: Icon(Icons.arrow_forward)),
                Spacer(),
              ],
            ),
          ),
        ],
      ),
    );
...

Per millorar l'aspecte dels botons, els posarem un color de fons i un color per a la icona. Per al color de fons, ens cal posar cada un dins d'un contenidor, que també ens servirà per definir unes vores arrodonides.

...
    return Padding(
      padding: const EdgeInsets.all(20.0),
      child: Column(
        children: [
          Image.asset(widget.foto, fit: BoxFit.fitWidth),
          Padding(
            padding: const EdgeInsets.only(top: 20.0),
            child: Row(
              children: [
                Spacer(),
                Container(
                  decoration: BoxDecoration(
                    color: Colors.cyan,
                    borderRadius: BorderRadius.circular(12),
                  ),
                  child: IconButton(
                    onPressed: () {},
                    color: Colors.white,
                    icon: Icon(Icons.arrow_back),
                  ),
                ),
                Spacer(),
                Container(
                  decoration: BoxDecoration(
                    color: Colors.cyan,
                    borderRadius: BorderRadius.circular(12),
                  ),
                  child: IconButton(
                    onPressed: () {},
                    color: Colors.white,
                    icon: Icon(Icons.arrow_forward),
                  ),
                ),
                Spacer(),
              ],
            ),
          ),
        ],
      ),
    );
...

L'aspecte dels botons ja es pot donar per acabat; però, de moment, no fan res. Anem a implementar les seves accions. Això implica fer canvis a la pantalla principal (on posarem els mètodes) i a la del contingut on els cridarem.

pant_principal.dart
...
class _PantPrincipalState extends State<PantPrincipal> {
  List<String> fotos = ["etseib1990.jpg", "etseib_diag.jpg", "etseib_bib.jpg"];
  @override
  Widget build(BuildContext context) {
    int ind = Random().nextInt(fotos.length);
    return Contingut(
      foto: "assets/images/${fotos[ind]}",
      incrementar: () {
        setState(() {
          ind++;
          if (ind == fotos.length) {
            ind = 0;
          }
        });
      },
      decrementar: () {
        setState(() {
          ind--;
          if (ind < 0) {
            ind += fotos.length;
          }
        });
      },
    );
  }
}

contingut.dart
class Contingut extends StatefulWidget {
  final String foto;
  final Function() incrementar;
  final Function() decrementar;
  const Contingut({
    super.key,
    required this.foto,
    required this.incrementar,
    required this.decrementar,
  });

  @override
  State<Contingut> createState() => ContingutState();
}

class ContingutState extends State<Contingut> {
  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(20.0),
      child: Column(
        children: [
          Image.asset(widget.foto, fit: BoxFit.fitWidth),
          Padding(
            padding: const EdgeInsets.only(top: 20.0),
            child: Row(
              children: [
                Spacer(),
                Container(
                  decoration: BoxDecoration(
                    color: Colors.cyan,
                    borderRadius: BorderRadius.circular(12),
                  ),
                  child: IconButton(
                    onPressed: () {
                      widget.decrementar();
                    },
                    color: Colors.white,
                    icon: Icon(Icons.arrow_back),
                  ),
                ),
                Spacer(),
                Container(
                  decoration: BoxDecoration(
                    color: Colors.cyan,
                    borderRadius: BorderRadius.circular(12),
                  ),
                  child: IconButton(
                    onPressed: () {
                      widget.incrementar();
                    },
                    color: Colors.white,
                    icon: Icon(Icons.arrow_forward),
                  ),
                ),
                Spacer(),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Si ho provem, observarem que no acaba de funcionar. El motiu és que quan es prem un dels polsadors es refan els ginys i, per tant, es torna a determinar un valor aleatori per a la variable ind. Podríem intentar posar la línia del valor aleatori més amunt:

...
class _PantPrincipalState extends State<PantPrincipal> {
  List<String> fotos = ["etseib1990.jpg", "etseib_diag.jpg", "etseib_bib.jpg"];
  int ind = Random().nextInt(fotos.length);
  @override
  Widget build(BuildContext context) {
    return Contingut(
...

però això no funcionaria perquè no ens deixa accedir a la variable fotos fora del giny. Cal, doncs, modificar el programa de la pantalla principal perquè el valor aleatori només es calculi una vegada.

...
class _PantPrincipalState extends State<PantPrincipal> {
  List<String> fotos = ["etseib1990.jpg", "etseib_diag.jpg", "etseib_bib.jpg"];
  bool inici = true;
  int ind = 0;
  @override
  Widget build(BuildContext context) {
    if(inici){
      ind = Random().nextInt(fotos.length);
      inici = false;
    }
    return Contingut(
...

Ara l'aplicació hauria de funcionar correctament.

Carrusel automàtic

Volem afegir que, a banda dels botons, les imatges avancin automàticament cada cinc segons. Ens caldrà, doncs, fer servir un temporitzador periòdic. Haurem d'importar la biblioteca dart:async, crear un objecte de tipus Timer en el nostre giny i afegir-hi dos mètodes (initState i dispose) que modifiquen mètodes interns de l'objecte. En el mètode initState caldrà indicar quina funció s'executa quan passi el temps establert.

Però en la mostra pantalla principal no tenim, de fet, cap funció que li poguem passar, atès que les funcions que passem al giny de components les definim directament en el moment de passar-les; caldrà, doncs, posar aquestes instruccions en un mètode que serà el que passarem tant al giny de components com a la funció initState.

pant_principal.dart
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:roda_fotos/contingut.dart';
import 'dart:async';
class PantPrincipal extends StatefulWidget {
  const PantPrincipal({super.key});

  @override
  State<PantPrincipal> createState() => _PantPrincipalState();
}

class _PantPrincipalState extends State<PantPrincipal> {
  List<String> fotos = ["etseib1990.jpg", "etseib_diag.jpg", "etseib_bib.jpg"];
  bool inici = true;
  int ind = 0;
  Timer? timer;
  @override
  Widget build(BuildContext context) {
    if (inici) {
      ind = Random().nextInt(fotos.length);
      inici = false;
    }
    return Contingut(
      foto: "assets/images/${fotos[ind]}",
      incrementar: () {
        incre();
      },
      decrementar: () {
        setState(() {
          ind--;
          if (ind < 0) {
            ind += fotos.length;
          }
        });
      },
    );
  }

  void incre() {
    setState(() {
      ind++;
      if (ind == fotos.length) {
        ind = 0;
      }
    });
  }

  @override
  void initState() {
    super.initState();
    timer = Timer.periodic(Duration(microseconds: 5), (Timer t) => incre());
  }

  @override
  void dispose() {
    timer?.cancel();
    super.dispose();
  }
}

Queda poc elegant que hi hagi una funció per a incrementar i, en canvi, el contingut de l'acció de decrementar estigui escrit directament. Seria més raonable que les dues accions s'implementessin de la mateixa forma. Podríem crear una segona funció per a decrementar, però sembla més raonable fer-ne una única que incrementi o decrementi segons un paràmetre.

pant_principal.dart
class _PantPrincipalState extends State<PantPrincipal> {
  List<String> fotos = ["etseib1990.jpg", "etseib_diag.jpg", "etseib_bib.jpg"];
  bool inici = true;
  int ind = 0;
  Timer? timer;
  @override
  Widget build(BuildContext context) {
    if (inici) {
      ind = Random().nextInt(fotos.length);
      inici = false;
    }
    return Contingut(
      foto: "assets/images/${fotos[ind]}",
      incrementar: () {incre(1);},
      decrementar: () {incre(-1);},
    );
  }

  void incre(int pas) {
    setState(() {
      ind += pas;
      if (ind == fotos.length) {ind = 0;}
      if (ind < 0) {ind += fotos.length;}
    });
  }

  @override
  void initState() {
    super.initState();
    timer = Timer.periodic(Duration(seconds: 5), (Timer t) => incre(1));
  }

  @override
  void dispose() {
    timer?.cancel();
    super.dispose();
  }
}

Acció temporitzada

Aprofitant que estem provant accions que depenen del temps, encara que potser no tindria molt sentit en una aplicació real, afegirem un botó més que quan es premi faci anar enrere però no de manera immediata sinó passats dos segons. Caldrà fer canvis a la pantalla principal i al giny del contingut. A la pantalla principal:

...
class _PantPrincipalState extends State<PantPrincipal> {
  List<String> fotos = ["etseib1990.jpg", "etseib_diag.jpg", "etseib_bib.jpg"];
  bool inici = true;
  int ind = 0;
  Timer? timer;
  @override
  Widget build(BuildContext context) {
    if (inici) {
      ind = Random().nextInt(fotos.length);
      inici = false;
    }
    return Contingut(
      foto: "assets/images/${fotos[ind]}",
      incrementar: () {incre(1);},
      decrementar: () {incre(-1);},
      retard: () {retardat();},
    );
  }

  void retardat() {
    Future.delayed(Duration(seconds: 2), () {incre(-1);});
  }

  void incre() {
    setState(() {
      ind++;
      if (ind == fotos.length) {
        ind = 0;
      }
    });
  }

  @override
  void initState() {
    super.initState();
    timer = Timer.periodic(Duration(microseconds: 5), (Timer t) => incre());
  }

  @override
  void dispose() {
    timer?.cancel();
    super.dispose();
  }
}

Al giny de contingut:

            child: Row(
              children: [
                Spacer(),
                Container(
                  decoration: BoxDecoration(
                    color: Colors.cyan,
                    borderRadius: BorderRadius.circular(12),
                  ),
                  child: IconButton(
                    onPressed: () {widget.decrementar();},
                    color: Colors.white,
                    icon: Icon(Icons.arrow_back),
                  ),
                ),
                Spacer(),
                Container(
                  decoration: BoxDecoration(
                    color: Colors.cyan,
                    borderRadius: BorderRadius.circular(12),
                  ),
                  child: IconButton(
                    onPressed: () {widget.retard();},
                    color: Colors.white,
                    icon: Icon(Icons.timer),
                  ),
                ),
                Spacer(),
                Container(
                  decoration: BoxDecoration(
                    color: Colors.cyan,
                    borderRadius: BorderRadius.circular(12),
                  ),
                  child: IconButton(
                    onPressed: () {widget.incrementar();},
                    color: Colors.white,
                    icon: Icon(Icons.arrow_forward),
                  ),
                ),
                Spacer(),
              ],
            ),

Per fer això, en lloc d'emprar Future.delayed, podíem haver fet servir Timer, amb el mateix resultat.

...
  void retardat() {
    Timer(Duration(seconds: 2), () {incre(-1);});
  }
...

 

 

 

 

 

 

 

 

 

 

Llicència de Creative Commons
Aquesta obra d'Oriol Boix està llicenciada sota una llicència no importada Reconeixement-NoComercial-SenseObraDerivada 3.0.