Salta al contenuto principale

Pull to refresh (trascina per aggiornare)

Riverpod supporta nativamente il meccanismo di pull-to-refresh grazie alla sua natura dichiarativa.

In generale, un meccanismo di pull-to-refresh può essere complesso a causa di diversi problemi da risolvere:

  • Al primo accesso in una pagina, vogliamo mostrare uno spinner. Ma durante l'aggiornamento, vogliamo invece mostrare l'indicatore di aggiornamento. Non dovremmo far vedere sia l'indicatore che lo spinner.
  • Mentre un aggiornamento è in attesa, vogliamo mostrare il dato/errore precedente.
  • Dobbiamo mostrare l'indicatore di aggiornamento per tutto il tempo in cui l'aggiornamento è in corso.

Vediamo come risolvere questo usando Riverpod. Faremo un semplice esempio che consiglia un'attività casuale agli utenti. E facendo un pull-to-refresh attiveremo un nuovo suggerimento:

A gif of the previously described application workingn. ## Realizzare un'applicazione semplice.

Prima di implementare un meccanismo di pull-to-refresh, abbiamo bisogno qualcosa da aggiornare. Possiamo fare una semplice applicazione che usa le Bored API per consigliare un'attività casuale agli utenti.

Per prima cosa, definiamo una classe Activity


class Activity with _$Activity {
factory Activity({
required String activity,
required String type,
required int participants,
required double price,
}) = _Activity;

factory Activity.fromJson(Map<String, dynamic> json) =>
_$ActivityFromJson(json);
}

Questa classe rappresenterà un'attività consigliata in un modo type-safe, e di gestire la codifica/decodifica JSON. Usare Freezed/json_serializable non è richiesto ma consigliato.

Ora, vogliamo definire un provider che effettua una richiesta HTTP GET per ottenere una singola attività.


Future<Activity> activity(ActivityRef ref) async {
final response = await http.get(
Uri.https('www.boredapi.com', '/api/activity'),
);

final json = jsonDecode(response.body) as Map;
return Activity.fromJson(Map.from(json));
}

Possiamo quindi utilizzare questo provider per mostrare un'attività casuale. Per ora, non gestiremo lo stato di caricamento/errore ma mostreremo l'attività appena disponibile:

class ActivityView extends ConsumerWidget {

Widget build(BuildContext context, WidgetRef ref) {
final activity = ref.watch(activityProvider);

return Scaffold(
appBar: AppBar(title: const Text('Pull to refresh')),
body: Center(
// Se abbiamo un'attività, la mostriamo, altrimenti aspettiamo
child: Text(activity.valueOrNull?.activity ?? ''),
),
);
}
}

Aggiungere un RefreshIndicator

Ora che abbiamo una semplice applicazione, possiamo aggiungerci un RefreshIndicator. Questo widget è un widget Material ufficiale responsabile di mostrare un indicatore di aggiornamento quando l'utente trascina in basso lo schermo.

Usare RefreshIndicator richiede una superficie scrollabile. Ma fino ad adesso non ne abbiamo alcuna. Possiamo risolvere usando una ListView/GridView/SingleChildScrollView/etc:

class ActivityView extends ConsumerWidget {

Widget build(BuildContext context, WidgetRef ref) {
final activity = ref.watch(activityProvider);

return Scaffold(
appBar: AppBar(title: const Text('Pull to refresh')),
body: RefreshIndicator(
onRefresh: () async => print('refresh'),
child: ListView(
children: [
Text(activity.valueOrNull?.activity ?? ''),
],
),
),
);
}
}

Gli utenti ora possono trascinare in basso lo schermo. Ma il nostro dato non è ancora aggiornato.

Aggiungere la logica di aggiornamento

Quando gli utenti trascinano in basso lo schermo, RefreshIndicator invocherà la callback onRefresh. Possiamo usare questa callback per aggiornare il nostro dato. All'interno, possiamo usare ref.refresh per aggiornare il provider di nostra scelta.

Nota: si prevede che onRefresh restituisca un Future. È importante che questo future venga completato una volta terminato l'aggiornamento.

Per ottenere tale future, possiamo leggere la proprietà .future del nostro provider. Ciò restituirà un future che si completa quando il nostro provider viene risolto.

Possiamo quindi aggiornare il nostro RefreshIndicator in questo modo:

