Flutter Timer
이 튜토리얼에서는 bloc 라이브러리를 사용해서 타이머 앱을 만들어 봅니다. 완성된 앱은 다음과 같습니다:

핵심 주제
섹션 제목: “핵심 주제”- BlocObserver로 상태 변화 관찰하기.
- BlocProvider로 하위 위젯에 bloc 제공하기.
- BlocBuilder로 상태 변화에 따라 위젯 다시 그리기.
- Equatable로 불필요한 rebuild 방지하기.
- Bloc에서
StreamSubscription사용하기. buildWhen으로 불필요한 rebuild 방지하기.
프로젝트 설정
섹션 제목: “프로젝트 설정”새로운 Flutter 프로젝트를 생성합니다:
flutter create flutter_timerpubspec.yaml 파일을 아래 내용으로 교체합니다:
name: flutter_timerdescription: A new Flutter project.version: 1.0.0+1publish_to: none
environment: sdk: ">=3.10.0 <4.0.0"
dependencies: bloc: ^9.0.0 equatable: ^2.0.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: trueflutter 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”Ticker는 타이머 앱의 데이터 소스입니다. 구독하고 반응할 수 있는 tick 스트림을 제공합니다.
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 수(초)를 받아서 매초마다 남은 시간을 emit하는
스트림을 반환하는 tick 함수를 제공합니다.
다음으로 Ticker를 사용하는 TimerBloc을 만들어야 합니다.
Timer Bloc
섹션 제목: “Timer Bloc”TimerState
섹션 제목: “TimerState”먼저 TimerBloc이 가질 수 있는 TimerState를 정의합니다.
TimerBloc의 상태는 다음 중 하나입니다:
TimerInitial: 지정된 시간부터 카운트다운을 시작할 준비가 된 상태.TimerRunInProgress: 지정된 시간부터 카운트다운 중인 상태.TimerRunPause: 남은 시간에서 일시 정지된 상태.TimerRunComplete: 남은 시간이 0으로 완료된 상태.
각 상태는 UI와 사용자가 수행할 수 있는 액션에 영향을 줍니다. 예를 들어:
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);}모든 TimerState는 duration 속성을 가진 추상 클래스 TimerState를 상속합니다.
TimerBloc이 어떤 상태에 있든 남은 시간을 알아야 하기 때문입니다. 또한
TimerState는 Equatable을 상속해서 동일한 상태가 발생했을 때 불필요한
rebuild를 방지합니다.
다음으로 TimerBloc이 처리할 TimerEvent를 정의하고 구현합니다.
TimerEvent
섹션 제목: “TimerEvent”TimerBloc은 다음 이벤트를 처리해야 합니다:
TimerStarted: 타이머를 시작해야 함을 알립니다.TimerPaused: 타이머를 일시 정지해야 함을 알립니다.TimerResumed: 타이머를 재개해야 함을 알립니다.TimerReset: 타이머를 원래 상태로 리셋해야 함을 알립니다._TimerTicked: tick이 발생했고 그에 따라 상태를 업데이트해야 함을 알립니다.
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이
1분(60초)의 기본 시간으로 TimerInitial 상태에서 시작하도록 합니다.
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 }}Ticker에 대한 StreamSubscription도 정의합니다. 이건 잠시 후에 다룹니다.
이제 이벤트 핸들러만 구현하면 됩니다. 가독성을 위해 각 이벤트 핸들러를 별도의
헬퍼 함수로 분리합니다. 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 상태를 push합니다. 이미 열린 _tickerSubscription이 있다면
메모리 해제를 위해 취소해야 합니다. 또한 TimerBloc이 닫힐 때
_tickerSubscription을 취소하도록 close 메서드를 override해야 합니다.
마지막으로 _ticker.tick 스트림을 listen하고 매 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 이벤트를 받을 때마다 tick의 duration이 0보다 크면 새로운
duration과 함께 TimerRunInProgress 상태를 push합니다. 그렇지 않고 tick의
duration이 0이면 타이머가 끝난 것이므로 TimerRunComplete 상태를 push합니다.
이제 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에서 TimerBloc의 state가 TimerRunInProgress이면
_tickerSubscription을 일시 정지하고 현재 타이머 duration과 함께
TimerRunPause 상태를 push합니다.
다음으로 타이머를 재개할 수 있도록 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을 재개하고 현재 duration과 함께 TimerRunInProgress
상태를 push합니다.
마지막으로 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 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(), ); }}TimerBloc이 TimerReset 이벤트를 받으면 추가 tick 알림을 받지 않도록 현재
_tickerSubscription을 취소하고 원래 duration과 함께 TimerInitial 상태를
push합니다.
TimerBloc은 이게 전부입니다. 이제 타이머 앱의 UI만 구현하면 됩니다.
앱 UI
섹션 제목: “앱 UI”MyApp
섹션 제목: “MyApp”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
섹션 제목: “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
섹션 제목: “Barrel”Timer 섹션의 import를 깔끔하게 정리하기 위해 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 위젯은 BlocBuilder를 사용해서 새로운 TimerState가 올 때마다 UI를
rebuild하는 StatelessWidget입니다. Actions는 context.read<TimerBloc>()을
사용해서 TimerBloc 인스턴스에 접근하고 TimerBloc의 현재 상태에 따라 다른
FloatingActionButton을 반환합니다. 각 FloatingActionButton은 onPressed
콜백에서 TimerBloc에 알리기 위해 이벤트를 추가합니다.
builder 함수가 호출되는 시점을 세밀하게 제어하고 싶다면 BlocBuilder에
선택적으로 buildWhen을 제공할 수 있습니다. buildWhen은 이전 bloc 상태와 현재
bloc 상태를 받아서 boolean을 반환합니다. buildWhen이 true를 반환하면
state와 함께 builder가 호출되고 위젯이 rebuild됩니다. buildWhen이
false를 반환하면 state와 함께 builder가 호출되지 않고 rebuild도 일어나지
않습니다.
이 경우 매 tick마다 Actions 위젯이 rebuild되는 것은 비효율적이므로 원하지
않습니다. 대신 TimerState의 runtimeType이 바뀔 때만 Actions가 rebuild되길
원합니다 (TimerInitial => TimerRunInProgress, TimerRunInProgress =>
TimerRunPause 등).
결과적으로 매 rebuild마다 위젯에 랜덤 색상을 칠하면 다음과 같이 보입니다:

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, ], ), ), ); }}이게 전부입니다! 이제 필요한 위젯만 효율적으로 rebuild하는 꽤 괜찮은 타이머 앱이 완성됐습니다.
이 예제의 전체 소스 코드는 여기에서 확인할 수 있습니다.