본문 바로가기

iOS 앱 개발자 프로젝트

[iOS] YZCenterFlowLayout 이해하기

앞서 만든 컬렉션 뷰 상단의 캐로셀 영역에서

UICollectionViewFlowLayout을 서브클래싱하여

중앙 정렬된 셀 레이아웃을 제공하는 YZCenterFlowLayout이라는 클래스를 살펴봅니다.

 

(아직 업데이트 중!)

 

출처:

https://github.com/yudiz-solutions/YZCenterFlowLayout

 

 

셀 사이의 간격, 스크롤 목표지점 등 수정을 해야합니다.. 

 

[수정내용]

  → func scrollToPage 현재 필요 없으므로 삭제

  → 셀 사이의 간격은 CollectionViewCell에서 아래 spacingMode 숫자를 수정하여 재설정

layout.spacingMode = .fixed(spacing: -5)

 

1. 열거형 정의

두 개의 열거형 정의:

아이템 간의 간격 설정 + 애니메이션 모드 설정

// 아이템 간의 간격을 설정하는 열거형
enum YZCenterFlowLayoutSpacingMode {
    case fixed(spacing: CGFloat) // 고정 간격
    case overlap(visibleOffset: CGFloat) // 겹치는 간격 (아이템이 겹치도록)
}

// 애니메이션 모드를 설정하는 열거형
enum YZCenterFlowLayoutAnimation {
    case rotation(sideItemAngle: CGFloat, sideItemAlpha: CGFloat, sideItemShift: CGFloat) // 회전 애니메이션
    case scale(sideItemScale: CGFloat, sideItemAlpha: CGFloat, sideItemShift: CGFloat) // 스케일 애니메이션
}

 

 

2. YZCenterFlowLayout 클래스 정의

UICollectionViewFlowLayout 상속받아 YZCenterFlowLayout 클래스를 정의

 

※ 여기서, .fixed(spacing) 값을 수정하거나, fixed(spacing) → .overlap(visibleOffset: int)  int 값 수정 시  셀 간격 조정

class YZCenterFlowLayout: UICollectionViewFlowLayout {
    
    // 레이아웃 상태 구조체
    fileprivate struct LayoutState {
        var size: CGSize
        var direction: UICollectionView.ScrollDirection
        
        // 현재 상태와 다른 상태를 비교하는 함수
        func isEqual(_ otherState: LayoutState) -> Bool {
            return self.size.equalTo(otherState.size) && self.direction == otherState.direction
        }
    }
    
    // 레이아웃 상태와 기타 속성 정의
    fileprivate var state = LayoutState(size: CGSize.zero, direction: .horizontal)
    var spacingMode = YZCenterFlowLayoutSpacingMode.fixed(spacing: 0)
    var animationMode = YZCenterFlowLayoutAnimation.scale(sideItemScale: 0.7, sideItemAlpha: 0.6, sideItemShift: 0.0)
    
    // 페이지 폭을 계산하는 계산 프로퍼티
    fileprivate var pageWidth: CGFloat {
        switch self.scrollDirection {
        case .horizontal:
            return self.itemSize.width + self.minimumLineSpacing
        case .vertical:
            return self.itemSize.height + self.minimumLineSpacing
        default:
            return 0.0
        }
    }
    
    /// 현재 중앙에 정렬된 페이지의 인덱스를 계산하는 프로퍼티
    var currentCenteredIndexPath: IndexPath? {
        guard let collectionView = self.collectionView else { return nil }
        let currentCenteredPoint = CGPoint(x: collectionView.contentOffset.x + collectionView.bounds.width/2, y: collectionView.contentOffset.y + collectionView.bounds.height/2)
        return collectionView.indexPathForItem(at: currentCenteredPoint)
    }
    
    // 현재 중앙에 있는 페이지의 인덱스를 반환하는 프로퍼티
    var currentCenteredPage: Int? {
        return currentCenteredIndexPath?.row
    }
}

 

 

3. prepare() 메서드: 컬렉션 뷰의 상태를 설정

override func prepare() {
    super.prepare()
    guard let collectionView = self.collectionView else { return }
    let currentState = LayoutState(size: collectionView.bounds.size, direction: self.scrollDirection)
    
    if !self.state.isEqual(currentState) {
        self.setupCollectionView()
        self.updateLayout()
        self.state = currentState
    }
}

 

 

4. 레이아웃 변경에 대한 무효화 설정: 경계가 변경될 때 레이아웃을 무효활 할 것인가? 여부 결정

override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
    return true
}

 

 

5. 특정 영역의 레이아웃 속성 반환: 지정된 사각형 내의 모든 레이아웃 속성을 리턴

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    guard let superAttributes = super.layoutAttributesForElements(in: rect),
          let attributes = NSArray(array: superAttributes, copyItems: true) as? [UICollectionViewLayoutAttributes]
    else { return nil }
    return attributes.map({ self.transformLayoutAttributes($0) })
}

 

 

6. 스크롤 목표 지점 설정:  스크롤이 멈출 목표 지점을 계산 (← 스무스하게 수평 스크롤 되도록 수정할 때 적용)

