안녕하세요! iOS 파트 정민지입니다:)
다들 iOS에서 UIPickerView를 사용해보셨나요?
터닝을 제작하면서 UIPickerView를 커스텀했던 경험을 공유하고자 합니다!
아래 사진은 제작해야 했던 화면입니다.
UIPickerView
let datePicker = UIDatePicker()
datePicker.datePickerMode = .date // 연/월/일 포함
datePicker.preferredDatePickerStyle = .wheels // 휠 스타일
위의 사진으로 기본으로 제공하는 UIPickerView를 date 모드와 wheels 스타일로 사용했을때의 사진입니다.
Apple이 공식적으로 제공하는 UIDatePickerMode는 다음과 같습니다.
- .time → 시/분
- .date → 연/월/일
- .dateAndTime → 연/월/일 + 시/분
- .countDownTimer → 타이머 형식
이중에선 .date 모드가 제작해야할 뷰에 가장 적절하지만, 내부적으로 Date 타입을 생성해야 하므로 연(year), 월(month), 일(day)이 필수입니다.
연도를 제거하거나 제외할 수 있는 옵션이 없습니다.😂
따라서 “년도/월만” 선택할 수 있도록 따로 커스텀 DatePicker를 구현해야했습니다!
UIPickerView 커스터마이징
UIPickerView를 커스터마이징하기 위해서는
UIPickerViewDataSource, UIPickerViewDelegate를 채택해서 구현을 해주어야합니다!
UIKit는 복잡한 UI 컴포넌트의 내부 구현을 숨기고, 사용자 정의 동작은 외부 객체(= 개발자)가 제공하도록 설계가 되어있습니다.
DataSource | 데이터 구조 제공 (몇 개의 열? 몇 개의 행?) |
Delegate | 동작 정의 (각 행에 어떤 내용? 선택 시 어떤 동작?) |
즉, UIPickerView는 그 자체로는 아무런 데이터도, 동작도 알지 못합니다.
따라서 개발자가 delegate와 dataSource를 통해 모든 정보를 제공해야만 정상적으로 작동합니다.
그리고 정보를 제공하는 과정에서 어떤 정보를 제공할 것인가에 따라 커스터마이징을 할 수 있게 되는거죠!
let pickerView = UIPickerView()
pickerView.delegate = self
pickerView.dataSource = self
다음과 같이 위임을 먼저 해주면 됩니다!
- 선택, UI 표시 등 사용자 인터랙션 처리를 이 객체(self)가 하겠다고 "delegate"로 위임
- 열/행 수 등 데이터 구조 제공 역할을 이 객체(self)가 하겠다고 "dataSource"로 위임
// MARK: - UIPickerViewDataSource & Delegate
extension ViewController: UIPickerViewDataSource, UIPickerViewDelegate {
// 컴포넌트 개수 설정 (열 개수: 0 → 연도, 1 → 월)
func numberOfComponents(in pickerView: UIPickerView) -> Int {
return 2 // 0: 년도, 1: 월
}
// 각 컴포넌트(열)의 행(row) 개수 지정
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
return component == 0 ? years.count : months.count
}
// 각 행(row)에 표시될 문자열 반환
func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
return component == 0 ? "\(years[row])년" : "\(months[row])월"
}
// 유저가 특정 행을 선택했을 때 동작
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
if component == 0 {
selectedYear = years[row]
} else {
selectedMonth = months[row]
}
resultLabel.text = "선택된 날짜: \(selectedYear)년 \(selectedMonth)월"
}
// 각 컴포넌트의 너비 설정
func pickerView(_ pickerView: UIPickerView, widthForComponent component: Int) -> CGFloat {
return view.frame.width / 2.5
}
}
그리고 나서 각 프로토콜에 정의된 함수들을 직접 구현하면서UIPickerView가 원하는 정보나 동작을 하나씩 알려주시면 됩니다!
이제 UIPickerView는 일을 수행하려 할 때 delegate/dataSource에게 "어떤 일을 해야하는거야?" 질문을 하고, 개발자가 어떤 일을 해야한다를 delegate/dataSource의 함수로 자세히 답변해주는 것입니다!
- “각 열은 몇 개의 row 가져야 해?” → pickerView(_:numberOfRowsInComponent:)
- “사용자가 선택하면 뭐 해줘?” → pickerView(_:didSelectRow:inComponent:)
- “각 row에 어떤 텍스트 보여줄까?” → pickerView(_:titleForRow:forComponent:)
- “나 몇 개의 컴포넌트 가져야 해?” → numberOfComponents(in:)
공식적으로 제공하는 커스터마이즈의 종류는 이 정도 입니다!
(각 함수가 어떤 정보를 커스터마이징할 수 있는지는 주석으로 작성을 해두었습니다!)
UIPickerView 커스터마이징 중 겪은 어려움
제가 UIPickerView를 사용하면서 가장 어려웠던 부분은 기본으로 제공하는 곡률 효과를 없애는 것이었습니다.
iOS 7부터 UIPickerView의 비선택 영역이 곡면처럼 휘어져 보이는 효과가 생겼습니다.
Apple의 UIPickerView는 내부적으로 수직 리스트를 단순히 보여주는 것이 아니라, 3D 원기둥 위에 컨텐츠를 배치한 것처럼 곡면 시각 효과를 적용합니다.
선택된 가운데 row는 평면처럼 보이고, 위/아래 row는 휘어진 곡면에 붙어 있는 듯한 시각 효과를 갖습니다.
특히 이 곡률 효과는 텍스트가 왼쪽 또는 가운데 정렬일 때 더 크게 느껴지며, 컴포넌트가 2개 이상일 때는 정렬 불균형으로 인해 더 눈에 띄게 보입니다.
따라서 년도와 달을 보여주는 PickerView를 만들면 디자인으로 위의 사진과 같이 구현이 됩니다!
그러나 제가 구현해야할 디자인은 기본 수직 리스트 형태였습니다.
UIKit의 가장 큰 장점은 개발자가 UI를 마음대로 커스터마이징할 수 있다는 유연함이라고 생각해왔습니다.
그래서 당시에는 디자이너가 준 시안 그대로 구현해내는 것이 곧 개발자로서의 책임이자, 맡은 일을 제대로 해냈다는 기준이라고 믿었습니다.
그러나 UIPickerView는 UITableView 기반이 아닌 비공개 구조로 구현되어 있으며,
특히 비선택 영역에 적용된 곡률(curvature) 효과는 공식 API로 제어할 수 없었습니다.
그럼에도 불구하고 해결책을 찾기 위해 검색을 이어갔고,
그 과정에서 StackOverflow의 한 답변을 통해 곡률 효과를 완화할 수 있는 비공식적인 접근 방식을 찾을 수 있었습니다!
위의 답변 중에서 UIPickerView의 곡률(휘어져 보이는 시각 효과)를 완전히 없애는 대신, 텍스트 정렬과 간격을 조절해서 그 곡률이 눈에 덜 띄게 만드는 방식을 채택하여 사용하게 되었습니다.
찾은 해결 방법
- attributedTitleForRow: 또는 viewForRow:를 이용해서 NSAttributedString으로 정렬 조정
- NSMutableParagraphStyle의 alignment = .right, tailIndent 설정
- tailIndent는 텍스트의 오른쪽 간격을 고정시켜주는 값
- widthForComponent의 너비를 tailIndent의 2배 정도로 조정
- 곡률 효과를 최소화할 수 있음
적용한 해결 방법
1. viewForRow를 사용하여 UILabel 직접 커스터마이징
단순한 titleForRow나 attributedTitleForRow가 아니라, UIView를 직접 반환할 수 있는 viewForRow를 사용했습니다.
이렇게 하면 UILabel뿐만 아니라 오토레이아웃, 색상, 패딩, 위치 등을 정교하게 조절할 수 있게 됩니다!
public func pickerView(_ pickerView: UIPickerView, viewForRow row: Int, forComponent component: Int, reusing view: UIView?) -> UIView
2. NSAttributedString + ParagraphStyle로 정렬 고정
다음과 같이 NSMutableParagraphStyle을 사용해 오른쪽 정렬 + tailIndent 고정을 적용했습니다.
tailIndent는 텍스트의 끝나는 위치를 픽셀 단위로 고정해주는 역할을 합니다.
이로 인해 텍스트가 휘어져 밀려나는 현상이 덜 보이게 됩니다~👍🏻
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .right
paragraphStyle.tailIndent = 60
let attributedString = NSAttributedString(string: text, attributes: [
.paragraphStyle: paragraphStyle,
.font: UIFont.title3,
.foregroundColor: UIColor.grey500
])
3. widthForComponent를 tailIndent에 맞춰 조정
곡률 효과를 최소화하기 위해, StackOverflow의 제안처럼
componentWidth ≈ tailIndent * 2
이 원칙을 적용하여 컴포넌트의 너비를 충분히 확보했습니다.
너비가 좁으면 텍스트가 휘어진 영역까지 밀려들어가 왜곡이 커지므로, 적절한 폭 확보가 중요합니다.
func pickerView(_ pickerView: UIPickerView, widthForComponent component: Int) -> CGFloat {
return 120 // tailIndent가 60일 경우 추천값
}
마무리 😎
해당 작업을 통해 UIKit 컴포넌트도 공식 API로는 제어할 수 없는 부분이 있다는 것을 체감했지만,
동시에 조금만 더 digging 해보면, 의도한 UI를 구현할 수 있는 방법은 충분히 있다는 점도 배울 수 있었습니다.
공식 문서에 없는 문제라도, 커뮤니티(특히 StackOverflow)에는 수많은 개발자들의 고민과 해결책이 공유되고 있었고,
그 내용을 이해하고 응용하는 능력이 실제 개발에서 굉장히 중요하다는 걸 다시 한번 느꼈습니다.
해당 작업을 하면서는 디자이너가 요청한 디자인을 그대로 구현하는 것이 개발자의 역할이고, 책임을 다하는 것이라고 생각을 했었습니다.
그러나 최근에 바뀐 생각으로는 제한적인 부분에도 많은 리소스를 쏟는 것보단, “이건 UIKit에서 기본으로 제공되는 스타일이라 완전한 커스터마이징이 어렵다”고 설명하고 조율하는 것이 더 좋은 협업 방식이라고 생각하게 되었습니다!
디자이너에게도 애플 생태계 안에서의 UI/UX 원칙을 공유하고 협업 과정 속에서 함께 성장하고, 서로의 도메인에 대한 이해를 넓혀가는 것이 서로에게 도움이 되고, 제품 완성도에도 긍정적인 영향을 줄 수 있다고 생각하게되었습니다!
그러나 이러한 경험도 UI가 깔끔하게 맞아떨어졌을 때의 뿌듯함과 디자인 시안에 최대한 가깝게 구현해냈다는 만족감 덕분에, 저에게는 정말 의미 있는 시간이었습니다.☺️
읽어주셔서 감사합니다! 🙌🏻
https://github.com/teamterning/Terning-iOS
GitHub - teamterning/Terning-iOS: 🍎 지금이 아요의 터닝포인트~ 🍎 SOPT 34기 iOS
🍎 지금이 아요의 터닝포인트~ 🍎 SOPT 34기 iOS. Contribute to teamterning/Terning-iOS development by creating an account on GitHub.
github.com