From 06b7728f5bc986605e04319772b7f656d22fe41d Mon Sep 17 00:00:00 2001 From: Doug Date: Thu, 11 May 2023 18:51:14 +0100 Subject: [PATCH] Use the existing quote bubble layout with TimelineReplyView. --- .../View/Replies/TimelineReplyView.swift | 57 +++++++------ .../View/Style/TimelineBubbleLayout.swift | 81 +++++++++++++++++++ .../Style/TimelineItemBubbledStylerView.swift | 52 ++++++++++-- .../View/Timeline/FormattedBodyText.swift | 77 ++---------------- changelog.d/pr-883.change | 1 + 5 files changed, 168 insertions(+), 100 deletions(-) create mode 100644 ElementX/Sources/Screens/RoomScreen/View/Style/TimelineBubbleLayout.swift create mode 100644 changelog.d/pr-883.change diff --git a/ElementX/Sources/Screens/RoomScreen/View/Replies/TimelineReplyView.swift b/ElementX/Sources/Screens/RoomScreen/View/Replies/TimelineReplyView.swift index b77cd1535..9447add2d 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Replies/TimelineReplyView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Replies/TimelineReplyView.swift @@ -25,19 +25,19 @@ struct TimelineReplyView: View { case .loaded(let sender, let content): switch content { case .audio(let content): - TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: nil)) + TimelineTextReplyView(sender: sender, plainBody: content.body, formattedBody: nil) case .emote(let content): - TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: content.formattedBody)) + TimelineTextReplyView(sender: sender, plainBody: content.body, formattedBody: content.formattedBody) case .file(let content): - TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: nil)) + TimelineTextReplyView(sender: sender, plainBody: content.body, formattedBody: nil) case .image(let content): - TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: nil)) + TimelineTextReplyView(sender: sender, plainBody: content.body, formattedBody: nil) case .notice(let content): - TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: content.formattedBody)) + TimelineTextReplyView(sender: sender, plainBody: content.body, formattedBody: content.formattedBody) case .text(let content): - TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: content.formattedBody)) + TimelineTextReplyView(sender: sender, plainBody: content.body, formattedBody: content.formattedBody) case .video(let content): - TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: nil)) + TimelineTextReplyView(sender: sender, plainBody: content.body, formattedBody: nil) } default: Text("Missing in-reply-to details") @@ -48,26 +48,35 @@ struct TimelineReplyView: View { } } - private func attributedString(for sender: TimelineItemSender, body: String, formattedBody: AttributedString?) -> AttributedString { - var attributedHeading = AttributedString("\(sender.displayName ?? sender.id)\n") - attributedHeading.font = .compound.bodyMD.bold() - attributedHeading.foregroundColor = .element.primaryContent - - var formattedBody = formattedBody ?? AttributedString(body) - formattedBody.font = .compound.bodyMD - formattedBody.foregroundColor = .element.secondaryContent - - attributedHeading += formattedBody - - return attributedHeading - } - private struct TimelineTextReplyView: View { - let attributedText: AttributedString + let sender: TimelineItemSender + let plainBody: String + let formattedBody: AttributedString? var body: some View { - FormattedBodyText(attributedString: attributedText) - .lineLimit(3) + VStack(alignment: .leading) { + Text(sender.displayName ?? sender.id) + .font(.compound.bodySMSemibold) + .foregroundColor(.compound.textPrimary) + + Text(formattedBody ?? AttributedString(plainBody)) + .font(.compound.bodyMD) + .foregroundColor(.compound.textPlaceholder) + .tint(.element.links) + .lineLimit(2) + } } } } + +struct TimelineReplyView_Previews: PreviewProvider { + static var previews: some View { + TimelineReplyView(timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + content: .text(.init(body: "This is a reply")))) + .background(Color.element.background) + .cornerRadius(8) + .padding(8) + .background(Color.element.bubblesYou) + .cornerRadius(12) + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineBubbleLayout.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineBubbleLayout.swift new file mode 100644 index 000000000..61f3214fe --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineBubbleLayout.swift @@ -0,0 +1,81 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +/// A custom layout used for quotes and content when using the bubbles timeline style. +/// +/// A custom layout is required as the embedded quote bubbles should fill the entire width of +/// the message bubble, without causing the width of the bubble to fill all of the available space. +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 that are only used for layout calculations. + static let hiddenQuote: Double = -1 + /// The priority of visible quote bubbles that are placed in the view with a full width. + static let visibleQuote: Double = 0 + /// The priority of regular text that is used for layout calculations and placed in the view. + static let regularText: Double = 1 + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + 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.visibleQuote } + + let subviewSizes = layoutSubviews.map { $0.sizeThatFits(proposal) } + let maxWidth = subviewSizes.map(\.width).reduce(0, max) + let totalHeight = subviewSizes.map(\.height).reduce(0, +) + let totalSpacing = CGFloat(layoutSubviews.count - 1) * spacing + + return CGSize(width: maxWidth, height: totalHeight + totalSpacing) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + guard !subviews.isEmpty else { return } + + // Calculate the width using the regular text and the non-greedy quote bubbles. + let layoutSubviews = subviews.filter { $0.priority != Priority.visibleQuote } + let maxWidth = layoutSubviews.map { $0.sizeThatFits(proposal).width }.reduce(0, max) + + // Place the regular text and greedy quote bubbles using the calculated width. + let visibleSubviews = subviews.filter { $0.priority != Priority.hiddenQuote } + let subviewSizes = visibleSubviews.map { $0.sizeThatFits(ProposedViewSize(width: maxWidth, height: proposal.height)) } + + var y = bounds.minY + for index in visibleSubviews.indices { + let height = subviewSizes[index].height + visibleSubviews[index].place(at: CGPoint(x: bounds.minX, y: y), + anchor: .topLeading, + proposal: ProposedViewSize(width: maxWidth, height: height)) + y += height + spacing + } + } +} + +extension View { + func timelineQuoteBubbleFormatting() -> some View { + foregroundColor(.compound.textPlaceholder) + .fixedSize(horizontal: false, vertical: true) + .padding(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12)) + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index 4d7b6bd46..20c96fae8 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -125,15 +125,27 @@ struct TimelineItemBubbledStylerView: View { @ViewBuilder var contentWithReply: some View { - VStack(alignment: .leading, spacing: 4.0) { + TimelineBubbleLayout(spacing: 8) { if let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol, let replyDetails = messageTimelineItem.replyDetails { + // The rendered reply bubble with a greedy width. The custom layout prevents + // the infinite width from increasing the overall width of the view. TimelineReplyView(timelineItemReplyDetails: replyDetails) + .timelineQuoteBubbleFormatting() + .frame(maxWidth: .infinity, alignment: .leading) .background(Color.element.background) .cornerRadius(8) + .layoutPriority(TimelineBubbleLayout.Priority.visibleQuote) + + // Add a fixed width reply bubble that is used for layout calculations but won't be rendered. + TimelineReplyView(timelineItemReplyDetails: replyDetails) + .timelineQuoteBubbleFormatting() + .layoutPriority(TimelineBubbleLayout.Priority.hiddenQuote) + .hidden() } content() + .layoutPriority(TimelineBubbleLayout.Priority.regularText) } } @@ -188,15 +200,45 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider { static let viewModel = RoomScreenViewModel.mock static var previews: some View { + mockTimeline + .previewDisplayName("Mock Timeline") + replies + .previewDisplayName("Replies") + } + + static var mockTimeline: some View { VStack(alignment: .leading, spacing: 0) { - ForEach(1..