mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
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:
@@ -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 { }
|
||||
Reference in New Issue
Block a user