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:
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
The last item's text get's truncated:
If I give slightly different dimensions like 240 and 170, then this doesn't truncate:
I think you just need to change the modifiers being applied to the Image
in the function createImageTile
:
.aspectRatio(contentMode: .fill)
. This is overriding the modifier .scaledToFit()
which comes before it, which is therefore redundant..cornerRadius
is deprecated, suggest using .clipShape
instead.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)
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:
continuousCarousel
.frame(minHeight: 300, alignment: .top) // π ADDED
ForEach(0 ..< imagesNames.count, id: \.self) { index in
createImageTile(
// ...
)
.minimumScaleFactor(0.1) // π ADDED
Text("Some description here")
.lineLimit(3, reservesSpace: true) // π ADDED
.padding(.bottom, 5)
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()
}
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
}