Таймер Flutter
В следующем руководстве мы рассмотрим, как создать приложение таймера используя библиотеку bloc. Готовое приложение должно выглядеть так:

Ключевые темы
Заголовок раздела «Ключевые темы»- Наблюдение за изменениями состояния с помощью BlocObserver.
- BlocProvider, виджет Flutter который предоставляет bloc своим дочерним элементам.
- BlocBuilder, виджет Flutter который обрабатывает построение виджета в ответ на новые состояния.
- Предотвращение ненужных перестроек с помощью Equatable.
- Изучение использования
StreamSubscriptionв Bloc. - Предотвращение ненужных перестроек с помощью
buildWhen.
Настройка
Заголовок раздела «Настройка»Начнем с создания нового 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 для установки всех зависимостей.
Структура проекта
Заголовок раздела «Структура проекта»├── 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 будет нашим источником данных для приложения таймера. Он предоставит поток тиков, на который мы можем подписаться и реагировать.
Начните с создания 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
Заголовок раздела «Timer Bloc»TimerState
Заголовок раздела «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
Заголовок раздела «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
Заголовок раздела «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
для нашего приложения таймера.
Пользовательский интерфейс приложения
Заголовок раздела «Пользовательский интерфейс приложения»Можем начать с удаления содержимого 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, который будет иметь соответствующие
действия (запуск, пауза и сброс).
Чтобы очистить наши импорты из раздела Timer, нам нужно создать файл barrel
timer/timer.dart.
export 'bloc/timer_bloc.dart';export 'view/timer_page.dart';Actions
Заголовок раздела «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
Заголовок раздела «Background»Наконец, добавьте виджет 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, ], ), ), ); }}Собираем все вместе
Заголовок раздела «Собираем все вместе»Вот и все! На этом этапе у нас есть довольно надежное приложение таймера, которое эффективно перестраивает только те виджеты, которые необходимо перестроить.
Полный исходный код этого примера можно найти здесь.