0

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)
            
        }
    }
}
1

1 Answer 1

2

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

  • When the orientation changes, the state variable used in the binding for the .scrollPosition gets reset.

  • When a lazy container is used, such as LazyVStack, the previous selection usually remains in view, or nearly in view. This is probably because the lazy container doesn't have many child views in memory.

  • When the container is not lazy, such as a VStack, the previous selection is usually out-of-view.

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:

  • Use a separate .onChange handler to detect an orientation change.

  • One way to do this is to use a GeometryReader to measure the screen size. This works for iPad split screen too.

  • The previous selection can be restored in an asychronous update whenever the screen size is detected to have changed.

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

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

11 Comments

When using a LazyVStack, I can achieve part 3 of the problem where i can choose any verse to scroll to. However, the rotation part is still quite buggy and produces variable results. As you've mentioned, this could be improved by using a VStack instead. Unfortunately, this actually worked less reliably. Also, I have attempted to integrate part 2 of the problem by passing a value to verseId in the hope that the view would start at the verse with that id. This didn't work either.
I've updated the original question to include code that more accurately represents my situation and may have an affect on your solution. Thanks!
@Ali Your comments about using VStack got me thinking. Answer revised.
I think I half understand the problem I'm having. When I try using your view as the main view of the app, it works seemlessly as expected. However, when I enclose it inside a NavigationStack, it doesn't seem to work properly. Instead, when the device rotates, it scrolls to a random part of the view, but when it rotates back, it returns to the correct position. The view being within a NavigationStack could be the reason why the other solutions I've tried aren't working as well. Not sure how to work around this though.
@Ali Example updated to work when the GeometryReader is surrounded by a NavigationStack. The screen size gets updated more than once, so it is important not to save the dummy id value in the .onChange handler. This just requires an extra check: if let newVal, newVal != dummyId
|

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.