mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2025-02-22 20:46:21 +00:00
Transition Timeline to List + stream post automatically + keep position + new counter UI
This commit is contained in:
parent
749846b9ba
commit
d88d9db1dc
9 changed files with 132 additions and 124 deletions
|
@ -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,
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -246,4 +246,8 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func statusDidAppear(status: Models.Status) async {
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -73,7 +73,6 @@ public struct StatusDetailView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .maxColumnWidth)
|
||||
}
|
||||
.padding(.top, .layoutPadding)
|
||||
}
|
||||
|
|
|
@ -16,4 +16,5 @@ public protocol StatusesFetcher: ObservableObject {
|
|||
var statusesState: StatusesState { get }
|
||||
func fetchStatuses() async
|
||||
func fetchNextPage() async
|
||||
func statusDidAppear(status: Status) async
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -115,6 +115,9 @@ public struct StatusRowView: View {
|
|||
remoteContentLoadingView
|
||||
}
|
||||
}
|
||||
.alignmentGuide(.listRowSeparatorLeading) { _ in
|
||||
-100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue