apple/iOS

[iOS] DiffableDataSource n-Section n-Item (섹션마다 다른 셀)

lgvv 2022. 9. 18. 15:58

[iOS] DiffableDataSource n-Section n-Item (섹션마다 다른 셀)

 

DiffableDataSource을 이용하여 각각의 섹션마다 다른 셀을 적용하고자 함.

앱 타겟은 iOS 13

iOS 14이상의 경우 아주 간단하게 처리할 수 있는데, 

UICollectionView.CellRegistration

이는 애플의 공식문서에도 나와있음 다만, iOS 14이상에서 가능

 

 

⚙️개발환경⚙️

Xcode 14.0

iOS 13.0 이상

Swift 5

SnapKit 5.6

 

 

🚨 코드를 보고난 후 주의할 점 🚨 

1. 기본적으로 Item의 경우에는 Hashable해야 함.

 

 결과 이미지의 세번째 사진을 보면 배열에 1이 4개가 들어있음에도 불구하고 1이 하나만 보여짐.

 그 이유는 Hashable하기 때문에 Set으로 처리 되어서 같은 값이 경우에는 하나로 인식

 따라서 model을 정의하여 사용할 때 uuid를 활용하여 Equtable를 상속받아 func == 을 잘 정의해주기.

 

만약 같은 Unique하지 않게 값이 들어가는 경우라면 콘솔에 아래와 같은 에러는 보여준다.

특히, 아래의 에러가 중복되는 수 만큼 찍혀서 디버그하기가 용이하다.

 

```

[UIDiffableDataSource] Diffable data source detected an attempt to insert or append 1 item identifier that already exists in the snapshot. The existing item identifier will be moved into place instead, but this operation will be more expensive. For best performance, inserted item identifiers should always be unique. Set a symbolic breakpoint on BUG_IN_CLIENT_OF_DIFFABLE_DATA_SOURCE__IDENTIFIER_ALREADY_EXISTS to catch this in the debugger. Item identifier that already exists: AppleCollectionView.MuseumViewController.Item.number

```

 

2. DataSource 후 snapshot 호출

순서를 지키지 않으면 앱이 크래시 난다.

 

 

 

 

 

 

✅ 결과 이미지 ✅

 

왼쪽부터 이미지 1, 2, 3

 

✅ 전체 코드  

//
//  MuseumViewController.swift
//  AppleCollectionView
//
//  Created by Hamlit Jason on 2022/09/18.
//

import UIKit
import SnapKit

final class MuseumViewController: UIViewController, UITableViewDelegate {
    
    // 🐬 1. 섹션 정의
    enum MySection: Int { // 1-1. Int를 상속받아야 section값 int로 찾을 수 있음
        case banner
        case number
    }
    
    // 🐬 2. 아이템 정의
    enum Item: Hashable {
        case bannerItem(String)
        case numberItem(Int)
    }
    
    // 🐬 3. 테이블 뷰 및 DataSource 선언
    var tableView = UITableView()
    var dataSource: UITableViewDiffableDataSource<MySection, Item>!
    
    var banners: [String] {
        [
            String.createRandomString(length: 100),
            String.createRandomString(length: 30),
            String.createRandomString(length: 50),
            String.createRandomString(length: 25),
            String.createRandomString(length: 50),
            String.createRandomString(length: 5),
            String.createRandomString(length: 25),
            String.createRandomString(length: 35),
            String.createRandomString(length: 45),
            String.createRandomString(length: 5),
            
        ]
    }
    var numbers: [Int] = [100000, 10000, 1000, 100, 10, 10, 12, 13, 14, 15, 16, 71, 81, 91, 31, 41, 1, 1, 1, 1]
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupTableView()
        
