ํ๋ก๋ชจ์ ํ์ ํ๋ฉด์ ๋ด๋นํด์ ๋ง๋ค์๋๋ฐ, ๋ฌดํ ์คํฌ๋กค(์ด๋ฏธ์ง ๋ฐฐ์ด ์ํ) + ํ์ด๋จธ ์๋์ด ๊ฐ๋ฅํ ๋ก ๊ตฌํ์ ํด์ผํ๋ค.
- ๋ด๊ฐ ๊ตฌํํ๊ณ ์ ํ ๊ฒ:
1. ์ฒซ๋ฒ์งธ ์ธ๋ฑ์ค ์ด๋ฏธ์ง์์ ์ผ์ชฝ์ผ๋ก ์ค์์ดํ ํ๋ฉด ๋ง์ง๋ง ์ธ๋ฑ์ค ์ด๋ฏธ์ง๊ฐ ๋์ค๊ณ , ๋ง์ง๋ง ์ธ๋ฑ์ค์์ ์ค๋ฅธ์ชฝ์ผ๋ก ์ค์์ดํํ๋ฉด ์ฒซ๋ฒ์งธ ์ธ๋ฑ์ค ์ด๋ฏธ์ง๊ฐ ๋์ค๋ ๋ฌดํ ์ํ ๊ตฌ์กฐ์ ์คํฌ๋กค
2. ๋ณ๋์ ์ ์ค์ฒ๊ฐ ์์ผ๋ฉด 3์ด๋ง๋ค ์ด๋ฏธ์ง ๋ณ๊ฒฝ + ์ด๋ฏธ์ง ๋ฐฐ์ด ์ํ
3. ์ ์ ๊ฐ ์๋์ผ๋ก ์ค์์ดํ ์ ๋ฐฐ์ด ์์์ ๋ง๊ฒ ์ด๋ฏธ์ง ๋ณ๊ฒฝ + ์ธ๋์ผ์ดํฐ ๋ณ๊ฒฝ
- ๋ด๊ฐ ๊ฒช์ ๊ฒ:
1. ํ์ด๋จธ ์กฐ์ ์ด ๋์ง ์์์ ์ด๋ฏธ์ง๋ฅผ ๋ฐ๋ง ์ค์์ดํ ํ์ ๋ ๋ฐ์ฉ ๋์จ ์ด๋ฏธ์ง๋ค์ด ์ด์ /๋ค์ ์ด๋ฏธ์ง๋ก ๋ณ๊ฒฝ + ํ์ด๋จธ ์๋
2. ์๋์ผ๋ก ์ค์์ดํ ํด์ ํ์ด๋จธ ๋ฉ์ถ๊ฒ ํ์ ์ ๋ค์ ์๋์ด ๋์ง ์๋ ์ด์ --> DragGesture์ onEnded๊ฐ ์ธ์ ๋์ง ์์
3. ์ด๋ฏธ์ง์ ์ธ๋ฑ์ค๊ฐ ๋ฐ๋์ง ์์ผ๋ฉด ํ์ด๋จธ ๋ฏธ์๋
2๋ฒ์ ๋ฌธ์ ๊ฐ์ ๊ฒฝ์ฐ ์ ํ์์ ๊ตฌ์ฒด์ ์ผ๋ก ์ค๋ช ํด์ค ๊ฑธ ์ฐพ์ ์ ์์๋๋ฐ, stack overflow์์ ๋น์ท~ํ ๋ฌธ์ ๋ฅผ ๊ฒช๋ ๊ธ ์ค ์์ ๊ฐ์ ์ถ์ธก ๋ต๋ณ์ ๋ฐ๊ฒฌํ๋ค. ๋๋ tabview๋ก ๋ฌดํ ์คํฌ๋กค์ ๊ตฌํ ํ๊ธฐ ๋๋ฌธ์ ์ด๋ผ? ์ถ์์ผ๋, tabview๋ ์คํฌ๋กค ๊ธฐ๋ฅ์ ๊ฐ์ง๊ณ ์๊ธฐ ๋๋ฌธ์ ๋์ผํ ๋ฌธ์ ์ธ ๊ฒ ๊ฐ์๋ค.
๋๋๊ทธ ์ก์ ๊ณผ ์คํฌ๋กค ์ก์ ์ด ์ถฉ๋ ๋์ onEnded๋ ์ธ์์ด ๋ถ๊ฐํ๊ฒ ์๋๊น ์๋ฌด๋๋ SwiftUI๋ก ๋ช ๋ฒ ์ก์ ๊ณผ ๊ด๋ จํ ์ผ์ ํด๊ฒฐํ๋ค๋ณด๋ ์คํฌ๋กค ์ก์ ๊ณผ ๋๋๊ทธ ์ ์ค์ฒ์ ์ฐ์ ์์๋ฅผ ๊ตณ์ด ๋ฐ์ง์๋ฉด ์คํฌ๋กค๋ทฐ/ํญ๋ทฐ์ ๋๋๊ทธ ์ ์ค์ฒ > ์ ์ค์ฒ์ ๋๋๊ทธ ์ ์ค์ฒ์ธ ๋ชจ์์ด์๋ค.
- ํด๊ฒฐ๋ฐฉ์:
๋ฌดํ ์คํฌ๋กค์ scroll์ด ๊ฐ๋ฅํ view๋ฅผ ์ฌ์ฉ ์ ํ๋ฉด ๋๊ฒ ์ง๋ง (์ค์ ๋ก ๋ฐ๋ก ์๋์ ์ถ๊ฐํ ๋ค๋ฅธ ๋ถ์ velog ๊ฒ์๊ธ์ ์ฐธ๊ณ ํ๋ฉด ๋ ๊ฒ ๊ฐ๋ค) ์ด๋ฏธ tabview๋ก ๊ตฌํํ๊ธฐ๋ ํ๊ณ , ๋ค์ ์ฝ๋๋ฅผ ์ง๊ธฐ์ ์๊ฐ์ด ๋ถ์กฑํด์ ์๋ ๊ตฌํํด๋์ view์์ dragGesture์ ์ธ๋ฑ์ค ์ถ์ ์ผ๋ก ํด๊ฒฐํ๊ธฐ๋ก ํ๋ค.
๊ตฌํ
- ๋ฌดํ ์คํฌ๋กค์ ๋ณด์กฐํ ์ปค์คํ ๋ทฐ:
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 ํญ๋ชฉ์ ๋ ์ฐธ๊ณ ํ๋ฉด ์ข์ ๊ฒ ๊ฐ๋ค.
๊ฑฐ๊ธฐ์ ์ฐพ์ ์ฝ๋์ ์์ธํ ์ค๋ช ์ ๋ณด๊ณ ๋ด ์ํฉ์ ๋ง๊ฒ ์ด์ง ๋ณ๊ฒฝํ๊ธฐ ๋๋ฌธ์ ๊ฐ์ ์ํฉ์ ๋ง๊ฒ ๋ณ๊ฒฝํ๋ฉด ๋ ๊ฒ ๊ฐ๋ค.
- ํ์ด๋จธ ๋ฐ ์ก์ ๊ตฌํ:
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๊ฐ ๋๋ฒ๊น ์ ํ์ง ์์์, ๋ด ์ํฉ์์ ํด๋น ๊ธฐ๋ฅ์ด ์ ์ฉ์ด ๋์ง ์๋ ๊ฑธ ์๊ฒ ๋์ด์ ๊ตฌํํ์ง ์์๋ค.
๋์ ์ฐํ์ ์ธ ๋ฐฉ๋ฒ์ผ๋ก ๋๋๊ทธ๋ฅผ ์์ํ์ ๋,
1. dragGesture์ onChanged ๊ฐ์ง
2. ๋๋๊ทธ ์ก์ ์ด ๊ฐ์ง ๋์๊ธฐ ๋๋ฌธ์ ์ถฉ๋ ๋ฐฉ์ง๋ฅผ ์ํด ํ์ด๋จธ๋ฅผ ๋ฉ์ถ๋ค (stopTimer ์คํ)
3. ์ธ๋ฑ์ค๊ฐ ๋ฐ๋์๋ค -> ์ฌ์ฉ์์ ๋๋๊ทธ ์ก์ ์ด ๋ฉ์ท๋ค -> ํ์ด๋จธ๋ฅผ ์คํํด์ ๋ค์ ์ปฌ๋ฌ๋ฅผ ์๋์ผ๋ก ์ํํ๋ค (startTimer ์คํ)
4. ๋๋๊ทธ ์ก์ ์ด ๊ฐ์ง (stopTimer ์คํ) ๋์๋๋ฐ ์ธ๋ฑ์ค๊ฐ ์ ๋ฐ๋์๋ค -> ๋ฉ์ถ ํ์ด๋จธ๋ฅผ ์คํ์์ผ ๋ค์ ์๋์ผ๋ก ์ํ์ํจ๋ค (startTimer ์คํ)
5. ์ธ๋ฑ์ค๊ฐ ๋ฐ๋์ง ์์์ ๋, ํ์ด๋จธ๊ฐ ๊ทธ๋๋ก ๋์๊ฐ๋ฉด ์ปฌ๋ฌ ์ ํ ํ์์ด ๋ถ์์ฐ์ค๋ฌ์์ dispatchQueue.main.asyncAfter๋ก 0.5์ด ์ ๋ ๋ฆ์ถฐ์ ์๋ํ๋๋ก ํ๋ค.
..ํ๋ ๋ฐฉ์์ผ๋ก ์ํ๋๋๋ก ํด์ฃผ๋ ํจ์๋ค. ์ฆ, ๋๋ dragGesture์ trigger๋ฅผ ๋๋๊ทธ ๊ทธ ์์ฒด๋ก ํ๋ฉด ํญ๋ฐ ์ก์ ๊ณผ ํผ๋๋๊ธฐ ๋๋ฌธ์ currentIndex๋ก ์ง์ ํ์ฌ ๋ด๊ฐ ๊ตฌํํ๊ณ ์ ํ ๋ถ๋ถ๋ค์ ๊ตฌํํ๋ค.
์ฌ์ค ๋ฒ ์คํธ์ธ ๋ฐฉ๋ฒ์ ์๋ ๊ฒ ๊ฐ์๋ฐ(startTimer์ stopTimer๊ฐ ๋ฑ ๊น๋ํ ์ ์ธ๋ ๊ฒ ์๋๋ผ์), ์ผ๋จ ์ค๋ฅ๊ฐ ๋๊ฑฐ๋ ๊ตฌํ ์คํจํ ๋ถ๋ถ์ ํ์ธ ๋์ง ์์์ ์์ ๊ฐ์ ๋ฐฉ๋ฒ์ ์ ์งํ๊ธฐ๋ก ํ๋ค. ๊ณ์ ๋ฆฌํฉํ ๋ง ํ๊ณ ์๊ธฐ ๋๋ฌธ์ ๋ ๋์ ๋ฐฉ๋ฒ์ด ์๊ฑฐ๋ ์ฐพ์ผ๋ฉด ๊ทธ๋ ๋ค์ ์์ ํ๊ธฐ๋ก.
- ๋ฉ์ธ ์ฌ๋ผ์ด๋ ๋ทฐ ๊ตฌํ:
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์ด๋ง๋ค ์๋์ผ๋ก ์ํํ๊ฒ ํ๋ค.
- onChange์ gesture๋ฅผ ํตํด ์ํฉ์ ๋ง๊ฒ ํ์ด๋จธ๊ฐ ๋ฉ์ท๋ค๊ฐ ๋ค์ ์๋ ๋๋๋ก ํ๋ค.
- onDisappear์์ ํด๋น ๋ทฐ๊ฐ ์ฌ๋ผ์ง ๋ stopTimer๋ฅผ ํธ์ถํ์ฌ ํ์ด๋จธ๋ฅผ ํด์ ํ๋ค.
Indicator ๋ถ๋ถ์ ์ปค์คํ ํด์ ์ ์ฉํ ๊ฑฐ๊ธฐ๋ ํ๊ณ , ์ด์ ์ ์ด ๊ธ๋ ์์ด์ ์ธ๋์ผ์ดํฐ ์ปค์คํ ์ ํด๋น ๊ธ์ ์ฐธ๊ณ ํ๋ ๊ฑธ ์ถ์ฒํ๋ค.
https://calliek.tistory.com/45
์ค์ ๋ก ์์ ์ ํ๊ฒ ๋ ์ด๊ธฐ์ ์ง ์ฝ๋๋ผ ์์ ํด์ผํ ๊ฒ ์กฐ๊ธ ๋ณด์ฌ์ ์กฐ๋ง๊ฐ ๊ธ ์์ ์ ํด์ผํ ๊ฒ ๊ฐ์ง๋ง,,,^^,,,,
References
https://sheep1sik.tistory.com/144
https://stackoverflow.com/questions/65457609/bidirectional-infinite-pageview-in-swiftui
'๐ 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] pre/next buttons๊ฐ ์๋ ์ด๋ฏธ์ง ์ฌ๋ผ์ด๋ ๊ตฌํํ๊ธฐ (2) | 2024.05.16 |