Swiftui ScrollView reset position

I have an SwiftUI app with a ScrollView. When I change the contents, while the ScrollView is scrolled a bit down, the scroll position is kept. I would like the that changes to data resets this scroll position, i.e. scroll to the top. I've included a small example of this behavior.


Scroll down a bit and press the "Toogle" text. Content changes but the position stays the same. How can I signal the ScrollView to 'reset' the scrolling?


struct ContentView: View
{
  static let data1 = [
    "Test 1","Test 2","Test 3","Test 4","Test 5",
    "Test 6","Test 7","Test 8","Test 9","Test 10",
    "Test 11","Test 12","Test 13","Test 14","Test 15",
    "Test 16","Test 17","Test 18","Test 19","Test 20"
  ]
  static let data2 = [
    "Test 101","Test 102","Test 103","Test 104","Test 105",
    "Test 106","Test 107","Test 108","Test 109","Test 110",
    "Test 111","Test 112","Test 113","Test 114","Test 115",
    "Test 116","Test 117","Test 118","Test 119","Test 120",
    "Test 121","Test 122","Test 123","Test 124","Test 125"
  ]

  @State var idx = 0
  @State var data : [String] = data1
  
  var body: some View
  {
    VStack
    {
      Text("Toggle")
        .onTapGesture
        {
          self.idx  = 1-self.idx
          self.data = self.idx == 0 ? ContentView.data1 : ContentView.data2
        }
      ScrollView
      {
        VStack
        {
          ForEach(self.data,id:\.self)
          {
            item in
            Text("\(self.idx) \(item)").padding(20)
          }
        }
        .frame(width:400)
      }
    }
  }
}
Answered by jensrodi in 401413022

I found a workarround that seems to work, maybe not optimal or pretty. It needs more testing to verify that it works consistently.
Instead of changing the data variable in one step like this:


self.data = self.idx == 0 ? ContentView.data1 : ContentView.data2


I first empty the array and then set it, but with a little delay. That way the ScrollView empties out, reset the scroll position (as it's empty) and repopulates with the new data and scrolled to the top.


self.data = []
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01)
{
  self.data = self.idx == 0 ? ContentView.data1 : ContentView.data2
}

There are some obscure (for me) things in @State.


I thought that the change you make to idx should force update.

Visibly not.


But see this: they change the state itself from a selector and seems to work.

Should try replace button by segmented control, just to see.


https://stackoverflow.com/questions/56666358/how-can-i-scrolltotop-in-swiftui

Well, with help from ttps://stackoverflow.com/questions/56666358/how-can-i-scrolltotop-in-swiftui


I made it work with this, if that can help.


struct ContentView: View {
   
    @State var idx = 0  // 2 valeurs
    @State var data : [String] = []
    @State var selected: Int = 0
    @State private var numbers = ["One","Two"]
    let data1 = [
        "Test 1","Test 2","Test 3","Test 4","Test 5",
        "Test 6","Test 7","Test 8","Test 9","Test 10",
        "Test 11","Test 12","Test 13","Test 14","Test 15",
        "Test 16","Test 17","Test 18","Test 19","Test 20"
    ]
    let data2 = [
        "Test 101","Test 102","Test 103","Test 104","Test 105",
        "Test 106","Test 107","Test 108","Test 109","Test 110",
        "Test 111","Test 112","Test 113","Test 114","Test 115",
        "Test 116","Test 117","Test 118","Test 119","Test 120",
        "Test 121","Test 122","Test 123","Test 124","Test 125"
    ]

    var body: some View {
        NavigationView {
            VStack {
                Picker("Numbers", selection: $selected) {
                    ForEach(0 ..< numbers.count) { index in
                        Text(self.numbers[index]).tag(index)
                    }
                }
                .pickerStyle(SegmentedPickerStyle())
               
                if selected == 0 {
                    ScrollView {
                        VStack {
                            ForEach(data1, id:\.self) {
                                item in
                                Text("\(self.idx) \(item)").padding(20)
                            }
                        }
                        .frame(width:400)
                    }
                } else {
                    ScrollView {
                        VStack {
                            ForEach(data2, id:\.self) {
                                item in
                                Text("\(self.idx) \(item)").padding(20)
                            }
                        }
                        .frame(width:400)
                    }
                }
            }
        }
    }
}

I already found the stackoverflow post before coming here - and unfortunately it didn't help. The shown example do have two simple arrays and an index to change between them but this is just because it was as easy way of illustrating the issue.


I my case, I have one single array which is populated dynamically from a database query. Switching between a couple of different ScrollViews is not realy an option.
What I don't understand is why the contents change but the scroll offset doesn't.

I tried to mimic what you put in your ortiginal post (2 arrays, a button to toggle values).


If that's not what you want to do, you'd better explain what you want precisely.


What I don't understand is why the contents change but the scroll offset doesn't.

AFAIU, the modification of state idx is not propagated.


Here is a version very close to the original post, with a toggle button

struct ContentView: View {
   
    @State var idx = 0  // 2 valeurs
    @State var data : [String] = []
    @State var selected: Int = 0

