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() | |
} | |
} |
Hi @beader, thanks for this simple and beautiful code, but I will be faced with an issue when I try to change the number of items in the list is not worked correctly, and the index is not correct. can you help me how to solve this issue? thanks.
Can you provide some code snippets?
Sorry for taking your time, I fix it. thanks.
I found it easier to implement using UIViewControllerRepresentable & UIPageViewController.
Check this gist
@beader nice code, thanks for sharing it.
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
}
}
}
Hi @beader, thanks for this simple and beautiful code, but I will be faced with an issue when I try to change the number of items in the list is not worked correctly, and the index is not correct. can you help me how to solve this issue? thanks.