Search code examples
iosswiftswiftuiuiscrollview

SwiftUI: Nested scroll views on opposite axis


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.


Solution

  • 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.

    @State private var selectedTab: String? = "Tab 1"
    @State private var minContentHeight = CGFloat.zero
    
    private func heightReader(target: String) -> some View {
        GeometryReader { proxy in
            Color.clear
                .onChange(of: selectedTab, initial: true) { oldVal, newVal in
                    if target == newVal {
                        withAnimation {
                            minContentHeight = 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: minContentHeight)
                    .scrollTargetLayout()
                }
                .scrollPosition(id: $selectedTab)
                .scrollTargetBehavior(.paging)
            }
        }
    }
    

    Animation