I have a view called SurahView
. In this view, I need 2 modes: translation
and reading
.
In the translation mode, I have a series of verses with both the Arabic text and English text for each verse. In the reading mode, I have a series of the same verses with just the Arabic text.
There are 3 main things that I need to achieve:
- When switching between modes, I want to ensure that the verse that is currently in view stays the same
- When rotating the device, I also need to ensure that the verse that is currently in view stays the same
- I want to be able to pass a parameter to the view that specifies which verse should be displayed when the view appears
Here is my code with as much taken out as possible to reproduce my problem:
struct SurahView: View {
let verses: [Surah.Verse]
@State private var translationMode = true
var body: some View {
ScrollView {
LazyVStack {
ForEach(verses) { verse in
VStack {
Text(verse.arabic)
.multilineTextAlignment(translationMode ? .trailing : .center)
if translationMode {
Text(verse.english)
}
}
}
}
}.overlay(alignment: .bottom) {
Picker("Mode", selection: $translationMode) {
Text("Translation")
.tag(true)
Text("Reading")
.tag(false)
}.pickerStyle(.segmented)
}
}
}
From my scouring the internet to find anything about maintaining scroll position, I've found that SwiftUI should automatically manage this on device rotation. This seems to be the case only when using a VStack
instead of a LazyVStack
, which is not really an option for me as there can be many hundreds of verses, requiring lazy loading which VStack
does not do.
I've tried a few things:
- Using a
TabView
to separate thetranslation
andreading
modes into completely distinct views - Using
.scrollPosition(id: Binding<Hashable>?)
with a@State var verseId: String?
to track the currently visible verse and reassign that value when the mode or orientation changes - Using a
ScrollViewReader
in combination with.scrollPosition
by usingproxy.scrollTo(id: verseId)
when the mode or orientation changes - achieved very similar results as just using.scrollPosition
Here is the code that I'm currently using:
struct SurahView: View {
let verses: [Surah.Verse]
@State private var translationMode = true
@State private var verseId: String?
init(
verses: [Surah.Verse],
verseId: String? = nil // Allows passing verseId for initial position
) {
self.verses = verses
self.verseId = verseId
}
var body: some View {
GeometryReader { geo in
TabView(selection: $translationMode) {
Tab("Translation", systemImage: "book.closed", value: true) {
TranslationView(verses: verses, verseId: $verseId)
}
Tab("Reading", systemImage: "book", value: false) {
ReadingView(verses: verses, verseId: $verseId)
}
}
.onChange(of: translationMode) { adjustScrollPosition() } // 1. Mode Change
.onChange(of: geo.size) { adjustScrollPosition() } // 2. Rotation
.onAppear { adjustScrollPosition() } // 3. Initial Position
}
}
private func adjustScrollPosition() {
let tempVerseId = self.verseId // Temporarily store currently visible verse id
self.verseId = "" // Set verseId to dummy value
DispatchQueue.main.async { self.verseId = tempVerseId } // Set verseId back to correct verse on the main thread to ensure it updates
}
}
struct TranslationView: View {
let verses: [Surah.Verse]
@Binding var verseId: String?
var body: some View {
ScrollView {
LazyVStack {
ForEach(verses) { verse in
VStack {
Text(verse.arabic)
.multilineTextAlignment(.trailing)
Text(verse.english)
}
}
}.scrollTargetLayout()
}.scrollPosition(id: $verseId, anchor: .top)
}
}
struct ReadingView: View {
let verses: [Surah.Verse]
@Binding var verseId: String?
var body: some View {
ScrollView {
LazyVStack {
ForEach(verses) { verse in
VStack {
Text(verse.arabic)
.multilineTextAlignment(.center)
}
}
}.scrollTargetLayout()
}.scrollPosition(id: $verseId, anchor: .top)
}
}
Currently, this results in the following:
- When switching between modes, it fairly accurately maintains the correct scroll position but is sometimes off by a few verses
- When rotating the device, the scroll position is lost, but on rotating back to the original orientation, it returns to a somewhat accurate position
- When a position is passed to the view, the scroll position is usually close to but rarely at the desired scroll position
- When a position is passed to the view, if I try to scroll back up, I get a glitchy animation that stops me from scrolling up. For instance, if I start at verse 48 and scroll up to verse 47, it will send me back to verse 48. If I try and scroll up again, it will let me scroll to verse 46 and then send me back to verse 47 and so on...
This is what I want:
- When I switch between the
translation
andreading
modes or rotate my phone, the currently visible verse should stay the same e.g. if verse number 33 is at the top of the view and I switch toreading
mode, verse number 33 should be the verse that is visible at the top of the view - I have a separate view
QuranView
which contains aList
ofNavigationLink
s which each navigate to aSurahView
. Sometimes, there will be abookmark: String?
variable which will be the id of a verse. This variable is passed to theSurahView
. If the value is notnil
, then I want the verse with that id to be the verse that is visible at the top of the view e.g. if the value ofbookmark
is "75", verse number 75 should be the verse that is visible at the top of the view
PS - There's a bit more...
Although the above code reflects more or less exactly what I've been working on, I've recently started using a slightly different set up. Instead of the Arabic just being plain text, it is a bit more complicated than that.
Instead of the following code:
Text(verse.arabic)
.multilineTextAlignment(translationMode ? .trailing : .center)
I'm now using a package WStack
to have a wrapping HStack
of the individual words of the Arabic so that each word can be a Button
, which when tapped, presents a .popover
of the English. (This applies in both the translation
and reading
mode:
WStack(verse.words, alignment: translationMode ? .leading : .center) { word in
Word(word: word)
}.environment(\.layoutDirection, .rightToLeft)
struct Word: View {
let word: Surah.Verse.Word
@State private var showTranslation = false
var body: some View {
Button {
showTranslation.toggle()
} label: {
Text(word.arabic)
}.popover(isPresented: $showTranslation) {
Text(word.english)
.presentationCompactAdaptation(.popover)
}
}
}
This has made things more difficult as I now have a few more problems:
- As
SwiftUI
now needs to render each word individually (and each word is aButton
), it has made the view take longer to load and impacted performance - As performance has been impacted, each of the 3 problems from before have been impacted as well, which means that in my current situation, I haven't been able to get anything to work consistently
While this isn't the main focus of the question and I would be willing to give up the possibility of having each word as a separate button if it means that I can have a clean, smooth interface where the above 3 problems are solved, I would greatly appreciate if anyone can help me out here as well.
Simply put, the only added challenge here is ensuring that everything works smoothly with a more complex layout that makes use of the WStack
package.
Please let me know if there's anything you need clarification on or if I can provide you with some more of my code and an even deeper insight into my situation to help with your solution.
Thanks in advance.
EDIT
As described in my response to BenzyNeez, I've gotten most of the problem down, but am still struggling with maintaining the scroll position while rotating the phone if I try to hide the navigation and tab bar as well.
Here's the new code:
struct SurahView: View {
let surah: Surah
@State private var translationMode = true
@State var verseId: String?
@State private var previousVerseId: String?
var body: some View {
GeometryReader { geo in
let isFullscreen = geo.size.width > geo.size.height // true if landscape, false if portrait
TabView(selection: $translationMode) {
Tab("Translation", systemImage: "book.closed", value: true) {
TranslationView(verses: surah.verses, verseId: $verseId)
.toolbarVisibility(isFullscreen ? .hidden : .visible, for: .navigationBar) // Hide navigation bar if landscape
.toolbarVisibility(isFullscreen ? .hidden : .visible, for: .tabBar) // Hide tab bar if landscape
}
Tab("Reading", systemImage: "book", value: false) {
ReadingView(verses: surah.verses, verseId: $verseId)
.toolbarVisibility(isFullscreen ? .hidden : .visible, for: .navigationBar) // Hide navigation bar if landscape
.toolbarVisibility(isFullscreen ? .hidden : .visible, for: .tabBar) // Hide tab bar if landscape
}
}
.onChange(of: verseId) { _, newVal in
guard let newVal, newVal != "" else { return }
previousVerseId = verseId
}
.onChange(of: translationMode) { maintainScrollPosition() }
.onChange(of: geo.size) { maintainScrollPosition() } // Maintain scroll position on rotation
.onAppear { initialiseScrollPosition() }
}
.navigationTitle(surah.transliteration)
.navigationBarTitleDisplayMode(.inline)
}
private func maintainScrollPosition() {
guard let previousVerseId else { return }
verseId = ""
Task { @MainActor in self.verseId = previousVerseId }
}
private func initialiseScrollPosition() {
guard let verseId = verseId else { return }
self.verseId = ""
Task { @MainActor in self.verseId = verseId }
}
}
struct TranslationView: View {
let verses: [Surah.Verse]
@Binding var verseId: String?
var body: some View {
ScrollView {
LazyVStack { // VStack had serious performance problems with large numbers of verses
ForEach(verses) { verse in
Text(verse.arabic)
Text(verse.english)
}
}.scrollTargetLayout()
}.scrollPosition(id: $verseId, anchor: .top)
}
}
struct ReadingView: View {
let verses: [Surah.Verse]
@Binding var verseId: String?
var body: some View {
ScrollView {
LazyVStack { // VStack had serious performance problems with large numbers of verses
ForEach(verses) { verse in
Text(verse.arabic)
}
}.scrollTargetLayout()
}.scrollPosition(id: $verseId, anchor: .top)
}
}
I think that a cause of the problem may be that the geo.size
changes twice - once when the phone rotates and again when the navigation and tab bars are hidden. This means that maintainScrollPosition
is called twice which may be causing problems, but I'm really not sure how to fix it.
.scrollPosition
, the answer shows the extra steps that are needed to get it to work..navigationTitle
on the view and aTabView
to switch between the modes. This means that I have a navigation and tab bar. When I rotate to landscape orientation, I want to hide these bars, which I've accomplished in the edit with.toolbarVisibility
. However, this has meant that when rotating, the scroll position is not maintained very well. I think this is because thegeo.size
is changing more than once, but I'm not sure.maintainScrollPosition()
. Try replacing the.onChange(of: geo.size) ...
with:.task(id: geo.size) { try? await Task.sleep(for: .seconds(0.5)) maintainScrollPosition() }
.seconds(0.1)
which seems to be long enough to do the job without any noticeable delay. Thanks again for the help. If possible, a look into how I can optimize everything to work withWStack
as described in the question would be much appreciated!