iosswiftswiftuiswiftui-scrollviewlazyhstack

Text getting truncated in Carousel using ScrollView and LazyHStack


I created a carousel view using a LazyHStack and a scroll view.

This worked well for the most part, however, at times, the height of each item in my content would vary which resulted in the content getting clipped

SwiftUI Carousel LazyHStack ScrollView

The reason for this is that a LazyHStack gives its content height based on the first element, expecting the rest of the content to have the same height.

This problem could be solved by using an HStack, however, I did want the performance improvement given by the LazyHStack.

The solution I went with was to create a hidden placeholder / footprint view as suggested by the 5th idea in this answer

The idea for the most part is to create a hidden placeholder to establish the footprint for the text, then show the actual content as an overlay. The placeholder is a ZStack of all the items in the carousel so which would grow to accomodate the tallest item and thus "reserve" the right amount of space for the tallest bit of content.

This almost solves my issue for the most part but the text gets truncated at times:

SwiftUi HStack ScrollView horizontal Carousel

This is my carousel code:

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 create the tile or item view where the footprint / height reservation business happens:

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)
    }
    
    // This the footprint / placeholder view used to reserve the height needed
    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
        }
    }
}

And then I use carousel and demo tile like 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
        }
    }
}

With some differences in the image width and height such as 240 height and 170 width, the text doesn't get truncated. This behavior is random depending on the content.

Is there another way or some additions that can be made to this to prevent my text from truncating and allow my footprint placeholder view grow as much as needed ?

Open to other alternative suggestions to solve this issue.


Solution

  • I copied the exact code from the question into a new project, then created the following calling view (incorporating the array definitions from your previous question):

    struct ContentView: View {
        @State private var defaultCarouselIndexTapped = 0
        let imagesNames = ["image1", "image2", "image3", "image4"]
        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"]
    
        var body: some View {
            CarouselView(spacing: 10) {
                // ...
            }
        }
    }
    

    When run on an iPhone 15 simulator with iOS 17.4 it works fine. In particular, when I scroll to the 4th tile it has a 2-line title and 4-line description.

    However, I was able to reproduce the problem by adding .fixedSize(horizontal: false, vertical: true) in two places:

    1. to CarouselView in the body of ContentView above:
    CarouselView(spacing: 10) {
        // ...
    }
    .fixedSize(horizontal: false, vertical: true)
    

    The reason for adding it here is because this was the suggestion that you accepted as the answer to an earlier question, so I am guessing that you might still be using it somewhere.

    1. to the VStack in the function createTextView:
    private func createTextView(title: String, supportingText: String) -> some View {
        VStack {
            // ...
        }
        .fixedSize(horizontal: false, vertical: true)
    }
    

    This prevents the text from being truncated.

    To fix

    The main cause of the problem is that the functions createTextView and createSupportingTextView are both formatting the wrong text parameters. Fix like this:

    private func createTextView(title: String, supportingText: String) -> some View {
        VStack {
            createTitleView(for: title)
            createSupportingTextView(for: supportingText ) // 👈 NOT title
        }
        .fixedSize(horizontal: false, vertical: true)
    }
    
    
    private func createSupportingTextView(for text: String) -> some View {
        // In an HStack to left align the text
        HStack {
            Text(text) // 👈 NOT supportingText
                .padding(.bottom, Constants.Padding.supportingTextBottom)
    
            Spacer()
        }
    }
    

    With these fixes in place, you might like to simplify the code by removing the following: