Skip to content

Commit b0964ac

Browse files
committed
[Feat] TTimePicker 컴포넌트 작성
1 parent cd8ebb1 commit b0964ac

File tree

3 files changed

+249
-0
lines changed

3 files changed

+249
-0
lines changed
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
//
2+
// TPickerColumn.swift
3+
// DesignSystem
4+
//
5+
// Created by 박민서 on 2/6/25.
6+
// Copyright © 2025 yapp25thTeamTnT. All rights reserved.
7+
//
8+
9+
import SwiftUI
10+
11+
/// 주어진 문자열 배열(items)을 반복하여 무한 스크롤을 표시하거나, 그대로 표시하는 열 뷰
12+
struct FlatPickerColumn: View {
13+
/// 원본 배열
14+
let items: [String]
15+
/// 각 행의 높이
16+
let rowHeight: CGFloat
17+
/// 한 화면에 보일 행의 수 (중앙 행이 선택됨)
18+
let visibleCount: Int
19+
/// 일반 텍스트 폰트
20+
let normalFont: Font
21+
/// 중앙(선택) 텍스트 폰트
22+
let selectedFont: Font
23+
/// 일반 텍스트 색상
24+
let normalColor: Color
25+
/// 중앙(선택) 텍스트 색상
26+
let selectedColor: Color
27+
/// 무한 스크롤 여부: true이면 원본 데이터를 반복하여 보여줌, false이면 원본 배열 그대로 표시
28+
let infiniteScroll: Bool
29+
30+
/// 반복 횟수 (무한 스크롤일 때만 사용)
31+
let repetition: Int = 100
32+
var totalCount: Int { infiniteScroll ? items.count * repetition : items.count }
33+
34+
/// 원본 배열의 선택된 인덱스 (예: 0 ~ items.count-1)
35+
@Binding var selected: Int
36+
37+
// 스크롤 스냅 예약 작업 관리
38+
@State private var pendingScrollTask: DispatchWorkItem? = nil
39+
40+
var body: some View {
41+
GeometryReader { geo in
42+
let containerCenterY = geo.size.height / 2
43+
ScrollViewReader { scrollProxy in
44+
ScrollView(.vertical, showsIndicators: false) {
45+
LazyVStack(spacing: 0) {
46+
ForEach(0..<totalCount, id: \.self) { index in
47+
let value = items[index % items.count]
48+
Text(value)
49+
.frame(height: rowHeight)
50+
.frame(maxWidth: .infinity)
51+
.font((index % items.count) == selected ? selectedFont : normalFont)
52+
.foregroundColor((index % items.count) == selected ? selectedColor : normalColor)
53+
.id(index) // 스크롤 시 특정 인덱스로 이동하기 위함
54+
.background(
55+
GeometryReader { rowGeo in
56+
Color.clear
57+
.preference(
58+
key: RowPreferenceKey.self,
59+
value: [RowPreferenceData(index: index, midY: rowGeo.frame(in: .named("scroll")).midY)]
60+
)
61+
}
62+
)
63+
}
64+
}
65+
// 위/아래 패딩 추가: 중앙에 한 행이 오도록 함
66+
.padding(.vertical, (geo.size.height - rowHeight) / 2)
67+
}
68+
.coordinateSpace(name: "scroll")
69+
// PreferenceKey 업데이트를 통해 현재 중앙에 가장 가까운 행을 찾고 스냅 예약
70+
.onPreferenceChange(RowPreferenceKey.self) { values in
71+
if let nearest = values.min(by: { abs($0.midY - containerCenterY) < abs($1.midY - containerCenterY) }) {
72+
let newSelection = nearest.index % items.count
73+
if newSelection != selected {
74+
DispatchQueue.main.async {
75+
selected = newSelection
76+
}
77+
}
78+
79+
pendingScrollTask?.cancel()
80+
81+
let targetIndex: Int
82+
if infiniteScroll {
83+
// 무한 스크롤인 경우: 현재 중앙 인덱스(currentIndex)를 기준으로 후보 계산
84+
let currentIndex = nearest.index
85+
let blockStart = currentIndex - (currentIndex % items.count)
86+
var candidate = blockStart + newSelection
87+
if candidate - currentIndex > items.count / 2 {
88+
candidate -= items.count
89+
} else if currentIndex - candidate > items.count / 2 {
90+
candidate += items.count
91+
}
92+
targetIndex = candidate
93+
} else {
94+
// 유한 스크롤인 경우: 그냥 newSelection (0 ~ items.count-1)
95+
targetIndex = newSelection
96+
}
97+
98+
let task = DispatchWorkItem {
99+
withAnimation {
100+
scrollProxy.scrollTo(targetIndex, anchor: .center)
101+
}
102+
}
103+
pendingScrollTask = task
104+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: task)
105+
}
106+
}
107+
.onAppear {
108+
let initialIndex: Int
109+
if infiniteScroll {
110+
// 중앙 블록의 시작점: totalCount / 2를 items.count로 나눈 나머지를 제거한 값
111+
let midBlock = (totalCount / 2) - ((totalCount / 2) % items.count)
112+
// 그 중앙 블록에서 selected 값만큼 오프셋 적용
113+
initialIndex = midBlock + selected
114+
} else {
115+
// 유한 스크롤일 경우, 선택값에 맞춰 중앙에 오도록
116+
initialIndex = selected
117+
}
118+
DispatchQueue.main.async {
119+
scrollProxy.scrollTo(initialIndex, anchor: .center)
120+
}
121+
}
122+
}
123+
}
124+
.frame(height: rowHeight * CGFloat(visibleCount))
125+
}
126+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//
2+
// RowPreference.swift
3+
// DesignSystem
4+
//
5+
// Created by 박민서 on 2/6/25.
6+
// Copyright © 2025 yapp25thTeamTnT. All rights reserved.
7+
//
8+
9+
import SwiftUI
10+
11+
/// 각 행의 인덱스와 해당 행의 중앙 Y 좌표를 저장
12+
struct RowPreferenceData: Equatable {
13+
let index: Int
14+
let midY: CGFloat
15+
}
16+
17+
/// 자식 뷰(행)에서 전달한 RowPreferenceData 배열을 모으는 PreferenceKey
18+
struct RowPreferenceKey: PreferenceKey {
19+
static var defaultValue: [RowPreferenceData] = []
20+
static func reduce(value: inout [RowPreferenceData], nextValue: () -> [RowPreferenceData]) {
21+
value.append(contentsOf: nextValue())
22+
}
23+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
//
2+
// TTimePicker.swift
3+
// DesignSystem
4+
//
5+
// Created by 박민서 on 2/6/25.
6+
// Copyright © 2025 yapp25thTeamTnT. All rights reserved.
7+
//
8+
9+
import SwiftUI
10+
11+
/// 시간, 분, 오전/오후 세 열을 나란히 배치하는 커스텀 타임 피커
12+
public struct TTimePicker: View {
13+
@State private var selectedHour: Int // 0 ~ 11 (표시 시에는 +1 해서 1~12)
14+
@State private var selectedMinute: Int // 0 ~ 59
15+
@State private var selectedPeriod: Int // 0: AM, 1: PM
16+
17+
// 설정값
18+
let rowHeight: CGFloat = 35
19+
let visibleCount: Int = 5
20+
let normalFont: Font = Typography.FontStyle.heading4.font
21+
let selectedFont: Font = Typography.FontStyle.heading3.font
22+
let normalColor: Color = .neutral400
23+
let selectedColor: Color = .neutral900
24+
25+
// 무한 스크롤 사용 여부 (여기서 전체 피커에 대해 동일하게 설정할 수 있음)
26+
let infiniteScroll: Bool = true // false로 설정하면 유한 스크롤이 됩니다.
27+
28+
/// 현재 날짜와 시각을 기준으로 기본 선택값을 지정하는 initializer
29+
public init(
30+
selectedHour: Int = {
31+
let calendar = Calendar.current
32+
let hour24 = calendar.component(.hour, from: .now)
33+
// 12시간제로 변환 (0일 경우 12로, 나머지는 그대로)
34+
let hour12 = hour24 % 12 == 0 ? 12 : hour24 % 12
35+
// TTimePicker는 0이 "1시", 11이 "12시"에 해당하므로 -1 처리
36+
return hour12 - 1
37+
}(),
38+
selectedMinute: Int = Calendar.current.component(.minute, from: .now),
39+
selectedPeriod: Int = {
40+
let hour24 = Calendar.current.component(.hour, from: .now)
41+
// 0: AM, 1: PM (오전이면 0, 오후면 1)
42+
return hour24 < 12 ? 0 : 1
43+
}()
44+
) {
45+
_selectedHour = State(initialValue: selectedHour)
46+
_selectedMinute = State(initialValue: selectedMinute)
47+
_selectedPeriod = State(initialValue: selectedPeriod)
48+
}
49+
50+
public var body: some View {
51+
ZStack {
52+
RoundedRectangle(cornerRadius: 8)
53+
.fill(Color.neutral100)
54+
.frame(height: rowHeight)
55+
56+
HStack(spacing: 0) {
57+
// 시간 열: "1" ~ "12"
58+
FlatPickerColumn(
59+
items: (1...12).map { "\($0)" },
60+
rowHeight: rowHeight,
61+
visibleCount: visibleCount,
62+
normalFont: normalFont,
63+
selectedFont: selectedFont,
64+
normalColor: normalColor,
65+
selectedColor: selectedColor,
66+
infiniteScroll: infiniteScroll,
67+
selected: $selectedHour
68+
)
69+
Text(":")
70+
.typographyStyle(.heading4, with: .neutral900)
71+
// 분 열: "00" ~ "59"
72+
FlatPickerColumn(
73+
items: (0..<60).map { String(format: "%02d", $0) },
74+
rowHeight: rowHeight,
75+
visibleCount: visibleCount,
76+
normalFont: normalFont,
77+
selectedFont: selectedFont,
78+
normalColor: normalColor,
79+
selectedColor: selectedColor,
80+
infiniteScroll: infiniteScroll,
81+
selected: $selectedMinute
82+
)
83+
Text(":")
84+
.typographyStyle(.heading4, with: .clear)
85+
// 오전/오후 열: "오전", "오후"
86+
FlatPickerColumn(
87+
items: ["오전", "오후"],
88+
rowHeight: rowHeight,
89+
visibleCount: visibleCount,
90+
normalFont: normalFont,
91+
selectedFont: selectedFont,
92+
normalColor: normalColor,
93+
selectedColor: selectedColor,
94+
infiniteScroll: false,
95+
selected: $selectedPeriod
96+
)
97+
}
98+
}
99+
}
100+
}

0 commit comments

Comments
 (0)