Flutter Timer
In questo tutorial vedremo come costruire un timer usando la libreria bloc. L'applicazione risultante dovrebbe essere così:

Argomenti Chiave
Sezione intitolata “Argomenti Chiave”- Osservare i cambiamenti di stato con BlocObserver;
- BlocProvider, widget Flutter che fornisce un bloc ai suoi figli;
- BlocBuilder, widget Flutter che gestisce la costruzione del widget in risposta a nuovi stati;
- Prevenire aggiornamenti non necessari con Equatable;
- Imparare a usare
StreamSubscriptionin un Bloc; - Prevenire aggiornamenti non necessari con
buildWhen.
Configurazione
Sezione intitolata “Configurazione”Inizieremo creando un nuovo progetto Flutter:
flutter create flutter_timerPossiamo poi sostituire il contenuto di pubspec.yaml con:
name: flutter_timerdescription: A new Flutter project.version: 1.0.0+1publish_to: none
environment: sdk: ^3.12.0
dependencies: bloc: ^9.0.0 equatable: ^2.1.0-dev.0 flutter: sdk: flutter flutter_bloc: ^9.1.0
dev_dependencies: bloc_lint: ^0.3.0 bloc_test: ^10.0.0 flutter_test: sdk: flutter mocktail: ^1.0.0
flutter: uses-material-design: trueSuccessivamente, esegui flutter pub get per installare tutte le dipendenze.
Struttura del Progetto
Sezione intitolata “Struttura del Progetto”├── lib| ├── timer│ │ ├── bloc│ │ │ └── timer_bloc.dart| | | └── timer_event.dart| | | └── timer_state.dart│ │ └── view│ │ | ├── timer_page.dart│ │ ├── timer.dart│ ├── app.dart│ ├── ticker.dart│ └── main.dart├── pubspec.lock├── pubspec.yamlIl ticker sarà la sorgente di dati per l'applicazione timer. Esporrà uno stream di "tick" a cui possiamo sottoscriverci e reagire.
Inizia creando ticker.dart.
class Ticker { const Ticker(); Stream<int> tick({required int ticks}) { return Stream.periodic( const Duration(seconds: 1), (x) => ticks - x - 1, ).take(ticks); }}Tutto ciò che fa la classe Ticker è esporre una funzione tick che prende il
numero di tick (secondi) che vogliamo e restituisce uno stream che emette i
secondi rimanenti ogni secondo.
Successivamente, dobbiamo creare il TimerBloc che consumerà il Ticker.
Timer Bloc
Sezione intitolata “Timer Bloc”TimerState
Sezione intitolata “TimerState”Inizieremo definendo i TimerStates in cui il TimerBloc può trovarsi.
Lo stato del TimerBloc può essere uno dei seguenti:
TimerInitial: pronto per iniziare il conteggio alla rovescia dalla durata specificata.TimerRunInProgress: sta attivamente contando alla rovescia dalla durata specificata.TimerRunPause: in pausa a una durata rimanente.TimerRunComplete: completato con una durata rimanente di 0.
Ognuno di questi stati avrà un'implicazione sull'interfaccia utente e sulle azioni che l'utente può eseguire. Ad esempio:
- se lo stato è
TimerInitiall'utente sarà in grado di avviare il timer; - se lo stato è
TimerRunInProgressl'utente sarà in grado di mettere in pausa e resettare il timer così come vedere la durata rimanente; - se lo stato è
TimerRunPausel'utente sarà in grado di riprendere il timer e resettare il timer; - se lo stato è
TimerRunCompletel'utente sarà in grado di resettare il timer.
Per mantenere tutti i nostri file bloc insieme, creiamo una directory bloc con
bloc/timer_state.dart.
part of 'timer_bloc.dart';
sealed class TimerState extends Equatable { const TimerState(this.duration); final int duration;
@override List<Object> get props => [duration];}
final class TimerInitial extends TimerState { const TimerInitial(super.duration);
@override String toString() => 'TimerInitial { duration: $duration }';}
final class TimerRunPause extends TimerState { const TimerRunPause(super.duration);
@override String toString() => 'TimerRunPause { duration: $duration }';}
final class TimerRunInProgress extends TimerState { const TimerRunInProgress(super.duration);
@override String toString() => 'TimerRunInProgress { duration: $duration }';}
final class TimerRunComplete extends TimerState { const TimerRunComplete() : super(0);}Nota che tutti i TimerStates estendono la classe base astratta TimerState
che ha una proprietà duration. Questo è perché non importa in quale stato il
nostro TimerBloc sia, vogliamo sempre sapere quanto tempo rimane. Inoltre,
TimerState estende Equatable per ottimizzare il nostro codice assicurandoci
che la nostra app non attivi aggiornamenti se viene emesso uno stato uguale al
precedente.
Successivamente, definiamo e implementiamo i TimerEvents che il nostro
TimerBloc processerà.
TimerEvent
Sezione intitolata “TimerEvent”Il nostro TimerBloc dovrà sapere come processare i seguenti eventi:
TimerStarted: informa il TimerBloc che il timer dovrebbe essere avviato;TimerPaused: informa il TimerBloc che il timer dovrebbe essere messo in pausa;TimerResumed: informa il TimerBloc che il timer dovrebbe essere ripreso;TimerReset: informa il TimerBloc che il timer dovrebbe essere resettato allo stato originale;_TimerTicked: informa il TimerBloc che si è verificato un tick e che deve aggiornare il suo stato di conseguenza.
Se non hai usato le
IntelliJ o
VSCode
estensioni, crea bloc/timer_event.dart e implementiamo quegli eventi.
part of 'timer_bloc.dart';
sealed class TimerEvent { const TimerEvent();}
final class TimerStarted extends TimerEvent { const TimerStarted({required this.duration}); final int duration;}
final class TimerPaused extends TimerEvent { const TimerPaused();}
final class TimerResumed extends TimerEvent { const TimerResumed();}
class TimerReset extends TimerEvent { const TimerReset();}
class _TimerTicked extends TimerEvent { const _TimerTicked({required this.duration}); final int duration;}Successivamente, implementiamo il TimerBloc!
TimerBloc
Sezione intitolata “TimerBloc”Se non l'hai già fatto, crea bloc/timer_bloc.dart e crea un TimerBloc vuoto.
import 'package:bloc/bloc.dart';
part 'timer_event.dart';part 'timer_state.dart';
class TimerBloc extends Bloc<TimerEvent, TimerState> { // TODO: set initial state TimerBloc(): super() { // TODO: implement event handlers }}La prima cosa che dobbiamo fare è definire lo stato iniziale del nostro
TimerBloc. In questo caso, vogliamo che il TimerBloc inizi nello stato
TimerInitial con una durata preimpostata di 1 minuto (60 secondi).
import 'package:bloc/bloc.dart';
part 'timer_event.dart';part 'timer_state.dart';
class TimerBloc extends Bloc<TimerEvent, TimerState> { static const int _duration = 60;
TimerBloc() : super(TimerInitial(_duration)) { // TODO: implement event handlers }}Successivamente, dobbiamo definire la dipendenza sul nostro Ticker.
import 'dart:async';import 'package:bloc/bloc.dart';import 'package:flutter_timer/ticker.dart';
part 'timer_event.dart';part 'timer_state.dart';
class TimerBloc extends Bloc<TimerEvent, TimerState> { final Ticker _ticker; static const int _duration = 60;
StreamSubscription<int>? _tickerSubscription;
TimerBloc({required Ticker ticker}) : _ticker = ticker, super(TimerInitial(_duration)) { // TODO: implement event handlers }}Stiamo anche definendo una StreamSubscription per il nostro Ticker che
implementeremo tra poco.
A questo punto, tutto ciò che resta da fare è implementare i gestori di eventi.
Per migliorare la leggibilità, mi piace separare ogni gestore di eventi in una
funzione dedicata. Inizieremo con l'evento TimerStarted.
import 'dart:async';import 'package:bloc/bloc.dart';import 'package:flutter_timer/ticker.dart';
part 'timer_event.dart';part 'timer_state.dart';
class TimerBloc extends Bloc<TimerEvent, TimerState> { final Ticker _ticker; static const int _duration = 60;
StreamSubscription<int>? _tickerSubscription;
TimerBloc({required Ticker ticker}) : _ticker = ticker, super(TimerInitial(_duration)) { on<TimerStarted>(_onStarted); }
@override Future<void> close() { _tickerSubscription?.cancel(); return super.close(); }
void _onStarted(TimerStarted event, Emitter<TimerState> emit) { emit(TimerRunInProgress(event.duration)); _tickerSubscription?.cancel(); _tickerSubscription = _ticker .tick(ticks: event.duration) .listen((duration) => add(_TimerTicked(duration: duration))); }}Se il TimerBloc riceve un evento TimerStarted, emette uno stato
TimerRunInProgress con la durata iniziale. Inoltre, se c'era già una
_tickerSubscription aperta dobbiamo cancellarla per deallocare la memoria.
Dobbiamo anche sovrascrivere il metodo close sul nostro TimerBloc così che
possiamo cancellare la _tickerSubscription quando il TimerBloc è chiuso.
Infine, ascoltiamo lo stream _ticker.tick e su ogni tick aggiungiamo un evento
_TimerTicked con la durata rimanente.
Successivamente, implementiamo il gestore di eventi _TimerTicked.
import 'dart:async';import 'package:bloc/bloc.dart';import 'package:flutter_timer/ticker.dart';
part 'timer_event.dart';part 'timer_state.dart';
class TimerBloc extends Bloc<TimerEvent, TimerState> { final Ticker _ticker; static const int _duration = 60;
StreamSubscription<int>? _tickerSubscription;
TimerBloc({required Ticker ticker}) : _ticker = ticker, super(TimerInitial(_duration)) { on<TimerStarted>(_onStarted); on<_TimerTicked>(_onTicked); }
@override Future<void> close() { _tickerSubscription?.cancel(); return super.close(); }
void _onStarted(TimerStarted event, Emitter<TimerState> emit) { emit(TimerRunInProgress(event.duration)); _tickerSubscription?.cancel(); _tickerSubscription = _ticker .tick(ticks: event.duration) .listen((duration) => add(_TimerTicked(duration: duration))); }
void _onTicked(_TimerTicked event, Emitter<TimerState> emit) { emit( event.duration > 0 ? TimerRunInProgress(event.duration) : TimerRunComplete(), ); }}Ogni volta che viene ricevuto un evento _TimerTicked, se la durata del tick è
maggiore di 0, dobbiamo emettere uno stato TimerRunInProgress aggiornato con
la nuova durata. Altrimenti, se la durata del tick è 0, il nostro timer è
terminato e dobbiamo emettere uno stato TimerRunComplete.
Ora implementiamo il gestore di eventi TimerPaused.
import 'dart:async';import 'package:bloc/bloc.dart';import 'package:flutter_timer/ticker.dart';
part 'timer_event.dart';part 'timer_state.dart';
class TimerBloc extends Bloc<TimerEvent, TimerState> { final Ticker _ticker; static const int _duration = 60;
StreamSubscription<int>? _tickerSubscription;
TimerBloc({required Ticker ticker}) : _ticker = ticker, super(TimerInitial(_duration)) { on<TimerStarted>(_onStarted); on<TimerPaused>(_onPaused); on<_TimerTicked>(_onTicked); }
@override Future<void> close() { _tickerSubscription?.cancel(); return super.close(); }
void _onStarted(TimerStarted event, Emitter<TimerState> emit) { emit(TimerRunInProgress(event.duration)); _tickerSubscription?.cancel(); _tickerSubscription = _ticker .tick(ticks: event.duration) .listen((duration) => add(_TimerTicked(duration: duration))); }
void _onPaused(TimerPaused event, Emitter<TimerState> emit) { if (state is TimerRunInProgress) { _tickerSubscription?.pause(); emit(TimerRunPause(state.duration)); } }
void _onTicked(_TimerTicked event, Emitter<TimerState> emit) { emit( event.duration > 0 ? TimerRunInProgress(event.duration) : TimerRunComplete(), ); }}In _onPaused se lo state del nostro TimerBloc è TimerRunInProgress,
allora possiamo mettere in pausa la _tickerSubscription ed emettere uno stato
TimerRunPause con la durata del timer corrente.
Successivamente, implementiamo il gestore di eventi TimerResumed così possiamo
riprendere il timer.
import 'dart:async';import 'package:bloc/bloc.dart';import 'package:flutter_timer/ticker.dart';
part 'timer_event.dart';part 'timer_state.dart';
class TimerBloc extends Bloc<TimerEvent, TimerState> { final Ticker _ticker; static const int _duration = 60;
StreamSubscription<int>? _tickerSubscription;
TimerBloc({required Ticker ticker}) : _ticker = ticker, super(TimerInitial(_duration)) { on<TimerStarted>(_onStarted); on<TimerPaused>(_onPaused); on<TimerResumed>(_onResumed); on<_TimerTicked>(_onTicked); }
@override Future<void> close() { _tickerSubscription?.cancel(); return super.close(); }
void _onStarted(TimerStarted event, Emitter<TimerState> emit) { emit(TimerRunInProgress(event.duration)); _tickerSubscription?.cancel(); _tickerSubscription = _ticker .tick(ticks: event.duration) .listen((duration) => add(_TimerTicked(duration: duration))); }
void _onPaused(TimerPaused event, Emitter<TimerState> emit) { if (state is TimerRunInProgress) { _tickerSubscription?.pause(); emit(TimerRunPause(state.duration)); } }
void _onResumed(TimerResumed resume, Emitter<TimerState> emit) { if (state is TimerRunPause) { _tickerSubscription?.resume(); emit(TimerRunInProgress(state.duration)); } }
void _onTicked(_TimerTicked event, Emitter<TimerState> emit) { emit( event.duration > 0 ? TimerRunInProgress(event.duration) : TimerRunComplete(), ); }}Il gestore di eventi TimerResumed è molto simile al gestore di eventi
TimerPaused. Se il TimerBloc ha uno state di TimerRunPause e riceve un
evento TimerResumed, riprende la _tickerSubscription ed emette uno stato
TimerRunInProgress con la durata corrente.
Infine, dobbiamo implementare il gestore di eventi TimerReset.
import 'dart:async';
import 'package:bloc/bloc.dart';import 'package:equatable/equatable.dart';import 'package:flutter_timer/ticker.dart';
part 'timer_event.dart';part 'timer_state.dart';
class TimerBloc extends Bloc<TimerEvent, TimerState> { TimerBloc({required this._ticker}) : super(const TimerInitial(_duration)) { on<TimerStarted>(_onStarted); on<TimerPaused>(_onPaused); on<TimerResumed>(_onResumed); on<TimerReset>(_onReset); on<_TimerTicked>(_onTicked); }
final Ticker _ticker; static const int _duration = 60;
StreamSubscription<int>? _tickerSubscription;
@override Future<void> close() { _tickerSubscription?.cancel(); return super.close(); }
void _onStarted(TimerStarted event, Emitter<TimerState> emit) { emit(TimerRunInProgress(event.duration)); _tickerSubscription?.cancel(); _tickerSubscription = _ticker .tick(ticks: event.duration) .listen((duration) => add(_TimerTicked(duration: duration))); }
void _onPaused(TimerPaused event, Emitter<TimerState> emit) { if (state is TimerRunInProgress) { _tickerSubscription?.pause(); emit(TimerRunPause(state.duration)); } }
void _onResumed(TimerResumed resume, Emitter<TimerState> emit) { if (state is TimerRunPause) { _tickerSubscription?.resume(); emit(TimerRunInProgress(state.duration)); } }
void _onReset(TimerReset event, Emitter<TimerState> emit) { _tickerSubscription?.cancel(); emit(const TimerInitial(_duration)); }
void _onTicked(_TimerTicked event, Emitter<TimerState> emit) { emit( event.duration > 0 ? TimerRunInProgress(event.duration) : const TimerRunComplete(), ); }}Se il TimerBloc riceve un evento TimerReset, deve cancellare la corrente
_tickerSubscription così che non viene notificato di tick aggiuntivi ed emette
uno stato TimerInitial con la durata originale.
Questo è tutto per il TimerBloc. Ora tutto ciò che resta è implementare l'UI
per il nostro Timer.
UI dell'Applicazione
Sezione intitolata “UI dell'Applicazione”Possiamo iniziare cancellando il contenuto di main.dart e sostituendolo con il
seguente.
import 'package:flutter/material.dart';import 'package:flutter_timer/app.dart';
void main() => runApp(const App());Successivamente, creiamo il nostro widget App in app.dart, che sarà la
radice della nostra applicazione.
import 'package:flutter/material.dart';import 'package:flutter_timer/timer/timer.dart';
class App extends StatelessWidget { const App({super.key});
@override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Timer', theme: ThemeData( colorScheme: const ColorScheme.light( primary: Color.fromRGBO(72, 74, 126, 1), ), ), home: const TimerPage(), ); }}Successivamente, dobbiamo implementare il nostro widget Timer.
Il nostro widget Timer (lib/timer/view/timer_page.dart) sarà responsabile di
visualizzare il tempo rimanente insieme ai pulsanti appropriati che
permetteranno agli utenti di avviare, mettere in pausa e resettare il timer.
import 'package:flutter/material.dart';import 'package:flutter_bloc/flutter_bloc.dart';import 'package:flutter_timer/ticker.dart';import 'package:flutter_timer/timer/timer.dart';
class TimerPage extends StatelessWidget { const TimerPage({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return BlocProvider( create: (_) => TimerBloc(ticker: Ticker()), child: const TimerView(), ); }}
class TimerView extends StatelessWidget { const TimerView({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Flutter Timer')), body: Stack( children: [ const Background(), Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: const <Widget>[ Padding( padding: EdgeInsets.symmetric(vertical: 100.0), child: Center(child: TimerText()), ), Actions(), ], ), ], ), ); }}
class TimerText extends StatelessWidget { const TimerText({Key? key}) : super(key: key); @override Widget build(BuildContext context) { final duration = context.select((TimerBloc bloc) => bloc.state.duration); final minutesStr = ((duration / 60) % 60).floor().toString().padLeft(2, '0'); final secondsStr = (duration % 60).floor().toString().padLeft(2, '0'); return Text( '$minutesStr:$secondsStr', style: Theme.of( context, ).textTheme.displayLarge?.copyWith(fontWeight: FontWeight.w500), ); }}Finora, stiamo solo usando BlocProvider per accedere all'istanza del nostro
TimerBloc.
Successivamente, implementeremo il nostro widget Actions che avrà le azioni
appropriate (avvia, pausa e reset).
Per pulire i nostri import dalla sezione Timer, dobbiamo creare un file
"barrel" timer/timer.dart.
export 'bloc/timer_bloc.dart';export 'view/timer_page.dart';Actions
Sezione intitolata “Actions”class Actions extends StatelessWidget { const Actions({super.key});
@override Widget build(BuildContext context) { return BlocBuilder<TimerBloc, TimerState>( buildWhen: (prev, state) => prev.runtimeType != state.runtimeType, builder: (context, state) { return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ ...switch (state) { TimerInitial() => [ FloatingActionButton( child: const Icon(Icons.play_arrow), onPressed: () => context .read<TimerBloc>() .add(TimerStarted(duration: state.duration)), ), ], TimerRunInProgress() => [ FloatingActionButton( child: const Icon(Icons.pause), onPressed: () => context.read<TimerBloc>().add(const TimerPaused()), ), FloatingActionButton( child: const Icon(Icons.replay), onPressed: () => context.read<TimerBloc>().add(const TimerReset()), ), ], TimerRunPause() => [ FloatingActionButton( child: const Icon(Icons.play_arrow), onPressed: () => context.read<TimerBloc>().add(const TimerResumed()), ), FloatingActionButton( child: const Icon(Icons.replay), onPressed: () => context.read<TimerBloc>().add(const TimerReset()), ), ], TimerRunComplete() => [ FloatingActionButton( child: const Icon(Icons.replay), onPressed: () => context.read<TimerBloc>().add(const TimerReset()), ), ] } ], ); }, ); }}Il widget Actions è solo un altro StatelessWidget che usa un BlocBuilder
per ricostruire la UI ogni volta che otteniamo un nuovo TimerState. Actions
usa context.read<TimerBloc>() per accedere all'istanza del TimerBloc e
restituisce diversi FloatingActionButtons in base allo stato corrente del
TimerBloc. Ognuno dei FloatingActionButtons aggiunge un evento nel suo
callback onPressed per notificare il TimerBloc.
Se vuoi un controllo granulare su quando la funzione builder viene chiamata
puoi fornire opzionalmente un buildWhen a BlocBuilder. Il buildWhen prende
lo stato precedente del bloc e lo stato corrente del bloc e restituisce un
booleano. Se buildWhen restituisce true, builder sarà chiamato con
state e il widget si ricostruirà. Se buildWhen restituisce false,
builder non sarà chiamato con state e non si verificherà alcun
aggiornamento.
In questo caso, non vogliamo che il widget Actions sia ricostruito su ogni
tick perché sarebbe inefficiente. Invece, vogliamo che Actions si ricostruisca
solo se il runtimeType del TimerState cambia (TimerInitial =>
TimerRunInProgress, TimerRunInProgress => TimerRunPause, ecc...).
Di conseguenza, se colorassimo casualmente i widget su ogngli aggiornamenti, sarebbe:

Background
Sezione intitolata “Background”Infine, aggiungi il widget background come segue:
class Background extends StatelessWidget { const Background({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.blue.shade50, Colors.blue.shade500, ], ), ), ); }}Riassumendo
Sezione intitolata “Riassumendo”Questo è tutto! A questo punto abbiamo un'applicazione timer abbastanza solida che ricostruisce efficientemente i widget solo quando necessario.
Il codice sorgente completo per questo esempio può essere trovato qui.