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 {
ToolbarItem(placement: .navigationBarLeading) {
AppAccountsSelectorView(routerPath: routerPath)
.id(client.id)
.id(currentAccount.account?.id)
}
}
statusEditorToolbarItem(routerPath: routerPath,

View file

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

View file

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

View file

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

View file

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

View file

@ -34,22 +34,22 @@ public struct TimelineView: View {
public var body: some View {
ScrollViewReader { proxy in
ZStack(alignment: .top) {
ScrollView {
Rectangle()
.frame(height: 0)
.id(Constants.scrollToTop)
LazyVStack {
List {
if viewModel.tag == nil {
scrollToTopView
} else {
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)
if viewModel.pendingStatusesEnabled {
makePendingNewPostsView(proxy: proxy)
@ -107,62 +107,65 @@ public struct TimelineView: View {
private func makePendingNewPostsView(proxy: ScrollViewProxy) -> some View {
if !viewModel.pendingStatuses.isEmpty {
HStack(spacing: 6) {
Spacer()
Button {
withAnimation {
proxy.scrollTo(Constants.scrollToTop)
viewModel.displayPendingStatuses()
proxy.scrollTo(viewModel.pendingStatuses.last?.id, anchor: .bottom)
}
} label: {
Text(viewModel.pendingStatusesButtonTitle)
}
.keyboardShortcut("r", modifiers: .command)
.buttonStyle(.bordered)
.background(.thinMaterial)
.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
private var tagHeaderView: some View {
if let tag = viewModel.tag {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("#\(tag.name)")
.font(.scaledHeadline)
Text("timeline.n-recent-from-n-participants \(tag.totalUses) \(tag.totalAccounts)")
.font(.scaledFootnote)
.foregroundColor(.gray)
VStack(alignment: .leading) {
Spacer()
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("#\(tag.name)")
.font(.scaledHeadline)
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()
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)
.padding(.vertical, 8)
.background(theme.secondaryBackgroundColor)
.listRowBackground(theme.secondaryBackgroundColor)
.listRowSeparator(.hidden)
.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?
enum PendingStatusesState {
case refresh, stream
}
@Published var pendingStatuses: [Status] = []
@Published var pendingStatusesState: PendingStatusesState = .stream
var pendingStatusesButtonTitle: LocalizedStringKey {
switch pendingStatusesState {
case .stream, .refresh:
return "timeline-new-posts \(pendingStatuses.count)"
}
"\(pendingStatuses.count)"
}
var pendingStatusesEnabled: Bool {
@ -81,7 +72,6 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
} else if let first = pendingStatuses.first ?? statuses.first {
var newStatuses: [Status] = await fetchNewPages(minId: first.id, maxPages: 20)
if userIntent || !pendingStatusesEnabled {
pendingStatuses.insert(contentsOf: newStatuses, at: 0)
statuses.insert(contentsOf: pendingStatuses, at: 0)
pendingStatuses = []
withAnimation {
@ -92,7 +82,10 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
!pendingStatuses.contains(where: { $0.id == status.id })
}
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 {
@ -153,14 +146,10 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
!statuses.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 {
withAnimation {
statuses.insert(event.status, at: 0)
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
}
} else {
pendingStatuses.insert(event.status, at: 0)
pendingStatusesState = .stream
pendingStatuses.insert(event.status, at: 0)
statuses.insert(event.status, at: 0)
withAnimation {
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
}
} else if let event = event as? StreamEventDelete {
withAnimation {
@ -175,22 +164,9 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
}
}
func displayPendingStatuses() {
guard timeline == .home else { return }
pendingStatuses = pendingStatuses.filter { status in
!statuses.contains(where: { $0.id == status.id })
func statusDidAppear(status: Status) async {
if let index = pendingStatuses.firstIndex(of: status) {
pendingStatuses.remove(at: index)
}
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)
}
}