문제
프로모션 팝업 화면을 담당하며 무한 스크롤(이미지 배열 순환)과 타이머 작동을 동시에 구현해야 했다. 내가 구현하고자 했던 기능은 다음과 같다.
- 무한 순환 스크롤: 첫 번째 인덱스 이미지에서 왼쪽으로 스와이프하면 마지막 인덱스 이미지가 나오고, 마지막 인덱스에서 오른쪽으로 스와이프하면 첫 번째 인덱스 이미지가 나오는 구조를 만든다.
- 자동 이미지 변경: 별도의 제스처가 없으면 3초마다 이미지가 자동으로 변경되고, 이미지 배열이 순환되도록 한다.
- 수동 제어: 사용자가 수동으로 스와이프하면 배열 순서에 맞게 이미지가 변경되고, 인디케이터도 함께 변경된다.
하지만 구현 과정에서 몇 가지 문제에 부딪혔다.
- 이미지를 반만 스와이프했을 때, 반쯤 보이는 이미지들이 이전/다음 이미지로 변경되는 동시에 타이머가 작동하는 등 타이머 제어가 매끄럽지 않았다.
- 수동으로 스와이프하여 타이머를 멈추게 했을 때, 타이머가 다시 작동하지 않는 문제가 발생했다. 특히 DragGesture의 onEnded가 제대로 인식되지 않는 현상이 있었다.
- 이미지의 인덱스가 바뀌지 않으면 타이머가 작동하지 않았다.

2번의 문제 같은 경우 애플에서 구체적으로 설명해준 걸 찾을 수 없었는데, stack overflow에서 비슷한 문제를 겪는 글 중 위와 같은 추측 답변을 발견했다. 나는 tabview로 무한 스크롤을 구현 했기 때문에 어라? 싶었으나, tabview도 스크롤 기능을 가지고 있기 때문에 동일한 문제인 것 같았다.
드래그 액션과 스크롤 액션이 충돌 나서 onEnded는 인식이 불가한게 아닐까 아무래도 SwiftUI로 몇 번 액션과 관련한 일을 해결하다보니 스크롤 액션과 드래그 제스처의 우선순위를 굳이 따지자면 스크롤뷰/탭뷰의 드래그 제스처 > 제스처의 드래그 제스처인 모양이었다.
해결
무한 스크롤을 스크롤 가능한 뷰를 사용하지 않고 구현하는 방법(실제로 아래 참고 자료 중 다른 분의 Velog 게시글을 참고하면 될 듯하다)도 있었지만, 이미 TabView로 구현하기도 했고, 다시 코드를 짜기엔 시간이 부족했다. 그래서 원래 구현해 놓은 뷰에서 DragGesture와 인덱스 추적을 통해 문제를 해결하기로 했다.
SwiftUI로 네이버웹툰 상단 배너 만들기
네이버 웹툰의 상단 배너무한 스크롤상단 이미지는 스크롤되지 않으면서 하단의 타이틀만 스크롤됨자동 스크롤겉보기에는 스크롤이 되는 것처럼 보이지만 scroll은 disable 시키고 drag gesture를 인
velog.io
구현

