iOS

[iOS] UIPickerView 커스텀 구현 - UIKit

띵지니어 2024. 5. 2. 23:27

안녕하세요 띵지니어 입니다. 😼

오늘은 PickerView 대해 포스팅해보려고 합니다.
따로 BottomSheet 안에
아래의 형태로 PickerView를 넣어보고자 합니다.


일단 기본 세팅 해줄게요

올라오는 바텀시트를 봐주세요!

 

RecruitmentNumberPickerViewController 안에 PickerView를 먼저 넣어 줍니다.
레이아웃은 참고만 해주시고 PickerView 속성에 집중해주세요!!

최종 코드 아닙니다!

//
//  RecruitmentNumberPickerViewController.swift
//
//  Created by 이명진 on 5/2/24.
//

import UIKit

import SnapKit
import Then

final class RecruitmentNumberPickerViewController: UIViewController {
    
    // MARK: - Properties
    
    var recruitPeopleLimitLists = (1...10).map { "\($0) 명" }
    
    // MARK: - UIComponents
    
    private let headTitle = LabelFactory.build(
        text: "모집 인원수",
        font: .body1,
        textColor: .puzzleGray800
    )
    
    private let bodyTitle = LabelFactory.build(
        text: "구하는 인원 수를 선택해 주세요",
        font: .subTitle3,
        textColor: .puzzleGray400
    )
    
    private lazy var vStackView = UIStackView(
        arrangedSubviews: [
            headTitle,
            bodyTitle
        ]
    ).then {
        $0.axis = .vertical
        $0.alignment = .leading
        $0.spacing = 8
    }
    
    let pickerView = UIPickerView().then {
        $0.backgroundColor = .clear
    }
    
    private lazy var saveButton = PuzzleMainButton(title: "항목저장")
    
    // MARK: - Life Cycles
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setUI()
        setHierarchy()
        setLayout()
        setDelegate()
    }

    // MARK: - UI & Layout
    
    private func setUI() {
        self.view.backgroundColor = .white
    }
    
    private func setHierarchy() {
        self.view.addSubviews(
            vStackView,
            pickerView,
            saveButton
        )
    }
    
    private func setLayout() {
        
        vStackView.snp.makeConstraints {
            $0.top.equalTo(self.view.snp.top).offset(24)
            $0.leading.equalToSuperview().inset(28)
        }
        
        pickerView.snp.makeConstraints {
            $0.top.equalTo(vStackView.snp.bottom).offset(16)
            $0.centerX.equalToSuperview()
            $0.width.height.equalTo(120)
        }
        
        saveButton.snp.remakeConstraints {
            $0.top.equalTo(pickerView.snp.bottom).offset(16)
            $0.centerX.equalToSuperview()
            $0.width.equalTo(332)
            $0.height.equalTo(52)
        }
    }
    
    private func setDelegate() {
        pickerView.delegate = self
        pickerView.dataSource = self
    }
}

extension RecruitmentNumberPickerViewController: UIPickerViewDataSource {
    func numberOfComponents(in pickerView: UIPickerView) -> Int {
        return 1
    }
    
    func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
        recruitPeopleLimitLists.count
    }
    
    func pickerView(_ pickerView: UIPickerView, rowHeightForComponent component: Int) -> CGFloat {
        return 44
    }
}

extension RecruitmentNumberPickerViewController: UIPickerViewDelegate {
    func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
        return recruitPeopleLimitLists[row]
    }
}

 

PickerView에서 주로 사용하는 메서드

 

extension RecruitmentNumberPickerViewController: UIPickerViewDataSource {
    func numberOfComponents(in pickerView: UIPickerView) -> Int {
        return 1
    }
    
    func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
        recruitPeopleLimitLists.count
    }
    
    func pickerView(_ pickerView: UIPickerView, rowHeightForComponent component: Int) -> CGFloat {
        return 44
    }
}

 

UIPickerViewDataSource

- numberOfComponents(in:):

