Status detail: Switch to List container + refactor to something much better

This commit is contained in:
Thomas Ricouard 2023-02-10 18:21:05 +01:00
parent 06120974fa
commit feefb02456
9 changed files with 164 additions and 83 deletions

View file

@ -21,10 +21,12 @@ extension View {
AccountSettingsView(account: account, appAccount: appAccount) AccountSettingsView(account: account, appAccount: appAccount)
case let .statusDetail(id): case let .statusDetail(id):
StatusDetailView(statusId: id) StatusDetailView(statusId: id)
case let .conversationDetail(conversation): case let .statusDetailWithStatus(status):
ConversationDetailView(conversation: conversation) StatusDetailView(status: status)
case let .remoteStatusDetail(url): case let .remoteStatusDetail(url):
StatusDetailView(remoteStatusURL: url) StatusDetailView(remoteStatusURL: url)
case let .conversationDetail(conversation):
ConversationDetailView(conversation: conversation)
case let .hashTag(tag, accountId): case let .hashTag(tag, accountId):
TimelineView(timeline: .constant(.hashtag(tag: tag, accountId: accountId)), scrollToTopSignal: .constant(0)) TimelineView(timeline: .constant(.hashtag(tag: tag, accountId: accountId)), scrollToTopSignal: .constant(0))
case let .list(list): case let .list(list):

View file

@ -8,8 +8,9 @@ public enum RouterDestinations: Hashable {
case accountDetailWithAccount(account: Account) case accountDetailWithAccount(account: Account)
case accountSettingsWithAccount(account: Account, appAccount: AppAccount) case accountSettingsWithAccount(account: Account, appAccount: AppAccount)
case statusDetail(id: String) case statusDetail(id: String)
case conversationDetail(conversation: Conversation) case statusDetailWithStatus(status: Status)
case remoteStatusDetail(url: URL) case remoteStatusDetail(url: URL)
case conversationDetail(conversation: Conversation)
case hashTag(tag: String, account: String?) case hashTag(tag: String, account: String?)
case list(list: Models.List) case list(list: Models.List)
case followers(id: String) case followers(id: String)

View file

@ -3,4 +3,8 @@ import Foundation
public struct StatusContext: Decodable { public struct StatusContext: Decodable {
public let ancestors: [Status] public let ancestors: [Status]
public let descendants: [Status] public let descendants: [Status]
public static func empty() -> StatusContext {
.init(ancestors: [], descendants: [])
}
} }

View file

@ -4,6 +4,7 @@ import Models
import Network import Network
import Shimmer import Shimmer
import SwiftUI import SwiftUI
import DesignSystem
public struct StatusDetailView: View { public struct StatusDetailView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@ -14,86 +15,87 @@ public struct StatusDetailView: View {
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@StateObject private var viewModel: StatusDetailViewModel @StateObject private var viewModel: StatusDetailViewModel
@State private var isLoaded: Bool = false @State private var isLoaded: Bool = false
@State private var statusHeight: CGFloat = 0
public init(statusId: String) { public init(statusId: String) {
_viewModel = StateObject(wrappedValue: .init(statusId: statusId)) _viewModel = StateObject(wrappedValue: .init(statusId: statusId))
} }
public init(status: Status) {
_viewModel = StateObject(wrappedValue: .init(status: status))
}
public init(remoteStatusURL: URL) { public init(remoteStatusURL: URL) {
_viewModel = StateObject(wrappedValue: .init(remoteStatusURL: remoteStatusURL)) _viewModel = StateObject(wrappedValue: .init(remoteStatusURL: remoteStatusURL))
} }
public var body: some View { public var body: some View {
ScrollViewReader { proxy in GeometryReader { reader in
ZStack { ScrollViewReader { proxy in
ScrollView { List {
LazyVStack { if isLoaded {
Group { topPaddingView
switch viewModel.state { }
case .loading:
ForEach(Status.placeholders()) { status in switch viewModel.state {
StatusRowView(viewModel: .init(status: status, isCompact: false)) case .loading:
.padding(.horizontal, .layoutPadding) loadingDetailView
.redacted(reason: .placeholder)
Divider() case let .display(status, context):
.padding(.vertical, .dividerPadding) if !context.ancestors.isEmpty {
} ForEach(context.ancestors) { ancestor in
case let .display(status, context): StatusRowView(viewModel: .init(status: ancestor, isCompact: false))
if !context.ancestors.isEmpty {
ForEach(context.ancestors) { ancestor in
StatusRowView(viewModel: .init(status: ancestor, isCompact: false))
.padding(.horizontal, .layoutPadding)
Divider()
.padding(.vertical, .dividerPadding)
}
}
StatusRowView(viewModel: .init(status: status,
isCompact: false,
isFocused: true))
.padding(.horizontal, .layoutPadding)
.id(status.id)
Divider()
.padding(.bottom, .dividerPadding * 2)
if !context.descendants.isEmpty {
ForEach(context.descendants) { descendant in
StatusRowView(viewModel: .init(status: descendant, isCompact: false))
.padding(.horizontal, .layoutPadding)
Divider()
.padding(.vertical, .dividerPadding)
}
}
case .error:
ErrorView(title: "status.error.title",
message: "status.error.message",
buttonTitle: "action.retry") {
Task {
await viewModel.fetch()
}
}
} }
} }
makeCurrentStatusView(status: status)
if !context.descendants.isEmpty {
ForEach(context.descendants) { descendant in
StatusRowView(viewModel: .init(status: descendant, isCompact: false))
}
}
if !isLoaded {
loadingContextView
}
Rectangle()
.foregroundColor(theme.secondaryBackgroundColor)
.frame(minHeight: reader.frame(in: .local).size.height - statusHeight)
.listRowSeparator(.hidden)
.listRowBackground(theme.secondaryBackgroundColor)
.listRowInsets(.init())
case .error:
errorView
} }
.padding(.top, .layoutPadding)
} }
.environment(\.defaultMinListRowHeight, 1)
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(theme.primaryBackgroundColor) .background(theme.primaryBackgroundColor)
} .onChange(of: viewModel.scrollToId, perform: { scrollToId in
.task { if let scrollToId {
guard !isLoaded else { return } viewModel.scrollToId = nil
isLoaded = true proxy.scrollTo(scrollToId, anchor: .top)
viewModel.client = client
let result = await viewModel.fetch()
if !result {
if let url = viewModel.remoteStatusURL {
openURL(url)
} }
DispatchQueue.main.async { })
_ = routerPath.path.popLast() .task {
guard !isLoaded else { return }
viewModel.client = client
let result = await viewModel.fetch()
isLoaded = true
if !result {
if let url = viewModel.remoteStatusURL {
openURL(url)
}
DispatchQueue.main.async {
_ = routerPath.path.popLast()
}
} }
} }
DispatchQueue.main.async {
proxy.scrollTo(viewModel.statusId, anchor: .center)
}
} }
.onChange(of: watcher.latestEvent?.id) { _ in .onChange(of: watcher.latestEvent?.id) { _ in
guard let lastEvent = watcher.latestEvent else { return } guard let lastEvent = watcher.latestEvent else { return }
@ -103,4 +105,58 @@ public struct StatusDetailView: View {
.navigationTitle(viewModel.title) .navigationTitle(viewModel.title)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} }
private func makeCurrentStatusView(status: Status) -> some View {
StatusRowView(viewModel: .init(status: status,
isCompact: false,
isFocused: true))
.overlay {
GeometryReader { reader in
VStack{}
.onAppear {
statusHeight = reader.size.height
}
}
}
.id(status.id)
}
private var errorView: some View {
ErrorView(title: "status.error.title",
message: "status.error.message",
buttonTitle: "action.retry") {
Task {
await viewModel.fetch()
}
}
.listRowBackground(theme.primaryBackgroundColor)
.listRowSeparator(.hidden)
}
private var loadingDetailView: some View {
ForEach(Status.placeholders()) { status in
StatusRowView(viewModel: .init(status: status, isCompact: false))
.redacted(reason: .placeholder)
}
}
private var loadingContextView: some View {
HStack {
Spacer()
ProgressView()
Spacer()
}
.frame(height: 50)
.listRowSeparator(.hidden)
.listRowBackground(theme.secondaryBackgroundColor)
.listRowInsets(.init())
}
private var topPaddingView: some View {
HStack { EmptyView() }
.listRowBackground(theme.primaryBackgroundColor)
.listRowSeparator(.hidden)
.listRowInsets(.init())
.frame(height: .layoutPadding)
}
} }

View file

@ -16,12 +16,20 @@ class StatusDetailViewModel: ObservableObject {
@Published var state: State = .loading @Published var state: State = .loading
@Published var title: LocalizedStringKey = "" @Published var title: LocalizedStringKey = ""
@Published var scrollToId: String?
init(statusId: String) { init(statusId: String) {
state = .loading state = .loading
self.statusId = statusId self.statusId = statusId
remoteStatusURL = nil remoteStatusURL = nil
} }
init(status: Status) {
state = .display(status: status, context: .empty())
title = "status.post-from-\(status.account.displayNameWithoutEmojis)"
statusId = status.id
remoteStatusURL = nil
}
init(remoteStatusURL: URL) { init(remoteStatusURL: URL) {
state = .loading state = .loading
@ -64,8 +72,9 @@ class StatusDetailViewModel: ObservableObject {
guard let client, let statusId else { return } guard let client, let statusId else { return }
do { do {
let data = try await fetchContextData(client: client, statusId: statusId) let data = try await fetchContextData(client: client, statusId: statusId)
state = .display(status: data.status, context: data.context)
title = "status.post-from-\(data.status.account.displayNameWithoutEmojis)" title = "status.post-from-\(data.status.account.displayNameWithoutEmojis)"
state = .display(status: data.status, context: data.context)
scrollToId = statusId
} catch { } catch {
state = .error(error: error) state = .error(error: error)
} }

View file

@ -24,11 +24,6 @@ public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
StatusRowView(viewModel: .init(status: status, isCompact: false)) StatusRowView(viewModel: .init(status: status, isCompact: false))
.padding(.horizontal, isEmbdedInList ? 0 : .layoutPadding) .padding(.horizontal, isEmbdedInList ? 0 : .layoutPadding)
.redacted(reason: .placeholder) .redacted(reason: .placeholder)
.listRowBackground(theme.primaryBackgroundColor)
.listRowInsets(.init(top: 12,
leading: .layoutPadding,
bottom: 12,
trailing: .layoutPadding))
if !isEmbdedInList { if !isEmbdedInList {
Divider() Divider()
.padding(.vertical, .dividerPadding) .padding(.vertical, .dividerPadding)
@ -52,11 +47,6 @@ public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
StatusRowView(viewModel: viewModel) StatusRowView(viewModel: viewModel)
.padding(.horizontal, isEmbdedInList ? 0 : .layoutPadding) .padding(.horizontal, isEmbdedInList ? 0 : .layoutPadding)
.id(status.id) .id(status.id)
.listRowBackground(theme.primaryBackgroundColor)
.listRowInsets(.init(top: 12,
leading: .layoutPadding,
bottom: 12,
trailing: .layoutPadding))
.onAppear { .onAppear {
fetcher.statusDidAppear(status: status) fetcher.statusDidAppear(status: status)
} }

View file

@ -49,7 +49,9 @@ struct StatusRowDetailView: View {
if viewModel.favoritesCount > 0 { if viewModel.favoritesCount > 0 {
Divider() Divider()
NavigationLink(value: RouterDestinations.favoritedBy(id: viewModel.status.id)) { Button {
routerPath.navigate(to: .favoritedBy(id: viewModel.status.id))
} label: {
HStack { HStack {
Text("status.summary.n-favorites \(viewModel.favoritesCount)") Text("status.summary.n-favorites \(viewModel.favoritesCount)")
.font(.scaledCallout) .font(.scaledCallout)
@ -59,10 +61,13 @@ struct StatusRowDetailView: View {
} }
.frame(height: 20) .frame(height: 20)
} }
.buttonStyle(.borderless)
} }
if viewModel.reblogsCount > 0 { if viewModel.reblogsCount > 0 {
Divider() Divider()
NavigationLink(value: RouterDestinations.rebloggedBy(id: viewModel.status.id)) { Button {
routerPath.navigate(to: .rebloggedBy(id: viewModel.status.id))
} label: {
HStack { HStack {
Text("status.summary.n-boosts \(viewModel.reblogsCount)") Text("status.summary.n-boosts \(viewModel.reblogsCount)")
.font(.scaledCallout) .font(.scaledCallout)
@ -72,6 +77,7 @@ struct StatusRowDetailView: View {
} }
.frame(height: 20) .frame(height: 20)
} }
.buttonStyle(.borderless)
} }
} }
.task { .task {

View file

@ -93,6 +93,11 @@ public struct StatusRowView: View {
leadingSwipeActions leadingSwipeActions
} }
} }
.listRowBackground(theme.primaryBackgroundColor)
.listRowInsets(.init(top: 12,
leading: .layoutPadding,
bottom: 12,
trailing: .layoutPadding))
.accessibilityElement(children: viewModel.isFocused ? .contain : .combine) .accessibilityElement(children: viewModel.isFocused ? .contain : .combine)
.accessibilityActions { .accessibilityActions {
accesibilityActions accesibilityActions
@ -331,8 +336,12 @@ public struct StatusRowView: View {
contextMenu contextMenu
} label: { } label: {
Image(systemName: "ellipsis") Image(systemName: "ellipsis")
.foregroundColor(.gray) .frame(width: 20, height: 30)
} }
.menuStyle(.borderlessButton)
.foregroundColor(.gray)
.contentShape(Rectangle())
.accessibilityHidden(true)
} }
@ViewBuilder @ViewBuilder

View file

@ -97,7 +97,11 @@ public class StatusRowViewModel: ObservableObject {
if isRemote, let url = URL(string: status.reblog?.url ?? status.url ?? "") { if isRemote, let url = URL(string: status.reblog?.url ?? status.url ?? "") {
routerPath.navigate(to: .remoteStatusDetail(url: url)) routerPath.navigate(to: .remoteStatusDetail(url: url))
} else { } else {
routerPath.navigate(to: .statusDetail(id: status.reblog?.id ?? status.id)) if let id = status.reblog?.id {
routerPath.navigate(to: .statusDetail(id: id))
} else {
routerPath.navigate(to: .statusDetailWithStatus(status: status))
}
} }
} }