티스토리 뷰

iOS

[iOS] 커스텀 팝업 선택 뷰 만들기

jisuuuu 2023. 10. 13. 20:01

이번 포스팅에서 구현할 뷰는 카카오맵 에서 확인할 수 있는 뷰인데, 하단에 붙어있는 조그마한 팝업뷰다.

 

기획, 디자인에서 이런 형태가 요구된다면,

시간이 들어도 재사용이 가능한 형태로 만들어두는 것이 결국 미래의 시간을 아끼게 될 수 있을 것이라 생각한다.

 

그래서 이러한 뷰를 다른 곳에서도 사용할 수 있도록 뷰 컨트롤러 활용해 만들어 보았다.

(이름도 NiceSimplePickerView 로 비장하게 만들어줬다..🙂)

카카오맵의 팝업 선택 뷰

 

 

1. 뷰의 구조

뷰의 구조를 설계하는데 다음 내용을 고려했다.

  • 들어오는 데이터의 갯수에 따라 선택 가능한 뷰가 늘어나야 함 -> 컬렉션뷰 사용
  • ContainerView는 CollectionView의 dataSource에 따라 크기가 늘어나야 함
  • 헤더 타이틀 뷰와 dismiss button은 어떻게 구현할까?
    •  CollectionView의 headerView로 구현하는 방법과 컨테이너 뷰에 Constraint를 잡아서 넣는 방법을 고려함
    • 하지만 컬렉션 뷰의 섹션 나누는 것이 요구되지는 않고, 보다 직관성있는 구조를 위해 ContainerView에 바로 구현함
  • 이러한 고민을 통해 다음과 같은 계층 구조를 만들게 되었다.

 

2. 커스텀 뷰 컨트롤러 생성

NiceSimplePickerView라는 뷰 컨트롤러를 생성했고, 전환시에는 present를 통해 부르도록 구현했다.

이때, 전체 화면을 덮을 수 있도록 modalPresentationStyle과 transitionStyle을 다음과 같이 설정했다.

 

- modalTransitionStyle = .crossDissolve 

-> 밑에서 올라오는 기본 transition이 아닌, dissolve 형태로 전환되도록 함

 

- modalPresentationStyle = .overFullScreen

-> 뒤의 뷰가 그대로 보이는 형태로 present

-> 반 투명한 뷰라면 뒤쪽의 뷰가 그대로 보임

 

- self.view.backgroundColor = .black.withAlphaComponent(0.5)

-> view를 alpha를 섞은 black으로 만들어서 반투명으로 설정

class NiceSimplePickerView: UIViewController {

    init() {
        super.init(nibName: nil, bundle: nil)
        self.modalTransitionStyle = .crossDissolve
        self.modalPresentationStyle = .overFullScreen
    }
    
        required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupUI() {
        self.view.backgroundColor = .black.withAlphaComponent(0.5)
    }
}

 

이렇게 만든 NiceSimplePickerView를 인스턴스 생성해 present 한다면 다음과 같이 나온다

(그리고 아무것도 할 수가 없을 것이다)

 

 

2. 내용을 담는 containerView 생성, collectionView constraint 설정

위까지는 그저 배경일 뿐이고, 실제 작동에 필요한 내용을 담는 컨테이너 뷰를 생성한다. 

배경색까지 생성한 containerView를 view에 addSubview 하고,

containerView 내부에 앞에서 설계한 뷰 들을 만들어 넣는다.

+ corenerRadius를 10으로 설정해 살짝 round 처리된 코너를 만든다.

 

그렇게 완성된 setupUI 함수는 다음과 같다⬇️

