Html 不尊重父母的子女';s码迅捷
我们目前正在为SwiftUI开发一个类似Twitter的客户端,我们必须将HTML内容转换为Html 不尊重父母的子女';s码迅捷,html,ios,swift,swiftui,nsattributedstring,Html,Ios,Swift,Swiftui,Nsattributedstring,我们目前正在为SwiftUI开发一个类似Twitter的客户端,我们必须将HTML内容转换为NSAttributedStrings。出于这个原因,我们正在使用样式设置HTML标记。它似乎工作正常,但当我们试图显示内容时,文本只会覆盖其他内容。我们认为这是因为它不尊重它的父母的大小,但它可能是其他的东西 我们如何解决这个问题?有没有更好的方法在SwiftUI中呈现HTML内容,而不需要使用WKWebViews,因为这会导致难以置信的性能下降 我们有什么遗漏吗 提前谢谢 我们的代码: Netwo
NSAttributedString
s。出于这个原因,我们正在使用样式设置HTML标记。它似乎工作正常,但当我们试图显示内容时,文本只会覆盖其他内容。我们认为这是因为它不尊重它的父母的大小,但它可能是其他的东西
我们如何解决这个问题?有没有更好的方法在SwiftUI中呈现HTML内容,而不需要使用WKWebViews,因为这会导致难以置信的性能下降
我们有什么遗漏吗
提前谢谢
我们的代码:
NetworkView.swift
struct NetworkView: View {
@ObservedObject var timeline = NetworkViewModel()
private let size: CGFloat = 300
private let padding: CGFloat = 10
private let displayPublic: Bool = true
var body: some View {
List {
Section {
NavigationLink(destination: Text("F").padding()) {
Label("Announcements", systemImage: "megaphone")
}
NavigationLink(destination: Text("F").padding()) {
Label("Activity", systemImage: "flame")
}
}
.listStyle(InsetGroupedListStyle())
Section(header:
Picker(selection: self.$timeline.type, label: Text("Network visibility")) {
Text("My community").tag(TimelineScope.local)
Text("Public").tag(TimelineScope.public)
} .pickerStyle(SegmentedPickerStyle())
.padding(.top)
.padding(.bottom, 2)) {
if self.timeline.statuses.isEmpty {
HStack {
Spacer()
VStack {
Spacer()
ProgressView(value: 0.5)
.progressViewStyle(CircularProgressViewStyle())
Text("Loading posts...")
Spacer()
}
Spacer()
}
} else {
ForEach(self.timeline.statuses, id: \.self.id) { status in
StatusView(status: status)
.buttonStyle(PlainButtonStyle())
}
}
}
}
.listStyle(GroupedListStyle())
}
}
import SwiftUI
import Atributika
/// A structure that computes statuses on demand from a `Status` data model.
struct StatusView: View {
#if os(iOS)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
#endif
/// In Starlight, there are two ways to display mastodon statuses:
/// - Standard: The status is being displayed from the feed.
/// - Focused: The status is the main post of a thread.
///
/// The ``StatusView`` instance should know what way it is being
/// displayed so that it can display the content properly.
/// In order to achieve this, we pass a bool to ``StatusView``, which when true,
/// tells it that it's content should be displayed as `Focused`.
private var isMain: Bool
/// The ``Status`` data model whose the data will be displayed.
var status: Status
#if os(iOS)
/// Using for triggering the navigation View **only**when the user taps
/// on the content, and not when it taps on the action buttons.
@State var goToThread: Int? = 0
@State var showMoreActions: Bool = false
@State var profileViewActive: Bool = false
#endif
private let rootStyle: Style = Style("p")
.font(.systemFont(ofSize: 17, weight: .light))
private let rootPresentedStyle: Style = Style("p")
.font(.systemFont(ofSize: 20, weight: .light))
private func configureLabel(_ label: AttributedLabel, size: CGFloat = 17) {
label.numberOfLines = 0
label.textColor = .label
label.font = .systemFont(ofSize: size)
label.lineBreakMode = .byWordWrapping
}
/// To easily use the same view on multiple platforms,
/// we use the `body` view as a container where we load platform-specific
/// modifiers.
var body: some View {
// We use this vertical stack to load platform specific modifiers,
// or to load specific views when a condition is met.
VStack {
// Whether the post is focused or not.
if self.isMain {
self.presentedView
} else {
// To provide the best experience, we want to allow the user to easily
// interact with a post directly from the feed. Because of that, we need
// to add a button
ZStack {
self.defaultView
NavigationLink(
destination: ThreadView(mainStatus: self.status),
tag: 1,
selection: self.$goToThread,
label: {
EmptyView()
}
)
}
}
}
.buttonStyle(PlainButtonStyle())
}
/// The status display mode when it is the thread's main post.
var presentedView: some View {
VStack(alignment: .leading) {
NavigationLink(destination:
ProfileView(isParent: false,
accountInfo: ProfileViewModel(accountID: self.status.account.id),
onResumeToParent: {
self.profileViewActive = false
}),
isActive: self.$profileViewActive) {
EmptyView()
}
HStack(alignment: .center) {
ProfileImage(from: self.status.account.avatarStatic, placeholder: {
Circle()
.scaledToFit()
.frame(width: 50, height: 50)
.foregroundColor(.gray)
})
VStack(alignment: .leading, spacing: 5) {
VStack(alignment: .leading) {
Text("\(self.status.account.displayName)")
.font(.headline)
Text("\(self.status.account.acct)")
.foregroundColor(.gray)
.lineLimit(1)
}
}
Spacer()
Button(action: { self.showMoreActions.toggle() }, label: {
Image(systemName: "ellipsis")
.imageScale(.large)
})
}
GeometryReader { (geometry: GeometryProxy) in
AttributedTextView(attributedText:
"\(self.status.content)"
.style(tags: rootPresentedStyle),
configured: { label in configureLabel(label, size: 20) },
maxWidth: geometry.size.width)
.fixedSize(horizontal: true, vertical: true)
}
if !self.status.mediaAttachments.isEmpty {
AttachmentView(from: self.status.mediaAttachments[0].url) {
Rectangle()
.scaledToFit()
.cornerRadius(10)
}
}
HStack {
Text("\(self.status.createdAt.getDate()!.format(as: "hh:mm · dd/MM/YYYY")) · ")
Button(action: {
if let application = self.status.application {
if let website = application.website {
openUrl(website)
}
}
}, label: {
Text("\(self.status.application?.name ?? "Mastodon")")
.lineLimit(1)
})
.foregroundColor(.accentColor)
.padding(.leading, -7)
}
.padding(.top)
Divider()
Text("\(self.status.repliesCount.roundedWithAbbreviations) ").bold()
+
Text("comments, ")
+
Text("\(self.status.reblogsCount.roundedWithAbbreviations) ").bold()
+
Text("boosts, and ")
+
Text("\(self.status.favouritesCount.roundedWithAbbreviations) ").bold()
+
Text("likes.")
Divider()
self.actionButtons
.padding(.vertical, 5)
.padding(.horizontal)
}
.buttonStyle(PlainButtonStyle())
.navigationBarHidden(self.profileViewActive)
.actionSheet(isPresented: self.$showMoreActions) {
ActionSheet(title: Text("More Actions"),
buttons: [
.default(Text("View @\(self.status.account.acct)'s profile"), action: {
self.profileViewActive = true
}),
.destructive(Text("Mute @\(self.status.account.acct)"), action: {
}),
.destructive(Text("Block @\(self.status.account.acct)"), action: {
}),
.destructive(Text("Report @\(self.status.account.acct)"), action: {
}),
.cancel(Text("Dismiss"), action: {})
]
)
}
}
var defaultView: some View {
VStack(alignment: .leading) {
Button(action: {
self.goToThread = 1
}, label: {
HStack(alignment: .top) {
ProfileImage(from: self.status.account.avatarStatic, placeholder: {
Circle()
.scaledToFit()
.frame(width: 50, height: 50)
.foregroundColor(.gray)
})
VStack(alignment: .leading, spacing: 5) {
HStack {
HStack(spacing: 5) {
Text("\(self.status.account.displayName)")
.font(.headline)
.lineLimit(1)
Text("\(self.status.account.acct)")
.foregroundColor(.gray)
.lineLimit(1)
Text("· \(self.status.createdAt.getDate()!.getInterval())")
.lineLimit(1)
}
}
GeometryReader { (geometry: GeometryProxy) in
AttributedTextView(attributedText:
"\(self.status.content)"
.style(tags: rootStyle),
configured: { label in configureLabel(label, size: 17) },
maxWidth: geometry.size.width)
.fixedSize(horizontal: true, vertical: false)
}
if !self.status.mediaAttachments.isEmpty {
AttachmentView(from: self.status.mediaAttachments[0].previewURL) {
Rectangle()
.scaledToFit()
.cornerRadius(10)
}
}
}
}
})
self.actionButtons
.padding(.leading, 60)
}
.contextMenu(
ContextMenu(menuItems: {
Button(action: {}, label: {
Label("Report post", systemImage: "flag")
})
Button(action: {}, label: {
Label("Report \(self.status.account.displayName)", systemImage: "flag")
})
Button(action: {}, label: {
Label("Share as Image", systemImage: "square.and.arrow.up")
})
})
)
}
/// The post's action buttons (favourite and reblog), and also the amount of replies.
///
/// If the post is focused (``isMain`` is true), the count is hidden.
var actionButtons: some View {
HStack {
HStack {
Image(systemName: "text.bubble")
if !self.isMain {
Text("\(self.status.repliesCount.roundedWithAbbreviations)")
}
}
Spacer()
Button(action: {
}, label: {
HStack {
Image(systemName: "arrow.2.squarepath")
if !self.isMain {
Text("\(self.status.reblogsCount.roundedWithAbbreviations)")
}
}
})
.foregroundColor(
labelColor
)
Spacer()
Button(action: {
}, label: {
HStack {
Image(systemName: "heart")
if !self.isMain {
Text("\(self.status.favouritesCount.roundedWithAbbreviations)")
}
}
})
.foregroundColor(
labelColor
)
Spacer()
Button(action: {
}, label: {
Image(systemName: "square.and.arrow.up")
})
.foregroundColor(
labelColor
)
}
}
}
extension StatusView {
/// Generates a View that displays a post on Mastodon.
///
/// - Parameters:
/// - isPresented: A boolean variable that determines whether
/// the status is being shown as the main post (in a thread).
/// - status: The identified data that the ``StatusView`` instance uses to
/// display posts dynamically.
public init(isMain: Bool = false, status: Status) {
self.isMain = isMain
self.status = status
}
}
struct StatusView_Previews: PreviewProvider {
@ObservedObject static var timeline = NetworkViewModel()
static var previews: some View {
VStack {
if self.timeline.statuses.isEmpty {
HStack {
Spacer()
VStack {
Spacer()
ProgressView(value: 0.5)
.progressViewStyle(CircularProgressViewStyle())
Text("Loading status...")
Spacer()
}
Spacer()
}
.onAppear {
self.timeline.fetchLocalTimeline()
}
} else {
StatusView(isMain: false, status: self.timeline.statuses[0])
}
}
.frame(width: 600, height: 300)
.previewLayout(.sizeThatFits)
}
}
import UIKit
import SwiftUI
import Foundation
import Atributika
// Note: Implementation pulled from pending PR in Atributika on GitHub: https://github.com/psharanda/Atributika/pull/119
// Credit to rivera-ernesto for this implementation.
/// A view that displays one or more lines of text with applied styles.
struct AttributedTextView: UIViewRepresentable {
typealias UIViewType = RestrainedLabel
/// The attributed text for this view.
var attributedText: AttributedText?
/// The configuration properties for this view.
var configured: ((AttributedLabel) -> Void)?
@State var maxWidth: CGFloat = 300
public func makeUIView(context: UIViewRepresentableContext<AttributedTextView>) -> RestrainedLabel {
let new = RestrainedLabel()
configured?(new)
return new
}
public func updateUIView(_ uiView: RestrainedLabel, context: UIViewRepresentableContext<AttributedTextView>) {
uiView.attributedText = attributedText
uiView.maxWidth = maxWidth
}
class RestrainedLabel: AttributedLabel {
var maxWidth: CGFloat = 0.0
open override var intrinsicContentSize: CGSize {
sizeThatFits(CGSize(width: maxWidth, height: .infinity))
}
}
}
StatusView.swift
struct NetworkView: View {
@ObservedObject var timeline = NetworkViewModel()
private let size: CGFloat = 300
private let padding: CGFloat = 10
private let displayPublic: Bool = true
var body: some View {
List {
Section {
NavigationLink(destination: Text("F").padding()) {
Label("Announcements", systemImage: "megaphone")
}
NavigationLink(destination: Text("F").padding()) {
Label("Activity", systemImage: "flame")
}
}
.listStyle(InsetGroupedListStyle())
Section(header:
Picker(selection: self.$timeline.type, label: Text("Network visibility")) {
Text("My community").tag(TimelineScope.local)
Text("Public").tag(TimelineScope.public)
} .pickerStyle(SegmentedPickerStyle())
.padding(.top)
.padding(.bottom, 2)) {
if self.timeline.statuses.isEmpty {
HStack {
Spacer()
VStack {
Spacer()
ProgressView(value: 0.5)
.progressViewStyle(CircularProgressViewStyle())
Text("Loading posts...")
Spacer()
}
Spacer()
}
} else {
ForEach(self.timeline.statuses, id: \.self.id) { status in
StatusView(status: status)
.buttonStyle(PlainButtonStyle())
}
}
}
}
.listStyle(GroupedListStyle())
}
}
import SwiftUI
import Atributika
/// A structure that computes statuses on demand from a `Status` data model.
struct StatusView: View {
#if os(iOS)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
#endif
/// In Starlight, there are two ways to display mastodon statuses:
/// - Standard: The status is being displayed from the feed.
/// - Focused: The status is the main post of a thread.
///
/// The ``StatusView`` instance should know what way it is being
/// displayed so that it can display the content properly.
/// In order to achieve this, we pass a bool to ``StatusView``, which when true,
/// tells it that it's content should be displayed as `Focused`.
private var isMain: Bool
/// The ``Status`` data model whose the data will be displayed.
var status: Status
#if os(iOS)
/// Using for triggering the navigation View **only**when the user taps
/// on the content, and not when it taps on the action buttons.
@State var goToThread: Int? = 0
@State var showMoreActions: Bool = false
@State var profileViewActive: Bool = false
#endif
private let rootStyle: Style = Style("p")
.font(.systemFont(ofSize: 17, weight: .light))
private let rootPresentedStyle: Style = Style("p")
.font(.systemFont(ofSize: 20, weight: .light))
private func configureLabel(_ label: AttributedLabel, size: CGFloat = 17) {
label.numberOfLines = 0
label.textColor = .label
label.font = .systemFont(ofSize: size)
label.lineBreakMode = .byWordWrapping
}
/// To easily use the same view on multiple platforms,
/// we use the `body` view as a container where we load platform-specific
/// modifiers.
var body: some View {
// We use this vertical stack to load platform specific modifiers,
// or to load specific views when a condition is met.
VStack {
// Whether the post is focused or not.
if self.isMain {
self.presentedView
} else {
// To provide the best experience, we want to allow the user to easily
// interact with a post directly from the feed. Because of that, we need
// to add a button
ZStack {
self.defaultView
NavigationLink(
destination: ThreadView(mainStatus: self.status),
tag: 1,
selection: self.$goToThread,
label: {
EmptyView()
}
)
}
}
}
.buttonStyle(PlainButtonStyle())
}
/// The status display mode when it is the thread's main post.
var presentedView: some View {
VStack(alignment: .leading) {
NavigationLink(destination:
ProfileView(isParent: false,
accountInfo: ProfileViewModel(accountID: self.status.account.id),
onResumeToParent: {
self.profileViewActive = false
}),
isActive: self.$profileViewActive) {
EmptyView()
}
HStack(alignment: .center) {
ProfileImage(from: self.status.account.avatarStatic, placeholder: {
Circle()
.scaledToFit()
.frame(width: 50, height: 50)
.foregroundColor(.gray)
})
VStack(alignment: .leading, spacing: 5) {
VStack(alignment: .leading) {
Text("\(self.status.account.displayName)")
.font(.headline)
Text("\(self.status.account.acct)")
.foregroundColor(.gray)
.lineLimit(1)
}
}
Spacer()
Button(action: { self.showMoreActions.toggle() }, label: {
Image(systemName: "ellipsis")
.imageScale(.large)
})
}
GeometryReader { (geometry: GeometryProxy) in
AttributedTextView(attributedText:
"\(self.status.content)"
.style(tags: rootPresentedStyle),
configured: { label in configureLabel(label, size: 20) },
maxWidth: geometry.size.width)
.fixedSize(horizontal: true, vertical: true)
}
if !self.status.mediaAttachments.isEmpty {
AttachmentView(from: self.status.mediaAttachments[0].url) {
Rectangle()
.scaledToFit()
.cornerRadius(10)
}
}
HStack {
Text("\(self.status.createdAt.getDate()!.format(as: "hh:mm · dd/MM/YYYY")) · ")
Button(action: {
if let application = self.status.application {
if let website = application.website {
openUrl(website)
}
}
}, label: {
Text("\(self.status.application?.name ?? "Mastodon")")
.lineLimit(1)
})
.foregroundColor(.accentColor)
.padding(.leading, -7)
}
.padding(.top)
Divider()
Text("\(self.status.repliesCount.roundedWithAbbreviations) ").bold()
+
Text("comments, ")
+
Text("\(self.status.reblogsCount.roundedWithAbbreviations) ").bold()
+
Text("boosts, and ")
+
Text("\(self.status.favouritesCount.roundedWithAbbreviations) ").bold()
+
Text("likes.")
Divider()
self.actionButtons
.padding(.vertical, 5)
.padding(.horizontal)
}
.buttonStyle(PlainButtonStyle())
.navigationBarHidden(self.profileViewActive)
.actionSheet(isPresented: self.$showMoreActions) {
ActionSheet(title: Text("More Actions"),
buttons: [
.default(Text("View @\(self.status.account.acct)'s profile"), action: {
self.profileViewActive = true
}),
.destructive(Text("Mute @\(self.status.account.acct)"), action: {
}),
.destructive(Text("Block @\(self.status.account.acct)"), action: {
}),
.destructive(Text("Report @\(self.status.account.acct)"), action: {
}),
.cancel(Text("Dismiss"), action: {})
]
)
}
}
var defaultView: some View {
VStack(alignment: .leading) {
Button(action: {
self.goToThread = 1
}, label: {
HStack(alignment: .top) {
ProfileImage(from: self.status.account.avatarStatic, placeholder: {
Circle()
.scaledToFit()
.frame(width: 50, height: 50)
.foregroundColor(.gray)
})
VStack(alignment: .leading, spacing: 5) {
HStack {
HStack(spacing: 5) {
Text("\(self.status.account.displayName)")
.font(.headline)
.lineLimit(1)
Text("\(self.status.account.acct)")
.foregroundColor(.gray)
.lineLimit(1)
Text("· \(self.status.createdAt.getDate()!.getInterval())")
.lineLimit(1)
}
}
GeometryReader { (geometry: GeometryProxy) in
AttributedTextView(attributedText:
"\(self.status.content)"
.style(tags: rootStyle),
configured: { label in configureLabel(label, size: 17) },
maxWidth: geometry.size.width)
.fixedSize(horizontal: true, vertical: false)
}
if !self.status.mediaAttachments.isEmpty {
AttachmentView(from: self.status.mediaAttachments[0].previewURL) {
Rectangle()
.scaledToFit()
.cornerRadius(10)
}
}
}
}
})
self.actionButtons
.padding(.leading, 60)
}
.contextMenu(
ContextMenu(menuItems: {
Button(action: {}, label: {
Label("Report post", systemImage: "flag")
})
Button(action: {}, label: {
Label("Report \(self.status.account.displayName)", systemImage: "flag")
})
Button(action: {}, label: {
Label("Share as Image", systemImage: "square.and.arrow.up")
})
})
)
}
/// The post's action buttons (favourite and reblog), and also the amount of replies.
///
/// If the post is focused (``isMain`` is true), the count is hidden.
var actionButtons: some View {
HStack {
HStack {
Image(systemName: "text.bubble")
if !self.isMain {
Text("\(self.status.repliesCount.roundedWithAbbreviations)")
}
}
Spacer()
Button(action: {
}, label: {
HStack {
Image(systemName: "arrow.2.squarepath")
if !self.isMain {
Text("\(self.status.reblogsCount.roundedWithAbbreviations)")
}
}
})
.foregroundColor(
labelColor
)
Spacer()
Button(action: {
}, label: {
HStack {
Image(systemName: "heart")
if !self.isMain {
Text("\(self.status.favouritesCount.roundedWithAbbreviations)")
}
}
})
.foregroundColor(
labelColor
)
Spacer()
Button(action: {
}, label: {
Image(systemName: "square.and.arrow.up")
})
.foregroundColor(
labelColor
)
}
}
}
extension StatusView {
/// Generates a View that displays a post on Mastodon.
///
/// - Parameters:
/// - isPresented: A boolean variable that determines whether
/// the status is being shown as the main post (in a thread).
/// - status: The identified data that the ``StatusView`` instance uses to
/// display posts dynamically.
public init(isMain: Bool = false, status: Status) {
self.isMain = isMain
self.status = status
}
}
struct StatusView_Previews: PreviewProvider {
@ObservedObject static var timeline = NetworkViewModel()
static var previews: some View {
VStack {
if self.timeline.statuses.isEmpty {
HStack {
Spacer()
VStack {
Spacer()
ProgressView(value: 0.5)
.progressViewStyle(CircularProgressViewStyle())
Text("Loading status...")
Spacer()
}
Spacer()
}
.onAppear {
self.timeline.fetchLocalTimeline()
}
} else {
StatusView(isMain: false, status: self.timeline.statuses[0])
}
}
.frame(width: 600, height: 300)
.previewLayout(.sizeThatFits)
}
}
import UIKit
import SwiftUI
import Foundation
import Atributika
// Note: Implementation pulled from pending PR in Atributika on GitHub: https://github.com/psharanda/Atributika/pull/119
// Credit to rivera-ernesto for this implementation.
/// A view that displays one or more lines of text with applied styles.
struct AttributedTextView: UIViewRepresentable {
typealias UIViewType = RestrainedLabel
/// The attributed text for this view.
var attributedText: AttributedText?
/// The configuration properties for this view.
var configured: ((AttributedLabel) -> Void)?
@State var maxWidth: CGFloat = 300
public func makeUIView(context: UIViewRepresentableContext<AttributedTextView>) -> RestrainedLabel {
let new = RestrainedLabel()
configured?(new)
return new
}
public func updateUIView(_ uiView: RestrainedLabel, context: UIViewRepresentableContext<AttributedTextView>) {
uiView.attributedText = attributedText
uiView.maxWidth = maxWidth
}
class RestrainedLabel: AttributedLabel {
var maxWidth: CGFloat = 0.0
open override var intrinsicContentSize: CGSize {
sizeThatFits(CGSize(width: maxWidth, height: .infinity))
}
}
}
AttributedTextView.swift
struct NetworkView: View {
@ObservedObject var timeline = NetworkViewModel()
private let size: CGFloat = 300
private let padding: CGFloat = 10
private let displayPublic: Bool = true
var body: some View {
List {
Section {
NavigationLink(destination: Text("F").padding()) {
Label("Announcements", systemImage: "megaphone")
}
NavigationLink(destination: Text("F").padding()) {
Label("Activity", systemImage: "flame")
}
}
.listStyle(InsetGroupedListStyle())
Section(header:
Picker(selection: self.$timeline.type, label: Text("Network visibility")) {
Text("My community").tag(TimelineScope.local)
Text("Public").tag(TimelineScope.public)
} .pickerStyle(SegmentedPickerStyle())
.padding(.top)
.padding(.bottom, 2)) {
if self.timeline.statuses.isEmpty {
HStack {
Spacer()
VStack {
Spacer()
ProgressView(value: 0.5)
.progressViewStyle(CircularProgressViewStyle())
Text("Loading posts...")
Spacer()
}
Spacer()
}
} else {
ForEach(self.timeline.statuses, id: \.self.id) { status in
StatusView(status: status)
.buttonStyle(PlainButtonStyle())
}
}
}
}
.listStyle(GroupedListStyle())
}
}
import SwiftUI
import Atributika
/// A structure that computes statuses on demand from a `Status` data model.
struct StatusView: View {
#if os(iOS)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
#endif
/// In Starlight, there are two ways to display mastodon statuses:
/// - Standard: The status is being displayed from the feed.
/// - Focused: The status is the main post of a thread.
///
/// The ``StatusView`` instance should know what way it is being
/// displayed so that it can display the content properly.
/// In order to achieve this, we pass a bool to ``StatusView``, which when true,
/// tells it that it's content should be displayed as `Focused`.
private var isMain: Bool
/// The ``Status`` data model whose the data will be displayed.
var status: Status
#if os(iOS)
/// Using for triggering the navigation View **only**when the user taps
/// on the content, and not when it taps on the action buttons.
@State var goToThread: Int? = 0
@State var showMoreActions: Bool = false
@State var profileViewActive: Bool = false
#endif
private let rootStyle: Style = Style("p")
.font(.systemFont(ofSize: 17, weight: .light))
private let rootPresentedStyle: Style = Style("p")
.font(.systemFont(ofSize: 20, weight: .light))
private func configureLabel(_ label: AttributedLabel, size: CGFloat = 17) {
label.numberOfLines = 0
label.textColor = .label
label.font = .systemFont(ofSize: size)
label.lineBreakMode = .byWordWrapping
}
/// To easily use the same view on multiple platforms,
/// we use the `body` view as a container where we load platform-specific
/// modifiers.
var body: some View {
// We use this vertical stack to load platform specific modifiers,
// or to load specific views when a condition is met.
VStack {
// Whether the post is focused or not.
if self.isMain {
self.presentedView
} else {
// To provide the best experience, we want to allow the user to easily
// interact with a post directly from the feed. Because of that, we need
// to add a button
ZStack {
self.defaultView
NavigationLink(
destination: ThreadView(mainStatus: self.status),
tag: 1,
selection: self.$goToThread,
label: {
EmptyView()
}
)
}
}
}
.buttonStyle(PlainButtonStyle())
}
/// The status display mode when it is the thread's main post.
var presentedView: some View {
VStack(alignment: .leading) {
NavigationLink(destination:
ProfileView(isParent: false,
accountInfo: ProfileViewModel(accountID: self.status.account.id),
onResumeToParent: {
self.profileViewActive = false
}),
isActive: self.$profileViewActive) {
EmptyView()
}
HStack(alignment: .center) {
ProfileImage(from: self.status.account.avatarStatic, placeholder: {
Circle()
.scaledToFit()
.frame(width: 50, height: 50)
.foregroundColor(.gray)
})
VStack(alignment: .leading, spacing: 5) {
VStack(alignment: .leading) {
Text("\(self.status.account.displayName)")
.font(.headline)
Text("\(self.status.account.acct)")
.foregroundColor(.gray)
.lineLimit(1)
}
}
Spacer()
Button(action: { self.showMoreActions.toggle() }, label: {
Image(systemName: "ellipsis")
.imageScale(.large)
})
}
GeometryReader { (geometry: GeometryProxy) in
AttributedTextView(attributedText:
"\(self.status.content)"
.style(tags: rootPresentedStyle),
configured: { label in configureLabel(label, size: 20) },
maxWidth: geometry.size.width)
.fixedSize(horizontal: true, vertical: true)
}
if !self.status.mediaAttachments.isEmpty {
AttachmentView(from: self.status.mediaAttachments[0].url) {
Rectangle()
.scaledToFit()
.cornerRadius(10)
}
}
HStack {
Text("\(self.status.createdAt.getDate()!.format(as: "hh:mm · dd/MM/YYYY")) · ")
Button(action: {
if let application = self.status.application {
if let website = application.website {
openUrl(website)
}
}
}, label: {
Text("\(self.status.application?.name ?? "Mastodon")")
.lineLimit(1)
})
.foregroundColor(.accentColor)
.padding(.leading, -7)
}
.padding(.top)
Divider()
Text("\(self.status.repliesCount.roundedWithAbbreviations) ").bold()
+
Text("comments, ")
+
Text("\(self.status.reblogsCount.roundedWithAbbreviations) ").bold()
+
Text("boosts, and ")
+
Text("\(self.status.favouritesCount.roundedWithAbbreviations) ").bold()
+
Text("likes.")
Divider()
self.actionButtons
.padding(.vertical, 5)
.padding(.horizontal)
}
.buttonStyle(PlainButtonStyle())
.navigationBarHidden(self.profileViewActive)
.actionSheet(isPresented: self.$showMoreActions) {
ActionSheet(title: Text("More Actions"),
buttons: [
.default(Text("View @\(self.status.account.acct)'s profile"), action: {
self.profileViewActive = true
}),
.destructive(Text("Mute @\(self.status.account.acct)"), action: {
}),
.destructive(Text("Block @\(self.status.account.acct)"), action: {
}),
.destructive(Text("Report @\(self.status.account.acct)"), action: {
}),
.cancel(Text("Dismiss"), action: {})
]
)
}
}
var defaultView: some View {
VStack(alignment: .leading) {
Button(action: {
self.goToThread = 1
}, label: {
HStack(alignment: .top) {
ProfileImage(from: self.status.account.avatarStatic, placeholder: {
Circle()
.scaledToFit()
.frame(width: 50, height: 50)
.foregroundColor(.gray)
})
VStack(alignment: .leading, spacing: 5) {
HStack {
HStack(spacing: 5) {
Text("\(self.status.account.displayName)")
.font(.headline)
.lineLimit(1)
Text("\(self.status.account.acct)")
.foregroundColor(.gray)
.lineLimit(1)
Text("· \(self.status.createdAt.getDate()!.getInterval())")
.lineLimit(1)
}
}
GeometryReader { (geometry: GeometryProxy) in
AttributedTextView(attributedText:
"\(self.status.content)"
.style(tags: rootStyle),
configured: { label in configureLabel(label, size: 17) },
maxWidth: geometry.size.width)
.fixedSize(horizontal: true, vertical: false)
}
if !self.status.mediaAttachments.isEmpty {
AttachmentView(from: self.status.mediaAttachments[0].previewURL) {
Rectangle()
.scaledToFit()
.cornerRadius(10)
}
}
}
}
})
self.actionButtons
.padding(.leading, 60)
}
.contextMenu(
ContextMenu(menuItems: {
Button(action: {}, label: {
Label("Report post", systemImage: "flag")
})
Button(action: {}, label: {
Label("Report \(self.status.account.displayName)", systemImage: "flag")
})
Button(action: {}, label: {
Label("Share as Image", systemImage: "square.and.arrow.up")
})
})
)
}
/// The post's action buttons (favourite and reblog), and also the amount of replies.
///
/// If the post is focused (``isMain`` is true), the count is hidden.
var actionButtons: some View {
HStack {
HStack {
Image(systemName: "text.bubble")
if !self.isMain {
Text("\(self.status.repliesCount.roundedWithAbbreviations)")
}
}
Spacer()
Button(action: {
}, label: {
HStack {
Image(systemName: "arrow.2.squarepath")
if !self.isMain {
Text("\(self.status.reblogsCount.roundedWithAbbreviations)")
}
}
})
.foregroundColor(
labelColor
)
Spacer()
Button(action: {
}, label: {
HStack {
Image(systemName: "heart")
if !self.isMain {
Text("\(self.status.favouritesCount.roundedWithAbbreviations)")
}
}
})
.foregroundColor(
labelColor
)
Spacer()
Button(action: {
}, label: {
Image(systemName: "square.and.arrow.up")
})
.foregroundColor(
labelColor
)
}
}
}
extension StatusView {
/// Generates a View that displays a post on Mastodon.
///
/// - Parameters:
/// - isPresented: A boolean variable that determines whether
/// the status is being shown as the main post (in a thread).
/// - status: The identified data that the ``StatusView`` instance uses to
/// display posts dynamically.
public init(isMain: Bool = false, status: Status) {
self.isMain = isMain
self.status = status
}
}
struct StatusView_Previews: PreviewProvider {
@ObservedObject static var timeline = NetworkViewModel()
static var previews: some View {
VStack {
if self.timeline.statuses.isEmpty {
HStack {
Spacer()
VStack {
Spacer()
ProgressView(value: 0.5)
.progressViewStyle(CircularProgressViewStyle())
Text("Loading status...")
Spacer()
}
Spacer()
}
.onAppear {
self.timeline.fetchLocalTimeline()
}
} else {
StatusView(isMain: false, status: self.timeline.statuses[0])
}
}
.frame(width: 600, height: 300)
.previewLayout(.sizeThatFits)
}
}
import UIKit
import SwiftUI
import Foundation
import Atributika
// Note: Implementation pulled from pending PR in Atributika on GitHub: https://github.com/psharanda/Atributika/pull/119
// Credit to rivera-ernesto for this implementation.
/// A view that displays one or more lines of text with applied styles.
struct AttributedTextView: UIViewRepresentable {
typealias UIViewType = RestrainedLabel
/// The attributed text for this view.
var attributedText: AttributedText?
/// The configuration properties for this view.
var configured: ((AttributedLabel) -> Void)?
@State var maxWidth: CGFloat = 300
public func makeUIView(context: UIViewRepresentableContext<AttributedTextView>) -> RestrainedLabel {
let new = RestrainedLabel()
configured?(new)
return new
}
public func updateUIView(_ uiView: RestrainedLabel, context: UIViewRepresentableContext<AttributedTextView>) {
uiView.attributedText = attributedText
uiView.maxWidth = maxWidth
}
class RestrainedLabel: AttributedLabel {
var maxWidth: CGFloat = 0.0
open override var intrinsicContentSize: CGSize {
sizeThatFits(CGSize(width: maxWidth, height: .infinity))
}
}
}
导入UIKit
导入快捷键
进口基金会
进口心房肌
//注意:从GitHub上Atributika的挂起PR中提取实现:https://github.com/psharanda/Atributika/pull/119
//这一实施归功于rivera ernesto。
///显示具有应用样式的一行或多行文字的视图。
结构AttributedTextView:UIViewRepresentable{
typealias UIViewType=受限标签
///此视图的属性文本。
var attributedText:attributedText?
///此视图的配置属性。
配置的变量:((AttributedLabel)->Void)?
@状态变量maxWidth:CGFloat=300
public func makeUIView(上下文:UIViewRepresentableContext)->RestrainedLabel{
let new=RestrainedLabel()
已配置?(新)
还新
}
public func UpdateUI视图(uiView:RestrainedLabel,context:UIViewRepresentableContext){
uiView.attributedText=attributedText
uiView.maxWidth=maxWidth
}
类限制标签:AttributedLabel{
var maxWidth:CGFloat=0.0
打开覆盖变量intrinsicContentSize:CGSize{
sizeThatFits(CGSize(宽度:maxWidth,高度:无穷大))
}
}
}