From ed8c57ff4effd54b10badd96d2290f283267f885 Mon Sep 17 00:00:00 2001 From: Bugen Zhao Date: Fri, 10 Oct 2025 14:11:00 +0800 Subject: [PATCH 1/2] refactor: async datasource Signed-off-by: Bugen Zhao --- app/Shared/Logic/LogicCall.swift | 15 +- app/Shared/Models/PagingDataSource.swift | 135 +++++++++--------- app/Shared/Views/FavoriteTopicListView.swift | 2 +- app/Shared/Views/GlobalSearchView.swift | 4 +- app/Shared/Views/HotTopicListView.swift | 2 +- app/Shared/Views/NotificationListView.swift | 2 +- .../Views/RecommendedTopicListView.swift | 2 +- .../Views/ShortMessageDetailsView.swift | 4 +- app/Shared/Views/ShortMessageListView.swift | 4 +- app/Shared/Views/TopicDetailsView.swift | 21 ++- app/Shared/Views/TopicHistoryListView.swift | 2 +- app/Shared/Views/TopicListView.swift | 8 +- app/Shared/Views/TopicSearchView.swift | 4 +- app/Shared/Views/UserProfileView.swift | 4 +- 14 files changed, 106 insertions(+), 103 deletions(-) diff --git a/app/Shared/Logic/LogicCall.swift b/app/Shared/Logic/LogicCall.swift index 83da7794..ec9614b9 100644 --- a/app/Shared/Logic/LogicCall.swift +++ b/app/Shared/Logic/LogicCall.swift @@ -28,11 +28,16 @@ func logicCallAsync( requestDispatchQueue: DispatchQueue = .global(qos: .userInitiated), errorToastModel: ToastModel? = .banner ) async -> Result { - await withCheckedContinuation { (continuation: CheckedContinuation, Never>) in - logicCallAsync(requestValue, requestDispatchQueue: requestDispatchQueue, errorToastModel: errorToastModel) { (res: Response) in - continuation.resume(returning: .success(res)) - } onError: { err in - continuation.resume(returning: .failure(err)) + await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: CheckedContinuation, Never>) in + logicCallAsync(requestValue, requestDispatchQueue: requestDispatchQueue, errorToastModel: errorToastModel) { (res: Response) in + continuation.resume(returning: .success(res)) + } onError: { err in + continuation.resume(returning: .failure(err)) + } } + } onCancel: { + // TODO: cancel the request in rust side + logger.debug("logicCallAsync (async): cancelled") } } diff --git a/app/Shared/Models/PagingDataSource.swift b/app/Shared/Models/PagingDataSource.swift index cf2e547f..0489b65c 100644 --- a/app/Shared/Models/PagingDataSource.swift +++ b/app/Shared/Models/PagingDataSource.swift @@ -80,6 +80,7 @@ class PagingDataSource: ObservableObject { return pagedItems.sorted { $0.key < $1.key }.map { (page: $0.key, items: $0.value) } } + @MainActor private func upsertItems(_ items: some Sequence, page: Int) { for item in items { let id = item[keyPath: id] @@ -93,6 +94,7 @@ class PagingDataSource: ObservableObject { } } + @MainActor private func replaceItems(_ items: some Sequence, page: Int) { if neverRemove == false { self.items.removeAll() @@ -101,16 +103,11 @@ class PagingDataSource: ObservableObject { upsertItems(items, page: page) } - func loadMore(after: Double = 0.0) { - DispatchQueue.main.asyncAfter(deadline: .now() + after) { - self.loadMore(background: false, alwaysAnimation: true) - } - } - + // TODO: `onAppear` works great while `task` seems glitchy func loadMoreIfNeeded(currentItem: Item) { if let index = itemToIndexAndPage[currentItem[keyPath: id]]?.index { let threshold = items.index(items.endIndex, offsetBy: -2) - if index >= threshold { loadMore(background: true) } + if index >= threshold { Task { await loadMore(backgroundQueue: true) } } } } @@ -121,6 +118,7 @@ class PagingDataSource: ObservableObject { latestError = e } + @MainActor private func preRefresh(fromPage: Int) -> AsyncRequest.OneOf_Value? { if isRefreshing || isLoading { return nil } dataFlowId = UUID() @@ -135,6 +133,7 @@ class PagingDataSource: ObservableObject { return request } + @MainActor private func onRefreshSuccess(response: Res, animated: Bool, fromPage: Int) { latestResponse = response latestError = nil @@ -152,6 +151,7 @@ class PagingDataSource: ObservableObject { lastRefreshTime = Date() } + @MainActor private func onRefreshError(_ e: LogicError, animated: Bool) { withAnimation(when: animated) { self.isRefreshing = false @@ -160,71 +160,68 @@ class PagingDataSource: ObservableObject { onError(e) } + // Sync version for compatibility. func refresh(animated: Bool = false, silentOnError: Bool = false, fromPage: Int = 1) { - guard let request = preRefresh(fromPage: fromPage) else { return } - - logicCallAsync(request, errorToastModel: silentOnError ? nil : .banner) { (response: Res) in - self.onRefreshSuccess(response: response, animated: animated, fromPage: fromPage) - } onError: { e in - self.onRefreshError(e, animated: animated) - } + Task { await refresh(animated: animated, silentOnError: silentOnError, fromPage: fromPage) } } - func refreshAsync(animated: Bool = false, fromPage: Int = 1) async { - let request = DispatchQueue.main.sync { preRefresh(fromPage: fromPage) } - guard let request else { return } + @MainActor + func refresh(animated: Bool = false, silentOnError: Bool = false, fromPage: Int = 1) async { + guard let request = preRefresh(fromPage: fromPage) else { return } - let response: Result = await logicCallAsync(request) + let response: Result = await logicCallAsync(request, errorToastModel: silentOnError ? nil : .banner) - DispatchQueue.main.sync { - switch response { - case let .success(response): - self.onRefreshSuccess(response: response, animated: animated, fromPage: fromPage) - case let .failure(e): - self.onRefreshError(e, animated: animated) - } + switch response { + case let .success(response): + onRefreshSuccess(response: response, animated: animated, fromPage: fromPage) + case let .failure(e): + onRefreshError(e, animated: animated) } } - func initialLoad() { + func initialLoad() async { if loadedPage == 0, latestError == nil { - refresh(animated: true) + await refresh(animated: true) } } - func reloadLastPages(evenIfNotLoaded: Bool) { + func reloadLastPages(evenIfNotLoaded: Bool) async { for page in [totalPages, totalPages + 1] { - reload(page: page, evenIfNotLoaded: evenIfNotLoaded) + await reload(page: page, evenIfNotLoaded: evenIfNotLoaded) } } - func reload(page: Int, evenIfNotLoaded: Bool, animated: Bool = true, after: (() -> Void)? = nil) { + func reload(page: Int, evenIfNotLoaded: Bool, animated: Bool = true) async { guard page <= loadedPage || evenIfNotLoaded else { return } let request = buildRequest(page) let currentId = dataFlowId - logicCallAsync(request) { (response: Res) in - guard currentId == self.dataFlowId else { return } + let response: Result = await logicCallAsync(request) - self.latestResponse = response - self.latestError = nil - let (newItems, newTotalPages) = self.onResponse(response) + await MainActor.run { + switch response { + case let .success(response): + guard currentId == dataFlowId else { return } + latestResponse = response + latestError = nil + let (newItems, newTotalPages) = onResponse(response) + + withAnimation(when: animated) { + upsertItems(newItems, page: page) + isLoading = false + } + totalPages = newTotalPages ?? totalPages - withAnimation(when: animated) { - self.upsertItems(newItems, page: page) - self.isLoading = false - } - self.totalPages = newTotalPages ?? self.totalPages - if let after { after() } - } onError: { e in - withAnimation { - self.isLoading = false + case let .failure(e): + withAnimation { + isLoading = false + } + onError(e) } - self.onError(e) } } - private func loadMore(background: Bool = false, alwaysAnimation: Bool = false) { + func loadMore(backgroundQueue: Bool = false, alwaysAnimation: Bool = false) async { if isLoading || loadedPage >= totalPages { return } isLoading = true @@ -232,27 +229,33 @@ class PagingDataSource: ObservableObject { let request = buildRequest(page) let currentId = dataFlowId - let queue = DispatchQueue.global(qos: background ? .background : .userInitiated) + let queue = DispatchQueue.global(qos: backgroundQueue ? .background : .userInitiated) - logicCallAsync(request, requestDispatchQueue: queue) { (response: Res) in - guard currentId == self.dataFlowId else { return } + let response: Result = await logicCallAsync(request, requestDispatchQueue: queue) - self.latestResponse = response - self.latestError = nil - let (newItems, newTotalPages) = self.onResponse(response) - logger.debug("page \(self.loadedPage + 1), newItems \(newItems.count)") + await MainActor.run { + switch response { + case let .success(response): + guard currentId == dataFlowId else { return } - withAnimation(when: self.items.isEmpty || alwaysAnimation) { - self.upsertItems(newItems, page: page) - self.isLoading = false - } - self.totalPages = newTotalPages ?? self.totalPages - self.loadedPage += 1 - } onError: { e in - withAnimation(when: self.items.isEmpty) { - self.isLoading = false + latestResponse = response + latestError = nil + let (newItems, newTotalPages) = onResponse(response) + logger.debug("page \(loadedPage + 1), newItems \(newItems.count)") + + withAnimation(when: items.isEmpty || alwaysAnimation) { + upsertItems(newItems, page: page) + isLoading = false + } + totalPages = newTotalPages ?? totalPages + loadedPage += 1 + + case let .failure(e): + withAnimation(when: items.isEmpty) { + isLoading = false + } + onError(e) } - self.onError(e) } } } @@ -264,9 +267,9 @@ struct PagingDataSourceRefreshable: ViewModifi @Environment(\.scenePhase) var scenePhase func doRefresh() async { - try? await Task.sleep(nanoseconds: UInt64(0.25 * Double(NSEC_PER_SEC))) - await dataSource.refreshAsync(animated: true) - try? await Task.sleep(nanoseconds: UInt64(0.25 * Double(NSEC_PER_SEC))) + // try? await Task.sleep(nanoseconds: UInt64(0.25 * Double(NSEC_PER_SEC))) + await dataSource.refresh(animated: true) + // try? await Task.sleep(nanoseconds: UInt64(0.25 * Double(NSEC_PER_SEC))) } func body(content: Content) -> some View { diff --git a/app/Shared/Views/FavoriteTopicListView.swift b/app/Shared/Views/FavoriteTopicListView.swift index 4b30f23f..e71c3f82 100644 --- a/app/Shared/Views/FavoriteTopicListView.swift +++ b/app/Shared/Views/FavoriteTopicListView.swift @@ -38,7 +38,7 @@ struct FavoriteTopicListView: View { Group { if dataSource.notLoaded { ProgressView() - .onAppear { dataSource.initialLoad() } + .task { await dataSource.initialLoad() } } else { List { ForEach($dataSource.items, id: \.id) { topic in diff --git a/app/Shared/Views/GlobalSearchView.swift b/app/Shared/Views/GlobalSearchView.swift index e423120c..040f6290 100644 --- a/app/Shared/Views/GlobalSearchView.swift +++ b/app/Shared/Views/GlobalSearchView.swift @@ -78,7 +78,7 @@ struct ForumSearchView: View { Group { if dataSource.notLoaded { ProgressView() - .onAppear { dataSource.initialLoad() } + .task { await dataSource.initialLoad() } } else { List { Section(header: Text("Search Results")) { @@ -99,7 +99,7 @@ struct UserSearchView: View { Group { if dataSource.notLoaded { ProgressView() - .onAppear { dataSource.initialLoad() } + .task { await dataSource.initialLoad() } } else { List { Section(header: Text("Search Results")) { diff --git a/app/Shared/Views/HotTopicListView.swift b/app/Shared/Views/HotTopicListView.swift index b9c59ab3..3cacd528 100644 --- a/app/Shared/Views/HotTopicListView.swift +++ b/app/Shared/Views/HotTopicListView.swift @@ -40,7 +40,7 @@ struct HotTopicListInnerView: View { Group { if dataSource.notLoaded { ProgressView() - .onAppear { dataSource.initialLoad() } + .task { await dataSource.initialLoad() } } else { List { Section(header: Text(range.description)) { diff --git a/app/Shared/Views/NotificationListView.swift b/app/Shared/Views/NotificationListView.swift index 1a1c37d1..64b36962 100644 --- a/app/Shared/Views/NotificationListView.swift +++ b/app/Shared/Views/NotificationListView.swift @@ -56,7 +56,7 @@ struct NotificationListView: View { Group { if dataSource.notLoaded { ProgressView() - .onAppear { dataSource.initialLoad() } + .task { await dataSource.initialLoad() } } else { List { ForEach($dataSource.items, id: \.id) { notification in diff --git a/app/Shared/Views/RecommendedTopicListView.swift b/app/Shared/Views/RecommendedTopicListView.swift index bc3c99e6..ebb7c04d 100644 --- a/app/Shared/Views/RecommendedTopicListView.swift +++ b/app/Shared/Views/RecommendedTopicListView.swift @@ -40,7 +40,7 @@ struct RecommendedTopicListView: View { Group { if dataSource.notLoaded { ProgressView() - .onAppear { dataSource.initialLoad() } + .task { await dataSource.initialLoad() } } else { List { ForEach($dataSource.items, id: \.id) { topic in diff --git a/app/Shared/Views/ShortMessageDetailsView.swift b/app/Shared/Views/ShortMessageDetailsView.swift index 309792ea..872ee4aa 100644 --- a/app/Shared/Views/ShortMessageDetailsView.swift +++ b/app/Shared/Views/ShortMessageDetailsView.swift @@ -57,7 +57,7 @@ struct ShortMessageDetailsView: View { Group { if dataSource.notLoaded { ProgressView() - .onAppear { dataSource.initialLoad() } + .task { await dataSource.initialLoad() } } else { List { Section(header: Text("Participants")) { @@ -84,7 +84,7 @@ struct ShortMessageDetailsView: View { .refreshable(dataSource: dataSource) .withTopicDetailsAction() .toolbar { toolbar } - .onChange(of: postModel.sent) { dataSource.reloadLastPages(evenIfNotLoaded: false) } + .task(id: postModel.sent) { await dataSource.reloadLastPages(evenIfNotLoaded: false) } } func doReply() { diff --git a/app/Shared/Views/ShortMessageListView.swift b/app/Shared/Views/ShortMessageListView.swift index 43718119..1ba3f3dd 100644 --- a/app/Shared/Views/ShortMessageListView.swift +++ b/app/Shared/Views/ShortMessageListView.swift @@ -48,7 +48,7 @@ struct ShortMessageListView: View { Group { if dataSource.notLoaded { ProgressView() - .onAppear { dataSource.initialLoad() } + .task { await dataSource.initialLoad() } } else { List { ForEach(dataSource.items, id: \.id) { message in @@ -65,7 +65,7 @@ struct ShortMessageListView: View { .mayGroupedListStyle() .refreshable(dataSource: dataSource) .toolbar { toolbar } - .onChange(of: postModel.sent) { dataSource.reload(page: 1, evenIfNotLoaded: false) } + .task(id: postModel.sent) { await dataSource.reload(page: 1, evenIfNotLoaded: false) } } func newShortMessage() { diff --git a/app/Shared/Views/TopicDetailsView.swift b/app/Shared/Views/TopicDetailsView.swift index f45ae577..0cdec82f 100644 --- a/app/Shared/Views/TopicDetailsView.swift +++ b/app/Shared/Views/TopicDetailsView.swift @@ -261,14 +261,13 @@ struct TopicDetailsView: View { @ViewBuilder var mayLoadBackButton: some View { if let _ = dataSource.loadFromPage, - let prevPage = dataSource.firstLoadedPage?.advanced(by: -1), prevPage >= 1, - let currFirst = dataSource.items.min(by: { $0.floor < $1.floor }) + let prevPage = dataSource.firstLoadedPage?.advanced(by: -1), prevPage >= 1 { Button(action: { - action.scrollToFloor = Int(currFirst.floor) // scroll to first for fixing scroll position - dataSource.reload(page: prevPage, evenIfNotLoaded: true) { + Task { + await dataSource.reload(page: prevPage, evenIfNotLoaded: true) guard let floor = dataSource.itemsAtPage(prevPage).map(\.floor).max() else { return } - DispatchQueue.main.async { action.scrollToFloor = Int(floor) } // scroll to last of prev page + action.scrollToFloor = Int(floor) // scroll to last of prev page } }) { Label("Load Page \(prevPage)", systemImage: "arrow.counterclockwise") @@ -359,7 +358,7 @@ struct TopicDetailsView: View { } if let nextPage = dataSource.nextPage { - let loadTrigger = Text("").onAppear { dataSource.loadMore() } + let loadTrigger = Text("").task { await dataSource.loadMore(alwaysAnimation: true) } Section(header: Text("Page \(nextPage)"), footer: loadTrigger) { // BUGEN'S HACK: // the first view of this section will unexpectedly call `onAppear(_:)` @@ -520,11 +519,11 @@ struct TopicDetailsView: View { .toolbar { toolbar } .refreshable(dataSource: dataSource) .toolbarRole(.editor) // make title left aligned - .onChange(of: postReply.sent) { reloadPageAfter(sent: $1) } + .task(id: postReply.sent) { await reloadPageAfter(sent: postReply.sent) } .onChange(of: dataSource.latestResponse) { onNewResponse(response: $1) } .onChange(of: dataSource.latestError) { onError(e: $1) } .environmentObject(postReply) - .onAppear { dataSource.initialLoad() } + .task { await dataSource.initialLoad() } .userActivity(Constants.Activity.openTopic) { $0.webpageURL = navID.webpageURL } } @@ -574,14 +573,14 @@ struct TopicDetailsView: View { }, pageToReload: .last) } - func reloadPageAfter(sent: PostReplyModel.Context?) { + func reloadPageAfter(sent: PostReplyModel.Context?) async { guard let sent else { return } switch sent.task.pageToReload { case let .exact(page): - dataSource.reload(page: page, evenIfNotLoaded: false) + await dataSource.reload(page: page, evenIfNotLoaded: false) case .last: - dataSource.reloadLastPages(evenIfNotLoaded: false) + await dataSource.reloadLastPages(evenIfNotLoaded: false) case .none: break } diff --git a/app/Shared/Views/TopicHistoryListView.swift b/app/Shared/Views/TopicHistoryListView.swift index 23d21518..102c2844 100644 --- a/app/Shared/Views/TopicHistoryListView.swift +++ b/app/Shared/Views/TopicHistoryListView.swift @@ -33,7 +33,7 @@ struct TopicHistoryListView: View { Group { if dataSource.notLoaded { ProgressView() - .onAppear { dataSource.initialLoad() } + .task { await dataSource.initialLoad() } } else { List { let items = dataSource.items.filter { search.commitedText == nil || $0.topicSnapshot.subject.full.contains(search.commitedText!) } diff --git a/app/Shared/Views/TopicListView.swift b/app/Shared/Views/TopicListView.swift index 2e0e1e4d..c45f722a 100644 --- a/app/Shared/Views/TopicListView.swift +++ b/app/Shared/Views/TopicListView.swift @@ -246,11 +246,7 @@ struct TopicListView: View { Group { if dataSource.notLoaded { ProgressView() - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { // hack for search bar animation - dataSource.initialLoad() - } - } + .task { await dataSource.initialLoad() } } else { List { Section(header: Text(orderOrDefault.description)) { @@ -282,7 +278,7 @@ struct TopicListView: View { .searchable(model: searchModel, prompt: "Search Topics".localized, if: !mock) .navigationTitleLarge(string: forum.name.localized) .sheet(isPresented: $showingSubforumsModal) { subforumsModal.presentationDetents([.medium, .large]) } - .onChange(of: postReply.sent) { dataSource.reload(page: 1, evenIfNotLoaded: false) } + .task(id: postReply.sent) { await dataSource.reload(page: 1, evenIfNotLoaded: false) } .navigationDestination(item: $currentShowingSubforum) { TopicListView.build(forum: $0) } .toolbar { toolbar } .onChange(of: prefs.defaultTopicListOrder) { if $1 != order { order = $1 } } diff --git a/app/Shared/Views/TopicSearchView.swift b/app/Shared/Views/TopicSearchView.swift index d2a6a773..2aaf6307 100644 --- a/app/Shared/Views/TopicSearchView.swift +++ b/app/Shared/Views/TopicSearchView.swift @@ -42,7 +42,7 @@ struct TopicSearchItemsView: View { var body: some View { if dataSource.notLoaded { LoadingRowView() - .onAppear { dataSource.initialLoad() } + .task { await dataSource.initialLoad() } } else { ForEachOrEmpty($dataSource.items, id: \.wrappedValue.id) { topic in CrossStackNavigationLinkHack(id: topic.w.id, destination: { @@ -61,7 +61,7 @@ struct TopicSearchView: View { var body: some View { if dataSource.notLoaded { ProgressView() - .onAppear { dataSource.initialLoad() } + .task { await dataSource.initialLoad() } } else { List { Section(header: Text("Search Results")) { diff --git a/app/Shared/Views/UserProfileView.swift b/app/Shared/Views/UserProfileView.swift index 493548a5..4086bcfa 100644 --- a/app/Shared/Views/UserProfileView.swift +++ b/app/Shared/Views/UserProfileView.swift @@ -82,7 +82,7 @@ struct UserProfileView: View { case .topics: if topicDataSource.notLoaded { LoadingRowView() - .onAppear { topicDataSource.initialLoad() } + .task { await topicDataSource.initialLoad() } } else { Section(header: Text("\(user.name.display)'s Topics")) { if topicDataSource.items.isEmpty { @@ -100,7 +100,7 @@ struct UserProfileView: View { case .posts: if postDataSource.notLoaded { LoadingRowView() - .onAppear { postDataSource.initialLoad() } + .task { await postDataSource.initialLoad() } } else { Section(header: Text("\(user.name.display)'s Posts")) { if postDataSource.items.isEmpty { From e450306608e2cf90787ae0b88d0d7f3827244483 Mon Sep 17 00:00:00 2001 From: Bugen Zhao Date: Fri, 10 Oct 2025 14:27:39 +0800 Subject: [PATCH 2/2] better scrolling Signed-off-by: Bugen Zhao --- app/Shared/Models/PagingDataSource.swift | 2 +- app/Shared/Views/TopicDetailsView.swift | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/app/Shared/Models/PagingDataSource.swift b/app/Shared/Models/PagingDataSource.swift index 0489b65c..480fd366 100644 --- a/app/Shared/Models/PagingDataSource.swift +++ b/app/Shared/Models/PagingDataSource.swift @@ -106,7 +106,7 @@ class PagingDataSource: ObservableObject { // TODO: `onAppear` works great while `task` seems glitchy func loadMoreIfNeeded(currentItem: Item) { if let index = itemToIndexAndPage[currentItem[keyPath: id]]?.index { - let threshold = items.index(items.endIndex, offsetBy: -2) + let threshold = items.index(items.endIndex, offsetBy: -3) if index >= threshold { Task { await loadMore(backgroundQueue: true) } } } } diff --git a/app/Shared/Views/TopicDetailsView.swift b/app/Shared/Views/TopicDetailsView.swift index 0cdec82f..f16751d1 100644 --- a/app/Shared/Views/TopicDetailsView.swift +++ b/app/Shared/Views/TopicDetailsView.swift @@ -265,7 +265,7 @@ struct TopicDetailsView: View { { Button(action: { Task { - await dataSource.reload(page: prevPage, evenIfNotLoaded: true) + await dataSource.reload(page: prevPage, evenIfNotLoaded: true, animated: false) guard let floor = dataSource.itemsAtPage(prevPage).map(\.floor).max() else { return } action.scrollToFloor = Int(floor) // scroll to last of prev page } @@ -358,7 +358,11 @@ struct TopicDetailsView: View { } if let nextPage = dataSource.nextPage { - let loadTrigger = Text("").task { await dataSource.loadMore(alwaysAnimation: true) } + let loadTrigger = Text("") + .onAppear { Task { + try? await Task.sleep(for: .seconds(1)) // less glitch + await dataSource.loadMore(alwaysAnimation: true) + } } Section(header: Text("Page \(nextPage)"), footer: loadTrigger) { // BUGEN'S HACK: // the first view of this section will unexpectedly call `onAppear(_:)` @@ -485,11 +489,11 @@ struct TopicDetailsView: View { }.onReceive(action.$scrollToFloor) { floor in guard let floor else { return } let item = dataSource.items.first { $0.floor == UInt32(floor) } - withAnimation { proxy.scrollTo(item, anchor: .top) } + proxy.scrollTo(item, anchor: .top) }.onReceive(action.$scrollToPid) { pid in guard let pid else { return } let item = dataSource.items.first { $0.id.pid == pid } - withAnimation { proxy.scrollTo(item, anchor: .top) } + proxy.scrollTo(item, anchor: .top) } }.mayGroupedListStyle() // Action Navigation