184 lines
8.1 KiB
Swift
184 lines
8.1 KiB
Swift
//
|
|
// Copyright 2022-2024 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct FormattedBodyText: View {
|
|
@Environment(\.layoutDirection) private var layoutDirection
|
|
|
|
private let attributedString: AttributedString
|
|
private let additionalWhitespacesCount: Int
|
|
private let boostFontSize: Bool
|
|
|
|
private let defaultAttributesContainer: AttributeContainer = {
|
|
var container = AttributeContainer()
|
|
// Equivalent to compound's bodyLG
|
|
container.font = UIFont.preferredFont(forTextStyle: .body)
|
|
container.foregroundColor = UIColor.compound.textPrimary
|
|
return container
|
|
}()
|
|
|
|
private var attributedComponents: [AttributedStringBuilderComponent] {
|
|
var adjustedAttributedString = attributedString + AttributedString(additionalWhitespacesSuffix)
|
|
|
|
// If this is not a list, force the writing direction by adding the correct unicode character.
|
|
if !String(attributedString.characters).starts(with: "\t") {
|
|
adjustedAttributedString = AttributedString(layoutDirection.isolateLayoutUnicodeString) + adjustedAttributedString
|
|
}
|
|
|
|
// Required to allow the underlying TextView to use body font when no font is specifie in the AttributedString.
|
|
adjustedAttributedString.mergeAttributes(defaultAttributesContainer, mergePolicy: .keepCurrent)
|
|
|
|
let string = String(attributedString.characters)
|
|
|
|
if boostFontSize, let range = adjustedAttributedString.range(of: string) {
|
|
adjustedAttributedString[range].font = UIFont.systemFont(ofSize: 48.0)
|
|
}
|
|
|
|
return adjustedAttributedString.formattedComponents
|
|
}
|
|
|
|
init(attributedString: AttributedString,
|
|
additionalWhitespacesCount: Int = 0,
|
|
boostFontSize: Bool = false) {
|
|
self.attributedString = attributedString
|
|
self.additionalWhitespacesCount = additionalWhitespacesCount
|
|
self.boostFontSize = boostFontSize
|
|
}
|
|
|
|
init(text: String, additionalWhitespacesCount: Int = 0, boostFontSize: Bool = false) {
|
|
self.init(attributedString: AttributedString(text),
|
|
additionalWhitespacesCount: additionalWhitespacesCount,
|
|
boostFontSize: boostFontSize)
|
|
}
|
|
|
|
// These is needed to create the slightly off inlined timestamp effect
|
|
private var additionalWhitespacesSuffix: String {
|
|
.generateBreakableWhitespaceEnd(whitespaceCount: additionalWhitespacesCount, layoutDirection: layoutDirection)
|
|
}
|
|
|
|
var body: some View {
|
|
mainContent
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(Text(attributedString))
|
|
}
|
|
|
|
@ViewBuilder
|
|
var mainContent: some View {
|
|
layout
|
|
.tint(.compound.textLinkExternal)
|
|
}
|
|
|
|
/// The attributed components laid out for the bubbles timeline style.
|
|
var layout: some View {
|
|
TimelineBubbleLayout(spacing: 8) {
|
|
ForEach(attributedComponents) { component in
|
|
// Ignore if the string contains only the layout correction
|
|
if String(component.attributedString.characters) == layoutDirection.isolateLayoutUnicodeString {
|
|
EmptyView()
|
|
} else if component.isBlockquote {
|
|
// The rendered blockquote with a greedy width. The custom layout prevents the
|
|
// infinite width from increasing the overall width of the view.
|
|
MessageText(attributedString: component.attributedString.mergingAttributes(blockquoteAttributes))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.leading, 12.0)
|
|
.overlay(alignment: .leading) {
|
|
// User an overlay here so that the rectangle's infinite height doesn't take priority
|
|
Capsule()
|
|
.frame(width: 2.0)
|
|
.padding(.leading, 5.0)
|
|
.foregroundColor(.compound.textSecondary)
|
|
.padding(.vertical, 2)
|
|
}
|
|
.layoutPriority(TimelineBubbleLayout.Priority.visibleQuote)
|
|
} else {
|
|
MessageText(attributedString: component.attributedString)
|
|
.padding(.horizontal, 4)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.layoutPriority(TimelineBubbleLayout.Priority.regularText)
|
|
}
|
|
}
|
|
|
|
// Make a second iteration through the components adding fixed width blockquotes
|
|
// which are used for layout calculations but won't be rendered.
|
|
ForEach(attributedComponents) { component in
|
|
if component.isBlockquote {
|
|
MessageText(attributedString: component.attributedString.mergingAttributes(blockquoteAttributes))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.padding(.leading, 12.0)
|
|
.layoutPriority(TimelineBubbleLayout.Priority.hiddenQuote)
|
|
.hidden()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var blockquoteAttributes: AttributeContainer {
|
|
// The paragraph style removes the block style paragraph that the parser adds by default
|
|
// Set directly in the constructor to avoid `Conformance to 'Sendable'` warnings
|
|
var container = AttributeContainer([.paragraphStyle: NSParagraphStyle.default])
|
|
// Sadly setting SwiftUI fonts do not work so we would need UIFont equivalents for compound, this one is bodyMD
|
|
container.font = UIFont.preferredFont(forTextStyle: .subheadline)
|
|
container.foregroundColor = UIColor.compound.textSecondary
|
|
|
|
return container
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
struct FormattedBodyText_Previews: PreviewProvider, TestablePreview {
|
|
static var previews: some View {
|
|
body(AttributedStringBuilderV1(cacheKey: "v1", mentionBuilder: MentionBuilder()))
|
|
.previewLayout(.sizeThatFits)
|
|
.previewDisplayName("v1")
|
|
|
|
body(AttributedStringBuilderV2(cacheKey: "v2", mentionBuilder: MentionBuilder()))
|
|
.previewLayout(.sizeThatFits)
|
|
.previewDisplayName("v2")
|
|
}
|
|
|
|
@ViewBuilder
|
|
static func body(_ attributedStringBuilder: AttributedStringBuilderProtocol) -> some View {
|
|
let htmlStrings = HTMLFixtures.allCases.map(\.rawValue)
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 4.0) {
|
|
ForEach(htmlStrings, id: \.self) { htmlString in
|
|
HStack(alignment: .top, spacing: 0) {
|
|
Text(htmlString)
|
|
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
|
.padding(4.0)
|
|
|
|
Divider()
|
|
.background(.black)
|
|
|
|
if let attributedString = attributedStringBuilder.fromHTML(htmlString) {
|
|
FormattedBodyText(attributedString: attributedString)
|
|
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
|
.bubbleBackground()
|
|
.padding(4.0)
|
|
}
|
|
}
|
|
.border(.black)
|
|
}
|
|
|
|
FormattedBodyText(attributedString: AttributedString("Some plain text wrapped in an AttributedString."))
|
|
.bubbleBackground()
|
|
|
|
FormattedBodyText(text: "Some plain text that's not an attributed component.")
|
|
.bubbleBackground()
|
|
|
|
FormattedBodyText(text: "❤️", boostFontSize: true)
|
|
.bubbleBackground()
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
}
|