UICollectionViewDiffable DataSource 튜토리얼

Uno·2023년 3월 19일
0

UIKit

목록 보기
9/9

나오게된 배경

Controller 에서 받은 데이터 vs UI(CollectionView) 에서 가지고 있는 데이터가 이원화 되어 관리되어서, 매번 reloadData() 를 해서 싱크를 맞춰야 했습니다. 이것을 다른 방식으로 접근해서 문제를 해결하려고 나왔습니다.

Diffable DataSource

기존의 UICollectionViewDataSource 는 명령형으로 구현해야 했습니다.
예를들면, 몇 개의 아이템을 보여줄 지, 어떤 셀을 보여줄지 ... 이런 것들을 델리게이트 프로토콜 메서드를 구현해서 정해줬습니다.

이번에 새로나온 diffable datasource 는 새로 업데이트할 값과 기존에 있던 값을 비교합니다. 그리고 비교해서 다른 부분만 변경합니다.
이 동작 원리 때문에, 각 데이터들이 Hashable 해야합니다. 여기서 Hashable 해야하는 이유는, "중복되지 않는 값임이 보장되어야 함" 이라는 속성을 따라야하는 데이터만 diffable datasource 에 사용될 수 있음을 뜻합니다.

말이 어려운데 정리해보면 다음과 같습니다.

  • 기존의 UICollectionViewDataSource 의 경우, 값이 변경할 때마다 직접 변경해주어야 했다.
  • Diffable DataSource의 경우, 기존값과 새로운 값을 비교해서 변경되는 부분만 알아서 변경해준다.
  • 그래서 최적화 + 애니메이션 효과를 가져올 수 있다.
  • 이 효과를 얻기 위해서는 데이터가 Hashable 을 채택하고 있어야 한다.

실습

소스코드

중간중간에 실수로 빼먹은 부분이 있을 수 있습니다. 그때 전체 코드 참고해서 봐주시면 감사하겠습니다 ㅜㅜ

기본적으로 라이브러리는 다음 두 개를 사용했습니다.

  • SnapKit (오토레이아웃 코드로 UI 잡을 때 편리한 라이브러리)
  • SDWebImage (이미지 캐싱 및 URL 로 된 이미지를 편리하게 적용하도록 도와주는 라이브러리)

초기세팅

1) 데이터 모델 구성하기

  • Cat.swift
import UIKit

class Cat: Hashable {
    var id = UUID()
    var name: String
    var thumbnilStr: String?
    
    init(name: String, thumbnilStr: String? = nil) {
        self.name = name
        self.thumbnilStr = thumbnilStr
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
    
    static func ==(lhs: Cat, rhs: Cat) -> Bool {
        return lhs.id == rhs.id
    }
}

extension Cat {
    static let allCats = [
        Cat(
            name: "첫 번째 고양이",
            thumbnilStr: "https://cdn2.thecatapi.com/images/0XYvRd7oD.jpg"
        ),
        Cat(
            name: "두 번째 고양이",
            thumbnilStr: "https://cdn2.thecatapi.com/images/MTc5NjI5Mw.jpg"
        ),
        Cat(
            name: "세 번째 고양이",
            thumbnilStr: "https://cdn2.thecatapi.com/images/MTUzMjkzMg.jpg"
        ),
        Cat(
            name: "네 번째 고양이",
            thumbnilStr: "https://cdn2.thecatapi.com/images/MTcwMDE1Mw.jpg"
        ),
        Cat(
            name: "다섯 번째 고양이",
            thumbnilStr: "https://cdn2.thecatapi.com/images/MTY4OTE5Mw.jpg"
        ),
        Cat(
            name: "여섯 번째 고양이",
            thumbnilStr: "https://cdn2.thecatapi.com/images/ad3.jpg"
        ),
        Cat(
            name: "일곱 번째 고양이",
            thumbnilStr: "https://cdn2.thecatapi.com/images/aa1.jpg"
        ),
        Cat(
            name: "여덟 번째 고양이",
            thumbnilStr: "https://cdn2.thecatapi.com/images/4m2.jpg"
        ),
        Cat(
            name: "열한번 번째 고양이",
            thumbnilStr: "https://cdn2.thecatapi.com/images/JBkP_EJm9.jpg"
        ),
        Cat(
            name: "열 번째 고양이",
            thumbnilStr: "https://cdn2.thecatapi.com/images/1nv.jpg"
        ),
        Cat(
            name: "아홉 번째 고양이",
            thumbnilStr: "https://cdn2.thecatapi.com/images/4v.jpg"
        )
    ]
}

2.CollectionViewController + SearhController + UICollectionViewCompositionalLayout + Cell 초기 세팅

