Swift

[Swift] 좌우 무한 collectionView 를 만들어 보자

웰코발 2024. 1. 5. 20:31

좌우로 무한적인 스크롤이 가능한 콜렉션 뷰를 만들어보자.

 

우선 콜렉션 뷰의 셀들을 나열할 실제 데이터 리스트가 11개라고 치자.

 

그럼 양 옆에 그 리스트 11개를 복제하여 양옆에 두면 첫번째 데이터에서 -1이 될때 데이터 리스트의 마지막으로 넘어가게 된다.

-1이 된 상태에서 애니메이팅을 false로 하여 실제 열번째 셀(11+11)으로 이동하게 하면 눈속임이 완성된다.

말로하면 어려우니 하단을 봐보자..

 

ex) 

0 1 2 3 4 5 6 7 8 9 10 0 1 2 3 4 5 6 7 8 9 10 0 1 2 3 4 5 6 7 8 9 10 

 

이런식의 데이터가 있으면 빨간색이 실체라 하고 다른색은 눈속임용이라 생각하자.

 

빨간색 0 에서 -1 이 되면 10으로 넘어가고 10에서 애니메이팅을 멈추면 빨간색 10으로 순간이동 시켜준다.

 

반대로

 

빨간색 10에서 +1 이 되면 0으로 넘어가고 0에서 애니메이팅을 멈추면 빨간색 0으로 순간이동 시켜준다.

 

이것을 잘 살려서 눈속임으로 만들어 보자.

 

만약에 스크롤링의 decelerate로 인해 양 끝에서 막힌다면 양 끝에 더더욱 많은 데이터들을 만들어 주면 되는 부분이다.

 

구현을 해보자.

import Foundation
import UIKit
import SnapKit


class InfiniteListView: UIView {

    let headerLbl = UILabel()
    
    let moreBtn = UIButton()
    
    let collectionView: UICollectionView = {
        // 커스텀 UICollectionViewFlowLayout
        let flowLayout = HightlightListViewFlowLayout()
        let cv = UICollectionView(frame: .zero, collectionViewLayout: flowLayout)
        // 스크롤 감속 속도
        cv.decelerationRate = .fast
        
        cv.showsHorizontalScrollIndicator = false
        return cv
    }()
    
    let leftBtn = UIButton()
    
    let rightBtn = UIButton()
    
//    let pageControl = UIPageControl()
    
    var deviceSize: CGSize = .zero
    
    // 가짜 데이터 배수 사이즈
    var scrollTempSize = 10000
    
    // 실제 현재 페이지
    var currentPage: Int = 0
    
    var dataList: [Data] = [Data()]
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        deviceSize = UIScreen.current?.bounds.size ?? .zero
        
        viewConfigure()
        
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    deinit {

    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        cellLocationInit()
        constraintConfigure()
    }
    
    private func cellLocationInit() {
	// 양 옆에 복제된 가짜 데이터를 두고 가짜데이터를 포함한 리스트의 정가운데에 위치시킴
        collectionView.scrollToItem(at: IndexPath(row: dataList.count * scrollTempSize/2, section: 0), at: .centeredHorizontally, animated: false)
        currentPage = dataList.count * scrollTempSize/2
    }
    