override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
    guard let collectionView = collectionView , !collectionView.isPagingEnabled,
          let layoutAttributes = self.layoutAttributesForElements(in: collectionView.bounds)
    else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset) }
    
    let isHorizontal = (self.scrollDirection == .horizontal)
    
    let midSide = (isHorizontal ? collectionView.bounds.size.width : collectionView.bounds.size.height) / 2
    let proposedCenterOffset = (isHorizontal ? proposedContentOffset.x : proposedContentOffset.y) + midSide
    
    var targetContentOffset: CGPoint
    if isHorizontal {
        let closest = layoutAttributes.sorted { abs($0.center.x - proposedCenterOffset) < abs($1.center.x - proposedCenterOffset) }.first ?? UICollectionViewLayoutAttributes()
        targetContentOffset = CGPoint(x: floor(closest.center.x - midSide), y: proposedContentOffset.y)
    } else {
        let closest = layoutAttributes.sorted { abs($0.center.y - proposedCenterOffset) < abs($1.center.y - proposedCenterOffset) }.first ?? UICollectionViewLayoutAttributes()
        targetContentOffset = CGPoint(x: proposedContentOffset.x, y: floor(closest.center.y - midSide))
    }
    
    return targetContentOffset
}

 

 

7. 페이지로 스크롤하는 메서드: 특정 인덱스의 페이지로 스크롤 (프로그래밍 방식)  (← 필요 없으므로 삭제 )

func scrollToPage(atIndex index: Int, animated: Bool = true) {
    guard let collectionView = self.collectionView else { return }
    
    let proposedContentOffset: CGPoint
    let shouldAnimate: Bool
    
    switch scrollDirection {
    case .horizontal:
        let pageOffset = CGFloat(index) * self.pageWidth - collectionView.contentInset.left
        proposedContentOffset = CGPoint(x: pageOffset, y: collectionView.contentOffset.y)
        shouldAnimate = abs(collectionView.contentOffset.x - pageOffset) > 1 ? animated : false
        collectionView.setContentOffset(proposedContentOffset, animated: shouldAnimate)
    case .vertical:
        let pageOffset = CGFloat(index) * self.pageWidth - collectionView.contentInset.top
        proposedContentOffset = CGPoint(x: collectionView.contentOffset.x, y: pageOffset)
        shouldAnimate = abs(collectionView.contentOffset.y - pageOffset) > 1 ? animated : false
        collectionView.setContentOffset(proposedContentOffset, animated: shouldAnimate)
    default:
        print("Default Case...")
    }
}

 

 

8. 프라이빗 메서드: 컬렉션 뷰를 설정하고 레이아웃을 업데이트 

 

※ 여기 updateLayout() 메서드 내에서 minimumLineSpacing 값을 직접 조정해서..  minimumLineSpacing 값을 설정하는 로직을 수정하여 셀 사이의 간격을 조정해 보기 시도 →  실패 

 

        // 추가한 코드
        let desiredSpacing: CGFloat = 10 // 원하는 간격 값으로 설정!
private extension YZCenterFlowLayout {
    
    // 컬렉션 뷰를 설정하는 메서드
    func setupCollectionView() {
        guard let collectionView = self.collectionView else { return }
        if collectionView.decelerationRate != UIScrollView.DecelerationRate.fast {
            collectionView.decelerationRate = UIScrollView.DecelerationRate.fast
        }
    }
    
    // 레이아웃을 업데이트하는 메서드
    func updateLayout() {
        guard let collectionView = self.collectionView else { return }
        
        let collectionSize = collectionView.bounds.size
        let isHorizontal = (self.scrollDirection == .horizontal)
        
        let yInset = (collectionSize.height - self.itemSize.height) / 2
        let xInset = (collectionSize.width - self.itemSize.width) / 2
        self.sectionInset = UIEdgeInsets.init(top: yInset, left: xInset, bottom: yInset, right: xInset)
        
        let side = isHorizontal ? self.itemSize.width : self.itemSize.height
        var scale: CGFloat = 1.0
        switch animationMode {
        case .scale(let sideItemScale, _, _):
            scale = sideItemScale
        default:
            break
        }
        let scaledItemOffset =  (side - side * scale) / 2
        
        switch self.spacingMode {
        case .fixed(let spacing):
            self.minimumLineSpacing = spacing - scaledItemOffset
        case .overlap(let visibleOffset):
            let fullSizeSideItemOverlap = visibleOffset + scaledItemOffset
            let inset = isHorizontal ? xInset : yInset
            self.minimumLineSpacing = inset - fullSizeSideItemOverlap
        }
    }
    
    // 레이아웃 속성을 변형하는 메서드
    func transformLayoutAttributes(_ attributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
        guard let collectionView = self.collectionView else { return attributes }
        
        let isHorizontal = (self.scrollDirection == .horizontal)
        
        let collectionCenter: CGFloat = isHorizontal ? collectionView.frame.size.width/2 : collectionView.frame.size.height/2
        let offset = isHorizontal ? collectionView.contentOffset.x : collectionView.contentOffset.y
        let normalizedCenter = (isHorizontal ? attributes.center.x : attributes.center.y