  • ViewController.swift
import UIKit
import SnapKit

final class ViewController: UICollectionViewController {
    // CollectionView 에서 사용할 데이터 인스턴스를 정의한다
    private var catList = Cat.allCats
    
    private var searchController = UISearchController(searchResultsController: nil)
    
    override func viewDidLoad() {
        super.viewDidLoad()
	      configureCollectionView()
    }

		func configureCollectionView() {
        collectionView.register(CollectionViewCell.self, forCellWithReuseIdentifier: CollectionViewCell.reusableIdentifier)
    }
}


// MARK: - UISearchResultsUpdating Delegate
extension ViewController: UISearchResultsUpdating {
    // 검색 시, 이곳이 호출한다
    func updateSearchResults(for searchController: UISearchController) {}
    
		// SearchController 기본 세팅
    func configureSearchController() {
      searchController.searchResultsUpdater = self
      searchController.obscuresBackgroundDuringPresentation = false
      searchController.searchBar.placeholder = "고양이"
      navigationItem.searchController = searchController
      definesPresentationContext = true
    }
}

// MARK: - Layout Handling
extension ViewController {

	// iOS 13 부터 사용 가능한 Compositional Layout 을 이용한다
  private func configureLayout() {
    collectionView.collectionViewLayout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
      let isPhone = layoutEnvironment.traitCollection.userInterfaceIdiom == UIUserInterfaceIdiom.phone
      let size = NSCollectionLayoutSize(
        widthDimension: NSCollectionLayoutDimension.fractionalWidth(1),
        heightDimension: NSCollectionLayoutDimension.absolute(isPhone ? 280 : 250)
      )
      let itemCount = isPhone ? 1 : 3
      let item = NSCollectionLayoutItem(layoutSize: size)
      let group = NSCollectionLayoutGroup.horizontal(layoutSize: size, subitem: item, count: itemCount)
      let section = NSCollectionLayoutSection(group: group)
      section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
      section.interGroupSpacing = 10
      return section
    })
  }
  
  override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
    super.viewWillTransition(to: size, with: coordinator)
    coordinator.animate(alongsideTransition: { context in
      self.collectionView.collectionViewLayout.invalidateLayout()
    }, completion: nil)
  }
}
  • CollectionViewCell.swift
//
//  CollectionViewCell.swift
//  UICollectionViewDataSource Example
//
//  Created by 김우성 on 2023/03/19.
//

import UIKit
import SnapKit
import SDWebImage

final class CollectionViewCell: UICollectionViewCell {
    static let reusableIdentifier = "CollectionViewCell"
    
    private let cellLabel: UILabel = {
        let label = UILabel()
        return label
    }()
    
    private let cellImageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFit
        return imageView
    }()
    
    override func awakeFromNib() {
        super.awakeFromNib()
    }
    
    override func prepareForReuse() {
        super.prepareForReuse()
    }
    
    func configure(_ cellData: Cat) {
        self.addSubview(cellLabel)
        cellLabel.snp.makeConstraints { make in
            make.centerX.equalTo(self.snp.centerX)
            make.bottom.equalTo(self.snp.bottom).inset(10)
        }
        
        self.addSubview(cellImageView)
        cellImageView.snp.makeConstraints { make in
            make.top.equalTo(self.snp.top).offset(10)
            make.left.equalTo(self.snp.left).offset(10)
            make.right.equalTo(self.snp.right).inset(10)
            make.bottom.equalTo(cellLabel.snp.top).offset(0)
        }
        
        
        if let imageURLStr = cellData.thumbnilStr {
            cellImageView.sd_setImage(with: URL(string: imageURLStr)!)
        }
        
        cellLabel.text = cellData.name
    }
}

구현

1) Section 을 정의합니다.

ViewController의 전역 범위에 정의합니다.

enum Section {
        case main
}

2) DataSource 의 타입을 지정합니다.