    private func viewConfigure() {
        
        headerLbl.text = "테스트"
        headerLbl.textColor = .black
        headerLbl.font = .systemFont(ofSize: 20, weight: .bold)
        self.addSubview(headerLbl)
        
        collectionView.backgroundColor = .clear
        self.addSubview(collectionView)
        
        collectionView.dataSource = self
        collectionView.delegate = self
        collectionView.contentInsetAdjustmentBehavior = .always
        collectionView.register(ImgCollectionCell.self, forCellWithReuseIdentifier: ImgCollectionCell.reuseIdentifier)
        
        leftBtn.setImage(UIImage(named: "icon_home_list_left_btn"), for: .normal)
        leftBtn.addTarget(self, action: #selector(btnOnClick(_:)), for: .touchUpInside)
        self.addSubview(leftBtn)
        
        rightBtn.setImage(UIImage(named: "icon_home_list_right_btn"), for: .normal)
        rightBtn.addTarget(self, action: #selector(btnOnClick(_:)), for: .touchUpInside)
        self.addSubview(rightBtn)

//        pageControl.isHidden = true
//        pageControl.numberOfPages = colors.count
//        pageControl.pageIndicatorTintColor = .red
//        pageControl.addTarget(self, action: #selector(pageValueChanged(_:)), for: .valueChanged)
//        pageControl.backgroundColor = .blue
//        self.addSubview(pageControl)
        
    }
    
    private func constraintConfigure() {

        
        headerLbl.snp.makeConstraints { make in
            make.top.equalToSuperview()
            make.leading.equalToSuperview().offset(20)
        }
        
        collectionView.snp.makeConstraints { make in
            make.top.equalTo(headerLbl.snp.bottom).offset(4)
            make.leading.trailing.equalToSuperview()
            make.height.equalTo(deviceSize.width)
            make.bottom.equalToSuperview()
            
        }
        
        leftBtn.snp.makeConstraints { make in
            make.centerY.equalToSuperview()
            make.leading.equalToSuperview().offset(28)
            make.width.height.equalTo(30)
        }
        
        rightBtn.snp.makeConstraints { make in
            make.centerY.equalToSuperview()
            make.trailing.equalToSuperview().offset(-28)
            make.width.height.equalTo(30)
        }
        
//        pageControl.snp.makeConstraints { make in
//            make.top.equalTo(collectionView.snp.bottom).offset(-16)
//            make.centerX.equalToSuperview()
//        }
        
    }
    
    func configureDataList(dataList: [Data]) {
        if dataList.count > 0 {
            self.dataList = dataList
            DispatchQueue.main.async {
                self.collectionView.reloadData()
            }
        }
        
        
    }
    

    @objc func btnOnClick(_ sender: UIButton) {
        switch sender {
        case leftBtn:
            currentPage = currentPage - 1
            log("currentPage1: \(self.currentPage)")
            let preIndexPath = IndexPath(row: currentPage, section: 0)
            DispatchQueue.main.async {
                self.collectionView.scrollToItem(at: preIndexPath, at: .centeredHorizontally, animated: true)
            }
            break
        case rightBtn:
            currentPage = currentPage + 1
            log("currentPage2: \(self.currentPage)")
            let nextIndexPath = IndexPath(row: currentPage, section: 0)
            DispatchQueue.main.async {
                self.collectionView.scrollToItem(at: nextIndexPath, at: .centeredHorizontally, animated: true)
            }
            break
        default:
            break
        }
    }
    
    func showOptionBtn() {
        
        UIView.animate(
            withDuration: 0.1,
            delay: 0,
            options: .curveEaseInOut,
            animations: { [weak self] in
                guard let self = self else { return }
                self.leftBtn.alpha = 1.0
                self.rightBtn.alpha = 1.0
            },
            completion: { [weak self] _ in
                guard let self = self else { return }
                self.leftBtn.isHidden = false
                self.rightBtn.isHidden = false
                
            })
        
    }
    func hideOptionBtn() {

        UIView.animate(
            withDuration: 0.1,
            delay: 0,
            options: .curveEaseInOut,
            animations: { [weak self] in
                guard let self = self else { return }
                self.leftBtn.alpha = 0.0
                self.rightBtn.alpha = 0.0
            },
            completion: { [weak self] _ in
                guard let self = self else { return }
                self.leftBtn.isHidden = true
                self.rightBtn.isHidden = true
                
            })
    }
    

}

 

 

extension InfiniteListView: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        // 무한 스크롤을 위한 왼쪽 오른쪽 배열을 더 만들어줌 (눈속임용)
        return homeTerrianDataList.count * scrollTempSize
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeTerrianCollectionCell.reuseIdentifier, for: indexPath) as! HomeTerrianCollectionCell

