Cleanup + use ScrollPoxy

This commit is contained in:
Thomas Ricouard 2024-09-17 14:01:32 +02:00
parent 576b52e8c8
commit 9ec6a4ef66
3 changed files with 26 additions and 103 deletions

View file

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

View file

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

View file

@ -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,30 +314,12 @@ 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)
}
}
// 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
withAnimation {
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
}
}
}