Перейти до вмісту

Таймер Flutter

beginner

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

demo

  • Спостереження за змінами стану за допомогою BlocObserver.
  • BlocProvider, віджет Flutter, який надає bloc своїм дочірнім елементам.
  • BlocBuilder, віджет Flutter, який обробляє побудову віджета у відповідь на нові стани.
  • Запобігання зайвим перебудовам за допомогою Equatable.
  • Навчитися використовувати StreamSubscription у Bloc.
  • Запобігання зайвим перебудовам за допомогою buildWhen.

Почнемо зі створення нового Flutter проєкту:

Terminal window
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. Тепер залишилося реалізувати інтерфейс для нашого додатку-таймера.

Ми можемо почати з видалення вмісту 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

Нарешті, додамо віджет фону наступним чином:

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,
],
),
),
);
}
}

Ось і все! На цьому етапі ми маємо досить надійний додаток-таймер, який ефективно перебудовує лише ті віджети, які потрібно перебудувати.

Повний вихідний код цього прикладу можна знайти тут.