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.
let spacing: CGFloat
/// Layout priority constants for the bubble content. These priorities are abused within
/// `TimelineBubbleLayout` to create the layout we would like. They aren't
/// used in the expected way that SwiftUI would normally use layout priorities.
enum Priority {
/// 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.
static let hiddenGreedyComponent: Double = -1
/// The priority of visible quote bubbles/code blocks that are placed in the view with a full width.
/// Any views given this priority should remain in their normal greedy form.
static let visibleGreedyComponent: Double = 0
/// The priority of regular text that is used for layout calculations and placed in the view.
static let nonGreedyComponent: Double = 1
/// Layout size for the bubble content. These sizing types are used within
/// `TimelineBubbleLayout` to create the layout we would like.
enum Size: LayoutValueKey, Equatable {
static let defaultValue: Size = .natural
/// Full width mode used for greedy components like blockquotes and code blocks.
enum BubbleWidthMode {
/// The view has its natural size and will be used for layout calculations only.
case layout
/// The view has a greedy width and will fill the available space within the bubble.
case rendering
}
/// 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 {
@@ -47,7 +68,7 @@ struct TimelineBubbleLayout: Layout {
guard !subviews.isEmpty else { return .zero }
// 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) }
@@ -62,11 +83,11 @@ struct TimelineBubbleLayout: Layout {
guard !subviews.isEmpty else { return }
// 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)
// 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) }
@@ -101,3 +122,10 @@ struct TimelineBubbleLayout: Layout {
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 {
ThreadDecorator()
.padding(.leading, 4)
.layoutPriority(TimelineBubbleLayout.Priority.nonGreedyComponent)
.timelineBubbleLayoutSize(.natural)
}
if let replyDetails = timelineItem.properties.replyDetails {
@@ -203,7 +203,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.compound.bgCanvasDefault)
.cornerRadius(8)
.layoutPriority(TimelineBubbleLayout.Priority.visibleGreedyComponent)
.timelineBubbleLayoutSize(.bubbleWidth(mode: .rendering))
.onTapGesture {
if context.viewState.timelineKind != .pinned {
context.send(viewAction: .focusOnEventID(replyDetails.eventID))
@@ -214,12 +214,12 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
.fixedSize(horizontal: false, vertical: true)
.padding(4.0)
.layoutPriority(TimelineBubbleLayout.Priority.hiddenGreedyComponent)
.timelineBubbleLayoutSize(.bubbleWidth(mode: .layout))
.hidden()
}
content()
.layoutPriority(TimelineBubbleLayout.Priority.nonGreedyComponent)
.timelineBubbleLayoutSize(.natural)
.cornerRadius(timelineItem.contentCornerRadius)
}
}