Notice
Recent Posts
Recent Comments
Link
๊ด€๋ฆฌ ๋ฉ”๋‰ด

lgvv98

[iOS] infinite carousel DiffableDataSource + CompositionalLayout ๋ณธ๋ฌธ

apple/๐Ÿ“š Apple Docs & iOS & Swift

[iOS] infinite carousel DiffableDataSource + CompositionalLayout

๐Ÿฅ• ์บ๋Ÿฟ๋งจ 2024. 4. 16. 01:35

[iOS] infinite carousel DiffableDataSource + CompositionalLayout

 

UIKit์—์„œ DiffableDataSource๊ณผ compositonalLayout์„ ์‚ฌ์šฉํ•˜์—ฌ ๊ตฌํ˜„ํ•ด๋ณด์ž.

์ „์ฒด ์†Œ์Šค์ฝ”๋“œ์™€ ํ•ด๋‹น ์ฝ”๋“œ์— ๋Œ€ํ•œ ์ฃผ์„์„ ํฌ์ŠคํŒ… ์ œ์ผ ํ•˜๋‹จ์— ์ „๋ถ€ ๋„ฃ์–ด๋‘์—ˆ์Œ.

 

์˜ˆ์ œ ์˜์ƒ

์˜ˆ์ œ ์˜์ƒ


๋ฌดํ•œํžˆ ํšŒ์ „ํ•˜๋Š” ์Šคํฌ๋กค ๋ทฐ์— ๋Œ€ํ•œ ๊ตฌํ˜„ ์•„์ด๋””์–ด๋Š” ์ด๋ฏธ ๋งŽ์ด ์กด์žฌํ•˜๋‚˜, ํ•ด๋‹น ํฌ์ŠคํŒ…์—์„œ๋Š” diffableDataSource์˜ ํŠน์„ฑ์— ๋งž๊ฒŒ๋” ์ ์šฉ

 

(๊ตฌํ˜„ ๋ฐฉ์‹)

[์•ž์— ๋ถ™๋Š” id๋งŒ ๋‹ค๋ฅธ ์›๋ณธ] + [์›๋ณธ] + [๋’ค์— ๋ถ™๋Š” id๋งŒ ๋‹ค๋ฅธ ์›๋ณธ]์„ ๋จผ์ € ๊ตฌ์„ฑํ•œ ํ›„ ํ•œ๋ฒˆ์— apply

 

diffable์˜ ๊ฒฝ์šฐ์—๋Š” ๋ฐ์ดํ„ฐ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ์„ ๋•Œ, snapshot์„ ์ˆ˜์ •ํ•˜๋Š”๊ฒŒ ์•„๋‹Œ ๋‹ค์‹œ ์ฐ์Œ.

ํ•ด๋‹น ๋ถ€๋ถ„์€ iOS 13, 14, 15์— ๋”ฐ๋ผ์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ๋ฉ”์†Œ๋“œ ๋“ฑ์˜ ์ฐจ์ด๊ฐ€ ์žˆ์œผ๋ฏ€๋กœ ์• ํ”Œ ๋‹คํ๋จผํŠธ ์ฐธ๊ณ .

 

์ „์ฒด ์˜ˆ์ œ ์ฝ”๋“œ

//
//  InfinityCarouselCollectionView.swift
//  DelegatePatternSwiftUI
//
//  Created by Geon Woo lee on 4/16/24.
//

import UIKit
import SwiftUI
import SnapKit

#Preview {
    TestViewController()
}

class TestViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
    private var originalItems: [MockModel] = [
        .init(num: 1, color: .red),
        .init(num: 2, color: .orange),
        .init(num: 3, color: .yellow)
    ]
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        1
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: InfinityCarouselContainerCell.id, for: indexPath) as! InfinityCarouselContainerCell
        
        cell.configure(originalItems)
        
        return cell
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        .init(width: UIScreen.main.bounds.width, height: 124)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.addSubview(collectionView)
        collectionView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
    }
    
    lazy var collectionView: UICollectionView = {
        let layout = UICollectionViewFlowLayout()
        let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
        cv.register(InfinityCarouselContainerCell.self, forCellWithReuseIdentifier: InfinityCarouselContainerCell.id)
        cv.dataSource = self
        cv.delegate = self
        return cv
    }()
}

