Перейти к содержимому

Таймер Flutter

beginner

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

demo

  • Наблюдение за изменениями состояния с помощью BlocObserver.
  • BlocProvider, виджет Flutter который предоставляет bloc своим дочерним элементам.
  • BlocBuilder, виджет Flutter который обрабатывает построение виджета в ответ на новые состояния.
  • Предотвращение ненужных перестроек с помощью Equatable.
  • Изучение использования StreamSubscription в Bloc.
  • Предотвращение ненужных перестроек с помощью buildWhen.

Начнем с создания нового Flutter проекта:

Окно терминала
flutter create flutter_timer

Затем можем заменить содержимое pubspec.yaml на:

pubspec.yaml
name: flutter_timer
description: A new Flutter project.
version: 1.0.0+1
publish_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.yaml

Ticker будет нашим источником данных для приложения таймера. Он предоставит поток тиков, на который мы можем подписаться и реагировать.

Начните с создания ticker.dart.

lib/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.

Начнем с определения TimerStates, в которых может находиться наш TimerBloc.

Наше состояние TimerBloc может быть одним из следующих:

  • TimerInitial: готов начать обратный отсчет с указанной продолжительности.
  • TimerRunInProgress: активно ведет обратный отсчет от указанной продолжительности.
  • TimerRunPause: приостановлен на некоторой оставшейся продолжительности.
  • TimerRunComplete: завершен с оставшейся продолжительностью 0.

Каждое из этих состояний будет иметь влияние на пользовательский интерфейс и действия, которые пользователь может выполнять. Например:

  • если состояние TimerInitial, пользователь сможет запустить таймер.
  • если состояние TimerRunInProgress, пользователь сможет приостановить и сбросить таймер, а также увидеть оставшуюся продолжительность.
  • если состояние TimerRunPause, пользователь сможет возобновить таймер и сбросить таймер.
  • если состояние TimerRunComplete, пользователь сможет сбросить таймер.

Чтобы держать все наши файлы bloc вместе, создадим каталог bloc с bloc/timer_state.dart.

lib/timer/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.

Наш TimerBloc должен знать, как обрабатывать следующие события:

  • TimerStarted: информирует TimerBloc, что таймер должен быть запущен.
  • TimerPaused: информирует TimerBloc, что таймер должен быть приостановлен.
  • TimerResumed: информирует TimerBloc, что таймер должен быть возобновлен.
  • TimerReset: информирует TimerBloc, что таймер должен быть сброшен к исходному состоянию.
  • _TimerTicked: информирует TimerBloc, что произошел тик и что он должен обновить свое состояние соответственно.

Если вы не использовали IntelliJ или VSCode расширения, то создайте bloc/timer_event.dart и реализуем эти события.

lib/timer/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!

Если вы еще не сделали это, создайте bloc/timer_bloc.dart и создайте пустой TimerBloc.

lib/timer/bloc/timer_bloc.dart
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 секунд).

lib/timer/bloc/timer_bloc.dart
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.

lib/timer/bloc/timer_bloc.dart
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.

lib/timer/bloc/timer_bloc.dart
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.

lib/timer/bloc/timer_bloc.dart
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.

lib/timer/bloc/timer_bloc.dart
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, чтобы мы могли снять паузу с таймера.

lib/timer/bloc/timer_bloc.dart
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.

lib/timer/bloc/timer_bloc.dart
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 и замены его на следующее.

lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_timer/app.dart';
void main() => runApp(const App());

Далее, создадим наш виджет 'App' в app.dart, который будет корнем нашего приложения.

lib/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) будет отвечать за отображение оставшегося времени вместе с соответствующими кнопками, которые позволят пользователям запускать, приостанавливать и сбрасывать таймер.

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.

lib/timer/timer.dart
export 'bloc/timer_bloc.dart';
export 'view/timer_page.dart';
lib/timer/view/timer_page.dart
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, и т.д...).

В результате, если бы мы случайно окрашивали виджеты при каждом перестроении, это выглядело бы так:

BlocBuilder buildWhen demo

Наконец, добавьте виджет background следующим образом:

lib/timer/view/timer_page.dart
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,
],
),
),
);
}
}

Вот и все! На этом этапе у нас есть довольно надежное приложение таймера, которое эффективно перестраивает только те виджеты, которые необходимо перестроить.

Полный исходный код этого примера можно найти здесь.