Search code examples
swiftswiftuiuiscrollviewscrollviewscrollviewreader

Why is ScrollView changing content height to a random value on device but not on preview?


I am currently using **Xcode Version 14.3.1** (14E300c).

The scrollview in the code has an array of objects that I am iterating through with, ForEach(). When the user scrolls, lets say to object 30(each object is numbered, for demonstration purposes), with an anchor value of 0.00135(you can think of this as the offset value in relation to said object), i want the user to be able to return to the exact location prior to deinitializing the view; exactly like, pretty much, every social media application.

I have attempted to do this by using ScrollViewReader.scrollTo(<id: , anchor:), but to no avail. It works perfectly with preview, but as soon as I use the simulator or a device(iPhone 14), it starts to resize my ScrollView Content height in random sequences.

Can someone please explain why this would be working perfectly in preview but not on devices; and how can I make it work for devices without this strange bug?

So the issue happens only when you reach near the end(bottom) of the scrollable content; and oddly enough, if you scroll near the beginning(top) of the scrollable content, it fixes itself.

**// VIEW THAT IS PROBABLY CAUSING THE ISSUE**
struct ScrollViewWithSavedPosition: View {
    @ObservedObject var scrollViewSavedValue: ScrollViewSavedValue
    @State private var isViewLoaded: Bool = false
    let geoProxy: GeometryProxy
    let maxScrollableHeight: CGFloat = 4809
    
    var body: some View {
        ScrollViewReader { scrollProxy in
            ScrollView(showsIndicators: false) {
                LazyVStack {
                    ForEach(0..<34) { poster in
                        ZStack {
                            Rectangle()
                                .fill(.black)
                                .frame(width: geoProxy.size.width * 0.4,
                                       height: geoProxy.size.height * 0.2)
                            
                            Text("\(poster)")
                                .foregroundColor(.orange)
                        }
                    }
                }
                .id("scrollPosition")
                .background(
                    GeometryReader {
                        Color.orange
                            .preference(key: CGPointPK2.self,
                                        value: $0.frame(in: .global).origin)
                    }
                )
                .onPreferenceChange(CGPointPK2.self) { scrollPosition in
                    DispatchQueue.main.async {
                        isViewLoaded = true
                    }
                    
                    if isViewLoaded {
                        let offsetValue = (-1 * (scrollPosition.y - geoProxy.safeAreaInsets.top)) / maxScrollableHeight
                        scrollViewSavedValue.scrollOffsetValue = offsetValue
                    }
                    
                    print(scrollViewSavedValue.scrollOffsetValue)
                }
            }
            .onAppear {
                scrollProxy.scrollTo("scrollPosition", anchor: UnitPoint(x: 0, y: scrollViewSavedValue.scrollOffsetValue))
            }
        }
        .preferredColorScheme(.light)
    }
}

struct CGPointPK2: PreferenceKey {
    static var defaultValue: CGPoint = .zero
    static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { }
}

class ScrollViewSavedValue: ObservableObject {
    @Published var scrollOffsetValue: CGFloat = 0
    @Published var selectedTab: Int = 1
}

struct TabBarView: View {
    @ObservedObject var scrollViewSavedValue: ScrollViewSavedValue
    let geoProxy: GeometryProxy
    
    var body: some View {
        VStack {
            Spacer()
            
            ZStack {
                Rectangle()
                    .fill(.blue)
                    .frame(width: geoProxy.size.width, height: 80)
                
                HStack(spacing: 100) {
                    Button {
                        DispatchQueue.main.async {
                            scrollViewSavedValue.selectedTab = 1
                        }
                    } label: {
                        ZStack {
                            Circle()
                                .fill(.black)
                                .frame(width: 60, height: 60)
                            
                            Text("View 1")
                                .foregroundColor(.white)
                                .font(.system(size: 12))
                                .fontWeight(.bold)
                        }
                    }
                    
                    Button {
                        DispatchQueue.main.async {
                            scrollViewSavedValue.selectedTab = 2
                        }
                    } label: {
                        ZStack {
                            Circle()
                                .fill(.black)
                                .frame(width: 60, height: 60)
                            
                            Text("View 2")
                                .foregroundColor(.white)
                                .font(.system(size: 12))
                                .fontWeight(.bold)
                        }
                    }
                }
            }
        }
        .ignoresSafeArea()
    }
}