        fetchDataSource()
        fetchSnapShot()
    }
    
    func setupTableView() {
        // 🐬 4. 테이블 뷰 설정
        // 🐬 표시가 있는것이 diffable과 관련한 코드
        
        // 4-0. 테이블 뷰 view에 추가
        view.addSubview(tableView)
        // 4-1. header 및 footer를 사용하기 위해서
        tableView.delegate = self
        
        
        // 🐬 4-2. 테이블 뷰에 사용되는 cell 연결
        tableView.register(MuseumBannerCell.self, forCellReuseIdentifier: MuseumBannerCell.id)
        tableView.register(MuseumNumberCell.self, forCellReuseIdentifier: MuseumNumberCell.id)
        
        // 4-3. 테이블 뷰에 사용되는 헤더 view 연결
        tableView.register(MuseumHeaderView.self, forHeaderFooterViewReuseIdentifier: MuseumHeaderView.id)
        
        // 4-4. 레이아웃 및 배경색 지정
        tableView.backgroundColor = .green
        tableView.snp.makeConstraints {
            $0.edges.equalTo(view.safeAreaLayoutGuide)
        }
    }
    
    // 🐬 5. 스냅샷을 통한 데이터 연결 - ✅ 중요
    func fetchSnapShot() {
        // 🐬 5-1. 스냅샷 데이터 소스 만들기
        var snapshot = NSDiffableDataSourceSnapshot<MySection, Item>()
        // 🐬 5-2. 스냡샷에 섹션추가
        snapshot.appendSections([.banner, .number])
        // 🐬 5-3. 섹션를 구분하여 아이템 append
        banners.map { snapshot.appendItems([.bannerItem($0)], toSection: .banner) }
        numbers.map { snapshot.appendItems([.numberItem($0)], toSection: .number) }
        
        // 🐬 5-4. 데이터 소스에 적용!
        dataSource.apply(snapshot)
        
        /* 🚨 주의사항!
         섹션이 여러개일 경우 appendItems 시 toSection: 을 지정하지 않으면, 마지막 섹션에 데이터가 몰아서 처리된다.
         */
        
    }
    
    // 🐬 6. 데이터 소스 정의하기 - ✅ 매우 중요
    func fetchDataSource() {
        dataSource = UITableViewDiffableDataSource<MySection, Item>(
            tableView: tableView
        ) { tableView, indexPath, itemIdentifier in
            
            // 🐬 6-1. section 정의하기
            let section = MySection(rawValue: indexPath.section)
            
            print("section: \(section): indexPath \(indexPath): item \(itemIdentifier) \(type(of: itemIdentifier))")
            
            // 🐬 6-2. itemIdentifier를 가지고 아이템 값 추출하기
            var value: Any
            switch itemIdentifier {
            case .bannerItem(let item):
                value = item
            case .numberItem(let item):
                value = item
            }
            
            // 🐬 6-3. 섹션에 따른 값 설정하기
            switch section {
            case .banner:
                guard let cell = tableView.dequeueReusableCell(
                    withIdentifier: MuseumBannerCell.id,
                    for: indexPath
                ) as? MuseumBannerCell else { return UITableViewCell() }
                
                print("BANNER")
                
                cell.configureCell(with: value as! String)
                cell.backgroundColor = .red
                
                return cell
                
            case .number:
                guard let cell = tableView.dequeueReusableCell(
                    withIdentifier: MuseumNumberCell.id,
                    for: indexPath
                ) as? MuseumNumberCell else { return UITableViewCell() }
                
                print("NUMBER")
                cell.configureCell(with: value as! Int)
                cell.backgroundColor = .blue
                
                return cell
            default:
                return UITableViewCell()
            }
        }
    }
    
    // MARK: - Header
    
    func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
        if section == 0 {
            return MuseumHeaderView()
        } else {
            return MuseumHeaderView()
        }
    }
    
    func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
        if section == 0 {
            return 100
        } else {
            return 150
        }
    }
    
    // MARK: FOOTER
    func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
        return nil
    }
    
    func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
        return 0
    }
}


class MuseumBannerCell: UITableViewCell {
    static let id: String = "MuseumBannerCell"
    
    let title = UILabel()
    
    // MARK: - Initialize
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        contentView.addSubview(title)
        title.numberOfLines = 0
        title.snp.makeConstraints {
            $0.edges.equalToSuperview().inset(20)
        }
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func configureCell(with item: String) {
        title.text = item
    }
}


final class MuseumNumberCell: UITableViewCell {
    static let id: String = "MuseumNumberCell"
    
    let title = UILabel()
    
    // MARK: - Initialize
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        contentView.addSubview(title)
        title.snp.makeConstraints {
            $0.edges.equalToSuperview().inset(20)
        }
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func configureCell(with item: Int) {
        title.text = "\(item)"
    }
}

class MuseumHeaderView: UITableViewHeaderFooterView {
    static let id: String = "MuseumBannerHeaderView"
    let headerTitle = UILabel()
    
    override init(reuseIdentifier: String?) {
        super.init(reuseIdentifier: reuseIdentifier)
        addSubview(headerTitle)
        headerTitle.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
        headerTitle.text = "헤더"
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

 

 

String+

extension String {
    static func createRandomString(length: Int) -> String {
        let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
        return String(
            (0..<length)
                .map { _ in letters.randomElement()! }
        )
    }
}

 

(참고)
https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/updating_collection_views_using_diffable_data_sources

 

Apple Developer Documentation

 

developer.apple.com