[Flutter] 스톱워치(Stopwatch) 앱 만들기

Tyger·2023년 4월 1일
0

Simple App

목록 보기
2/2

스톱워치(Stopwatch) 앱 만들기

flutter_bloc | Flutter Package
equatable | Dart Package

Count App으로 알아보는 상태관리 - Cubit편
Equatable 사용해보기 1편
Equatable 사용해보기 2편

이번 글에서는 간단한 스톱워치 앱을 만들어 보려고 한다.

앱 개발을 시작하는 개발자 분들이 초기에 만들기 좋은 앱으로 Todo 앱, 알림 앱, 스톱워치, 메모 앱 등이 있다. 이번에는 스톱워치를 만들어 보도록하자.

스톱워치는 앱 구조가 매우 간단해 보이지만 조금 까다롭게 처리해야 하는 부분이 있다.

우선 스톱워치를 만들 때에 필요한 기능에 대해서 살펴보자. 가장 중요한 건 스톱워치를 실행시키면 시간이 흐를 수 있도록 해줘야 하고, 시간을 멈출 수도 재시작할 수도 있어야 한다.
여기에 추가적으로 LAP기능도 있어야 한다.

간단하게 만들어보자.

Flutter

스톱워치 앱을 만들기 위해 심플하게 Stateful 위젯을 사용하려 했지만, Cubit 사용 방법도 함께 살펴보면 좋을 것 같아 Cubit을 사용하게 되었다.

Bloc을 사용하기에는 너무 단순한 앱이라 Cubit 정도만 사용하겠다.

Cubit을 잘 모르시는 분들도 한 번 따라해보시면 어렵지 않을 것 같다. Cubit은 Bloc에 비하면 상대적으로 가볍고, 개인적으로 Get, Provider와 비슷한 수준의 학습 레벨이면 배울 수 있다.

블로그에 모든 과정을 기술하기에는 내용이 너무 길어져서 기능 위주로만 설명을 할 예정이고, UI 관련된 내용은 Git에 업로드 해놨다.

시작해 보자.

dependencies

먼저 Cubit 사용을 위해 dependencies에 flutter_bloc과 equatable을 추가해보자. 만일 bloc을 사용하고 있다면 bloc을 사용해도 된다.

dependencies:
	flutter_bloc: ^8.1.1
    equatable: ^2.0.5

Create Cubit

이번 앱 구조에 사용되는 Cubit과 UI에 Cubit을 생성하는 방법, 그리고 모델의 구조에 대해서 먼저 살펴보도록 하자.

Cubit

먼저 cubit 파일을 하나 생성해 주자. stopwatch_cubit_cubit.dart 파일을 생성해서 아래와 같이 생성해 주자.

class StopwatchCubitCubit extends Cubit<StopwatchCubitState> {
StopwatchCubitCubit() : super(StopwatchCubitState();
}

이번에는 State 부분을 생성할거다. 여기서 Equatable을 상속 받아 상태 변경을 비교하는데 사용할 것이고, Cubit의 state 사용은 copyWith 방식으로 사용할 것이다.

우선은 이렇게만 생성해 줘도 된다.

class StopwatchCubitState extends Equatable {
  const StopwatchCubitState();

