-
Notifications
You must be signed in to change notification settings - Fork 0
[Style] #185 - 취득 예정 모달 뷰 UI 구현 #191
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
66e7285
bb96007
a352144
a1012f7
f88c750
4c072c8
eb86ab6
29d01c6
f313081
b17281a
cea4d54
3d4bcfd
b56a568
dec6285
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } | ||
| } |
| 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) | ||
| 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() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 얘는 무슨 함수인가요?? 인디케이터가 머지요?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 레이아웃을 이런식으로 잡는군요! 이렇게 잡으면 오토레이아웃이 잘 적용될듯..
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. p3.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 지금 이 컴포넌트가 Global/Components 폴더에 위치해 있는데 파일을 분리해도 괜찮을까요? 오직 이 서티 타임 피커를 위한 구조체라서요. 하나의 파일에서 관리하려고 했습니다. 파일을 따로 분리하는 게 더 이해하기 편할까요?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. p3 저는 한 곳에서 관리하는 게 더 이해하기 편할 것 같긴해요!
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 좋아요 수정했습니다! |
||
| @Binding var isAM: Bool | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이거 변수명 실환가요..ㅋㅋ
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 불형이라서 어쩔 수 없시 isAM이라고 했어여... 왜냐면 Daypart가 true이면 오전인지 오후인지 모르잔아여...... 아닌감
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 바보 질문 죄송한데 self가 무슨 의미인지 잘 이해가 안돼서 설명 한번만..
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 구조체 안에 클래스를 바로 넣는 건 처음바요..
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 스유와 유킷을 연결하기 위해서 이렇게 되더라고영. 헷갈릴 소지가 있을 거 같아서 extension으로 빼두었습니다.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. p2 Coordinator 라는 네이밍을 사용하신 이유가 있을까요?? 만약 이 네이밍이 전통적이고 필수적인게 아니라면 Adapter 같은 다른 네이밍을 고려해보시는 건 어떨까요??
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이거 한번만 설명해주세용
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 애플식 무한스크롤을 구현하기 위해서 hoursInfinite 는 1부터 12까지 Int를 100번 반복한 배열이에요. 이 배열의 값을 12로 나눈 나머지값이 입력되도록 했습니다. 사실 반복한 배열 자체가 1~12 값만 있기 때문에 12로 안 나눠도 괜찮은데 혹시 몰라 배열 범위를 벗어나는 것을 방지하기 위해서 적용했습니다.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
p1 로그를 찍어보니까 무한 스크롤이다보니 라벨이 계속해서 생성되고 있더라구요!! 제가 알기론 UILabel()을 호출할 때마다 시스템은 힙(Heap) 영역에 메모리 공간을 새로 확보하고, 객체를 초기화합니다. 그래서 저는 두가지 방안을 고려해보셨으면 좋을 것 같습니다!
|
||
| case 2: | ||
| parent.minute = Self.minutesInfinite[row % 12] | ||
| default: break | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // MARK: - SwiftUI Component | ||
|
|
||
| struct CertiTimePicker: View { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. p3.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
| } | ||
|
|
||

There was a problem hiding this comment.
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로 사용하신 이유가 있을까요?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
엇 그러네요..? 색상 인식이 안 돼서 썼던 건데 ㅠ UIColor 다 지우고 .purple로 수정했습니다