티스토리 뷰

 

이전 글에서 간단한(?) carousel effect가 들어간 컬렉션뷰를 만들어봤었는데요. 이제 우리가 최종적으로 만들려고 하는 위와 같은 컬렉션뷰를 만들어 보겠습니다. 

기존 코드에서 우리가 만들었던 setupLayout 메서드 일부와 transformLayoutAttributes 메서드만 수정해주면 됩니다 ㅎㅎ

접근 방식

1. 우선 스케일의 변화가 기존 2단에서 3단으로 변하기 때문에, 거리에 따라 달라지는 ratio 공식을 수정해야합니다. 

2. 아이템간의 스페이싱이 음수이기 때문에, (즉 여러 아이템들이 겹쳐지는 컬렉션뷰이기 때문에) 앞에 있는 아이템이 뒤에 있는 아이템에 가려지지 않도록 하는 적절한 조치가 필요합니다.

3. paging이 가능하도록 만들자! 

 

● setupLayout()

변경 전 

let scaledItemOffset =  (itemWidth - itemWidth*self.sideItemScale) / 2
self.minimumLineSpacing = spacing - scaledItemOffset

 

변경 후

let scaledItemOffset =  (itemWidth - (itemWidth*(self.sideItemScale + (1 - self.sideItemScale)/2))) / 2
self.minimumLineSpacing = spacing - scaledItemOffset

scaledItemOffset이 조금 달라졌죠??, 이렇게 달라진 이유는, 스케일이 3단으로 변하기 때문에 그 영향을 고려해 주기 위함입니다. 

만약 변경 전의 코드를 이해 하셨다면, 변경 후의 코드도 이해 하실 수 있을겁니다 :)

 

● transformLayoutAttributes(attributes: )

    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 = 2*(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
        

        if abs(collectionCenter - center) > maxDistance + 1 {
            attributes.alpha = 0
        }
        
        
        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(attributes: ) 메서드의 경우 어느 부분이 바뀌었는지 아시겠나요? 많이 바뀌는 부분은 없습니다. 스케일링 되는 아이템이 두개이므로 기존 maxDistance에 2를 곱하면 끝이에요! 이렇게 되면 스케일링 되는 범주가 두배 만큼 늘어나는 거죠, 

그리고 추가가 된 코드가 있는데요. 바로 이 코드 입니다. 

        if abs(collectionCenter - center) > maxDistance + 1 {
            attributes.alpha = 0
        }

만약 컬렉션뷰 중앙을 기준으로 각 아이템들의 오프셋된 거리가 maxDistance + 1 보다 클 경우 투명도 값을 0으로 처리하겠다는 의미입니다. 왜 maxDistance가 아니라 maxDistance + 1 이지? 라고 의아해 할 수 있을 텐데요. + 1을 해준 이유는 약간의 오차를 보정하기 위함입니다. 

이렇게 해주고 실행을 해보면 다음과 같이 우리가 만들려던...!  뷰를 볼 수 있습니다 ㅎㅎ

 

하지만 이렇게하면 아직 페이징은 안된다는거.. 페이징이 가능하면 더 좋겠죠? (만약 여기서 단순히 collectionView.isPagingEnabled =  true 로 하면 우리가 원하던 대로 페이징이 되지 않을꺼에요!)

하단의 코드를 우리가 만든 CarouselLayout에 추가해줘야 합니다. 

    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {

        guard let collectionView = self.collectionView else {
            let latestOffset = super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity)
            return latestOffset
        }

        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)
    }

우리는 targetContentOffset 메서드를 오버라이드 해서 사용 할 겁니다. 왜냐?? 애플 개발자 문서에 들어가서 이 메서드에 대한 설명을 보면 이 메서드를 재정의해서 사용하면 스크롤이 중지되는 지점을 변경 할 수 있다고 하거든요! 

 

 

이 targetContentOffset 메서드는 스크롤을 중지할 지점을 찾아서 반환하는 메소드 입니다. 

입력 파라미터로는 1. proposedContentOffset 2. velocity 두개가 있습니다.

1. proposedContentOffset - 스크롤이 자연스럽게 중지 되는 값, point는 visable content의 좌측 위 모서리를 가리킵니다.

2. velocity - 스크롤 속도 입니다. (points / sec)

이제 이 메서드가 뭔지 알았으니 코드를 작성합시다.

