Add expand/collapse UI for reactions (#1249)

* 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.

* Remove Flow and simplify implementation

- Remove SwiftUI-Flow
- Add strings by importing from Localyse
- Remove count on expand button as requires GeometryReader and can cause loops
- Don't use GeometryReader for hiding reactions with opacity(just put them way off screen for now)
- Fix unit and UI tests

* Address PR comments

- use synthesized inits
- use rows rather than lines for naming flow layout
- other naming improvements
- reactions were already rendered in another ui test, removing my test on favour of those and updating the screenshots for those.
This commit is contained in:
David Langley
2023-07-10 16:13:58 +01:00
committed by GitHub
parent c453cc0680
commit 2cec1858ff
29 changed files with 610 additions and 143 deletions
@@ -50,7 +50,15 @@ extension AggregatedReaction {
AggregatedReaction(accountOwnerID: alice, key: "🚀", senders: [alice] + mockIds(3)),
AggregatedReaction(accountOwnerID: alice, key: "😇", senders: mockIds(2)),
AggregatedReaction(accountOwnerID: alice, key: "🤭", senders: [alice] + mockIds(8)),
AggregatedReaction(accountOwnerID: alice, key: "🫤", senders: mockIds(10))
AggregatedReaction(accountOwnerID: alice, key: "🫤", senders: mockIds(10)),
AggregatedReaction(accountOwnerID: alice, key: "🐶", senders: mockIds(1)),
AggregatedReaction(accountOwnerID: alice, key: "🐱", senders: mockIds(1)),
AggregatedReaction(accountOwnerID: alice, key: "🐭", senders: mockIds(1)),
AggregatedReaction(accountOwnerID: alice, key: "🐹", senders: mockIds(1)),
AggregatedReaction(accountOwnerID: alice, key: "🐰", senders: mockIds(1)),
AggregatedReaction(accountOwnerID: alice, key: "🦊", senders: mockIds(1)),
AggregatedReaction(accountOwnerID: alice, key: "🐻", senders: mockIds(1)),
AggregatedReaction(accountOwnerID: alice, key: "🐼", senders: mockIds(1))
]
}
}
@@ -0,0 +1,203 @@
//
// 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 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 {
static let pointOffscreen = CGPoint(x: -10000, y: -10000)
/// The horizontal spacing between items
var itemSpacing: CGFloat = 0
/// The vertical spacing between rows
var rowSpacing: CGFloat = 0
/// Whether the layout should display in expanded or collapsed state
var collapsed = true
/// The number of rows before the collapse/expand button is shown
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))
// If we have extended beyond the defined number of rows we are showing the expand/collapse ui
if let rowsBeforeCollapsible, rowsNoButton.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)
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)
return size
}
} else {
// Otherwise we are just calculating the size of all items without the button
return sizeThatFits(proposal: proposal, rows: rowsNoButton)
}
}
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))
// If we have extended beyond the defined number of rows we are showing the expand/collapse ui
if let rowsBeforeCollapsible, rowsNoButton.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)
// "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)
}
} 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)
}
}
/// Given a proposed size and a flat list of subviews, calculates and returns a structure representing
/// how the subviews should wrap on to multiple rows given the size's width.
/// - Parameters:
/// - proposal: The proposed size
/// - subviews: The subviews
/// - Returns: A 2d array, the first dimension representing the rows, the second being the items per row.
private func calculateRows(proposal: ProposedViewSize, subviews: [FlowLayoutSubview]) -> [[FlowLayoutSubview]] {
var rows = [[FlowLayoutSubview]]()
var currentRow = [FlowLayoutSubview]()
var rowX: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
let horizontalSpacing = currentRow.isEmpty ? 0 : itemSpacing
// If the current view does not fine on this row bump to the next
if rowX + size.width > proposal.width ?? 0 {
rows.append(currentRow)
currentRow = [LayoutSubview]()
rowX = 0
}
rowX += horizontalSpacing + size.width
currentRow.append(subview)
}
// If there are items in the current row remember to append it to the returned value
if currentRow.count > 0 {
rows.append(currentRow)
}
return rows
}
/// Given a list of rows calculate the size needed to display them
/// - Parameters:
/// - proposal: The proposed size
/// - rows: The list of rows
/// - Returns: The size render the rows
private func sizeThatFits(proposal: ProposedViewSize, rows: [[FlowLayoutSubview]]) -> CGSize {
rows.enumerated().reduce(CGSize.zero) { partialResult, rowItem in
let (rowIndex, row) = rowItem
let rowSize = row.enumerated().reduce(CGSize.zero) { partialResult, subviewItem in
let (subviewIndex, subview) = subviewItem
let size = subview.sizeThatFits(.unspecified)
let horizontalSpacing = subviewIndex == 0 ? 0 : itemSpacing
return CGSize(width: partialResult.width + size.width + horizontalSpacing, height: max(partialResult.height, size.height))
}
let verticalSpacing = rowIndex == 0 ? 0 : rowSpacing
return CGSize(width: max(partialResult.width, rowSize.width), height: partialResult.height + rowSize.height + verticalSpacing)
}
}
/// Used to render the collapsed state, this takes the rows inputted and adds the button to the last row,
/// removing only as many trailing subviews as needed to make space for it. It also returns the items removed.
/// - Parameters:
/// - rowWidth: The width of the parent
/// - 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]) {
var rows = rows
let lastRow = rows[rows.count - 1]
let buttonSize = button.sizeThatFits(.unspecified)
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]
let subviewsToHide = Array(lastRow.suffix(lastRow.count - i))
rows[rows.count - 1] = lastRowWithButton
return (rows, subviewsToHide)
}
}
let lastRowWithButton = Array(lastRow) + [button]
rows[rows.count - 1] = lastRowWithButton
return (rows, [])
}
/// Given a list of rows place them in the layout.
/// - Parameters:
/// - bounds: The bounds of the parent
/// - rows: The input row structure.
private func placeSubviews(in bounds: CGRect, rows: [[FlowLayoutSubview]]) {
var rowY: CGFloat = bounds.minY
var rowHeight: CGFloat = 0
for (i, row) in rows.enumerated() {
var rowX: CGFloat = bounds.minX
let verticalSpacing = i == 0 ? 0 : rowSpacing
for (j, subview) in row.enumerated() {
let size = subview.sizeThatFits(.unspecified)
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)
rowX += size.width + horizontalSpacing
}
rowY += rowHeight + verticalSpacing
}
}
}
/// A protocol representing subviews so that we can inject mocks in unit tests.
protocol FlowLayoutSubviews: RandomAccessCollection where Element: FlowLayoutSubview, Index == Int, SubSequence == Self { }
extension LayoutSubviews: FlowLayoutSubviews { }
/// A protocol representing a subview so that we can inject mocks in unit tests.
protocol FlowLayoutSubview {
func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize
func place(at position: CGPoint, anchor: UnitPoint, proposal: ProposedViewSize)
}
extension LayoutSubview: FlowLayoutSubview { }
@@ -28,12 +28,13 @@ import SwiftUI
/// ```
struct ViewFrameReader: View {
@Binding var frame: CGRect
let coordinateSpace: CoordinateSpace = .local
var body: some View {
GeometryReader { geometry in
Color.clear
.preference(key: FramePreferenceKey.self,
value: geometry.frame(in: .local))
value: geometry.frame(in: coordinateSpace))
}
.onPreferenceChange(FramePreferenceKey.self) { newValue in
guard frame != newValue else { return }
@@ -127,6 +127,10 @@ struct RoomScreenViewStateBindings {
}
}
/// The state of wether reactions listed on the timeline are expanded/collapsed.
/// Key is itemID, value is the collapsed state.
var reactionsCollapsed: [String: Bool]
/// A media item that will be previewed with QuickLook.
var mediaPreviewItem: MediaPreviewItem?
@@ -52,7 +52,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
timelineStyle: appSettings.timelineStyle,
readReceiptsEnabled: appSettings.readReceiptsEnabled,
isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom,
bindings: .init(composerText: "", composerFocused: false)),
bindings: .init(composerText: "", composerFocused: false, reactionsCollapsed: [:])),
imageProvider: mediaProvider)
setupSubscriptions()
@@ -656,6 +656,19 @@ private extension RoomProxyProtocol {
}
}
extension RoomScreenViewModel.Context {
/// A function to make it easier to bind to reactions expand/collapsed state
/// - Parameter itemID: The id of the timeline item the reacted to
/// - Returns: Wether the reactions should show in the collapsed state, true by default.
func reactionsCollapsedBinding(for itemID: String) -> Binding<Bool> {
Binding(get: {
self.reactionsCollapsed[itemID] ?? true
}, set: {
self.reactionsCollapsed[itemID] = $0
})
}
}
// MARK: - Mocks
extension RoomScreenViewModel {
@@ -109,14 +109,12 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
.accessibilityElement(children: .combine)
if !timelineItem.properties.reactions.isEmpty {
TimelineReactionsView(reactions: timelineItem.properties.reactions) { key in
context.send(viewAction: .toggleReaction(key: key, eventID: timelineItem.id))
} showReactionSummary: { key in
context.send(viewAction: .reactionSummary(itemID: timelineItem.id, key: key))
}
.environment(\.layoutDirection, reactionsLayoutDirection)
// Workaround to stop the message long press stealing the touch from the reaction buttons
.onTapGesture { }
TimelineReactionsView(itemID: timelineItem.id,
reactions: timelineItem.properties.reactions,
collapsed: context.reactionsCollapsedBinding(for: timelineItem.id))
.environment(\.layoutDirection, reactionsLayoutDirection)
// Workaround to stop the message long press stealing the touch from the reaction buttons
.onTapGesture { }
}
}
}
@@ -117,13 +117,11 @@ struct TimelineItemPlainStylerView<Content: View>: View {
}
if !timelineItem.properties.reactions.isEmpty {
TimelineReactionsView(reactions: timelineItem.properties.reactions) { key in
context.send(viewAction: .toggleReaction(key: key, eventID: timelineItem.id))
} showReactionSummary: { key in
context.send(viewAction: .reactionSummary(itemID: timelineItem.id, key: key))
}
// Workaround to stop the message long press stealing the touch from the reaction buttons
.onTapGesture { }
TimelineReactionsView(itemID: timelineItem.id,
reactions: timelineItem.properties.reactions,
collapsed: context.reactionsCollapsedBinding(for: timelineItem.id))
// Workaround to stop the message long press stealing the touch from the reaction buttons
.onTapGesture { }
}
}
}
@@ -14,34 +14,90 @@
// limitations under the License.
//
import Flow
import SwiftUI
struct TimelineReactionsView: View {
@Environment(\.layoutDirection) var layoutDirection: LayoutDirection
/// We use a coordinate space for measuring the reactions within their container.
/// For some reason when using .local the origin of reactions always shown as (0, 0)
private static let flowCoordinateSpace = "flowCoordinateSpace"
private static let horizontalSpacing: CGFloat = 4
private static let verticalSpacing: CGFloat = 4
@EnvironmentObject private var context: RoomScreenViewModel.Context
@Environment(\.layoutDirection) private var layoutDirection: LayoutDirection
let itemID: String
let reactions: [AggregatedReaction]
let toggleReaction: (String) -> Void
let showReactionSummary: (String) -> Void
@Binding var collapsed: Bool
var body: some View {
HFlow(itemSpacing: 4, rowSpacing: 4) {
CollapsibleFlowLayout(itemSpacing: 4, rowSpacing: 4, collapsed: collapsed, rowsBeforeCollapsible: 2) {
ForEach(reactions, id: \.self) { reaction in
TimelineReactionButton(reaction: reaction,
toggleReaction: toggleReaction,
showReactionSummary: showReactionSummary)
TimelineReactionButton(itemID: itemID, reaction: reaction) { key in
context.send(viewAction: .toggleReaction(key: key, eventID: itemID))
} showReactionSummary: { key in
context.send(viewAction: .reactionSummary(itemID: itemID, key: key))
}
}
Button {
collapsed.toggle()
} label: {
TimelineCollapseButtonLabel(collapsed: collapsed)
}
}
.environment(\.layoutDirection, layoutDirection)
.coordinateSpace(name: Self.flowCoordinateSpace)
}
}
/// The pill shape for the label that surrounds both the reaction and collapse buttons.
struct TimelineReactionButtonLabel<Content: View>: View {
var isHighlighted = false
@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)
}
var backgroundShape: some InsettableShape {
RoundedRectangle(cornerRadius: 14, style: .continuous)
}
var overlayBackgroundColor: Color {
isHighlighted ? Color.compound.bgSubtlePrimary : .compound.bgSubtleSecondary
}
var overlayBorderColor: Color {
isHighlighted ? Color.compound.borderInteractivePrimary : .clear
}
}
struct TimelineCollapseButtonLabel: View {
var collapsed: Bool
var body: some View {
TimelineReactionButtonLabel {
Text(collapsed ? L10n.screenRoomReactionsShowMore : L10n.screenRoomReactionsShowLess)
.layoutPriority(1)
.drawingGroup()
.font(.compound.bodyMD)
.foregroundColor(.compound.textPrimary)
}
}
}
struct TimelineReactionButton: View {
let itemID: String
let reaction: AggregatedReaction
let toggleReaction: (String) -> Void
let showReactionSummary: (String) -> Void
@State private var didLongPress = false
var body: some View {
label
.onTapGesture {
@@ -53,7 +109,7 @@ struct TimelineReactionButton: View {
}
var label: some View {
HStack(spacing: 4) {
TimelineReactionButtonLabel(isHighlighted: reaction.isHighlighted) {
Text(reaction.key)
.font(.compound.bodyMD)
if reaction.count > 1 {
@@ -62,41 +118,33 @@ struct TimelineReactionButton: View {
.foregroundColor(textColor)
}
}
.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)
}
var backgroundShape: some InsettableShape {
RoundedRectangle(cornerRadius: 12, style: .continuous)
}
var textColor: Color {
reaction.isHighlighted ? Color.compound.textPrimary : .compound.textSecondary
}
var overlayBackgroundColor: Color {
reaction.isHighlighted ? Color.compound.bgSubtlePrimary : .compound.bgSubtleSecondary
}
var overlayBorderColor: Color {
reaction.isHighlighted ? Color.compound.borderInteractivePrimary : .clear
}
struct TimelineReactionViewPreviewsContainer: View {
@State private var collapseState1 = false
@State private var collapseState2 = true
var body: some View {
VStack {
TimelineReactionsView(itemID: "1", reactions: Array(AggregatedReaction.mockReactions.prefix(3)), collapsed: .constant(true))
Divider()
TimelineReactionsView(itemID: "2", reactions: AggregatedReaction.mockReactions, collapsed: $collapseState1)
Divider()
TimelineReactionsView(itemID: "3", reactions: AggregatedReaction.mockReactions, collapsed: $collapseState2)
.environment(\.layoutDirection, .rightToLeft)
}
.background(Color.red)
.frame(maxWidth: 250, alignment: .leading)
}
}
struct TimelineReactionView_Previews: PreviewProvider {
static var previews: some View {
VStack {
TimelineReactionButton(reaction: AggregatedReaction.mockThumbsUpHighlighted) { _ in } showReactionSummary: { _ in }
TimelineReactionButton(reaction: AggregatedReaction.mockClap) { _ in } showReactionSummary: { _ in }
TimelineReactionButton(reaction: AggregatedReaction.mockParty) { _ in } showReactionSummary: { _ in }
TimelineReactionsView(reactions: AggregatedReaction.mockReactions) { _ in } showReactionSummary: { _ in }
.environment(\.layoutDirection, .leftToRight)
TimelineReactionsView(reactions: AggregatedReaction.mockReactions) { _ in } showReactionSummary: { _ in }
.environment(\.layoutDirection, .rightToLeft)
}
TimelineReactionViewPreviewsContainer()
}
}
@@ -66,21 +66,11 @@ enum RoomTimelineItemFixtures {
isEditable: true,
sender: .init(id: "", displayName: "Bob"),
content: .init(body: "New home office set up!"),
properties: RoomTimelineItemProperties(reactions: [
AggregatedReaction(accountOwnerID: "me", key: "🙏", senders: ["helena"]),
AggregatedReaction(accountOwnerID: "me", key: "🙌", senders: ["me", "helena", "jacob", "bob", "alice", "charlie", "dan", "erin"]),
AggregatedReaction(accountOwnerID: "me", key: "👌", senders: ["helena"]),
AggregatedReaction(accountOwnerID: "me", key: "🥳", senders: ["bob"]),
AggregatedReaction(accountOwnerID: "me", key: "🍾", senders: ["charlie"]),
AggregatedReaction(accountOwnerID: "me", key: "🎈", senders: ["helena"]),
AggregatedReaction(accountOwnerID: "me", key: "👏", senders: ["helena"]),
AggregatedReaction(accountOwnerID: "me", key: "🎉", senders: ["helena"])
],
orderedReadReceipts: [ReadReceipt(userID: "alice", formattedTimestamp: nil),
ReadReceipt(userID: "bob", formattedTimestamp: nil),
ReadReceipt(userID: "charlie", formattedTimestamp: nil),
ReadReceipt(userID: "dan", formattedTimestamp: nil)])),
properties: RoomTimelineItemProperties(reactions: AggregatedReaction.mockReactions,
orderedReadReceipts: [ReadReceipt(userID: "alice", formattedTimestamp: nil),
ReadReceipt(userID: "bob", formattedTimestamp: nil),
ReadReceipt(userID: "charlie", formattedTimestamp: nil),
ReadReceipt(userID: "dan", formattedTimestamp: nil)])),
TextRoomTimelineItem(id: UUID().uuidString,
timestamp: "5 PM",
isOutgoing: false,
@@ -323,8 +323,12 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func aggregateReactions(_ reactions: [Reaction]) -> [AggregatedReaction] {
reactions.map { reaction in
AggregatedReaction(accountOwnerID: userID, key: reaction.key, senders: reaction.senders)
}.sorted { a, b in
a.count > b.count
}
.sorted { a, b in
// Sort by count and then by key for a consistence experience.
// Otherwise emojis can switch around. We can replace
// with timestamp as a secondary sort when it is available.
(a.count, a.key) > (b.count, b.key)
}
}