Transition Timeline to List + stream post automatically + keep position + new counter UI

This commit is contained in:
Thomas Ricouard 2023-01-30 21:41:42 +01:00
parent 749846b9ba
commit d88d9db1dc
9 changed files with 132 additions and 124 deletions

View file

@ -144,7 +144,7 @@ struct TimelineTab: View {
if UIDevice.current.userInterfaceIdiom != .pad { if UIDevice.current.userInterfaceIdiom != .pad {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
AppAccountsSelectorView(routerPath: routerPath) AppAccountsSelectorView(routerPath: routerPath)
.id(client.id) .id(currentAccount.account?.id)
} }
} }
statusEditorToolbarItem(routerPath: routerPath, statusEditorToolbarItem(routerPath: routerPath,

View file

@ -69,7 +69,7 @@ public struct AccountDetailView: View {
if viewModel.selectedTab == .statuses { if viewModel.selectedTab == .statuses {
pinnedPostsView pinnedPostsView
} }
StatusesListView(fetcher: viewModel) StatusesListView(fetcher: viewModel, isEmbdedInList: false)
case .followedTags: case .followedTags:
tagsListView tagsListView
case .lists: case .lists:

View file

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

View file

@ -73,7 +73,6 @@ public struct StatusDetailView: View {
} }
} }
} }
.frame(maxWidth: .maxColumnWidth)
} }
.padding(.top, .layoutPadding) .padding(.top, .layoutPadding)
} }

View file

@ -16,4 +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
} }

View file

@ -4,62 +4,83 @@ import Shimmer
import SwiftUI import SwiftUI
public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher { public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
@EnvironmentObject private var theme: Theme
@ObservedObject private var fetcher: Fetcher @ObservedObject private var fetcher: Fetcher
private let isRemote: Bool private let isRemote: Bool
private let isEmbdedInList: Bool
public init(fetcher: Fetcher, isRemote: Bool = false) { public init(fetcher: Fetcher, isRemote: Bool = false, isEmbdedInList: Bool = true) {
self.fetcher = fetcher self.fetcher = fetcher
self.isRemote = isRemote self.isRemote = isRemote
self.isEmbdedInList = isEmbdedInList
} }
public var body: some View { public var body: some View {
Group { switch fetcher.statusesState {
switch fetcher.statusesState { case .loading:
case .loading: ForEach(Status.placeholders()) { status in
ForEach(Status.placeholders()) { status in StatusRowView(viewModel: .init(status: status, isCompact: false))
StatusRowView(viewModel: .init(status: status, isCompact: false)) .padding(.horizontal, isEmbdedInList ? 0 : .layoutPadding)
.redacted(reason: .placeholder) .redacted(reason: .placeholder)
.padding(.horizontal, .layoutPadding) .listRowBackground(theme.primaryBackgroundColor)
.listRowInsets(.init(top: 12,
leading: .layoutPadding,
bottom: 12,
trailing: .layoutPadding))
if !isEmbdedInList {
Divider() Divider()
.padding(.vertical, .dividerPadding) .padding(.vertical, .dividerPadding)
} }
case .error: }
ErrorView(title: "status.error.title", case .error:
message: "status.error.loading.message", ErrorView(title: "status.error.title",
buttonTitle: "action.retry") { message: "status.error.loading.message",
Task { buttonTitle: "action.retry") {
await fetcher.fetchStatuses() Task {
} await fetcher.fetchStatuses()
} }
}
.listRowBackground(theme.primaryBackgroundColor)
case let .display(statuses, nextPageState): case let .display(statuses, nextPageState):
ForEach(statuses, id: \.viewId) { status in ForEach(statuses, id: \.viewId) { status in
let viewModel = StatusRowViewModel(status: status, isCompact: false, isRemote: isRemote) let viewModel = StatusRowViewModel(status: status, isCompact: false, isRemote: isRemote)
if viewModel.filter?.filter.filterAction != .hide { if viewModel.filter?.filter.filterAction != .hide {
StatusRowView(viewModel: viewModel) StatusRowView(viewModel: viewModel)
.id(status.id) .padding(.horizontal, isEmbdedInList ? 0 : .layoutPadding)
.padding(.horizontal, .layoutPadding) .id(status.id)
.listRowBackground(theme.primaryBackgroundColor)
.listRowInsets(.init(top: 12,
leading: .layoutPadding,
bottom: 12,
trailing: .layoutPadding))
.onAppear {
Task {
await fetcher.statusDidAppear(status: status)
}
}
if !isEmbdedInList {
Divider() Divider()
.padding(.vertical, .dividerPadding) .padding(.vertical, .dividerPadding)
} }
} }
}
switch nextPageState { switch nextPageState {
case .hasNextPage: case .hasNextPage:
loadingRow loadingRow
.onAppear { .onAppear {
Task { Task {
await fetcher.fetchNextPage() await fetcher.fetchNextPage()
}
} }
case .loadingNextPage: }
loadingRow case .loadingNextPage:
case .none: loadingRow
EmptyView() case .none:
} EmptyView()
} }
} }
.frame(maxWidth: .maxColumnWidth)
} }
private var loadingRow: some View { private var loadingRow: some View {
@ -69,5 +90,6 @@ public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
Spacer() Spacer()
} }
.padding(.horizontal, .layoutPadding) .padding(.horizontal, .layoutPadding)
.listRowBackground(theme.primaryBackgroundColor)
} }
} }

View file

@ -115,6 +115,9 @@ public struct StatusRowView: View {
remoteContentLoadingView remoteContentLoadingView
} }
} }
.alignmentGuide(.listRowSeparatorLeading) { _ in
-100
}
} }
} }

View file

@ -34,22 +34,22 @@ public struct TimelineView: View {
public var body: some View { public var body: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ZStack(alignment: .top) { ZStack(alignment: .top) {
ScrollView { List {
Rectangle() if viewModel.tag == nil {
.frame(height: 0) scrollToTopView
.id(Constants.scrollToTop) } else {
LazyVStack {
tagHeaderView tagHeaderView
.padding(.bottom, 16)
switch viewModel.timeline {
case .remoteLocal:
StatusesListView(fetcher: viewModel, isRemote: true)
default:
StatusesListView(fetcher: viewModel)
}
} }
.padding(.top, .layoutPadding + (!viewModel.pendingStatuses.isEmpty ? 28 : 0)) switch viewModel.timeline {
case .remoteLocal:
StatusesListView(fetcher: viewModel, isRemote: true)
default:
StatusesListView(fetcher: viewModel)
}
} }
.environment(\.defaultMinListRowHeight, 1)
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(theme.primaryBackgroundColor) .background(theme.primaryBackgroundColor)
if viewModel.pendingStatusesEnabled { if viewModel.pendingStatusesEnabled {
makePendingNewPostsView(proxy: proxy) makePendingNewPostsView(proxy: proxy)
@ -107,62 +107,65 @@ public struct TimelineView: View {
private func makePendingNewPostsView(proxy: ScrollViewProxy) -> some View { private func makePendingNewPostsView(proxy: ScrollViewProxy) -> some View {
if !viewModel.pendingStatuses.isEmpty { if !viewModel.pendingStatuses.isEmpty {
HStack(spacing: 6) { HStack(spacing: 6) {
Spacer()
Button { Button {
withAnimation { withAnimation {
proxy.scrollTo(Constants.scrollToTop) proxy.scrollTo(viewModel.pendingStatuses.last?.id, anchor: .bottom)
viewModel.displayPendingStatuses()
} }
} label: { } label: {
Text(viewModel.pendingStatusesButtonTitle) Text(viewModel.pendingStatusesButtonTitle)
} }
.keyboardShortcut("r", modifiers: .command)
.buttonStyle(.bordered) .buttonStyle(.bordered)
.background(.thinMaterial) .background(.thinMaterial)
.cornerRadius(8) .cornerRadius(8)
if viewModel.pendingStatuses.count > 1 {
Button {
withAnimation {
viewModel.dequeuePendingStatuses()
}
} label: {
Image(systemName: "text.insert")
}
.buttonStyle(.bordered)
.background(.thinMaterial)
.cornerRadius(8)
}
} }
.padding(.top, 6) .padding(12)
} }
} }
@ViewBuilder @ViewBuilder
private var tagHeaderView: some View { private var tagHeaderView: some View {
if let tag = viewModel.tag { if let tag = viewModel.tag {
HStack { VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 4) { Spacer()
Text("#\(tag.name)") HStack {
.font(.scaledHeadline) VStack(alignment: .leading, spacing: 4) {
Text("timeline.n-recent-from-n-participants \(tag.totalUses) \(tag.totalAccounts)") Text("#\(tag.name)")
.font(.scaledFootnote) .font(.scaledHeadline)
.foregroundColor(.gray) Text("timeline.n-recent-from-n-participants \(tag.totalUses) \(tag.totalAccounts)")
.font(.scaledFootnote)
.foregroundColor(.gray)
}
Spacer()
Button {
Task {
if tag.following {
viewModel.tag = await account.unfollowTag(id: tag.name)
} else {
viewModel.tag = await account.followTag(id: tag.name)
}
}
} label: {
Text(tag.following ? "account.follow.following" : "account.follow.follow")
}.buttonStyle(.bordered)
} }
Spacer() Spacer()
Button {
Task {
if tag.following {
viewModel.tag = await account.unfollowTag(id: tag.name)
} else {
viewModel.tag = await account.followTag(id: tag.name)
}
}
} label: {
Text(tag.following ? "account.follow.following" : "account.follow.follow")
}.buttonStyle(.bordered)
} }
.padding(.horizontal, .layoutPadding) .listRowBackground(theme.secondaryBackgroundColor)
.padding(.vertical, 8) .listRowSeparator(.hidden)
.background(theme.secondaryBackgroundColor) .listRowInsets(.init(top: 8,
leading: .layoutPadding,
bottom: 8,
trailing: .layoutPadding))
} }
} }
private var scrollToTopView: some View {
HStack{ }
.listRowBackground(theme.primaryBackgroundColor)
.listRowSeparator(.hidden)
.listRowInsets(.init())
.frame(height: .layoutPadding)
.id(Constants.scrollToTop)
}
} }

View file

@ -38,19 +38,10 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
} }
@Published var tag: Tag? @Published var tag: Tag?
enum PendingStatusesState {
case refresh, stream
}
@Published var pendingStatuses: [Status] = [] @Published var pendingStatuses: [Status] = []
@Published var pendingStatusesState: PendingStatusesState = .stream
var pendingStatusesButtonTitle: LocalizedStringKey { var pendingStatusesButtonTitle: LocalizedStringKey {
switch pendingStatusesState { "\(pendingStatuses.count)"
case .stream, .refresh:
return "timeline-new-posts \(pendingStatuses.count)"
}
} }
var pendingStatusesEnabled: Bool { var pendingStatusesEnabled: Bool {
@ -81,7 +72,6 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
} else if let first = pendingStatuses.first ?? statuses.first { } else if let first = pendingStatuses.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 {
pendingStatuses.insert(contentsOf: newStatuses, at: 0)
statuses.insert(contentsOf: pendingStatuses, at: 0) statuses.insert(contentsOf: pendingStatuses, at: 0)
pendingStatuses = [] pendingStatuses = []
withAnimation { withAnimation {
@ -92,7 +82,10 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
!pendingStatuses.contains(where: { $0.id == status.id }) !pendingStatuses.contains(where: { $0.id == status.id })
} }
pendingStatuses.insert(contentsOf: newStatuses, at: 0) pendingStatuses.insert(contentsOf: newStatuses, at: 0)
pendingStatusesState = .refresh statuses.insert(contentsOf: pendingStatuses, at: 0)
withAnimation {
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
}
} }
} }
} catch { } catch {
@ -153,14 +146,10 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
!statuses.contains(where: { $0.id == event.status.id }), !statuses.contains(where: { $0.id == event.status.id }),
!pendingStatuses.contains(where: { $0.id == event.status.id }) !pendingStatuses.contains(where: { $0.id == event.status.id })
{ {
if event.status.account.id == currentAccount.account?.id, pendingStatuses.isEmpty { pendingStatuses.insert(event.status, at: 0)
withAnimation { statuses.insert(event.status, at: 0)
statuses.insert(event.status, at: 0) withAnimation {
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
}
} else {
pendingStatuses.insert(event.status, at: 0)
pendingStatusesState = .stream
} }
} else if let event = event as? StreamEventDelete { } else if let event = event as? StreamEventDelete {
withAnimation { withAnimation {
@ -175,22 +164,9 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
} }
} }
func displayPendingStatuses() { func statusDidAppear(status: Status) async {
guard timeline == .home else { return } if let index = pendingStatuses.firstIndex(of: status) {
pendingStatuses = pendingStatuses.filter { status in pendingStatuses.remove(at: index)
!statuses.contains(where: { $0.id == status.id })
} }
statuses.insert(contentsOf: pendingStatuses, at: 0)
pendingStatuses = []
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
}
func dequeuePendingStatuses() {
guard timeline == .home else { return }
if pendingStatuses.count > 1 {
let status = pendingStatuses.removeLast()
statuses.insert(status, at: 0)
}
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
} }
} }