Tidy-up FormattedBodyText and MessageBubbleLayout. (#5019)

* Tidy-up FormattedBodyText.

* Switch TimelineBubbleLayout.Priority from .layoutPriority to .layoutValue.

* Rename TimelineBubbleLayout.Priority to TimelineBubbleLayout.Size
This commit is contained in:
Doug
2026-01-30 10:26:36 +00:00
committed by GitHub
parent 39be9c4908
commit 7fbb0bb86c
3 changed files with 112 additions and 80 deletions
@@ -20,18 +20,39 @@ struct TimelineBubbleLayout: Layout {
/// The spacing between the components in the bubble. /// The spacing between the components in the bubble.
let spacing: CGFloat let spacing: CGFloat
/// Layout priority constants for the bubble content. These priorities are abused within /// Layout size for the bubble content. These sizing types are used within
/// `TimelineBubbleLayout` to create the layout we would like. They aren't /// `TimelineBubbleLayout` to create the layout we would like.
/// used in the expected way that SwiftUI would normally use layout priorities. enum Size: LayoutValueKey, Equatable {
enum Priority { static let defaultValue: Size = .natural
/// The priority of hidden quote bubbles/code blocks that are only used for layout calculations.
/// Any views given this priority should be made non-greedy for the calculations to work. /// Full width mode used for greedy components like blockquotes and code blocks.
static let hiddenGreedyComponent: Double = -1 enum BubbleWidthMode {
/// The priority of visible quote bubbles/code blocks that are placed in the view with a full width. /// The view has its natural size and will be used for layout calculations only.
/// Any views given this priority should remain in their normal greedy form. case layout
static let visibleGreedyComponent: Double = 0 /// The view has a greedy width and will fill the available space within the bubble.
/// The priority of regular text that is used for layout calculations and placed in the view. case rendering
static let nonGreedyComponent: Double = 1 }
/// The view will fill the available width, with different behaviour depending on the mode.
/// Views using the `.layout` mode should be hidden and are used only for width calculations.
/// Views using the `.rendering` mode should be visible and are placed to fill the bubble's calculated width.
case bubbleWidth(mode: BubbleWidthMode)
/// The view uses its natural size for both layout calculations and rendering.
case natural
var shouldLayout: Bool {
switch self {
case .natural, .bubbleWidth(mode: .layout): true
default: false
}
}
var shouldRender: Bool {
switch self {
case .natural, .bubbleWidth(mode: .rendering): true
default: false
}
}
} }
func makeCache(subviews: Subviews) -> Cache { func makeCache(subviews: Subviews) -> Cache {
@@ -47,7 +68,7 @@ struct TimelineBubbleLayout: Layout {
guard !subviews.isEmpty else { return .zero } guard !subviews.isEmpty else { return .zero }
// Calculate the natural size using the regular text and non-greedy quote bubbles. // Calculate the natural size using the regular text and non-greedy quote bubbles.
let layoutSubviews = subviews.filter { $0.priority != Priority.visibleGreedyComponent } let layoutSubviews = subviews.filter { $0[Size.self].shouldLayout }
let subviewSizes = layoutSubviews.map { size(for: $0, subviews: subviews, proposedSize: proposal, cache: &cache) } let subviewSizes = layoutSubviews.map { size(for: $0, subviews: subviews, proposedSize: proposal, cache: &cache) }
@@ -62,11 +83,11 @@ struct TimelineBubbleLayout: Layout {
guard !subviews.isEmpty else { return } guard !subviews.isEmpty else { return }
// Calculate the width using the regular text along with non-greedy versions of any greedy components. // Calculate the width using the regular text along with non-greedy versions of any greedy components.
let layoutSubviews = subviews.filter { $0.priority != Priority.visibleGreedyComponent } let layoutSubviews = subviews.filter { $0[Size.self].shouldLayout }
let maxWidth = layoutSubviews.map { size(for: $0, subviews: subviews, proposedSize: proposal, cache: &cache).width }.reduce(0, max) let maxWidth = layoutSubviews.map { size(for: $0, subviews: subviews, proposedSize: proposal, cache: &cache).width }.reduce(0, max)
// Place the regular text and greedy components using the calculated width. // Place the regular text and greedy components using the calculated width.
let visibleSubviews = subviews.filter { $0.priority != Priority.hiddenGreedyComponent } let visibleSubviews = subviews.filter { $0[Size.self].shouldRender }
let subviewSizes = visibleSubviews.map { size(for: $0, subviews: subviews, proposedSize: ProposedViewSize(width: maxWidth, height: proposal.height), cache: &cache) } let subviewSizes = visibleSubviews.map { size(for: $0, subviews: subviews, proposedSize: ProposedViewSize(width: maxWidth, height: proposal.height), cache: &cache) }
@@ -101,3 +122,10 @@ struct TimelineBubbleLayout: Layout {
return size return size
} }
} }
extension View {
/// Sets the layout size for this view when placed within a `TimelineBubbleLayout`.
func timelineBubbleLayoutSize(_ size: TimelineBubbleLayout.Size) -> some View {
layoutValue(key: TimelineBubbleLayout.Size.self, value: size)
}
}
@@ -190,7 +190,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
if !context.viewState.timelineKind.isThread, timelineItem.properties.isThreaded { if !context.viewState.timelineKind.isThread, timelineItem.properties.isThreaded {
ThreadDecorator() ThreadDecorator()
.padding(.leading, 4) .padding(.leading, 4)
.layoutPriority(TimelineBubbleLayout.Priority.nonGreedyComponent) .timelineBubbleLayoutSize(.natural)
} }
if let replyDetails = timelineItem.properties.replyDetails { if let replyDetails = timelineItem.properties.replyDetails {
@@ -203,7 +203,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(Color.compound.bgCanvasDefault) .background(Color.compound.bgCanvasDefault)
.cornerRadius(8) .cornerRadius(8)
.layoutPriority(TimelineBubbleLayout.Priority.visibleGreedyComponent) .timelineBubbleLayoutSize(.bubbleWidth(mode: .rendering))
.onTapGesture { .onTapGesture {
if context.viewState.timelineKind != .pinned { if context.viewState.timelineKind != .pinned {
context.send(viewAction: .focusOnEventID(replyDetails.eventID)) context.send(viewAction: .focusOnEventID(replyDetails.eventID))
@@ -214,12 +214,12 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails) TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.padding(4.0) .padding(4.0)
.layoutPriority(TimelineBubbleLayout.Priority.hiddenGreedyComponent) .timelineBubbleLayoutSize(.bubbleWidth(mode: .layout))
.hidden() .hidden()
} }
content() content()
.layoutPriority(TimelineBubbleLayout.Priority.nonGreedyComponent) .timelineBubbleLayoutSize(.natural)
.cornerRadius(timelineItem.contentCornerRadius) .cornerRadius(timelineItem.contentCornerRadius)
} }
} }
@@ -63,14 +63,10 @@ struct FormattedBodyText: View {
} }
var body: some View { var body: some View {
mainContent
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(attributedString))
}
var mainContent: some View {
layout layout
.tint(.compound.textLinkExternal) .tint(.compound.textLinkExternal)
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(attributedString))
} }
/// The attributed components laid out for the bubbles timeline style. /// The attributed components laid out for the bubbles timeline style.
@@ -83,61 +79,36 @@ struct FormattedBodyText: View {
} else { } else {
switch component.type { switch component.type {
case .blockquote: case .blockquote:
// The rendered blockquote with a greedy width. The custom layout prevents the BlockquoteView(attributedString: component.attributedString, mode: .rendering)
// infinite width from increasing the overall width of the view. .timelineBubbleLayoutSize(.bubbleWidth(mode: .rendering))
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.visibleGreedyComponent)
case .codeBlock: case .codeBlock:
// The rendered codeblock with a greedy width (due to the scroll view). The custom CodeBlockView(attributedString: component.attributedString, mode: .rendering)
// layout prevents the scroll view from increasing the overall width of the view. .timelineBubbleLayoutSize(.bubbleWidth(mode: .rendering))
ScrollView(.horizontal) { .contextMenu {
MessageText(attributedString: component.attributedString) Button(L10n.actionCopy) {
.padding([.horizontal, .top], 4) UIPasteboard.general.string = component.attributedString.string
.padding(.bottom, 8) }
}
.background(.compound._bgCodeBlock)
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
.scrollIndicatorsFlash(onAppear: true)
.padding(.horizontal, 4)
.layoutPriority(TimelineBubbleLayout.Priority.visibleGreedyComponent)
.contextMenu {
Button(L10n.actionCopy) {
UIPasteboard.general.string = component.attributedString.string
} }
}
case .plainText: case .plainText:
MessageText(attributedString: component.attributedString) MessageText(attributedString: component.attributedString)
.padding(.horizontal, 4) .padding(.horizontal, 4)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.layoutPriority(TimelineBubbleLayout.Priority.nonGreedyComponent) .timelineBubbleLayoutSize(.natural)
} }
} }
} }
// Make a second iteration through the components adding fixed width blockquotes/codeblocks // Make a second iteration through the components adding naturally sized versions of the
// which are used for layout calculations but won't be rendered. // block quotes and code blocks which are used for layout calculations but won't be rendered.
ForEach(attributedComponents) { component in ForEach(attributedComponents) { component in
switch component.type { switch component.type {
case .blockquote: case .blockquote:
MessageText(attributedString: component.attributedString.mergingAttributes(blockquoteAttributes)) BlockquoteView(attributedString: component.attributedString, mode: .layout)
.fixedSize(horizontal: false, vertical: true) .timelineBubbleLayoutSize(.bubbleWidth(mode: .layout))
.padding(.leading, 12.0)
.layoutPriority(TimelineBubbleLayout.Priority.hiddenGreedyComponent)
.hidden() .hidden()
case .codeBlock: case .codeBlock:
HiddenCodeBlockScrollView(attributedString: component.attributedString) CodeBlockView(attributedString: component.attributedString, mode: .layout)
.layoutPriority(TimelineBubbleLayout.Priority.hiddenGreedyComponent) .timelineBubbleLayoutSize(.bubbleWidth(mode: .layout))
.hidden() .hidden()
case .plainText: case .plainText:
EmptyView() EmptyView()
@@ -146,33 +117,66 @@ struct FormattedBodyText: View {
} }
} }
private var blockquoteAttributes: AttributeContainer { // MARK: - Component Views
// 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 /// The view used to render a blockquote component. It can be configured in one of 2 modes:
var container = AttributeContainer([.paragraphStyle: NSParagraphStyle.default]) /// - `.layout`: The view is given it's natural size to be used for layout calculations.
// Sadly setting SwiftUI fonts do not work so we would need UIFont equivalents for compound, this one is bodyMD /// - `.rendering`: The view has a greedy width that, in combination with the custom layout,
container.font = UIFont.preferredFont(forTextStyle: .subheadline) /// will fill any available space, whilst remaining constrained by the bubble's calculated width.
container.foregroundColor = UIColor.compound.textSecondary struct BlockquoteView: View {
let attributedString: AttributedString
let mode: TimelineBubbleLayout.Size.BubbleWidthMode
return container var body: some View {
MessageText(attributedString: attributedString.mergingAttributes(blockquoteAttributes))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: mode == .rendering ? .infinity : nil, alignment: .leading)
.padding(.leading, 12.0)
.overlay(alignment: .leading) {
// Use an overlay here so that the rectangle's infinite height doesn't take priority
if mode == .rendering {
Capsule()
.frame(width: 2.0)
.padding(.leading, 5.0)
.foregroundColor(.compound.textSecondary)
.padding(.vertical, 2)
}
}
}
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
}
} }
/// A self-sizing version of the code block component's view, necessary /// The view used to render a code block component. It can be configured in one of 2 modes:
/// because unlike quote bubbles, code blocks don't wrap when the space /// - `.layout`: The view is given it's natural size to be used for layout calculations.
/// is constrained. /// - `.rendering`: The view has a greedy width that, in combination with the custom layout,
private struct HiddenCodeBlockScrollView: View { /// will fill any available space, whilst remaining constrained by the bubble's calculated width.
private struct CodeBlockView: View {
let attributedString: AttributedString let attributedString: AttributedString
let mode: TimelineBubbleLayout.Size.BubbleWidthMode
@State private var maxSize: CGSize = .zero @State private var maxWidth: CGFloat = .zero
var body: some View { var body: some View {
ScrollView(.horizontal) { ScrollView(.horizontal) {
MessageText(attributedString: attributedString) MessageText(attributedString: attributedString)
.padding([.horizontal, .top], 4) .padding([.horizontal, .top], 4)
.padding(.bottom, 8) .padding(.bottom, 8)
.onGeometryChange(for: CGSize.self) { $0.size } action: { maxSize = $0 } .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { maxWidth = $0 }
} }
.frame(maxWidth: maxSize.width) .frame(maxWidth: mode == .layout ? maxWidth : nil)
.background(.compound._bgCodeBlock)
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
.scrollIndicatorsFlash(onAppear: true)
.padding(.horizontal, 4) .padding(.horizontal, 4)
} }
} }