iosswiftswiftuihstack

SwiftUI subviews getting clipped inside a scrollview


I've created a Carousel View using a ScrollView and a LazyHStack as follows:

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 {
        VStack {
            ScrollView(.horizontal, showsIndicators: showsIndicators) {
                LazyHStack(spacing: spacing) {
                    content
                }.apply {
                    if #available(iOS 17.0, *), shouldSnap {
                        $0.scrollTargetLayout()
                    } else {
                        $0
                    }
                }
            }
            .clipped() // tried to add this to avoid clipping
            .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) }
}

I then use it as follows:

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)
            }
            
            Spacer() // added to top align content
        }
    }
}

This gives me the result I desire for the most part, however, as you can see, the top of the image gets clipped by the safe area:

enter image description here

I tried to add the clipped modifier to the scrollview as suggested by this answer but to no avail.

How can I solve this ?

Update based on Benzy Neez's 5th idea

This idea of using a hidden footprint almost makes everything work

Just for the example, I store all my titles and supporting text in an array:

let titles = ["Cedele Bakery Kitchen", "Elements Bar and Grill Woolloomooloo", "Osteria di Russo & Russo", "Hopsters Co-op Brewery"]

let supportingText = ["Bangalay Dining, Shoalhaven Heads\n15.5/20", "Modern Australian | Wine bar", "Neptune’s Grotto\n15.5/20.0", "Vegan mapo tofu with shiitake mushrooms and crispy chilli oil\n<30 mins"]

The Carousel implementation does not change that much. One thing to notice is that the last bit of text in the supportingText array is the longest

The only difference is that I have replaced my imageTile with a DemoTile view which has to figure out the right height using the footprint technique:

struct DemoTileView: View {
    
    private let imageName: String
    private let imageWidth: CGFloat
    private let imageHeight: CGFloat
    private let title: String
    private let supportingText: String
    private let allTitles: [String]
    private let allSupportingText: [String]
    
    init(imageName: String,
         title: String,
         allTitles: [String],
         supportingText: String,
         allSupportingText: [String],
         imageWidth: CGFloat,
         imageHeight: CGFloat) {
        self.imageName = imageName
        self.title = title
        self.supportingText = supportingText
        self.allTitles = allTitles
        self.allSupportingText = allSupportingText
        self.imageWidth = imageWidth
        self.imageHeight = imageHeight
    }
    
    var body: some View {
        VStack(spacing: .zero) {
            Image(imageName)
                .resizable()
                .scaledToFill()
                .frame(width: imageWidth, height: imageHeight)
                .clipShape(RoundedRectangle(cornerRadius: Constants.Border.cornerRadius))
                .padding(.bottom, Constants.Padding.imageBottom)
            
            textViewFootprint
                .overlay(alignment: .top) {
                    createTextView(title: title, supportingText: supportingText)
                }
            
            Spacer() // added to top align content
        }
        .frame(width: imageWidth)
    }
    
    private var textViewFootprint: some View {
        ZStack {
            ForEach(allTitles.indices, id: \.self) { index in
                let currentTitle = allTitles[index]
                let currentSupportingText = allSupportingText[index]
                createTextView(title: currentTitle, supportingText: currentSupportingText)
            }
        }
        .hidden()
    }
    
    private func createTextView(title: String, supportingText: String) -> some View {
        VStack {
            createTitleView(for: title)
            createSupportingTextView(for: title)
        }
    }
    
    private func createTitleView(for text: String) -> some View {
        // In an HStack to left align the text
        HStack {
            Text(text)
                .bold()
                .padding(.bottom, Constants.Padding.titleBottom)
            
            Spacer()
        }
    }
    
    private func createSupportingTextView(for text: String) -> some View {
        // In an HStack to left align the text
        HStack {
            Text(supportingText)
                .padding(.bottom, Constants.Padding.supportingTextBottom)
            
            Spacer()
        }
    }
    
    struct Constants {
        struct Padding {
            static let imageBottom = 12.0
            static let titleBottom = 4.0
            static let supportingTextBottom = 20.0
        }
        
        struct Border {
            static let cornerRadius = 30.0
        }
    }
}

Then within the Carousel, I just do this:

CarouselView(spacing: 10) {
    ForEach(0 ..< imagesNames.count, id: \.self) { index in
        DemoTileView(imageName: imagesNames[index],
                     title: titles[index],
                     allTitles: titles,
                     supportingText: supportingText[index],
                     allSupportingText: supportingText,
                     imageWidth: 180,
                     imageHeight: 120)
        .onTapGesture {
            defaultCarouselIndexTapped = index
        }
    }
}

First two items laid out perfectly

Carousel SwiftUI LazyHStack ScrollView

The last item's text get's truncated:

SwiftUI Carousel ScrollView

If I give slightly different dimensions like 240 and 170, then this doesn't truncate:

How to create a carousel in swiftUI


Solution

  • I think you just need to change the modifiers being applied to the Image in the function createImageTile:

    BTW, the height parameter being passed to this function is not being used.

    So here is an updated version that uses scaled-to-fill. The images are clipped to the containing frame (200x100) using a rounded rectangle.

    Image(image)
        .resizable()
    //    .cornerRadius(30)
    //    .scaledToFit()
    //    .aspectRatio(contentMode: .fill)
        .scaledToFill()
        .frame(width: 200, height: 100)
        .clipShape(RoundedRectangle(cornerRadius: 30))
        .padding(.bottom, 30)
    

    Screenshot


    EDIT Following from your comments, it sounds like you need to find a way of making sure the LazyHStack reserves enough space to fit the tallest item. You already asked another question about this and marked an answer as accepted, so I would have expected, you found a solution already. However, if it's still an issue then some techniques you could try are:

    1. Set a minimum height on the carousel:
    continuousCarousel
        .frame(minHeight: 300, alignment: .top) // πŸ‘ˆ ADDED
    
    1. Allow the text to shrink to fit:
    ForEach(0 ..< imagesNames.count, id: \.self) { index in
        createImageTile(
            // ...
        )
        .minimumScaleFactor(0.1) // πŸ‘ˆ ADDED
    
    1. Reserve enough space for the maximum number of text lines
    Text("Some description here")
        .lineLimit(3, reservesSpace: true) // πŸ‘ˆ ADDED
        .padding(.bottom, 5)
    
    1. Use hidden content to reserve space:
    ForEach(0 ..< numberOfLines, id: \.self) { _ in
        Text("Some description here")
            .padding(.bottom, 5)
    }
    ForEach(0 ..< (3-numberOfLines), id: \.self) { _ in // πŸ‘ˆ BLOCK ADDED
        Text("Hidden")
            .padding(.bottom, 5)
            .hidden()
    }
    
    1. Use a hidden placeholder to establish the footprint for the text, then show the actual content as an overlay:
    VStack(spacing: .zero) {
        Image(image)
            // ...
    
        textFootprint // πŸ‘ˆ PLACEHOLDER (implement separately)
            .overlay(alignment: .top) {
                VStack(spacing: 0) {
                    Text("Headline")
                        // ...
                    ForEach(0 ..< numberOfLines, id: \.self) { _ in
                        // ...
                    }
                }
            }
        Spacer() // added to top align content
    }