티스토리 뷰

이번 글에서는 UICollectionView를 활용하여 무한히 도는 carousel view를 만든 개발 경험을 공유해 볼 예정이에요!

시간이 없으시다면 아래 내용을 GitHub에 올려 놓았으니 빠르게 확인해보시면 될 것 같네요.

혹시 질문이 있으시다면 아는 만큼 답해드릴테니 댓 달아주세요~

Infinite Carousel이란?

요즘 소비자와 맞닿아 있는 서비스를 제공하는 앱들은 carousel로 구현된 상단 광고를 많이 붙여 놓는 것 같아요.

예를 들어 아래 이마트 몰도 여러 장의 사진들이 계속 돌아가고, 앞과 뒤로도 움직이는 그런 뷰를 상단에 보여주고 있어요.

Carousel은 회전목마를 뜻하니, 왜 이런 view를 carousel view라고 하는지 알 것 같죠?

회사에서 이런 view를 만들어보는 과제를 처음 받았을 때 그렇게 어려울 것 같지 않았어요.

눈에 보이는 몇 가지 스펙들만 충족하면 된다고 생각했고, 예전에 부스트캠프에서도 비슷한 경험을 했어서

쉽게 해낼 수 있을 것 같았죠. 허허 어리석었다 나 자신 :)

 

구현해야 하는 carousel view는 다음과 같은 특징을 갖고 있습니다.

1. 방향과 무관하게 무한 페이징이 가능해야 함

2. 타이머를 적용해서 사용자가 터치하지 않아도 자동으로 페이징 되어야 함

 

프로젝트를 생성하자마자 받은 첫 과제라 조금 긴장감을 갖고 했는지 점심시간 이후로 퇴근 전 까지 계속 고민했답니다.

부족한게 많음을 느낀 하루였지만 잘 완성했으니 여러분과도 공유해볼게요.

 

... 모 이리도 못 그렸는지 핳

이렇게 생길(?) 예정인데요, UICollectionView, UIProgressView 그리고 Timer를 사용해서 만들어보겠습니다.

1. View 구성하기

요즘 스토리보드보다 코드로 뷰를 구성하는 것에 재미가 들렸기 때문에 코드로 view hiearchy를 구성하고 constraint를 잡았어요.

UICollectionView의 cell은 UIImageView가 채워지지만 편의성을 위해 background color가 지정된 UIView를 올릴게요.

그리고 현재 페이징 되고 있는 view가 몇 번째인지를 시각적으로 나타내기 위해 UIProgressView를 달았어요.

class CarouselViewController: UIViewController {
    
    // MARK:- Views
    private lazy var carouselView: UICollectionView = {
        let flowlayout = UICollectionViewFlowLayout()
        flowlayout.minimumLineSpacing = 0
        flowlayout.minimumInteritemSpacing = 0
        flowlayout.scrollDirection = .horizontal
        
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowlayout)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.isPagingEnabled = true
        collectionView.showsHorizontalScrollIndicator = false
        return collectionView
    }()
    
    private lazy var carouselProgressView: UIProgressView = {
        let progressView = UIProgressView()
        progressView.translatesAutoresizingMaskIntoConstraints = false
        progressView.trackTintColor = .gray
        progressView.progressTintColor = .white
        return progressView
    }()

    // MARK:- Life Cylce
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.addSubview(carouselView)
        view.addSubview(carouselProgressView)
        
        carouselView.register(CarouselCollectionViewCell.self,
                              forCellWithReuseIdentifier: CarouselCollectionViewCell.reuseIdentifier)
        carouselView.dataSource = self
        carouselView.delegate = self

        NSLayoutConstraint.activate([
            carouselView.topAnchor.constraint(equalTo: view.topAnchor),
            carouselView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            carouselView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            carouselView.heightAnchor.constraint(equalToConstant: 300),
            
            carouselProgressView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            carouselProgressView.bottomAnchor.constraint(equalTo: carouselView.bottomAnchor, constant: -20),
            carouselProgressView.widthAnchor.constraint(equalToConstant: view.frame.width * 0.8)
        ])
    }

}

