본문으로 건너뛰기

CollectionView 그리기(1)

머리말

UIKit 에서 다량의 데이터를 표현하기 위해 자주 사용되는 UI인 UICollectionView 를 사용해보고, FlexLayout 으로 그려보자.

UICollectionView 작성하기

UICollectionView?

이미지 설명

컬렉션 뷰는 커스텀 가능한 레이아웃을 사용하고 정렬된 컬렉션을 관리하는 오브젝트 ..라고 한다. 쉽게 말해 그룹화 된 요소들을 위한 UI다.

컬렉션 뷰는 크게 LayoutItems(cells and supplementray views) 로 구성되어 있다. 레이아웃은 컬렉션 뷰가 보여지는 방식을 의미하고, Items는 데이터 소스를 활용해 컬렉션 뷰를 구성하는 개별 요소들을 의미한다. 이들을 조작하기 위해서, delegate 패턴을 주로 활용한다.

컬렉션 뷰는 다량의 데이터를 보여주기 위해 최적화되어 있다. delegate를 통해 prefetch 기능도 제공하고, 화면에서만 보여지는 뷰만을 보여주기 위해 뷰를 재사용 하는 것과 같이 다양한 기능을 포함하고 있다. 이는 SwiftUI에서는 제공하지 않는다. 컬렉션 뷰에 대한 자세한 설명은 위의 애플의 문서들을 참고하자.[1]

UICollectionView 사용하기

class ViewController: UIViewController {
// 1. 컬렉션 뷰 선언
var collectionView: UICollectionView!
var layout = UICollectionViewFlowLayout()

override func viewDidLoad() {
super.viewDidLoad()
// 2. 컬렉션 뷰 생성
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
collectionView.backgroundColor = .blue

// 3. 컬렉션 뷰 추가
view.addSubview(collectionView)
}
}

컬렉션 뷰를 생성하기 위해 framecollectionViewLayout 의 값을 인자로 넘겨줘야 하기 때문에, viewDidLoad 시점에 많이 생성한다. 컬렉션 뷰 생성 시 컬렉션 뷰에 대한 설정을 함께 작성하는 것이 일반적인 듯 하다.

    ...
override func viewDidLoad() {
super.viewDidLoad()

collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)

// layout 설정
layout.minimumLineSpacing = 8

// protocol delegate 설정
collectionView.dataSource = self
collectionView.delegate = self

// collectionView에서 사용할 cell 등록
collectionView.register(ProjectCell.self, forCellWithReuseIdentifier: ProjectCell.identifier)

view.addSubview(collectionView)
}
...

// DataSource delegate 처리를 위한 타입 확장
extension ViewController: UICollectionViewDataSource {
// 셀 갯수 설정
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
10
}

// 셀 dequeue 설정 - cell row
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ProjectCell.identifier, for: indexPath) as! ProjectCell
cell.configure(project: projects[indexPath.row])

return cell
}
}

// FlowLayout 레이아웃 설정을 위한 타입 확장
extension ViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
.init(width: collectionView.bounds.width, height: 200)
}
}


CollectionView의 delegate에는 컬렉션 뷰를 조작할 수 있는 다양한 메서드를 제공하고 있어, 이를 활용하면 상호작용 가능한 컬렉션 뷰를 작성할 수 있다.

extension ViewController: UICollectionViewDataSource {
// 특정 cell을 선택했을 때 호출되는 메서드
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
delegate?.pushNavigation(with: projects[indexPath.row]) // 특정 cell 클릭 시 Navigation 처리
}
}


대충 이런 식의 구조가 된다고 상상하면 될듯.

FlexLayout 사용하여 컬렉션 뷰 그리기

FlexLayout을 활용해 컬렉션 뷰를 그려보자. 플렉스레이아웃을 사용하면, 컬렉션 뷰를 구성하는 Cell 을 손 쉽게 그릴 수 있다.

class MyCollectionView: UIView {
private let collectionView: UICollectionView
private let flowLayout = UICollectionViewFlowLayout()
// 레이아웃 설정을 위한 Cell 인스턴스
private let cellTemplate = ProjectCell()
// 바인딩 할 데이터
private var projects: [Project] = []
}

플렉스레이아웃을 사용할 때 일반적인 패턴으로 뷰를 뷰 컨트롤러와 분리시킨다. 위 코드에서도 분리 된 UIView 안에 레이아웃을 위한 요소들과 뷰를 보여주기 위한 데이터를 선언하였다. MyCollectionView 를 완성하자.