struct PresentationView: View {
    @StateObject private var scrollViewSavedValue = ScrollViewSavedValue()
    
    var body: some View {
        GeometryReader { geoProxy in
            switch scrollViewSavedValue.selectedTab {
            case 1:
                ScrollViewWithSavedPosition(scrollViewSavedValue: scrollViewSavedValue, geoProxy: geoProxy)
            default:
                Text("View 2")
                    .foregroundColor(.black)
            }
            
            TabBarView(scrollViewSavedValue: scrollViewSavedValue, geoProxy: geoProxy)
        }
    }
}

Solution

  • Hello and welcome to Stack Overflow,

    After playing around with your code for a while, I believe the problem lies with the fact that the scroll height is not constant which it makes it difficult to determine where you should scroll to. I initially thought through using mathematics I could figure it out, but after some testing I found the scroll height is affected not only by the top padding, but also the bottom padding and the size of the tab bar. So each phone will need to be tested individually.

    A much simpler solution is when changing to view 2, not to remove the scroll view, which I have renamed to ScrollPosterView. This can be done by using the opacity and disable modifiers. This ensures that the scroll view is still rendered but is not able to be seen or operated on by the user. Using this method, the scroll view's scroll position does not need to be saved. As this looks like to be a social media type app, the images do not need to be reloaded, saving on processing power.

    This is done as so (where I have simplified the code):

    struct ContentView: View {
    
    @State var selectedTab: Int = 1
    
    var body: some View {
        VStack(spacing: 0) {
            ZStack {
                ScrollPosterView()
                    .opacity(selectedTab == 1 ? 1 : 0)
                    .disabled(selectedTab == 2)
                ScrollView {
                    VStack {
                        Text("View 2")
                            .foregroundColor(.black)
                        Spacer()
                    }
                }
                .disabled(selectedTab == 1)
                .opacity(selectedTab == 2 ? 1 : 0)
    
            }
        
            TabBarView(selectedTab: $selectedTab)
        }
    }
    }
    

    Where ScrollPosterView is:

    struct ScrollPosterView: View {
    var body: some View {
        ScrollView(showsIndicators: false) {
            LazyVStack(spacing: 10) {
                ForEach(0..<20) { poster in
                    ZStack {
                        Rectangle()
                            .fill(.black)
                            .frame(width: 100,
                                   height: 100)
                        
                        Text("\(poster)")
                            .foregroundColor(.orange)
                    }
                }
            }
            .background(Color.orange)
        }
        .preferredColorScheme(.light)
    }
    }
    

    And TabBarView is:

    struct TabBarView: View {
    
    @Binding var selectedTab: Int
    
    var body: some View {
        ZStack {
            HStack(spacing: 100) {
                Button {
                    selectedTab = 1
                } label: {
                    ZStack {
                        Circle()
                            .fill(.black)
                            .frame(width: 60, height: 60)
                        
                        Text("View 1")
                            .foregroundColor(.white)
                            .font(.system(size: 12))
                            .fontWeight(.bold)
                    }
                }
                
                Button {
                    selectedTab = 2
                } label: {
                    ZStack {
                        Circle()
                            .fill(.black)
                            .frame(width: 60, height: 60)
                        
                        Text("View 2")
                            .foregroundColor(.white)
                            .font(.system(size: 12))
                            .fontWeight(.bold)
                    }
                }
            }
        }
        .frame(height: 70)
        .frame(maxWidth: .infinity)
        .background(Rectangle()
            .foregroundColor(.blue)
            .ignoresSafeArea())
    }
    }