4

The Challenge: I would like to track which subview of a ScrollView is in the middle of the visible area of this ScrollView.

The Problem: I understand that there is no native SwiftUI way of finding out whether a ScrollView's subview is currently visible on screen. Or do I miss anything here?

My Solution: I am using PreferenceKey to collect the subviews positions and act onPreferenceChange(s) that happen continuously while the ScrollView is being scrolled: to check for the view closest to the center of the screen.

The Issue: A PreferenceKey based solution works fine for a few subviews. But I need to track up to 3000 subviews (views of a large structured document being constructed out of a database). Performance is not acceptable for a large number of views, even after implementing some optimisations I have been able to come up with.

My questions is: is there

  • a way to improve the performance of the solution shown below, or
  • a different way of approaching this challenge

(iOS 14.4 / Xcode 12.4)

SwiftUI track subview at screen center

private struct ViewOffsetsKey: PreferenceKey {
    static var defaultValue: [Int: CGFloat] = [:]
    static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) {
        value.merge(nextValue(), uniquingKeysWith: { $1 })
    }
}

struct ContentView: View {
    @State private var offsets: [Int: CGFloat] = [:]
    @State private var mainViewHeight: CGFloat = 800   // demo approximation
    @State private var highlightItem: Bool = false
    @State private var timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
    
    var body: some View {
        ZStack(alignment: .top) {
            VStack {
                ScrollView {
                    VStack {
                        ForEach(0..<3000) { i in
                            Text("Item \(i)")
                                .id(i)
                                .padding()
                                .background(GeometryReader { geo in
                                    Color.clear.preference(
                                        key: ViewOffsetsKey.self,
                                        value: [i: geo.frame(in: .named("scrollView")).origin.y]) })
                                .overlay((i == middleItemNo && highlightItem) ? Color.orange.opacity(0.5) : Color.clear)
                        }
                    }
                    .onPreferenceChange(ViewOffsetsKey.self, perform: { prefs in
                        let filteredPrefs = prefs.filter { $1 > 0 && $1 < mainViewHeight }
                        // Cleaning offsets seams to increase reliablilty.
                        offsets = [:]
                        // Dispatch to silence "Bound preference ... update multiple times per frame" warning.
                        DispatchQueue.main.async {
                            for pref in filteredPrefs { offsets[pref.key] = pref.value }
                        }
                        timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
                    })
                }.coordinateSpace(name: "scrollView")
            }
            
            HStack {
                Text("Middle item no: \(middleItemNo)")
                    .padding(5)
                    .background(Color.white)
                Spacer()
            }.onReceive(timer) { _ in
                highlightItem = true
                timer.upstream.connect().cancel()
                DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
                    highlightItem = false
                }
            }
        }
    }
    
    private var middleItemNo: Int {
        offsets.sorted(by: { $0 < $1 }).first(where: { $1 >= mainViewHeight / 2 - 100 })?.key ?? 0
    }
}
KlausM
  • 193
  • 12
  • It is good project you have been started, if even everything works as you wanted, how would this project be fast enough working with massive data? for example you would have 2000 row, then you are going work with 2000 row geometry data plus onPreferenceChange for 2000, with this setup I think you could not use LazyVStack also, have you though about that? – ios coder Apr 03 '21 at 20:52
  • @swiftPunk yes, I realised the LazyVStack improvement literally minutes after posting the question. That is why I answered my own question moments later with this improvement. – KlausM Apr 04 '21 at 07:09

1 Answers1

1

Changing the VStack within the ScrollView for a LazyVStack solves the performance problem. Came up with that idea only after publishing the post. I will leave it online for others to learn from.

Dharman
  • 30,962
  • 25
  • 85
  • 135
KlausM
  • 193
  • 12