Created
August 23, 2023 13:50
-
-
Save levochkaa/38d5203c8b2f14943d261df620092a26 to your computer and use it in GitHub Desktop.
CarouselView in SwiftUI
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Combine | |
import SwiftUI | |
// https://stackoverflow.com/questions/58896661/swiftui-create-image-slider-with-dots-as-indicators | |
struct CarouselView<Content>: View where Content: View { | |
let maxIndex: Int | |
var content: () -> Content | |
@State private var offset = CGFloat.zero | |
@State private var dragging = false | |
@State var index = 0 | |
@State var cancelDrag: AnyCancellable? | |
init(count: Int?, @ViewBuilder content: @escaping () -> Content) { | |
if count != nil { | |
self.maxIndex = count! - 1 | |
} else { | |
if let value = Mirror(reflecting: content()).descendant("value") { | |
let lessonMirror = Mirror(reflecting: value) | |
self.maxIndex = lessonMirror.children.count - 1 | |
} else { | |
self.maxIndex = 0 | |
} | |
} | |
self.content = content | |
} | |
func onEnded(_ value: _ChangedGesture<DragGesture>.Value, geometry: GeometryProxy) { | |
cancelDrag?.cancel() | |
let predictedEndOffset = -CGFloat(index) * geometry.size.width + value.predictedEndTranslation.width | |
let predictedIndex = Int(round(predictedEndOffset / -(geometry.size.width + 0.0001))) | |
index = clampedIndex(from: predictedIndex) | |
withAnimation(.easeOut) { // animation only on false! | |
dragging = false | |
} | |
} | |
var body: some View { | |
ZStack(alignment: .bottom) { | |
GeometryReader { geometry in | |
ScrollView(.horizontal, showsIndicators: false) { | |
LazyHStack(spacing: 0) { | |
content() | |
.frame(width: geometry.size.width, height: geometry.size.height) | |
.clipped() | |
}.allowsHitTesting(false) | |
} | |
.content | |
.offset(x: offset(in: geometry), y: 0) | |
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .leading) | |
.clipped() | |
.overlay { | |
Rectangle() | |
.opacity(0.00001) | |
.gesture( | |
DragGesture(minimumDistance: 20) | |
.onChanged { value in | |
dragging = true | |
offset = -CGFloat(index) * geometry.size.width | |
+ max(min(value.translation.width, geometry.size.width), -geometry.size.width) | |
let just = Just<Bool>(true).delay(for: .seconds(1), scheduler: RunLoop.main) | |
cancelDrag?.cancel() | |
cancelDrag = just.sink { _ in | |
onEnded(value, geometry: geometry) | |
} | |
} | |
.onEnded { value in | |
onEnded(value, geometry: geometry) | |
}, | |
including: .gesture | |
) | |
} | |
} | |
if maxIndex > 0 { | |
PageIndicator(index: $index, maxIndex: maxIndex) | |
} | |
} | |
} | |
func offset(in geometry: GeometryProxy) -> CGFloat { | |
if dragging { | |
return max(min(offset, 0), -CGFloat(maxIndex) * geometry.size.width) | |
} else { | |
return -CGFloat(index) * geometry.size.width | |
} | |
} | |
func clampedIndex(from predictedIndex: Int) -> Int { | |
let newIndex = min(max(predictedIndex, index - 1), index + 1) | |
return max(min(newIndex, maxIndex), 0) | |
} | |
} | |
struct PageIndicator: View { | |
@Binding var index: Int | |
let maxIndex: Int | |
var body: some View { | |
HStack(spacing: 8) { | |
ForEach(0...maxIndex, id: \.self) { currentIndex in | |
ZStack { | |
Circle() | |
.fill(.white) | |
.frame(width: 12, height: 12) | |
if currentIndex == index { | |
Circle() | |
.fill(.blue) | |
.frame(width: 9.2, height: 9.2) | |
} else { | |
Circle() | |
.stroke(.blue, lineWidth: 1.2) | |
.frame(width: 8, height: 8) | |
} | |
} | |
} | |
} | |
.padding(15) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment