Reactions: Add more button (#1314)

* Add expand/collapse UI for reactions

- Adds a CollapsibleFlowLayout for controlling the layout
- Adds tests for  this layout and some mocks for testing layouts generally
- Improves the rendering of the reaction buttons which were not pixel perfect
- Adds the UI for the expand collapse buttons including the count of hidden items in the collapsed state.

* Add comment for reactionsCollapsed binding.

* Add the add more button and change the layout to be reactions specific and account for the add more button specifics.

* Update CollapsibleReactionLayout.swift

* Rename lines to rows, to hopefully ease the incoming merge.

* Add LayoutValueKey to organise subviews by item type.

- create ReactionLayoutItemType to tag subviews with ReactionLayoutItem
- Add LayoutValueKey support to the layout mocks
- Fix some mistakes in the merge
- Make the test code a bit more DRY
- Make the animation of the addMore and collapse buttons consistent

* Fix naming of the add more button label

* Address comments and fix padding

- Fix typos
- Use pdf/svg for icon
- Fix incorrect padding from a bad merge

* Fix "Add more" button padding to match designs

* Update snapshot test reference images.

* Fix naming, add Preserves Vector Data
This commit is contained in:
David Langley
2023-07-14 17:32:32 +01:00
committed by GitHub
parent a44a60cb0a
commit b31af77686
20 changed files with 415 additions and 141 deletions
+1
View File
@@ -42,6 +42,7 @@ internal enum Asset {
internal static let locationPointerFull = ImageAsset(name: "images/location-pointer-full")
internal static let locationPointer = ImageAsset(name: "images/location-pointer")
internal static let timelineComposerSendMessage = ImageAsset(name: "images/timeline-composer-send-message")
internal static let timelineReactionAddMore = ImageAsset(name: "images/timeline-reaction-add-more")
internal static let waitingGradient = ImageAsset(name: "images/waiting-gradient")
}
}
@@ -15,11 +15,12 @@
//
import SwiftUI
/// A flow layout that will show a collapse/expand button when the layout wraps over a defined number of rows.
/// With n subviews passed to the layout, n-1 first views represent the main views to be laid out.
/// The nth subview is the collapse/expand button which is only shown when the layout overflows `rowsBeforeCollapsible` number of rows.
/// When the button is shown it is tagged on the end of the collapsed or expanded layout.
struct CollapsibleFlowLayout: Layout {
/// A flow layout for reactions that will show a collapse/expand button when the layout wraps over a defined number of rows.
/// It displays an add more button when there are greater than 0 reactions and always displays the reaction and add more button
/// on the same row (moving them both to a new row if necessary).
/// Each subview should be marked with the appropriate `ReactionLayoutItem` using the `reactionLayoutItem` modified
/// so the layout can appropriately treat each type of item.
struct CollapsibleReactionLayout: Layout {
static let pointOffscreen = CGPoint(x: -10000, y: -10000)
/// The horizontal spacing between items
var itemSpacing: CGFloat = 0
@@ -31,61 +32,76 @@ struct CollapsibleFlowLayout: Layout {
var rowsBeforeCollapsible: Int?
func sizeThatFits(proposal: ProposedViewSize, subviews: some FlowLayoutSubviews, cache: inout ()) -> CGSize {
let collapseButton = subviews[subviews.count - 1]
var subviewsWithoutCollapseButton = subviews
subviewsWithoutCollapseButton.removeLast()
// Calculate the layout of the rows without the button
let rowsNoButton = calculateRows(proposal: proposal, subviews: Array(subviewsWithoutCollapseButton))
guard let subviewsByType = getSubviewsByItemType(subviews: Array(subviews)), subviewsByType.reactions.count > 0 else {
return .zero
}
// Calculate the layout of the rows with the reactions button and add more button
let reactionsAndAddMore = calculateRows(proposal: proposal, subviews: Array(subviewsByType.reactions + [subviewsByType.addMoreButton]))
// If we have extended beyond the defined number of rows we are showing the expand/collapse ui
if let rowsBeforeCollapsible, rowsNoButton.count > rowsBeforeCollapsible {
if let rowsBeforeCollapsible, reactionsAndAddMore.count > rowsBeforeCollapsible {
if collapsed {
// Truncate to `rowsBeforeCollapsible` number of rows and replace the item at the end of the last row with the button
let collapsedRows = Array(rowsNoButton.prefix(rowsBeforeCollapsible))
let (collapsedRowsWithButton, _) = replaceTrailingItemsWithButton(rowWidth: proposal.width ?? 0, rows: collapsedRows, button: collapseButton)
let size = sizeThatFits(proposal: proposal, rows: collapsedRowsWithButton)
let collapsedRows = Array(reactionsAndAddMore.prefix(rowsBeforeCollapsible))
let (collapsedRowsWithButtons, _) = replaceTrailingItemsWithButtons(rowWidth: proposal.width ?? 0,
rows: collapsedRows,
collapseButton: subviewsByType.collapseButton,
addMoreButton: subviewsByType.addMoreButton)
let size = sizeThatFits(proposal: proposal, rows: collapsedRowsWithButtons)
return size
} else {
// Show all subviews with the button at the end
let rowsWithButton = calculateRows(proposal: proposal, subviews: Array(subviews))
let size = sizeThatFits(proposal: proposal, rows: rowsWithButton)
var rowsWithButtons = calculateRows(proposal: proposal, subviews: Array(subviews))
ensureCollapseAndAddMoreButtonsAreOnTheSameRow(&rowsWithButtons)
let size = sizeThatFits(proposal: proposal, rows: rowsWithButtons)
return size
}
} else {
// Otherwise we are just calculating the size of all items without the button
return sizeThatFits(proposal: proposal, rows: rowsNoButton)
return sizeThatFits(proposal: proposal, rows: reactionsAndAddMore)
}
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: some FlowLayoutSubviews, cache: inout ()) {
let collapseButton = subviews[subviews.count - 1]
var subviewsWithoutCollapseButton = subviews
subviewsWithoutCollapseButton.removeLast()
// Calculate the layout of the rows without the button
let rowsNoButton = calculateRows(proposal: ProposedViewSize(bounds.size), subviews: Array(subviewsWithoutCollapseButton))
guard let subviewsByType = getSubviewsByItemType(subviews: Array(subviews)), subviewsByType.reactions.count > 0 else {
subviews.forEach { subview in
subview.place(at: Self.pointOffscreen, anchor: .leading, proposal: .zero)
}
return
}
// Calculate the layout of the rows with the reactions button and add more button
let reactionsAndAddMore = calculateRows(proposal: ProposedViewSize(bounds.size), subviews: Array(subviewsByType.reactions + [subviewsByType.addMoreButton]))
// If we have extended beyond the defined number of rows we are showing the expand/collapse ui
if let rowsBeforeCollapsible, rowsNoButton.count > rowsBeforeCollapsible {
if let rowsBeforeCollapsible, reactionsAndAddMore.count > rowsBeforeCollapsible {
if collapsed {
// Truncate to `rowsBeforeCollapsible` number of rows and replace the item at the end of the last row with the button
let collapsedRows = Array(rowsNoButton.prefix(rowsBeforeCollapsible))
let (collapsedRowsWithButton, subviewsToHide) = replaceTrailingItemsWithButton(rowWidth: bounds.width, rows: collapsedRows, button: collapseButton)
let remainingSubviews = subviewsToHide + Array(rowsNoButton.suffix(rowsNoButton.count - rowsBeforeCollapsible)).joined()
placeSubviews(in: bounds, rows: collapsedRowsWithButton)
let collapsedRows = Array(reactionsAndAddMore.prefix(rowsBeforeCollapsible))
let (collapsedRowsWithButtons, subviewsToHide) = replaceTrailingItemsWithButtons(rowWidth: bounds.width,
rows: collapsedRows,
collapseButton: subviewsByType.collapseButton,
addMoreButton: subviewsByType.addMoreButton)
var remainingSubviews = subviewsToHide + Array(reactionsAndAddMore.suffix(reactionsAndAddMore.count - rowsBeforeCollapsible)).joined()
// remove the add button which was in initial rows calculation
remainingSubviews.removeLast()
placeSubviews(in: bounds, rows: collapsedRowsWithButtons)
// "Remove" (place with a proposed zero frame) any additional subviews
remainingSubviews.forEach { subview in
subview.place(at: Self.pointOffscreen, anchor: .leading, proposal: .zero)
}
} else {
// Show all subviews with the button at the end
let rowsWithButton = calculateRows(proposal: ProposedViewSize(bounds.size), subviews: Array(subviews))
placeSubviews(in: bounds, rows: rowsWithButton)
// Show all subviews with the buttons at the end
var rowsWithButtons = calculateRows(proposal: ProposedViewSize(bounds.size), subviews: Array(subviews))
ensureCollapseAndAddMoreButtonsAreOnTheSameRow(&rowsWithButtons)
placeSubviews(in: bounds, rows: rowsWithButtons)
}
} else {
// Otherwise we are just calculating the size of all items without the button
placeSubviews(in: bounds, rows: rowsNoButton)
// "Remove"(place with a proposed zero frame) the button
collapseButton.place(at: Self.pointOffscreen, anchor: .leading, proposal: .zero)
// Otherwise we are just placing the reactions and add button
placeSubviews(in: bounds, rows: reactionsAndAddMore)
// "Remove"(place with a proposed zero frame) the collapse button
subviewsByType.collapseButton.place(at: Self.pointOffscreen, anchor: .leading, proposal: .zero)
}
}
@@ -145,27 +161,40 @@ struct CollapsibleFlowLayout: Layout {
/// - rows: The input list of rows
/// - button: The button to replace the trailing items
/// - Returns: The new rows structure with button replaced and the subviews remove from the input to make space for the button
private func replaceTrailingItemsWithButton(rowWidth: CGFloat, rows: [[FlowLayoutSubview]], button: FlowLayoutSubview) -> ([[FlowLayoutSubview]], [FlowLayoutSubview]) {
private func replaceTrailingItemsWithButtons(rowWidth: CGFloat, rows: [[FlowLayoutSubview]], collapseButton: FlowLayoutSubview, addMoreButton: FlowLayoutSubview) -> ([[FlowLayoutSubview]], [FlowLayoutSubview]) {
var rows = rows
let lastRow = rows[rows.count - 1]
let buttonSize = button.sizeThatFits(.unspecified)
let collapseButtonSize = collapseButton.sizeThatFits(.unspecified)
let addMoreButtonSize = addMoreButton.sizeThatFits(.unspecified)
let buttonsWidth = collapseButtonSize.width + itemSpacing + addMoreButtonSize.width
var rowX: CGFloat = 0
for (i, subview) in lastRow.enumerated() {
let size = subview.sizeThatFits(.unspecified)
let horizontalSpacing = i == 0 ? 0 : itemSpacing
rowX += size.width + horizontalSpacing
if rowX > (rowWidth - (buttonSize.width + horizontalSpacing)) {
let lastRowWithButton = Array(lastRow.prefix(i)) + [button]
if rowX > (rowWidth - (buttonsWidth + horizontalSpacing)) {
let lastRowWithButton = Array(lastRow.prefix(i)) + [collapseButton, addMoreButton]
let subviewsToHide = Array(lastRow.suffix(lastRow.count - i))
rows[rows.count - 1] = lastRowWithButton
return (rows, subviewsToHide)
}
}
let lastRowWithButton = Array(lastRow) + [button]
let lastRowWithButton = Array(lastRow) + [collapseButton, addMoreButton]
rows[rows.count - 1] = lastRowWithButton
return (rows, [])
}
private func ensureCollapseAndAddMoreButtonsAreOnTheSameRow(_ rows: inout [[FlowLayoutSubview]]) {
guard var lastRow = rows.last, lastRow.count == 1 else {
return
}
var secondLastRow = rows[rows.count - 2]
let collapseButton = secondLastRow.removeLast()
lastRow.prepend(collapseButton)
rows[rows.count - 2] = secondLastRow
rows[rows.count - 1] = lastRow
}
/// Given a list of rows place them in the layout.
/// - Parameters:
/// - bounds: The bounds of the parent
@@ -173,20 +202,60 @@ struct CollapsibleFlowLayout: Layout {
private func placeSubviews(in bounds: CGRect, rows: [[FlowLayoutSubview]]) {
var rowY: CGFloat = bounds.minY
var rowHeight: CGFloat = 0
for (i, row) in rows.enumerated() {
let sizes = rows.map { row in
row.map { subview in
subview.sizeThatFits(.unspecified)
}
}
let maxHeight = sizes.joined().reduce(0) { partialResult, size in
max(partialResult, size.height)
}
for (i, row) in sizes.enumerated() {
var rowX: CGFloat = bounds.minX
let verticalSpacing = i == 0 ? 0 : rowSpacing
for (j, subview) in row.enumerated() {
let size = subview.sizeThatFits(.unspecified)
for (j, size) in row.enumerated() {
let subview = rows[i][j]
let horizontalSpacing = j == 0 ? 0 : itemSpacing
let point = CGPoint(x: rowX + horizontalSpacing, y: rowY + verticalSpacing + (size.height / 2))
subview.place(at: point, anchor: .leading, proposal: ProposedViewSize(size))
rowHeight = max(rowHeight, size.height)
let point = CGPoint(x: rowX + horizontalSpacing, y: rowY + verticalSpacing + (maxHeight / 2))
subview.place(at: point, anchor: .leading, proposal: ProposedViewSize(CGSize(width: size.width, height: maxHeight)))
rowHeight = max(rowHeight, maxHeight)
rowX += size.width + horizontalSpacing
}
rowY += rowHeight + verticalSpacing
}
}
/// Group the subviews by type using `ReactionLayoutItemType`
/// - Parameter subviews: A flat list of all the subviews
/// - Returns: The subviews organised by type
private func getSubviewsByItemType(subviews: [FlowLayoutSubview]) -> ReactionSubviews? {
var collapseButton: FlowLayoutSubview?
var addMoreButton: FlowLayoutSubview?
var reactions: [FlowLayoutSubview] = []
for subview in subviews {
switch subview[ReactionLayoutItemType.self] {
case .reaction:
reactions.append(subview)
case .expandCollapse:
collapseButton = subview
case .addMore:
addMoreButton = subview
}
}
guard let collapseButton, let addMoreButton, reactions.count > 0 else {
return nil
}
return ReactionSubviews(reactions: reactions, collapseButton: collapseButton, addMoreButton: addMoreButton)
}
}
struct ReactionSubviews {
var reactions: [FlowLayoutSubview]
var collapseButton: FlowLayoutSubview
var addMoreButton: FlowLayoutSubview
}
/// A protocol representing subviews so that we can inject mocks in unit tests.
@@ -198,6 +267,23 @@ extension LayoutSubviews: FlowLayoutSubviews { }
protocol FlowLayoutSubview {
func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize
func place(at position: CGPoint, anchor: UnitPoint, proposal: ProposedViewSize)
subscript<K>(key: K.Type) -> K.Value where K: LayoutValueKey { get }
}
extension LayoutSubview: FlowLayoutSubview { }
enum ReactionLayoutItem {
case reaction
case expandCollapse
case addMore
}
struct ReactionLayoutItemType: LayoutValueKey {
static let defaultValue: ReactionLayoutItem = .reaction
}
extension View {
func reactionLayoutItem(_ value: ReactionLayoutItem) -> some View {
layoutValue(key: ReactionLayoutItemType.self, value: value)
}
}
@@ -24,25 +24,36 @@ struct TimelineReactionsView: View {
private static let verticalSpacing: CGFloat = 4
@EnvironmentObject private var context: RoomScreenViewModel.Context
@Environment(\.layoutDirection) private var layoutDirection: LayoutDirection
@Namespace private var animation
let itemID: TimelineItemIdentifier
let reactions: [AggregatedReaction]
@Binding var collapsed: Bool
var body: some View {
CollapsibleFlowLayout(itemSpacing: 4, rowSpacing: 4, collapsed: collapsed, rowsBeforeCollapsible: 2) {
CollapsibleReactionLayout(itemSpacing: 4, rowSpacing: 4, collapsed: collapsed, rowsBeforeCollapsible: 2) {
ForEach(reactions, id: \.self) { reaction in
TimelineReactionButton(itemID: itemID, reaction: reaction) { key in
TimelineReactionButton(reaction: reaction) { key in
context.send(viewAction: .toggleReaction(key: key, itemID: itemID))
} showReactionSummary: { key in
context.send(viewAction: .reactionSummary(itemID: itemID, key: key))
}
.reactionLayoutItem(.reaction)
}
Button {
collapsed.toggle()
} label: {
TimelineCollapseButtonLabel(collapsed: collapsed)
}
.reactionLayoutItem(.expandCollapse)
.animation(.easeOut, value: collapsed)
Button {
context.send(viewAction: .displayEmojiPicker(itemID: itemID))
} label: {
TimelineReactionAddMoreButtonLabel()
}
.animation(.easeOut, value: collapsed)
.reactionLayoutItem(.addMore)
}
.coordinateSpace(name: Self.flowCoordinateSpace)
}
@@ -54,15 +65,11 @@ struct TimelineReactionButtonLabel<Content: View>: View {
@ViewBuilder var content: () -> Content
var body: some View {
HStack(spacing: 4) {
content()
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.background(backgroundShape.inset(by: 1).fill(overlayBackgroundColor))
.overlay(backgroundShape.inset(by: 2.0).strokeBorder(overlayBorderColor))
.overlay(backgroundShape.strokeBorder(Color.compound.bgCanvasDefault, lineWidth: 2))
.accessibilityElement(children: .combine)
content()
.background(backgroundShape.inset(by: 1).fill(overlayBackgroundColor))
.overlay(backgroundShape.inset(by: 2.0).strokeBorder(overlayBorderColor))
.overlay(backgroundShape.strokeBorder(Color.compound.bgCanvasDefault, lineWidth: 2))
.accessibilityElement(children: .combine)
}
var backgroundShape: some InsettableShape {
@@ -84,6 +91,8 @@ struct TimelineCollapseButtonLabel: View {
var body: some View {
TimelineReactionButtonLabel {
Text(collapsed ? L10n.screenRoomReactionsShowMore : L10n.screenRoomReactionsShowLess)
.padding(.vertical, 6)
.padding(.horizontal, 8)
.layoutPriority(1)
.drawingGroup()
.font(.compound.bodyMD)
@@ -93,7 +102,6 @@ struct TimelineCollapseButtonLabel: View {
}
struct TimelineReactionButton: View {
let itemID: TimelineItemIdentifier
let reaction: AggregatedReaction
let toggleReaction: (String) -> Void
let showReactionSummary: (String) -> Void
@@ -110,13 +118,17 @@ struct TimelineReactionButton: View {
var label: some View {
TimelineReactionButtonLabel(isHighlighted: reaction.isHighlighted) {
Text(reaction.key)
.font(.compound.bodyMD)
if reaction.count > 1 {
Text(String(reaction.count))
HStack(spacing: 4) {
Text(reaction.key)
.font(.compound.bodyMD)
.foregroundColor(textColor)
if reaction.count > 1 {
Text(String(reaction.count))
.font(.compound.bodyMD)
.foregroundColor(textColor)
}
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
}
}
@@ -125,6 +137,22 @@ struct TimelineReactionButton: View {
}
}
struct TimelineReactionAddMoreButtonLabel: View {
@ScaledMetric private var addMoreButtonIconSize = 16
var body: some View {
TimelineReactionButtonLabel {
Image(asset: Asset.Images.timelineReactionAddMore)
.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)
}
}
}
struct TimelineReactionViewPreviewsContainer: View {
@State private var collapseState1 = false
@State private var collapseState2 = true