I am trying to create a horizontal carousel using SwiftUI ScrollView
This is what I have done so far:
struct CarouselView<Content: View>: View {
let content: Content
@State private var currentIndex = 0
@State private var contentSize: CGSize = .zero
private var showsIndicators: Bool
private var spacing: CGFloat
private var offset: CGFloat
private var shouldSnap: Bool
init(showsIndicators: Bool = true,
spacing: CGFloat = 0,
offset: CGFloat = 0,
shouldSnap: Bool = false,
@ViewBuilder content: @escaping () -> Content) {
self.content = content()
self.showsIndicators = showsIndicators
self.spacing = spacing
self.offset = offset
self.shouldSnap = shouldSnap
}
var body: some View {
ScrollView(.horizontal, showsIndicators: showsIndicators) {
LazyHStack(spacing: spacing) {
content
.offset(x: offset)
}.apply {
if #available(iOS 17.0, *), shouldSnap {
$0.scrollTargetLayout()
} else {
$0
}
}
}
.apply {
if #available(iOS 17.0, *), shouldSnap {
$0.scrollTargetBehavior(.viewAligned)
} else {
$0
}
}
}
}
extension View {
func apply<V: View>(@ViewBuilder _ block: (Self) -> V) -> V { block(self) }
}
Then I make use of this as follows:
struct ContentView: View {
let imagesNames: [String] = ["img-1", "img-2", "img-3"]
var body: some View {
VStack(spacing: 10, content: {
pagingCarousel
continuousCarousel
})
}
private var pagingCarousel: some View {
CarouselView(showsIndicators: false,
spacing: 10,
shouldSnap: true) {
ForEach(imagesNames, id: \.self) { image in
createTile(with: image)
}
}
}
private var continuousCarousel: some View {
CarouselView(showsIndicators: true,
spacing: 20,
offset: 20) {
ForEach(imagesNames, id: \.self) { image in
createTile(with: image)
}
}
}
private func createTile(with image: String) -> some View {
ZStack {
Image(image)
.resizable()
.scaledToFill()
.overlay(Color.black.opacity(0.5))
Text("Headline")
.bold()
.foregroundStyle(.white)
}
.frame(height: 200)
.cornerRadius(30)
.clipped()
}
}
Functionality wise, this works well and gives me this:
The issue I have currently is that I want the scrollview to only take up as much space as its content with respect to its height.
While it is not so clear in the example above, if I add a background to the carousel view, you can see how much extra space is taken up.
So now updating this:
var body: some View {
VStack(spacing: 10, content: {
pagingCarousel
.background(.yellow)
continuousCarousel
.background(.green)
})
}
Gives me this:
How can I force my scrollview to set its height based on the height of the content?
I gave a few of these solutions a go but it just caused my content to disappear:
This is because LazyHStack
is designed to lazily draw its views. It will not try to lay everything out and find the maximum height it needs (that's not lazy at all).
Therefore, it tries to fill all the available space in this case. In other cases, where its ideal size needs to be used, it will calculate its ideal size using only the first view in the stack. You can force this by doing .fixedSize(horizontal: false, vertical: true)
.
In your toy example here, all the images have a constant height of 200, so it is trivial to fix this by setting the carousel's height with .frame(height: 200)
. In general, you need some other way to find out what the maximum height will be, and do something like .frame(height: findMaxHeight(data))
. For example, the images might come from an API call, which also happens to return the image's sizes. Then you can use that information to find the maximum height, without drawing all the views.
If there is no way to know the maximum height other than drawing the views out, you should just use a non-lazy HStack
instead.