Skip to content

Commit 9a8de61

Browse files
committed
Prepare virtual list destinations before distant status bar returns
1 parent 903b1cd commit 9a8de61

3 files changed

Lines changed: 189 additions & 0 deletions

File tree

‎platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7039,6 +7039,16 @@ private struct LUIVirtualListView: View {
70397039
}
70407040
}
70417041
.coordinateSpace(name: contentSpace)
7042+
.background {
7043+
#if os(iOS)
7044+
if let first = rows.segments.first(where: { $0.window != nil }), let window = first.window, window.count > 0 {
7045+
LUIVirtualScrollTopBridge(prepared: window.start == 0) {
7046+
backend.performVisibleRange(node: first.source, first: 0, last: min(31, window.count - 1))
7047+
}
7048+
.frame(width: 0, height: 0)
7049+
}
7050+
#endif
7051+
}
70427052
.overlay(alignment: .topLeading) {
70437053
if let editor {
70447054
let row = rows[editor.position]
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
#if os(iOS)
2+
import SwiftUI
3+
import UIKit
4+
5+
/// A distant status-bar return needs its destination window before UIKit moves.
6+
struct LUIVirtualScrollTopBridge: UIViewRepresentable {
7+
let prepared: Bool
8+
let requestTop: () -> Void
9+
10+
func makeUIView(context: Context) -> Probe { Probe() }
11+
12+
func updateUIView(_ view: Probe, context: Context) {
13+
view.prepared = prepared
14+
view.requestTop = requestTop
15+
view.install()
16+
view.completeReturn()
17+
}
18+
19+
static func dismantleUIView(_ view: Probe, coordinator: ()) { view.uninstall() }
20+
21+
final class Probe: UIView {
22+
var prepared = false
23+
var requestTop: (() -> Void)?
24+
private weak var scroll: UIScrollView?
25+
private let proxy = DelegateProxy()
26+
private var returning = false
27+
28+
init() {
29+
super.init(frame: .zero)
30+
isUserInteractionEnabled = false
31+
proxy.shouldReturn = { [weak self] scroll in
32+
guard let self, scroll.contentOffset.y + scroll.adjustedContentInset.top > scroll.bounds.height else { return true }
33+
returning = true
34+
// Stop momentum without replacing the visible window while the destination is prepared.
35+
scroll.setContentOffset(scroll.contentOffset, animated: false)
36+
if prepared { completeReturn() } else { requestTop?() }
37+
return false
38+
}
39+
proxy.cancelReturn = { [weak self] in self?.returning = false }
40+
}
41+
42+
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
43+
44+
override func didMoveToWindow() {
45+
super.didMoveToWindow()
46+
if window != nil { install() }
47+
}
48+
49+
override func layoutSubviews() {
50+
super.layoutSubviews()
51+
install()
52+
}
53+
54+
func install() {
55+
var ancestor = superview
56+
while let view = ancestor {
57+
if let scroll = view as? UIScrollView {
58+
if self.scroll !== scroll { uninstall(); self.scroll = scroll }
59+
if scroll.delegate !== proxy {
60+
proxy.original = scroll.delegate
61+
scroll.delegate = proxy
62+
}
63+
return
64+
}
65+
ancestor = view.superview
66+
}
67+
}
68+
69+
func uninstall() {
70+
if let scroll, scroll.delegate === proxy { scroll.delegate = proxy.original }
71+
scroll = nil
72+
}
73+
74+
func completeReturn() {
75+
guard returning, prepared, let scroll else { return }
76+
returning = false
77+
scroll.setContentOffset(CGPoint(x: scroll.contentOffset.x, y: -scroll.adjustedContentInset.top), animated: false)
78+
}
79+
}
80+
81+
private final class DelegateProxy: NSObject, UIScrollViewDelegate {
82+
weak var original: UIScrollViewDelegate?
83+
var shouldReturn: (@MainActor (UIScrollView) -> Bool)?
84+
var cancelReturn: (@MainActor () -> Void)?
85+
86+
override func responds(to selector: Selector!) -> Bool {
87+
if super.responds(to: selector) { return true }
88+
return MainActor.assumeIsolated { original?.responds(to: selector) == true }
89+
}
90+
91+
override func forwardingTarget(for selector: Selector!) -> Any? {
92+
let target = MainActor.assumeIsolated { original }
93+
return target?.responds(to: selector) == true ? target : super.forwardingTarget(for: selector)
94+
}
95+
96+
func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
97+
guard original?.scrollViewShouldScrollToTop?(scrollView) != false else { return false }
98+
return shouldReturn?(scrollView) ?? true
99+
}
100+
101+
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
102+
cancelReturn?()
103+
original?.scrollViewWillBeginDragging?(scrollView)
104+
}
105+
}
106+
}
107+
#endif
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#if os(iOS)
2+
import XCTest
3+
import UIKit
4+
@testable import LUIAppleBackend
5+
6+
@MainActor final class LUIVirtualScrollTopTests: XCTestCase {
7+
private final class OriginalDelegate: NSObject, UIScrollViewDelegate {
8+
var allowsReturn = true
9+
var scrollEvents = 0
10+
func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { allowsReturn }
11+
func scrollViewDidScroll(_ scrollView: UIScrollView) { scrollEvents += 1 }
12+
}
13+
14+
func testDistantReturnWaitsForDestinationAndRestoresDelegate() {
15+
let scroll = UIScrollView(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
16+
scroll.contentSize = CGSize(width: 320, height: 10_000)
17+
let original = OriginalDelegate()
18+
scroll.delegate = original
19+
let probe = LUIVirtualScrollTopBridge.Probe()
20+
scroll.addSubview(probe)
21+
probe.install()
22+
var requests = 0
23+
probe.requestTop = { requests += 1 }
24+
scroll.setContentOffset(CGPoint(x: 0, y: 5000), animated: false)
25+
XCTAssertEqual(scroll.delegate?.scrollViewShouldScrollToTop?(scroll), false)
26+
XCTAssertEqual(requests, 1)
27+
XCTAssertEqual(scroll.contentOffset.y, 5000)
28+
probe.prepared = true
29+
probe.completeReturn()
30+
XCTAssertEqual(scroll.contentOffset.y, 0)
31+
probe.uninstall()
32+
XCTAssertTrue(scroll.delegate === original)
33+
}
34+
35+
func testNearbyReturnAndOriginalDelegateDecisionsArePreserved() {
36+
let scroll = UIScrollView(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
37+
scroll.contentSize = CGSize(width: 320, height: 10_000)
38+
let original = OriginalDelegate()
39+
scroll.delegate = original
40+
let probe = LUIVirtualScrollTopBridge.Probe()
41+
scroll.addSubview(probe)
42+
probe.install()
43+
defer { probe.uninstall() }
44+
var requests = 0
45+
probe.requestTop = { requests += 1 }
46+
scroll.setContentOffset(CGPoint(x: 0, y: 50), animated: false)
47+
XCTAssertEqual(scroll.delegate?.scrollViewShouldScrollToTop?(scroll), true)
48+
scroll.setContentOffset(CGPoint(x: 0, y: 5000), animated: false)
49+
original.allowsReturn = false
50+
XCTAssertEqual(scroll.delegate?.scrollViewShouldScrollToTop?(scroll), false)
51+
XCTAssertEqual(requests, 0)
52+
let count = original.scrollEvents
53+
scroll.delegate?.scrollViewDidScroll?(scroll)
54+
XCTAssertEqual(original.scrollEvents, count + 1)
55+
}
56+
57+
func testNewDragCancelsPendingReturn() {
58+
let scroll = UIScrollView(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
59+
scroll.contentSize = CGSize(width: 320, height: 10_000)
60+
let probe = LUIVirtualScrollTopBridge.Probe()
61+
scroll.addSubview(probe)
62+
probe.install()
63+
defer { probe.uninstall() }
64+
scroll.setContentOffset(CGPoint(x: 0, y: 5000), animated: false)
65+
XCTAssertEqual(scroll.delegate?.scrollViewShouldScrollToTop?(scroll), false)
66+
scroll.delegate?.scrollViewWillBeginDragging?(scroll)
67+
probe.prepared = true
68+
probe.completeReturn()
69+
XCTAssertEqual(scroll.contentOffset.y, 5000)
70+
}
71+
}
72+
#endif

0 commit comments

Comments
 (0)