Skip to content

Instantly share code, notes, and snippets.

@beader
Last active July 3, 2024 22:21
Show Gist options
  • Save beader/e1312aa5b88af30407bde407235fbe67 to your computer and use it in GitHub Desktop.
Save beader/e1312aa5b88af30407bde407235fbe67 to your computer and use it in GitHub Desktop.
Infinite Scrollable TabView using SwiftUI

Infinite Scrollable TabView using SwiftUI

Demo

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.

InfiniteTabView

//
// 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()
}
}
@Muhammadbarznji
Copy link

Sorry for taking your time, I fix it. thanks.

@beader
Copy link
Author

beader commented May 2, 2023

I found it easier to implement using UIViewControllerRepresentable & UIPageViewController.
Check this gist

@Muhammadbarznji
Copy link

@beader nice code, thanks for sharing it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment