iosswiftui

SwiftUI keeping track of and maintaining scroll position on device rotation


I have a ScrollView containing a ForEach iterating through an array of elements of type Verse. Each view within the ForEach is a VStack with some Arabic text and some English text.

The English text is only visible when readingMode is false. The Arabic text becoms aligned to the centre when readingMode is false

There are 3 parts to the problem:

  1. When the device is rotated, the view does not maintain the scroll position
  2. When the view appears, there may be an initial scroll position (a verse which the view should start at)
  3. I want to be able to update the scroll position based on a selected verse within a wheel picker

Here is a simplified example of the code I have that contains only the essential parts of the view. Please let me know if you need any more information.

ScrollView {
    ForEach(verses) { verse in
        VStack {
            HStack {
                if !readingMode {
                    Spacer()
                }
                
                Text("Some Arabic Text")
            }
            
            if !readingMode {
                Text("Some English Text")
            }
        }.id(verse.id)
    }
}

I have tried using modifiers such as .onChange in combination with UIDevice.current.orientation and the .scrollPosition modifer to get the scroll position before rotation and set that as the scroll position after rotation but this is very buggy and often doesn't work

I have managed to achieve part 2 and 3 of the problem using a ScrollViewReader and the scrollTo function but the ScrollView can, as a result, randomly jump to a different part of the view for seemingly no reason.

From what I have found online through many hours of research, SwiftUI should automatically maintain the scroll position when the device is rotated so I might be doing something completely wrong that is causing all my problems.

Thanks in advance for your help!


EDIT: MORE SPECIFIC CODE

The following code more accurately represents my specific context which could have an affect on possible solutions

ScrollView {
    Text("Some Header")
    
    LazyVStack {
        ForEach(verses) { verse in
            
            VStack {
                HStack {
                    if !readingMode {
                        Spacer()
                    }
                    
                    Text(verse.arabic)
                        .multilineTextAlignment(readingMode ? .center : .trailing)
                }
                
                if !readingMode {
                    HStack {
                        Text(verse.english)
                            .multilineTextAlignment(.leading)
                        
                        Spacer()
                    }
                }
                
                Divider()
            }
            .id(verse.id)
            
        }
    }
}

Solution

  • I would suggest using .scrollPosition in combination with .scrollTargetLayout. However, there is more to it than this.

    To keep the selection in view, you can use an .onChange handler to detect updates to the binding and save the new value if it is not nil. Then:

    The example below shows it working. This uses an anchor of .center for the scroll position:

    struct Verse: Identifiable {
        let someVerses = [
            "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
            "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
            "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
        ]
        let id: Int
        let text: String
        let bgColor: Color
        let fgColor: Color
    
        init(id: Int) {
            self.id = id
            text = someVerses[.random(in: 0..<someVerses.count)]
            let red = Double.random(in: 0...1)
            let green = Double.random(in: 0...1)
            let blue = Double.random(in: 0...1)
            bgColor = Color(red: red, green: green, blue: blue)
            fgColor = red + green + blue < 1.5 ? .white : .black
        }
    }
    
    struct ContentView: View {
        private let dummyId = 0
        private let verses: [Verse]
        @State private var verseId: Int?
        @State private var previousVerseId: Int?
    
        init() {
            var verses = [Verse]()
            for i in 1...100 {
                verses.append(Verse(id: i))
            }
            self.verses = verses
        }
    
        var body: some View {
            NavigationStack {
                GeometryReader { proxy in
                    ScrollView {
                        VStack {
                            ForEach(verses) { verse in
                                VStack {
                                    Text("Verse \(verse.id)")
                                    Text(verse.text)
                                }
                                .padding()
                                .frame(maxWidth: .infinity)
                                .background(verse.bgColor)
                                .foregroundStyle(verse.fgColor)
                            }
                        }
                        .scrollTargetLayout()
                    }
                    .scrollPosition(id: $verseId, anchor: .center)
                    .onChange(of: verseId) { oldVal, newVal in
                        if let newVal, newVal != dummyId {
                            previousVerseId = newVal
                        }
                    }
                    .onChange(of: proxy.size) {
                        if let previousVerseId {
                            verseId = dummyId
                            Task { @MainActor in
                                verseId = previousVerseId
                            }
                        }
                    }
                }
            }
        }
    }
    

    You will notice that the selection is explicitly set to a dummy id before it is updated asynchronously. I found that the update gets ignored if this is not done. Using nil works if the view is scrolled between switches, but not if it is switched straight back without scrolling.

    Animation