회사 프로젝트에서 프로모션 팝업 화면을 담당하며 무한 스크롤과 타이머 작동을 동시에 구현해야 했다.
그런데 이게 생각보다 만만치 않았다.
불과 일주일 전 같은 맥락의 게시글을 작성했었는데,
그 방식대로 구현하면 타이머가 작동할 때 인덱스 변경 시 애니메이션이 적용되지 않는 문제가 발생했다.
그래서 해당 글에 추가해 두었던 블로그 글을 참고하여 전면 수정을 시도했다.
하지만 이번에는 드래그를 할 때 해당 인덱스의 화면이 드래그되는 모습이 보이지 않고,
액션이 다 끝난 후에야 슬라이드 애니메이션으로 화면이 전환되는 문제가 생겼다.
이전 글:
https://calliek.tistory.com/62
[SwiftUI] TabView + DragGesture로 무한 스크롤 타이머 조절하기
프로모션 팝업 화면을 담당해서 만들었는데, 무한 스크롤(이미지 배열 순환) + 타이머 작동이 가능토록 구현을 해야했다. - 내가 구현하고자 한 것: 1. 첫번째 인덱스 이미지에서 왼쪽으로 스와
calliek.tistory.com
다른 사람들의 코드를 무작정 가져다 쓰는 대신, 결국 내가 직접 커스텀하는 방식으로 해결하기로 마음먹었다!
1. Infinite Carousel 구현 (타이머 X)
먼저 타이머 기능 없이 오직 무한 캐러셀만 구현하는 방법을 소개한다.

1.1. 페이크 아이템 추가
/// 페이크 아이템 추가
private func configureCircularItemList() {
// 시작과 끝에 마지막, 첫 번째 색상 추가
colors.insert(colors[colors.count - 1], at: 0)
colors.append(colors[1])
}
- UIKit으로 무한 스크롤을 구현한 사례들을 보면, 순환할 배열의 0번째 인덱스와 마지막 인덱스 앞뒤로 각각 마지막 아이템과 0번째 아이템을 추가하는 방식을 사용한다.그리고 실제 무한 스크롤이 되어야 하는 인덱스에 도달하면 보여줘야 하는 인덱스를 다르게 하는 작은 눈속임을 택했다.
1.2. 무한 스크롤을 위한 페이크 인덱스 계산
/// 무한 스크롤 구현
private func getInfiniteScrollIndex(newValue: Int) {
if newValue == 0 {
// 처음으로 갔을 때 끝쪽으로 이동
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
currentIndex = colors.count - 2
}
} else if newValue == colors.count - 1 {
// 마지막으로 갔을 때 첫쪽으로 이동
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
currentIndex = 1
}
}
}
1.3. 메인 구현 (전체 코드)
import SwiftUI
struct CustomCarouselSlides3: View {
/// 순환배열
@State var colors: [Color] = [.red, .orange, .yellow, .green, .blue]
/// 현재 인덱스
@State private var currentIndex: Int = 1
var body: some View {
TabView(selection: $currentIndex) {
// 순환 리스트에서 첫 번째와 마지막 색을 추가
ForEach(0..<colors.count, id: \.self) { index in
Rectangle()
.fill(colors[index])
.frame(width: UIScreen.main.bounds.width)
.tag(index) // 현재 인덱스를 구분하기 위한 태그
} //: ForEach
.ignoresSafeArea()
} //: Tabview
.ignoresSafeArea()
// MARK: - 인디케이터
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
/// Custom Indicator
.overlay(alignment:.bottom) { }
// MARK: - LifeCycle
.onAppear {
configureCircularItemList() // 앞뒤 배열에 추가
currentIndex = 1
}
// MARK: - Action
.onChange(of: currentIndex) { newValue in
getInfiniteScrollIndex(newValue: newValue) // 무한 스크롤 구현
} //: onChange
} //: Body
} //: View
extension CustomCarouselSlides3 {
/// 무한 스크롤 구현
private func getInfiniteScrollIndex(newValue: Int) {
if newValue == 0 {
// 처음으로 갔을 때 끝쪽으로 이동
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
currentIndex = colors.count - 2
}
} else if newValue == colors.count - 1 {
// 마지막으로 갔을 때 첫쪽으로 이동
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
currentIndex = 1
}
}
}
/// 페이크 아이템 추가
private func configureCircularItemList() {
// 시작과 끝에 마지막, 첫 번째 색상 추가
colors.insert(colors[colors.count - 1], at: 0)
colors.append(colors[1])
}
/// 찐 인덱스 구하기
/// - 인디케이터와 현재 인덱스 맞추기 위함
private func getRealIndex() -> Int { }
}
OnAppear
- 뷰가 화면에 나타날 때 configureCircularItemList()를 호출하여 배열 앞뒤에 페이크 아이템을 추가한다.
- 초기 currentIndex는 0이 아닌 1로 설정한다. (페이크 아이템 때문에 실제 첫 번째 요소는 인덱스 1에 위치한다.)
ForEach
- TabView의 selection으로 currentIndex를 추적하고 있기 때문에, 각 아이템의 인덱스는 tag를 사용하여 추적한다.
onChange
- currentIndex가 변경될 때 필요한 액션을 추가했다. 여기서는 getInfiniteScrollIndex 함수를 호출하여 인위적으로 추가한 페이크 아이템을 이용한 무한 스크롤을 구현한다.
2. Infinite Carousel (타이머 O)
이제 위에서 구현한 Infinite Carousel에 타이머 기능을 추가해보자.