우리는 proposedContentOffset 를 통해 스크롤뷰가 어느 지점에서 중지되는지를 알 수 있죠? 위에 point는 visable content의 좌측 위 모서리를 가리킨다고 했는데, 이 visable content는 collectionView bounds의 좌측 모서리라 생각하며 될 것 같습니다. 

 

let horizontalCenter = proposedContentOffset.x + collectionView.frame.width / 2

이제 이 proposedContentOffset.x에 collectionView.frame.width/2를 더하면, 스크롤 뷰가 멈출 중앙 지점을 얻을 수 있습니다.

        for layoutAttributes in rectAttributes {
            let itemHorizontalCenter = layoutAttributes.center.x
            if (itemHorizontalCenter - horizontalCenter).magnitude < offsetAdjustment.magnitude {
                offsetAdjustment = itemHorizontalCenter - horizontalCenter
            }
        }

(참고. 기존에 선언했던 var offsetAdjustment = CGFloat.greatestFiniteMagnitude는 CGFloat 값중 가장 큰 값을 의미합니다.)

itemHorizontalCenter - 각 아이템(셀)들의 x축 중앙 값

이제 offsetAdjustment라는 것을 우리가 구해줄겁니다. 이건 어디에 사용하려고 하느냐? 스크롤뷰가 멈출 것으로 예상되는 포인트에서 가장 가까운 아이템(셀)의 중앙 값을 얻어 온 후, 그 값이 스크롤뷰가 멈출 것으로 예상되는 포인트와 얼마나 차이나는지 계산해서 멈출 지점을 보정해주는데 사용하기 위함입니다.  반복문을 통해, 가장 작은 offsetAdjustment를 갖게 되고 이 값을 스크롤이 멈출 것으로 예상되는 지점인 proposedContentOffset.x에 더해주면 됩니다.

return CGPoint(x: proposedContentOffset.x + offsetAdjustment, y: proposedContentOffset.y)

이렇게 하면 이제 셀이 정 가운데로 올 수 있도록 페이징이 가능 합니다. 

 

● 전체 코드

레이아웃

import UIKit

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 + (1 - self.sideItemScale)/2))) / 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 = 2*(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
        
        if abs(collectionCenter - center) > maxDistance + 1 {
            attributes.alpha = 0
        }
        
        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
    }
    
    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {

        guard let collectionView = self.collectionView else {
            let latestOffset = super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity)
            return latestOffset
        }

        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)
    }
}

 

뷰컨트롤러

import UIKit

class ViewController: UIViewController {
    
    @IBOutlet weak var collectionView: UICollectionView! // 컬렉션뷰 사이즈: width = 315 height = 347
    
    override func viewDidLoad() {
        super.viewDidLoad()
        addCollectionView()
        collectionView.backgroundColor = .clear
        self.view.backgroundColor = .brown
    }
    
    func addCollectionView(){

        let layout = CarouselLayout()
        
        layout.itemSize = CGSize(width: collectionView.frame.size.width*0.796, height: collectionView.frame.size.height) 
        layout.sideItemScale = 175/251
        layout.spacing = -197
        layout.isPagingEnabled = true
        layout.sideItemAlpha = 0.5

        collectionView.collectionViewLayout = layout
            
        self.collectionView?.delegate = self
        self.collectionView?.dataSource = self

        self.collectionView?.register(CarouselCell.self, forCellWithReuseIdentifier: "carouselCell")
    }


}

extension ViewController: UICollectionViewDelegate, UICollectionViewDataSource {
    
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 1
    }

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        
        return 20
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "carouselCell", for: indexPath) as! CarouselCell

        cell.customView.backgroundColor = .white
        return cell
    }
}

 

컬렉션뷰 셀

import UIKit

class CarouselCell: UICollectionViewCell {
    
    let customView: UIView = {
        let view = UIView()
        view.translatesAutoresizingMaskIntoConstraints = false
        view.layer.cornerRadius = 12
        return view
    }()


    override init(frame: CGRect) {
        super.init(frame: frame)

        self.addSubview(self.customView)

        self.customView.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
        self.customView.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
        self.customView.widthAnchor.constraint(equalTo: self.widthAnchor, multiplier: 1).isActive = true
        self.customView.heightAnchor.constraint(equalTo: self.heightAnchor, multiplier: 1).isActive = true
    }

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

 

긴 글 읽어주셔서 감사합니다 :) 

혹시 글을 읽고서 이해가 잘 안되는 부분이 있거나 잘못된 정보가 있다면 꼭 댓글 달아주세요 !!

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