mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2025-02-23 13:06:16 +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 {
|
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,
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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)
|
.padding(.top, .layoutPadding)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,25 +4,35 @@ 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:
|
case .error:
|
||||||
ErrorView(title: "status.error.title",
|
ErrorView(title: "status.error.title",
|
||||||
message: "status.error.loading.message",
|
message: "status.error.loading.message",
|
||||||
|
@ -31,18 +41,31 @@ public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
|
||||||
await fetcher.fetchStatuses()
|
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)
|
||||||
|
.padding(.horizontal, isEmbdedInList ? 0 : .layoutPadding)
|
||||||
.id(status.id)
|
.id(status.id)
|
||||||
.padding(.horizontal, .layoutPadding)
|
.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:
|
||||||
|
@ -59,8 +82,6 @@ public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .maxColumnWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var loadingRow: some View {
|
private var loadingRow: some View {
|
||||||
HStack {
|
HStack {
|
||||||
|
@ -69,5 +90,6 @@ public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.horizontal, .layoutPadding)
|
.padding(.horizontal, .layoutPadding)
|
||||||
|
.listRowBackground(theme.primaryBackgroundColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -115,6 +115,9 @@ public struct StatusRowView: View {
|
||||||
remoteContentLoadingView
|
remoteContentLoadingView
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.alignmentGuide(.listRowSeparatorLeading) { _ in
|
||||||
|
-100
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -34,13 +34,12 @@ 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 {
|
switch viewModel.timeline {
|
||||||
case .remoteLocal:
|
case .remoteLocal:
|
||||||
StatusesListView(fetcher: viewModel, isRemote: true)
|
StatusesListView(fetcher: viewModel, isRemote: true)
|
||||||
|
@ -48,8 +47,9 @@ public struct TimelineView: View {
|
||||||
StatusesListView(fetcher: viewModel)
|
StatusesListView(fetcher: viewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, .layoutPadding + (!viewModel.pendingStatuses.isEmpty ? 28 : 0))
|
.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,38 +107,27 @@ 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)
|
|
||||||
.background(.thinMaterial)
|
|
||||||
.cornerRadius(8)
|
|
||||||
if viewModel.pendingStatuses.count > 1 {
|
|
||||||
Button {
|
|
||||||
withAnimation {
|
|
||||||
viewModel.dequeuePendingStatuses()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "text.insert")
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.background(.thinMaterial)
|
.background(.thinMaterial)
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
}
|
.padding(12)
|
||||||
.padding(.top, 6)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var tagHeaderView: some View {
|
private var tagHeaderView: some View {
|
||||||
if let tag = viewModel.tag {
|
if let tag = viewModel.tag {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Spacer()
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("#\(tag.name)")
|
Text("#\(tag.name)")
|
||||||
|
@ -160,9 +149,23 @@ public struct TimelineView: View {
|
||||||
Text(tag.following ? "account.follow.following" : "account.follow.follow")
|
Text(tag.following ? "account.follow.following" : "account.follow.follow")
|
||||||
}.buttonStyle(.bordered)
|
}.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, .layoutPadding)
|
Spacer()
|
||||||
.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?
|
@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 {
|
|
||||||
withAnimation {
|
|
||||||
statuses.insert(event.status, at: 0)
|
|
||||||
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
pendingStatuses.insert(event.status, at: 0)
|
pendingStatuses.insert(event.status, at: 0)
|
||||||
pendingStatusesState = .stream
|
statuses.insert(event.status, at: 0)
|
||||||
|
withAnimation {
|
||||||
|
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
|
||||||
}
|
}
|
||||||
} 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue