Shawn Frank
Shawn Frank

Reputation: 5153

SwiftUI carousel with content with variable height content using ScrollView and LazyHStack

I am using a ScrollView and a LazyHStack to create a horizontal carousel.

The goal I want to achieve is this:

SwiftUI Carousel ScrollView LazyHStack variable height

What I want is:

  1. Image which has the same width and height
  2. The text below the image can be 1,2 or 3 lines
  3. The image should all be in the same line in terms of alignment
  4. The first line of text should all start on the same line in terms of alignment

What I am able to achieve so far is this:

SwiftUI Carousel ScrollView HStack variable size

As you can see, the images don't align and neither does the text due to that.

Here is my code

The carousel:

struct CarouselView<Content: View>: View {
    let content: Content
    @State private var currentIndex = 0
    @State private var contentSize: CGSize = .zero
    
    private let showsIndicators: Bool
    private let spacing: CGFloat
    private let shouldSnap: Bool
    
    init(showsIndicators: Bool = true,
         spacing: CGFloat = .zero,
         shouldSnap: Bool = false,
         @ViewBuilder content: @escaping () -> Content) {
        self.content = content()
        self.showsIndicators = showsIndicators
        self.spacing = spacing
        self.shouldSnap = shouldSnap
    }
    
    var body: some View {
        ScrollView(.horizontal, showsIndicators: showsIndicators) {
            LazyHStack(spacing: spacing) {
                content
            }.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) }
}

And then I use the carousel like this:

struct ContentView: View {
    
    let imagesNames = ["img-1", "img-2", "img-3", "img-4"]
    let numberOfLines = [2, 1, 3, 2]
    
    var body: some View {
        
        VStack(spacing: 40) {
            continuousCarousel
        }
        
    }
    
    private var continuousCarousel: some View {
        CarouselView(showsIndicators: true,
                     spacing: 20) {
            ForEach(0 ..< imagesNames.count, id: \.self) { index in
                createImageTile(with: imagesNames[index],
                                height: 70,
                                numberOfLines: numberOfLines[index])
            }
        }
    }
    
    private func createImageTile(with image: String,
                                 height: CGFloat,
                                 numberOfLines: Int) -> some View {
        VStack(spacing: .zero) {
            Image(image)
                .resizable()
                .cornerRadius(30)
                .scaledToFit()
                .aspectRatio(contentMode: .fill)
                .frame(width: 200, height: 100)
                .padding(.bottom, 30)
            
            Text("Headline")
                .bold()
                .padding(.bottom, 10)
            
            ForEach(0 ..< numberOfLines, id: \.self) { _ in
                Text("Some description here")
                    .padding(.bottom, 5)
            }
        }
    }
}

I feel like applying an alignment somewhere or a spacer might fix this, but I don't know where.

Applying a bottom alignment on my lazyHStack didn't work as it moved the main content region to the bottom of the scrollview.

How can I achieve what I need ?

Update 1

After giving the first answer below a go, adding a spacer to the VStack, did help align everything to the top as follows:

private func createImageTile(with image: String,
                             height: CGFloat,
                             numberOfLines: Int) -> some View {
    VStack(spacing: .zero) {
        Image(image)
            .resizable()
            .cornerRadius(30)
            .scaledToFit()
            .aspectRatio(contentMode: .fill)
            .frame(width: 200, height: 100)
            .padding(.bottom, 30)
        
        Text("Headline")
            .bold()
            .padding(.bottom, 10)
        
        ForEach(0 ..< numberOfLines, id: \.self) { _ in
            Text("Some description here")
                .padding(.bottom, 5)
        }
        
        Spacer() // added this
    }
}

However, this introduced another issue, the top of my image is getting clipped.

enter image description here

I tried adding the clipped modifier to the scrollview as suggested here, however, that didn't change anything.

If I add a top spacing to the VStack, this could work but the value seems arbitrary and seems more of a hack than a solution.

Upvotes: 0

Views: 497

Answers (1)

Shehata Gamal
Shehata Gamal

Reputation: 100503

You can try

VStack(spacing: .zero) { // .leading align content to the left
       Image(image)
           .resizable()
           .cornerRadius(30)
           .scaledToFit()
           .aspectRatio(contentMode: .fill)
           .frame(width: 200, height: 100)
           .padding(.bottom, 30)
           Text("Headline")
               .bold()
               .padding(.bottom, 10)
           ForEach(0 ..< numberOfLines, id: \.self) { _ in
               Text("Some description here")
                   .padding(.bottom, 5)
           }
        Spacer()  // Align vertical content to top
}

Upvotes: 0

Related Questions