2

I am trying to create an interface with a vertical scroll view at the top level, then a horizontal scroll view (using the new paginated scrolling from iOS 17) to display a number of child views that the user can scroll sideways between. So far it's behaving exactly as I want, except that the height of the first of the views in the horizontal scroll view seems to set the height for each of the other views, even if they have a taller content. To be honest, I'm not sure what behavior I imagine this having, but I was wondering if anyone had solved a similar issue or had designed a similar layout another way.

Here is a minimum reproducible example:

import SwiftUI

struct ContentView: View {
    @State private var selectedTab: String? = "Tab 1"

    var body: some View {
        ScrollView(.vertical) {
            LazyVStack {
                Image(systemName: "photo.fill")
                    .resizable()
                    .aspectRatio(contentMode: .fill)

                ScrollView(.horizontal) {
                    LazyHStack(spacing: 0) {
                        SampleView(.purple, 5)
                            .id("Tab 1")
                            .containerRelativeFrame(.horizontal)

                        SampleView(.red, 12)
                            .id("Tab 2")
                            .containerRelativeFrame(.horizontal)

                        SampleView(.blue, 20)
                            .id("Tab 3")
                            .containerRelativeFrame(.horizontal)
                    }
                    .scrollTargetLayout()
                }
                .scrollPosition(id: $selectedTab)
                .scrollTargetBehavior(.paging)
            }
        }
    }

    @ViewBuilder
    func SampleView(_ color: Color, _ size: Int) -> some View {
        LazyVGrid(columns: Array(repeating: GridItem(), count: 2), content: {
            ForEach(1...size, id: \.self) { _ in
                RoundedRectangle(cornerRadius: 15)
                    .fill(color.gradient)
                    .frame(height: 150)
            }
        })
    }
}

UI Gif

As you can see from the example, the height of the horizontal scrollview is locked in at the height of the first child view.

1 Answer 1

8
+50

This is happening because of the LazyHStack being used for the horizontal scrolled content.

  • The outer LazyVStack bases its height on the ideal height of the nested (horizontal) ScrollView.
  • The nested ScrollView uses the ideal height of the LazyHStack.
  • The LazyHStack uses the height of the first child, because the other subviews are not visible and so not yet sized.

If you change the LazyHStack to an HStack then vertical scrolling works to the full height of the tallest child. But then of course, you can also scroll down beyond the first child. You probably want to add .top alignment too:

ScrollView(.vertical) {
    LazyVStack {
        Image(systemName: "photo.fill")
            // ...

        ScrollView(.horizontal) {
            HStack(alignment: .top, spacing: 0) { // 👈 HERE
                // ...
            }
            .scrollTargetLayout()
        }
        .scrollPosition(id: $selectedTab)
        .scrollTargetBehavior(.paging)
    }
}

Animation

If the height of the tallest child is known then an alternative solution would be to apply this as minHeight to the LazyHStack:

LazyHStack(alignment: .top, spacing: 0) {
    // ...
}
.frame(minHeight: 1000)
.scrollTargetLayout()

However, if you over-estimate the height then vertical scrolling just scrolls into empty space, and if you under-estimate the height then a taller child gets clipped, as you were seeing originally.

If you at least know, which of the subviews will be the tallest, then another workaround would be to use the tallest subview to form a hidden footprint for the first subview. The actual first subview can then be shown as an overlay over this footprint:

LazyHStack(alignment: .top, spacing: 0) {
    SampleView(.blue, 20)
        .id("Tab 1")
        .containerRelativeFrame(.horizontal)
        .hidden()
        .overlay(alignment: .top) {
            SampleView(.purple, 5)
        }

    SampleView(.red, 12)
        .id("Tab 2")
        .containerRelativeFrame(.horizontal)

    SampleView(.blue, 20)
        .id("Tab 3")
        .containerRelativeFrame(.horizontal)
}
.scrollTargetLayout()

EDIT Your comment got me thinking. Since you are using scrollTargetLayout, a change of subview will be reflected by a change to selectedTab. By using a GeometryReader in the background of each subview, the minHeight for the LazyHStack can be updated to correspond to the height of the selected subview whenever the content changes.

EDIT2 If the first view is longer than the second view then it scrolls into blank space when going from the first to the second view. A workaround is to set maxHeight, in addition to minHeight.

@State private var selectedTab: String? = "Tab 1"
@State private var contentHeight: CGFloat?

private func heightReader(target: String) -> some View {
    GeometryReader { proxy in
        Color.clear
            .onChange(of: selectedTab, initial: true) { oldVal, newVal in
                if target == newVal {
                    withAnimation {
                        contentHeight = proxy.size.height
                    }
                }
            }
    }
}

var body: some View {
    ScrollView(.vertical) {
        LazyVStack {
            Image(systemName: "photo.fill")
                .resizable()
                .aspectRatio(contentMode: .fill)

            ScrollView(.horizontal) {
                LazyHStack(alignment: .top, spacing: 0) {
                    SampleView(.purple, 5)
                        .id("Tab 1")
                        .containerRelativeFrame(.horizontal)
                        .background { heightReader(target: "Tab 1") }

                    SampleView(.red, 12)
                        .id("Tab 2")
                        .containerRelativeFrame(.horizontal)
                        .background { heightReader(target: "Tab 2") }

                    SampleView(.blue, 20)
                        .id("Tab 3")
                        .containerRelativeFrame(.horizontal)
                        .background { heightReader(target: "Tab 3") }
                }
                .frame(minHeight: contentHeight, maxHeight: contentHeight)
                .scrollTargetLayout()
            }
            .scrollPosition(id: $selectedTab)
            .scrollTargetBehavior(.paging)
        }
    }
}

Animation

Sign up to request clarification or add additional context in comments.

9 Comments

Answer with several different solutions, thank you! I was just about able to change the height of the horizontal scroll view dynamically with geometry readers as well, still ironing out some issues there though.
Your suggestion is so much cleaner than what I was trying to do and much more bug-free, really appreciate you taking the time for that!
@iltercengiz Yes, .onGeometryChange could be used as well. The easiest way to replace the GeometryReader in the solution above might be to wrap the .onGeometryChange modifier as a ViewModifier, together with a binding to the state variable. Btw, using a GeometryReader is not wrong, nor is it deprecated. I suspect, .onGeometryChange works in a similar way under the cover, particularly where it was back-ported to earlier iOS versions.
The problem still exists when the size of SampleView is larger than the second view. SampleView(.purple, 35) and SampleView(.red, 12) when you get to second SampleView, the vertical scrolling will just scrolls into empty space.
@KyawTheMonkey You're right. This is because, the height of a child view was only being used to set minHeight on the LazyHStack. It helps to set maxHeight too, answer updated.
|

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.