/// InfinityCarouselCell ์…€
final class InfinityCarouselContainerCell: UICollectionViewCell {
    static let id: String = String(describing: InfinityCarouselContainerCell.self)
    
    func configure(_ originalItems: [MockModel]) {
        collectionView.configure(originalItems)
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        contentView.addSubview(collectionView)
        collectionView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private let collectionView = InfinityCarouselCollectionView()
}

struct MockModel: Hashable {
    var id = UUID()
    
    var num: Int
    var color: UIColor
}

final class InfinityCarouselCollectionView: UICollectionView {
    typealias DiffableDataSource = UICollectionViewDiffableDataSource<Section, MockModel>
    typealias Snapshot = NSDiffableDataSourceSnapshot<Section, MockModel>

    /// ์„น์…˜
    enum Section {
        case main
    }

    /// ๋””ํผ๋ธ” ๋ฐ์ดํ„ฐ ์†Œ์Šค
    private var diffableDataSource: DiffableDataSource?
    /// ์˜ค๋ฆฌ์ง€๋„ ์•„์ดํ…œ
    private var originalItems: [MockModel] = []

    func configure(_ originalItems: [MockModel]) {
        self.originalItems = originalItems
        
        // โœ… ์•„๋ž˜ ๋‘ ํ•จ์ˆ˜ ํ˜ธ์ถœ ์ˆœ์„œ์— ์ฃผ์˜
        configureDiffableDataSource()
        updateSnapshot()
    }
    
    /// ์ปฌ๋ ‰์…˜ ๋ทฐ์— ๋“ค์–ด๊ฐ€๋Š” ๋ฐ์ดํ„ฐ์†Œ์Šค๋ฅผ ๊ตฌ์„ฑ
    private func configureDiffableDataSource() {
        diffableDataSource = .init(collectionView: self) { collectionView, indexPath, itemIdentifier -> UICollectionViewCell? in
            guard let cell = collectionView.dequeueReusableCell(
                withReuseIdentifier: BannerCell.id,
                for: indexPath
            ) as? BannerCell else { return UICollectionViewCell() }
            
            // โœ… ์•ž๋’ค๋กœ 3๋ฐฐ ํ–ˆ์œผ๋ฏ€๋กœ ์ด๋ ‡๊ฒŒ ์ ์šฉ.
            let item = self.originalItems[indexPath.row % 3]
            cell.configure(item, indexPath)
            
            return cell
        }
    }
    
    /// ์Šค๋ƒ…์ƒท ์—…๋ฐ์ดํŠธ
    private func updateSnapshot() {
        var snapshot = Snapshot()
        snapshot.appendSections([.main])
        
        // โœ… ํ™•์žฅ๋œ ์•„์ดํ…œ์— ๋ฐฐ์—ด์„ ๋„ฃ๊ณ 
        var extendedItems = originalItems
        
        for i in 0..<(originalItems.count * 2) {
            let item = originalItems[i % 3]
            // โœ… diffable์˜ ํŠน์„ฑ์ƒ id๊ฐ€ ๊ฒน์น˜๋ฉด ์•ˆ๋˜๋ฏ€๋กœ ์ƒˆ๋กœ์šด ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•ด์„œ ๋„ฃ์–ด์คŒ.
            // โœ… ๋ชจ๋ธ์ด ํฐ ๊ฒฝ์šฐ์—๋Š” id๊ฐ’๋งŒ ๋ฐ”๊พธ๋Š” ์ „๋žต ์ด์šฉ.
            extendedItems.append(MockModel(num: item.num, color: item.color))
        }
        
        snapshot.appendItems(extendedItems)
        
        self.diffableDataSource?.apply(snapshot, animatingDifferences: true) { [weak self] in
            guard let self else { return }
            
            // โœ… ์ฒ˜์Œ์— collectionView๊ฐ€ ๋‚˜ํƒ€๋‚  ๋•Œ, ํ•  ๋•Œ ์ขŒ์ธก์— ์•„์ดํ…œ์ด ํ•œ๊ฐœ ์ด์ƒ์€ ์žˆ์–ด์•ผ ํ•˜๋ฏ€๋กœ ์Šคํฌ๋กค ์ˆ˜ํ–‰
            self.scrollToItem(at: [0, self.originalItems.count],
                              at: .left,
                              animated: false)
            // ์ปฌ๋ ‰์…˜ ๋ทฐ๊ฐ€ ๊ทธ๋ ค์ง€๊ณ  ๋‚œ ํ›„์— ๋ถˆ๋ฆผ.
        }
    }
    
