안녕하세요. 컬랙션 뷰 horizontal 기본 연습을 하다 그냥하면 심심해서 커스텀 카로셀 효과 + 애니메이션까지 적용해봤는데 공부하면서 배운 개념을 정리하려고 합니다.
이렇게 안 하려고 했는데 하다보니,, 최종적으로 완성한 결과물입니다. 구현하면서 스크롤 방향 시점을 체크하는데 애를 좀 먹었지만 에러 없이 잘 됩니다:)
1. Setup UICollectionVIew's scroll direction + layout
이전 포스트에서 컬랙션 뷰의 개념을 간단하게 정리했지만 간단 요약하자면 컬랙션 뷰는 크게 3개의 object가 있습니다. Data source, delegate, layout입니다. 이 중 layout은 collectionView에 보여지는 모든 객체들의 화면의 위치를 담당합니다. cell 크기, cell간 spacing 등등.. 그리고 flow layout object의 멤버 변수에는 scrollDirection이 있습니다. 이를 통해 scrollable direction을 지정할 수 있습니다. flow layout의 경우에는 스크롤 방향을 지정할 수 있는다 단일 방향입니다.
초기 프로젝트 생성시 스토리보드에 연결된 뷰컨으로 컬랙션 뷰 스크롤 뷰의 간단 탐구를 시작합니다.
let lists: [UIColor] = [.blue,.black,.brown,.cyan,.systemPink]
let colors: [UIColor] = (0..<5*5).map{ lists[$0%5] }
기본적으로 cell을 표시할 colors dataSource입니다.
class ViewController: UIViewController {
let CellId = "CollectionViewCell"
let collectionView = {
let layout = UICollectionViewFlowLayout()
/// 스크롤 방향 지정!!
layout.scrollDirection = .horizontal
let cv = UICollectionView(
frame: .zero,
collectionViewLayout: layout)
cv.backgroundColor = .clear
return cv
}()
override func viewDidLoad() {
super.viewDidLoad()
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: CellId)
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
collectionView.dataSource = self
collectionView.delegate = self
collectionView.decelerationRate = .fast
setupConstraints()
}
}
컬랙션 뷰를 생성할 때 flowLayot을 반드시 추가합니다.(UICollectionViewLayout으로 하면 화면 안보입니다.. flow layout으로 코드를 구현했기 때문입니다.) 이때 스크롤 방향을 horizontal로 지정할 수 있습니다.
Data source는 cell의 개수 및 데이터를 제공할 객체이므로 반드시 초기화 하고, delegate의 경우엔 UICollectionViewDelegateFlowLayout를 사용할 것이기 때문에!! delegateFlowLayout은 UICollectionViewDeleagte를 채택하기 때문에 dataSources도 self로 초기화 해주어야 합니다.(flow deleagte를 VC에서 채택하고 특정 메서드를 구현 할 것이기 때문입니다.)
이때 스크롤의 감속률을 빠르게 해서 사용자가 아무리 빨리 스크롤 속도를 올려도 천천히 스크롤되는 설정을 collectionView's decelerationRate 를 통해 설정할 수 있습니다.
func setupConstraints() {
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(
equalTo: view.safeAreaLayoutGuide.topAnchor),
collectionView.leftAnchor.constraint(
equalTo: view.leftAnchor),
collectionView.rightAnchor.constraint(equalTo: view.rightAnchor),
collectionView.heightAnchor.constraint(equalToConstant: view.frame.width/2)])
}
컬랙션 뷰의 auto layout입니다. 중요한 것 중 하나입니다. 컬랙션 뷰 자체가 있어야 할 위치를 정해야 합니다. 한 화면의 가로방향을 꽉 차게 해야 좀 더 많은 cell을 보여줄 수 있습니다.
//MARK: - Collection view data source
extension ViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return colors.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CellId, for: indexPath)
cell.layer.cornerRadius = 14
cell.backgroundColor = colors[indexPath.row]
if indexPath.row != 0 {
cell.transform = CGAffineTransform(scaleX: 1, y: 0.87)
}
if indexPath.row == 0 {
view.backgroundColor = colors[indexPath.row]
}
return cell
}
}
DataSource object가 필요한 데이터를 제공해줍니다.
colors의 개수만큼 cell을 생성하고 따로 cell 클래스는 만들지 않았습니다.( 컬랙션 뷰 horizontal 맛 좀만 보려고 해서,,) 그 대신 cell의 백그라운드 색을 colors의 특정 element로 대입해서 영역을 구분지었습니다. 화면에 보이는 가장 왼쪽을 강조할 것이기 때문에 + 초기에 보여지는 cell은 indexPath.row가 0,1,2 이기 때문에 0을 제외한 나머지는 다 조금씩 작게 만들었습니다.
그리고 cell의 corner layer를 둥글게 만들었습니다 :)
하지만 아직까지 cell의 layout(collectionView의 어느 지점에 어떤 크기로 어떻게 위치할 것인가!!)을 정하지 않았습니다.
//MARK: - UICollectionViewDelegateFlowLayout
extension ViewController: UICollectionViewDelegateFlowLayout {
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath
) -> CGSize {
let width = view.frame.width/3
return CGSize(width: width, height: view.frame.width/2)
}
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
minimumInteritemSpacingForSectionAt section: Int
) -> CGFloat {
return 0
}
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
minimumLineSpacingForSectionAt section: Int
) -> CGFloat {
return 17
}
}
UICollectionVIewDelegateFlowLayout을 통해 cell의 크기, cell간 간격을 지정합니다. 각각의 메서드는 위에서부터 cell의 크기, section간 특정 cell's line 간격, cell 간 간격을 지정을 할 수 있습니다. 이로써 collectionView 의 each cell's layout도 지정됬습니다.
horizontal도 vertical과 비슷하게 사용하면 되는군요. +_+
2. Setup specific cell's animation + carousel effect
이제부터 custom carousel을 적용해보려고 합니다. 이를 적용하면 스크롤이 멈췄을 때, cell 3개가 한 화면에 보여지는데 스크롤 할 경우 맨 왼쪽의 cell이 화면의 맨 왼쪽에 붙도록!! carousel effect target를 해보려고 합니다.
추가적으로 왼쪽으로 스크롤 할 때, 맨 왼쪽에 위치할 cell을 강조하기 위해 화면의 가운데에 있는 cell이 화면의 맨 왼쪽으로 이동할 때 작 -> 크게 보여주기, 반대로 오른쪽으로 스크롤할 때, 스크롤 시작할 때 맨 왼쪽에 있는 cell을 작게 만들고 다음으로 맨 왼쪽으로 나올 prevCell의 크기를 크게 전환할 애니메이션도 구현할 것 입니다.
carousel effect를 하기 위해선 UIScrollViewDelegate 의 scrollViewWillEndDragging(_:withVelocity:targetContentOffset:) 메서드를 사용해야 합니다.
extension ViewController: UIScrollViewDelegate {
func scrollViewWillEndDragging(
_ scrollView: UIScrollView,
withVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>) {
//1.
let cellArea = CGFloat(Int(view.frame.width)/3 + 17)
//2.
var offset = targetContentOffset.pointee
//3.
let idx = round(offset.x/cellArea)
//4.
offset = CGPoint(x: idx*cellArea, y: 0)
//5.
targetContentOffset.pointee = offset
}
...
1. scrollable direction이 horizontal이므로 cell의 width + cell간 minimum inter spacing길이를 포함하는 cell의 크기 정보를 정확하게 얻습니다. 정확하게 얻지 못한다면 매 스크롤마다 cell의 위치가 밀리게 되서 나중엔 이상한 위치로 target content's offset 지정됩니다.
2. targetContentOffset 으로 어디에 스크롤 중인 애니메이션(위 함수가 호출되는 시점은 스크롤이 진행 중 입니다.)이 끝나는 지점인지를 조정해야 합니다. 4번의 그리고 offset.x를 통해 x좌표가 얼마나 이동했는지를 알 수 있습니다.
3. 어느 지점에서 스크롤이 멈출 것인가 대략적인 위치를 offset.x로 알 수 있는데, 화면 안의 특정 가로 범위 이내 입니다.( 화면에 보여지는 특정 스크롤 범위!!) cellArea로 나눈 후 반올림이 된다면 idx에 해당하는 특정 cell의 index를 얻을 수 있고 반올림에 실패하면 그 이전 Cell을 오게할 것인지, cell의 idx를 지정합니다.
4. 특정 cell의 idx가 스크롤이 완료된 지점에 위치할 수 있도록 좌표를 정해줍니다. 좌표는 특정 cell의 index * (cell's width + cell's inter spacing)을 반올림한 값으로 어느 index의 cell이 올건지!! 온다면 정확하게 화면의 맨 왼쪽에 오도록!! x좌표를 index*cellArea로 정확히 계산할 수 있습니다.(화면의 맨 왼쪽이 될 위치) // 특정 cell을 정했다면 화면의 맨 왼쪽으로!! CGPoint(x: idx*cellArea, y: 0) 계산합니다.
5. 4에서 계산한 위치로 스크롤 애니메이션이 끝날 위치를 지정해줍니다.
이로써 custom carousel은 완벽하게 계산했는데 마지막으로 맨 왼쪽에 올 cell을 쪼금 크게 해줄 에니메이션을 부여할 것입니다. 이때 단순히 scrollViewDidScroll(_:) 메서드를 이용해서 된다면 스크롤이 완전히 끝난 시점 이후에 맨 왼쪽의 cell이 커지는 에니메이션이 부여됩니다. 그래서 엄청난 고민 끝에... 현재 이동할 때의 이동 방향을 얻어올 수 있다는 스택 오버플로우 글을 찾았습니다. 이를 이용해서 이동 방향에 따라 각각 부여하기로 했습니다.
class ViewController: UIViewController {
...
var lastScrollOffset: CGFloat = 0
}
extension ViewController: UIScrollViewDelegate {
func scrollViewDldScroll(_ scrollView: UIScrollView) {
let cellArea = CGFloat(Int(view.frame.width)/3 + 17)
let curScrollOffset = scrollView.contentOffset.x
if lastScrollOffset < curScrollOffset {
// 오른쪽으로 이동합니다.
} else if lastScrollOffset > curScrollOffset {
// 왼쪽으로 이동합니다.
}
lastScrollOffset = scrollView.contentOffset.x
}
}
방향은 scrollViewDidScroll(_:) 이 함수가 끝나는 시점에 방향 contentOffset.x 값을 저장함으로 매번 스크롤을 할 때 조건문에 따라 0.1이라도 크면 오른쪽, 왼쪽으로 스크롤 방향을 구분할 수 있게 됬습니다.
이렇게 스크롤 방향을 측정하지 않았을 때는, 아주 느리게 cell을 오른쪽으로 갔다 왼쪽으로 갈 때 예상치 못한 cell이 커지는 등.. 오류가 많았습니다.
대표적으로 오른쪽으로 스크롤 할 때 에니메이션 부여하는 경우를 코드로 보겠습니다.
...
if lastScrollOffset < curScrollOffset {
let idx = curScrollOffset / cellArea
let indexPath = IndexPath(item: Int(round(idx)), section: 0)
if let cell = collectionView.cellForItem(at: indexPath) {
transformCellOriginSize(cell) {
print("오른쪽 갈 때 현재꺼 크게합니다")
self.view.backgroundColor = colors[indexPath.row].withAlphaComponent(0.5)
}
}
let prevIndexPath = IndexPath(
item: Int(round(idx) - 1), section: 0)
if let prevCell = collectionView.cellForItem(at: prevIndexPath) {
transformCellMinifyWithAnimation(prevCell) {
print("오른쪽 갈 때 현재 이전꺼 축소합니다")
}
}
} else if lastScrollOffset > curScrollOffset {
...
}
오른쪽으로 이동할 때, 현재 스크롤이 완료 될 따끈 따끈한 스크롤 완료 될 위치를 scrollView.contentOffset.x로 얻을 수 있습니다. 물론 위 함수에서 인자값으로 얻을 수 있는 UIScrollView 타입의 객체는 collectionView의 상위 class 인스턴스입니다. 그래서collectionView.contentOffset.x로도 동일한 결과가 나옵니다.
여기서 중요한 것은 round인 것 같습니다. 현재 이동이 완료될, 화면에 보여질 맨 왼쪽 cell은 크게하고, 이 바로 직전의 cell의 크기는 작게 축소합니다.
왼쪽으로 스크롤 할 경우에는 스크롤이 끝나기 이전 시점에!! 맨 처음에 왼쪽으로 스크롤이 될 때, 화면의 맨 왼쪽에 있었던 cell이 오른쪽으로 이동하게 됩니다. 이 cell의 크기는 커져있는 상태이고 해당 cell의 위치는 lastScrollOffset로 저장했기 때문에!!! 축소 시키고 스크롤이 끝날 시점은 currentScrollOffset로 얻고 이 위치에 올 cell의 index를 찾아 해당 cell의 크기를 애니메이션으로 키웁니다.
애니메이션 함수입니다.
func transformCellOriginSize(
_ cell: UICollectionViewCell,
completion: @escaping () -> Void) {
UIView.animate(
withDuration: 0.13,
delay: 0,
options: .curveEaseOut,
animations: {cell.transform = .identity}) {_ in
completion()
}
}
func transformCellMinifyWithAnimation(
_ cell: UICollectionViewCell,
completion: @escaping () -> Void) {
UIView.animate(
withDuration: 0.13,
delay: 0,
options: .curveEaseOut,
animations: {cell.transform = CGAffineTransform(scaleX: 1, y: 0.87)}) {_ in
completion()
}
}
여러 시행착오를 겪었지만.. 결국 제가 테스트 해봤을 때, 에러가 없는 애니메이션까지 추가했습니다. 이전 포스트에서 carousel effect를 위한 특정 cell's index를 구할 때는 round를 이용하지 않았습니다. minimum line spacing이 없었기 때문에 + cell크기도 컸기 때문에 스크롤이 끝나는 시점에 prev cell이냐 next cell인지 index를 확실하게 구할 수 있었습니다. 지금은 cell의 크기도 작고 minimum inter spacing도 있기 때문에 round를 사용해야 scrollViewDldScroll이 될 위치를 정확하게 구할 수 있었던 것 같습니다.
혹시 에러 발견시 댓글로 남겨주세요....
제 전체 코드입니다.
Reference:
https://stackoverflow.com/questions/31857333/how-to-get-uiscrollview-vertical-direction-in-swift