iosswiftswiftuiswiftui-scrollview

Dynamically set horizontal scroll view height based on max content height in SwiftUI


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:

SwiftUI Carousel ScrollView LazyHStack

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:

SwiftUI ScrollView HStack LazyHStack Carousel Geometry Reader

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:

  1. Solution 1
  2. Solution 2

Solution

  • 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.