SwiftUI ScrollView: find subview that is currently visible at center of screen. Subview's position within ScrollView. PreferenceKey. ScrollViewProxy

Viewed 995

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
    }
}
1 Answers

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.

Related