Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//
// CertificationDetailPreviewUseCase.swift
// CERTI-iOS
//
// Created by 이상엽 on 11/16/25.
//

struct PreviewFetchCertificationDetailUseCase: FetchCertificationDetailUseCase {
func execute(id: Int) async -> Result<CertificationDetailEntity, NetworkError> {
let dummy = CertificationDetailEntity(
certificationId: id,
certificationName: "GTQ 1급 (그래픽기술자격)",
tags: ["디자인", "그래픽"],
averagePeriod: "2개월",
charge: "35,000원",
agencyName: "한국생산성본부",
testType: "필기/실기",
description: "그래픽 편집 능력을 평가하는 자격으로, 포토샵 및 일러스트 사용 능력을 측정합니다.",
testDateInformation: "매월 정기 시행",
applicationMethod: "온라인 접수",
applicationUrl: "https://www.kpc.or.kr/gtq",
expirationPeriod: "영구"
)
return .success(dummy)
}
}

struct PreviewAddPreCertificationUseCase: AddPreCertificationUseCase {
func execute(certificationId: Int) async -> Result<AppendPreCertificationStatus, NetworkError> {
// Always succeed for preview
return .success(.success)
}
}

struct PreviewAddAcquisitionUseCase: AddAcquisitionUseCase {
func execute(certificationId: Int) async -> Result<Bool, NetworkError> {
// Always succeed for preview
return .success(true)
}
}
257 changes: 257 additions & 0 deletions CERTI-iOS/Global/Components/CertiTimePicker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
//
// CertiTimePicker.swift
// CERTI-iOS
//
// Created by 이상엽 on 11/21/25.
//

import SwiftUI
import UIKit

// MARK: - UIKit View

final class CertiPickerView: UIPickerView {

private var topLines: [UIView] = []
private var bottomLines: [UIView] = []

private struct ComponentLayout {
let textWidth: CGFloat
let lineLocationValue: CGFloat
let lineWidth: CGFloat
}

private let layouts: [ComponentLayout] = [
.init(textWidth: 45, lineLocationValue: 5, lineWidth: 45),
.init(textWidth: 129, lineLocationValue: 0, lineWidth: 35),
.init(textWidth: 38, lineLocationValue: -7, lineWidth: 38)
]

private let lineSpacing: [CGFloat] = [12, 10]

override init(frame: CGRect) {
super.init(frame: frame)
setupLines()
}

required init?(coder: NSCoder) {
super.init(coder: coder)
setupLines()
}

private func setupLines() {
for _ in 0..<3 {
let topLine = UIView()
topLine.backgroundColor = UIColor(Color.purpleblue)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3

topLine.backgroundColor = UIColor(.purpleblue) 혹은 topLine.backgroundColor = .purpleblue

이렇게 작성해도 정상적으로 빌드되더라구요! 혹시 Color.purpleblue로 사용하신 이유가 있을까요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

엇 그러네요..? 색상 인식이 안 돼서 썼던 건데 ㅠ UIColor 다 지우고 .purple로 수정했습니다

addSubview(topLine)
topLines.append(topLine)

let bottomLine = UIView()
bottomLine.backgroundColor = UIColor(Color.purpleblue)
addSubview(bottomLine)
bottomLines.append(bottomLine)
}
}

override func layoutSubviews() {
super.layoutSubviews()

hideIndicator()
layoutLines()
}
}


// MARK: - Layout Methods

private extension CertiPickerView {
func hideIndicator() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

얘는 무슨 함수인가요?? 인디케이터가 머지요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pr에 보시믄 기본 datePicker에서 사용되는 중앙에 선택된 시간을 알려주는 회색 바(?) 예요. 우리 서티 픽커에서는 이게 안 보여야해서 안 보이게 해주는 메서드입니다.

for sub in subviews {
let isIndicator =
sub.subviews.isEmpty &&
sub.bounds.height > 5 &&
sub.bounds.height < 60

if isIndicator {
sub.isHidden = true
sub.alpha = 0
}
}
}

func layoutLines() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

레이아웃을 이런식으로 잡는군요! 이렇게 잡으면 오토레이아웃이 잘 적용될듯..

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이게 좋아보이지는 않는데 유킷적 관점에서 괜찮나요?

let totalContentWidth =
layouts.map { $0.textWidth }.reduce(0, +) + lineSpacing.reduce(0, +)

var xOffset: CGFloat = (bounds.width - totalContentWidth) / 2

let rowHeight = rowSize(forComponent: 0).height
let lineHeight: CGFloat = 2
let topLineY = bounds.midY - rowHeight / 2
let bottomLineY = bounds.midY + rowHeight / 2