이 메서드는 피커 뷰에서 컴포넌트(열)의 수를 반환합니다.
저는 1을 반환했기 때문에 피커 뷰에 단일 컴포넌트(열)만 존재하게 됩니다.

- numberOfRowsInComponent(_:):

각 컴포넌트에 표시할 행의 수를 반환합니다.
저는 recruitPeopleLimitLists.count를 사용하여, 선택 가능한 모집 인원수 목록의 항목 수인 10개의 행을 지정했습니다.

- rowHeightForComponent(_:):

각 행의 높이를 지정합니다.
저는 행의 높이를 피그마에 맞게 44 px로 수정하였습니다.
일반적으로 충분한 공간을 제공하여 내용을 쉽게 읽을 수 있게 합니다.


 

extension RecruitmentNumberPickerViewController: UIPickerViewDelegate {
    func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
        return recruitPeopleLimitLists[row]
    }
    
    func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
        print(row)
    }
}

 

UIPickerViewDelegate

- titleForRow(_:forComponent:): 

각 행에 표시될 문자열을 반환합니다. 
recruitPeopleLimitLists [row]을 사용하여 해당 행의 모집 인원수를 문자열로 반환합니다.
 저는 1명부터 10명까지의 문자열이 있는 리스트에, 각 행에는 "X 명" 으로 매핑해주었습니다 

- didSelectRow(_:inComponent:): 

유저가 PickerView의 특정 행을 선택할 때 호출됩니다. 
선택된 행의 인덱스를 print(row) 해서 어떤 행이 선택되었는지 확인할 수 있습니다.
이 메서드는 선택 이벤트를 처리할 때 사용 됩니다.
저는 간단하게 콘솔에 출력해 보기 위해 print 만 사용하였습니다

 


Custom

이제 위에서 구현한 PickerView에서 배경을 없애보고,
위아래 선을 추가해보겠습니다.

 

 viewWillLayoutSubviews() 메서드에 setPickerView() 함수를 넣어 줍니다.

import UIKit

import SnapKit
import Then

final class RecruitmentNumberPickerViewController: UIViewController {

    let upLine = UIView().then {
        $0.backgroundColor = .black
    }

    let underLine = UIView().then {
        $0.backgroundColor = .black
    }
    
    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        
        self.setPickerView()
    }
    
    private func setPickerView() {
        // 서브 뷰 배경 제거
        pickerView.subviews[1].backgroundColor = .clear
        
        // 서브 뷰 라인 추가
        pickerView.subviews[1].addSubview(upLine)
        pickerView.subviews[1].addSubview(underLine)
        
        upLine.snp.makeConstraints {
            $0.width.equalTo(pickerView.snp.width)
            $0.height.equalTo(0.8)
            $0.top.equalToSuperview().offset(5)
        }
        
        underLine.snp.makeConstraints {
            $0.width.equalTo(pickerView.snp.width)
            $0.height.equalTo(0.8)
            $0.top.equalToSuperview().offset(40)
        }
    }
}

 

 - viewWillLayoutSubviews() 메서드에서 처리해야 하는 이유?

정의는 다음과 같습니다

뷰 컨트롤러는 뷰가 하위 뷰를 배치하기 전에 변경을 수행하기 위해 이 메서드를 재정의할 수 있습니다.

 

즉 뷰가 자신의 서브뷰들의 배치를 조정하기 전에 하고 싶은 게 있으면
viewWillLayoutSubviews 를 override 해서 사용하면 됩니다.

viewWillLayoutSubviews 메서드는 뷰컨트롤러의 뷰가 자신의 서브뷰들의 레이아웃을 조정하기 직전에 호출됩니다.
pickerView의 뷰을 추가하고 프로퍼티를 업데이트해야 하니까, 해당 메서드에서 작동해야 합니다!

