티스토리 뷰

오늘은 아래와 같은 컬렉션 뷰를 구현하는 방법을 알려드리려고 합니다. 

 

참고! 제 글을 읽기전에 기초적인 UICollectionView에 대한 이해를 필요로 합니다! 

[iOS] UICollectionView 기초 정리(1), [iOS] UICollectionView 기초 정리(2)를 보고 와 주세요. 

본격적인 시작 전에, UICollectionView와 UICollectionViewLayout이 어떻게 상호작용 하는지에 대해 짚고 넘어갈 필요가 있습니다.

UICollectionView는 CollectionViewLayout을 기반으로 뷰를 그리게 되는데, 레이아웃이 업데이트될 때마다 컬렉션 뷰는 레이아웃에 아이템(셀)에 대한 사이즈 및 속성들에 대한 정보를 요청합니다. 그럼 레이아웃은 그 정보들을 컬렉션 뷰에 알려주고, 컬렉션 뷰는 그 정보들을 기반으로 업데이트된 레이아웃을 그리게 되는 것이죠. 

아래의 메서드는 우리가 carousel effect를 주기 위해 알고 있어야하는 것들입니다.

  • prepare() - 처음 컬렉션 뷰가 나타날 때 호출되거나 레이아웃을 명시적 혹은 암묵적으로 무효화했을 때 호출
  • shouldInvalidateLayout(forBoundsChange: ) - 레이아웃 객체에 레이아웃 업데이트가 필요한지 요청하는 메서드로 레이아웃 변화가 필요할 경우 true를 반환하고 그렇지 않을 경우 false를 반환하면 됩니다. -> 기본 값은 false로, 우리가 true로 반환해 주지 않으면 prepare() 메서드는 처음 딱 한 번만 호출됩니다. 우리는 매번 레이아웃 업데이트가 필요하기에 true로 설정!
  • layoutAttributesForElements(in: ) - 모든 셀과 뷰에 대한 레이아웃 속성을  UICollectionViewLayoutAttributes 배열로 반환합니다.

 

이제 어떻게 설계(구현)를 할지 생각을 해야 하는데, 접근 방법은 이렇습니다. 

사용자가 컬렉션 뷰를 스크롤할 때 컬렉션 뷰의 가운데 축을 중심으로 cell들이 offset 된 거리의 만큼을 비율 값으로 계산하여 스케일 값과 투명도를 조정하는 겁니다.

아직 글로만 읽었을 때는 감이 잘 안 오시죠?? 코드와 그림을 통해서 좀 더 자세히 살펴보져! 개념적 이해를 위해 뷰를 좀 단순화했습니다.

 

전체 코드

class CarouselLayout: UICollectionViewFlowLayout {
    
    public var sideItemScale: CGFloat = 0.5
    public var sideItemAlpha: CGFloat = 0.5
    public var spacing: CGFloat = 10

    public var isPagingEnabled: Bool = false
    
    private var isSetup: Bool = false
    
    override public func prepare() {
        super.prepare()
        if isSetup == false {
            setupLayout()
            isSetup = true
        }
    }
    
    private func setupLayout() {
        guard let collectionView = self.collectionView else {return}
                
        let collectionViewSize = collectionView.bounds.size
        
        let xInset = (collectionViewSize.width - self.itemSize.width) / 2
        let yInset = (collectionViewSize.height - self.itemSize.height) / 2
        
        self.sectionInset = UIEdgeInsets(top: yInset, left: xInset, bottom: yInset, right: xInset)
        
        let itemWidth = self.itemSize.width
        
        let scaledItemOffset =  (itemWidth - itemWidth*self.sideItemScale) / 2
        self.minimumLineSpacing = spacing - scaledItemOffset

        self.scrollDirection = .horizontal
    }
    
    public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        return true
    }
    
    public 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(attributes: $0) })
    }
    
    private func transformLayoutAttributes(attributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
        
        guard let collectionView = self.collectionView else {return attributes}
        
        let collectionCenter = collectionView.frame.size.width / 2
        let contentOffset = collectionView.contentOffset.x
        let center = attributes.center.x - contentOffset
        
        let maxDistance = self.itemSize.width + self.minimumLineSpacing
        let distance = min(abs(collectionCenter - center), maxDistance)

        let ratio = (maxDistance - distance)/maxDistance

        let alpha = ratio * (1 - self.sideItemAlpha) + self.sideItemAlpha
        let scale = ratio * (1 - self.sideItemScale) + self.sideItemScale
        
        attributes.alpha = alpha
        
        let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size)
        let dist = attributes.frame.midX - visibleRect.midX
        var transform = CATransform3DScale(CATransform3DIdentity, scale, scale, 1)
        transform = CATransform3DTranslate(transform, 0, 0, -abs(dist/1000))
        attributes.transform3D = transform
        
        return attributes
    }
}

 

코드 설명

    private func setupLayout() {
        guard let collectionView = self.collectionView else {return}
                
        let collectionViewSize = collectionView.bounds.size
        
        let xInset = (collectionViewSize.width - self.itemSize.width) / 2
        let yInset = (collectionViewSize.height - self.itemSize.height) / 2
        
        self.sectionInset = UIEdgeInsets(top: yInset, left: xInset, bottom: yInset, right: xInset)
        
        let itemWidth = self.itemSize.width
        
        let scaledItemOffset =  (itemWidth - itemWidth*self.sideItemScale) / 2
        self.minimumLineSpacing = spacing - scaledItemOffset

        self.scrollDirection = .horizontal
    }
    
    public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        return true
    }

먼저 prepare()가 처음으로 호출될 때 컬렉션 뷰에 대한 초기 설정을 하기 위해, setupLayout()이라는 함수를 만들었습니다. 여기서 섹션 인셋, 미니멈라인 스페이싱 등의 설정을 해줍니다. prepare는 사용자가 스크롤 시 매번 호출되기 때문에, isSetup이라는 프로퍼티를 만들어 초기에 딱 한 번만 호출되도록 하였습니다.

shouldInvalidateLayout(forBoundsChange: )의 경우 위에서도 설명했듯이 true로 반환 함으로써 사용자가 스크롤 시 prepare()를 통해 레이아웃 업데이트가 가능하게 끔 합니다. 

 

    public 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(attributes: $0) })
    }

layoutAttributesForElements(in: )는 모든 셀과 뷰에 대한 레이아웃 속성을  UICollectionViewLayoutAttributes 배열로 반환하는데, 우리는 이 속성을 변환해서 반환할 거기 때문에 고차 함수 map을 사용했습니다. 이 map에서 전달 인자로 받는 함수에 우리가 각 아이템들을 어떻게 변환시킬 것인지에 대한 내용이 들어가 있습니다.

 

    private func transformLayoutAttributes(attributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
        
        guard let collectionView = self.collectionView else {return attributes}
        
        let collectionCenter = collectionView.frame.size.width / 2
        let contentOffset = collectionView.contentOffset.x
        let center = attributes.center.x - contentOffset
        
        let maxDistance = self.itemSize.width + self.minimumLineSpacing
        let distance = min(abs(collectionCenter - center), maxDistance)

        let ratio = (maxDistance - distance)/maxDistance

        let alpha = ratio * (1 - self.sideItemAlpha) + self.sideItemAlpha
        let scale = ratio * (1 - self.sideItemScale) + self.sideItemScale
        
        attributes.alpha = alpha
        
        let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size)
        let dist = attributes.frame.midX - visibleRect.midX
        var transform = CATransform3DScale(CATransform3DIdentity, scale, scale, 1)
        transform = CATransform3DTranslate(transform, 0, 0, -abs(dist/1000))
        attributes.transform3D = transform
        
        return attributes
    }

이제 각 아이템(셀)들의 레이아웃 속성 변화를 담당할 transformLayoutAttributes입니다. 이해를 좀 더 쉽게 하기 위해 나름대로 자료를 만들어 봤는데, 도움이 될지 모르 겠네요. 그래도 그림과 같이 보면서 설명드리겠습니다. 

 

  • collectionCenter - 컬렉션 뷰의 중앙값으로 변하지 않는 고정 값입니다.
  • contentOffset - 사용자가 스크롤할 때 기준점으로부터 offset(이동한 거리)된 거리입니다. (x축) => 가변 값
  • center - 각 아이템(셀)들의 중앙값입니다. => 가변 값 

이제 위의 값들을 기반으로 하여 거리에 따른 비율을 계산하고 그 비율을 갖고서 alpha와 scale 값을 조정하는 공식을 만들게 됩니다. 즉 선형 방정식, 1차 방정식을 만드는 것이죠!  

