mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-11-25 09:41:02 +00:00
Cleanup + use ScrollPoxy
This commit is contained in:
parent
576b52e8c8
commit
9ec6a4ef66
3 changed files with 26 additions and 103 deletions
|
@ -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
|
|
||||||
}
|
|
|
@ -6,7 +6,6 @@ import Network
|
||||||
import StatusKit
|
import StatusKit
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftUIIntrospect
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public struct TimelineView: View {
|
public struct TimelineView: View {
|
||||||
|
@ -20,11 +19,9 @@ public struct TimelineView: View {
|
||||||
@Environment(RouterPath.self) private var routerPath
|
@Environment(RouterPath.self) private var routerPath
|
||||||
|
|
||||||
@State private var viewModel = TimelineViewModel()
|
@State private var viewModel = TimelineViewModel()
|
||||||
@State private var prefetcher = TimelineMediaPrefetcher()
|
|
||||||
@State private var contentFilter = TimelineContentFilter.shared
|
@State private var contentFilter = TimelineContentFilter.shared
|
||||||
|
|
||||||
@State private var wasBackgrounded: Bool = false
|
@State private var wasBackgrounded: Bool = false
|
||||||
@State private var collectionView: UICollectionView?
|
|
||||||
|
|
||||||
@Binding var timeline: TimelineFilter
|
@Binding var timeline: TimelineFilter
|
||||||
@Binding var pinnedFilters: [TimelineFilter]
|
@Binding var pinnedFilters: [TimelineFilter]
|
||||||
|
@ -67,18 +64,6 @@ public struct TimelineView: View {
|
||||||
.if(canFilterTimeline && !pinnedFilters.isEmpty) { view in
|
.if(canFilterTimeline && !pinnedFilters.isEmpty) { view in
|
||||||
view.toolbarBackground(.hidden, for: .navigationBar)
|
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 {
|
.toolbar {
|
||||||
toolbarTitleView
|
toolbarTitleView
|
||||||
toolbarTagGroupButton
|
toolbarTagGroupButton
|
||||||
|
@ -193,25 +178,23 @@ public struct TimelineView: View {
|
||||||
.id(client.id)
|
.id(client.id)
|
||||||
.environment(\.defaultMinListRowHeight, 1)
|
.environment(\.defaultMinListRowHeight, 1)
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(theme.primaryBackgroundColor)
|
.background(theme.primaryBackgroundColor)
|
||||||
#endif
|
#endif
|
||||||
.introspect(.list, on: .iOS(.v17, .v18)) { (collectionView: UICollectionView) in
|
.onChange(of: viewModel.scrollToId) { _, newValue in
|
||||||
DispatchQueue.main.async {
|
if let newValue {
|
||||||
self.collectionView = collectionView
|
proxy.scrollTo(newValue, anchor: .top)
|
||||||
}
|
viewModel.scrollToId = nil
|
||||||
prefetcher.viewModel = viewModel
|
|
||||||
collectionView.isPrefetchingEnabled = true
|
|
||||||
collectionView.prefetchDataSource = prefetcher
|
|
||||||
}
|
}
|
||||||
.onChange(of: selectedTabScrollToTop) { _, newValue in
|
}
|
||||||
if newValue == 0, routerPath.path.isEmpty {
|
.onChange(of: selectedTabScrollToTop) { _, newValue in
|
||||||
withAnimation {
|
if newValue == 0, routerPath.path.isEmpty {
|
||||||
proxy.scrollTo(ScrollToView.Constants.scrollToTop, anchor: .top)
|
withAnimation {
|
||||||
}
|
proxy.scrollTo(ScrollToView.Constants.scrollToTop, anchor: .top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable class TimelineViewModel {
|
@Observable class TimelineViewModel {
|
||||||
var scrollToIndex: Int?
|
var scrollToId: String?
|
||||||
var statusesState: StatusesState = .loading
|
var statusesState: StatusesState = .loading
|
||||||
var timeline: TimelineFilter = .home {
|
var timeline: TimelineFilter = .home {
|
||||||
willSet {
|
willSet {
|
||||||
|
@ -229,13 +229,11 @@ extension TimelineViewModel: StatusesFetcher {
|
||||||
{
|
{
|
||||||
await datasource.set(cachedStatuses)
|
await datasource.set(cachedStatuses)
|
||||||
let statuses = await datasource.getFiltered()
|
let statuses = await datasource.getFiltered()
|
||||||
if let latestSeenId = await cache.getLatestSeenStatus(for: client, filter: timeline.id)?.first,
|
if let latestSeenId = await cache.getLatestSeenStatus(for: client, filter: timeline.id)?.first
|
||||||
let index = await datasource.indexOf(statusId: latestSeenId),
|
|
||||||
index > 0
|
|
||||||
{
|
{
|
||||||
// Restore cache and scroll to latest seen status.
|
// Restore cache and scroll to latest seen status.
|
||||||
|
scrollToId = latestSeenId
|
||||||
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
|
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
|
||||||
scrollToIndex = index + 1
|
|
||||||
} else {
|
} else {
|
||||||
// Restore cache and scroll to top.
|
// Restore cache and scroll to top.
|
||||||
withAnimation {
|
withAnimation {
|
||||||
|
@ -299,6 +297,9 @@ extension TimelineViewModel: StatusesFetcher {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateTimelineWithNewStatuses(_ newStatuses: [Status]) async {
|
private func updateTimelineWithNewStatuses(_ newStatuses: [Status]) async {
|
||||||
|
defer {
|
||||||
|
canStreamEvents = true
|
||||||
|
}
|
||||||
let topStatus = await datasource.getFiltered().first
|
let topStatus = await datasource.getFiltered().first
|
||||||
await datasource.insert(contentOf: newStatuses, at: 0)
|
await datasource.insert(contentOf: newStatuses, at: 0)
|
||||||
if let lastVisible = visibleStatuses.last {
|
if let lastVisible = visibleStatuses.last {
|
||||||
|
@ -313,33 +314,15 @@ extension TimelineViewModel: StatusesFetcher {
|
||||||
visibleStatuses.contains(where: { $0.id == topStatus.id }),
|
visibleStatuses.contains(where: { $0.id == topStatus.id }),
|
||||||
scrollToTopVisible
|
scrollToTopVisible
|
||||||
{
|
{
|
||||||
updateTimelineWithScrollToTop(newStatuses: newStatuses, statuses: statuses, nextPageState: .hasNextPage)
|
scrollToId = topStatus.id
|
||||||
|
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
|
||||||
} else {
|
} 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 {
|
enum NextPageError: Error {
|
||||||
case internalError
|
case internalError
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue