ios collectionView Tag cell - 여러 옵션 선택하기 기능 구현

ios collectionView Tag 여러 cell 선택하기 기능 구현

 

어느날 문득 아이폰 앱중에 사용자의 관심사나 성향 등을 입력하는 화면으로 아래와 같은 화면들이 자주 보였다. 아래는 취업관련 앱의 화면중 하나인데 관심있는 분야를 아래 레이아웃 형태로 구성해서 사용자가 쉽게 선택하게끔 유도했다. 그래서 나도 한번 만들어 보고 싶었다.

 

만들기전에 관련 기술들이 뭐가 있을까 검색을 하다가 아래 처럼 구현해주는 라이브러리도 있다는것을 알았다. 그래도 라이브러리쓰면 너무 싱거우니깐 간단하게 만들어 보자.

 

 

 

시작하기 전에 구조를 잠깐 훑고 가보자.

 

구조소개

[1]Tendency 파일은 Tendency 즉 각각의 취향 데이터를 담을 struct 타입의 데이터를 담고 있다.

 

[2]TendencyItem : UIView 타입으로 TendencyCell 안에 포함되어 (아래 그림에서 회색 백그라운드) 사용자가 선택하면 배경색과 글자를 변경해주는 역할을 한다.

 

[3]TendencyCell: TendencyCV 안의 컬렉션뷰가 하나하나의 cell을 만들어 재사용할때 사용한다.

 

[4]TendencyCV :  여러 데이터를 collectionview 형태로 보여주는 역할을 한다.

 

[5]ViewController : 주어진 데이터를 가지고 TendencyCV를 호출해서 화면에 뿌려준다.

 

 

Tendency.swift

 

먼저 데이터 모델 부터 살펴보자

Tendency.swift 에는 아래 처럼 각각의 옵션값을 담을 struct와 헤더 정보를 담을 struct 가 작성된다. 데이터는 주석처리 해놓은 형태로 서버에서 받아올거라고 가정하고 진행하기로 하자.

import Foundation


/*
 [
     {
     title : 취미,
     tendencyList : [{title:운동 , clicked:false} , {title:볼링 , clicked:false}]
     order : 1
     }
 ,
 
     {
     title : 성향,
     tendencyList : [{title:성향1 , clicked:false} , {title:성향2 , clicked:false}]
     order : 2
     }
 ......
 
 ]

 */

///개별 옵션
struct Tendency{
    var title:String = ""
    var clicked = false
}
/// header
/// header는 여러 옵션을 가지고 있다.
struct TendencyHeaders{
    var title:String = ""
    var tendencyList:[Tendency] = []
    var order:Int =  0
}

TendencyCV.swift

이 프로젝트에서 제일 중요한 부분인 TendencyCV.swift 안의 내용을 작성한다.

 

TendencyCV도 UIView 타입으로 가지고 있는 프로퍼티는 TendencyHeaders 배열 타입의items 변수와 collectionView 변수이다. 

collectionView는코드로 작성했고, layout 객체를 생성해서 각 cell 사이의 간격, 아래 cell와 위의 cell사이 간격, 헤더 높이 등을 지정해 주고, header view와 커스텀 cell을 등록해준다.

 

class TendencyCV: UIView {

    //step.1
    //MARK: 프로퍼티
    ///데이터
    public var items:[TendencyHeaders] = []
    
    //MARK: 컬렉션뷰
    /// 컬렉션뷰
    private lazy var collectionView:UICollectionView = {
        
        /// 레이아웃 속성 설정 : 왼쪽 정렬 Layout 커스텀 클래스로 초기화
        let layout = LeftAlignedCollectionViewFlowLayout()
        layout.scrollDirection = .vertical
        layout.minimumLineSpacing = 10
        layout.minimumInteritemSpacing = 5
        layout.sectionInset = .init(top: 50, left: 16, bottom: 50, right: 50)
        layout.headerReferenceSize = CGSize(width: UIScreen.main.bounds.width, height: 40)
        
        layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
        //layout.estimatedItemSize = .zero //안됨

        let collectionView = UICollectionView(frame: .zero , collectionViewLayout: layout)
        collectionView.showsVerticalScrollIndicator = false
        collectionView.delegate = self
        collectionView.dataSource = self
        
        ///cell 등록
        collectionView.register(TendencyCell.self, forCellWithReuseIdentifier: TendencyCell.identifier)
        
        /// 헤더 등록
        collectionView.register(TendencyHeader.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "TendencyHeader")
        
        /// 코드로 layout 처리 허용
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        
        return collectionView
    }()
    

    /// 초기화
    init(items:[TendencyHeaders] = []) {
        
        self.items = items
        
        /// 파라미터 할당 후에 초기화 시켜줘야함.
        super.init(frame: .zero)
        
        setupUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    /// 레이아웃 셋팅
    func setupUI(){
        self.translatesAutoresizingMaskIntoConstraints = false
        self.addSubview(collectionView)
    
        /// 컬렉션뷰 제약조건 설정
        NSLayoutConstraint.activate([
            collectionView.widthAnchor.constraint(equalTo: self.widthAnchor) ,
            collectionView.heightAnchor.constraint(equalTo: self.heightAnchor) ,
            collectionView.centerYAnchor.constraint(equalTo: self.centerYAnchor) ,
            collectionView.centerXAnchor.constraint(equalTo: self.centerXAnchor)
        ])
    }
}

 

그리고 이어서 collectionView를 사용하기 위해서 필용한 프로토콜을 채택하고 반드시 구현해야될 함수들을 구현해준다. 각 함수들의 역할은 주석으로 작성해 놓았다. 색션개수, cell 개수, cell 내용, cell 클릭시 이벤트 등을 처리하는 함수들이다.

 

//step.2
//MARK: delegate
extension TendencyCV:UICollectionViewDelegate , UICollectionViewDataSource , UICollectionViewDelegateFlowLayout {
    
    /// 색션 개수
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return items.count
    }
    
    /// cell 개수
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        
        if section == 0 {
            return items[section].tendencyList.count
        }else if section == 1{
            return items[section].tendencyList.count
        }
        
        return 0
    }
    
    /// cell 구성
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TendencyCell.identifier, for: indexPath) as! TendencyCell
        cell.view = TendencyItem(title: items[indexPath.section].tendencyList[indexPath.row].title)
        return cell
    }
    
    /// 각각의 cell 크기
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {

        let title = items[indexPath.section].tendencyList[indexPath.row].title
        let cellWidth = (title as NSString).size(withAttributes: [.font:UIFont.systemFont(ofSize: 14)]).width + 30
        print("width size : \(cellWidth)")
        return CGSize(width: cellWidth, height: 25)
    }
    

    /// cell 선택 이벤트
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        
        let cell = collectionView.cellForItem(at: indexPath) as! TendencyCell
        items[indexPath.section].tendencyList[indexPath.row].clicked = !items[indexPath.section].tendencyList[indexPath.row].clicked
        cell.view?.onSelected()
    }
    
    //MARK: 헤더
    func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
    
        if kind == UICollectionView.elementKindSectionHeader {
            
                let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier:
                "TendencyHeader", for: indexPath) as! TendencyHeader
                header.titleLabel.text = items[indexPath.section].title
            
            return header
        }else{
            return UICollectionReusableView()
        }
    }
}

 

그리고 마지막으로 collectionView의 cell들을 왼쪽정렬 할 수 있게끔 UICollectionViewFlowLayout 타입의 클래스를 만들어서 직접 collectionView 안의 cell 들의 layout을 지정해준다.

 

//MARK: 왼쪽정렬
/// UICollectionViewCell 최대한 왼쪽정렬시켜주는 flowLayout
class LeftAlignedCollectionViewFlowLayout: UICollectionViewFlowLayout {
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        
        /// layout 속성값
        guard let attributes = super.layoutAttributesForElements(in: rect) else {
            return []
        }
        
        /// 레이아웃을 지정할 수 있는 속성들
        for attribute in attributes{
            print("frame:\(attribute.frame) , maxX:\(attribute.frame.maxX) row : \(attribute.indexPath.row)")
        }
        /*
         #FlowLayout이 배치한 attributes에는 frame과 indexPath 등의 요소들이 있는데,
         이 요소들을 이용해서 cell을 움직일 수 있다.
         
         frame:(16.0, 90.0, 60.56375, 25.0) , row : 0
         frame:(96.66666666666667, 90.0, 62.518828125, 25.0) , row : 1
         frame:(179.0, 90.0, 62.846953125, 25.0) , row : 2
         */
  
        
        for (index,value) in attributes.enumerated(){
            
            /// 이 속성의 카테고리가 cell 이면
            if attributes[index].representedElementCategory == .cell {
                ///collectionView의 제일 첫번째 cell 거르기
                ///collectionView InSet Left 를 16으로 주었기 때문
                if value.frame.origin.x == 16 {
                    print("제일 좌측 cell 입니다. pass합니다.")
                    continue
                }
                
                ///첫번째 다음 cell 의 x 좌표 = 이전 cell의 최대 x 좌표 + cell 간격
                value.frame.origin.x = attributes[index-1].frame.maxX + minimumInteritemSpacing
            }
        }
        return attributes
    }
}

 

TendencyCell.swift