let ratio = (maxDistance - distance)/maxDistance

비율을 구하기 위해서 maxDistance와 distnace를 사용했는데, maxDistance는 여기서 아이템 중앙과 아이템 중앙 사이의 거리를 의미하는 고정 값입니다. distance의 경우 maxDistance와 collectionCenter - center의 절대 값 중 더 작은 값을 의미합니다. 그래서 distance는 0~maxDistance 값을 갖게 됩니다. 

비율을 구하는 공식은 (maxDistance - distance)/maxDistance 인데, 여기서 distance가 0이면 비율은 1, distance가 maxDistance이면 비율은 0이 됩니다. 즉, maxDistance는 고정 값이기 때문에 가변 값인 distance값의 변화에 따라 비율은 0~1을 오가게 되는 것이죠!

이제 이 비율을 갖고 아이템의 스케일과 투명도를 조정하는 공식을 만들어 보겠습니다.

        let alpha = ratio * (1 - self.sideItemAlpha) + self.sideItemAlpha
        let scale = ratio * (1 - self.sideItemScale) + self.sideItemScale

공식이 이해가 되시나요? ratio가 1일 때는 alpha, scale이 1이 되는 것을 볼 수 있고 ratio가 0일 때는 우리가 설정한 sideItemAlpha 값과 sideItemScale이 되는 것을 볼 수 있습니다. 위의 그림에서 보면, 파란색 셀이 distance가 0으로 ratio는 1이 되기 때문에, scale값과 alpha값 모두 1을 갖게 되는 것이고 우측의 핑크색 셀은 distance = maxDistance로 ratio는 0이 되고, scale값과 alpha값 모두 우리가 지정해 놓은 값이 되는 겁니다. 

 

        attributes.alpha = alpha
        
        let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size)
        let dist = attributes.frame.midX - visibleRect.midX
        var transform = CATransform3DScale(CATransform3DIdentity, scale, scale, 1)
        transform = CATransform3DTranslate(transform, 0, 0, -abs(dist/1000))
        attributes.transform3D = transform
        
        return attributes

이렇게 구한 alpha와 scale값을 갖고 위와 같이 작성하면 됩니다. 근데 CATransform3D...? 좀 생소하시죠?? 차근차근 알아봅시다!

CATransform3D

CATransform3D는 구조체로, 코어 애니메이션 전체에서 사용되는 표준 변환 매트릭스입니다. 이 transform matrix는 회전, 스케일 조절, 변환 등에 사용됩니다!라고 되어 있네요.

 

CATransform3DScale(_ t: CATransform3D, _ sx: CGFloat, _ sy: CGFloat, _ sz: CGFloat) -> CATransform3D

t: CATransform3DIdentity(4x4 단위 행렬), sx: scale, sy: scale, sz: 1 로 하였고, 그 결과로 result: t = scale(sx, sy, sz) * t인 CATransform3D를 반환합니다. 

 

CATransform3DTranslate(CATransform3D t, CGFloat tx, CGFloat ty, CGFloat tz)는 translate(tx, ty, tz) * t 만큼 이동할 수 있습니다.  -> 만약 minimumLineSpacing이 음수로 설정되어 여러 셀들이 겹쳐있을 경우 스크롤 시 앞에 나와야 할 셀들이 다른 셀에 가려지는 등의 문제가 발생할 수 있습니다. 그래서 dist = attributes.frame.midX - visibleRect.midX, transform = CATransform3DTranslate(transform, 0, 0, -abs(dist/1000))를 통해 이러한 문제를 해결할 수 있습니다. 한번 print로 -abs(dist/1000)를 확인해보세요 ! 

이렇게 하면, 간단한 CarouselCollectionView가 완성이 되게 됩니다. 우리는 3가지 스케일로 구성이 되는 CarouselCollectionView를 만들어야 하는데, 너무 내용이 길어져서 다음 포스팅에서 이어서 하겠습니다 :) 

 

참고)

blog.naver.com/ojh6t3k/20194968081

 

3D 변환행렬

3D 변환행렬(Transform Matrix)는 3D 수학에 있어서 가장 중요한 핵심 이론 중 하나이다. 3D에서 공간...

blog.naver.com

댓글
링크
최근에 올라온 글
최근에 달린 댓글