티스토리 뷰
오늘은 아래와 같은 컬렉션 뷰를 구현하는 방법을 알려드리려고 합니다.
참고! 제 글을 읽기전에 기초적인 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는 구조체로, 코어 애니메이션 전체에서 사용되는 표준 변환 매트릭스입니다. 이 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를 만들어야 하는데, 너무 내용이 길어져서 다음 포스팅에서 이어서 하겠습니다 :)
참고)
'Swift&iOS > iOS' 카테고리의 다른 글
[iOS] Cell을 재사용시 생기는 문제점들과 해결 방법 (1) | 2020.10.26 |
---|---|
[iOS] CollectionView 3D 전환, carousel effect 주기 (2) (1) | 2020.08.03 |
[iOS] UICollectionView 기초 정리(2) (0) | 2020.07.25 |
[iOS] UICollectionView 기초 정리(1) (0) | 2020.07.25 |
[iOS] UIView 살펴보기 frame과 bounds의 차이 (0) | 2020.07.06 |