        cell.configureData(data: homeTerrianDataList[indexPath.item % homeTerrianDataList.count])
        
        return cell
    }
    
    
}

extension InfiniteListView: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        // 보여지는 셀들
        // 왼쪽부터 0~2 인덱스
        let visibleIndexPathList = collectionView.indexPathsForVisibleItems.sorted()

        // 보여지는 셀 개수가 3개일경우만 클릭 모션 활성화
	// 2개인 경우는 이미지 스와이프를 절반만 해논상태에서 클릭할 경우임.
        if visibleIndexPathList.count == 3 {
            // 클릭한 셀이
            switch indexPath {
            // 왼쪽 혹은 오른쪽 일 경우
            case visibleIndexPathList[0], visibleIndexPathList[2]:
                // 처음 혹은 마지막 부분일 경우 0.3초 이후에 다시 가운데 배열로 재배열 하도록 함. (눈속임)
                self.collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
                break
            // 가운데(현재 셀) 일 경우
            case visibleIndexPathList[1]:
		// 현재 셀 클릭 액션 취하기
		 …..
                break
            default:
                break
            }
        
        } else {
	    // 스와이핑 후 애니메이션 동작을 마무리 하도록 만들어줌.
            self.collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
        }
        
        
    }
    
    // 스크롤 중일 때 호출
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        // 보여지는 스크롤 뷰의 정 가운데의 x,y 값
        let xPoint = scrollView.contentOffset.x + scrollView.frame.width / 2
        let yPoint = scrollView.frame.height / 2
        let center = CGPoint(x: xPoint, y: yPoint)
        // 정 가운데 셀의 인덱스
        if let indexPath = collectionView.indexPathForItem(at: center) {
            // 페이지컨트롤 지정
//            self.pageControl.currentPage = ip.row % colors.count
            self.currentPage = indexPath.row
        }
        
    }
    
    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        self.hideOptionBtn()
    }
    
    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        self.showOptionBtn()
    }
    
    // 스크롤 끝마친 후 호출
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        // 보여지는 스크롤 뷰의 정 가운데의 x,y 값
        let xPoint = scrollView.contentOffset.x + scrollView.frame.width / 2
        let yPoint = scrollView.frame.height / 2
        let center = CGPoint(x: xPoint, y: yPoint)
        // 정 가운데 셀의 인덱스
        if let row = collectionView.indexPathForItem(at: center)?.row {
            // 가운데 배열의 인덱스로 다시 눈속임으로 스크롤링하게끔 함 (무한스크롤을 위함)
            collectionView.scrollToItem(at: IndexPath(item: row, section: 0), at: .centeredHorizontally, animated: false)
            self.currentPage = row
        }


    }
}

 

 

 

HightlightListViewFlowLayout 의 경우는 해당 설명은 나의 다른 글에 있다. 최 하단 링크를 참고해보자.


import Foundation
import UIKit

class HightlightListViewFlowLayout: UICollectionViewFlowLayout {
    
    let activeDistance: CGFloat = 0
    // 하이라이트 된 셀 크기 확대 비율
    let zoomFactor: CGFloat = 0.0

    var deviceSize: CGSize = .zero
    
    override init() {
        super.init()
        deviceSize = UIScreen.current?.bounds.size ?? .zero
        // 가로 방향 스크롤링
        scrollDirection = .horizontal
        // 셀 간의 간격
        minimumLineSpacing = 10
        // 셀 사이즈
        itemSize = CGSize(width: deviceSize.width - 40, height: deviceSize.width - 40)
        
        
    }

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

