Skip to content

Instantly share code, notes, and snippets.

@Codelaby
Last active February 6, 2025 09:00
Show Gist options
  • Save Codelaby/ef50289099a86325e9cbfb4c0852e831 to your computer and use it in GitHub Desktop.
Save Codelaby/ef50289099a86325e9cbfb4c0852e831 to your computer and use it in GitHub Desktop.
InfiniteCarousel IOS18
//
// CarouselInfinite.swift
// IOS18Playground
//
// Created by Codelaby on 28/9/24.
//
// based: https://www.youtube.com/watch?v=p1nN9eFOPNQ
import SwiftUI
// MARK: Sample model
struct ColorDemoModel: Identifiable, Hashable, Sendable {
let id: UUID = .init()
let title: String
let summary: String
let color: Color
static let allItems: [ColorDemoModel] = [
ColorDemoModel(title: "Red", summary: "This is red", color: .red),
ColorDemoModel(title: "Green", summary: "This is green", color: .green),
ColorDemoModel(title: "Blue", summary: "This is blue", color: .blue),
ColorDemoModel(title: "Yellow", summary: "This is yellow", color: .yellow),
ColorDemoModel(title: "Orange", summary: "This is orange", color: .orange),
ColorDemoModel(title: "Pink", summary: "This is pink", color: .pink),
ColorDemoModel(title: "Mint", summary: "This is Mint", color: .mint),
ColorDemoModel(title: "Teal", summary: "This is teal", color: .teal),
ColorDemoModel(title: "Cyan", summary: "This is cyan", color: .cyan),
ColorDemoModel(title: "Indigo", summary: "This is indigo", color: .indigo),
ColorDemoModel(title: "Purple", summary: "This is purple", color: .purple),
ColorDemoModel(title: "Gray", summary: "This is Gray", color: .gray),
ColorDemoModel(title: "Brown", summary: "This is Brown", color: .brown)
// ColorItem(title: "Red", summary: "This is red", color: .red),
// ColorItem(title: "Green", summary: "This is green", color: .green),
// ColorItem(title: "Blue", summary: "This is blue", color: .blue),
// ColorItem(title: "Yellow", summary: "This is yellow", color: .yellow),
// ColorItem(title: "Orange", summary: "This is orange", color: .orange),
// ColorItem(title: "Pink", summary: "This is pink", color: .pink),
// ColorItem(title: "Mint", summary: "This is Mint", color: .mint),
// ColorItem(title: "Teal", summary: "This is teal", color: .teal),
// ColorItem(title: "Cyan", summary: "This is cyan", color: .cyan),
// ColorItem(title: "Indigo", summary: "This is indigo", color: .indigo),
// ColorItem(title: "Purple", summary: "This is purple", color: .purple),
// ColorItem(title: "Gray", summary: "This is Gray", color: .gray),
// ColorItem(title: "Brown", summary: "This is Brown", color: .brown)
]
}
// MARK: InfiniteCarousel
struct InfiniteCarousel<Content: View>: View {
@Binding var scrollPosition: Int
@ViewBuilder var content: Content
@State private var isScrolling: Bool = false
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
Group(subviews: content) { subviews in
if subviews.isEmpty {
Text("no views collection")
} else {
LazyHStack(spacing: 0) {
if let lastItem = subviews.last {
lastItem
.containerRelativeFrame(.horizontal)
.id(-1)
.onChange(of: isScrolling) { oldValue, newValue in
if !newValue && scrollPosition == -1 {
scrollPosition = subviews.count - 1
}
}
}
ForEach(Array(subviews.enumerated()), id: \.element.id) { index, subview in
subview
.containerRelativeFrame(.horizontal)
.id(index)
}
if let firstItem = subviews.first {
firstItem
.containerRelativeFrame(.horizontal)
.id(subviews.count)
.onChange(of: isScrolling) { oldValue, newValue in
if !newValue && scrollPosition == subviews.count {
scrollPosition = 0
}
}
}
}
.scrollTargetLayout()
}
}
}
.scrollTargetBehavior(.paging)
.onScrollPhaseChange { oldPhase, newPhase in
isScrolling = newPhase.isScrolling
}
.scrollPosition(id: .init(
get: { scrollPosition },
set: { value, _ in
if let value {
scrollPosition = value
}
}
))
.onChange(of: scrollPosition, initial: true, { oldValue, newValue in
if oldValue == newValue {
DispatchQueue.main.asyncAfter(deadline: .now() ) {
scrollPosition = newValue
//scrollViewProxy.scrollTo(newValue * 10, anchor: .center)
}
}
})
}
}
// MARK: DotPageIndicator
struct DotPageIndicator: View {
let currentIndex: Int
let max: Int
private var adjustedIndex: Int {
if currentIndex < 0 {
return max
} else if currentIndex > max {
return 0
} else {
return currentIndex
}
}
init(currentIndex: Int, max: Int) {
self.currentIndex = currentIndex
self.max = max - 1
}
var body: some View {
HStack {
ForEach(Array(0..<max + 1), id: \.self) { index in
Circle()
.fill(index == adjustedIndex ? Color.accentColor : Color.accentColor.mix(with: .white, by: 0.5))
.frame(width: 5, height: 5)
}
}
.padding(4)
.background(.thinMaterial, in: .capsule)
}
}
// MARK: Playground
struct CarouselDemo: View {
@State var currentIndex: Int = 6
var body: some View {
VStack {
InfiniteCarousel(scrollPosition: $currentIndex) {
ForEach(ColorDemoModel.allItems) { item in
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(item.color.gradient)
.padding(.horizontal)
.overlay {
VStack {
Text(item.title)
.font(.title)
.fontWeight(.bold)
.foregroundStyle(.white)
Text(item.summary)
.font(.subheadline)
.foregroundStyle(.white)
}
}
.onTapGesture {
print("tap", item)
}
}
}
.frame(height: 240)
DotPageIndicator(currentIndex: currentIndex, max: ColorDemoModel.allItems.count)
}
}
}
#Preview {
VStack {
SampleTitleView(title: "Carousel infinite in SwiftUI", summary: "reusable carousel with dot page indicador")
Spacer()
CarouselDemo()
.frame(maxHeight: 280)
Spacer()
CreditsView()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment