[iOS] Modern Collection View - Grid 구현
👨🏻‍💻iOS 공부/iOS & Swift

[iOS] Modern Collection View - Grid 구현

728x90
반응형

Modern Collection View - Grid 구현

앞선 글([iOS] Modern Collection View - List 구현)에서는 collection View를 활용하여 list 형태의 뷰를 구현하는 방식을 봤었다. 이제는 원래 collection View의 목적이라고도 할 수 있는(?) grid(격자)형태의 뷰를 구성해보자.
(List 형태를 구현할 때와 코드가 거의 유사하니 이전 글을 참고해도 좋을 것 같다.)

이 또한 iOS 14이후의 버전에 대해 지원해주는 modern한 구현 방식이 있는데, 이를 알아보기 전에 기존 방식으로는 어떻게 구현했는지 살펴보자.

기존 collectionView 구현 방식

우선 그릴 화면을 먼저 보자.

가장 흔히 볼 수 있는 grid 형태의 뷰이다. 이를 구현하기 위해서는 어떠한 순서대로 구현하면 좋을지 먼저 살펴보자. 구현 방식에는 개개인마다 차이가 있다는 점을 유의하면서 보면 좋을 것 같다.

1. UICollectionView를 생성해둔다.
    1-1. 이 때 클로저 방식으로 생성하면서 속성들을 모두 부여해준다.
    1-2. 기본적으로 UICollectionViewFlowLayout으로 기본적인 레이아웃을 잡아준다.
    1-3. 직접 만든 cell을 등록시켜준다.
2. UICollectionViewCell을 생성한다.
3. (데이터 관리) UICollectionViewDataSourceViewController에 채택한다.
    3-1.  (개수)collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int 메서드를 통해 한 섹션에 몇 개의 아이템이 올지 정해준다.
    3-2. (데이터) collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell 메서드를 통해 cell을 dequeue해주고, cell에 데이터를 할당하고, 꾸며준다.
    3-3. 커스텀한 레이아웃을 위해 UICollectionViewDelegateFlowLayout를 채택해주고, collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize메서드를 통해 item별 사이즈를 정해준다.

세부적으로 스텝을 구분해보긴 했지만 크게 보면 3가지 부분으로 볼 수 있을 것 같다.

 

1. UICollectionView의 데이터
2. UICollectionView의 레이아웃
3. UICollectionViewCell 생성

 

전체 코드를 보자.

 

더보기
import UIKit

class AnimalViewController: UIViewController {
    let api = APIService()
    var animalData: [Animal] = []
    let collectionView: UICollectionView = {
        let flowLayout = UICollectionViewFlowLayout()
        flowLayout.scrollDirection = .vertical

        let cv = UICollectionView(frame: .zero, collectionViewLayout: flowLayout)
        cv.register(AnimalCollectionViewCell.self, forCellWithReuseIdentifier: AnimalCollectionViewCell.identifier)
        cv.contentInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
        return cv
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        self.title = "ZOO"
        view.backgroundColor = .white
        getData()
    }

    func getData() {
        api.getAnimalData { result in
            switch result {
            case .success(let animals):
                self.animalData = animals
                DispatchQueue.main.async {
                    self.setupCollectionView()
                }
            case .failure(let error):
                print(error)
            }
        }
    }

    func setupCollectionView() {
        collectionView.dataSource = self
        collectionView.delegate = self

        view.addSubview(collectionView)

        collectionView.translatesAutoresizingMaskIntoConstraints = false

        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])

    }
}


extension AnimalViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return animalData.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as? AnimalCollectionViewCell else {
            return AnimalCollectionViewCell()
        }

        let data = animalData[indexPath.item]
        cell.configCell(with: data)
        return cell
    }
}

extension AnimalViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        let margin: CGFloat = 10
        return CGSize(width: (self.view.frame.width - (margin * 3)) / 2, height: self.view.frame.height / 4)
    }
}
더보기
import UIKit

class AnimalCollectionViewCell: UICollectionViewCell {
    static let identifier = "cell"

    let nameLabel: UILabel = {
        let lb = UILabel()
        lb.textColor = .white
        lb.textAlignment = .right
        lb.numberOfLines = 0
        return lb
    }()

