
다양한 뷰를 그리기 위해서 평상시 사용하는 앱들을 클론하던 도중, 타이머 기능을 포함한 뷰를 그리게 되었다.

비록 위의 이미지는 아이폰 기본 시계 앱의 타이머에서 가져온 것으로 해당 앱의 타이머와는 다르긴 하지만, 이런 식으로 상하로 이동해서 마치 자전거에 채우는 자물쇠 모양처럼 생긴 컴포넌트가 있다. 해당 컴포넌트가 뭔지 찾기 위해서 library를 뒤져서 UIPickerView 인 것을 알 수 있었다. (유사하게, 나중에 날짜 선택하는 앱을 담당할 때는 Date Picker를 사용하면 될 것 같다.)

A view that uses a spinning-wheel or slot-machine metaphor to show one or more sets of values.
picker view는 아이템 선택을 위한 하나 이상의 휠을 제공함. 각 휠은 컴포넌트로써 아이템 선택을 위한 인덱싱 되어있는 행들을 제공함. 각 행은 문자열이나 뷰를 표현함으로써 각 행들의 아이템이 무엇을 나타내늦지 알 수 있도록 함. 유저들은 selection indicator와 연결되어있는 돌아가는 휠을 통해 원하는 값을 선택함.
Note
UIDatePicker는 날짜와 시간을 나타내기 위한 UIPickerView를 상속받은 자식 클래스다. 예시를 보기 위해서, Clock app의 alarm에서 +버튼을 눌러라.
UIPickerViewDataSource를 채택하는 picker data source를 통해 picker view에서 표현할 데이터를 제공함. UIPickerViewDelegate를 채택하는 데이터를 표현하는 뷰들과 user selection에 대해서 제공해라.
Important
UIPickerView and its descendants aren’t available when the user interface idiom is UIUserInterfaceIdiom.mac.
=> 한마디로, 맥에서는 UIPickerView를 사용하지 못한다는 뜻임. 사실 아직까지의 나와, 이 글을 읽게되는 대부분의 사람들은 macOS를 고려하지 않고 iOS만을 고려하기 때문에 큰 문제는 되지 않을듯 싶다.
여기까지가 Apple Documentation에서의 UIDatePickerView를 해석한 내용이다. 나 또한 해당 글을 쓰면서 처음으로 UIPickerView를 사용해보고 있기 때문에 아직 data source와 delegate를 작성해보지는 않았지만, 늘 해왔듯이, collection view나 table view와 비슷하게 구현하면 될 것 같다. 추가로 data source와 delegate도 해석할까 했으나, 너무 노가다고 재미가 없을 것 같다. 뷰를 그리면서 필요한 메서드 등을 다뤄보고 해당 부분에 대해서 제시하려고 한다.


이런식으로, 뷰 컨트롤러에서 해당 컴포넌트를 넣고 dataSource = self로 한 뒤, UIPickerViewDataSource를 채택하도록 했다. 대부분은 컬렉션 뷰와 상당히 유사하지 않은가? numberOfItemsInSection, numberOfSections와 매우 흡사해보인다. 동일하게 사실 어떤 메서드였는지 인자 찾아서 하는 방법도 있지만, 없으면 경고가 뜨기 때문에 fix 버튼을 눌러도 자동으로 삽입해준다.
일단 이 정도로 매듭을 짓고, 내가 코드에서 어떻게 적용했는지 알아보자.

