iosswiftswiftuiuiscrollview

iOS 17 SwiftUI: Color Views in ScrollView Using containerRelativeFrame Overlap When Scrolling


I'm encountering a layout issue in SwiftUI on iOS 17 where Color views within a VStack inside a ScrollView begin to overlap as I scroll down. Each Color view is supposed to take up the full container's relative frame, but as I scroll, the next Color view encroaches upon the space of the previous one. This issue compounds with further scrolling, with subsequent views taking more and more space.

I have attached a video to demonstrate the issue, and here is the code to reproduce the problem. Just copy and paste it.

struct TestView: View {
    var body: some View {
        ScrollView(.vertical, showsIndicators: false) {
            VStack(spacing: 0) {
                ForEach(0..<10, id:\.self) { idx in
                    Color.random()
                    .containerRelativeFrame([.horizontal, .vertical])
                }
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.paging)
    }
}

#Preview {
    TestView()
}

I'm using .containerRelativeFrame([.horizontal, .vertical]) for each color view to make them fill the available space, and .scrollTargetLayout() on the VStack. The .scrollTargetBehavior(.paging) modifier is applied to the ScrollView to enable paging behavior.

The expected result is for each color view to occupy its own page space without affecting the others. However, when I scroll, the layout starts to break as shown in the video.

Has anyone experienced something similar or can provide insight into what might be causing this issue?

My Environment:

Simulator running version: iOS 17.0

XCode Version: 15.0.1 (15A507)

Swift Version: swift-driver version: 1.87.1 Apple Swift version 5.9 (swiftlang-5.9.0.128.108 clang-1500.0.40.1) Target: x86_64-apple-macosx13.0

enter image description here


Solution

  • I think this must be a bug.

    It seems to be compensating for the bottom insets incorrectly. If you try it on an iPhone SE then it works, because the bottom insets are 0. Otherwise, the "slippage" appears to be half the size of the bottom insets.

    Possible workarounds:

    1. Ignore bottom safe area insets
    ScrollView(.vertical, showsIndicators: false) {
        // content as before
    }
    .scrollTargetBehavior(.paging)
    .ignoresSafeArea(edges: .bottom) // <- ADDED
    
    1. Use a GeometryReader to measure the safe area insets and set the VStack spacing accordingly
    GeometryReader { proxy in // <- ADDED
        ScrollView(.vertical, showsIndicators: false) {
            VStack(spacing: proxy.safeAreaInsets.bottom / 2) { // <- CHANGED
                // content as before
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.paging)
    }
    
    1. Add padding below the pages

    With this approach, you can eliminate the space between the colors that is seen with solution 2.

    GeometryReader { proxy in
        ScrollView(.vertical, showsIndicators: false) {
            VStack(spacing: 0) {
                ForEach(0..<10, id:\.self) { idx in
                    Color.clear
                        .containerRelativeFrame([.horizontal, .vertical])
                        .padding(.bottom, proxy.safeAreaInsets.bottom / 2)
                        .background(Color.random())
                }
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.paging)
    }
    
    1. Set the height and width of the subviews explicitly, instead of using .containerRelativeFrame
    GeometryReader { proxy in
        ScrollView(.vertical, showsIndicators: false) {
            VStack(spacing: 0) {
                ForEach(0..<10, id:\.self) { idx in
                    Color.random()
                        .frame(
                            width: proxy.size.width,
                            height: proxy.size.height + (proxy.safeAreaInsets.bottom / 2)
                        )
                }
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.paging)
    }
    

    I would go for the first workaround if you can, because if the bug is fixed in a future version then it should still continue to work.

    Ps. You might want to report the bug as feedback to Apple.