Smoother scrolling up in the Timeline

This commit is contained in:
Thomas Ricouard 2023-01-31 08:04:35 +01:00
parent bef45d8621
commit 1a351eaa7c
6 changed files with 47 additions and 24 deletions

View file

@ -247,7 +247,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
} }
} }
func statusDidAppear(status: Models.Status) async { func statusDidAppear(status: Models.Status) {
} }
} }

View file

@ -16,5 +16,5 @@ public protocol StatusesFetcher: ObservableObject {
var statusesState: StatusesState { get } var statusesState: StatusesState { get }
func fetchStatuses() async func fetchStatuses() async
func fetchNextPage() async func fetchNextPage() async
func statusDidAppear(status: Status) async func statusDidAppear(status: Status)
} }

View file

@ -56,9 +56,7 @@ public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
bottom: 12, bottom: 12,
trailing: .layoutPadding)) trailing: .layoutPadding))
.onAppear { .onAppear {
Task { fetcher.statusDidAppear(status: status)
await fetcher.statusDidAppear(status: status)
}
} }
if !isEmbdedInList { if !isEmbdedInList {
Divider() Divider()

View file

@ -0,0 +1,22 @@
import Foundation
import SwiftUI
import Models
@MainActor
class PendingStatusesObserver: ObservableObject {
@Published var pendingStatusesCount: Int = 0
var pendingStatuses: [String] = [] {
didSet {
pendingStatusesCount = pendingStatuses.count
}
}
func removeStatus(status: Status) {
if let index = pendingStatuses.firstIndex(of: status.id) {
pendingStatuses.removeSubrange(index...(pendingStatuses.count - 1))
}
}
init() { }
}

View file

@ -19,8 +19,10 @@ public struct TimelineView: View {
@EnvironmentObject private var routerPath: RouterPath @EnvironmentObject private var routerPath: RouterPath
@StateObject private var viewModel = TimelineViewModel() @StateObject private var viewModel = TimelineViewModel()
@StateObject private var pendingStatusesObserver = PendingStatusesObserver()
@State private var scrollProxy: ScrollViewProxy? @State private var scrollProxy: ScrollViewProxy?
@Binding var timeline: TimelineFilter @Binding var timeline: TimelineFilter
@Binding var scrollToTopSignal: Int @Binding var scrollToTopSignal: Int
@ -62,9 +64,14 @@ public struct TimelineView: View {
.navigationTitle(timeline.localizedTitle()) .navigationTitle(timeline.localizedTitle())
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onAppear { .onAppear {
viewModel.pendingStatusesObserver = pendingStatusesObserver
if viewModel.client == nil { if viewModel.client == nil {
viewModel.client = client viewModel.client = client
viewModel.timeline = timeline viewModel.timeline = timeline
} else {
Task {
await viewModel.fetchStatuses(userIntent: false)
}
} }
} }
.refreshable { .refreshable {
@ -105,15 +112,15 @@ public struct TimelineView: View {
@ViewBuilder @ViewBuilder
private func makePendingNewPostsView(proxy: ScrollViewProxy) -> some View { private func makePendingNewPostsView(proxy: ScrollViewProxy) -> some View {
if !viewModel.pendingStatuses.isEmpty { if pendingStatusesObserver.pendingStatusesCount > 0 {
HStack(spacing: 6) { HStack(spacing: 6) {
Spacer() Spacer()
Button { Button {
withAnimation { withAnimation {
proxy.scrollTo(viewModel.pendingStatuses.last?.id, anchor: .bottom) proxy.scrollTo(pendingStatusesObserver.pendingStatuses.last, anchor: .bottom)
} }
} label: { } label: {
Text(viewModel.pendingStatusesButtonTitle) Text("\(pendingStatusesObserver.pendingStatusesCount)")
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.background(.thinMaterial) .background(.thinMaterial)

View file

@ -17,13 +17,15 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
// Internal source of truth for a timeline. // Internal source of truth for a timeline.
private var statuses: [Status] = [] private var statuses: [Status] = []
var pendingStatusesObserver: PendingStatusesObserver?
@Published var statusesState: StatusesState = .loading @Published var statusesState: StatusesState = .loading
@Published var timeline: TimelineFilter = .federated { @Published var timeline: TimelineFilter = .federated {
didSet { didSet {
Task { Task {
if oldValue != timeline { if oldValue != timeline {
statuses = [] statuses = []
pendingStatuses = [] pendingStatusesObserver?.pendingStatuses = []
tag = nil tag = nil
} }
await fetchStatuses(userIntent: false) await fetchStatuses(userIntent: false)
@ -38,11 +40,7 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
} }
@Published var tag: Tag? @Published var tag: Tag?
@Published var pendingStatuses: [Status] = [] @Published var pendingStatusesCount: Int = 0
var pendingStatusesButtonTitle: LocalizedStringKey {
"\(pendingStatuses.count)"
}
var pendingStatusesEnabled: Bool { var pendingStatusesEnabled: Bool {
timeline == .home timeline == .home
@ -60,7 +58,7 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
guard let client else { return } guard let client else { return }
do { do {
if statuses.isEmpty { if statuses.isEmpty {
pendingStatuses = [] pendingStatusesObserver?.pendingStatuses = []
statusesState = .loading statusesState = .loading
statuses = try await client.get(endpoint: timeline.endpoint(sinceId: nil, statuses = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
maxId: nil, maxId: nil,
@ -69,11 +67,11 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
withAnimation { withAnimation {
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage) statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
} }
} else if let first = pendingStatuses.first ?? statuses.first { } else if let first = statuses.first {
var newStatuses: [Status] = await fetchNewPages(minId: first.id, maxPages: 20) var newStatuses: [Status] = await fetchNewPages(minId: first.id, maxPages: 20)
if userIntent || !pendingStatusesEnabled { if userIntent || !pendingStatusesEnabled {
statuses.insert(contentsOf: pendingStatuses, at: 0) statuses.insert(contentsOf: newStatuses, at: 0)
pendingStatuses = [] pendingStatusesObserver?.pendingStatuses = []
withAnimation { withAnimation {
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage) statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
} }
@ -81,7 +79,7 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
newStatuses = newStatuses.filter { status in newStatuses = newStatuses.filter { status in
!statuses.contains(where: { $0.id == status.id }) !statuses.contains(where: { $0.id == status.id })
} }
pendingStatuses.insert(contentsOf: newStatuses, at: 0) pendingStatusesObserver?.pendingStatuses.insert(contentsOf: newStatuses.map{ $0.id }, at: 0)
statuses.insert(contentsOf: newStatuses, at: 0) statuses.insert(contentsOf: newStatuses, at: 0)
withAnimation { withAnimation {
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage) statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
@ -145,7 +143,7 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
pendingStatusesEnabled, pendingStatusesEnabled,
!statuses.contains(where: { $0.id == event.status.id }) !statuses.contains(where: { $0.id == event.status.id })
{ {
pendingStatuses.insert(event.status, at: 0) pendingStatusesObserver?.pendingStatuses.insert(event.status.id, at: 0)
statuses.insert(event.status, at: 0) statuses.insert(event.status, at: 0)
withAnimation { withAnimation {
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
@ -163,9 +161,7 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
} }
} }
func statusDidAppear(status: Status) async { func statusDidAppear(status: Status) {
if let index = pendingStatuses.firstIndex(of: status) { pendingStatusesObserver?.removeStatus(status: status)
pendingStatuses.remove(at: index)
}
} }
} }