Таймер Flutter
У наступному підручнику ми розглянемо, як створити додаток-таймер з використанням бібліотеки bloc. Готовий додаток повинен виглядати так:

Ключові теми
Section titled “Ключові теми”- Спостереження за змінами стану за допомогою BlocObserver.
- BlocProvider, віджет Flutter, який надає bloc своїм дочірнім елементам.
- BlocBuilder, віджет Flutter, який обробляє побудову віджета у відповідь на нові стани.
- Запобігання зайвим перебудовам за допомогою Equatable.
- Навчитися використовувати
StreamSubscriptionу Bloc. - Запобігання зайвим перебудовам за допомогою
buildWhen.
Налаштування
Section titled “Налаштування”Почнемо зі створення нового Flutter проєкту:
flutter create flutter_timerПотім ми можемо замінити вміст pubspec.yaml на:
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: trueДалі виконайте flutter pub get для встановлення всіх залежностей.
Структура проєкту
Section titled “Структура проєкту”├── 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.yamlTicker
Section titled “Ticker”Ticker буде нашим джерелом даних для додатку-таймера. Він надаватиме потік тіків, на які ми зможемо підписатися та реагувати.
Почнемо зі створення 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); }}Все, що робить наш клас Ticker — це надає функцію tick, яка приймає кількість
тіків (секунд) і повертає потік, який кожну секунду випускає секунди, що
залишилися.
Далі нам потрібно створити наш TimerBloc, який споживатиме Ticker.
Timer Bloc
Section titled “Timer Bloc”TimerState
Section titled “TimerState”Почнемо з визначення TimerStates, в яких може перебувати наш TimerBloc.
Стан нашого TimerBloc може бути одним з наступних:
TimerInitial: готовий почати зворотний відлік від вказаної тривалості.TimerRunInProgress: активно відлічує від вказаної тривалості.TimerRunPause: призупинений на певній тривалості, що залишилася.TimerRunComplete: завершений з тривалістю 0, що залишилася.
Кожен з цих станів матиме вплив на інтерфейс користувача та дії, які може виконувати користувач. Наприклад:
- якщо стан
TimerInitial, користувач зможе запустити таймер. - якщо стан
TimerRunInProgress, користувач зможе призупинити та скинути таймер, а також бачити тривалість, що залишилася. - якщо стан
TimerRunPause, користувач зможе відновити таймер та скинути таймер. - якщо стан
TimerRunComplete, користувач зможе скинути таймер.
Для зберігання всіх файлів bloc разом створимо каталог bloc з
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);}Зверніть увагу, що всі TimerStates розширюють абстрактний базовий клас
TimerState, який має властивість duration. Це тому, що незалежно від стану
нашого TimerBloc, ми хочемо знати, скільки часу залишилося. Крім того,
TimerState розширює Equatable для оптимізації коду, забезпечуючи, що наш
додаток не запускає перебудови, якщо відбувається однаковий стан.
Далі визначимо та реалізуємо TimerEvents, які оброблятиме наш TimerBloc.
TimerEvent
Section titled “TimerEvent”Наш TimerBloc повинен знати, як обробляти наступні події:
TimerStarted: інформує TimerBloc про запуск таймера.TimerPaused: інформує TimerBloc про призупинення таймера.TimerResumed: інформує TimerBloc про відновлення таймера.TimerReset: інформує TimerBloc про скидання таймера до початкового стану._TimerTicked: інформує TimerBloc про те, що стався тік, і йому потрібно оновити свій стан відповідно.
Якщо ви не використовували розширення
IntelliJ або
VSCode,
створіть bloc/timer_event.dart і реалізуємо ці події.
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;}Далі реалізуємо TimerBloc!
TimerBloc
Section titled “TimerBloc”Якщо ви ще цього не зробили, створіть bloc/timer_bloc.dart та порожній
TimerBloc.
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 }}Перше, що нам потрібно зробити — визначити початковий стан нашого TimerBloc. У
цьому випадку ми хочемо, щоб TimerBloc починав зі стану TimerInitial з
попередньо встановленою тривалістю 1 хвилина (60 секунд).
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 }}Далі нам потрібно визначити залежність від нашого 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 }}Ми також визначаємо StreamSubscription для нашого Ticker, до якої
повернемося трохи пізніше.
На цьому етапі залишилося лише реалізувати обробники подій. Для кращої
читабельності я виношу кожен обробник подій у окрему допоміжну функцію. Почнемо
з події 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))); }}Якщо TimerBloc отримує подію TimerStarted, він встановлює стан
TimerRunInProgress з початковою тривалістю. Крім того, якщо вже була відкрита
_tickerSubscription, нам потрібно скасувати її для вивільнення пам'яті. Нам
також потрібно перевизначити метод close нашого TimerBloc, щоб скасувати
_tickerSubscription, коли TimerBloc закривається. Нарешті, ми слухаємо потік
_ticker.tick і на кожному тіку додаємо подію _TimerTicked з тривалістю, що
залишилася.
Далі реалізуємо обробник подій _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(), ); }}Кожного разу, коли отримується подія _TimerTicked, якщо тривалість тіку більше
0, нам потрібно встановити оновлений стан TimerRunInProgress з новою
тривалістю. Інакше, якщо тривалість тіку дорівнює 0, наш таймер завершився, і
нам потрібно встановити стан TimerRunComplete.
Тепер реалізуємо обробник подій 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(), ); }}В _onPaused, якщо state нашого TimerBloc є TimerRunInProgress, ми можемо
призупинити _tickerSubscription та встановити стан TimerRunPause з поточною
тривалістю таймера.
Далі реалізуємо обробник подій TimerResumed, щоб мати можливість відновити
таймер.
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(), ); }}Обробник подій TimerResumed дуже схожий на обробник подій TimerPaused. Якщо
TimerBloc має state TimerRunPause та отримує подію TimerResumed, то він
відновлює _tickerSubscription та встановлює стан TimerRunInProgress з
поточною тривалістю.
Нарешті, нам потрібно реалізувати обробник подій 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(), ); }}Якщо TimerBloc отримує подію TimerReset, йому потрібно скасувати поточну
_tickerSubscription, щоб не отримувати додаткові тіки, та встановити стан
TimerInitial з початковою тривалістю.
Ось і все щодо TimerBloc. Тепер залишилося реалізувати інтерфейс для нашого
додатку-таймера.
UI додатку
Section titled “UI додатку”Ми можемо почати з видалення вмісту main.dart та заміни його наступним.
import 'package:flutter/material.dart';import 'package:flutter_timer/app.dart';
void main() => runApp(const App());Далі створимо наш віджет 'App' у app.dart, який буде кореневим елементом
нашого додатку.
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(), ); }}Далі нам потрібно реалізувати наш віджет Timer.
Наш віджет Timer (lib/timer/view/timer_page.dart) відповідатиме за
відображення часу, що залишився, а також відповідних кнопок, які дозволять
користувачам запускати, призупиняти та скидати таймер.
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), ); }}Наразі ми просто використовуємо BlocProvider для доступу до екземпляра нашого
TimerBloc.
Далі ми реалізуємо наш віджет Actions, який матиме відповідні дії (запуск,
пауза та скидання).
Barrel
Section titled “Barrel”Для очищення наших імпортів з розділу Timer нам потрібно створити barrel-файл
timer/timer.dart.
export 'bloc/timer_bloc.dart';export 'view/timer_page.dart';Actions
Section titled “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()), ), ] } ], ); }, ); }}Віджет Actions — це ще один StatelessWidget, який використовує BlocBuilder
для перебудови UI кожного разу, коли ми отримуємо новий TimerState. Actions
використовує context.read<TimerBloc>() для доступу до екземпляра TimerBloc
та повертає різні FloatingActionButtons залежно від поточного стану
TimerBloc. Кожен з FloatingActionButtons додає подію у своєму зворотному
виклику onPressed для сповіщення TimerBloc.
Якщо ви хочете мати точний контроль над тим, коли викликається функція
builder, можна надати необов'язковий buildWhen для BlocBuilder.
buildWhen приймає попередній стан bloc та поточний стан bloc і повертає
boolean. Якщо buildWhen повертає true, builder буде викликаний зі
state, і віджет перебудується. Якщо buildWhen повертає false, builder не
буде викликаний зі state, і перебудова не відбудеться.
У цьому випадку ми не хочемо, щоб віджет Actions перебудовувався на кожному
тіку, оскільки це було б неефективно. Замість цього ми хочемо, щоб Actions
перебудовувався лише тоді, коли змінюється runtimeType TimerState
(TimerInitial => TimerRunInProgress, TimerRunInProgress => TimerRunPause тощо).
Як наслідок, якби ми випадково розфарбовували віджети при кожній перебудові, це виглядало б так:

Background
Section titled “Background”Нарешті, додамо віджет фону наступним чином:
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, ], ), ), ); }}Збираємо все разом
Section titled “Збираємо все разом”Ось і все! На цьому етапі ми маємо досить надійний додаток-таймер, який ефективно перебудовує лише ті віджети, які потрібно перебудувати.
Повний вихідний код цього прикладу можна знайти тут.