mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
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:
@@ -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,34 +79,11 @@ 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) {
|
|
||||||
MessageText(attributedString: component.attributedString)
|
|
||||||
.padding([.horizontal, .top], 4)
|
|
||||||
.padding(.bottom, 8)
|
|
||||||
}
|
|
||||||
.background(.compound._bgCodeBlock)
|
|
||||||
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
|
|
||||||
.scrollIndicatorsFlash(onAppear: true)
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
.layoutPriority(TimelineBubbleLayout.Priority.visibleGreedyComponent)
|
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button(L10n.actionCopy) {
|
Button(L10n.actionCopy) {
|
||||||
UIPasteboard.general.string = component.attributedString.string
|
UIPasteboard.general.string = component.attributedString.string
|
||||||
@@ -120,24 +93,22 @@ struct FormattedBodyText: View {
|
|||||||
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,6 +117,33 @@ struct FormattedBodyText: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Component Views
|
||||||
|
|
||||||
|
/// The view used to render a blockquote component. It can be configured in one of 2 modes:
|
||||||
|
/// - `.layout`: The view is given it's natural size to be used for layout calculations.
|
||||||
|
/// - `.rendering`: The view has a greedy width that, in combination with the custom layout,
|
||||||
|
/// will fill any available space, whilst remaining constrained by the bubble's calculated width.
|
||||||
|
struct BlockquoteView: View {
|
||||||
|
let attributedString: AttributedString
|
||||||
|
let mode: TimelineBubbleLayout.Size.BubbleWidthMode
|
||||||
|
|
||||||
|
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 {
|
private var blockquoteAttributes: AttributeContainer {
|
||||||
// The paragraph style removes the block style paragraph that the parser adds by default
|
// 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
|
// Set directly in the constructor to avoid `Conformance to 'Sendable'` warnings
|
||||||
@@ -156,23 +154,29 @@ struct FormattedBodyText: View {
|
|||||||
|
|
||||||
return container
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user