    let animalImage: UIImageView = {
        let iv = UIImageView()
        iv.contentMode = .scaleAspectFill
        return iv
    }()

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

    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }

    func layout() {
        [animalImage, nameLabel].forEach {
            contentView.addSubview($0)
            $0.translatesAutoresizingMaskIntoConstraints = false
        }

        layer.masksToBounds = true
        layer.cornerRadius = 10

        NSLayoutConstraint.activate([
            animalImage.topAnchor.constraint(equalTo: contentView.topAnchor),
            animalImage.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            animalImage.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            animalImage.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),

            nameLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10),
            nameLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
            nameLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
        ])
    }

    func configCell(with animal: Animal) {
        ImageLoader.loadImage(from: animal.imageLink) { image in
            self.animalImage.image = image
        }

        let attrString = NSAttributedString(
            string: animal.name,
            attributes: [
                NSAttributedString.Key.strokeColor: UIColor.black,
                NSAttributedString.Key.foregroundColor: UIColor.white,
                NSAttributedString.Key.strokeWidth: -2.0,
                NSAttributedString.Key.font: UIFont(name: "Helvetica-Bold", size: 30)
            ]
        )

        nameLabel.attributedText = attrString
    }
}

기존의 방식은 잘 알 것이라고 생각한다...!
(사실 비교를 위해 기존 방식의 코드를 넣은거긴 하니까... 세세한 설명들은 스킵...!)

List를 구현할 때는 전용 cell을 만들어서 사용했지만, 다행인 건 grid 형태를 구현할 때는 modern 방식에서도 기존 cell을 그대로 사용할 수 있다!

Modern CollectionView - Grid 구현

자 이제 본 목적인 modern하게 grid형태의 collection view를 그리는 방법을 살펴보자.

이 또한 먼저 순서를 살펴보자!

 

1. UICollectionView 타입의 프로퍼티 정의
2. UICollectionViewDiffableDataSource 타입의 프로퍼티 정의
3. UICollectionViewCompositionalLayout 레이아웃 생성
4. UICollectionView 인스턴스 생성 및 레이아웃 적용
5. UICollectionViewDiffableDataSource 구현
    5-1. CellRegistration 구현
    5-2. UICollectionViewDiffableDataSource 인스턴스 생성 및 cellProvider의 dequeueConfiguredReusableCell 구현
    5-3. NSDiffableDataSourceSnapShot 생성

 

하나씩 살펴보자.

1. UICollectionView 타입의 프로퍼티 정의

var gridCollectionView: UICollectionView!

이건 뭐..!
(사실 암시적 추출 옵셔널보다는 옵셔널(?)을 사용하는 것이 더 안전하지만 편의를 위해 암시적 추출 옵셔널을 사용하여 진행합니다!)

2. UICollectionViewDiffableDataSource 타입의 프로퍼티 정의

var dataSource: UICollectionViewDiffableDataSource<Section, Animal>!

사용할 section과 데이터 타입을 명시해줍니다.

3. UICollectionViewCompositionalLayout 레이아웃 생성

이제 item -> group -> section 순서대로 생성해주고 section을 사용하여 UICollectionViewCompositionalLayout 인스턴스를 하나 만들어 반환해줍니다.

앞서서 먼저 item/group/section 그리고 이들의 사이즈를 정해주기 위한 NSCollectionLayoutSize에 대해서 알아보고 코드를 보자.

  • NSCollectionLayoutSize
    • collection view내 요소의 너비와 높이를 나타낸다.
    • width/height diemension을 통해 크기를 줄 수 있다.
    • .fractionalWidth: 포함하는 그룹 너비에 상대적인 크기이다. 1.0 이라는 것은 동일한 크기를 주겠다는 것. 1보다 큰 수를 주면 그 배수만큼 값이 적용된다. 0.2를 적으면 20%만큼의 크기를 가지겠다는 의미가 된다!
    • .fractionalHeight: 위의 높이 버전!
    • .estimated() : 데이터가 로딩되거나 응답이 바뀌는 경우와 같이 런타임에 컨텐츠의 크기가 변경될 수 있기에 추정값을 넣어준다. 초기 추정 크기를 제공하면 시스템은 실제 값을 나중에 연산한다.
    • .absolute(): 절대적인 크기를 입력하여 사용하는 방식이다.