ViewDidLoad에서는 뷰 컨트롤러의 뷰가 메모리가 로드되었지만,
아직 뷰의 크기나 위치가 최종적으로 결정되지 않았기 때문에
뷰의 크기나 위치에 의존하는 초기화 작업을 수행하기 적절하지 않습니다.
실제로 ViewDidLoad에서 해당 작업을 수행하게 되면 오류가 발생합니다.!!

viewWillLayoutSubviews 시점에는 모든 뷰의 크기와 위치가 결정되기 직전이므로
뷰의 크기나 위치에 의존하는 초기화 작업을 수행하기에 적합합니다.

따라서 viewWillLayoutSubviews에서 호출한 이유는
UIPickerView의 위치가 확정된 상태에서,
서브뷰에 접근해서 배경색을 변경하고, 선을 추가하는 작업을 하기 위함입니다.

 

ViewController에서 레이아웃이 결정되는 과정은 아래와 같습니다.

1. viewWillLayoutSubviews() 메서드 호출

2. ViewController의 contentView가 layoutSubviews() 메서드 호출
layoutSubviews(): 현재 레이아웃 정보들을 바탕으로 새로운 레이아웃 정보를 계산,
이후 뷰 계층 구조를 순회하면서 모든 하위 뷰들이 동일한 메서드를 호출

3. 레이아웃 정보의 변경사항을 뷰들에 반영

4. viewDidLayoutSubviews() 메서드 호출

 

 

이렇게 보니 글씨 크기가 마음에 들지 않습니다


글씨 크기 변경하는 방법

 

pickerView에는 내부 view를 설정하는 부분이 존재합니다.
따라서 새로운 View를 만들고 그 안에 Label을 넣는 과정을 진행했습니다.

UIPickerViewDataSource에서 viewForRow 키워드를 치면 

요롷게 return 값이 UIView인 메서드가 나오게 됩니다.

 

코드는 더 보기 클릭

더보기
    func pickerView(_ pickerView: UIPickerView, viewForRow row: Int, forComponent component: Int, reusing view: UIView?) -> UIView {
        let view = UIView(frame: CGRect(x: 0, y: 0, width: 120, height: 44))
        
        let puzzleLabel = UILabel(frame: CGRect(x: 0, y: 0, width: 120, height: 44))
        puzzleLabel.text = recruitPeopleLimitLists[row]
        puzzleLabel.textAlignment = .center
        puzzleLabel.font = .body1
        puzzleLabel.textColor = .black
        
        view.addSubview(puzzleLabel)
        
        return view
    }

따로 뷰를 만들어주고 해당 메서드에 커스텀 뷰를 따로 넣어주면 

요구 사항과 비슷한 PickerView를 구현할 수 있습니다.


 

리뷰

뷰가 그려지는 순서가 꼬여서 레이아웃을 draw 하는 과정에서 오류가 많이 발생하였습니다.
viewWillLayoutSubviews() 메서드와 viewDidLayoutSubviews() 메서드에 대해 공부를 하였고 각각 어떤 상황에서 써야 할지 공부하였습니다.

레이아웃이 그려질 때 호출되는 순서는 viewWillLayoutSubviews() -> layoutSubviews() -> viewDidLayoutSubviews() 입니다.

pickerView를 처음 사용해 봤지만, 다양한 메서드가 있어서 커스텀하기 편했습니다.

- DataSoure에는

picker뷰의 열의 개수, 행의 개수, 각 행의 높이를 조절하는 방법에 대해 공부했습니다.
추가로 따로 View를 넣어서 picker뷰 내부 Label을 커스텀해주었습니다.

- Delegate에서는

titleForRow 에서 각 행의 title을 매핑해 주는 메서드와,
각 행을 select 할 때 발생되는 didSelectRow 메서드에 대해서 공부하였습니다.
참고로 커스텀 뷰를 넣을 때는 titleForRow를 작성할 필요가 없습니다!

아래는 최종 코드인데, pickerView 부분만 따로 작성하였습니다.

import UIKit

import SnapKit
import Then

