0

I need to create a screen that displays an Array of sentences in a List.
The Array can be very long and the user cannot read it all at once.
Therefore, the following features are needed:

  1. save the index of the currently displayed cell each time the user scrolls
  2. scroll to the cell with the saved index when the user opens the screen again

I have seen that the functionality of 2. can be easily implemented using .scrollTo in ScrollViewProxy (ScrollViewReader).
I am not sure how to implement the function of 1.

It is possible for users to bookmark cells by tapping on them themselves, but the user experience is compromised.
It should be saved automatically.

For example, in the scrolling state of the image below, I would like to get the index of 435, which is the last cell fully displayed.

import SwiftUI

private let lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit."

struct ContentView: View {
    @State private var largeTextArray: [String] = (0..<500).map { _ in
        String(repeating: lorem, count: Int.random(in: 3..<10))
    }

    var body: some View {
        List {
            ForEach(largeTextArray.indices, id: \.self) { index in
                Text("\(index): ")
                    .font(.title)
                    .fontWeight(.heavy)
                    + Text(largeTextArray[index])
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

enter image description here

Any ideas are welcome.

Must support iOS 16 or later.

Mac
  • 137
  • 1
  • 8

2 Answers2

0

Since SwiftUI doesn't have any API like UITableview's indexPathsForVisibleRows which can get us the index of visible cells.But once your List's text is updated, it has the updated index value too. See the code below.

struct ContentView: View {
    @State var scrollToIndex = 0
    @State private var largeTextArray: [String] = (0..<500).map { _ in
        String(repeating: lorem, count:2)
    }
    
    var body: some View {
        ScrollViewReader { proxy in
            Button("Scroll TO : \(scrollToIndex)", action: {
                withAnimation() {
                    proxy.scrollTo(scrollToIndex, anchor: .bottom)
                }
            })
            List {
                ForEach(largeTextArray.indices, id: \.self) { index in
                    Text("\(index): ")
                        .font(.title)
                        .fontWeight(.heavy)
                    Text(largeTextArray[index]).onAppear{
                        print("Visible Row Index is: \(index)")
                    }
                }
            }.onAppear {
                scrollToIndex = 150
            }
        }
    }
}

Tested on iOS 16.0 Working fine for me.Hope it helps.

B25Dec
  • 2,301
  • 5
  • 31
  • 54
  • You shouldn't use indices within a ForEach view as it will not be able to detect/animate changes to the list contents if they are changed/reordered: the index of the first cell will always be zero, even if the content has changed. You need to use the underlying identifiable data element. This question https://stackoverflow.com/q/57244713/3218273 and this answer https://stackoverflow.com/a/68756394/3218273 in particular, go into the detail. – flanker Aug 15 '23 at 10:26
0

Like in your example, you can find many answers to similar questions suggesting using the array's .indices as the controlling element for a 'List or ForEach. However these have the problem that they do not change if the array's content changes or is reordered - the first element in an array is always index 0 no matter what happens to it. For Lists to work properly the content itself need to be identifiable.

For your example however you can't just use the largeTextArray as the data source for the ForEach as the elements in it are not unique/identifiable (note: this may not be a problem if this is just an effect of your simplified example code). Therefore you need to wrap the text up in a struct that is unique and identifiable. A trivial example could be:

struct MyText: Identifiable, Equatable {
   static func == (lhs: MyText, rhs: MyText) -> Bool {
      lhs.id == rhs.id
   }
   
   let id = UUID()
   let str = String(repeating: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", 
                    count: Int.random(in: 3..<10))
}

Then if you have an array of these structs, rather than of String, you can use it directly within the ForEach. As your data in the array is now identifiable it also means you can use .firstIndex(of:) to work out it's position in the array, and both the view and the index will now respond to changes in the arrays content/ordering.

struct SOScrollView: View {
   @State private var largeContentArray = (0..<500).map { _ in
      MyText()
   }
   
   var body: some View {
      List {
         ForEach(largeContentArray) { content in
            let index = largeContentArray.firstIndex(of: content)!
            (Text("\(index): ")
               .font(.title)
               .fontWeight(.heavy)
             + Text(content.str))
            .onAppear{print(largeContentArray.firstIndex(of: content)!)}
         }
      }
   }
}

Note: using .onAppear will also report the index of the top visible item in the list if the scrolling is in the opposite direction; you may need to manage this.

flanker
  • 3,840
  • 1
  • 12
  • 20
  • Equatable might cause problems best remove that and stick with Identifiable – malhal Aug 16 '23 at 15:24
  • @malhal `Equatable` is needed if using `firstIndex(of:)` and I'm not aware of a type being `equatable` causing problems with SwiftUI as long as it is also `Identifiable`. Can you elaborate? – flanker Aug 16 '23 at 16:36
  • Use firstIndex(where:) and compare ids. The problem with overriding equals is 2 different structs with same id are equal when they shouldn't be – malhal Aug 16 '23 at 22:28
  • That would be an alternative, but there is no need. For starters this isn't overriding equality, it is defining it, and it is based on the `id` that is (a) the basis of identifiable, so if it was invalid for equality it would be for identifiable, (b) being using in your `firstIndex(where:)` and so is actually performing the exact same check as the defined equatable, but mainly(c) because the equatable check is comparing two different UUID's which are, to all practical purposes, unique by definition. – flanker Aug 17 '23 at 00:32