    /// โœ… ๋ฌดํ•œ ์Šคํฌ๋กค์„ ์œ„ํ•ด visibleItemsInvalidationHandler์—์„œ indexPath๋ฅผ ์กฐ์ •
    private func handleVisibleItemIndexPath(_ indexPath: IndexPath) {
        switch indexPath.row {
            case self.originalItems.count - 1:
            self.scrollToItem(at: [0, self.originalItems.count * 2 - 1], at: .left, animated: false)
            case self.originalItems.count * 2 + 1:
            self.scrollToItem(at: [0, self.originalItems.count], at: .left, animated: false)
            default:
            break
        }
    }
    
    override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
        super.init(frame: frame, collectionViewLayout: .init())
        self.collectionViewLayout = createLayout()
        self.register(BannerCell.self, forCellWithReuseIdentifier: BannerCell.id)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    /// ๋ ˆ์ด์•„์›ƒ
    private func createLayout() -> UICollectionViewCompositionalLayout {
        return UICollectionViewCompositionalLayout { (sectionNumber, env) -> NSCollectionLayoutSection? in
            let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1.0),
                                                                heightDimension: .fractionalHeight(1.0)))
            item.contentInsets = .init(top: 0, leading: 10, bottom: 0, trailing: 10)
            
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(0.9),
                                                                             heightDimension: .fractionalHeight(1.0)),
                                                           subitems: [item])
            
            let section = NSCollectionLayoutSection(group: group)
            section.orthogonalScrollingBehavior = .groupPagingCentered
            section.contentInsets = .init(top: 12, leading: 0, bottom: 12, trailing: 0)
            
            section.visibleItemsInvalidationHandler = { [weak self] (visibleItems, offset, environment) in
                // โœ… UIScrollViewDelegate์˜ didScroll๊ฐ€ ์•„๋‹Œ ํ•ด๋‹น ์˜์—ญ์—์„œ ์ฒ˜๋ฆฌํ•ด์•ผ ํ•จ.
                guard let self else { return }
                
                // visibleItems ์ค‘ ๊ฐ€์žฅ ๋งˆ์ง€๋ง‰ indexPath๋ฅผ ์ฐพ์•„์„œ ๋ฏธ๋ฆฌ ์Šคํฌ๋กค ํ•ด๋‘ฌ์•ผ ์• ๋‹ˆ๋ฉ”์ด์…˜ ๋Š๊ธฐ๋Š” ๋ฌธ์ œ ์—†์Œ.
                guard let lastIndexPath = visibleItems.last?.indexPath else { return }
                self.handleVisibleItemIndexPath(lastIndexPath)
            }
            
            return section
        }
    }
    
    final class BannerCell: UICollectionViewCell {
        static let id: String = String(describing: BannerCell.self)
        
        func configure(_ model: MockModel, _ indexPath: IndexPath) {
            contentView.backgroundColor = model.color
            contentView.layer.cornerRadius = 16
            titleLabel.text = """
            ์•„์ดํ…œ์˜ ์ˆซ์ž \(model.num)
            ์ธ๋ฑ์Šค ํŒจ์Šค \(indexPath)
            """
        }
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            
            configureUI()
        }
        
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        
        private lazy var titleLabel: UILabel = {
            var lb = UILabel()
            lb.textAlignment = .center
            lb.numberOfLines = 2
            lb.font = .systemFont(ofSize: 22, weight: .medium)
            return lb
        }()
        
        private func configureUI() {
            contentView.addSubview(titleLabel)
            titleLabel.snp.makeConstraints {
                $0.edges.equalToSuperview()
            }
        }
    }
}

 

 

Comments