Various optimisations (#1878)

* Cache TimelineBubbleLayout subview sizes

* Cache MessageText sizes, avoid extra updates

* Only use the `CollapsibleReactionLayout` if there's more than 5 reactions on a particular message

* Upgrade Sentry to 8.13.0, disable various options as they're not useful and impact performance

* Address PR comments, fix unit tests
This commit is contained in:
Stefan Ceriu
2023-10-11 16:21:45 +03:00
committed by GitHub
parent b6470c9651
commit ff4d59bac7
16 changed files with 149 additions and 45 deletions
@@ -21,6 +21,10 @@ import SwiftUI
/// 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 {
struct Cache {
var sizes = [Int: [ProposedViewSize: CGSize]]()
}
/// The spacing between the components in the bubble.
let spacing: CGFloat
@@ -36,13 +40,23 @@ struct TimelineBubbleLayout: Layout {
static let regularText: Double = 1
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
func makeCache(subviews: Subviews) -> Cache {
Cache()
}
func updateCache(_ cache: inout Cache, subviews: Subviews) {
// A subview changed, reset everything
cache = Cache()
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> 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 { size(for: $0, subviews: subviews, proposedSize: proposal, cache: &cache) }
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
@@ -50,16 +64,17 @@ struct TimelineBubbleLayout: Layout {
return CGSize(width: maxWidth, height: totalHeight + totalSpacing)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
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)
let maxWidth = layoutSubviews.map { size(for: $0, subviews: subviews, proposedSize: proposal, cache: &cache).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)) }
let subviewSizes = visibleSubviews.map { size(for: $0, subviews: subviews, proposedSize: ProposedViewSize(width: maxWidth, height: proposal.height), cache: &cache) }
var y = bounds.minY
for index in visibleSubviews.indices {
@@ -70,4 +85,25 @@ struct TimelineBubbleLayout: Layout {
y += height + spacing
}
}
// MARK: - Private
private func size(for subview: LayoutSubview, subviews: LayoutSubviews, proposedSize: ProposedViewSize, cache: inout Cache) -> CGSize {
guard let index = subviews.firstIndex(of: subview) else {
fatalError()
}
if cache.sizes[index] == nil {
cache.sizes[index] = [:]
}
if let cachedSize = cache.sizes[index]?[proposedSize] {
return cachedSize
}
let size = subview.sizeThatFits(proposedSize)
cache.sizes[index]?[proposedSize] = size
return size
}
}
@@ -32,9 +32,9 @@ struct TimelineReactionsView: View {
guard isLayoutRTL else { return layoutDirection }
return layoutDirection == .leftToRight ? .rightToLeft : .leftToRight
}
var body: some View {
CollapsibleReactionLayout(itemSpacing: 4, rowSpacing: 4, collapsed: collapsed, rowsBeforeCollapsible: 2) {
layout {
ForEach(reactions, id: \.self) { reaction in
TimelineReactionButton(reaction: reaction) { key in
feedbackGenerator.impactOccurred()
@@ -45,14 +45,18 @@ struct TimelineReactionsView: View {
.reactionLayoutItem(.reaction)
.environment(\.layoutDirection, layoutDirection)
}
Button {
collapsed.toggle()
} label: {
TimelineCollapseButtonLabel(collapsed: collapsed)
.transaction { $0.animation = nil }
if isCollapsible {
Button {
collapsed.toggle()
} label: {
TimelineCollapseButtonLabel(collapsed: collapsed)
.transaction { $0.animation = nil }
}
.reactionLayoutItem(.expandCollapse)
.environment(\.layoutDirection, layoutDirection)
}
.reactionLayoutItem(.expandCollapse)
.environment(\.layoutDirection, layoutDirection)
Button {
context.send(viewAction: .displayEmojiPicker(itemID: itemID))
} label: {
@@ -64,6 +68,23 @@ struct TimelineReactionsView: View {
.animation(.easeInOut(duration: 0.1).disabledDuringTests(), value: reactions)
.padding(.leading, 4)
}
// MARK: - Private
private var isCollapsible: Bool {
reactions.count > 5
}
private var layout: AnyLayout {
if isCollapsible {
return AnyLayout(CollapsibleReactionLayout(itemSpacing: 4,
rowSpacing: 4,
collapsed: collapsed,
rowsBeforeCollapsible: 2))
}
return AnyLayout(HStackLayout(spacing: 4.0))
}
}
/// The pill shape for the label that surrounds both the reaction and collapse buttons.
@@ -159,10 +180,8 @@ struct TimelineReactionAddMoreButtonLabel: View {
Image(asset: Asset.Images.addReaction)
.resizable()
.frame(width: addMoreButtonIconSize, height: addMoreButtonIconSize)
// Vertical sizing is done by the layout so that the add more button
// matches the height of the text based buttons.
.padding(.horizontal, 10)
.frame(maxHeight: .infinity, alignment: .center)
.padding(.vertical, 8)
.padding(.horizontal, 12)
.foregroundColor(.compound.iconSecondary)
}
}