0

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.

2
  • I copied this exact code into a new project, then created a calling view called ContentView that contains nothing more than the (missing) definitions for imagesNames, titles and supportingText from your previous question and your last block of code as the content of body. When I run this 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. So I am wondering, how exactly is CarouselView being embedded in the parent view? Commented May 3, 2024 at 10:01
  • I did put it inside a list and scrollview and had the same result Commented May 3, 2024 at 10:42

1 Answer 1

1

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:

  • all cases of .fixedSize
  • the Spacer inside the VStack in the body of DemoTileView
Sign up to request clarification or add additional context in comments.

2 Comments

Amazing, thank you so much. The .fixedSize(horizontal: false, vertical: true) wasn't the issue as I removed it everywhere based on your previous advice. The issue was exactly what you caught, the wrong parameters being used ! This thing works really well now. Just as a final question for now, is there an idea for the footprint view thing to be done just once because we don't really need to do it for every item in the carousel I believe as doing it the first time should be enough to get the right height. I could pass all the tile items to the carousel but that solutions doesn't seem right.
@ShawnFrank Pleased to hear it's working, thanks for accepting the answer. Two suggestions for minimising the overhead of preparing the footprint: (1) if the carousel always starts with tile 0 selected then it might be sufficient to use the footprint approach for this tile only; (2) if you have lots of tiles then you could filter out the longest titles and descriptions and just pass this subset to the footprint function. If in fact there are hundreds of tiles then I would probably go for the approach of setting a lineLimit with reserved space, as per option 3 of my previous answer.

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.