이어서 위에서 작성한 collectionView가 사용할 cell을 작성해준다. cell에는 클릭했을때 어떤 이벤트를 처리해줄 ClickedTendencyProtocol이 있고 cell은 ClickedTendencyProtocol을 채택한 UIView 타입의 변수 view를 가지고 있다.

 

import Foundation
import UIKit

/// 선택 or 선택 해제
protocol ClickedTendencyProtocol:UIView {
    func onSelected()
}

//MARK: Cell
class TendencyCell:UICollectionViewCell{
    
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.setupUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    static var identifier :String{
        return String(describing: self)
    }
    static var nib:UINib {
        return UINib(nibName: identifier, bundle: nil)
    }
    
    ///container View 만 가지고 있음
    ///ClickedTendencyProtocol  채택한 View 임
    public var view:ClickedTendencyProtocol?{
        didSet{
            setupUI()
        }
    }
    
    private func setupUI(){
        
        self.backgroundColor = .blue
    
        guard let view = view else { return }
        self.contentView.addSubview(view)
        
        view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            view.leftAnchor.constraint(equalTo: self.contentView.leftAnchor , constant: 5), 
            view.rightAnchor.constraint(equalTo: self.contentView.rightAnchor , constant: -5) ,
            view.topAnchor.constraint(equalTo: self.topAnchor , constant: 5),
            view.bottomAnchor.constraint(equalTo: self.bottomAnchor , constant: -5)

        ])
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        /// 모서리 둥글게
        //self.view?.layer.cornerRadius = frame.height / 2
    }
    
}

 

그리고 같은 파일 아래 이어서 collectionView의 헤더를 담당할 UICollectionReusableView 타입의 TendencyHeader class를 작성해 준다. 이 클래스에는 titleLabel 하나만 존재한다.

 

//MARK: 헤더
class TendencyHeader:UICollectionReusableView{
    
    /// 타이틀 라벨
    lazy var titleLabel:UILabel = {
       let label = UILabel()
        label.text = ""
        label.font = .boldSystemFont(ofSize: 18)
        label.textColor = .black
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        //레이아웃경고뜸
        //self.translatesAutoresizingMaskIntoConstraints = false
        self.setupUI()
    }
    /// 레이아웃 세팅
    func setupUI(){
        self.backgroundColor = .lightGray
        self.addSubview(titleLabel)
        NSLayoutConstraint.activate([
            titleLabel.leftAnchor.constraint(equalTo: self.leftAnchor, constant: 10) ,
            titleLabel.rightAnchor.constraint(equalTo: self.rightAnchor, constant: -10) ,
            titleLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor)
        ])
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

 

TendencyItem.swift

마지막으로 cell안에 위치할 UIView 타입의 TendencyItem 클래스를 작성해준다. 이 클래스는 ClickedTendencyProtocol을 채택하여서 onSelected 함수를 구현했다. onSelected 함수 호출시 타이틀 글자 font와 배경색을 바꾸어준다. 

 

import Foundation
import UIKit


class TendencyItem : UIView, ClickedTendencyProtocol {

    /// 타이틀 변수
    public let title:String
    var clicked = false
    
    /// 타이틀 라벨
    lazy var titleLabel:UILabel = {
       let label = UILabel()
        label.font = .boldSystemFont(ofSize: 15)
        label.textColor = .black
        label.text = title
        label.textAlignment = .center
        label.backgroundColor = .green
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    /// 초기화
    init(title:String){
        self.title = title
        super.init(frame: .zero)
        self.translatesAutoresizingMaskIntoConstraints = false
        self.setupUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    

    ///레이아웃 셋팅
    func setupUI(){
        self.backgroundColor = .lightGray
        self.addSubview(titleLabel)
        NSLayoutConstraint.activate([
            titleLabel.topAnchor.constraint(equalTo: self.topAnchor, constant: 5)        ,
            titleLabel.leftAnchor.constraint(equalTo: self.leftAnchor, constant: 5)      ,
            titleLabel.rightAnchor.constraint(equalTo: self.rightAnchor , constant: -5)  ,
            titleLabel.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -5)
        ])
    }
    
    /// 셀 선택시 타이틀과 배경색 변경
    func changeBackgroundAndFont(){
        if clicked {
            self.backgroundColor = .black
            self.titleLabel.textColor = .white
        }else{
            self.backgroundColor = .lightGray
            self.titleLabel.textColor = .black
        }
    }
    
}

extension TendencyItem {
    func onSelected() {
        print("선택")
        clicked = !clicked
        changeBackgroundAndFont()
    }

}

 

ViewController.swift

이제 ViewController에서 더미 데이터들을 만들고 제일 위에서 만든 Tendency struct로 매핑한 다음 TendencyCV를 생성해서  TendencyCV 제약조건을 설정해준다.

 

import UIKit

class ViewController: UIViewController {

    /// 확인 버튼
    lazy var bottomBtn:UIButton = {
       let btn = UIButton()
        btn.backgroundColor = .blue
        btn.setTitle("시작하기", for: .normal)
        btn.translatesAutoresizingMaskIntoConstraints = false
        btn.titleLabel?.textColor = .black
        btn.addTarget(self, action: #selector(buttonAction), for: .touchUpInside)
        return btn
    }()
    

    /// 확인 버튼 클릭시 선택한 아이템 출력
    @objc func buttonAction(sender: UIButton!) {
        
        for header in tendencyView.items {
            print("header 제목 : \(header.title)")
            for option in header.tendencyList {
                print("option 제목 : \(option)")
            }
            print("")
        }
    }

    
    /// 옵션 리스트 처리를 담당하는 컬렉션뷰
    lazy var tendencyView : TendencyCV = {
        
        /// 데이터 모델 생성
        let tendency1 = Tendency(title: "커리어고민", clicked: false)
        let tendency2 = Tendency(title: "취업/이직", clicked: false)
        let tendency3 = Tendency(title: "회사 생활", clicked: false)
        let tendency4 = Tendency(title: "인간관계", clicked: false)

        let tendencyList1 = [tendency1,tendency2,tendency3,tendency4]
        let tendencyHeader1 = TendencyHeaders(title: "💼직장인 공감", tendencyList: tendencyList1, order: 1)
        
        let tendency11 = Tendency(title: "개발", clicked: false)
        let tendency22 = Tendency(title: "데이터", clicked: false)
        let tendency33 = Tendency(title: "HR", clicked: false)
        let tendency44 = Tendency(title: "서비스기획", clicked: false)
        let tendency55 = Tendency(title: "마케팅", clicked: false)
        let tendency66 = Tendency(title: "디자인", clicked: false)
        let tendency77 = Tendency(title: "경영/전략", clicked: false)
        
        let tendencyList2 = [tendency11,tendency22,tendency33,tendency44,tendency55,tendency66,tendency77]
        let tendencyHeader2 = TendencyHeaders(title: "🌈관심분야", tendencyList: tendencyList2, order: 2)
        
        let headers = [tendencyHeader1, tendencyHeader2]
        
        let tendencyView = TendencyCV(items: headers)
        
        tendencyView.translatesAutoresizingMaskIntoConstraints = false
        return tendencyView
    }()
    
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
    }

    /// 레이아웃 셋팅
    private func setupUI(){
        
        self.view.addSubview(tendencyView)
        self.view.addSubview(bottomBtn)
        self.view.backgroundColor = .yellow
        
        /// 하단 버튼
        NSLayoutConstraint.activate([
            bottomBtn.heightAnchor.constraint(equalToConstant: 50) ,
            bottomBtn.widthAnchor.constraint(equalTo: self.view.widthAnchor) ,
            bottomBtn.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor)
        ])
        
        /// 옵션 리스트 담당 컬렉션뷰
        NSLayoutConstraint.activate([
            tendencyView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor) ,
            tendencyView.widthAnchor.constraint(equalTo: self.view.widthAnchor) ,
            tendencyView.bottomAnchor.constraint(equalTo: self.bottomBtn.topAnchor)
        ])
    }
}

 

코드를 위 처럼 작성하고 옵션들을 클릭해보자. 선택한 옵션들은 배경색과 글자색이 바뀐다. 그리고 시작하기를 누르면 내가 선택한 옵션들이 true 값으로 저장되어 있는것을 볼 수 있다.

 

 

출력로그

 

header 제목 : 💼직장인 공감

option 제목 : Tendency(title: "커리어고민", clicked: true)

option 제목 : Tendency(title: "취업/이직", clicked: true)

option 제목 : Tendency(title: "회사 생활", clicked: false)

option 제목 : Tendency(title: "인간관계", clicked: false)

 

header 제목 : 🌈관심분야

option 제목 : Tendency(title: "개발", clicked: true)

option 제목 : Tendency(title: "데이터", clicked: true)

option 제목 : Tendency(title: "HR", clicked: false)

option 제목 : Tendency(title: "서비스기획", clicked: false)

option 제목 : Tendency(title: "마케팅", clicked: false)

option 제목 : Tendency(title: "디자인", clicked: false)

option 제목 : Tendency(title: "경영/전략", clicked: false)

 

소스코드

https://drive.google.com/file/d/13ZqTh_-DpPwZSsoLnqrnhjDfXY0G032g/view?usp=sharing