더보기
    private func setupUI() {
        self.view.backgroundColor = .black.withAlphaComponent(0.5)
        
        self.view.addSubview(containerView)
        containerView.snp.makeConstraints {
            $0.leading.trailing.bottom.equalToSuperview()
        }
        
        containerView.addSubview(titleHeaderLabel)
        titleHeaderLabel.snp.makeConstraints {
            $0.leading.equalToSuperview().inset(contentInset)
            $0.height.equalTo(20)
            $0.top.equalToSuperview().inset(20)
        }
        
        containerView.addSubview(dismissButton)
        dismissButton.snp.makeConstraints {
            $0.top.equalTo(titleHeaderLabel.snp.top)
            $0.trailing.equalToSuperview().inset(contentInset)
            $0.width.height.equalTo(25)
        }
        
        containerView.addSubview(collectionView)
        collectionView.snp.makeConstraints {
            $0.leading.trailing.equalToSuperview()
            $0.top.equalTo(titleHeaderLabel.snp.bottom).inset(-16)
            $0.height.equalTo(collectionViewHeight)
            $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(10)
        }
        
        containerView.layer.cornerRadius = 10
        containerView.clipsToBounds = true
        
        self.view.addSubview(emptyView)
        emptyView.snp.makeConstraints {
            $0.leading.trailing.top.equalToSuperview()
            $0.bottom.equalTo(containerView.snp.top)
        }
        
        self.titleHeaderLabel.text = self.headerText
    }

 

가장 중요한 부분은 colletionView이다. 

처음 생각에는 edge의 제약조건을 모두 걸어주었고 내부 contentSize에 따라 자동으로 height가 정해질 줄 알았으나

그렇지 않았고 최대 길이만큼 늘어나는 모습이였다.

 

이를 해결하기 위해 두가지 방법이 있을 것 같다. 

1. 레이아웃 셋업 끝난 뒤 contentSize를 가지고 제약조건을 잡아주는 방법

2. dataSource에 따른 height를 미리 계산해 넣는 방법

 

나는 두번째 방법으로 collectionView의 height를 계산하여 연산 프로퍼티로 만들고 제약조건을 잡아주었더니 잘 적용되었다.

    /// Datasource의 count를 기반으로 collectionView의 height를 계산해 반환
    var collectionViewHeight: CGFloat {
        // 데이터 소스 count에 기반한 line 수 구하기
        let lineCount = round(Float(dataSource.count) / 2)
        // cellheight와 line 수 더하기
        var height = cellHeight * CGFloat(lineCount)
        // cellSpacing 추가
        height += CGFloat(lineCount - 1) * cellSpacing
        return height
    }

 

 

구현할 뷰는 한 줄에 2개의 셀이 들어간다. 

dataSource의 갯수를 2로 나눈 뒤 반올림 하면 총 줄의 갯수를 구할 수 있다. (7개 데이터인 경우 4개)

그렇게 lineCount와 셀의 높이, spacing 등을 통해 height를 구할 수 있다.

 

+ 비슷한 방법으로 CollectionView item의 Size도 선언해주었다.

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {

    // 컬렉션뷰 가로 길이에서
    var width = collectionView.frame.width
    // 좌우 inset을 빼주고
    width -= contentInset * 2
    // 가운데 spacing을 빼주고 2로 나눔
    width -= cellSpacing
    width /= 2
    return CGSize(width: width, height: cellHeight)
}

 

더미데이터들과 함께 넣어서 아래와 같은 뷰가 완성이 되었다.

 

 

3. Cell 선택 이벤트, 선택한 데이터 전달

그렇다면 뷰를 열었을때 초기 선택되어 있는 부분은 어떻게 구현할 수 있을까?

 

먼저, Nice한 PickerView에서는 데이터 관리를 위해 다음 구조체를 사용한다.

struct NiceSimplePickerConfig: Equatable {
    // equatable: id로 비교
    static func == (lhs: Self, rhs: Self) -> Bool {
        return lhs.id == rhs.id
    }
    
    let id: Int
    let title: String
}

Equatable 프로토콜을 채택했고, title과 상관없이 id를 통해 비교연산자 ==를 사용하도록 했다.

그리고 다음의 주요 Public Properties를 선언했다. (그리고 delegate까지)

//MARK: - Public Properties
/// picker dataSource
public var dataSource: [NiceSimplePickerConfig] = []
/// 선택된 데이터
public var selectedData: NiceSimplePickerConfig?
/// delegate
weak var delegate: NiceSimplePickerDelegate?

사용하는 쪽에서는 dataSource에 접근하여 전체 데이터 배열을 넣고, 

