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:
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)
}
}
}
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.