2.1. 전체 코드
import SwiftUI
struct CustomCarouselSlides3: View {
/// 순환배열
@State var colors: [Color] = [.red, .orange, .yellow, .green, .blue]
/// 타이머
@State var timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
/// 현재 인덱스
@State private var currentIndex: Int = 1
var body: some View {
TabView(selection: $currentIndex) {
// 순환 리스트에서 첫 번째와 마지막 색을 추가
ForEach(0..<colors.count, id: \.self) { index in
Rectangle()
.fill(colors[index])
.frame(width: UIScreen.main.bounds.width)
.tag(index) // 현재 인덱스를 구분하기 위한 태그
} //: ForEach
.ignoresSafeArea()
} //: Tabview
.ignoresSafeArea()
// MARK: - 인디케이터
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
/// Custom Indicator
.overlay(alignment:.bottom) { }
// MARK: - LifeCycle
.onAppear {
configureCircularItemList() // 앞뒤 배열에 추가
currentIndex = 1
}
// MARK: - Action
.gesture(
DragGesture()
.onChanged { _ in
timer.upstream.connect().cancel()
DispatchQueue.main.asyncAfter(deadline: .now() + 3){
timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
}
}
)
.onChange(of: currentIndex) { newValue in
getInfiniteScrollIndex(newValue: newValue) // 무한 스크롤 구현
} //: onChange
.onReceive(timer) { _ in
/// 타이머 가동 시 인덱스 하나씩 옮김
withAnimation(.easeIn) {
currentIndex += 1
}
}
} //: Body
} //: View
2.2. 기존 코드에 추가된 부분
/// 1.타이머
@State var timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
-------------------------------
/// 2.타이머 가동
.onReceive(timer) { _ in
/// 타이머 가동 시 인덱스 하나씩 옮김
withAnimation(.easeIn) {
currentIndex += 1
}
}
-------------------------------
/// 3.타이머 초기화 + 재기동
.gesture(
DragGesture()
.onChanged { _ in
timer.upstream.connect().cancel()
DispatchQueue.main.asyncAfter(deadline: .now() + 3){
timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
}
}
)
1. 타이머 추가:
- 3초마다 이벤트를 발행하는 타이머를 추가했다. autoconnect()를 사용해 즉시 타이머가 작동하도록 설정했다.
2. onReceive로 타이머 가동
- onReceive 모디파이어를 사용하여 타이머가 3초마다 이벤트를 발행할 때마다 currentIndex를 1씩 증가시킨다.
- withAnimation(.easeIn)을 추가하여 부드러운 슬라이드 애니메이션 효과를 구현했다.
3. gesture()로 사용자 상호작용 시 타이머 관리
...를 안해주면 드래그를 하는 와중에 3초가 지나면 인덱스가 +1이 되어버린다.
- 사용자의 드래그 액션이 감지되면 (onChanged 클로저 내에서) 현재 작동 중인 타이머를 cancel()하여 초기화한다.
- DispatchQueue.main.asyncAfter를 사용하여 3초 후에 새로운 타이머를 다시 시작한다.
References
https://ios-development.tistory.com/1197
[iOS - swift] Infinite Carousel (무한 스크롤 뷰) 구현 방법
구현 아이디어 수평 스크롤을 위해서 UIScrollView를 이용해도 되지만, 데이터 소스 입력 편의를 위해 UICollectionView 사용 무한 스크롤 원리 (데이터가 1,2,3 이렇게 있을 경우,) 왼쪽에서 오른쪽으로
ios-development.tistory.com
사실 이게 최선의 방법인지는 잘 모르겠다… 😂
나중에 더 좋은 방법을 알게 되면 이 시리즈에 다시 게시글을 쓰거나 이 글에 내용을 추가할 예정이다.
'Dev > 구현' 카테고리의 다른 글
| [SwiftUI] Infinite Carousel 구현하기 1 (feat. Timer) (0) | 2024.09.05 |
|---|---|
| [SwiftUI] pagerView 만들기 (iOS 버전대응) (2) | 2024.08.14 |
| [SwiftUI] CustomPopUpView 애니메이션 효과 해결하기 (0) | 2024.06.27 |
| [SwiftUI] 스유로 섹션 접었다폈다 구현하기 (0) | 2024.06.21 |
| [SwiftUI] TabView page indicator 커스텀하기 (1) | 2024.04.25 |