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
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:
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.
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:
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.
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.
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:
.fixedSize
Spacer
inside the VStack
in the body of DemoTileView