selectedData에 초기 선택하게 할 데이터를 넣어준다.

 

그리고, cellForRowAt에서 뿌려주는 데이터 모델이 selectedData와 같다면

collectionView.selectItem 메소드를 호출해 셀이 선택된 상태로 만들어주도록 구현했다.

(처음에는 cellForRowAt 이후에 for문을 돌릴까? 생각했는데, 어차피 cellForRowAt에서 배열을 순회하니까 그때 넣어주는게 더 좋을 것 같다.)

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: NiceSimplePickerCell.identifier, for: indexPath) as? NiceSimplePickerCell else { return UICollectionViewCell() }
    let data = self.dataSource[indexPath.row]
    cell.cellTintColor = self.tintColor
    cell.pickerConfig = data

    if let selectedData = self.selectedData,
       // 선택한 데이터와 같다면, select 메소드 호출
       selectedData == data {
        self.setSelectedData(indexPath)
    }

    return cell
}

/// 선택한 데이터를 설정
private func setSelectedData(_ indexPath: IndexPath) {
    self.collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .top)
    self.collectionView.layoutIfNeeded()
}

그렇다면 선택한 데이터를 어떻게 전달할까?

delegate 패턴을 사용해서 데이터를 전달할 때 어떤 데이터를 건네주는게 범용성이 좋을지 고민했다.

들어온 dataSource 배열의 index를 건네주는 방법도 있을 것 같고,

NiceSimplePickerConfig 모델을 건네주는 방법도 있을 것 같다. 

어떤 데이터를 전달 하는 것이 대부분의 상황에 대응 되도록 고민을 하다가.. 그냥 다 건네주기로 했다.🥲

그렇게 만든 프로토콜은 다음과 같다.

( 아직 고민 진행중이다. 사용하면서 변경될듯 )

extension NiceSimplePickerView: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        self.delegate?.didTappedSelection(picker: self, index: indexPath.row)
        self.delegate?.didTappedSelection(picker: self, config: dataSource[indexPath.row])
        self.dismiss(animated: true)
    }
}

protocol NiceSimplePickerDelegate: AnyObject {
    func didTappedSelection(picker: NiceSimplePickerView, index: Int)
    func didTappedSelection(picker: NiceSimplePickerView, config: NiceSimplePickerConfig)
}

extension NiceSimplePickerDelegate {
    func didTappedSelection(picker: NiceSimplePickerView, index: Int) {}
    func didTappedSelection(picker: NiceSimplePickerView, config: NiceSimplePickerConfig) {}
}

 

 

4. 선택된 Cell UI 구현

컬렉션뷰를 탭했을 때나 collectionView.selectItem 메소드를 호출했을 때, 

collectionViewCell 내부의 isSelected가 동작하니, 그것을 오버라이딩 하여 사용하면 간편하다.

아래는 그 코드다.

class NiceSimplePickerCell: UICollectionViewCell {
    //MARK: - Properties
    static let identifier = "NiceSimplePickerCell"
    
    var cellTintColor: UIColor = .systemIndigo
    
    private let contentLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 16, weight: .medium)
        label.textAlignment = .center
        label.textColor = .black
        return label
    }()
    
    var pickerConfig: NiceSimplePickerConfig? {
        didSet {
            configure()
        }
    }
    
    override var isSelected: Bool {
        didSet {
            configureSelection()
        }
    }
    
    private func configure() {
        guard let config = self.pickerConfig else { return }
        self.contentLabel.text = config.title
        self.configureSelection()
    }
    
    private func configureSelection() {
        let selection: PickerCellSelectConfig = isSelected ? .selected : .none
        self.layer.borderColor = selection.getBorderColor(cellTintColor)
        self.layer.borderWidth = selection.borderWidth
        self.contentLabel.textColor = selection.getTextColor(cellTintColor)
    }
}

isSelected에 didSet을 달아두어서 변화가 생겼을 때 configureSelection() 메소드가 호출되게 했으며,

해당 메소드에서는 셀 자체의 borderColor와 width, 텍스트의 컬러 등을 변경하도록 구현했다.

 