위의 이미지의 뷰를 만들었다. 탭 뷰 아이템 각각에 대해서 뷰 컨트롤러를 생성했고, 타이머 탭에 대한 뷰 컨트롤러로 TimerViewController를 만들었다.
기본적으로 5분부터 60분까지를 가리키는 아이템들을 포함하는 PickerView를 만들었고, 현재 PickerView에서 선택 중인 아이템을 하이라이트하기 위해 label을 만든 후, 추가로 시작 버튼을 생성했다.
코드를 보면서 알아보자.
import UIKit
class TimerViewController: UIViewController {
let pickerView = UIPickerView()
let minuteLabel: UILabel = {
let label = UILabel()
label.backgroundColor = .darkGray
label.clipsToBounds = true
label.text = "분 동안 "
label.textAlignment = .right
label.textColor = .white
label.layer.cornerRadius = 10
label.numberOfLines = 1
return label
}()
let startButton: UIButton = {
let button = UIButton()
button.addTarget(self, action: #selector(updateTime), for: .touchUpInside)
button.addTarget(self, action: #selector(updateBgColor), for: .touchDown)
button.setTitle("시작", for: .normal)
button.setTitleColor(.black, for: .normal)
button.backgroundColor = .white
button.layer.cornerRadius = 25
return button
}()
@objc func updateTime() {
self.chosenTime = self.selectedTime
if let chosenTime = self.chosenTime { print("chosenTime: \(chosenTime)") }
self.startButton.backgroundColor = .white
}
@objc func updateBgColor() {
startButton.backgroundColor = .darkGray
}
var selectedTime: Int? = 5
var chosenTime: Int? = 5
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = .black
// navigation setting
self.navigationItem.title = "타이머"
self.navigationController?.navigationBar.prefersLargeTitles = true
self.navigationController?.navigationBar.isTranslucent = false
self.navigationController?.navigationBar.tintColor = .white
self.navigationController?.navigationBar.largeTitleTextAttributes = [
.foregroundColor: UIColor.white
]
pickerView.delegate = self
pickerView.dataSource = self
pickerView.isOpaque = false
// Auto Layout
self.view.addSubview(pickerView)
self.view.addSubview(minuteLabel)
self.view.addSubview(startButton)
pickerView.translatesAutoresizingMaskIntoConstraints = false
minuteLabel.translatesAutoresizingMaskIntoConstraints = false
startButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
pickerView.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
pickerView.centerYAnchor.constraint(equalTo: self.view.centerYAnchor, constant: -100),
pickerView.widthAnchor.constraint(equalToConstant: self.view.frame.width - 100),
pickerView.heightAnchor.constraint(equalToConstant: 300),
minuteLabel.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
minuteLabel.centerYAnchor.constraint(equalTo: self.view.centerYAnchor, constant: -100),
minuteLabel.widthAnchor.constraint(equalToConstant: self.view.frame.width - 100),
minuteLabel.heightAnchor.constraint(equalToConstant: 35),
startButton.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
startButton.topAnchor.constraint(equalTo: pickerView.bottomAnchor, constant: 80),
startButton.widthAnchor.constraint(equalToConstant: 140),
startButton.heightAnchor.constraint(equalToConstant: 50)
])
self.view.bringSubviewToFront(pickerView)
}
}
선언한 컴포넌트 순서대로 알아보자면, 먼저 minuteLabel으로, 현재 selectedRow를 보다 더 효과적으로 나타내고, 뒤에 분 동안이라는 suffix를 달아줄 목적으로 생성했다. 뷰에서 보이는 시작 버튼은 startButton으로, 버튼이 눌렸을 때, chosenTime이라는 프로퍼티에 값을 전달해주고 출력하는 용도의 메서드를 연결했으며, 이 밖에도 버튼을 눌르는 도중과 눌렀을 때 백그라운드 색상을 updateTime과 updateBgColor 메서드를 통해서 변경해줬다. 추가로, chosenTime이라는 것이 있는데, 이는 시작 버튼이 눌리기 전에 현재 PickerView에서 선택되어있는 값을 저장하기 위한 프로퍼티다.
viewDidLoad 메서드에서는 항상 하던 배경 색상 지정, 네비게이션바, addSubview, 오토레이아웃 등을 수행했다. 위에서 언급한 PickerView의 delegate와 dataSource를 self로 지정해줬는데, 이 부분에 대해서는 따로 하단에 설명하겠다.
이 외에 이번 뷰를 그려보면서 처음 사용하게 된 메서드가 있는데, 이는 bringSubviewToFront다. 현재 코드 상에서는 minuteLabel과 pickerView의 selectedRow가 겹쳐져있다. 이 때문에, minuteLabel이 해당 row를 완전히 가려버리는 문제가 발생했다. SwiftUI라면 그냥 ZStack으로 처리하면 될 문제라고 생각하지만, UIKit에서는 어떻게 해야할지 몰라서 isOpaque값을 수정해봤는데 소용없길래 한참 찾다가 간신히 찾았다. 나중에도 자주 쓸 것 같다.
extension TimerViewController: UIPickerViewDataSource {
func numberOfComponents(in pickerView: UIPickerView) -> Int {
return 1
}
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
return 12
}
}
extension TimerViewController: UIPickerViewDelegate {
func pickerView(_ pickerView: UIPickerView, rowHeightForComponent component: Int) -> CGFloat {
return 50
}
func pickerView(_ pickerView: UIPickerView, widthForComponent component: Int) -> CGFloat {
return pickerView.frame.width
}
func pickerView(_ pickerView: UIPickerView, attributedTitleForRow row: Int, forComponent component: Int) -> NSAttributedString? {
return NSAttributedString(string: "\(row * 5 + 5)", attributes: [NSAttributedString.Key.foregroundColor: UIColor.white])
}
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
self.selectedTime = row * 5 + 5
if let selectedTime = self.selectedTime { print("selectedTime: \(selectedTime)")}
}
}
사실 이 뷰에서 PickerView가 차지하는 코드 양은 적다. 먼저 기본으로 numberOfComponents에서 Components는 column의 갯수를 의미한다. 내가 생성한 뷰에서는? 오로지 시간만 나타내기 때문에 return 1로 주면 끝난다. numberOfRowsInComponent에서는 5분부터 60분까지 5분 단위기 때문에 12개를 리턴해주기만 하면 된다.
rowHeight과 width 관련해서는 각자 원하는 값을 주기 때문에 생략하고, attributedTitleForRow에 대해서 얘기하려고 한다. 분명 attributedTitleForRow가 아닌, titleForRow를 지정하는 메서드도 있다.
func pickerView(_ pickerView: UIPickerView, attributedTitleForRow row: Int, forComponent component: Int) -> NSAttributedString? {
return NSAttributedString(string: "\(row * 5 + 5)", attributes: [NSAttributedString.Key.foregroundColor: UIColor.white])
}
// 단순히 타이틀만 주는 메서드
func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
return "\(row * 5 + 5)"
}
굳이 attributedTitleForRow를 준 이유는, 다크모드 라이트모드 모두 고려하기 위해서 그냥 넣었다. 뷰의 백그라운드를 black으로 고정시킨 이상, 다크모드와 라이트모드에서 모두 하얗게 보인다고 가정하고 작성했기 때문이다. 만약 그냥 타이틀로 주는 경우, 다음과 같은 문제가 발생한다.

보시다싶이, 라이트모드에서 선택된 아이템 외에는 전혀 보이지 않기 때문에 PickerView가 맞나 싶다. 이 때문에 색상을 이렇게 줬으며, 각 row에 대한 title은 당연히 5 * row + 5로 줘서 5 부터 60까지의 5의 배수를 가질 수 있게 했다.
이 외에도 didSelectRow를 통해, TimerViewController의 selectedTime의 값을 동기화하도록 했다.
뷰 그리는걸 연습하면서, UIPickerView를 처음 사용해볼 기회가 생겨 포스트를 작성하면서 코드를 짰지만, 사실 해당 뷰에서 UIPickerView보다는 어떻게 저 minuteLabel 를 적절하게 잘 표시할까에 대해서 훨씬 더 많이 고민하고 시간을 투자했던 것 같다. 이번 포스트 및 연습에서는, UIPickerView와 bringSubviewToFront 메서드를 알 수 있었던 경험이었다. 확실히 보는 것보다 직접 그리는 것이 훨씬 까다롭다는 느낌을 받았고, 다음 포스트에서도 경험을 통해 얻은 것을 공유하려고 한다.