Checkout the demo video in the comment below.
Using ZStack with 3 container views to build a infinite paged tabView.
The offsets and page indices for each container view builder are calculated using a periodic function and current page number.
// | |
// ContentView.swift | |
// InfinityTabView | |
// | |
// Created by beader on 2022/10/9. | |
// | |
import SwiftUI | |
struct ContentView: View { | |
let colors: [Color] = [.red, .green, .blue] | |
var body: some View { | |
GeometryReader { geometry in | |
InfiniteTabPageView(width: geometry.size.width) { page in | |
Text("\(page)") | |
.frame(maxWidth: .infinity, maxHeight: .infinity) | |
.background(colors[ (page % 3 + 3) % 3 ]) | |
} | |
.frame(height: 300) | |
} | |
} | |
} | |
struct InfiniteTabPageView<Content: View>: View { | |
@GestureState private var translation: CGFloat = .zero | |
@State private var currentPage: Int = 0 | |
@State private var offset: CGFloat = .zero | |
private let width: CGFloat | |
private let animationDuration: CGFloat = 0.25 | |
let content: (_ page: Int) -> Content | |
init(width: CGFloat = 390, @ViewBuilder content: @escaping (_ page: Int) -> Content) { | |
self.width = width | |
self.content = content | |
} | |
private var dragGesture: some Gesture { | |
DragGesture(minimumDistance: 0) | |
.updating($translation) { value, state, _ in | |
let translation = min(width, max(-width, value.translation.width)) | |
state = translation | |
} | |
.onEnded { value in | |
offset = min(width, max(-width, value.translation.width)) | |
let predictEndOffset = value.predictedEndTranslation.width | |
withAnimation(.easeOut(duration: animationDuration)) { | |
if offset < -width / 2 || predictEndOffset < -width { | |
offset = -width | |
} else if offset > width / 2 || predictEndOffset > width { | |
offset = width | |
} else { | |
offset = 0 | |
} | |
} | |
DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) { | |
if offset < 0 { | |
currentPage += 1 | |
} else if offset > 0 { | |
currentPage -= 1 | |
} | |
offset = 0 | |
} | |
} | |
} | |
var body: some View { | |
ZStack { | |
content(pageIndex(currentPage + 2) - 1) | |
.frame(maxWidth: .infinity, maxHeight: .infinity) | |
.offset(x: CGFloat(1 - offsetIndex(currentPage - 1)) * width) | |
content(pageIndex(currentPage + 1) + 0) | |
.frame(maxWidth: .infinity, maxHeight: .infinity) | |
.offset(x: CGFloat(1 - offsetIndex(currentPage + 1)) * width) | |
content(pageIndex(currentPage + 0) + 1) | |
.frame(maxWidth: .infinity, maxHeight: .infinity) | |
.offset(x: CGFloat(1 - offsetIndex(currentPage)) * width) | |
} | |
.contentShape(Rectangle()) | |
.offset(x: translation) | |
.offset(x: offset) | |
.gesture(dragGesture) | |
.clipped() | |
} | |
private func pageIndex(_ x: Int) -> Int { | |
// 0 0 0 3 3 3 6 6 6 . . . 周期函数 | |
// 用来决定 3 个 content 分别应该展示第几页 | |
Int((CGFloat(x) / 3).rounded(.down)) * 3 | |
} | |
private func offsetIndex(_ x: Int) -> Int { | |
// 0 1 2 0 1 2 0 1 2 ... 周期函数 | |
// 用来决定静止状态 3 个 content 的摆放顺序 | |
if x >= 0 { | |
return x % 3 | |
} else { | |
return (x + 1) % 3 + 2 | |
} | |
} | |
} | |
struct ContentView_Previews: PreviewProvider { | |
static var previews: some View { | |
ContentView() | |
} | |
} |
Vertical Infinite Tab Page View Component
struct ContentView: View {
let colors: [Color] = [.red, .green, .blue]
var body: some View {
GeometryReader { geometry in
VerticalInfiniteTabPageView(height: geometry.size.height) { page in
Text("\(page)")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(colors[(page % 3 + 3) % 3])
}
}
}
}
struct VerticalInfiniteTabPageView<Content: View>: View {
@State private var translation: CGFloat = .zero
@State private var currentPage: Int = 0
@State private var offset: CGFloat = .zero
private let height: CGFloat
private let animationDuration: CGFloat = 0.25
let content: (_ page: Int) -> Content
init(height: CGFloat = 800, @ViewBuilder content: @escaping (_ page: Int) -> Content) {
self.height = height
self.content = content
}
private var dragGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
// محاسبه translation بر اساس جابهجایی فعلی
translation = min(height, max(-height, value.translation.height))
}
.onEnded { value in
offset = min(height, max(-height, value.translation.height))
let predictEndOffset = value.predictedEndTranslation.height
withAnimation(.easeOut(duration: animationDuration)) {
if offset < -height / 2 || predictEndOffset < -height {
offset = -height
} else if offset > height / 2 || predictEndOffset > height {
offset = height
} else {
offset = 0
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) {
if offset < 0 {
currentPage += 1
} else if offset > 0 {
currentPage -= 1
}
offset = 0
}
// بازنشانی translation به صفر
translation = 0
}
}
var body: some View {
ZStack {
content(pageIndex(currentPage + 2) - 1)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.offset(y: CGFloat(1 - offsetIndex(currentPage - 1)) * height)
content(pageIndex(currentPage + 1) + 0)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.offset(y: CGFloat(1 - offsetIndex(currentPage + 1)) * height)
content(pageIndex(currentPage + 0) + 1)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.offset(y: CGFloat(1 - offsetIndex(currentPage)) * height)
}
.contentShape(Rectangle())
.offset(y: translation)
.offset(y: offset)
.gesture(dragGesture)
.clipped()
}
private func pageIndex(_ x: Int) -> Int {
Int((CGFloat(x) / 3).rounded(.down)) * 3
}
private func offsetIndex(_ x: Int) -> Int {
if x >= 0 {
return x % 3
} else {
return (x + 1) % 3 + 2
}
}
}
@beader nice code, thanks for sharing it.