NSCollectionLayoutItem

  • collection view 레이아웃에서 가장 기본이 되는 구성 요소이다.
  • item은 collection view내 컨텐츠의 개별적인 공간의 배열이나 공간, 크기를 나타내는 청사진이다. item은 스크린에 보여질 단일 뷰이며, 일반적으로 cell이라고 봐도 된다. 하지만 item은 헤더, 푸터, 데코뷰가 될 수도 있다.

NSCollectionLayoutGroup

  • 방향에 따라 item을 배치하는 item들의 집합의 컨테이너이다.
  • 그룹은 collection view의 item이 서로간에 어떻게 배치되는지를 결정한다. 그룹은 item을 horizontal/vertical 혹은 커스텀하게 배치할 지 결정할 수 있다. 그룹은 item들이 렌더링되는 방법에 대한 규칙을 정하지만 스스로(그룹)는 내용을 렌더링하지 않는다.(즉 아이템이 무조건 있어야 한다는 것 같다!)
  • 그룹은 NSCollectionLayoutItem의 하위 클래스이기 때문에 아이템처럼 사용할 수 있다. 그룹을 다른 아이템들과 결합하거나 아래와 같이 그룹을 더 복잡한 레이아웃으로도 사용해볼 수 있다.

NSCollectionLayoutSection

  • 그룹들을 고유한 시각적 묶음으로 결합하는 컨테이너이다.
  • 즉 그룹들의 모음집이라고도 볼 수 있다.
  • collection view의 레이아웃은 하나 이상의 섹션을 가진다. 섹션은 레이아웃을 서로 다른 조각으로 구분할 수 있는 방법을 제공한다.
  • 각 섹션은 collection view의 다른 섹션들과 동일한 레이아웃을 가질 수 있고, 다를 수도 있다. 섹션의 레이아웃은 그룹의 프로퍼티에 의해 결정된다.
  • 각 섹션은 다른 섹션과 구분하기 위해 배경, 헤더, 푸터를 가질 수 있다.

자 이제 코드로 하나하나 쌓아보면 이해가 더 잘 될 것이다.

func createLayout() -> UICollectionViewCompositionalLayout{
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)

    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(self.view.frame.height * 0.25))
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 2)
    group.interItemSpacing = .fixed(10)

    let section = NSCollectionLayoutSection(group: group)
    section.interGroupSpacing = CGFloat(10)
    section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)

    let layout = UICollectionViewCompositionalLayout(section: section)

    return layout
}

앞서 설명한 것 처럼 item -> group -> section 순으로 쌓고 layout을 만드는 것을 볼 수 있다.

 

이제 UICollectionViewCompositionalLayout 인스턴스를 만들었으니 이를 활용하여 UICollectionView 인스턴스를 생성해보자.

4. UICollectionView 인스턴스 생성 및 레이아웃 적용

앞서 만든 레이아웃을 활용하여 collection view에 적용하고 collection view의 레이아웃을 잡아주면 된다!

func createGridCollectionView() {
    gridCollectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout())

    view.addSubview(gridCollectionView)
    gridCollectionView.translatesAutoresizingMaskIntoConstraints = false

    NSLayoutConstraint.activate([
        gridCollectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
        gridCollectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
        gridCollectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        gridCollectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
    ])
}

5. UICollectionViewDiffableDataSource 구현

이제 data source를 다룰 시간이다.


이 부분은 list를 구현할 때와 거의 사실상 동일하다.

func configDataSource() {
    // 5-1. `CellRegistration` 구현
    let cellRegistration = UICollectionView.CellRegistration<AnimalCollectionViewCell, Animal> { cell, indexPath, animal in
        cell.configCell(with: animal)
    }

    // 5-2. `UICollectionViewDiffableDataSource` 인스턴스 생성 및 cellProvider의 `dequeueConfiguredReusableCell` 구현
    dataSource = UICollectionViewDiffableDataSource<Section, Animal>(collectionView: gridCollectionView, cellProvider: { (collectionView, indexPath, itemIdentifier) -> UICollectionViewCell? in
        return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: itemIdentifier)
    })

    // 5-3. `NSDiffableDataSourceSnapShot` 생성 
    var snapShot = NSDiffableDataSourceSnapshot<Section, Animal>()
    snapShot.appendSections([.main])
    snapShot.appendItems(animalData)
    dataSource.apply(snapShot)
}