2. DataSource 지정

실제로 사용할 때는 네트워킹을 통해 이미지 데이터 등을 받아오는 단계가 있겠지만,

편의를 위해 저희는 UIView의 background color로만 지정하겠습니다. 

그러기 위해서는 UIColor 배열을 만들어서 data source로 지정하는 과정이 필요하겠네요.

// MARK:- Properties
let colors: [UIColor] = [.red, .orange, .yellow, .brown, .purple]
// UICollectionViewDataSource 부분 아래와 같이 수정
extension CarouselViewController: UICollectionViewDataSource {
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return colors.count * 3
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let color = colors[indexPath.item]
        if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CarouselCollectionViewCell.reuseIdentifier,
                                                         for: indexPath) as? CarouselCollectionViewCell {
            
            cell.backgroundColor = color
            return cell
        }
        
        return UICollectionViewCell()
    }
    
}

 

자 그런데 조금 의아한 곳이 있죠? numberOfItemsInSection이 작성한 color 갯수의 3배에요.

무한히 도는 carousel 이기 때문에 사용자는 언제나 양방향으로 스크롤을 할 수 있어야 합니다.

아래 그림을 자세히 보시면 UIProgressView(얘를 써서 구현했는지는 잘 모르겠지만ㅋㅋ) 는 첫번째에 있는데 제가 왼→오 로 스크롤을 시도했기 때문에 왼쪽에 있는 사진도 빼꼼 보이죠?

여기서 착안해보면 UICollectionView의 시작이 0번째 item이 아니라는 것을 알 수 있습니다.

⭐️ 그.래.서 ⭐️

1. UIColor 배열을 하나의 segment라고 생각해본다면, 총 3개의 segment를 이어붙인다.

2. 시작과 동시에 중간 segment의 첫번째 item으로 오게 한다.

3. 그러면 좌우 모두로 스크롤을 바로 할 수 있다!

3. 시작하면 가운데로 보내주기

이 프로젝트를 하기 전까지 제 상식은 UICollectionView의 시작은 0부터였어요.

물론 이 사실은 변함이 없지만 매우 빠르게 이동을 시킬 수는 있는 방법을 찾았죠! scrollToItem(at:at:animated:)

지정해준 scroll position의 content를 보여주는 역할을 하는 메소드

 

override func viewDidLayoutSubviews() {
   let segmentSize = colors.count
   carouselView.scrollToItem(at: IndexPath(item: segmentSize, section: 0),
                             at: .centeredHorizontally,
                             animated: false)
}

아마 조금 특이하게 생각할 수도 있는 부분은 scrollToItem을 viewDidLayoutSubviews( )에 넣었다는 것일텐데요,

어찌 생각해보면 조금 당연한 이유가 있습니다.

collection view의 원하는 cell을 볼 수 있도록 scroll을 해주는 역할을 하려면 당연히 cell들이 존재해야겠죠?

이제 첫번째 cell을 deque 했는데 다섯번째 cell로 이동을 할 수는 없다는 것이죠.

그렇기 때문에 모든 layout이 완료된 시점에 scroll을 시켜주는 것이죠. (출처: stackoverflow)

 

4. UIProgressView 설정해주기 + 현재 보여지는 content의 IndexPath 구하기

[1] Progress 객체를 생성하고 초기값을 1로 설정하고 progressView에 적용

[2] 현재 보여지는 페이지에 따라 완성도 재설정하고 progressView에 적용

   [2-1] 사용자의 scroll에 따라 완성도 재설정

   [2-2] 좌/우 스크롤 방향 파악이 아닌 현재 보여지는 페이지의 IndexPath를 통해 완성도 구하기

 

위 그림 처럼 나타내기 위해서는 먼저 현재 진생 상태를 알려 줄 객체 progress를 만들고