final class ViewController: UIViewController {
    
    // MARK: - Properties
    
    var recruitPeopleLimitLists = (1...10).map { "\($0) 명" }
    
    // MARK: - UIComponents

    let pickerView = UIPickerView().then {
        $0.backgroundColor = .clear
    }
    
    let upLine = UIView().then {
        $0.backgroundColor = .black
    }
    
    let underLine = UIView().then {
        $0.backgroundColor = .black
    }
    
    // MARK: - Life Cycles
    
    override func viewDidLoad() {
        super.viewDidLoad()

        setUI()
        setHierarchy()
        setLayout()
        setDelegate()
    }
    
    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        
        self.setPickerView()
    }
    
    // MARK: - UI & Layout
    
    private func setUI() {
        self.view.backgroundColor = .white
    }
    
    private func setHierarchy() {
        self.view.addSubview(pickerView)
    }
    
    private func setLayout() {
        pickerView.snp.makeConstraints {
            $0.top.equalToSuperview().offset(16)
            $0.centerX.equalToSuperview()
            $0.width.equalTo(120)
            $0.height.equalTo(130)
        }
    }
    
    private func setDelegate() {
        pickerView.delegate = self
        pickerView.dataSource = self
    }
    
    private func setPickerView() {
        pickerView.subviews[1].backgroundColor = .clear
        
        pickerView.subviews[1].addSubview(upLine)
        pickerView.subviews[1].addSubview(underLine)
        
        upLine.snp.makeConstraints {
            $0.width.equalTo(pickerView.snp.width)
            $0.height.equalTo(0.8)
            $0.top.equalToSuperview().offset(5)
        }
        
        underLine.snp.makeConstraints {
            $0.width.equalTo(pickerView.snp.width)
            $0.height.equalTo(0.8)
            $0.top.equalToSuperview().offset(40)
        }
    }
}

extension RecruitmentNumberPickerViewController: UIPickerViewDataSource {
    func numberOfComponents(in pickerView: UIPickerView) -> Int {
        return 1
    }
    
    func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
        recruitPeopleLimitLists.count
    }
    
    func pickerView(_ pickerView: UIPickerView, rowHeightForComponent component: Int) -> CGFloat {
        return 44
    }
    
    func pickerView(_ pickerView: UIPickerView, viewForRow row: Int, forComponent component: Int, reusing view: UIView?) -> UIView {
        let view = UIView(frame: CGRect(x: 0, y: 0, width: 120, height: 44))
        
        let puzzleLabel = UILabel(frame: CGRect(x: 0, y: 0, width: 120, height: 44))
        puzzleLabel.text = recruitPeopleLimitLists[row]
        puzzleLabel.textAlignment = .center
        puzzleLabel.font = .systemFont(ofSize: 14)
        puzzleLabel.textColor = .black
        
        view.addSubview(puzzleLabel)
        
        return view
    }
}

extension RecruitmentNumberPickerViewController: UIPickerViewDelegate {
//    func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
//        return recruitPeopleLimitLists[row]
//    }
    
    func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
        print(row)
    }
}

 

Xcode 15.1
iOS 17.4.1
MacOS Sonoma 14.2.1
환경에서 작성 한 글입니다.

 

 

참고한 글

https://blog.naver.com/doctor-kick/222437253619

 

[iOS] viewDidLayoutSubviews란? viewWillLayoutSubviews란?

버튼을 코드로 생성하고, 그 버튼의 frame을 viewDidLayoutSubviews에 설정을 해주다가 문득.. 왜 여기...

blog.naver.com

https://ggasoon2.tistory.com/14

 

UIPickerView UI custom 하기

이렇게 생긴 기본 UIPickerView 를 이렇게 custom 해보도록 하겠습니다. 맨위의 기본 UIPickerView의 코드 입니다. 여기서 수정해나가겠습니다. // // TestViewController.swift // BusanWelfareProgram // // Created by jh on 2

ggasoon2.tistory.com