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)
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
}
}