From 9ec6a4ef66892a925883929dcb2f09474cf8d194 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Tue, 17 Sep 2024 14:01:32 +0200 Subject: [PATCH] Cleanup + use ScrollPoxy --- .../Timeline/TimelineMediaPrefetcher.swift | 43 ------------------ .../Sources/Timeline/View/TimelineView.swift | 45 ++++++------------- .../Timeline/View/TimelineViewModel.swift | 41 +++++------------ 3 files changed, 26 insertions(+), 103 deletions(-) delete mode 100644 Packages/Timeline/Sources/Timeline/TimelineMediaPrefetcher.swift diff --git a/Packages/Timeline/Sources/Timeline/TimelineMediaPrefetcher.swift b/Packages/Timeline/Sources/Timeline/TimelineMediaPrefetcher.swift deleted file mode 100644 index 50ee37f1..00000000 --- a/Packages/Timeline/Sources/Timeline/TimelineMediaPrefetcher.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Models -import Nuke -import Observation -import SwiftUI -import UIKit - -@Observable final class TimelineMediaPrefetcher: NSObject, UICollectionViewDataSourcePrefetching { - private let prefetcher = ImagePrefetcher() - - weak var viewModel: TimelineViewModel? - - func collectionView(_: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) { - let imageURLs = getImageURLs(for: indexPaths) - prefetcher.startPrefetching(with: imageURLs) - } - - func collectionView(_: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) { - let imageURLs = getImageURLs(for: indexPaths) - prefetcher.stopPrefetching(with: imageURLs) - } - - private func getImageURLs(for indexPaths: [IndexPath]) -> [URL] { - guard let viewModel, case let .display(statuses, _) = viewModel.statusesState else { - return [] - } - return indexPaths.compactMap { - $0.row < statuses.endIndex ? statuses[$0.row] : nil - }.flatMap(getImages) - } -} - -private func getImages(for status: Status) -> [URL] { - var urls = status.mediaAttachments.compactMap { - if $0.supportedType == .image { - return status.mediaAttachments.count > 1 ? $0.previewUrl ?? $0.url : $0.url - } - return nil - } - if let url = status.card?.image { - urls.append(url) - } - return urls -} diff --git a/Packages/Timeline/Sources/Timeline/View/TimelineView.swift b/Packages/Timeline/Sources/Timeline/View/TimelineView.swift index b4c412b8..76215585 100644 --- a/Packages/Timeline/Sources/Timeline/View/TimelineView.swift +++ b/Packages/Timeline/Sources/Timeline/View/TimelineView.swift @@ -6,7 +6,6 @@ import Network import StatusKit import SwiftData import SwiftUI -import SwiftUIIntrospect @MainActor public struct TimelineView: View { @@ -20,11 +19,9 @@ public struct TimelineView: View { @Environment(RouterPath.self) private var routerPath @State private var viewModel = TimelineViewModel() - @State private var prefetcher = TimelineMediaPrefetcher() @State private var contentFilter = TimelineContentFilter.shared @State private var wasBackgrounded: Bool = false - @State private var collectionView: UICollectionView? @Binding var timeline: TimelineFilter @Binding var pinnedFilters: [TimelineFilter] @@ -67,18 +64,6 @@ public struct TimelineView: View { .if(canFilterTimeline && !pinnedFilters.isEmpty) { view in view.toolbarBackground(.hidden, for: .navigationBar) } - .onChange(of: viewModel.scrollToIndex) { _, newValue in - if let collectionView, - let newValue, - let rows = collectionView.dataSource?.collectionView(collectionView, numberOfItemsInSection: 0), - rows > newValue - { - collectionView.scrollToItem(at: .init(row: newValue, section: 0), - at: .top, - animated: false) - viewModel.scrollToIndex = nil - } - } .toolbar { toolbarTitleView toolbarTagGroupButton @@ -193,25 +178,23 @@ public struct TimelineView: View { .id(client.id) .environment(\.defaultMinListRowHeight, 1) .listStyle(.plain) - #if !os(visionOS) - .scrollContentBackground(.hidden) - .background(theme.primaryBackgroundColor) - #endif - .introspect(.list, on: .iOS(.v17, .v18)) { (collectionView: UICollectionView) in - DispatchQueue.main.async { - self.collectionView = collectionView - } - prefetcher.viewModel = viewModel - collectionView.isPrefetchingEnabled = true - collectionView.prefetchDataSource = prefetcher +#if !os(visionOS) + .scrollContentBackground(.hidden) + .background(theme.primaryBackgroundColor) +#endif + .onChange(of: viewModel.scrollToId) { _, newValue in + if let newValue { + proxy.scrollTo(newValue, anchor: .top) + viewModel.scrollToId = nil } - .onChange(of: selectedTabScrollToTop) { _, newValue in - if newValue == 0, routerPath.path.isEmpty { - withAnimation { - proxy.scrollTo(ScrollToView.Constants.scrollToTop, anchor: .top) - } + } + .onChange(of: selectedTabScrollToTop) { _, newValue in + if newValue == 0, routerPath.path.isEmpty { + withAnimation { + proxy.scrollTo(ScrollToView.Constants.scrollToTop, anchor: .top) } } + } } } diff --git a/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift b/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift index b5ac0319..5777156f 100644 --- a/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift +++ b/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift @@ -7,7 +7,7 @@ import SwiftUI @MainActor @Observable class TimelineViewModel { - var scrollToIndex: Int? + var scrollToId: String? var statusesState: StatusesState = .loading var timeline: TimelineFilter = .home { willSet { @@ -229,13 +229,11 @@ extension TimelineViewModel: StatusesFetcher { { await datasource.set(cachedStatuses) let statuses = await datasource.getFiltered() - if let latestSeenId = await cache.getLatestSeenStatus(for: client, filter: timeline.id)?.first, - let index = await datasource.indexOf(statusId: latestSeenId), - index > 0 + if let latestSeenId = await cache.getLatestSeenStatus(for: client, filter: timeline.id)?.first { // Restore cache and scroll to latest seen status. + scrollToId = latestSeenId statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) - scrollToIndex = index + 1 } else { // Restore cache and scroll to top. withAnimation { @@ -299,6 +297,9 @@ extension TimelineViewModel: StatusesFetcher { } private func updateTimelineWithNewStatuses(_ newStatuses: [Status]) async { + defer { + canStreamEvents = true + } let topStatus = await datasource.getFiltered().first await datasource.insert(contentOf: newStatuses, at: 0) if let lastVisible = visibleStatuses.last { @@ -313,33 +314,15 @@ extension TimelineViewModel: StatusesFetcher { visibleStatuses.contains(where: { $0.id == topStatus.id }), scrollToTopVisible { - updateTimelineWithScrollToTop(newStatuses: newStatuses, statuses: statuses, nextPageState: .hasNextPage) + scrollToId = topStatus.id + statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) } else { - updateTimelineWithAnimation(statuses: statuses, nextPageState: .hasNextPage) + withAnimation { + statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) + } } } - - // Refresh the timeline while keeping the scroll position to the top status. - private func updateTimelineWithScrollToTop(newStatuses: [Status], statuses: [Status], nextPageState: StatusesState.PagingState) { - pendingStatusesObserver.disableUpdate = true - statusesState = .display(statuses: statuses, nextPageState: nextPageState) - scrollToIndex = newStatuses.count + 1 - - DispatchQueue.main.async { [weak self] in - self?.pendingStatusesObserver.disableUpdate = false - self?.canStreamEvents = true - } - } - - // Refresh the timeline while keeping the user current position. - // It works because a side effect of withAnimation is that it keep scroll position IF the List is not scrolled to the top. - private func updateTimelineWithAnimation(statuses: [Status], nextPageState: StatusesState.PagingState) { - withAnimation { - statusesState = .display(statuses: statuses, nextPageState: nextPageState) - canStreamEvents = true - } - } - + enum NextPageError: Error { case internalError }