    // CollectionView가 해당 콘텐츠를 처음으로 표시할 때와 view가 변경되어 레이아웃이 무효화 될때 발생
    // 콜렉션뷰의 사이즈 및 item의 위치 등을 결정하기 위한 초기 계산 등을 수행
    // 즉 초기에 collectionview의 레이아웃을 설정할 때 호출
    override func prepare() {
        guard let collectionView = collectionView else { fatalError() }
        let verticalInsets = (collectionView.frame.height - collectionView.adjustedContentInset.top - collectionView.adjustedContentInset.bottom - itemSize.height) / 2
        let horizontalInsets = (collectionView.frame.width - collectionView.adjustedContentInset.right - collectionView.adjustedContentInset.left - itemSize.width) / 2
        
        // 각 라인별 cell의 수를 제한 할 수 있도록 inset을 지정해줌
        sectionInset = UIEdgeInsets(top: verticalInsets, left: horizontalInsets, bottom: verticalInsets, right: horizontalInsets)

        super.prepare()
    }
    
    // CollectionView안의 모든 요소에 대한 Layout요소들을 리턴함.
    // 현재 기준 보여지는 요소들만 해당함.
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        guard let collectionView = collectionView else { return nil }
        let rectAttributes = super.layoutAttributesForElements(in: rect)!.map { $0.copy() as! UICollectionViewLayoutAttributes }
        let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.frame.size)

        // Make the cells be zoomed when they reach the center of the screen
        for attributes in rectAttributes where attributes.frame.intersects(visibleRect) {
            let distance = visibleRect.midX - attributes.center.x
            let normalizedDistance = distance / activeDistance

            if distance.magnitude < activeDistance {
                let zoom = 1 + zoomFactor * (1 - normalizedDistance.magnitude)
                attributes.transform3D = CATransform3DMakeScale(zoom, zoom, 1)
                attributes.zIndex = Int(zoom.rounded())
            }
        }

        return rectAttributes
    }
    
    // 스크롤 시 스크롤이 중지되는 지점을 변경 할 수 있는 메서드
    // proposedContentOffset: 스크롤이 자연스럽게 중지되는 값, velocity: 스크롤 속도
    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
        guard let collectionView = collectionView else { return .zero }
        
        // Add some snapping behaviour so that the zoomed cell is always centered
        let targetRect = CGRect(x: proposedContentOffset.x, y: 0, width: collectionView.frame.width, height: collectionView.frame.height)
        guard let rectAttributes = super.layoutAttributesForElements(in: targetRect) else { return .zero }

        var offsetAdjustment = CGFloat.greatestFiniteMagnitude
        let horizontalCenter = proposedContentOffset.x + collectionView.frame.width / 2

        for layoutAttributes in rectAttributes {
            let itemHorizontalCenter = layoutAttributes.center.x
            if (itemHorizontalCenter - horizontalCenter).magnitude < offsetAdjustment.magnitude {
                offsetAdjustment = itemHorizontalCenter - horizontalCenter
            }
        }
        
        return CGPoint(x: proposedContentOffset.x + offsetAdjustment, y: proposedContentOffset.y)
    }
    
    // Bounds에 변화가 있을때 마다, 함수를 호출할 지 결정함. -> InvalidateLayout(with:)을 호출.
    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        // Invalidate layout so that every cell get a chance to be zoomed when it reaches the center of the screen
        return true
    }

    override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
        let context = super.invalidationContext(forBoundsChange: newBounds) as! UICollectionViewFlowLayoutInvalidationContext
        // invalidate된 셀이 크기를 재설정 해야할경우 No가 default, Yes일 경우 다시 계산
        context.invalidateFlowLayoutDelegateMetrics = newBounds.size != collectionView?.bounds.size
        return context
    }
    
}

 

 

 

 

https://seonghooony.tistory.com/40

 

[Swift] 하이라이트 슬라이드 뷰 만들기 (UICollectionView)

스크롤 시 가운데 셀이 하이라이트 되고 양 옆 셀을 클릭 시 스크롤 되는 커스텀 콜렉션 뷰를 만들었다. flowLayout을 커스텀하는데 있어 이해하는 시간이 좀 필요했다. 3D적으로도 생각하다보니 더

seonghooony.tistory.com