for i in 0..<layouts.count {
let layout = layouts[i]

var centerX = xOffset + layout.textWidth / 2
centerX += layout.lineLocationValue

let lineX = centerX - layout.lineWidth / 2

topLines[i].frame = CGRect(
x: lineX,
y: topLineY,
width: layout.lineWidth,
height: lineHeight
)

bottomLines[i].frame = CGRect(
x: lineX,
y: bottomLineY,
width: layout.lineWidth,
height: lineHeight
)

if i < lineSpacing.count {
xOffset += layout.textWidth + lineSpacing[i]
} else {
xOffset += layout.textWidth
}
}
}
}

// MARK: - UIViewRepresentable

struct CustomTimePicker: UIViewRepresentable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3.
여기서부터는 다른 구조체가 시작되는데, 파일 분리해주는 것도 좋을 거 같습니다

Copy link
Contributor Author

@sangyup12 sangyup12 Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금 이 컴포넌트가 Global/Components 폴더에 위치해 있는데 파일을 분리해도 괜찮을까요? 오직 이 서티 타임 피커를 위한 구조체라서요. 하나의 파일에서 관리하려고 했습니다. 파일을 따로 분리하는 게 더 이해하기 편할까요?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3

저는 한 곳에서 관리하는 게 더 이해하기 편할 것 같긴해요!
CertiTimePicker를 구현하기 위한 CertiPickerView 를 SwiftUI에서 사용할 수 있게 해주는 UIViewRepresentable 이니까 꼭 분리할 필요는 없지 않을까...
다만 지금은 UIKit으로 구현한 CertiPickerView가 파일 상단에 위치하고 하단에 우리 프로젝트에서 실질적으로 사용하는 SwiftUI 구조체가 위치해 있는데, 둘의 순서를 바꾸는 건 어떨지! 의견만 내봅니다

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋아요 수정했습니다!

@Binding var isAM: Bool
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 변수명 실환가요..ㅋㅋ
p3.
Meridiem Indicator나 Cycle, Daypart 어떠신지 조심스럽게 추천해봅니다..
근데 덜 직관적이라면 지금처럼 가는 것도 조은거 가타요

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

불형이라서 어쩔 수 없시 isAM이라고 했어여... 왜냐면 Daypart가 true이면 오전인지 오후인지 모르잔아여...... 아닌감

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3

저어는 지금 상태가 조금 더 직관적인 것 같긴 합니다..ㅎㅎ

@Binding var hour: Int
@Binding var minute: Int

func makeCoordinator() -> Coordinator {
Coordinator(self)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

바보 질문 죄송한데 self가 무슨 의미인지 잘 이해가 안돼서 설명 한번만..

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CustomTimePicker 구조체 입니다. 스유와 유킷을 연결해주는 코디네이터에게 자신을 알려줌으로써 변경되는 @binding 변수인 시간, 분을 업데이트 해줄 수 있게 되는 것입니다.

}

func makeUIView(context: Context) -> UIPickerView {
let picker = CertiPickerView()
picker.delegate = context.coordinator
picker.dataSource = context.coordinator
return picker
}

func updateUIView(_ uiView: UIPickerView, context: Context) {
uiView.selectRow(isAM ? 0 : 1, inComponent: 0, animated: false)
uiView.selectRow(hour - 1, inComponent: 1, animated: false)
uiView.selectRow(minute / 5, inComponent: 2, animated: false)
}
}

// MARK: - Coordinator

