본문 바로가기
공부용 프로젝트/Clock_App

Clock_App / 타임 휠 제작 (타임 피커)

by ch5c 2025. 12. 15.
반응형

우선적으로 타임 라벨을 제작했으니 이제 그 밑에 있는 타임 피커. 휠을 제작할 차례다.

맨 처음에는 CupertinoPicker로 구현하려고 했었다. 하지만 얘는 내가 원하는 애니메이션이라든가 크기 설정 같은 조율이 생각대로 되지 않아 사용하지 않았다. 그래서 ListWeelScrollView를 사용하였는데 지나가는 개발자가 코드를 야무지게 적어놓셔서 그대로 뺏겨서 사용했다.

https://stackoverflow.com/questions/51118136/how-to-implement-cycle-wheel-scroll-list-widget

 

How to implement cycle wheel scroll list widget

Flutter has ListWheelScrollView widget but I want cycle wheel scroll widget. Any ideas how to implement such widget. How it should work: For example, I have a list with 10 items and a selected it...

stackoverflow.com

핵심적인 위젯은 이 ListWheelScrollView.useDelegate인데,

하위 속성인 childDelegate에다가 반드시 ListWheelChildLoopingListDelegate를 넣어줘야 내가 원하는 타이머에 사용될 무한 스크롤이 생성되게 된다.

 

위에 글을 바탕으로 가장 먼저 제작한 것은 아래와 같은 화면이었다.

 

스크롤이 잘 되는 것을 확인할 수 있다.

간략하게 코드 리뷰를 하자면 가장 먼저 크기 제약을 걸어줬다. 그 이유로는 내가 사용할 ListWheelScrollView는 부모 크기에 매우 민감한데 명확한 크기를 주지 않으면 레이아웃 깨짐, 의도치 않은 스크롤 영역 발생등의 오류가 날 수 있기 때문이다.

이렇게 크기 제약을 걸어두어서 크기를 맞춰 놓은 다음에 이러한 클래스 3개를 겹쳐서 화면을 완성시킬 계획이었다.

 

ConstrainedBox(
  constraints: const BoxConstraints(
    maxHeight: 69 * 3,
    maxWidth: 72,
),

또한 원래 이 휠은 리스트뷰처럼 동작하여 어느 한 지점에 딱 맞추는 게 힘들었는데 FixedExtentScrollPhysics를 사용하여서 깔끔하게 해결하였다. 이 위젯을 사용하면 리스트뷰처럼 동작하던 휠이 페이지뷰처럼 지정된 인덱스에 딱 맞춰지게 된다.

physics: const FixedExtentScrollPhysics(),

그런 후 숫자도 98, 99, 0, 1, 2, 3... 이런 식이였던 것을 숫자 포맷팅을 하여 00, 01, 02 이렇게 표시하게 하였다.

index.toString().padLeft(2, '0')

 

여기까지 초기 코드였고 이제 디자인적 요소와 당연하게도 외부에서 이 휠의 인덱스 데이터를 사용해야 하기 때문에 추가로 코드를 작성해 주었다. (사실 이게 메인이다.) 

time_wheel_picker.dart

import 'package:clock_app/theme/design_system.dart';
import 'package:flutter/material.dart';

class TimeWheelPicker extends StatelessWidget {
  final ValueNotifier<int> selectedIndexNotifier;
  final FixedExtentScrollController controller;
  final int itemCount;
  final ValueNotifier<bool> isDraggingNotifier;

  const TimeWheelPicker({
    super.key,
    required this.selectedIndexNotifier,
    required this.isDraggingNotifier,
    required this.controller,
    required this.itemCount,
  });

  @override
  Widget build(BuildContext context) {
    return NotificationListener<ScrollNotification>(
      onNotification: (notification) {
        if (notification is ScrollStartNotification) {
          isDraggingNotifier.value = true;
        } else if (notification is ScrollEndNotification) {
          isDraggingNotifier.value = false;
        }
        return false;
      },
      child: ConstrainedBox(
        constraints: const BoxConstraints(
          maxHeight: 69 * 3,
          maxWidth: 72,
        ),
        child: ValueListenableBuilder<int>(
          valueListenable: selectedIndexNotifier,
          builder: (context, selectedIndex, _) {
            return ValueListenableBuilder<bool>(
              valueListenable: isDraggingNotifier,
              builder: (context, isDragging, _) {
                return ListWheelScrollView.useDelegate(
                  physics: const FixedExtentScrollPhysics(),
                  controller: controller,
                  itemExtent: 72,
                  onSelectedItemChanged: (index) {
                    selectedIndexNotifier.value = index;
                  },
                  childDelegate: ListWheelChildLoopingListDelegate(
                    children: List.generate(itemCount, (index) {
                      final isSelected = index == selectedIndex;
                      return AnimatedDefaultTextStyle(
                        duration: Duration(milliseconds: 200),
                        style: TextStyle(
                          fontFamily: DesignSystem.fontFamily.inter,
                          fontSize: DesignSystem.fontSize.large,
                          color: isSelected ?
                          (isDragging ? DesignSystem.color.kDeepPurple : DesignSystem.color.kBlack) :
                          (isDragging ? DesignSystem.color.kDeepPurple.withAlpha(100) : DesignSystem.color.kGray),
                          fontWeight:
                          isSelected ? FontWeight.bold : FontWeight.normal,
                        ),
                        child: Center(
                          child: Text(
                            index.toString().padLeft(2, '0'),
                          ),
                        ),
                      );
                    }),
                  ),
                );
              }
            );
          },
        ),
      ),
    );
  }
}

 

이 안에서 사용되고 있는 것들에 대해 설명하고 넘어가겠다.

class TimeWheelPicker extends StatelessWidget

일단 기본적으로 StatelessWiidget를 사용하고 있는데 그 이유는 내가 현재 MVVM 스타일에 가깝게 설계를 하고 있기 때문이다. 현재 이 코드에서 사용하고 있는 모든 상태는 외부에서 주입받고 있어서 StatefulWidget을 사용하지 않아도 된다.

final ValueNotifier<int> selectedIndexNotifier;
final ValueNotifier<bool> isDraggingNotifier;

사용하고 있는 상태를 보면 첫 번째로 ValueNotifier를 사용하고 있는데 이유는 setState를 사용하게 되면 상위 위젯까지 다 빌드되게 되는데 이렇게 ValueNotifier을 사용하게 되면 해당 값에 의존하는 부분만 리빌드 되기 때문에 타이머 휠처럼 프레임 단위로 값이 바뀌는 UI에 최적되어 있기 때문이다.

return NotificationListener<ScrollNotification>(
  onNotification: (notification) {
    if (notification is ScrollStartNotification) {
      isDraggingNotifier.value = true;
    } else if (notification is ScrollEndNotification) {
      isDraggingNotifier.value = false;
    }
    return false;
  },

 

가장 먼저 빌드 코드를 NotificationListener<ScrollNotification>으로 감싸준 이유는 이 위젯으로 스크롤 이벤트를 감지하기 위해서인데 이 위젯으로 스크롤 이벤트를 감지하여 사용하는 스크롤만 색이 바뀔 수 있도록 해주었다.

        child: ValueListenableBuilder<int>(
          valueListenable: selectedIndexNotifier,
          builder: (context, selectedIndex, _) {
            return ValueListenableBuilder<bool>(
              valueListenable: isDraggingNotifier,
              builder: (context, isDragging, _) {

 

그리고 이 코드에서 핵심이라고 볼 수 있는 것은 머니머니해도 바로 이것일 것이다.

onSelectedItemChanged: (index) {
  selectedIndexNotifier.value = index;
},

onSelectedItemChanged는 휠이 한 칸 이동할 때마다 호출되게 되는데 그럴 때마다 선택된 숫자를 상태로 확정시켜주는 역할을 가지고 있다. 쉽게 말해 UI에서 상태 동기화로 연결되는 지점이라는 것이다.

 

그리고 가장 중요한 상태를 관리하는 컨트롤러다.

4_timer_controller.dart

import 'package:flutter/material.dart';

class TimerController {
  final TimerWheelState hours = TimerWheelState();
  final TimerWheelState minutes = TimerWheelState();
  final TimerWheelState seconds = TimerWheelState();
}

class TimerWheelState {
  final ValueNotifier<int> selectedIndex = ValueNotifier<int>(0);
  final ValueNotifier<bool> isDragging = ValueNotifier(false);
  final FixedExtentScrollController controller = FixedExtentScrollController();
}

아까도 말했듯이 시간 분 초 같이 3개나 위젯을 만들어야 하기 때문에 상태도 3개로 분리시킨 후 따로따로 관리할 수 있도록 하였다.

암튼 저 타임피커들은 또 한 번 Row로 감싸주었다.

time_wheel_picker_row.dart

 

 

import 'package:clock_app/theme/design_system.dart';
import 'package:clock_app/controllers/4_timer_controller.dart';
import 'package:clock_app/screens/components/4_timer/labels/time_separator.dart';
import 'package:clock_app/screens/components/4_timer/wheels/time_wheel_picker.dart';
import 'package:flutter/material.dart';

class TimeWheelPickerRow extends StatelessWidget {
  final TimerController controller;

  const TimeWheelPickerRow({super.key, required this.controller});

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Spacer(flex: 3),
        TimeWheelPicker(
          selectedIndexNotifier: controller.hours.selectedIndex,
          isDraggingNotifier: controller.hours.isDragging,
          controller: controller.hours.controller,
          itemCount: 100,
        ),
        Spacer(),
        TimeSeparator(),
        Spacer(),
        TimeWheelPicker(
          selectedIndexNotifier: controller.minutes.selectedIndex,
          isDraggingNotifier: controller.minutes.isDragging,
          controller: controller.minutes.controller,
          itemCount: 60,
        ),
        Spacer(),
        TimeSeparator(),
        Spacer(),
        TimeWheelPicker(
          selectedIndexNotifier: controller.seconds.selectedIndex,
          isDraggingNotifier: controller.seconds.isDragging,
          controller: controller.seconds.controller,
          itemCount: 60,
        ),
        Spacer(flex: 3),
      ],
    );
  }
}

 

 

+ 중간에 TimeSeparator

import 'package:clock_app/theme/design_system.dart';
import 'package:flutter/material.dart';

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

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text(
        ':',
        style: TextStyle(
          fontSize: DesignSystem.fontSize.large,
          color: DesignSystem.color.kBlack,
        ),
      ),
    );
  }
}

 

그리고 이렇게 분리시켜 놓은 컴포너트는 한 번에 중앙()에서 관리해 주면 완벽한 MVVM 패턴으로 제작이 완료된다.

TimeWheelPickerRow(
  controller: controller,
),

 

 

반응형