
Infinite Carousel
심화과제의 티빙화면의 상단에 위치한 스크롤되는 화면을 구현과정을 정리해서 남겨놓고자 합니다 !
화면의 특징을 먼저 살펴보면
- 좌우로 스크롤되면서 무한정 스크롤 됨. (마지막에서 첫번째, 첫번째에서 마지막으로 갈 때도 마찬가지)
- 자동으로 일정시간이 지나면 화면이 스크롤 됨.
이렇게가 있습니다.
핵심 구현 아이디어
데이터소스를 활용해서 데이터를 넣기 위해 collectionView를 활용하고


스크롤뷰가 스크롤이 끝난 시점에 scrollView의 setContentOffset메서드를 통해서 contentOffset.x좌표를 이동시켜주면서 화면을 전환시켜줄 예정입니다.
데이터 세팅
4개의 아이템이 있다고 가정. -> [1, 2, 3, 4]
첫번째 아이템에서 마지막으로 갈 때 (<-)
- [4, 1, 2, 3, 4]
첫번째 사진에서 왼쪽으로 스크롤을 하면 마지막 사진이 나와야합니다. 따라서 첫번째 요소 앞에 마지막 아이템을 넣어줍니다.
마지막 아이템에서 첫번째로 갈 때 (->)
- [1, 2, 3, 4, 1]
마지막 사진에서 오른쪽으로 스크롤하면 첫번째 사진이 나와야하기 때문에 위의 방법과 마찬가지로 첫번째 아이템을 넣어서 확장시켜줍니다.
양방향 스크롤이 되야하기 때문에 앞 뒤로 아이템을 추가한 새로운 배열을 만들어줘야합니다 !
var images: [UIImage] = [
UIImage(named: "1")!, // 최강 야구
UIImage(named: "2")!, // 너의 이름은
UIImage(named: "3")!, // 스즈메
UIImage(named: "4")! // 귀칼
]
/// viewDidLoad
images.insert(images[images.count - 1], at: 0)
images.append(images[1])
CollectionView 설정
private let carouselCollectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.minimumLineSpacing = 0
layout.minimumInteritemSpacing = 0
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.isScrollEnabled = true
collectionView.isPagingEnabled = true
collectionView.showsHorizontalScrollIndicator = false
collectionView.backgroundColor = .clear
collectionView.register(CollectionViewCell.self, forCellWithReuseIdentifier: CollectionViewCell.identifier)
return collectionView
}()
PageControl 설정
private let pageControl: UIPageControl = {
let pageControl = UIPageControl(frame: .zero)
pageControl.currentPageIndicatorTintColor = .white
pageControl.pageIndicatorTintColor = .gray
pageControl.backgroundStyle = .minimal
pageControl.numberOfPages = 4
pageControl.currentPage = 0
pageControl.isUserInteractionEnabled = false
return pageControl
}()
초기화면 설정
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
carouselCollectionView.setContentOffset(
.init(x: Size.screenWidth, y: carouselCollectionView.contentOffset.y), animated: false)
}
[4, 1, 2, 3, 4, 1]
앞에서 배열 앞뒤로 추가했기 때문에 처음 화면에 뜨는 화면은 4번째 사진이 됩니다.
따라서 맨처음 사진을 띄우기 위해 contentoffset.x을 한번(기기 화면 너비만큼) 이동시킵니다. 이 때 애니메이션 효과를 false로 설정해줘야합니다 !

scrollViewDidEndDecelerating
// 4 1 2 3 4 1
if scrollView.contentOffset.x == 0 { // 첫번째(4)가 보이면 4번째 index의 4로 이동시키기
scrollView.setContentOffset(.init(x: Size.screenWidth * 4, y: scrollView.contentOffset.y), animated: false)
}
else if scrollView.contentOffset.x == Size.screenWidth * 5 { //마지막 1이 보이면 1번째 index의 1로 이동
scrollView.setContentOffset(.init(x: Size.screenWidth, y: scrollView.contentOffset.y), animated: false)
}
- 첫번째(4)가 보이면 4번째 index의 4로 이동시킵니다. (ScreenWidth * 4: 아래 사진 참고)
- 왼쪽에서 스크롤할 시에 3번째가 보여야하고 왼쪽으로 계속 스크롤 되는 것처럼 보여야하기 때문 !

