본문 바로가기

iOS/Deep dive!!!

[iOS/UIKit] UICollectionView horizontal scrollable + custom carousel effect + animations | No4. 컬랙션 뷰 탐구

 

안녕하세요. 컬랙션 뷰 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이 될 위치를 정확하게 구할 수 있었던 것 같습니다.

 

 

혹시 에러 발견시 댓글로 남겨주세요....

 

제 전체 코드입니다.

https://github.com/SHcommit/UIKitDeepDive/tree/master/CustomCollectionVIew/CollectionViewHori%2BCustomCarouselEffect

 

Reference:

https://stackoverflow.com/questions/31857333/how-to-get-uiscrollview-vertical-direction-in-swift