2

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
5
  • @Sweeper - you are right, it isn't needed and also wasn't used. I forgot to remove it from one of the example solutions I tried. I have updated the code to reflect your comment - no geometry reader ... however, the result is exactly the same as shown in my question cause I never used the geometry reader for anything. Commented Apr 30, 2024 at 7:28
  • Ah, it's the laziness of the lazy stacks again. LazyHStack doesn't know what the maximum height of its contents will be (because it's lazy). In this case your images have a constant height, since you did .frame(height: 200). In general, you will need to find the maximum height some other way, and set that to be the LazyHStack's height. Commented Apr 30, 2024 at 7:38
  • See also this question which is caused by the same laziness. Though in that case the LazyHStack shrinks to the height of the first subview, because it is in a list row. Commented Apr 30, 2024 at 7:48
  • @Sweeper So is the conclusion that this can't be achieved because of the LazyHStack ? Could this be achievable if we used an HStack instead ? Commented Apr 30, 2024 at 8:30
  • Yeah, you can change that to a HStack and it should work as you expect. Commented Apr 30, 2024 at 8:31

1 Answer 1

6

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.

Sign up to request clarification or add additional context in comments.

3 Comments

Thanks, this does make sense. I was just wondering about your comment, 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. Is there a way to at least make the scrollview height the same as the first item in the lazyHStack ?
@ShawnFrank Yes. Use .fixedSize(horizontal: false, vertical: true) to force it to use its ideal height.
Thank you for all your time and help, you can add that last bit as well to the answer as all my elements would be fixed width so taking the height of the first element works and so .fixedSize(horizontal: false, vertical: true) works for me.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.