- 마지막(1)이 보이면 1번째 index의 1로 이동시킵니다.
- 오른쪽으로 스크롤할시에 2가 보여야하고 오른쪽으로 계속 스크롤 되는 것처럼 보여야하기 때문 !
PageControl도 이동시켜줘야한다.
pageControl.currentPage = Int(scrollView.contentOffset.x / scrollView.frame.maxX) - 1
scrollView의 frame의 maxX는 기기의 화면 너비와 같다 !
따라서 scrollView의 contentOffset을 해당 너비로 나누면 몇번째 페이지인지 알 수 있습니다!
하지만 우리는 맨 처음에 배열에 값을 추가해줬기 때문에 시작점이 1번째 index이기 때문에 0부터 시작이 아니라 iphone 13 mini기준 375의 contentOffset.x부터 시작합니다. 따라서 1을 빼주면 됩니다.
결과 !

Timer 기능을 넣어보자 !
Timer기능을 사용할 때 우리가 스크롤 할 때에는 timer가 돌아가면 안됩니다! 만약 timer가 돌아가면 우리가 스크롤을 돌리자마자 바로 화면이 넘어가는 등 이상하게 작동할 것입니다. 따라서 아래처럼 scrollViewDidEndDecelerating메서드에서 timer를 껐다 켜줘야합니다 !
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
invalidateTimer()
activateTimer()
if scrollView.contentOffset.x == 0 { // 첫번째(4)가 보이면 4번째 index의 4로 이동시키기
scrollView.setContentOffset(.init(x: Size.screenWidth * 4, y: scrollView.contentOffset.y), animated: false)
}
else if scrollView.contentOffset.x == Size.screenWidth * 5 { //마지막 1이 보이면 1번째 index의 1로 이동
scrollView.setContentOffset(.init(x: Size.screenWidth, y: scrollView.contentOffset.y), animated: false)
}
pageControl.currentPage = Int(scrollView.contentOffset.x / scrollView.frame.maxX) - 1
}
invalidateTimer
private func invalidateTimer() {
timer?.invalidate()
}
- timer를 끄는 건 단순히 invalidate메서드를 활용하면 됩니다.
activateTimer
private func activateTimer() {
timer = Timer.scheduledTimer(timeInterval: 2, target: self, selector: #selector(timerCallBack), userInfo: nil, repeats: true)
}
@objc
private func timerCallBack() {
let visibleItem = carouselCollectionView.indexPathsForVisibleItems[0].item
let nextItem = visibleItem + 1
let initialPosterCounts = posters.count - 2
carouselCollectionView.scrollToItem(at: IndexPath(item: nextItem, section: 0), at: .centeredHorizontally, animated: true)
if visibleItem == initialPosterCounts {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.carouselCollectionView.scrollToItem(at: IndexPath(item: 1, section: 0), at: .centeredHorizontally, animated: false)
}
}
pageControl.currentPage = visibleItem % initialPosterCounts
}
- 2초 간격동안 반복되게끔 timer를 설정 !
- indexPathForVisibleItems[0]: 현재 화면에 보이는 아이템의 indexPath를 가져올 수 있습니다 !
- 다음 indexPath의 item으로 스크롤 하되 만약 다음번이 마지막! 이면 처음으로 contentOffset을 설정해준다!
- 여기서 DispatchQueue.main.asyncAfter없이 한다면 마지막에서 갑자기 팟하고 나타나서 매우 부자연스럽게 넘어가집니다.
- 따라서 우리가 위에서 마지막 뒤에도 첫번째 사진을 추가해줬기 때문에 우선 다음번으로 scroll을 시키고 그 뒤에 contentOffset을 바꾸어주면 됩니다 !

시뮬레이터를 gif로 찍으면 이상하게 나오는데...잘나오는거 맞습니다🥲 마지막에 최강야구가 갑자기 팟! 뜨는 부분만 보시면 됩니다 !
최종 결과 !

Reference
https://sueaty.tistory.com/144
https://ios-development.tistory.com/1197
'Dev > 고민과 삽질의 기록들🤔' 카테고리의 다른 글
Swift에서의 싱글톤에 대한 생각정리 (0) | 2023.09.11 |
---|---|
[삽질] 클래스의 확장성 개선(protocol 의존성 주입: any) (0) | 2023.06.01 |
[삽질] 상단 Custom TabBar 구현 (3) | 2023.05.01 |
[삽질] BottomSheet 구현 중 시행착오 + 해결과정 (PanGesture, CGAffineTransform) (1) | 2023.04.16 |
[Q&A] ViewController생성: Factory Method Pattern & DI (0) | 2023.04.15 |
댓글