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

맨 처음에는 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,
),

'공부용 프로젝트 > Clock_App' 카테고리의 다른 글
| Clock_App / 타임 라벨 컴포넌트화 시키 (0) | 2025.12.11 |
|---|---|
| Clock_App / 공통 도구(Utils) 제작 (0) | 2025.12.10 |
| Clock_App / 폰트 삽입 (0) | 2025.12.05 |
| Clock_App / 타이머 화면 디자인 (0) | 2025.11.28 |
| Clock_App / 공통 UI 제작하기 (0) | 2025.10.24 |