Deleted
Deleted

Reputation: 771

How to get on screen rows from List in SwiftUI?

I'm trying to put a Text for each visible (within the screen bounds) row in a List of their current index in all the visible rows.

I have an example here. If I have a list showing 3 rows at a time, and initially I want it showing like this:

___|___
A  |  0
B  |  1
C  |  2
-------

And if I scroll the list down, and A goes out of the screen, then it will be like

___|___
B  |  0
C  |  1
D  |  2
-------

Any idea how can I do it?

Besides, I used to use UITableView.visibleCells to obtain the visible cells on screen, but I couldn't find something similar in SwiftUI. How exactly am I suppose to get visible row in a list with only index provided in SwiftUI?

Hope someone can direct me to the right place. Thanks a lot!

Upvotes: 6

Views: 5554

Answers (3)

Naresh
Naresh

Reputation: 955

I would suggest to use the onAppear modifier with LazyHStack or LazyVStack's subview. Place this LazyHStack or LazyVStack inside the ScrollView. e.g.

Here is the scroll view and LazyVStack

import SwiftUI

struct ProductListView: View {
    var staff = [
        Person(name: "Ram", phoneNumber: "(408) 555-4301"),
        Person(name: "Aman", phoneNumber: "(919) 555-2481"),
        Person(name: "John", phoneNumber: "(408) 555-4301"),
        Person(name: "Jeff", phoneNumber: "(408) 555-4301"),
        Person(name: "Boing", phoneNumber: "(408) 555-4301"),
        Person(name: "Kim", phoneNumber: "(408) 555-4301"),
        Person(name: "Juhi", phoneNumber: "(408) 555-4301"),
        Person(name: "Mei Chen", phoneNumber: "(919) 555-2481"),
        Person(name: "Rahul", phoneNumber: "(408) 555-4301"),
        Person(name: "Mahesh", phoneNumber: "(408) 555-4301"),
        Person(name: "Ninja", phoneNumber: "(408) 555-4301"),
        Person(name: "Rohit", phoneNumber: "(408) 555-4301")
    ]
    var body: some View {
        ScrollView(.vertical) {
            LazyVStack(alignment: .leading, spacing: 10) {
                ForEach(staff) { person in
                    ProductListItem.init(title: person.name, description: person.phoneNumber)
                        .onAppear(perform: {
                            print("Item appred - ", person.name)
                        })
                }
            }
        }
    }
}

Here is the List Item

import SwiftUI

struct ProductListItem: View {
    let title: String
    let description: String
    var body: some View {
        ZStack(alignment: .bottom, content: {
            Image("placeholder", bundle: nil)
                .resizable()
            HStack(alignment: .bottom, content: {
                VStack(alignment: .leading, content: {
                    Text(title).font(.headline)
                        .foregroundStyle(.white)
                    Text(description).font(.subheadline)
                        .foregroundStyle(.white)
                })
                Spacer()
            })
            .padding()
            .foregroundColor(.primary)
            .background(Color.secondary
                .colorInvert()
                .opacity(0.90))
        })
    }
}

NOTE - Just to increase the item size add some placeholder image in assets folder.

Upvotes: 0

Klay
Klay

Reputation: 105

While you could use .onAppear and .onDisappear, those modifiers are based on SwiftUI's conception of appearance, rather than user visibility. It's common for a view, for example, to be out of sight for the user but still visible to SwiftUI until a new List item is loaded.

To appropriately measure which List items are visible to the user, you'll need the items to report their positions, then filter the ones based on some condition that determines visibility. In practice, that means using preferences to send information up the view hierarchy.

The best way to accomplish that is with anchor preferences (Swift with Majid has a very good article on this). If you're using a ScrollView, you may be able to get away with scrollPosition(id:anchor:); but preferences allow you to specify your own filtering criteria.

First, you'll need the preference:

struct VisibleItem<Item> {
  let item: Item
  let anchor: Anchor<CGRect>
}

struct VisiblePreferenceKey<Item>: PreferenceKey {
  typealias Value = [VisibleItem<Item>]

  static var defaultValue: Value { [] }

  static func reduce(value: inout Value, nextValue: () -> Value) {
    value.append(contentsOf: nextValue())
  }
}

VisibleItem will be the struct useful for filtering via visibility. The reason it has an Anchor is because it allows you to convert the geometry of a view into the coordinate space of another (in other words, it allows the filtering to actually encompass the user visible space)

The preference will then be applied to List items that should report their positions:

struct UserImages: View {
  let images: [UserImage]

  var body: some View {
    List(images) { image in
      UserImageView(image: image)
        .anchorPreference(key: VisiblePreferenceKey.self, value: .bounds) { [.init(item: image, anchor: $0)] }
    }
  }
}

Note that we're storing image as the item so we can reference it after we filter.

Finally, on your List, you can read the preference value and apply your filtering:

struct UserImages: View {
  let images: [UserImage]

  var body: some View {
    List(images) { image in
      // ...
    }.backgroundPreferenceValue(VisiblePreferenceKey<UserImage>.self) { items in
      GeometryReader { proxy in
        let local = proxy.frame(in: .local)
        let images = items
          .filter { local.intersects(proxy[$0.anchor]) }
          .map(\.item)

        // Do something with `images`
      }
    }
  }
}

I'm using backgroundPreferenceValue(_:_:) here just to demonstrate, but as can be seen, the frame of the List is stored in local, and each item is filtered with respect to the List's coordinate space (the proxy[$0.anchor]), and checked to see if the two CGRects intersect (in other words, do they overlap?)

You could do a lot with this, but for your specific case, what you could do is use a Color.clear.onChange(of: images) {...} and store the order of each item in your model so you can display it in your UI (items is already sorted by the preference key). If you wanted to be more strict, per say, and only include items that are completely visible (so a view that is half-scrolled out is not counted), you could e.g. change local.intersects(...) to local.contains(...).

Upvotes: 1

Aleksey Gotyanov
Aleksey Gotyanov

Reputation: 550

You can use onAppear and onDisappear functions to maintain a list of visible rows and then use these to find a visible index.

struct ContentView: View {
    let rows = (Unicode.Scalar("A").value...Unicode.Scalar("Z").value)
        .map { String(Unicode.Scalar($0)!) }

    @State var visibleRows: Set<String> = []

    var body: some View {
        List(rows, id: \.self) { row in
            HStack {
                Text(row)
                    .padding(40)
                    .onAppear { self.visibleRows.insert(row) }
                    .onDisappear { self.visibleRows.remove(row) }
            }
            Spacer()
            Text(self.getVisibleIndex(for: row)?.description ?? "")
        }
    }

    func getVisibleIndex(for row: String) -> Int? {
        visibleRows.sorted().firstIndex(of: row)
    }
}

Upvotes: 18

Related Questions