현재 보여지는 페이지에 따라 progress의 완료도(completedUnitCount)를 통해 조절해주면 됩니다.

이 부분도 조금 특이점이 있는데요, progress의 시작이 0이 아니라 1이 되어야 한다는 것이에요.

우린 첫번째 사진은 한 칸 만큼 채워줘야 하잖아요?

 

// colors가 정의된 곳 밑에 (MARK:- Properties)
var progress: Progress?
// viewDidLoad() 하단에 추가

override func viewDidLoad() {
   ...
   
   carouselProgressView.progress = 0.0
   progress = Progress(totalUnitCount: Int64(colors.count))
   progress?.completedUnitCount = 1
   carouselProgressView.setProgress(Float(progress!.fractionCompleted), animated: false)
}

 

(물론 이렇게 viewDidLoad()에 다 때려넣는 걸 좋아하진 않지만 방법을 익히면 활용은 무한히 가능하니까~)

 

이제 스크롤을 하면 completedUnitCount 를 재설정을 해주어야 합니다.

UIScrollView의 delegate method 중 scrollViewDidEndDecelerating(_:) 를 활용할 예정입니다.

UICollectionView는 UIScrollView를 상속하니 당연히 scrollview의 delegate method를 쓸 수 있죠.

UICollectionViewDelegate 아래에 다음 코드를 채워 넣어 볼게요.

 

extension CarouselViewController: UICollectionViewDelegate {
    
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        var item = visibleCellIndexPath().item
        if item == colors.count * 3 - 2 {
            item = colors.count * 2
        } else if item == 1 {
            item = colors.count + 1
        }
        carouselView.scrollToItem(at: IndexPath(item: item, section: 0),
                                  at: .centeredHorizontally,
                                  animated: false)
        
        let unitCount: Int = item % colors.count + 1
        progress?.completedUnitCount = Int64(unitCount)
        carouselProgressView.setProgress(Float(progress!.fractionCompleted), animated: false)
    }
    
}

 

뭐시기 분기문이 생겼네요? 별다른 것은 아니고 사용자가 스크롤을 하다가 제일 처음 또는 제일 끝으로 갔을 때 다시 움직여주는 코드에요.

0번째 item까지 도달했다면 다시 가운데 segment의 첫번째로 가고, 마지막 item 까지 갔다면 다시 가운데 segment의 마지막으로 가고.

그...런ㄷ..데... 그 코드가 아닌 것 같죠? item == 0이 아니라 item == 1 이니까요!

사실 직접 해보니 0번째 item에서 scrollToItem 하니까 조금의 delay가 있더라구요.

사용자가 빠르게 스크롤을 하면 미처 scrollToItem할 시간이 없는거에요. 그래서 0번째, 제일 끝이 아닌 1번, 끝에서 2번째 이렇게 좀 미리 잡아 둔거랍니다ㅋㅋ

 

그리고 못 보던 코드가 있어요. visibleCellIndexPath().item.

아래 구현을 해볼건데요, 현재 보이는 content의 IndexPath를 계산해서 return 해주는 함수에유.

 

private func visibleCellIndexPath() -> IndexPath {
    return carouselView.indexPathsForVisibleItems[0]
}

 

그리곤 마지막에 completedUnitCount를 통해 progressView의 상태를 업데이트 해줍니다.

이렇게 까지만 해도 무한대로 도는 carousel은 다 만든거에유~ 👯‍♀️

이제 Timer 달아서 auto scroll이 되게 하면 끝날 것 같네요?

5. Timer를 사용한  Auto Scroll

timer를 사용할 때 많은 것들을 고려한다고 해요.

잘못 사용하면 메모리 릭도 나지만 이거 하나 만드는거니까 정말 단순하게 만들게요?

(못해서 안하는거...마즘ㅎㅎ)

 

// progress가 정의된 곳 밑에 (MARK:- Properties)
var timer: Timer?

timer를 시작하는 함수와 초기화시키는 함수를 만들거에요.

각각의 코드가 길지 않은 것 알지만 여러 곳에서 사용한다면 조금 더 읽기 좋게 함수명으로 지정하면 좋을 것 같아서요~

private func invalidateTimer() {
   timer?.invalidate()
}
private func activateTimer() {
    timer = Timer.scheduledTimer(timeInterval: 2,
                                 target: self,
                                 selector: #selector(timerCallBack),
                                 userInfo: nil,
                                 repeats: true)
}

 

activateTimer는 viewDidLoad에 한 번 넣어주세요 바로 시작할 수 있게.

2초마다 한번씩 스크롤을 넘어가게 하고 싶은 것이니 selector 함수 timerCallBack을 만들어주면 됩니다!

(하... 끝이 보인다)

이제 어떻게 구현하면 될지 보이지 않나여~?! (안보이면 말구룽)

 

 @objc func timerCallBack() {
     var item = visibleCellIndexPath().item
     if item == colors.count * 3 - 1 {
         carouselView.scrollToItem(at: IndexPath(item: colors.count * 2 - 1, section: 0),
                                   at: .centeredHorizontally,
                                   animated: false)
         item = colors.count * 2 - 1
     }
        
     item += 1
     carouselView.scrollToItem(at: IndexPath(item: item, section: 0),
                               at: .centeredHorizontally,
                               animated: true)
     let unitCount: Int = item % colors.count + 1
     progress?.completedUnitCount = Int64(unitCount)
     carouselProgressView.setProgress(Float(progress!.fractionCompleted), animated: false)
}

끝났!!!!....이 아니고 할 일 딱! 하나 더 남았어요.

지금 상황에서 scroll을 사용자가 하게 되면 조금 이상하게 작동하게 됩니다.

타이머는 흐르고 4초마다 바뀌라는 콜백이 들어가있기 때문에 중간에 사용자가 스크롤하면 새로운 페이지에서 4초 있다가

넘어가야하는데 지금의 경우에는 흐르던 4초를 기준으로 하죠.

그렇기 때문에 아까 decelerating 코드의 시작 부분에 한번 invalidate 처리를 해주면 좋을 것 같아요.

 

extension CarouselViewController: UICollectionViewDelegate {
   
   func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
      invalidateTimer()
      activateTimer()
      
      ...
   }

}

이렇게 말이죱!

 

하... 끝났다... 다했어요 여러분... 전체 코드가 있는 레포는 위에 링크 걸어뒀어요 인트로에...

Trial & Error

1. 도대체 왜 그렇게 시간이 오래 걸렸느냐

사실 그렇게 큰 이유가 있는건 아니에요. 사실 부스트캠프 때 Coursel을 ScrollView로 offset 계산해서 움직였기 때문에 비슷하게 하면 될거라고 착각했거든요. 그래서 처음엔 ScrollView로 시도하다가 한 3시 반 쯤? "아 이건 삽질이구나" 라는 것을 깨닫고 UICollectionView로 갈아탔어요. 그런데 혹시 ScrollView로도 가능한 부분인가여?! 가능한 것이라면.. 알려주세요 꼭 구경해보고 싶습니다.

 

2. scrollToItem 안되는디...?

위에도 명시해 놓았지마 viewDidLayoutSubviews( )에 넣는 것이 중요해요. 원한다고 모든 곳으로 scroll이 가능하지 않다는 걸 왜 바로 떠올리지 못했을까요?ㅋㅋ

 

3. 도대체 무한으로 어떻게 해....

처음에는 cell을 Int.max 막 이렇게 줬던 것 같아요. 설마 몇 만번을 하겠어? 라는 생각으로.

그런데 github.com/DroidsOnRoids/SwiftCarousel 의 코드를 읽다보니 3개의 segment로 만드는 것을 보게 되었어요.

이 레포에서 아이디어를 얻어 scrollToItem 까지 해보게 되었답니다 랄라~

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/03   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함