Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use the existing quote bubble layout with TimelineReplyView. #883

Merged
merged 1 commit into from
May 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -125,15 +125,27 @@ struct TimelineItemBubbledStylerView<Content: View>: 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)
}
}

Expand Down Expand Up @@ -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..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in
let item = MockRoomTimelineController().timelineItems[index]
RoomTimelineViewProvider(timelineItem: item, groupStyle: .single)
.padding(TimelineStyle.bubbles.rowInsets) // Insets added in the table view cells
ForEach(viewModel.state.items) { item in
item.padding(TimelineStyle.bubbles.rowInsets) // Insets added in the table view cells
}
}
.timelineStyle(.bubbles)
.previewLayout(.sizeThatFits)
.environmentObject(viewModel.context)
}

static var replies: some View {
VStack {
RoomTimelineViewProvider.text(TextRoomTimelineItem(id: "",
timestamp: "10:42",
isOutgoing: true,
isEditable: false,
sender: .init(id: "whoever"),
content: .init(body: "A long message that should be on multiple lines."),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
content: .text(.init(body: "Short")))),
.single)

RoomTimelineViewProvider.text(TextRoomTimelineItem(id: "",
timestamp: "10:42",
isOutgoing: true,
isEditable: false,
sender: .init(id: "whoever"),
content: .init(body: "Short message"),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
content: .text(.init(body: "A long message that should be on more than 2 lines and so will be clipped by the layout.")))),
.single)
}
.environmentObject(viewModel.context)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,65 +14,8 @@
// limitations under the License.
//

import Foundation
import SwiftUI

/// Layout priority constants for `FormattedBodyText`. These priorities are abused within
/// `FormattedBodyTextBubbleLayout` to create the layout we would like. They aren't
/// used in the expected way that SwiftUI would normally use layout priorities.
private enum LayoutPriority {
/// The priority of hidden blockquotes that are only used for layout calculations.
static let hiddenBlockquote: Double = -1
/// The priority of visible blockquotes that are placed in the view with a full width.
static let visibleBlockquote: Double = 0
/// The priority of regular text that is used for layout calculations and placed in the view.
static let regularText: Double = 1
}

/// A custom layout used for formatted text components when in the bubbles timeline style.
///
/// A custom layout is required as the embedded blockquotes should fill the entire width of the message
/// bubble, without causing the width of the bubble to fill all of the available space.
struct FormattedBodyTextBubbleLayout: Layout {
/// The spacing between the components in the bubble.
let spacing: CGFloat

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 blockquote bubbles.
let layoutSubviews = subviews.filter { $0.priority != LayoutPriority.visibleBlockquote }

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 blockquote bubbles.
let layoutSubviews = subviews.filter { $0.priority != LayoutPriority.visibleBlockquote }
let maxWidth = layoutSubviews.map { $0.sizeThatFits(proposal).width }.reduce(0, max)

// Place the regular text and greedy blockquote bubbles using the calculated width.
let visibleSubviews = subviews.filter { $0.priority != LayoutPriority.hiddenBlockquote }
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
}
}
}

struct FormattedBodyText: View {
@Environment(\.timelineStyle) private var timelineStyle

Expand All @@ -94,23 +37,23 @@ struct FormattedBodyText: View {

/// The attributed components laid out for the bubbles timeline style.
var bubbleLayout: some View {
FormattedBodyTextBubbleLayout(spacing: 8) {
TimelineBubbleLayout(spacing: 8) {
ForEach(attributedComponents, id: \.self) { component in
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.
Text(component.attributedString.mergingAttributes(blockquoteAttributes))
.blockquoteFormatting()
.timelineQuoteBubbleFormatting()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.element.background)
.cornerRadius(8)
.layoutPriority(LayoutPriority.visibleBlockquote)
.layoutPriority(TimelineBubbleLayout.Priority.visibleQuote)
} else {
Text(component.attributedString)
.padding(.horizontal, timelineStyle == .bubbles ? 4 : 0)
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.element.primaryContent)
.layoutPriority(LayoutPriority.regularText)
.layoutPriority(TimelineBubbleLayout.Priority.regularText)
}
}

Expand All @@ -119,8 +62,8 @@ struct FormattedBodyText: View {
ForEach(attributedComponents, id: \.self) { component in
if component.isBlockquote {
Text(component.attributedString.mergingAttributes(blockquoteAttributes))
.blockquoteFormatting()
.layoutPriority(LayoutPriority.hiddenBlockquote)
.timelineQuoteBubbleFormatting()
.layoutPriority(TimelineBubbleLayout.Priority.hiddenQuote)
.hidden()
}
}
Expand Down Expand Up @@ -163,14 +106,6 @@ extension FormattedBodyText {
}
}

private extension View {
func blockquoteFormatting() -> some View {
foregroundColor(.element.tertiaryContent)
.fixedSize(horizontal: false, vertical: true)
.padding(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
}
}

// MARK: - Previews

struct FormattedBodyText_Previews: PreviewProvider {
Expand Down
1 change: 1 addition & 0 deletions changelog.d/pr-883.change
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Use the existing quote bubble layout with TimelineReplyView.