1. 무한 스크롤을 보조할 커스텀 뷰: InfinitePageView
무한 스크롤을 구현하기 위해 InfinitePageView라는 제네릭 커스텀 뷰를 만들었다.
import SwiftUI
struct InfinitePageView<C, T>: View where C: View, T: Hashable {
@Binding var selection: T
// 이전 항목을 계산함
let before: (T) -> T
// 다음 항목을 계산함
let after: (T) -> T
// 뷰 생성
@ViewBuilder let view: (T) -> C
// 현재 탭 인덱스 저장
@State private var currentTab: Int = 0
var body: some View {
// 이전 및 다음 아이템 계산
let previousIndex = before(selection)
let nextIndex = after(selection)
TabView(selection: $currentTab) {
// 이전 항목 표시 뷰
view(previousIndex)
.tag(-1)
// 현재 뷰
view(selection)
.onDisappear() {
if currentTab != 0 {
selection = currentTab < 0 ? previousIndex : nextIndex
currentTab = 0
}
}
.tag(0)
// 다음 항목을 표시하는 뷰
view(nextIndex)
.tag(1)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onChange(of: selection) { _, newValue in
selection = newValue
}
// FIXME: workaround to avoid glitch when swiping twice very quickly
// 탭이 0이 아닐 때 스와이프를 비활성화하여 빠른 스와이프 시 발생하는 글리치 방지
.disabled(currentTab != 0)
}
}
위 코드는 아래 references 항목을 더 참고하면 좋을 것 같다.
거기서 찾은 코드의 자세한 설명을 보고 내 상황에 맞게 살짝 변경했기 때문에 각자 상황에 맞게 변경하면 될 것 같다.
2. 타이머 및 액션 구현: TimerSlides 확장
extension TimerSlides {
// MARK: - Slides
/// 다음 아이템으로 이동
private func moveToNextIndex() {
let nextIndex = (currentIndex + 1) % colors.count
withAnimation() {
currentIndex = nextIndex
}
}
// MARK: - Timer
/// 타이머 작동 (시작)
private func startTimer() {
/// 기존 타이머가 있으면 중지
stopTimer()
/// 3초마다 반복되는 타이머 설정
timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in
moveToNextIndex()
}
}
/// 타이머 작동 멈춤
private func stopTimer() {
/// 타이머 무효화 + nil로 설정
timer?.invalidate()
timer = nil
}
/// 사용자가 수동으로 슬라이드 넘기려고 할 때 DragGesture
/// - 인덱스가 바뀌지 않으면 타이머가 계속 가동
/// - 인덱스가 바뀌면 타이머가 멈추고 다시 가동
private func manageTimerWithIndex() -> some Gesture {
/// 커스텀 제스처
var userDragGesture: some Gesture {
/// gaurd 조건: 드래그 동작 중 인덱스가 변경되었는지 확인
guard currentIndex == currentIndex else {
/// 인덱스가 바뀌면 타이머 멈춤 O
return DragGesture(coordinateSpace: .global)
.onChanged { _ in
stopTimer()
}
} // guard
/// 인덱스가 바뀌지 않으면 타이머 멈춤 X
return DragGesture(coordinateSpace: .global)
.onChanged { _ in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
startTimer()
}
}
}
/// 커스텀한 제스처 반환
return userDragGesture
}
// MARK: - Indicator
/// 이미지 커스텀 인디케이터
private func imageCustomIndicator() -> some View {
ZStack {
if colors.count > 1 {
HStack(spacing: 4) {
ForEach(colors.indices, id: \.self) { index in
Capsule()
.stroke(.white, lineWidth: 1)
.frame(width: currentIndex == index ? 16 : 6, height: 6)
.opacity(currentIndex == index ? 1 : 0.5)
.background(currentIndex == index ? .white : Color.clear)
}
} // H
.padding(.bottom, 24)
} // if
} // Z
}
}
이 게시글에서 가장 중요한 부분은 타이머와 드래그 제스처를 구현한 위 부분이다.
- moveToNextIndex: 함수 이름에서 알 수 있듯이 현재 인덱스에서 +1 한 인덱스, 즉 다음 인덱스 값을 계산하는 함수다. 이 함수는 타이머가 작동하면 자동으로 다음 컬러가 나오도록 해줄 때 사용된다.
- startTimer: 타이머 설정과 더불어 설정한 타임이 지나면 컬러를 자동으로 넘겨주는 역할을 한다. 타이머를 리셋하는 stopTimer()를 포함한 건, 기존 타이머를 리셋해주지 않으면 액션이나 시간이 충돌날 수 있기 때문이다.
- stopTimer: 타이머를 리셋 및 정지 해주는 역할을 한다.
- manageTimerWithIndex: 드래그 제스처에서 onEnded가 디버깅을 타지 않아서, 내 상황에선 해당 기능이 적용이 되지 않는 걸 알게 되어서 구현하지 않았다.
대신 우회적인 방법으로 드래그를 시작했을 때,
- dragGesture의 onChanged 감지
- 드래그 액션이 감지 되었기 때문에 충돌 방지를 위해 타이머를 멈춘다 (stopTimer 실행)
- 인덱스가 바뀌었다 -> 사용자의 드래그 액션이 멈췄다 -> 타이머를 실행해서 다시 컬러를 자동으로 순환한다 (startTimer 실행)
- 드래그 액션이 감지 (stopTimer 실행) 되었는데 인덱스가 안 바뀌었다 -> 멈춘 타이머를 실행시켜 다시 자동으로 순환시킨다 (startTimer 실행)
- 인덱스가 바뀌지 않았을 때, 타이머가 그대로 돌아가면 컬러 전환 타임이 부자연스러워서 dispatchQueue.main.asyncAfter로 0.5초 정도 늦춰서 작동하도록 했다.
사실 이 방식이 가장 최선인지는 모르겠다 (특히 startTimer와 stopTimer 호출 방식이 아주 깔끔하진 않다고 생각한다). 하지만 현재까지 오류가 나거나 구현 실패한 부분은 확인되지 않아 이 방법을 유지하기로 했다. 계속 리팩토링 중이므로, 더 나은 방법이 있거나 찾게 되면 그때 다시 수정할 예정이다.
3. 메인 슬라이드 뷰 구현: TimerSlides
import SwiftUI
import Combine
struct TimerSlides: View {
// MARK: - properties
/// 무한으로 순환할 배열
var colors: [Color] = [.red, .orange, .yellow, .blue, .green]
/// 애니메이션 타이머
@State private var timer: Timer?
/// 현재 인덱스 저장
@State private var currentIndex = 0
// MARK: - body
var body: some View {
// MARK: - Image Slide
InfinitePageView(
selection: $currentIndex,
before: { $0 == 0 ? colors.count - 1 : $0 - 1 },
after: { $0 == colors.count - 1 ? 0 : $0 + 1 },
view: { index in
ZStack {
Rectangle()
.fill(colors[index])
.tag(index)
} // Z
.ignoresSafeArea()
}) // InfinitePageView
.ignoresSafeArea()
// 인덱스 변화
.onChange(of: currentIndex, { _, newIndex in
currentIndex = newIndex
startTimer()
})
// MARK: - 액션
// 수동 드래그 시 타이머 조정 액션
.gesture(manageTimerWithIndex())
// MARK: - 인디케이터
.overlay(
alignment: .bottom,
content: { imageCustomIndicator() }
) // Overlay
// MARK: - LifeCycle (타이머 작동 관리)
.onAppear {
startTimer()
}
.onDisappear {
stopTimer()
}
} // body
} // view
- onAppear에서 해당 뷰가 그려지자마자 startTimer()를 호출하여 이미지 배열이 3초마다 자동으로 순환하게 한다.
- gesture를 통해 사용자의 수동 드래그 액션에 따라 타이머가 멈췄다가 다시 작동되도록 관리한다.
- onDisappear에서 해당 뷰가 사라질 때 stopTimer()를 호출하여 타이머를 해제한다. 이는 메모리 누수 방지 및 불필요한 작동 중단을 위함이다.
Indicator 부분은 커스텀해서 적용한 거기도 하고, 이전에 쓴 글도 있어서 인디케이터 커스텀은 해당 글을 참고하는 걸 추천한다.
https://calliek.tistory.com/45
[SwiftUI] TabView page indicator 커스텀하기
프로젝트 진행 중 내가 맡은 ui에서 슬라이드뷰를 만들어야 했는데, 피그마에 올라와 있는 디자인 가이드가 위 이미지와 같았다. 따라서 SwiftUI에서 Tabview가 제공해주는 원형 인디케이터를
calliek.tistory.com
스유로 작업을 하게 된 초기에 짠 코드라 수정해야할 게 조금 보여서 조만간 글 수정을 해야할 것 같지만,,,^^,,,,
References
https://sheep1sik.tistory.com/144
[ SwiftUI ] 광고배너 만들기
SwiftUI를 통해 광고배너를 만들어봤습니다.먼저 광고배너를 만들기 위해서 TabView를 채택하여 구현을 했고 구현 목표는 아래와 같았습니다. 3초간격으로 화면 이동무한적인 광고배너 ( 1 -> 2 -> 3 -
sheep1sik.tistory.com
https://stackoverflow.com/questions/65457609/bidirectional-infinite-pageview-in-swiftui
Bidirectional infinite PageView in SwiftUI
I'm trying to make a bidirectional TabView (with .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))) whose datasource will change over time. Below is the code that describes what is expected...
stackoverflow.com
DragGestures onEnded never called if canceled by ScrollView
I have the following problem. I have a View inside a ScrollView that I want to drag. Now, everything works fine, the dragging occurs and the scrolling as well. But once I try to do both, the dragOf...
stackoverflow.com
'Dev > 구현' 카테고리의 다른 글
| [SwiftUI] Infinite Carousel 구현하기 2 (feat.Timer) (0) | 2024.09.11 |
|---|---|
| [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 |