  StopwatchCubitState copyWith({
  }) {
    return StopwatchCubitState();
  }

  
  List<Object?> get props => [];
}

UI

Cubit을 생성하여 UI에 빌더를 만들어 주자.

class AppStopWatchScreen extends StatelessWidget {
  const AppStopWatchScreen({super.key});

  
  Widget build(BuildContext context) {
    return BlocProvider<StopwatchCubitCubit>(
      create: (context) => StopwatchCubitCubit(),
      child: BlocBuilder<StopwatchCubitCubit, StopwatchCubitState>(
        builder: (context, state) {
          return Scaffold(
          ...
          ...
          

Model

스톱워치를 관리하기 위해 시/분/초/밀리초를 하나의 객체로 만들었다. int 타입이 아닌 String 타입으로 만든 이유는 int로 만들게 되면 "01"이라고 사용할 수 없어서 UI 부분에 이런 처리를 따로 해줘야 하는데, 개인적으로 UI 파일에 이런 코드를 넣는걸 별로 좋아하지 않아서 String으로 관리하였다.

추후 DateTime 객체로 변환시에도 문자열로 관리하는게 더 간결해진다.

class AppStopwatchModel {
  final String hour;
  final String minute;
  final String seconds;
  final String millseconds;
  const AppStopwatchModel({
    required this.hour,
    required this.minute,
    required this.seconds,
    required this.millseconds,
  });

  factory AppStopwatchModel.empty() => const AppStopwatchModel(
      hour: "00", minute: "00", seconds: "00", millseconds: "00");
}`

이제 기능 위주로 만들어 보자.

Start Stopwatch

첫 번째로 만들 기능은 스톱워치의 기본이면서 중요한 시간을 흐르게 하는거다. 시간을 어떻게 흐르게 해야 할까? 이때, 사용하기 좋은 방법이 바로 flutter에 내장된 Timer 비동기를 사용하는 것이다.

State

Cubit의 state 부분에 코드를 추가해 보자. Timer를 Nullable 타입으로 해줘야 한다. Timer에 의해 시간을 증가하게 해주는 상태인 count 변수도 함께 추가하자.

class StopwatchCubitState extends Equatable {
  final Timer? timer;
  final int count;
  const StopwatchCubitState({
    this.timer,
    this.count = 0,
  });

  StopwatchCubitState copyWith({
    Timer? timer,
    int? count,
  }) {
    return StopwatchCubitState(
      timer: timer ?? this.timer,
      count: count ?? this.count,
    );
  }

  
  List<Object?> get props => [timer, count];
}

Cubit

Cubit 부분에 이벤트를 추가해보자. 스톱워치를 시작했을 때에 시간이 흐르면 되기에, started라는 이벤트를 생성하였다.

해당 이벤트에 Timer를 실행시켜 10 밀리세컨 간격마다 listener를 호출하게 해주었고, state에 등록한 timer 상태를 새롭게 생성해준 timer로 변경해주었다.

class StopwatchCubitCubit extends Cubit<StopwatchCubitState> {
StopwatchCubitCubit() : super(StopwatchCubitState();

Future<void> started() async {
    Timer? _timer = Timer.periodic(const Duration(milliseconds: 10), (timer) {
      _listener(state.count);
    });
    emit(state.copyWith(timer: _timer));
  }
}

listener 부분에서 state의 count를 현재 count에서 1을 더해서 상태를 변경해 주자.

void _listener(int count) async {
    emit(
      state.copyWith(count: count + 1),
    );
  }

UI부분에서 state.count를 화면에 넣어보면 숫자가 증가하는 것을 확인할 수 있을 것이다.

State

이번에는 숫자만 증가시키는게 아닌 스톱워치에 사용될 모델을 활용해서 실제로 시간을 흐르게 해보자. 위에서 살펴본 Model을 추가해 주자. stopwatch 모델은 Nullable일 필요가 없으므로, 초기 상태를 넣어주어야 한다.

class StopwatchCubitState extends Equatable {
  final Timer? timer;
  final int count;
  final AppStopwatchModel stopwatch;
  const StopwatchCubitState({
    this.timer,
    this.count = 0,
    required this.stopwatch,
  });

  StopwatchCubitState copyWith({
    Timer? timer,
    int? count,
    AppStopwatchModel? stopwatch,
  }) {
    return StopwatchCubitState(
      timer: timer ?? this.timer,
      count: count ?? this.count,
      stopwatch: stopwatch ?? this.stopwatch,
    );
  }

  
  List<Object?> get props => [timer, count, stopwatch];
}

Cubit

Cubit의 초기 상태에서 stopwatch 변수의 상태를 empty 객체로 넣어주면 된다.

class StopwatchCubitCubit extends Cubit<StopwatchCubitState> {
  StopwatchCubitCubit()
      : super(StopwatchCubitState(
            stopwatch: AppStopwatchModel.empty()));
}

listener 함수를 수정해주자. stopwatch 상태를 아래의 상태로 변경해주면 되는데, 저는 "1초"이렇게 보여지는 것보다 "01초"라고 보여지는걸 더 좋아해서 이렇게 해주었다.

void _listener(int count) async {
    emit(
      state.copyWith(
          stopwatch: AppStopwatchModel(
            hour: ((state.count ~/ (60000 * 6))) > 9
                ? ((state.count ~/ (60000 * 6))).toString()
                : "0${((state.count ~/ (60000 * 6)))}",
            minute: ((state.count ~/ 6000) % 60) > 9
                ? ((state.count ~/ 6000) % 60).toString()
                : "0${((state.count ~/ 6000) % 60)}",
            seconds: ((state.count ~/ 100) % 60) > 9
                ? ((state.count ~/ 100) % 60).toString()
                : "0${((state.count ~/ 100) % 60)}",
            millseconds: ((state.count % 100).toString().padLeft(2, '0')),
          ),
          count: count + 1),
    );
  }

자 이제 state.stopwatch 객체의 hour/minute/seconds/millseconds를 화면에 넣어보자.

스톱워치가 작동되기 시작하였다.

Stop Stopwatch

이번에는 스톱워치를 멈추는 기능을 알아보자. 멈추는 방법은 timer 스트림을 취소해주고, timer 상태를 null로 변경해 주면된다.

여기서 중요한 점이 반드시 timer 스트림 구독을 취소해야한다.

스톱워치를 시작하고 추가한 stoped 함수를 실행시키면 스톱워치가 정상적으로 멈추는 것을 확인할 수 있다.

class StopwatchCubitCubit extends Cubit<StopwatchCubitState> {
  StopwatchCubitCubit()
      : super(StopwatchCubitState(
            stopwatch: AppStopwatchModel.empty());
            
            ...
            
 Future<void> stoped() async {
      state.timer?.cancel();
      emit(state.copyWith(timer: null));
  }  
}

추가적으로 타이머가 진행되는 상태로 해당 페이지를 벗어나게 되면 비동기 에러가 발생하는데, Timer의 구독을 취소하지 않은 상태로 CubitState가 제거되어 발생하는 에러이다.

close를 재정의 해서 Cubit이 제거될 때에 timer 구독도 같이 취소해주는 로직을 추가해 주자.

class StopwatchCubitCubit extends Cubit<StopwatchCubitState> {
  StopwatchCubitCubit()
      : super(StopwatchCubitState(
            stopwatch: AppStopwatchModel.empty());
            
            ...
            
  
  Future<void> close() {
    state.timer?.cancel();
    return super.close();
  } 
}

ReStart Stopwatch

이번에는 멈춘 스톱워치를 재시작해보자. 재시작하는 기능은 따로 개발하지 않아도 된다. 그냥 started 함수를 사용해도 된다.

우리는 stoped 함수를 통해서 구독된 Timer 객체를 취소해주었기 때문에, 메모리 상에서 객체의 인스턴스는 제거되있는 상태이다. Timer 객체가 하는 일은 일정한 간격으로 listener를 호출해주는 기능으로만 사용하고 있기에, 다시 started 함수를 실행시켜 주면 스톱워치 시간이 다시 증가하는 것을 확인할 수 있다.

Reset Stopwatch

이번에는 스톱워치 초기화 부분을 개발해보자. 초기화 부분도 간단하다. state의 Timer 스트림 구독을 취소하고, timer를 null로, count 상태는 0으로 변경해주고, stopwatch 객체도 우리가 만들었던 empty 상태로 변경해주면 리셋된다.

class StopwatchCubitCubit extends Cubit<StopwatchCubitState> {
  StopwatchCubitCubit()
      : super(StopwatchCubitState(
            stopwatch: AppStopwatchModel.empty());
            
            ...
            
void reset() {
    state.timer?.cancel();
    emit(state.copyWith(
        timer: null,
        count: 0,
        stopwatch: AppStopwatchModel.empty()));
  }
}

LAP

이번에는 현재 스톱워치의 시간을 기록하는 LAP 기능에 대해서 살펴보자. 스톱워치가 작동되고 있을 때에 LAP을 클릭하면 현재의 LAP을 리스트에 담아서 보여주어야 한다.

State

State에 laps라는 AppStopwatchModel을 타입으로 하는 리스트를 생성해주자.

class StopwatchCubitState extends Equatable {
  final Timer? timer;
  final int count;
  final AppStopwatchModel stopwatch;
  final List<AppStopwatchModel> laps;
  const StopwatchCubitState({
    this.timer,
    this.count = 0,
    required this.stopwatch,
    required this.laps,
  });

  StopwatchCubitState copyWith({
    Timer? timer,
    int? count,
    AppStopwatchModel? stopwatch,
    List<AppStopwatchModel>? laps,
  }) {
    return StopwatchCubitState(
      timer: timer ?? this.timer,
      count: count ?? this.count,
      stopwatch: stopwatch ?? this.stopwatch,
      laps: laps ?? this.laps,
    );
  }

  
  List<Object?> get props => [timer, count, stopwatch, laps];
}

Cubit

Cubit의 초기 상태에 laps의 상태로 빈 리스트로 넣어주자.

class StopwatchCubitCubit extends Cubit<StopwatchCubitState> {
  StopwatchCubitCubit()
      : super(StopwatchCubitState(
            stopwatch: AppStopwatchModel.empty(), laps: []);
            
            ...
}

addLap이라는 함수를 생성하여 현재 상태의 state.laps 객체에 state.stopwatch의 상태를 담아주면 된다.

void addLap() {
    List<AppStopwatchModel> _data = state.laps;
    _data = [state.stopwatch, ...state.laps];
    emit(state.copyWith(laps: _data));
  }

Result

Git

https://github.com/boglbbogl/flutter_velog_sample/tree/main/lib/app/stopwatch

마무리

간단한 스톱워치 앱을 만들어보았다. Cubit이 익숙하지 않으신 분들에게는 다소 어렵게 느껴질 수도 있지만, 기능만 가져다가 원하는 방법으로 사용하시면 되기 때문에 한 번쯤 따라해보면서 만들어보면 도움이 될겁니다.

스톱워치는 제가 만든 방법 외에도 다른 방법으로 만드시는 분들도 있기 때문에, 많은 방식으로 개발을 해보시는게 좋습니다.

궁금하신 점은 댓글 남겨주세요 !

profile
Flutter Developer

0개의 댓글