타입별칭(typealias) 를 사용하지 않아도 되지만, 유독 이름이 긴 타입이므로 가능하면 사용해보려고 합니다. ;)

// ### 3. DataSource 를 정의한다
    typealias DataSource = UICollectionViewDiffableDataSource<Section, Cat>

3) DataSource 를 구현합니다.

여기 코드에서 보면 dataSource 를 프로토콜로 채택하는 방식이 아니라, 인스턴스로 생성해서 결정합니다. 덕분에 비슷한 데이터 소스가 나오면 해당 데이터 소스를 주입하는 방식으로 사용하면 됩니다.

cellProvider 부분에서 어떤 UICollectionViewCell 을 쓸지, 어떤 색션에 적용할지 그리고 어떤 데이터를 적용할지 까지 결정됩니다. 이 결정되는 부분인 이전 타입 결정할 때, 제네릭으로Cat 타입을 전달해줘서 파라미터로 전달받게 됩니다.

func makeDataSource() -> DataSource {
        
        // 데이터 소스는 어떤 컬렉션뷰에 적용될지와, 셀은 어떤식으로 생성할지를 포함한다
        let dataSource = DataSource(
            collectionView: collectionView,
            cellProvider: { (collectionView, indexPath, cat) -> UICollectionViewCell? in
                // 셀을 생성한다
                let cell = collectionView.dequeueReusableCell(
                    withReuseIdentifier: CollectionViewCell.reusableIdentifier,
                    for: indexPath
                ) as! CollectionViewCell
                return cell
            }
        )
        return dataSource
    }

4) DataSource 인스턴스를 생성합니다.

private lazy var dataSource = makeDataSource()

SnapShot 생성하기

diifable DataSource 의 경우, snapshot 이라는 특정 시점의 특정 값 비교를 통해서 값을 갱신합니다. 그래서 reloaddata 대신 어떤 것이 snapshot 인지 datasource 에게 알려주어야 합니다.

1) SnapShot 의 타입을 지정합니다.

typealias SnapShot = NSDiffableDataSourceSnapshot<Section, Cat>

2) SnapShot 생성하는 메서드를 구현합니다.

func applySnapshot(animatingDifferences: Bool = true) {
        // 스냅샷을 생성한다
        var snapshot = Snapshot()
        // 스냅샷에 색션을 추가한다.
        snapshot.appendSections([.main])
        // 스냅샷에 섹션에 데이터를 추가한다.
        snapshot.appendItems(catList, toSection: .main)
        // 데이터 소스에 저장한다.
        dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
    }

3) 지금까지 구현한 것들을 호출합니다.

override func viewDidLoad() {
        super.viewDidLoad()
        configureCollectionView()
        configureSearchController()
        configureLayout()
        applySnapshot(animatingDifferences: false)
}
    

검색기능 구현하기

1) 검색 메서드를 구현합니다.

func filteredCats(for queryOrNil: String?) -> [Cat] {
        // 전체 데이터
        // 이 부분이 만약 서버라면, 검색 결과자체를 받아서 리턴
        let cats = Cat.allCats
        
        // 검색어가 nil 이거나 없으면, 전체를 리턴한다.
        guard let query = queryOrNil,
              !query.isEmpty else {
            return cats
        }
        
        // 전체 데이터 중에서 "name" 만 비교해서 리턴한다.
        return cats.filter { cat in
            return cat.name.lowercased().contains(query.lowercased())
        }
    }

2) 검색 결과를 snapshot 으로 만들어서 적용합니다.

updateSearchResults 메소드는 검색동작이 할때마다 호출되는 이벤트메소드입니다. 그래서 이곳에 apply() 메소드를 호출합니다.

// 검색 시, 이곳이 호출됩니다.
func updateSearchResults(for searchController: UISearchController) {
	catList = filteredCats(for: searchController.searchBar.text)
	applySnapshot()
}

최종결과

정리

  • UICollectionViewDiffable DataSource 를 사용해서, UI 에서의 상태값과 다른 객체에서의 상태값이 싱크가 안맞는 문제를 해결할 수 있다. (이로 인해 크래시가 발생했었음)
  • 위 기능을 사용하기 위해서는 각 데이터가 Hashable 프로토콜을 따랴야 한다.
  • reloadData 에서 apply 로 변경되었다.

참고자료

소스코드

profile
iOS & Flutter

0개의 댓글