Skip to content

Commit 506c493

Browse files
lividclaude
andcommitted
Defer List scrolls on macOS 12 until content settles to avoid offset corruption
macOS 12's SwiftUI List corrupts its scroll offset when ScrollViewProxy.scrollTo runs while the list content is still settling, which resurfaced after following a planet (the article list is wholesale-replaced while FollowPlanetView and the selection restore both request scrolls). Since many senders post scroll notifications, gate the receivers instead of individual senders: a shared ListScrollSettleGate runs scrolls immediately on macOS 13+ and on macOS 12 defers them until the list content has been stable for a moment, keeping only the latest request. Wired into all three List-backed scroll sites: the article list (articlesVersion), the sidebar (planet id changes), and search results (result id changes, which previously scrolled on every keystroke's result replacement). ScrollView-based views (AI chat, avatar picker) are unaffected by the List bug and left unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ef1b41f commit 506c493

5 files changed

Lines changed: 103 additions & 19 deletions

File tree

Planet/Helper/ViewUtils.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,61 @@ extension Color {
155155
}
156156
}
157157

158+
/// macOS 12's SwiftUI List corrupts its scroll offset when
159+
/// ScrollViewProxy.scrollTo runs while the list content is still settling
160+
/// (initial layout, or a wholesale replacement such as switching to a freshly
161+
/// followed planet). On macOS 13+, `perform` runs the scroll action
162+
/// immediately; on macOS 12 it defers the action until the list content has
163+
/// been stable for `settleDelay`. Only the latest deferred action is kept.
164+
/// Call `noteContentChange` whenever the list content is replaced.
165+
@MainActor
166+
final class ListScrollSettleGate {
167+
private var lastContentChange = Date()
168+
private var deferredTask: Task<Void, Never>?
169+
private let settleDelay: TimeInterval
170+
private let maxWait: TimeInterval
171+
172+
init(settleDelay: TimeInterval = 0.6, maxWait: TimeInterval = 5) {
173+
self.settleDelay = settleDelay
174+
self.maxWait = maxWait
175+
}
176+
177+
func noteContentChange() {
178+
lastContentChange = Date()
179+
}
180+
181+
func perform(_ action: @escaping () -> Void) {
182+
if #available(macOS 13.0, *) {
183+
action()
184+
return
185+
}
186+
deferredTask?.cancel()
187+
deferredTask = Task { @MainActor [weak self] in
188+
let startedAt = Date()
189+
while true {
190+
guard let self, !Task.isCancelled else { return }
191+
let remaining = self.settleDelay - Date().timeIntervalSince(self.lastContentChange)
192+
if remaining <= 0 {
193+
break
194+
}
195+
// Content keeps churning; give up rather than risking a
196+
// corrupted offset from scrolling mid-layout.
197+
if Date().timeIntervalSince(startedAt) > self.maxWait {
198+
return
199+
}
200+
try? await Task.sleep(nanoseconds: UInt64(max(remaining, 0.05) * 1_000_000_000))
201+
}
202+
guard !Task.isCancelled else { return }
203+
action()
204+
}
205+
}
206+
207+
func cancel() {
208+
deferredTask?.cancel()
209+
deferredTask = nil
210+
}
211+
}
212+
158213
enum ViewVisibility: CaseIterable {
159214
case visible // view is fully visible
160215
case invisible // view is hidden but takes up space

Planet/Search/SearchView.swift

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ struct SearchView: View {
1717

1818
@AppStorage("searchText") private var storedSearchText: String = ""
1919
@State private var searchTask: Task<Void, Never>?
20+
@State private var scrollSettleGate = ListScrollSettleGate()
2021

2122
@Environment(\.dismiss) private var dismiss
2223
private let searchEmptyAnchorID = "search-results-empty-anchor"
@@ -58,12 +59,18 @@ struct SearchView: View {
5859
ScrollViewReader { proxy in
5960
searchResultView()
6061
.onChange(of: result.map(\.articleID)) { _ in
62+
scrollSettleGate.noteContentChange()
6163
Task { @MainActor in
6264
await Task.yield()
63-
if let firstID = result.first?.articleID {
64-
proxy.scrollTo(firstID, anchor: .top)
65-
} else {
66-
proxy.scrollTo(searchEmptyAnchorID, anchor: .top)
65+
// Resolve the target when the scroll runs: on
66+
// macOS 12 the deferred scroll can fire after the
67+
// results have changed again.
68+
scrollSettleGate.perform {
69+
if let firstID = result.first?.articleID {
70+
proxy.scrollTo(firstID, anchor: .top)
71+
} else {
72+
proxy.scrollTo(searchEmptyAnchorID, anchor: .top)
73+
}
6774
}
6875
}
6976
if let focusedResult, !result.contains(focusedResult) {
@@ -72,8 +79,10 @@ struct SearchView: View {
7279
}
7380
.onChange(of: focusedResult?.articleID) { id in
7481
if let id = id {
75-
withAnimation(.easeInOut(duration: 0.12)) {
76-
proxy.scrollTo(id, anchor: .center)
82+
scrollSettleGate.perform {
83+
withAnimation(.easeInOut(duration: 0.12)) {
84+
proxy.scrollTo(id, anchor: .center)
85+
}
7786
}
7887
}
7988
}
@@ -95,6 +104,7 @@ struct SearchView: View {
95104
searchTask = nil
96105
isSearching = false
97106
storedSearchText = searchText
107+
scrollSettleGate.cancel()
98108
}
99109
}
100110

Planet/Views/Articles/ArticleListView.swift

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -461,16 +461,19 @@ struct ArticleListView: View {
461461
@State var articles: [ArticleModel]? = []
462462
@State private var pendingScrollArticleID: UUID?
463463
@State private var pendingScrollTask: Task<Void, Never>?
464+
@State private var scrollSettleGate = ListScrollSettleGate()
464465

465466
let articleDropDelegate = ArticleListDropDelegate()
466467

467468
private func scrollToArticle(_ articleID: UUID, proxy: ScrollViewProxy, animated: Bool) {
468-
if animated {
469-
withAnimation {
469+
scrollSettleGate.perform {
470+
if animated {
471+
withAnimation {
472+
proxy.scrollTo(articleID, anchor: .center)
473+
}
474+
} else {
470475
proxy.scrollTo(articleID, anchor: .center)
471476
}
472-
} else {
473-
proxy.scrollTo(articleID, anchor: .center)
474477
}
475478
}
476479

@@ -559,13 +562,18 @@ struct ArticleListView: View {
559562
}.id(article.id)
560563
}
561564
.onReceive(NotificationCenter.default.publisher(for: .scrollToTopArticleList)) { n in
562-
if let article = viewModel.articles.first {
563-
debugPrint("Scrolling to top of Article List: \(article)")
564-
pendingScrollTask?.cancel()
565-
pendingScrollTask = nil
566-
pendingScrollArticleID = nil
567-
withAnimation {
568-
proxy.scrollTo(article.id, anchor: .top)
565+
pendingScrollTask?.cancel()
566+
pendingScrollTask = nil
567+
pendingScrollArticleID = nil
568+
// Resolve the first article when the scroll runs:
569+
// on macOS 12 the deferred scroll can fire after
570+
// the list content is replaced.
571+
scrollSettleGate.perform {
572+
if let article = viewModel.articles.first {
573+
debugPrint("Scrolling to top of Article List: \(article)")
574+
withAnimation {
575+
proxy.scrollTo(article.id, anchor: .top)
576+
}
569577
}
570578
}
571579
}
@@ -585,6 +593,7 @@ struct ArticleListView: View {
585593
}
586594
}
587595
.onChange(of: viewModel.articlesVersion) { _ in
596+
scrollSettleGate.noteContentChange()
588597
guard pendingScrollArticleID != nil else {
589598
return
590599
}
@@ -679,6 +688,7 @@ struct ArticleListView: View {
679688
pendingScrollTask?.cancel()
680689
pendingScrollTask = nil
681690
pendingScrollArticleID = nil
691+
scrollSettleGate.cancel()
682692
}
683693
}
684694
}

Planet/Views/Sidebar/PlanetSidebarView.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ struct PlanetSidebarView: View {
1616
@StateObject var ipfsState = IPFSState.shared
1717
@AppStorage(String.settingsAIIsReady) private var settingsAIIsReady: Bool = false
1818
@State private var isOnDeviceAIAvailable: Bool = false
19+
@State private var scrollSettleGate = ListScrollSettleGate()
1920

2021
let timer1m = Timer.publish(every: 60, on: .current, in: .common).autoconnect()
2122
let timer3m = Timer.publish(every: 180, on: .current, in: .common).autoconnect()
@@ -107,9 +108,17 @@ struct PlanetSidebarView: View {
107108
.listStyle(.sidebar)
108109
.onReceive(NotificationCenter.default.publisher(for: .scrollToSidebarItem)) { n in
109110
if let id = n.object as? String {
110-
sidebarProxy.scrollTo(id, anchor: .center)
111+
scrollSettleGate.perform {
112+
sidebarProxy.scrollTo(id, anchor: .center)
113+
}
111114
}
112115
}
116+
.onChange(of: planetStore.myPlanets.map(\.id)) { _ in
117+
scrollSettleGate.noteContentChange()
118+
}
119+
.onChange(of: planetStore.followingPlanets.map(\.id)) { _ in
120+
scrollSettleGate.noteContentChange()
121+
}
113122
}
114123

115124
Divider()

Planet/versioning.xcconfig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
CURRENT_PROJECT_VERSION = 2833
1+
CURRENT_PROJECT_VERSION = 2834

0 commit comments

Comments
 (0)