Refactor Timeline position management + add thin cache layer + fix crashes

This commit is contained in:
Thomas Ricouard 2023-02-01 12:43:11 +01:00
parent 9bf40b262f
commit cdf45fa58c
7 changed files with 213 additions and 130 deletions

View File

@ -2,7 +2,7 @@ import Foundation
import Models
import SwiftUI
public class Client: ObservableObject, Equatable, Identifiable {
public class Client: ObservableObject, Equatable, Identifiable, Hashable {
public static func == (lhs: Client, rhs: Client) -> Bool {
lhs.isAuth == rhs.isAuth &&
lhs.server == rhs.server &&
@ -21,6 +21,10 @@ public class Client: ObservableObject, Equatable, Identifiable {
public var id: String {
"\(isAuth)\(server)\(oauthToken?.accessToken ?? "")"
}
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
public var server: String
public let version: Version

View File

@ -23,6 +23,7 @@ public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
StatusRowView(viewModel: .init(status: status, isCompact: false))
.padding(.horizontal, isEmbdedInList ? 0 : .layoutPadding)
.redacted(reason: .placeholder)
.id(UUID())
.listRowBackground(theme.primaryBackgroundColor)
.listRowInsets(.init(top: 12,
leading: .layoutPadding,

View File

@ -132,7 +132,7 @@ public struct StatusPollView: View {
}
}
.frame(height: .pollBarHeight)
.buttonStyle(.borderless)
}
.buttonStyle(.borderless)
}
}

View File

@ -28,17 +28,11 @@ class PendingStatusesObserver: ObservableObject {
struct PendingStatusesObserverView: View {
@ObservedObject var observer: PendingStatusesObserver
var proxy: ScrollViewProxy
var body: some View {
if observer.pendingStatusesCount > 0 {
HStack(spacing: 6) {
Spacer()
Button {
withAnimation {
proxy.scrollTo(observer.pendingStatuses.last, anchor: .top)
}
} label: {
Button { } label: {
Text("\(observer.pendingStatusesCount)")
}
.buttonStyle(.bordered)

View File

@ -0,0 +1,19 @@
import SwiftUI
import Models
import Network
actor TimelineCache {
static let shared: TimelineCache = .init()
private var memoryCache: [Client: [Status]] = [:]
private init() {}
func set(statuses: [Status], client: Client) {
memoryCache[client] = statuses.prefix(upTo: min(100, (statuses.count - 1))).map{ $0 }
}
func getStatuses(for client: Client) -> [Status]? {
memoryCache[client]
}
}

View File

@ -48,18 +48,26 @@ public struct TimelineView: View {
StatusesListView(fetcher: viewModel)
}
}
.id(account.account?.id ?? client.id)
.id(client.id)
.environment(\.defaultMinListRowHeight, 1)
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(theme.primaryBackgroundColor)
if viewModel.pendingStatusesEnabled {
makePendingNewPostsView(proxy: proxy)
PendingStatusesObserverView(observer: viewModel.pendingStatusesObserver)
}
}
.onAppear {
viewModel.scrollProxy = proxy
.onChange(of: viewModel.scrollToStatus) { statusId in
if let statusId {
viewModel.scrollToStatus = nil
proxy.scrollTo(statusId, anchor: .top)
}
}
.onChange(of: scrollToTopSignal, perform: { _ in
withAnimation {
proxy.scrollTo(Constants.scrollToTop, anchor: .top)
}
})
}
.navigationTitle(timeline.localizedTitle())
.navigationBarTitleDisplayMode(.inline)
@ -83,11 +91,6 @@ public struct TimelineView: View {
viewModel.handleEvent(event: latestEvent, currentAccount: account)
}
}
.onChange(of: scrollToTopSignal, perform: { _ in
withAnimation {
viewModel.scrollProxy?.scrollTo(Constants.scrollToTop, anchor: .top)
}
})
.onChange(of: timeline) { newTimeline in
switch newTimeline {
case let .remoteLocal(server):
@ -115,11 +118,6 @@ public struct TimelineView: View {
})
}
@ViewBuilder
private func makePendingNewPostsView(proxy: ScrollViewProxy) -> some View {
PendingStatusesObserverView(observer: viewModel.pendingStatusesObserver, proxy: proxy)
}
@ViewBuilder
private var tagHeaderView: some View {
if let tag = viewModel.tag {
@ -158,11 +156,17 @@ public struct TimelineView: View {
}
private var scrollToTopView: some View {
HStack{ }
HStack{ EmptyView() }
.listRowBackground(theme.primaryBackgroundColor)
.listRowSeparator(.hidden)
.listRowInsets(.init())
.frame(height: .layoutPadding)
.id(Constants.scrollToTop)
.onAppear {
viewModel.scrollToTopVisible = true
}
.onDisappear {
viewModel.scrollToTopVisible = false
}
}
}

View File

@ -3,9 +3,10 @@ import Models
import Network
import Status
import SwiftUI
import Account
@MainActor
class TimelineViewModel: ObservableObject, StatusesFetcher {
class TimelineViewModel: ObservableObject {
var client: Client? {
didSet {
if oldValue != client {
@ -17,13 +18,15 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
// Internal source of truth for a timeline.
private var statuses: [Status] = []
private var visibileStatusesIds = Set<String>()
var scrollToTopVisible: Bool = false
private var canStreamEvents: Bool = true
var scrollProxy: ScrollViewProxy?
var pendingStatusesObserver: PendingStatusesObserver = .init()
let pendingStatusesObserver: PendingStatusesObserver = .init()
let cache: TimelineCache = .shared
@Published var scrollToStatus: String?
@Published var statusesState: StatusesState = .loading
@Published var timeline: TimelineFilter = .federated {
didSet {
@ -55,108 +58,6 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
client?.server ?? "Error"
}
func fetchStatuses() async {
guard let client else { return }
do {
if statuses.isEmpty {
pendingStatusesObserver.pendingStatuses = []
statusesState = .loading
statuses = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
maxId: nil,
minId: nil,
offset: statuses.count))
withAnimation {
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
}
} else if let first = statuses.first {
canStreamEvents = false
var newStatuses: [Status] = await fetchNewPages(minId: first.id, maxPages: 10)
if !pendingStatusesEnabled {
statuses.insert(contentsOf: newStatuses, at: 0)
pendingStatusesObserver.pendingStatuses = []
withAnimation {
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
canStreamEvents = true
}
} else {
newStatuses = newStatuses.filter { status in
!statuses.contains(where: { $0.id == status.id })
}
guard !newStatuses.isEmpty else {
canStreamEvents = true
return
}
pendingStatusesObserver.pendingStatuses.insert(contentsOf: newStatuses.map{ $0.id }, at: 0)
pendingStatusesObserver.feedbackGenerator.impactOccurred()
// High chance the user is scrolled to the top, this is a workaround to keep scroll position when prepending statuses.
if let firstStatusId = statuses.first?.id, visibileStatusesIds.contains(firstStatusId) {
statuses.insert(contentsOf: newStatuses, at: 0)
pendingStatusesObserver.disableUpdate = true
withAnimation {
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
self.scrollProxy?.scrollTo(firstStatusId, anchor: .top)
self.pendingStatusesObserver.disableUpdate = false
self.canStreamEvents = true
}
}
} else {
statuses.insert(contentsOf: newStatuses, at: 0)
withAnimation {
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
canStreamEvents = true
}
}
}
}
} catch {
statusesState = .error(error: error)
canStreamEvents = true
print("timeline parse error: \(error)")
}
}
func fetchNewPages(minId: String, maxPages: Int) async -> [Status] {
guard let client else { return [] }
var pagesLoaded = 0
var allStatuses: [Status] = []
var latestMinId = minId
do {
while let newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
maxId: nil,
minId: latestMinId,
offset: statuses.count)),
!newStatuses.isEmpty,
pagesLoaded < maxPages
{
pagesLoaded += 1
allStatuses.insert(contentsOf: newStatuses, at: 0)
latestMinId = newStatuses.first?.id ?? ""
}
} catch {
return allStatuses
}
return allStatuses
}
func fetchNextPage() async {
guard let client else { return }
do {
guard let lastId = statuses.last?.id else { return }
statusesState = .display(statuses: statuses, nextPageState: .loadingNextPage)
let newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
maxId: lastId,
minId: nil,
offset: statuses.count))
statuses.append(contentsOf: newStatuses)
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
} catch {
statusesState = .error(error: error)
}
}
func fetchTag(id: String) async {
guard let client else { return }
@ -188,7 +89,167 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
}
}
}
}
// MARK: - Cache
extension TimelineViewModel {
private func cache(statuses: [Status]) async {
if let client {
await cache.set(statuses: statuses, client: client)
}
}
private func getCachedStatuses() async -> [Status]? {
if let client {
return await cache.getStatuses(for: client)
}
return nil
}
}
// MARK: - StatusesFetcher
extension TimelineViewModel: StatusesFetcher {
func fetchStatuses() async {
guard let client else { return }
do {
if statuses.isEmpty {
try await fetchFirstPage(client: client)
} else if let latest = statuses.first {
try await fetchNewPagesFrom(latestStatus: latest, client: client)
}
} catch {
statusesState = .error(error: error)
canStreamEvents = true
print("timeline parse error: \(error)")
}
}
// Hydrate statuses in the Timeline when statuses are empty.
private func fetchFirstPage(client: Client) async throws {
pendingStatusesObserver.pendingStatuses = []
statusesState = .loading
// If we get statuses from the cache for the home timeline, we displays those.
// Else we fetch top most page from the API.
if let cachedStatuses = await getCachedStatuses(), timeline == .home {
statuses = cachedStatuses
withAnimation {
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
}
// And then we fetch statuses again toget newest statuses from there.
await fetchStatuses()
} else {
statuses = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
maxId: nil,
minId: nil,
offset: statuses.count))
if timeline == .home {
await cache(statuses: statuses)
}
withAnimation {
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
}
}
}
// Fetch pages from the top most status of the tomeline.
private func fetchNewPagesFrom(latestStatus: Status, client: Client) async throws {
canStreamEvents = false
var newStatuses: [Status] = await fetchNewPages(minId: latestStatus.id, maxPages: 10)
// Dedup statuses, a status with the same id could have been streamed in.
newStatuses = newStatuses.filter { status in
!statuses.contains(where: { $0.id == status.id })
}
// If no new statuses, resume streaming and exit.
guard !newStatuses.isEmpty else {
canStreamEvents = true
return
}
// Keep track of the top most status, so we can scroll back to it after view update.
let topStatusId = statuses.first?.id
// Insert new statuses in internal datasource.
statuses.insert(contentsOf: newStatuses, at: 0)
// Cache statuses for home timeline.
if timeline == .home {
await cache(statuses: statuses)
}
// If pending statuses are not enabled, we simply load status on the top regardless of the current position.
if !pendingStatusesEnabled {
pendingStatusesObserver.pendingStatuses = []
withAnimation {
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
canStreamEvents = true
}
} else {
// Append new statuses in the timeline indicator.
pendingStatusesObserver.pendingStatuses.insert(contentsOf: newStatuses.map{ $0.id }, at: 0)
pendingStatusesObserver.feedbackGenerator.impactOccurred()
// High chance the user is scrolled to the top.
// We need to update the statuses state, and then scroll to the previous top most status.
if let topStatusId, visibileStatusesIds.contains(topStatusId), scrollToTopVisible {
pendingStatusesObserver.disableUpdate = true
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
scrollToStatus = topStatusId
DispatchQueue.main.async {
self.pendingStatusesObserver.disableUpdate = false
self.canStreamEvents = true
}
} else {
// This will keep the scroll position (if the list is scrolled) and prepend statuses on the top.
withAnimation {
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
canStreamEvents = true
}
}
}
}
private func fetchNewPages(minId: String, maxPages: Int) async -> [Status] {
guard let client else { return [] }
var pagesLoaded = 0
var allStatuses: [Status] = []
var latestMinId = minId
do {
while let newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
maxId: nil,
minId: latestMinId,
offset: statuses.count)),
!newStatuses.isEmpty,
pagesLoaded < maxPages
{
pagesLoaded += 1
allStatuses.insert(contentsOf: newStatuses, at: 0)
latestMinId = newStatuses.first?.id ?? ""
}
} catch {
return allStatuses
}
return allStatuses
}
func fetchNextPage() async {
guard let client else { return }
do {
guard let lastId = statuses.last?.id else { return }
statusesState = .display(statuses: statuses, nextPageState: .loadingNextPage)
let newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
maxId: lastId,
minId: nil,
offset: statuses.count))
statuses.append(contentsOf: newStatuses)
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
} catch {
statusesState = .error(error: error)
}
}
func statusDidAppear(status: Status) {
pendingStatusesObserver.removeStatus(status: status)
visibileStatusesIds.insert(status.id)