Skip to content

Commit a8a0ba0

Browse files
authored
[Merge] #185 - 취득 예정 모달 뷰 UI 구현
2 parents 8e3199a + ad069f2 commit a8a0ba0

7 files changed

Lines changed: 783 additions & 5 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
//
2+
// CertificationDetailPreviewUseCase.swift
3+
// CERTI-iOS
4+
//
5+
// Created by 이상엽 on 11/16/25.
6+
//
7+
8+
struct PreviewFetchCertificationDetailUseCase: FetchCertificationDetailUseCase {
9+
func execute(id: Int) async -> Result<CertificationDetailEntity, NetworkError> {
10+
let dummy = CertificationDetailEntity(
11+
certificationId: id,
12+
certificationName: "GTQ 1급 (그래픽기술자격)",
13+
tags: ["디자인", "그래픽"],
14+
averagePeriod: "2개월",
15+
charge: "35,000원",
16+
agencyName: "한국생산성본부",
17+
testType: "필기/실기",
18+
description: "그래픽 편집 능력을 평가하는 자격으로, 포토샵 및 일러스트 사용 능력을 측정합니다.",
19+
testDateInformation: "매월 정기 시행",
20+
applicationMethod: "온라인 접수",
21+
applicationUrl: "https://www.kpc.or.kr/gtq",
22+
expirationPeriod: "영구"
23+
)
24+
return .success(dummy)
25+
}
26+
}
27+
28+
struct PreviewAddPreCertificationUseCase: AddPreCertificationUseCase {
29+
func execute(certificationId: Int) async -> Result<AppendPreCertificationStatus, NetworkError> {
30+
// Always succeed for preview
31+
return .success(.success)
32+
}
33+
}
34+
35+
struct PreviewAddAcquisitionUseCase: AddAcquisitionUseCase {
36+
func execute(certificationId: Int) async -> Result<Bool, NetworkError> {
37+
// Always succeed for preview
38+
return .success(true)
39+
}
40+
}
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
//
2+
// CertiTimePicker.swift
3+
// CERTI-iOS
4+
//
5+
// Created by 이상엽 on 11/21/25.
6+
//
7+
8+
import SwiftUI
9+
import UIKit
10+
11+
// MARK: - SwiftUI Component
12+
13+
struct CertiTimePicker: View {
14+
@Binding var isAM: Bool
15+
@Binding var hour: Int
16+
@Binding var minute: Int
17+
18+
var body: some View {
19+
ZStack {
20+
CustomTimePicker(isAM: $isAM, hour: $hour, minute: $minute)
21+
.frame(height: 180)
22+
23+
Text(":")
24+
.applyCertiFont(.caption_semibold_14)
25+
.foregroundColor(.grayscale600)
26+
.offset(x: 49, y: 0)
27+
}
28+
}
29+
}
30+
31+
// MARK: - UIKit View
32+
33+
final class CertiPickerView: UIPickerView {
34+
35+
private var topLines: [UIView] = []
36+
private var bottomLines: [UIView] = []
37+
private var didHideIndicator = false
38+
private var lastBounds: CGRect = .zero
39+
40+
private struct ComponentLayout {
41+
let textWidth: CGFloat
42+
let lineLocationValue: CGFloat
43+
let lineWidth: CGFloat
44+
}
45+
46+
private let layouts: [ComponentLayout] = [
47+
.init(textWidth: 45, lineLocationValue: 5, lineWidth: 45),
48+
.init(textWidth: 129, lineLocationValue: 0, lineWidth: 35),
49+
.init(textWidth: 38, lineLocationValue: -7, lineWidth: 38)
50+
]
51+
52+
private let lineSpacing: [CGFloat] = [12, 10]
53+
54+
override init(frame: CGRect) {
55+
super.init(frame: frame)
56+
setupLines()
57+
}
58+
59+
required init?(coder: NSCoder) {
60+
super.init(coder: coder)
61+
setupLines()
62+
}
63+
64+
private func setupLines() {
65+
for _ in 0..<3 {
66+
let topLine = UIView()
67+
topLine.backgroundColor = .purpleblue
68+
addSubview(topLine)
69+
topLines.append(topLine)
70+
71+
let bottomLine = UIView()
72+
bottomLine.backgroundColor = .purpleblue
73+
addSubview(bottomLine)
74+
bottomLines.append(bottomLine)
75+
}
76+
}
77+
78+
override func layoutSubviews() {
79+
super.layoutSubviews()
80+
81+
if !didHideIndicator {
82+
hideIndicator()
83+
didHideIndicator = true
84+
}
85+
86+
if bounds != lastBounds {
87+
layoutLines()
88+
lastBounds = bounds
89+
}
90+
}
91+
}
92+
93+
// MARK: - Layout Methods
94+
95+
private extension CertiPickerView {
96+
func hideIndicator() {
97+
for sub in subviews {
98+
let isIndicator =
99+
sub.subviews.isEmpty &&
100+
sub.bounds.height > 5 &&
101+
sub.bounds.height < 60
102+
103+
if isIndicator {
104+
sub.isHidden = true
105+
sub.alpha = 0
106+
}
107+
}
108+
}
109+
110+
func layoutLines() {
111+
let totalContentWidth = layouts.map { $0.textWidth }.reduce(0, +) + lineSpacing.reduce(0, +)
112+
113+
var xOffset: CGFloat = (bounds.width - totalContentWidth) / 2
114+
115+
let rowHeight = rowSize(forComponent: 0).height
116+
let lineHeight: CGFloat = 2
117+
let topLineY = bounds.midY - rowHeight / 2
118+
let bottomLineY = bounds.midY + rowHeight / 2
119+
120+
for i in 0..<layouts.count {
121+
let layout = layouts[i]
122+
123+
var centerX = xOffset + layout.textWidth / 2
124+
centerX += layout.lineLocationValue
125+
126+
let lineX = centerX - layout.lineWidth / 2
127+
128+
topLines[i].frame = CGRect(
129+
x: lineX,
130+
y: topLineY,
131+
width: layout.lineWidth,
132+
height: lineHeight
133+
)
134+
135+
bottomLines[i].frame = CGRect(
136+
x: lineX,
137+
y: bottomLineY,
138+
width: layout.lineWidth,
139+
height: lineHeight
140+
)
141+
142+
if i < lineSpacing.count {
143+
xOffset += layout.textWidth + lineSpacing[i]
144+
} else {
145+
xOffset += layout.textWidth
146+
}
147+
}
148+
}
149+
}
150+
151+
// MARK: - UIViewRepresentable
152+
153+
struct CustomTimePicker: UIViewRepresentable {
154+
@Binding var isAM: Bool
155+
@Binding var hour: Int
156+
@Binding var minute: Int
157+
158+
func makeCoordinator() -> Coordinator {
159+
Coordinator(self)
160+
}
161+
162+
func makeUIView(context: Context) -> UIPickerView {
163+
let picker = CertiPickerView()
164+
picker.delegate = context.coordinator
165+
picker.dataSource = context.coordinator
166+
167+
let middleHourIndex = Coordinator.hoursInfinite.count / 2
168+
let middleMinuteIndex = Coordinator.minutesInfinite.count / 2
169+
170+
picker.selectRow(isAM ? 0 : 1, inComponent: 0, animated: false)
171+
picker.selectRow(middleHourIndex + (hour - 1), inComponent: 1, animated: false)
172+
picker.selectRow(middleMinuteIndex + (minute / 5), inComponent: 2, animated: false)
173+
174+
context.coordinator.currentHourRow = middleHourIndex + (hour - 1)
175+
context.coordinator.currentMinuteRow = middleMinuteIndex + (minute / 5)
176+
177+
return picker
178+
}
179+
180+
func updateUIView(_ uiView: UIPickerView, context: Context) {
181+
let meridiemRow = isAM ? 0 : 1
182+
183+
if uiView.selectedRow(inComponent: 0) != meridiemRow {
184+
uiView.selectRow(meridiemRow, inComponent: 0, animated: false)
185+
}
186+
187+
let currentHourRow = uiView.selectedRow(inComponent: 1)
188+
let currentDisplayedHour = Coordinator.hoursInfinite[currentHourRow]
189+
190+
if currentDisplayedHour != hour {
191+
let middleIndex = Coordinator.hoursInfinite.count / 2
192+
uiView.selectRow(middleIndex + (hour - 1), inComponent: 1, animated: false)
193+
}
194+
195+
let currentMinuteRow = uiView.selectedRow(inComponent: 2)
196+
let currentDisplayedMinute = Coordinator.minutesInfinite[currentMinuteRow]
197+
198+
if currentDisplayedMinute != minute {
199+
let middleIndex = Coordinator.minutesInfinite.count / 2
200+
uiView.selectRow(middleIndex + (minute / 5), inComponent: 2, animated: false)
201+
}
202+
}
203+
}
204+
205+
// MARK: - Coordinator
206+
207+
extension CustomTimePicker {
208+
class Coordinator: NSObject, UIPickerViewDelegate, UIPickerViewDataSource {
209+
private var parent: CustomTimePicker
210+
211+
fileprivate var currentHourRow: Int?
212+
fileprivate var currentMinuteRow: Int?
213+
214+
static let ampm: [String] = ["오전", "오후"]
215+
static let hours: [Int] = Array(1...12)
216+
static let minutes: [Int] = Array(stride(from: 0, to: 60, by: 5))
217+
static let hoursInfinite: [Int] = Array(repeating: hours, count: 30).flatMap { $0 }
218+
static let minutesInfinite: [Int] = Array(repeating: minutes, count: 30).flatMap { $0 }
219+
220+
init(_ parent: CustomTimePicker) {
221+
self.parent = parent
222+
}
223+
224+
func numberOfComponents(in pickerView: UIPickerView) -> Int {
225+
return 3
226+
}
227+
228+
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
229+
switch component {
230+
case 0: return Self.ampm.count
231+
case 1: return Self.hoursInfinite.count
232+
case 2: return Self.minutesInfinite.count
233+
default: return 0
234+
}
235+
}
236+
237+
func pickerView(_ pickerView: UIPickerView,
238+
viewForRow row: Int,
239+
forComponent component: Int,
240+
reusing view: UIView?) -> UIView {
241+
242+
let label: UILabel
243+
if let reused = view as? UILabel {
244+
label = reused
245+
} else {
246+
label = UILabel()
247+
label.textAlignment = .center
248+
label.font = UIFont(name: "Pretendard-SemiBold", size: 14)
249+
label.textColor = UIColor(named: "grayscale600")
250+
}
251+
252+
switch component {
253+
case 0: label.text = Self.ampm[row]
254+
case 1: label.text = "\(Self.hoursInfinite[row])"
255+
case 2: label.text = String(format: "%02d", Self.minutesInfinite[row])
256+
default: break
257+
}
258+
259+
return label
260+
}
261+
262+
func pickerView(_ pickerView: UIPickerView, widthForComponent component: Int) -> CGFloat {
263+
switch component {
264+
case 0: return 45
265+
case 1: return 129
266+
case 2: return 38
267+
default: return 50
268+
}
269+
}
270+
271+
func pickerView(_ pickerView: UIPickerView, rowHeightForComponent component: Int) -> CGFloat {
272+
return 40
273+
}
274+
275+
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
276+
switch component {
277+
case 0:
278+
parent.isAM = (row == 0)
279+
case 1:
280+
currentHourRow = row
281+
parent.hour = Self.hoursInfinite[row]
282+
case 2:
283+
currentMinuteRow = row
284+
parent.minute = Self.minutesInfinite[row]
285+
default: break
286+
}
287+
}
288+
}
289+
}
290+
291+
#Preview {
292+
struct PreviewWrapper: View {
293+
@State var isAM = true
294+
@State var hour = 1
295+
@State var minute = 0
296+
297+
var body: some View {
298+
CertiTimePicker(isAM: $isAM, hour: $hour, minute: $minute)
299+
}
300+
}
301+
return PreviewWrapper()
302+
}
303+

0 commit comments

Comments
 (0)