configureSelection() 에서는 isSelect 상태에 따라 PickerCellSelectConfig 열거형의 상태로 전환이 되며, 

해당 열거형에서 상태에 따른 UI 요소들을 정리함으로 사용하는 쪽에서는 간편하게 사용하도록 했다.

(관리할 데이터가 많지는 않지만, UI의 요소들을 한번에 관리하기 위해 따로 만들었다.)

enum PickerCellSelectConfig {
    case selected
    case none
    
    var borderWidth: CGFloat {
        switch self {
        case .selected: return 2
        case .none: return 1
        }
    }
    
    func getTextColor(_ tintColor: UIColor) -> UIColor {
        switch self {
        case .selected: return tintColor
        case .none: return .lightGray
        }
    }
    
    func getBorderColor(_ tintColor: UIColor) -> CGColor {
        switch self {
        case .selected: return tintColor.cgColor
        case .none: return UIColor.lightGray.cgColor
        }
    }
}

그렇게 다음 뷰까지 완성했다 

완성되어가는중 ..

 

5. 여러개의 Picker를 구현했을 때 delegate가 중복된다..

Nice한 Picker에서 요소를 선택했을 때 선택한 데이터 전달에는 delegate 방식을 사용한다.

그런데 여러개의 picker를 하나의 뷰에서 구현했을 때 delegate가 중복 호출이 되는 문제가 있었다.

(동물과 탈것이 있을 때 동물의 delegate가 탈것에도 호출되는 경우)

 

이건 UiKit에서의 delegate에서도 흔하게 일어나는 현상이다.

동일한 여러개의 컴포넌트들이 있을 때 delegate가 중첩된다. 이때 나는 타입캐스팅을 통해 분기처리를 하곤 했다. 

 

그런데 NickPicker는 클래스 변수로 선언을 하지 않았고, 클릭 이벤트 함수 내에서 인스턴스를 생성했다.

( 사용할 때도 그럴 경우가 많을 것 같다 )

그렇기 때문에 타입캐스팅 외에 어떤 방법으로 해결 가능할까?

 

그래서 애플이 UiView에 tag를 만들어둔 것 같다. 태그를 붙여서 해결이 가능할 것 같다.

그런데 UIViewController에는 tag 가 선언되어 있지 않았고, 그래서 내가 만들었다. 

이를 통해 delegate에서 self를 전달하는 방법으로 사용하는 쪽에서 구분하도록 했다.

//MARK: - NiceSimplePickerDelegate
extension ViewController: NiceSimplePickerDelegate {
    func didTappedSelection(picker: NiceSimplePickerView, index: Int) {
        if picker.tag == 0 {
            setAnimalSelectedData(config: AnimalExampleConfigs.allCases[index])
        } else if picker.tag == 1 {
            setVehicleSelectedData(config: VehicleExampleConfigs.allCases[index])
        }
    }
}

 

6. 기타

그 외에 빈곳을 터치한 경우 dismiss 되도록 구현했고, 

사용하는 쪽에서 content inset과 tintColor 등을 설정할 수 있도록 구현했다. 

사용하는 쪽의 코드는 다음과 같다

@IBAction func didTappedVehicleButton(_ sender: Any) {
    let pickerView = NiceSimplePickerView()
    pickerView.delegate = self
    pickerView.dataSource = VehicleExampleConfigs.allCases
        .map({ $0.pickerConfig })
    pickerView.selectedData = self.vehicleSelectedData.pickerConfig
    pickerView.tintColor = .systemBlue
    pickerView.contentInset = 10
    pickerView.tag = 1
    pickerView.headerText = "🚀뭐에 타실래요?"
    self.present(pickerView, animated: true)
}

 

 

이렇게 모든 구현을 완료했지만, 여러가지 옵션을 추가해주고 싶은 생각이 든다

확인 버튼을 넣거나, 한줄에 배치되는 셀의 갯수, 헤더 숨기기 등등

추후 업데이트해서 더 nice 해지도록 만들고 싶다

 

7. 완성된 뷰

완성된 뷰

 

소스코드 : https://github.com/jisu15-kim/examples/tree/main/NiceSimplePicker

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/02   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28
글 보관함