    let data1 = [
        "Test 1","Test 2","Test 3","Test 4","Test 5",
        "Test 6","Test 7","Test 8","Test 9","Test 10",
        "Test 11","Test 12","Test 13","Test 14","Test 15",
        "Test 16","Test 17","Test 18","Test 19","Test 20"
    ]
    let data2 = [
        "Test 101","Test 102","Test 103","Test 104","Test 105",
        "Test 106","Test 107","Test 108","Test 109","Test 110",
        "Test 111","Test 112","Test 113","Test 114","Test 115",
        "Test 116","Test 117","Test 118","Test 119","Test 120",
        "Test 121","Test 122","Test 123","Test 124","Test 125"
    ]

    var body: some View {
        NavigationView {
            VStack {
                Button(action: {self.selected = 1 - self.selected }) { Text("Toggle")}
               
                if selected == 0 {
                    ScrollView {
                        VStack {
                            ForEach(data1, id:\.self) {
                                item in
                                Text("\(self.idx) \(item)").padding(20)
                            }
                        }
                        .frame(width:400)
                    }
                } else {
                    ScrollView {
                        VStack {
                            ForEach(data2, id:\.self) {
                                item in
                                Text("\(self.idx) \(item)").padding(20)
                            }
                        }
                        .frame(width:400)
                    }
                }
            }
        }
    }
}



But that's not yet what you want.

I tried this, but did not complete the reading and setting of content.offset as explained here:

h ttps://zacwhite.com/2019/scrollview-content-offsets-swiftui/


struct ContentView: View {
  static let data1 = [
    "Test 1","Test 2","Test 3","Test 4","Test 5",
    "Test 6","Test 7","Test 8","Test 9","Test 10",
    "Test 11","Test 12","Test 13","Test 14","Test 15",
    "Test 16","Test 17","Test 18","Test 19","Test 20"
  ]
  static let data2 = [
    "Test 101","Test 102","Test 103","Test 104","Test 105",
    "Test 106","Test 107","Test 108","Test 109","Test 110",
    "Test 111","Test 112","Test 113","Test 114","Test 115",
    "Test 116","Test 117","Test 118","Test 119","Test 120",
    "Test 121","Test 122","Test 123","Test 124","Test 125"
  ]
 
  @State var idx = 0
  @State var data : [String] = data1
  @State var offset : CGFloat = 0   // May be save the offset current value ?

  var body: some View {
    VStack {
      Button(action: {
        self.idx = 1 - self.idx
        self.data = self.idx == 0 ? ContentView.data1 : ContentView.data2
        // Need to set offset
      }) { Text("Toggle")}
       
        ScrollView() {
            VStack {
                ForEach(self.data,id:\.self) {
                    item in
                    Text("\(self.idx) \(item)").padding(20)
                }
            }
            .frame(width:400)
        }
        // .content.offset(y: self.offset)
    }
  }
   
}

I did a quick test, assuming that an initial offset for the scroll would be 0 (?) by adding the following line to the ScrollView:


.content.offset(y:0)


At the minimum, I would expect this to position the ScrollView at a specific position. But instead, it messes up the layout of the ScrollView and also disables the ability to scroll the view. Even though I could read out the current scroll position (which I've found several examples for similar to the linked post) I fail to see how that would help when I cannot push the position back without cripling the ScrollView.

Accepted Answer

I found a workarround that seems to work, maybe not optimal or pretty. It needs more testing to verify that it works consistently.
Instead of changing the data variable in one step like this:


self.data = self.idx == 0 ? ContentView.data1 : ContentView.data2


I first empty the array and then set it, but with a little delay. That way the ScrollView empties out, reset the scroll position (as it's empty) and repopulates with the new data and scrolled to the top.


self.data = []
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01)
{
  self.data = self.idx == 0 ? ContentView.data1 : ContentView.data2
}

Yes, I tried the same and that freezes the scroll, in a weird position.


I even fear it would be very difficult to do it with the present state of ScrollView in SwiftUI (from the link I psted):

Scroll views are an incredibly important view type for displaying content on all the Apple platforms. Unfortunately,

ScrollView
in SwiftUI has some pretty glaring and difficult to work around limitations. Some of the pretty important features lacking out of the box are:

Offset change callback (via

UIScrollViewDelegate.scrollViewDidScroll(_ scrollView: UIScrollView)
in UIKit)


So, for the time being, the safest may well be to stay with multiple calls to ScrollView (minor improvement would be to put this ScrollView into a new struct with a few @state.

Very good, where did you find the hint ?


That works wellEven with 0.001 delay. So, should be pretty safe with 0.01 delay.

But emptying array is needed.


That would be worth a bug report with the 2 cases, because the basic behavior is very surprising.

I figured that emptying the array would reset the scroll. Only problem was that setting the new data right away wasn't seen by the ScrollView as two changes. So I tried delaying setting the data and a small delay is apparently enough the the ScrollView to recognise the two changes.
A bit of a hack in my own oppinion, it should work better and maybe this will be corrected in a future Xcode/SwiftUI release. Until then, this will work ok for me.
Thanks for the guidance - again :-)

That seem to be a wise decision, we could spend hours on this !


Will you file a bug ? Otherwise I'll do it.

I trie to implement h ttps://zacwhite.com/2019/ scrollview-content-offsets-swiftui/

without success.


Don'y forget to close the thread.

Please feel free to file the bug. I havent tried filing bugs yet and am a little pressed for time.

Swiftui ScrollView reset position
 
 
Q