extension CustomTimePicker {
class Coordinator: NSObject, UIPickerViewDelegate, UIPickerViewDataSource {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

구조체 안에 클래스를 바로 넣는 건 처음바요..
분리할 수 있는 방법 없을까요..?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스유와 유킷을 연결하기 위해서 이렇게 되더라고영. 헷갈릴 소지가 있을 거 같아서 extension으로 빼두었습니다.
참고한 글: https://ios-development.tistory.com/1043

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p2

Coordinator 라는 네이밍을 사용하신 이유가 있을까요??
비록 이 구조체 내에서만 사용되기는 하지만, iOS에서 Coordinator 라고 하면 네비게이션 로직과 관련된 작업을 하는 객체를 떠올리는 게 일반적이라고 생각해요.
심지어 Certi 프로젝트는 Coordinator 패턴을 채택해서 화면전환을 전담하는 객체로서 사용하고 있기 때문에 용도에 대한 혼동이 생길 수 있을 것 같아요.

만약 이 네이밍이 전통적이고 필수적인게 아니라면 Adapter 같은 다른 네이밍을 고려해보시는 건 어떨까요??

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제가 임의로 정한 건 아니고 애플 공식문서에서 유킷 델리게이트 역할을 담당하는 애를 Coordinator라고 사용하고 있어용. UIViewRepresentable에 makeCoordinator()라는 메서드도 구현해야 하기 때문에 다른 네이밍을 쓰기보다 애플에서 정의한대로 작성하는 게 적절하다고 생각합니다!

var parent: CustomTimePicker

static let ampm: [String] = ["오전", "오후"]
static let hours: [Int] = Array(1...12)
static let minutes: [Int] = Array(stride(from: 0, to: 60, by: 5))
static let hoursInfinite: [Int] = Array(repeating: hours, count: 100).flatMap { $0 }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

얘도 끝이 있는 타임피커?!

static let minutesInfinite: [Int] = Array(repeating: minutes, count: 100).flatMap { $0 }

init(_ parent: CustomTimePicker) {
self.parent = parent
}

func numberOfComponents(in pickerView: UIPickerView) -> Int {
return 3
}

func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
switch component {
case 0: return Self.ampm.count
case 1: return Self.hoursInfinite.count
case 2: return Self.minutesInfinite.count
default: return 0
}
}

func pickerView(_ pickerView: UIPickerView,
viewForRow row: Int,
forComponent component: Int,
reusing view: UIView?) -> UIView {

let label = UILabel()
label.textAlignment = .center
label.font = UIFont(name: "Pretendard-SemiBold", size: 14)
label.textColor = UIColor(named: "grayscale600")

switch component {
case 0: label.text = Self.ampm[row]
case 1: label.text = "\(Self.hoursInfinite[row % 12])"
case 2: label.text = String(format: "%02d", Self.minutesInfinite[row % 12])
default: break
}

return label
}

func pickerView(_ pickerView: UIPickerView, widthForComponent component: Int) -> CGFloat {
switch component {
case 0: return 45
case 1: return 129
case 2: return 38
default: return 50
}
}

func pickerView(_ pickerView: UIPickerView, rowHeightForComponent component: Int) -> CGFloat {
return 40
}

func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
switch component {
case 0:
parent.isAM = (row == 0)
case 1:
parent.hour = Self.hoursInfinite[row % 12]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 한번만 설명해주세용

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

애플식 무한스크롤을 구현하기 위해서 hoursInfinite 는 1부터 12까지 Int를 100번 반복한 배열이에요. 이 배열의 값을 12로 나눈 나머지값이 입력되도록 했습니다. 사실 반복한 배열 자체가 1~12 값만 있기 때문에 12로 안 나눠도 괜찮은데 혹시 몰라 배열 범위를 벗어나는 것을 방지하기 위해서 적용했습니다.
아래에 minutesInfinite도 같은 방식입니다

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

p1

로그를 찍어보니까 무한 스크롤이다보니 라벨이 계속해서 생성되고 있더라구요!!

제가 알기론 UILabel()을 호출할 때마다 시스템은 힙(Heap) 영역에 메모리 공간을 새로 확보하고, 객체를 초기화합니다.
피커를 빠르게 휙 돌리면 1초에 수십 개의 row가 지나가는데, 그때마다 수십 번의 메모리 할당과 해제가 반복되며 성능상 부담이 갈 수 있을 것 같아요!

그래서 저는 두가지 방안을 고려해보셨으면 좋을 것 같습니다!

  1. 기획측과 무한 스크롤이 필요한가? 논의해보기 (비추)
  2. 지금 상태에서 메모리 사용량을 Instruments 툴을 통해 측정하고 캐싱 혹은 뷰 재사용등의 방식으로 개선해서 정량적 수치로 나타낼 수 있는 개선해본 경험 만들기 (추천)

case 2:
parent.minute = Self.minutesInfinite[row % 12]
default: break
}
}
}
}

// MARK: - SwiftUI Component

struct CertiTimePicker: View {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3.
얘두 분리 가능하면 파일 분리 해주면 조을 거 같슴당

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

파일 분리에 대해서 위에 의견 남겨주세요!!

@Binding var isAM: Bool
@Binding var hour: Int
@Binding var minute: Int

var body: some View {
ZStack {
CustomTimePicker(isAM: $isAM, hour: $hour, minute: $minute)
.frame(height: 180)

Text(":")
.applyCertiFont(.caption_semibold_14)
.foregroundColor(.grayscale600)
.offset(x: 49, y: 0)
Comment on lines +237 to +240
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3

offset으로 위치를 설정해놔서 그런디 혹시 13mini랑 17 pro max랑 둘 다 시뮬레이터 상으로 괜찮았나유

}
}
}

#Preview {
struct PreviewWrapper: View {
@State var isAM = true
@State var hour = 1
@State var minute = 0

var body: some View {
CertiTimePicker(isAM: $isAM, hour: $hour, minute: $minute)
}
}
return PreviewWrapper()
}

Loading