class ActivityView extends ConsumerWidget {

Widget build(BuildContext context, WidgetRef ref) {
final activity = ref.watch(activityProvider);

return Scaffold(
appBar: AppBar(title: const Text('Pull to refresh')),
body: RefreshIndicator(
// Aggiornando "activityProvider.future" e restituendo il risultato,
// l'indicatore di aggiornamento continuerà ad apparire finchè la nuova attività
// non viene ottenuta.
onRefresh: () => ref.refresh(activityProvider.future),
child: ListView(
children: [
Text(activity.valueOrNull?.activity ?? ''),
],
),
),
);
}
}

Mostrare uno spinner solo durante il caricamento iniziale e gestire gli errori.

Al momento, la nostra UI non gestisce gli stati di errore/caricamento. Al contrario, il dato compare magicamente quando il caricamento/aggiornamento è terminato.

Cambiamo questo comportamento gestendo con dettaglio quegli stati. Ci sono due casi:

  • Durante il primo caricamento, vogliamo mostrare uno spinner a tutto schermo.
  • Durante un aggiornamento, vogliamo mostrare l'indicatore di aggiornamento e il precedente dato/errore

Fortunatamente, quando si ascolta un provider asincrono in Riverpod, Riverpod ci fornisce un AsyncValue, che offre tutto ciò di cui abbiamo bisogno.

Questo AsyncValue può inoltre essere combinato con il pattern matching di Dart 3.0 come segue:

class ActivityView extends ConsumerWidget {

Widget build(BuildContext context, WidgetRef ref) {
final activity = ref.watch(activityProvider);

return Scaffold(
appBar: AppBar(title: const Text('Pull to refresh')),
body: RefreshIndicator(
onRefresh: () => ref.refresh(activityProvider.future),
child: ListView(
children: [
switch (activity) {
// Se un dato è disponibile, lo mostriamo.
// Nota che il dato rimarrà ancora disponibile durante un aggiornamento.
AsyncValue<Activity>(:final valueOrNull?) => Text(valueOrNull.activity),
// Un errore è disponibile, quindi lo mostriamo.
AsyncValue(:final error?) => Text('Error: $error'),
// Nessun dato ne errore, quindi siamo nello stato di caricamento.
_ => const CircularProgressIndicator(),
},
],
),
),
);
}
}
caution

Usiamo valueOrNull siccome al momento, usare value genera un'eccezione se siamo negli stati di errore/caricamento.

Con Riverpod 3.0 value si comporterà come valueOrNull. Ma per ora, rimaniamo con valueOrNull.

tip

Si noti l'uso della sintassi :final valueOrNull? nel nostro pattern matching. Questa sintassi può essere utilizzata solo perché activityProvider restituisce un'Activity non nullabile.

Se il tuo dato può essere null, puoi invece utilizzare AsyncValue(hasData: true, :final valueOrNull). Ciò gestirà in modo corretto i casi dove il dato è null, al costo di qualche carattere in più.

Riassumendo: applicazione completa

Di seguito il codice combinato con tutte ciò che abbiamo visto fino ad ora.

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:http/http.dart' as http;
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'codegen.g.dart';
part 'codegen.freezed.dart';

void main() => runApp(ProviderScope(child: MyApp()));

class MyApp extends StatelessWidget {

Widget build(BuildContext context) {
return MaterialApp(home: ActivityView());
}
}

class ActivityView extends ConsumerWidget {

Widget build(BuildContext context, WidgetRef ref) {
final activity = ref.watch(activityProvider);

return Scaffold(
appBar: AppBar(title: const Text('Pull to refresh')),
body: RefreshIndicator(
onRefresh: () => ref.refresh(activityProvider.future),
child: ListView(
children: [
switch (activity) {
AsyncValue<Activity>(:final valueOrNull?) =>
Text(valueOrNull.activity),
AsyncValue(:final error?) => Text('Error: $error'),
_ => const CircularProgressIndicator(),
},
],
),
),
);
}
}


Future<Activity> activity(ActivityRef ref) async {
final response = await http.get(
Uri.https('www.boredapi.com', '/api/activity'),
);

final json = jsonDecode(response.body) as Map;
return Activity.fromJson(Map.from(json));
}


class Activity with _$Activity {
factory Activity({
required String activity,
required String type,
required int participants,
required double price,
}) = _Activity;

factory Activity.fromJson(Map<String, dynamic> json) =>
_$ActivityFromJson(json);
}