diff --git a/Sources/Flow/Example/ContentView.swift b/Sources/Flow/Example/ContentView.swift index 996c836d..08813dc3 100644 --- a/Sources/Flow/Example/ContentView.swift +++ b/Sources/Flow/Example/ContentView.swift @@ -1,6 +1,6 @@ import SwiftUI -@available(macOS 14.0, *) +@available(macOS 13.0, *) struct ContentView: View { @State private var axis: Axis = .horizontal @State private var contents: Contents = .boxes @@ -11,6 +11,7 @@ struct ContentView: View { @State private var justified: Justified = .none @State private var horizontalAlignment: HAlignment = .center @State private var verticalAlignment: VAlignment = .center + @State private var distibuteItemsEvenly: Bool = false private let texts = "This is a long text that wraps nicely in flow layout".components(separatedBy: " ").map { string in AnyView(Text(string)) } @@ -68,22 +69,21 @@ struct ContentView: View { } Section(header: Text("Alignment")) { switch axis { - case .horizontal: - picker($verticalAlignment) - case .vertical: - picker($horizontalAlignment) + case .horizontal: picker($verticalAlignment) + case .vertical: picker($horizontalAlignment) } } Section(header: Text("Spacing")) { stepper("Item", $itemSpacing) stepper("Line", $lineSpacing) } - Section(header: Text("Justification")) { - picker($justified) + Section(header: Text("Extras")) { + picker($justified, style: .radioGroup) + Toggle("Distibute evenly", isOn: $distibuteItemsEvenly.animation()) } } .listStyle(.sidebar) - .frame(minWidth: 280) + .frame(minWidth: 250) .navigationTitle("Flow Layout") .padding() } detail: { @@ -98,32 +98,34 @@ struct ContentView: View { .frame(maxWidth: width, maxHeight: height) .border(.red) } - .frame(minWidth: 600, minHeight: 500) + .frame(minWidth: 600, minHeight: 600) } private func stepper(_ title: String, _ selection: Binding) -> some View { HStack { - Toggle(isOn: Binding(get: { selection.wrappedValue != nil }, - set: { selection.wrappedValue = $0 ? 8 : nil }).animation()) { + Toggle(isOn: Binding( + get: { selection.wrappedValue != nil }, + set: { selection.wrappedValue = $0 ? 8 : nil }).animation() + ) { Text(title) } if let value = selection.wrappedValue { Text("\(value.formatted())") - Stepper("", value: Binding(get: { value }, - set: { selection.wrappedValue = $0 }).animation(), step: 4) + Stepper("", value: Binding( + get: { value }, + set: { selection.wrappedValue = $0 } + ).animation(), step: 4) } }.fixedSize() } - private func picker(_ selection: Binding) -> some View where Value: Hashable & CaseIterable & CustomStringConvertible, Value.AllCases: RandomAccessCollection { + private func picker(_ selection: Binding, style: some PickerStyle = .segmented) -> some View where Value: Hashable & CaseIterable & CustomStringConvertible, Value.AllCases: RandomAccessCollection { Picker("", selection: selection.animation()) { ForEach(Value.allCases, id: \.self) { value in Text(value.description).tag(value) } } - #if !os(watchOS) - .pickerStyle(.segmented) - #endif + .pickerStyle(style) } private var layout: AnyLayout { @@ -134,7 +136,8 @@ struct ContentView: View { alignment: verticalAlignment.value, itemSpacing: itemSpacing, rowSpacing: lineSpacing, - justification: justified.justification + justification: justified.justification, + distibuteItemsEvenly: distibuteItemsEvenly ) ) case .vertical: @@ -143,7 +146,8 @@ struct ContentView: View { alignment: horizontalAlignment.value, itemSpacing: itemSpacing, columnSpacing: lineSpacing, - justification: justified.justification + justification: justified.justification, + distibuteItemsEvenly: distibuteItemsEvenly ) ) } @@ -157,25 +161,26 @@ enum Contents: String, CustomStringConvertible, CaseIterable { var description: String { rawValue } } +@available(macOS 13.0, *) enum Justified: String, CustomStringConvertible, CaseIterable { - case none - case stretchItems - case stretchSpaces + case none = "no justification" + case stretchItems = "stretch items" + case stretchSpaces = "stretch spaces" + case stretchItemsAndSpaces = "stretch both" var description: String { rawValue } var justification: Justification? { switch self { - case .none: nil - case .stretchItems: .stretchItems - case .stretchSpaces: .stretchSpaces + case .none: nil + case .stretchItems: .stretchItems + case .stretchSpaces: .stretchSpaces + case .stretchItemsAndSpaces: .stretchItemsAndSpaces } } } -@available(macOS 14.0, *) -struct SwiftUIView_Previews: PreviewProvider { - static var previews: some View { - ContentView() - } +@available(macOS 13.0, *) +#Preview { + ContentView() } diff --git a/Sources/Flow/HFlow.swift b/Sources/Flow/HFlow.swift index 96f090db..cca55758 100644 --- a/Sources/Flow/HFlow.swift +++ b/Sources/Flow/HFlow.swift @@ -40,6 +40,7 @@ public struct HFlow: View { itemSpacing: CGFloat? = nil, rowSpacing: CGFloat? = nil, justification: Justification? = nil, + distibuteItemsEvenly: Bool = false, @ViewBuilder content contentBuilder: () -> Content ) { content = contentBuilder() @@ -47,7 +48,8 @@ public struct HFlow: View { alignment: alignment, itemSpacing: itemSpacing, rowSpacing: rowSpacing, - justification: justification + justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly ) } @@ -65,6 +67,7 @@ public struct HFlow: View { alignment: VerticalAlignment = .center, spacing: CGFloat? = nil, justification: Justification? = nil, + distibuteItemsEvenly: Bool = false, @ViewBuilder content contentBuilder: () -> Content ) { self.init( @@ -72,6 +75,7 @@ public struct HFlow: View { itemSpacing: spacing, rowSpacing: spacing, justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly, content: contentBuilder ) } @@ -105,13 +109,15 @@ extension HFlow: Layout where Content == EmptyView { alignment: VerticalAlignment = .center, itemSpacing: CGFloat? = nil, rowSpacing: CGFloat? = nil, - justification: Justification? = nil + justification: Justification? = nil, + distibuteItemsEvenly: Bool = false ) { self.init( alignment: alignment, itemSpacing: itemSpacing, rowSpacing: rowSpacing, - justification: justification + justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly ) { EmptyView() } @@ -136,7 +142,7 @@ extension HFlow: Layout where Content == EmptyView { } } - public func sizeThatFits(proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout ()) -> CGSize { + public func sizeThatFits(proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout FlowLayoutCache) -> CGSize { layout.sizeThatFits( proposal: proposal, subviews: subviews, @@ -144,7 +150,7 @@ extension HFlow: Layout where Content == EmptyView { ) } - public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout ()) { + public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout FlowLayoutCache) { layout.placeSubviews( in: bounds, proposal: proposal, @@ -153,6 +159,10 @@ extension HFlow: Layout where Content == EmptyView { ) } + public func makeCache(subviews: LayoutSubviews) -> FlowLayoutCache { + FlowLayoutCache(subviews, axis: .horizontal) + } + public static var layoutProperties: LayoutProperties { HFlowLayout.layoutProperties } diff --git a/Sources/Flow/Internal/HFlowLayout.swift b/Sources/Flow/Internal/HFlowLayout.swift index 07401668..02109cf7 100644 --- a/Sources/Flow/Internal/HFlowLayout.swift +++ b/Sources/Flow/Internal/HFlowLayout.swift @@ -8,27 +8,33 @@ public struct HFlowLayout { alignment: VerticalAlignment = .center, itemSpacing: CGFloat? = nil, rowSpacing: CGFloat? = nil, - justification: Justification? = nil + justification: Justification? = nil, + distibuteItemsEvenly: Bool = false ) { layout = .horizontal( alignment: alignment, itemSpacing: itemSpacing, lineSpacing: rowSpacing, - justification: justification + justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly ) } } @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) extension HFlowLayout: Layout { - public func sizeThatFits(proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout ()) -> CGSize { + public func sizeThatFits(proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout FlowLayoutCache) -> CGSize { layout.sizeThatFits(proposal: proposal, subviews: subviews, cache: &cache) } - public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout ()) { + public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout FlowLayoutCache) { layout.placeSubviews(in: bounds, proposal: proposal, subviews: subviews, cache: &cache) } + public func makeCache(subviews: LayoutSubviews) -> FlowLayoutCache { + FlowLayoutCache(subviews, axis: .horizontal) + } + public static var layoutProperties: LayoutProperties { var properties = LayoutProperties() properties.stackOrientation = .horizontal diff --git a/Sources/Flow/Internal/Layout.swift b/Sources/Flow/Internal/Layout.swift index b3f23a28..758a9d6e 100644 --- a/Sources/Flow/Internal/Layout.swift +++ b/Sources/Flow/Internal/Layout.swift @@ -7,50 +7,35 @@ struct FlowLayout { var itemSpacing: CGFloat? var lineSpacing: CGFloat? var reversedBreadth: Bool = false + var alternatingReversedBreadth: Bool = false var reversedDepth: Bool = false var justification: Justification? + var distibuteItemsEvenly: Bool let align: (Dimensions) -> CGFloat private struct ItemWithSpacing { var item: T var size: Size - var spacing: CGFloat + var spacing: CGFloat = 0 - private init( - _ item: T, - size: Size, - spacing: CGFloat = 0 - ) { - self.item = item - self.size = size - self.spacing = spacing - } - - init(_ item: some Subview, size: Size) where T == [ItemWithSpacing] { - self.init([.init(item, size: size)], size: size) - } - - mutating func append( - _ item: some Subview, - size: Size, - spacing: CGFloat - ) - where T == [ItemWithSpacing] { - self.item.append(.init(item, size: size, spacing: spacing)) + mutating func append(_ item: Item, size: Size, spacing: CGFloat) where Self == ItemWithSpacing { + self.item.append(.init(item: item, size: size, spacing: spacing)) self.size = Size(breadth: self.size.breadth + spacing + size.breadth, depth: max(self.size.depth, size.depth)) } } - private typealias Line = [ItemWithSpacing] + private typealias Item = (subview: Subview, cache: FlowLayoutCache.SubviewCache) + private typealias Line = [ItemWithSpacing] private typealias Lines = [ItemWithSpacing] func sizeThatFits( proposal proposedSize: ProposedViewSize, - subviews: some Subviews + subviews: some Subviews, + cache: inout FlowLayoutCache ) -> CGSize { guard !subviews.isEmpty else { return .zero } - let lines = calculateLayout(in: proposedSize, of: subviews) + let lines = calculateLayout(in: proposedSize, of: subviews, cache: cache) let spacings = lines.map(\.spacing).reduce(into: 0, +=) let size = lines .map(\.size) @@ -62,57 +47,55 @@ struct FlowLayout { func placeSubviews( in bounds: CGRect, proposal: ProposedViewSize, - subviews: some Subviews + subviews: some Subviews, + cache: inout FlowLayoutCache ) { guard !subviews.isEmpty else { return } - var bounds = bounds - if reversedBreadth { - bounds.reverseOrigin(along: axis) - } - if reversedDepth { - bounds.reverseOrigin(along: axis.perpendicular) - } + var reversedBreadth = self.reversedBreadth var target = bounds.origin.size(on: axis) - let originalBreadth = target.breadth - let lines = calculateLayout(in: proposal, of: subviews) - for var line in lines { - if let justification { - let remainingSpace = bounds.size.value(on: axis) - line.size.breadth - switch justification { - case .stretchItems: - let distributedSpace = remainingSpace / CGFloat(line.item.count) - for index in line.item.indices { - line.item[index].size.breadth += distributedSpace - } - case .stretchSpaces: - let distributedSpace = remainingSpace / CGFloat(line.item.count - 1) - for index in line.item.indices.dropFirst() { - line.item[index].spacing += distributedSpace - } - } - } + let lines = calculateLayout(in: proposal, of: subviews, cache: cache) + for line in lines { if reversedDepth { - target.depth -= line.size.depth + target.depth -= line.size.depth + line.spacing + } else { + target.depth += line.spacing + } + if reversedBreadth { + target.breadth = switch axis { + case .horizontal: bounds.maxX + case .vertical: bounds.maxY + } + } else { + target.breadth = switch axis { + case .horizontal: bounds.minX + case .vertical: bounds.minY + } } - target.depth += line.spacing for item in line.item { if reversedBreadth { - target.breadth -= item.size.breadth + target.breadth -= item.size.breadth + item.spacing + } else { + target.breadth += item.spacing } - target.breadth += item.spacing alignAndPlace(item, in: line, at: target) if !reversedBreadth { target.breadth += item.size.breadth } } + if alternatingReversedBreadth { + reversedBreadth.toggle() + } if !reversedDepth { target.depth += line.size.depth } - target.breadth = originalBreadth } } + func makeCache(_ subviews: some Subviews) -> FlowLayoutCache { + FlowLayoutCache(subviews, axis: axis) + } + private func alignAndPlace( _ item: Line.Element, in line: Lines.Element, @@ -122,46 +105,119 @@ struct FlowLayout { let proposedSize = ProposedViewSize(size: Size(breadth: item.size.breadth, depth: line.size.depth), axis: axis) let depth = item.size.depth if depth > 0 { - placement.depth += (align(item.item.dimensions(proposedSize)) / depth) * (line.size.depth - depth) + placement.depth += (align(item.item.subview.dimensions(proposedSize)) / depth) * (line.size.depth - depth) } - item.item.place(at: .init(size: placement, axis: axis), anchor: .topLeading, proposal: proposedSize) + item.item.subview.place(at: .init(size: placement, axis: axis), anchor: .topLeading, proposal: proposedSize) } private func calculateLayout( in proposedSize: ProposedViewSize, - of subviews: some Subviews + of subviews: some Subviews, + cache: FlowLayoutCache ) -> Lines { var lines: Lines = [] let proposedBreadth = proposedSize.replacingUnspecifiedDimensions().value(on: axis) - for (index, subview) in subviews.enumerated() { - var size = subview.sizeThatFits(proposedSize).size(on: axis) - if case .stretchItems = justification { - let ideal = subview.dimensions(.unspecified).size(on: axis).breadth - let max = subview.dimensions(.infinity).size(on: axis).breadth - let isFlexible = max - ideal > 0 - if isFlexible { - size.breadth = ideal - } + + let sizes = cache.subviewsCache.map(\.ideal) + let spacings = if let itemSpacing { + [0] + Array(repeating: itemSpacing, count: subviews.count - 1) + } else { + [0] + zip(cache.subviewsCache, cache.subviewsCache.dropFirst()).map { lhs, rhs in + lhs.spacing.distance(to: rhs.spacing, along: axis) } + } + + for index in subviews.indices { + let (subview, size, spacing, cache) = (subviews[index], sizes[index], spacings[index], cache.subviewsCache[index]) if let lastIndex = lines.indices.last { - let spacing = self.itemSpacing(toPrevious: index, subviews: subviews) let additionalBreadth = spacing + size.breadth if lines[lastIndex].size.breadth + additionalBreadth <= proposedBreadth { - lines[lastIndex].append(subview, size: size, spacing: spacing) + lines[lastIndex].append((subview, cache), size: size, spacing: spacing) continue } } - lines.append(.init(subview, size: size)) + lines.append(.init(item: [.init(item: (subview: subview, cache: cache), size: size)], size: size)) + } + distributeItems(in: &lines, proposedSize: proposedSize, subviews: subviews, sizes: sizes, spacings: spacings, cache: cache) + updateFlexibleItems(in: &lines, proposedSize: proposedSize) + updateLineSpacings(in: &lines) + return lines + } + + private func distributeItems( + in lines: inout Lines, + proposedSize: ProposedViewSize, + subviews: some Subviews, + sizes: [Size], + spacings: [CGFloat], + cache: FlowLayoutCache + ) { + guard distibuteItemsEvenly else { return } + + // Knuth-Plass Line Breaking Algorithm + let proposedBreadth = proposedSize.replacingUnspecifiedDimensions().value(on: axis) + let count = sizes.count + var costs: [CGFloat] = Array(repeating: CGFloat.infinity, count: count + 1) + var breaks: [Int?] = Array(repeating: nil, count: count + 1) + + costs[0] = 0 + + for end in 0 ..< count { + var totalBreadth: CGFloat = 0 + for start in stride(from: end, through: 0, by: -1) { + let size = sizes[start].breadth + let spacing = start > 0 && start < end ? spacings[start] : 0 + totalBreadth += size + spacing + if totalBreadth > proposedBreadth { + break + } + let penalty = abs(totalBreadth - (proposedBreadth / CGFloat(end - start + 1))) + let cost = costs[start] + pow(proposedBreadth - totalBreadth, 2) + penalty + if cost < costs[end + 1] { + costs[end + 1] = cost + breaks[end + 1] = start + } + } + } + + var breakpoints: [Int] = [] + var i = count + while let breakPoint = breaks[i], breakPoint >= 0 { + breakpoints.insert(i, at: 0) + i = breakPoint + } + breakpoints.insert(0, at: 0) + + var newLines: Lines = [] + for (start, end) in zip(breakpoints, breakpoints.dropFirst()) { + var line: ItemWithSpacing = .init(item: [], size: .zero) + for index in start..( @@ -171,21 +227,71 @@ struct FlowLayout { guard index != subviews.startIndex else { return 0 } return self.itemSpacing ?? subviews[index.advanced(by: -1)].spacing.distance(to: subviews[index].spacing, along: axis) } + + private func updateFlexibleItems(in line: inout ItemWithSpacing, proposedSize: ProposedViewSize, justification: Justification) { + let subviewsInPriorityOrder = line.item.enumerated().map { offset, subview in + SubviewProperties(indexInLine: offset, spacing: subview.spacing, cache: subview.item.cache) + }.sorted(using: [KeyPathComparator(\.cache.priority), KeyPathComparator(\.flexibility), KeyPathComparator(\.cache.ideal.breadth)]) + + let sumOfIdeal = subviewsInPriorityOrder.map { $0.spacing + $0.cache.ideal.breadth }.reduce(into: 0, +=) + var remainingSpace = proposedSize.value(on: axis) - sumOfIdeal + let count = line.item.count + + if case .stretchSpaces = justification { + let distributedSpace = remainingSpace / Double(count - 1) + for index in line.item.indices.dropFirst() { + line.item[index].spacing += distributedSpace + remainingSpace -= distributedSpace + } + } else { + let sumOfMax = subviewsInPriorityOrder.map { $0.spacing + $0.cache.max.breadth }.reduce(into: 0, +=) + let potentialGrowth = sumOfMax - sumOfIdeal + if potentialGrowth <= remainingSpace { + for subview in subviewsInPriorityOrder { + line.item[subview.indexInLine].size.breadth = subview.cache.max.breadth + remainingSpace -= subview.flexibility + } + } else { + var remainingItemCount = count + for subview in subviewsInPriorityOrder { + let offer = remainingSpace / Double(remainingItemCount) + let actual = min(subview.flexibility, offer) + remainingSpace -= actual + remainingItemCount -= 1 + line.item[subview.indexInLine].size.breadth += actual + } + } + if case .stretchItemsAndSpaces = justification { + let distributedSpace = remainingSpace / Double(count - 1) + for index in line.item.indices.dropFirst() { + line.item[index].spacing += distributedSpace + remainingSpace -= distributedSpace + } + } + } + line.size.breadth = proposedSize.value(on: axis) - remainingSpace + } } @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -extension FlowLayout { +extension FlowLayout: Layout { + func makeCache(subviews: LayoutSubviews) -> FlowLayoutCache { + makeCache(subviews) + } + static func vertical( alignment: HorizontalAlignment, itemSpacing: CGFloat?, lineSpacing: CGFloat?, - justification: Justification? = nil + justification: Justification? = nil, + distibuteItemsEvenly: Bool = false ) -> FlowLayout { .init( axis: .vertical, itemSpacing: itemSpacing, lineSpacing: lineSpacing, - justification: justification + justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly ) { $0[alignment] } @@ -195,97 +301,21 @@ extension FlowLayout { alignment: VerticalAlignment, itemSpacing: CGFloat?, lineSpacing: CGFloat?, - justification: Justification? = nil + justification: Justification? = nil, + distibuteItemsEvenly: Bool = false ) -> FlowLayout { .init( axis: .horizontal, itemSpacing: itemSpacing, lineSpacing: lineSpacing, - justification: justification + justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly ) { $0[alignment] } } } -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -protocol Subviews: RandomAccessCollection where Element: Subview, Index == Int {} - -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -extension LayoutSubviews: Subviews {} - -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -protocol Subview { - var spacing: ViewSpacing { get } - func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize - func dimensions(_ proposal: ProposedViewSize) -> Dimensions - func place(at position: CGPoint, anchor: UnitPoint, proposal: ProposedViewSize) -} - -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -extension LayoutSubview: Subview { - func dimensions(_ proposal: ProposedViewSize) -> Dimensions { - dimensions(in: proposal) - } -} - -protocol Dimensions { - var width: CGFloat { get } - var height: CGFloat { get } - - subscript(guide: HorizontalAlignment) -> CGFloat { get } - subscript(guide: VerticalAlignment) -> CGFloat { get } -} -extension ViewDimensions: Dimensions {} - -extension Dimensions { - func size(on axis: Axis) -> Size { - Size( - breadth: value(on: axis), - depth: value(on: axis.perpendicular) - ) - } - - func value(on axis: Axis) -> CGFloat { - switch axis { - case .horizontal: width - case .vertical: height - } - } -} - -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -extension FlowLayout: Layout { - typealias Cache = Void - func sizeThatFits( - proposal proposedSize: ProposedViewSize, - subviews: LayoutSubviews, - cache: inout Cache - ) -> CGSize { - sizeThatFits(proposal: proposedSize, subviews: subviews) - } - - func placeSubviews( - in bounds: CGRect, - proposal: ProposedViewSize, - subviews: LayoutSubviews, - cache: inout Cache - ) { - placeSubviews(in: bounds, proposal: proposal, subviews: subviews) - } -} - -private extension CGRect { - mutating func reverseOrigin(along axis: Axis) { - switch axis { - case .horizontal: - origin.x = maxX - case .vertical: - origin.y = maxY - } - } -} - private extension Array where Element == Size { func reduce( _ initial: Size, @@ -293,13 +323,18 @@ private extension Array where Element == Size { depth: (CGFloat, CGFloat) -> CGFloat ) -> Size { reduce(initial) { result, size in - Size(breadth: breadth(result.breadth, size.breadth), - depth: depth(result.depth, size.depth)) + Size( + breadth: breadth(result.breadth, size.breadth), + depth: depth(result.depth, size.depth) + ) } } } -public enum Justification { - case stretchItems - case stretchSpaces +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +private struct SubviewProperties { + var indexInLine: Int + var spacing: Double + var cache: FlowLayoutCache.SubviewCache + var flexibility: Double { cache.max.breadth - cache.ideal.breadth } } diff --git a/Sources/Flow/Internal/Protocols.swift b/Sources/Flow/Internal/Protocols.swift new file mode 100644 index 00000000..9dd516d1 --- /dev/null +++ b/Sources/Flow/Internal/Protocols.swift @@ -0,0 +1,48 @@ +import SwiftUI + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +protocol Subviews: RandomAccessCollection where Element: Subview, Index == Int {} + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +extension LayoutSubviews: Subviews {} + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +protocol Subview { + var spacing: ViewSpacing { get } + var priority: Double { get } + func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize + func dimensions(_ proposal: ProposedViewSize) -> Dimensions + func place(at position: CGPoint, anchor: UnitPoint, proposal: ProposedViewSize) +} + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +extension LayoutSubview: Subview { + func dimensions(_ proposal: ProposedViewSize) -> Dimensions { + dimensions(in: proposal) + } +} + +protocol Dimensions { + var width: CGFloat { get } + var height: CGFloat { get } + + subscript(guide: HorizontalAlignment) -> CGFloat { get } + subscript(guide: VerticalAlignment) -> CGFloat { get } +} +extension ViewDimensions: Dimensions {} + +extension Dimensions { + func size(on axis: Axis) -> Size { + Size( + breadth: value(on: axis), + depth: value(on: axis.perpendicular) + ) + } + + func value(on axis: Axis) -> CGFloat { + switch axis { + case .horizontal: width + case .vertical: height + } + } +} diff --git a/Sources/Flow/Internal/VFlowLayout.swift b/Sources/Flow/Internal/VFlowLayout.swift index a1e9dc36..913aafa8 100644 --- a/Sources/Flow/Internal/VFlowLayout.swift +++ b/Sources/Flow/Internal/VFlowLayout.swift @@ -8,27 +8,33 @@ public struct VFlowLayout { alignment: HorizontalAlignment = .center, itemSpacing: CGFloat? = nil, columnSpacing: CGFloat? = nil, - justification: Justification? = nil + justification: Justification? = nil, + distibuteItemsEvenly: Bool = false ) { layout = .vertical( alignment: alignment, itemSpacing: itemSpacing, lineSpacing: columnSpacing, - justification: justification + justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly ) } } @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) extension VFlowLayout: Layout { - public func sizeThatFits(proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout ()) -> CGSize { + public func sizeThatFits(proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout FlowLayoutCache) -> CGSize { layout.sizeThatFits(proposal: proposal, subviews: subviews, cache: &cache) } - public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout ()) { + public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout FlowLayoutCache) { layout.placeSubviews(in: bounds, proposal: proposal, subviews: subviews, cache: &cache) } + public func makeCache(subviews: LayoutSubviews) -> FlowLayoutCache { + FlowLayoutCache(subviews, axis: .vertical) + } + public static var layoutProperties: LayoutProperties { var properties = LayoutProperties() properties.stackOrientation = .vertical diff --git a/Sources/Flow/Support.swift b/Sources/Flow/Support.swift new file mode 100644 index 00000000..b334625b --- /dev/null +++ b/Sources/Flow/Support.swift @@ -0,0 +1,35 @@ +import SwiftUI + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +public enum Justification { + case stretchItems + case stretchSpaces + case stretchItemsAndSpaces +} + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +public struct FlowLayoutCache { + struct SubviewCache { + var priority: Double + var spacing: ViewSpacing + var min: Size + var ideal: Size + var max: Size + + init(_ subview: some Subview, axis: Axis) { + priority = subview.priority + spacing = subview.spacing + min = subview.dimensions(.zero).size(on: axis) + ideal = subview.dimensions(.unspecified).size(on: axis) + max = subview.dimensions(.infinity).size(on: axis) + } + } + + let subviewsCache: [SubviewCache] + + init(_ subviews: some Subviews, axis: Axis) { + subviewsCache = subviews.map { + SubviewCache($0, axis: axis) + } + } +} diff --git a/Sources/Flow/VFlow.swift b/Sources/Flow/VFlow.swift index 3dea99b5..8f89f48d 100644 --- a/Sources/Flow/VFlow.swift +++ b/Sources/Flow/VFlow.swift @@ -40,6 +40,7 @@ public struct VFlow: View { itemSpacing: CGFloat? = nil, columnSpacing: CGFloat? = nil, justification: Justification? = nil, + distibuteItemsEvenly: Bool = false, @ViewBuilder content contentBuilder: () -> Content ) { content = contentBuilder() @@ -47,7 +48,8 @@ public struct VFlow: View { alignment: alignment, itemSpacing: itemSpacing, columnSpacing: columnSpacing, - justification: justification + justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly ) } @@ -65,6 +67,7 @@ public struct VFlow: View { alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, justification: Justification? = nil, + distibuteItemsEvenly: Bool = false, @ViewBuilder content contentBuilder: () -> Content ) { self.init( @@ -72,6 +75,7 @@ public struct VFlow: View { itemSpacing: spacing, columnSpacing: spacing, justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly, content: contentBuilder ) } @@ -105,13 +109,15 @@ extension VFlow: Layout where Content == EmptyView { alignment: HorizontalAlignment = .center, itemSpacing: CGFloat? = nil, columnSpacing: CGFloat? = nil, - justification: Justification? = nil + justification: Justification? = nil, + distibuteItemsEvenly: Bool = false ) { self.init( alignment: alignment, itemSpacing: itemSpacing, columnSpacing: columnSpacing, - justification: justification + justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly ) { EmptyView() } @@ -129,18 +135,20 @@ extension VFlow: Layout where Content == EmptyView { public init( alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, - justification: Justification? = nil + justification: Justification? = nil, + distibuteItemsEvenly: Bool = false ) { self.init( alignment: alignment, spacing: spacing, - justification: justification + justification: justification, + distibuteItemsEvenly: distibuteItemsEvenly ) { EmptyView() } } - public func sizeThatFits(proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout ()) -> CGSize { + public func sizeThatFits(proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout FlowLayoutCache) -> CGSize { layout.sizeThatFits( proposal: proposal, subviews: subviews, @@ -148,7 +156,7 @@ extension VFlow: Layout where Content == EmptyView { ) } - public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout ()) { + public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout FlowLayoutCache) { layout.placeSubviews( in: bounds, proposal: proposal, @@ -157,6 +165,10 @@ extension VFlow: Layout where Content == EmptyView { ) } + public func makeCache(subviews: LayoutSubviews) -> FlowLayoutCache { + FlowLayoutCache(subviews, axis: .vertical) + } + public static var layoutProperties: LayoutProperties { VFlowLayout.layoutProperties } diff --git a/Tests/FlowTests/FlowTests.swift b/Tests/FlowTests/FlowTests.swift index b4dc5841..43420b75 100644 --- a/Tests/FlowTests/FlowTests.swift +++ b/Tests/FlowTests/FlowTests.swift @@ -6,32 +6,46 @@ import XCTest final class FlowTests: XCTestCase { func test_HFlow_size_singleElement() throws { // Given - let views = [ - TestSubview(width: 50, height: 50) - ] let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 10, lineSpacing: 20) // When - let size = sut.sizeThatFits(proposal: ProposedViewSize(width: 100, height: 100), subviews: views) + let size = sut.sizeThatFits(proposal: 100×100, subviews: [50×50]) // Then - XCTAssertEqual(size, CGSize(width: 50, height: 50)) + XCTAssertEqual(size, 50×50) } func test_HFlow_size_multipleElements() throws { // Given - let views = [ - TestSubview(width: 50, height: 50), - TestSubview(width: 50, height: 50), - TestSubview(width: 50, height: 50) - ] let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 10, lineSpacing: 20) // When - let size = sut.sizeThatFits(proposal: ProposedViewSize(width: 130, height: 130), subviews: views) + let size = sut.sizeThatFits(proposal: 130×130, subviews: repeated(50×50, times: 3)) // Then - XCTAssertEqual(size, CGSize(width: 110, height: 120)) + XCTAssertEqual(size, 110×120) + } + + func test_HFlow_size_justifiedSpaces() throws { + // Given + let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 0, lineSpacing: 0, justification: .stretchSpaces) + + // When + let size = sut.sizeThatFits(proposal: 1000×1000, subviews: [50×50, 50×50]) + + // Then + XCTAssertEqual(size, 1000×50) + } + + func test_HFlow_size_justifiedItems() throws { + // Given + let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 0, lineSpacing: 0, justification: .stretchItems) + + // When + let size = sut.sizeThatFits(proposal: 1000×1000, subviews: [50×1...100×1]) + + // Then + XCTAssertEqual(size, 100×1) } func test_HFlow_layout_top() { @@ -94,12 +108,12 @@ final class FlowTests: XCTestCase { """) } - func test_HFlow_layout() { + func test_HFlow_default() { // Given let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0) // When - let result = sut.layout(Array(repeating: 1×1, count: 15), in: 11×3) + let result = sut.layout(repeated(1×1, times: 15), in: 11×3) // Then XCTAssertEqual(render(result), """ @@ -111,230 +125,205 @@ final class FlowTests: XCTestCase { """) } - func test_VFlow_layout_leading() { + func test_HFlow_distibuted() throws { // Given - let sut: FlowLayout = .vertical(alignment: .leading, itemSpacing: 1, lineSpacing: 1) + let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0, distibuteItemsEvenly: true) // When - let result = sut.layout([1×1, 3×1, 1×1, 1×1, 1×1], in: 5×5) + let result = sut.layout(repeated(1×1, times: 13), in: 11×3) // Then XCTAssertEqual(render(result), """ - +-----+ - |X X| - | | - |XXX X| - | | - |X | - +-----+ + +-----------+ + |X X X X X | + |X X X X | + |X X X X | + +-----------+ """) } - func test_VFlow_layout_center() { + + func test_HFlow_justifiedSpaces_rigid() { // Given - let sut: FlowLayout = .vertical(alignment: .center, itemSpacing: 1, lineSpacing: 1) + let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0, justification: .stretchSpaces) // When - let result = sut.layout([1×1, 3×1, 1×1, 1×1, 1×1], in: 5×5) + let result = sut.layout([3×1, 3×1, 2×1], in: 9×2) // Then XCTAssertEqual(render(result), """ - +-----+ - | X X| - | | - |XXX X| - | | - | X | - +-----+ + +---------+ + |XXX XXX| + |XX | + +---------+ """) } - func test_VFlow_layout_trailing() { + func test_HFlow_justifiedSpaces_flexible() { // Given - let sut: FlowLayout = .vertical(alignment: .trailing, itemSpacing: 1, lineSpacing: 1) + let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0, justification: .stretchSpaces) // When - let result = sut.layout([1×1, 3×1, 1×1, 1×1, 1×1], in: 5×5) + let result = sut.layout([3×1, 3×1...inf×1, 2×1], in: 9×2) // Then XCTAssertEqual(render(result), """ - +-----+ - | X X| - | | - |XXX X| - | | - | X | - +-----+ + +---------+ + |XXX XXX| + |XX | + +---------+ """) } - func test_VFlow_layout() { + func test_HFlow_justifiedItems_rigid() { // Given - let sut: FlowLayout = .vertical(alignment: .center, itemSpacing: 0, lineSpacing: 0) + let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0, justification: .stretchItems) // When - let result = sut.layout(Array(repeating: 1×1, count: 17), in: 6×3) + let result = sut.layout([3×1, 3×1, 2×1], in: 9×2) // Then XCTAssertEqual(render(result), """ - +------+ - |XXXXXX| - |XXXXXX| - |XXXXX | - +------+ + +---------+ + |XXX XXX | + |XX | + +---------+ """) } - func test_HFlow_justifiedItems() { + func test_HFlow_justifiedItems_flexible() { // Given let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0, justification: .stretchItems) // When - let result = sut.layout([3×1, 3×1, 2×1], in: 9×2, flexible: true) + let result = sut.layout([3×1...4×1, 3×1...inf×1, 2×1...5×1], in: 9×2) // Then XCTAssertEqual(render(result), """ +---------+ |XXXX XXXX| - |XXXXXXXXX| + |XXXXX | +---------+ """) } - func test_HFlow_justifiedSpaces() { + func test_HFlow_justifiedItemsAndSpaces_rigid() throws { // Given - let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0, justification: .stretchSpaces) + let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0, justification: .stretchItemsAndSpaces) // When - let result = sut.layout([3×1, 3×1, 2×1], in: 9×2, flexible: true) + let result = sut.layout([1×1, 4×1, 3×1, 2×1, 2×1, 3×1], in: 12×2) // Then XCTAssertEqual(render(result), """ - +---------+ - |XXX XXX| - |XX | - +---------+ + +------------+ + |X XXXX XXX| + |XX XX XXX| + +------------+ """) } -} -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -private extension FlowLayout { - func layout(_ views: [CGSize], in bounds: CGSize, flexible: Bool = false) -> (subviews: [TestSubview], size: CGSize) { - let subviews = views.map { TestSubview(width: $0.width, height: $0.height, flexible: flexible) } - let size = sizeThatFits( - proposal: ProposedViewSize(width: bounds.width, height: bounds.height), - subviews: subviews - ) - placeSubviews( - in: CGRect(origin: .zero, size: bounds), - proposal: ProposedViewSize(width: size.width, height: size.height), - subviews: subviews - ) - return (subviews, bounds) - } -} + func test_HFlow_justifiedItemsAndSpaces_flexible() throws { + // Given + let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0, justification: .stretchItemsAndSpaces) -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -private func render(_ layout: (subviews: [TestSubview], size: CGSize), border: Bool = true) -> String { - struct Point: Hashable { - let x, y: Int - } + // When + let result = sut.layout([1×1, 2×1...5×1, 1×1...inf×1, 2×1, 5×1...inf×1, 5×1...inf×1], in: 13×2) - var positions: Set = [] - for view in layout.subviews { - if let point = view.placement { - for y in Int(point.y) ..< Int(point.y + view.size.height) { - for x in Int(point.x) ..< Int(point.x + view.size.width) { - positions.insert(Point(x: x, y: y)) - } - } - } - } - let width = Int(layout.size.width) - let height = Int(layout.size.height) - var result = "" - if border { - result += "+" + String(repeating: "-", count: width) + "+\n" - } - for y in 0 ... height - 1 { - if border { - result += "|" - } - for x in 0 ... width - 1 { - result += positions.contains(Point(x: x, y: y)) ? "X" : " " - } - if border { - result += "|" - } else { - result = result.trimmingCharacters(in: .whitespaces) - } - result += "\n" - } - if border { - result += "+" + String(repeating: "-", count: width) + "+\n" + // Then + XCTAssertEqual(render(result), """ + +-------------+ + |X XXXX XXX XX| + |XXXXXX XXXXXX| + +-------------+ + """) } - return result.trimmingCharacters(in: .newlines) -} -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -private final class TestSubview: Subview, CustomStringConvertible { - var spacing = ViewSpacing() - var placement: CGPoint? - var size: CGSize - var flexible: Bool - - init(width: CGFloat, height: CGFloat, flexible: Bool = false) { - size = .init(width: width, height: height) - self.flexible = flexible - } + func test_HFlow_justifiedItemsAndSpaces_strethBoth() throws { + // Given + let sut: FlowLayout = .horizontal(alignment: .center, itemSpacing: 1, lineSpacing: 0, justification: .stretchItemsAndSpaces) - func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { - size - } + // When + let result = sut.layout([4×1...5×1, 4×1...5×1, 4×1...5×1, 4×1...5×1, 4×1...5×1], in: 15×2) - func dimensions(_ proposal: ProposedViewSize) -> Dimensions { - TestDimensions(width: size.width, height: size.height) + // Then + XCTAssertEqual(render(result), """ + +---------------+ + |XXXX XXXX XXXXX| + |XXXXX XXXXX| + +---------------+ + """) } - func place(at position: CGPoint, anchor: UnitPoint, proposal: ProposedViewSize) { - placement = position - if flexible, let width = proposal.width { - size.width = width - } - if flexible, let height = proposal.height { - size.height = height - } + func test_VFlow_layout_leading() { + // Given + let sut: FlowLayout = .vertical(alignment: .leading, itemSpacing: 1, lineSpacing: 1) + + // When + let result = sut.layout([1×1, 3×1, 1×1, 1×1, 1×1], in: 5×5) + + // Then + XCTAssertEqual(render(result), """ + +-----+ + |X X| + | | + |XXX X| + | | + |X | + +-----+ + """) } + func test_VFlow_layout_center() { + // Given + let sut: FlowLayout = .vertical(alignment: .center, itemSpacing: 1, lineSpacing: 1) - var description: String { - "origin: \((placement?.x).map { "\($0)" } ?? "nil")×\((placement?.y).map { "\($0)" } ?? "nil"), size: \(size.width)×\(size.height)" + // When + let result = sut.layout([1×1, 3×1, 1×1, 1×1, 1×1], in: 5×5) + + // Then + XCTAssertEqual(render(result), """ + +-----+ + | X X| + | | + |XXX X| + | | + | X | + +-----+ + """) } -} -@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) -extension [TestSubview]: Subviews {} + func test_VFlow_layout_trailing() { + // Given + let sut: FlowLayout = .vertical(alignment: .trailing, itemSpacing: 1, lineSpacing: 1) -private struct TestDimensions: Dimensions { - let width, height: CGFloat + // When + let result = sut.layout([1×1, 3×1, 1×1, 1×1, 1×1], in: 5×5) - subscript(guide: HorizontalAlignment) -> CGFloat { - switch guide { - case .center: 0.5 * width - case .trailing: width - default: 0 - } + // Then + XCTAssertEqual(render(result), """ + +-----+ + | X X| + | | + |XXX X| + | | + | X | + +-----+ + """) } - subscript(guide: VerticalAlignment) -> CGFloat { - switch guide { - case .center: 0.5 * height - case .bottom: height - default: 0 - } - } -} + func test_VFlow_default() { + // Given + let sut: FlowLayout = .vertical(alignment: .center, itemSpacing: 0, lineSpacing: 0) + + // When + let result = sut.layout(repeated(1×1, times: 16), in: 6×3) -infix operator ×: MultiplicationPrecedence -private func × (lhs: CGFloat, rhs: CGFloat) -> CGSize { - CGSize(width: lhs, height: rhs) + // Then + XCTAssertEqual(render(result), """ + +------+ + |XXXXXX| + |XXXXX | + |XXXXX | + +------+ + """) + } } diff --git a/Tests/FlowTests/Utils/Operators.swift b/Tests/FlowTests/Utils/Operators.swift new file mode 100644 index 00000000..de1cab84 --- /dev/null +++ b/Tests/FlowTests/Utils/Operators.swift @@ -0,0 +1,28 @@ +import SwiftUI +import Foundation + +infix operator ×: MultiplicationPrecedence + +func × (lhs: CGFloat, rhs: CGFloat) -> CGSize { + CGSize(width: lhs, height: rhs) +} + +func × (lhs: CGFloat, rhs: CGFloat) -> TestSubview { + .init(size: .init(width: lhs, height: rhs)) +} + +func × (lhs: CGFloat, rhs: CGFloat) -> ProposedViewSize { + .init(width: lhs, height: rhs) +} + +infix operator ...: RangeFormationPrecedence + +func ... (lhs: CGSize, rhs: CGSize) -> TestSubview { + TestSubview(minSize: lhs, idealSize: lhs, maxSize: rhs) +} + +let inf: CGFloat = .infinity + +func repeated(_ factory: @autoclosure () -> T, times: Int) -> [T] { + (1...times).map { _ in factory() } +} diff --git a/Tests/FlowTests/Utils/TestSubview.swift b/Tests/FlowTests/Utils/TestSubview.swift new file mode 100644 index 00000000..f4e79356 --- /dev/null +++ b/Tests/FlowTests/Utils/TestSubview.swift @@ -0,0 +1,155 @@ +import SwiftUI +import XCTest +@testable import Flow + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +final class TestSubview: Subview, CustomStringConvertible { + var spacing = ViewSpacing() + var priority: Double = 1 + var placement: (position: CGPoint, size: CGSize)? + var minSize: CGSize + var idealSize: CGSize + var maxSize: CGSize + + init(size: CGSize) { + minSize = size + idealSize = size + maxSize = size + } + + init(minSize: CGSize, idealSize: CGSize, maxSize: CGSize) { + self.minSize = minSize + self.idealSize = idealSize + self.maxSize = maxSize + } + + func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { + switch proposal { + case .zero: + minSize + case .unspecified: + idealSize + case .infinity: + maxSize + default: + CGSize( + width: min(max(minSize.width, proposal.width ?? idealSize.width), maxSize.width), + height: min(max(minSize.height, proposal.height ?? idealSize.height), maxSize.height) + ) + } + } + + func dimensions(_ proposal: ProposedViewSize) -> Dimensions { + let size = switch proposal { + case .zero: minSize + case .unspecified: idealSize + case .infinity: maxSize + default: sizeThatFits(proposal) + } + return TestDimensions(width: size.width, height: size.height) + } + + func place(at position: CGPoint, anchor: UnitPoint, proposal: ProposedViewSize) { + let size = sizeThatFits(proposal) + placement = (position, size) + } + + var description: String { + "origin: \((placement?.position.x).map { "\($0)" } ?? "nil")×\((placement?.position.y).map { "\($0)" } ?? "nil"), size: \(idealSize.width)×\(idealSize.height)" + } +} + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +extension [TestSubview]: Subviews {} + +typealias LayoutDescription = (subviews: [TestSubview], reportedSize: CGSize) + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +extension FlowLayout { + func layout(_ subviews: [TestSubview], in bounds: CGSize) -> LayoutDescription { + var cache = makeCache(subviews) + let size = sizeThatFits( + proposal: ProposedViewSize(width: bounds.width, height: bounds.height), + subviews: subviews, + cache: &cache + ) + placeSubviews( + in: CGRect(origin: .zero, size: bounds), + proposal: ProposedViewSize(width: size.width, height: size.height), + subviews: subviews, + cache: &cache + ) + return (subviews, bounds) + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: [TestSubview]) -> CGSize { + var cache = makeCache(subviews) + return sizeThatFits(proposal: proposal, subviews: subviews, cache: &cache) + } +} + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +func render(_ layout: LayoutDescription, border: Bool = true) -> String { + struct Point: Hashable { + let x, y: Int + } + + var positions: Set = [] + for view in layout.subviews { + if let placement = view.placement { + let point = placement.position + for y in Int(point.y) ..< Int(point.y + placement.size.height) { + for x in Int(point.x) ..< Int(point.x + placement.size.width) { + let result = positions.insert(Point(x: x, y: y)) + precondition(result.inserted, "Boxes should not overlap") + } + } + } else { + fatalError("Should be placed") + } + } + let width = Int(layout.reportedSize.width) + let height = Int(layout.reportedSize.height) + var result = "" + if border { + result += "+" + String(repeating: "-", count: width) + "+\n" + } + for y in 0 ... height - 1 { + if border { + result += "|" + } + for x in 0 ... width - 1 { + result += positions.contains(Point(x: x, y: y)) ? "X" : " " + } + if border { + result += "|" + } else { + result = result.trimmingCharacters(in: .whitespaces) + } + result += "\n" + } + if border { + result += "+" + String(repeating: "-", count: width) + "+\n" + } + return result.trimmingCharacters(in: .newlines) +} + +private struct TestDimensions: Dimensions { + let width, height: CGFloat + + subscript(guide: HorizontalAlignment) -> CGFloat { + switch guide { + case .center: 0.5 * width + case .trailing: width + default: 0 + } + } + + subscript(guide: VerticalAlignment) -> CGFloat { + switch guide { + case .center: 0.5 * height + case .bottom: height + default: 0 + } + } +}