跳转到内容

Flutter Timer

此内容尚不支持你的语言。

beginner

In the following tutorial we’re going to cover how to build a timer application using the bloc library. The finished application should look like this:

demo

Key Topics

  • Observe state changes with BlocObserver.
  • BlocProvider, Flutter widget which provides a bloc to its children.
  • BlocBuilder, Flutter widget that handles building the widget in response to new states.
  • Prevent unnecessary rebuilds with Equatable.
  • Learn to use StreamSubscription in a Bloc.
  • Prevent unnecessary rebuilds with buildWhen.

Setup

We’ll start off by creating a brand new Flutter project:

Terminal window
flutter create flutter_timer

We can then replace the contents of pubspec.yaml with:

pubspec.yaml
name: flutter_timer
description: A new Flutter project.
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
bloc: ^8.1.0
equatable: ^2.0.3
flutter:
sdk: flutter
flutter_bloc: ^8.1.1
dev_dependencies:
bloc_test: ^9.0.0
flutter_test:
sdk: flutter
mocktail: ^1.0.0
flutter:
uses-material-design: true

Next, run flutter packages get to install all the dependencies.

Project Structure

├── 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

The ticker will be our data source for the timer application. It will expose a stream of ticks which we can subscribe and react to.

Start off by creating 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);
}
}

All our Ticker class does is expose a tick function which takes the number of ticks (seconds) we want and returns a stream which emits the remaining seconds every second.

Next up, we need to create our TimerBloc which will consume the Ticker.

Timer Bloc

TimerState

We’ll start off by defining the TimerStates which our TimerBloc can be in.

Our TimerBloc state can be one of the following:

  • TimerInitial: ready to start counting down from the specified duration.
  • TimerRunInProgress: actively counting down from the specified duration.
  • TimerRunPause: paused at some remaining duration.
  • TimerRunComplete: completed with a remaining duration of 0.

Each of these states will have an implication on the user interface and actions that the user can perform. For example:

  • if the state is TimerInitial the user will be able to start the timer.
  • if the state is TimerRunInProgress the user will be able to pause and reset the timer as well as see the remaining duration.
  • if the state is TimerRunPause the user will be able to resume the timer and reset the timer.
  • if the state is TimerRunComplete the user will be able to reset the timer.

In order to keep all of our bloc files together, let’s create a bloc directory with 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);
}

Note that all of the TimerStates extend the abstract base class TimerState which has a duration property. This is because no matter what state our TimerBloc is in, we want to know how much time is remaining. Additionally, TimerState extends Equatable to optimize our code by ensuring that our app does not trigger rebuilds if the same state occurs.

Next up, let’s define and implement the TimerEvents which our TimerBloc will be processing.

TimerEvent

Our TimerBloc will need to know how to process the following events:

  • TimerStarted: informs the TimerBloc that the timer should be started.
  • TimerPaused: informs the TimerBloc that the timer should be paused.
  • TimerResumed: informs the TimerBloc that the timer should be resumed.
  • TimerReset: informs the TimerBloc that the timer should be reset to the original state.
  • _TimerTicked: informs the TimerBloc that a tick has occurred and that it needs to update its state accordingly.

If you didn’t use the IntelliJ or VSCode extensions, then create bloc/timer_event.dart and let’s implement those events.

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;
}

Next up, let’s implement the TimerBloc!

TimerBloc

If you haven’t already, create bloc/timer_bloc.dart and create an empty 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
}
}

The first thing we need to do is define the initial state of our TimerBloc. In this case, we want the TimerBloc to start off in the TimerInitial state with a preset duration of 1 minute (60 seconds).

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
}
}

Next, we need to define the dependency on our 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
}
}

We are also defining a StreamSubscription for our Ticker which we will get to in a bit.

At this point, all that’s left to do is implement the event handlers. For improved readability, I like to break out each event handler into its own helper function. We’ll start with the TimerStarted event.

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

If the TimerBloc receives a TimerStarted event, it pushes a TimerRunInProgress state with the start duration. In addition, if there was already an open _tickerSubscription we need to cancel it to deallocate the memory. We also need to override the close method on our TimerBloc so that we can cancel the _tickerSubscription when the TimerBloc is closed. Lastly, we listen to the _ticker.tick stream and on every tick we add a _TimerTicked event with the remaining duration.

Next, let’s implement the _TimerTicked event handler.

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

Every time a _TimerTicked event is received, if the tick’s duration is greater than 0, we need to push an updated TimerRunInProgress state with the new duration. Otherwise, if the tick’s duration is 0, our timer has ended and we need to push a TimerRunComplete state.

Now let’s implement the TimerPaused event handler.

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

In _onPaused if the state of our TimerBloc is TimerRunInProgress, then we can pause the _tickerSubscription and push a TimerRunPause state with the current timer duration.

Next, let’s implement the TimerResumed event handler so that we can unpause the timer.

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

The TimerResumed event handler is very similar to the TimerPaused event handler. If the TimerBloc has a state of TimerRunPause and it receives a TimerResumed event, then it resumes the _tickerSubscription and pushes a TimerRunInProgress state with the current duration.

Lastly, we need to implement the TimerReset event handler.

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 Ticker ticker})
: _ticker = 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(),
);
}
}

If the TimerBloc receives a TimerReset event, it needs to cancel the current _tickerSubscription so that it isn’t notified of any additional ticks and pushes a TimerInitial state with the original duration.

That’s all there is to the TimerBloc. Now all that’s left is implement the UI for our Timer Application.

Application UI

MyApp

We can start off by deleting the contents of main.dart and replacing it with the following.

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

Next, let’s create our ‘App’ widget in app.dart, which will be the root of our application.

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

Next, we need to implement our Timer widget.

Timer

Our Timer widget (lib/timer/view/timer_page.dart) will be responsible for displaying the remaining time along with the proper buttons which will enable users to start, pause, and reset the 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.headline1,
);
}
}

So far, we’re just using BlocProvider to access the instance of our TimerBloc.

Next, we’re going to implement our Actions widget which will have the proper actions (start, pause, and reset).

Barrel

In order to clean up our imports from the Timer section, we need to create a barrel file timer/timer.dart.

lib/timer/timer.dart
export 'bloc/timer_bloc.dart';
export 'view/timer_page.dart';

Actions

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

The Actions widget is just another StatelessWidget which uses a BlocBuilder to rebuild the UI every time we get a new TimerState. Actions uses context.read<TimerBloc>() to access the TimerBloc instance and returns different FloatingActionButtons based on the current state of the TimerBloc. Each of the FloatingActionButtons adds an event in its onPressed callback to notify the TimerBloc.

If you want fine-grained control over when the builder function is called you can provide an optional buildWhen to BlocBuilder. The buildWhen takes the previous bloc state and current bloc state and returns a boolean. If buildWhen returns true, builder will be called with state and the widget will rebuild. If buildWhen returns false, builder will not be called with state and no rebuild will occur.

In this case, we don’t want the Actions widget to be rebuilt on every tick because that would be inefficient. Instead, we only want Actions to rebuild if the runtimeType of the TimerState changes (TimerInitial => TimerRunInProgress, TimerRunInProgress => TimerRunPause, etc…).

As a result, if we randomly colored the widgets on every rebuild, it would look like:

BlocBuilder buildWhen demo

Background

Lastly, add the background widget as follows:

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

Putting it all together

That’s all there is to it! At this point we have a pretty solid timer application which efficiently rebuilds only widgets that need to be rebuilt.

The full source for this example can be found here.