이 부분은 크게 어렵지 않을 것이다.

 

cell을 등록해주고, cell을 반환해주고, cell에 들어갈 데이터를 snapshot을 통해 넣어주는 그러한 일련의 과정이다.
각 메서드에 대한 자세한 설명은 [iOS] Modern Collection View - List 구현를 참고하자.


이제 구현한 화면을 볼 시간!

이전에 만든 코드로 영상을 딴게 아닐까 싶을 정도로 유사하게, 아니 똑같이 만들 수 있다.

modern하게 grid collection view 구현하는 방법의 장점을 생각해보면 2가지 정도 있는 것 같다.

  • 직관적으로 grid내 계층을 그릴 수 있다.
  • DiffableDataSource로 데이터와 UI를 쉽게 관리할 수 있다.

코드 자체의 길이는 비슷비슷한 것 같지만 modern의 방식이 조금 더 코드의 흐름을 읽기에도 좋은 것 같다는 생각이 든다.

물론 iOS 14 이상에서만 가능한 코드이지만... 계속 버전이 올라감에 따라 이 방법이 표준이 될 날도 얼마 남지 않을 것 같다.

약간 SwiftUI에서 뷰를 하나하나 쌓은 것 처럼, UIKit을 가지고 유사하게 구현한(?) 느낌을 개인적으로 받았다.


아무튼! 지금까지 grid 형태의 collection view를 만드는 두 가지 방법을 봤다. 각자 생각하는 장단점에 의거해서 구현 방식을 선택하면 될 것 같다!

 

전체 코드는 아래와 같다. 

 

더보기
import UIKit


class AnimalGridViewController: UIViewController {
    enum Section {
        case main
    }
    
    let api = APIService()
    var animalData: [Animal] = []
    var gridCollectionView: UICollectionView!
    var dataSource: UICollectionViewDiffableDataSource<Section, Animal>!
    
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.title = "ZOO"
        view.backgroundColor = .white
        getData()
    }
    
    func getData() {
        api.getAnimalData { result in
            switch result {
            case .success(let animals):
                self.animalData = animals
                DispatchQueue.main.async {
                    self.createGridCollectionView()
                    self.configDataSource()
                }
            case .failure(let error):
                print(error)
            }
        }
    }
    
    func createLayout() -> UICollectionViewCompositionalLayout{
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(2.0), heightDimension: .fractionalHeight(1.0))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        
        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(2.0), heightDimension: .absolute(self.view.frame.height * 0.25))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 2)
        group.interItemSpacing = .fixed(10)
        
        let section = NSCollectionLayoutSection(group: group)
        section.interGroupSpacing = CGFloat(10)
        section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)
        
        let layout = UICollectionViewCompositionalLayout(section: section)
        
        return layout
    }
    
    func createGridCollectionView() {
        gridCollectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout())
        
        view.addSubview(gridCollectionView)
        gridCollectionView.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            gridCollectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            gridCollectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            gridCollectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            gridCollectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])
    }
    
    func configDataSource() {
        let cellRegistration = UICollectionView.CellRegistration<AnimalCollectionViewCell, Animal> { cell, indexPath, animal in
            cell.configCell(with: animal)
        }
        
        dataSource = UICollectionViewDiffableDataSource<Section, Animal>(collectionView: gridCollectionView, cellProvider: { (collectionView, indexPath, itemIdentifier) -> UICollectionViewCell? in
            return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: itemIdentifier)
        })
        
        var snapShot = NSDiffableDataSourceSnapshot<Section, Animal>()
        snapShot.appendSections([.main])
        snapShot.appendItems(animalData)
        dataSource.apply(snapShot)
    }

}

끄읕


Ref

https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/implementing_modern_collection_views

728x90
반응형