MyCollectionView
class MyCollectionView: UIView {
private let collectionView: UICollectionView
private let flowLayout = UICollectionViewFlowLayout()
// 레이아웃 설정을 위한 Cell 인스턴스
private let cellTemplate = ProjectCell()
// 바인딩 할 데이터
private var projects: [Project] = []

init() {
// collectionView 생성
collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout)

super.init(frame: .zero)

// layout 설정
flowLayout.minimumLineSpacing = 8
flowLayout.minimumInteritemSpacing = 0

// collectionView 설정
collectionView.backgroundColor = .white
collectionView.dataSource = self
collectionView.delegate = self
collectionView.register(ProjectCell.self, forCellWithReuseIdentifier: ProjectCell.reuseIdentifier)

// View에 추가
addSubview(collectionView)
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

// 데이터 전달을 위한 메서드
func configure(projects: [Project]) {
self.projects = projects
collectionView.reloadData()
}

// 레이아웃 변경 시 처리를 위한 메서드
func viewOrientationDidChange() {
flowLayout.invalidateLayout()
}

override func layoutSubviews() {
super.layoutSubviews()
collectionView.pin.vertically().horizontally(pin.safeArea)
}
}

extension MyCollectionView: UICollectionViewDelegateFlowLayout, UICollectionViewDataSource {
// cell 갯수 설정
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return projects.count
}

// cell 설정
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ProjectCell.reuseIdentifier, for: indexPath) as! ProjectCell
cell.configure(project: projects[indexPath.row])
return cell
}

// cell 사이즈 설정
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
cellTemplate.configure(project: projects[indexPath.row])
return cellTemplate.sizeThatFits(CGSize(width: collectionView.bounds.width, height: .greatestFiniteMagnitude))
}
}

MyViewController
class MyViewController: UIViewController {
private var mainView: MyCollectionView {
self.view as! MyCollectionView
}

override func viewDidLoad() {
super.viewDidLoad()
}

override func loadView() {
view = MyCollectionView()
mainView.configure(projects: [])
}

override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
mainView.viewOrientationDidChange()
}
}

뷰 컨트롤러에서 뷰를 생성하고 configure(projects: [Project]) 메서드를 통해 컬렉션 뷰에 필요한 데이터를 전달하면, delegate 를 통해 전달 받은 데이터로 컬렉션 뷰의 cell 을 그린다.

ProjectCell
class ProjectCell: UICollectionViewCell {
static let reuseIdentifier = "ProjectCell"
private let titleLabel = UILabel()

override init(frame: CGRect) {
super.init(frame: frame)

contentView.flex.define { flex in
flex.addItem(titleLabel)
}
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

// cell의 데이터를 변경하는 메서드
func configure(project: Project) {
titleLabel.text = project.name
// 데이터 변경 시 레이아웃도 변경되므로 레이아웃 변경 되었음을 알림
titleLabel.flex.markDirty()
setNeedsLayout()
}

// 플렉스 레이아웃 적용
override func layoutSubviews() {
super.layoutSubviews()
layout()
}

// 특정 사이즈의 값이 설정되었을 때 사이즈 값 변경 후 반환
override func sizeThatFits(_ size: CGSize) -> CGSize {
contentView.pin.width(size.width)
layout()
return contentView.frame.size
}

private func layout() {
contentView.flex.layout(mode: .adjustHeight)
}
}

Cell 역시 configure 메서드를 통해 값을 전달 받아 cell 레이아웃을 변경한다. 구조를 살펴보면 다음과 같음을 알 수 있다.


흠.. 뷰 컨트롤러에서 projects 를 넘기는게 신경 쓰이긴 한다. projects 를 View 로 넘겨 줄 때 양이 크다면 복사되는 메모리가 커질 것 같은데.. COW 이 해결해줄라나?

그래도 각각의 역할을 수행한다는 점에서 깔끔하긴 하다. 뷰 컨트롤러는 데이터를 전달하고, 컬렉션 뷰는 뷰를 그리고, 델리게이트를 통해 셀에 데이터를 전달하고, 셀은 뷰를 그린다.

맺음말

지금까지 플렉스레이아웃을 사용해서 컬렉션 뷰를 그려보았다. 지금 상태로도 레이아웃을 작성하기에는 문제는 없지만, 컬렉션 뷰를 작성하기 까지, 작성할 코드도 많다는 것을 알 수 있다. 다음 글에선 작성에 필요한 코드를 좀 더 줄이고, dataSource 대신 RxSwift를 사용해서 컬렉션 뷰를 그려보자.


[1] 애플은 다양한 컬렉션 뷰에 관한 다양한 문서를 제공하고 있다. 많이 참고하도록 하자. (너무 많다 하)