├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Package.swift ├── README.md └── Sources └── FlowStackLayout ├── FlowStack.swift └── FlowStackLayout.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 smyshlaevalex 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "FlowStackLayout", 8 | platforms: [.iOS(.v16), .macCatalyst(.v16), .macOS(.v13), .tvOS(.v16), .watchOS(.v9)], 9 | products: [ 10 | .library( 11 | name: "FlowStackLayout", 12 | targets: ["FlowStackLayout"]), 13 | ], 14 | dependencies: [ 15 | ], 16 | targets: [ 17 | .target( 18 | name: "FlowStackLayout", 19 | dependencies: []) 20 | ] 21 | ) 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FlowStackLayout 2 | 3 | `FlowStack` acts like a `HStack` until there are no more space for new cells, then `FlowStack` will wrap to the next row 4 | 5 | `FlowStack` supports vertical and horizontal alignment 6 | 7 | Horizontal alignment will align rows 8 | 9 | Vertical alignment will align cells based on row height 10 | 11 | `FlowStackLayout` conforms to `Layout` protocol, while `FlowStack` conforms to `View` and wraps `FlowStackLayout` 12 | 13 | View modifiers can't be used with `Layout` protocol, so `FlowStack` is preferred for normal use 14 | 15 | `FlowStack(alignment: Alignment = .center, horizontalSpacing: Double? = nil, verticalSpacing: Double? = nil, content: @escaping () -> Content)` 16 | 17 | ```swift 18 | FlowStack { 19 | ForEach(0...20, id: \.self) { 20 | Text(String($0)) 21 | .foregroundColor(Color(uiColor: .systemBackground)) 22 | .padding([.leading, .trailing]) 23 | .frame(height: 30) 24 | .background { 25 | Capsule() 26 | .fill(.mint) 27 | } 28 | } 29 | } 30 | ``` 31 | 32 | `FlowStackLayout(alignment: Alignment = .center, horizontalSpacing: Double? = nil, verticalSpacing: Double? = nil)` 33 | 34 | ```swift 35 | let layout = isVertical ? AnyLayout(VStack()) : AnyLayout(FlowStackLayout()) 36 | 37 | layout { 38 | 39 | } 40 | ``` 41 | 42 | ## Installation 43 | 44 | ### Swift Package Manager 45 | 46 | ```swift 47 | https://github.com/smyshlaevalex/FlowStackLayout.git 48 | ``` 49 | -------------------------------------------------------------------------------- /Sources/FlowStackLayout/FlowStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FlowStack.swift 3 | // 4 | // 5 | // Created by Alexander Smyshlaev on 30.06.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct FlowStack: View { 11 | private let alignment: Alignment 12 | private let horizontalSpacing: Double? 13 | private let verticalSpacing: Double? 14 | private let content: () -> Content 15 | 16 | public init(alignment: Alignment = .center, 17 | horizontalSpacing: Double? = nil, 18 | verticalSpacing: Double? = nil, 19 | @ViewBuilder content: @escaping () -> Content) { 20 | self.alignment = alignment 21 | self.horizontalSpacing = horizontalSpacing 22 | self.verticalSpacing = verticalSpacing 23 | self.content = content 24 | } 25 | 26 | public var body: some View { 27 | FlowStackLayout(alignment: alignment, horizontalSpacing: horizontalSpacing, verticalSpacing: verticalSpacing) { 28 | content() 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/FlowStackLayout/FlowStackLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FlowStackLayout.swift 3 | // 4 | // 5 | // Created by Alexander Smyshlaev on 30.06.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct FlowStackLayoutResult { 11 | let containerSize: CGSize 12 | let subviewPositions: [CGPoint] 13 | } 14 | 15 | public struct FlowStackLayout: Layout { 16 | private let alignment: Alignment 17 | private let horizontalSpacing: Double? 18 | private let verticalSpacing: Double? 19 | 20 | public init(alignment: Alignment = .center, horizontalSpacing: Double? = nil, verticalSpacing: Double? = nil) { 21 | self.alignment = alignment 22 | self.horizontalSpacing = horizontalSpacing 23 | self.verticalSpacing = verticalSpacing 24 | } 25 | 26 | public func makeCache(subviews: Subviews) -> FlowStackLayoutResult { 27 | FlowStackLayoutResult(containerSize: .zero, subviewPositions: []) 28 | } 29 | 30 | public func updateCache(_ cache: inout FlowStackLayoutResult, subviews: Subviews) { 31 | cache = FlowStackLayoutResult(containerSize: .zero, subviewPositions: []) 32 | } 33 | 34 | public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout FlowStackLayoutResult) -> CGSize { 35 | cache = layout(maxWidth: proposal.replacingUnspecifiedDimensions().width, subviews: subviews) 36 | return cache.containerSize 37 | } 38 | 39 | public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout FlowStackLayoutResult) { 40 | for (subview, subviewPosition) in zip(subviews, cache.subviewPositions) { 41 | subview.place(at: CGPoint(x: bounds.minX + subviewPosition.x, 42 | y: bounds.minY + subviewPosition.y), 43 | proposal: proposal) 44 | } 45 | } 46 | 47 | private func layout(maxWidth: CGFloat, subviews: Subviews) -> FlowStackLayoutResult { 48 | var containerSize: CGSize = .zero 49 | var currentSubviewPosition: CGPoint = .zero 50 | var previousHorizontalViewSpacing: ViewSpacing? 51 | var verticalViewSpacing = ViewSpacing() 52 | var subviewFrameRows: [[CGRect]] = [] 53 | var currentSubviewFrameRow: [CGRect] = [] 54 | 55 | for subview in subviews { 56 | let size = subview.sizeThatFits(.unspecified) 57 | let horizontalSpacing = previousHorizontalViewSpacing.flatMap { 58 | self.horizontalSpacing ?? subview.spacing.distance(to: $0, along: .horizontal) 59 | } ?? 0 60 | previousHorizontalViewSpacing = subview.spacing 61 | 62 | let subviewWidthAndSpacing = size.width + horizontalSpacing 63 | 64 | let newContainerWidth = currentSubviewPosition.x + subviewWidthAndSpacing 65 | if newContainerWidth <= maxWidth { 66 | currentSubviewPosition.x += horizontalSpacing 67 | 68 | currentSubviewFrameRow.append(CGRect(origin: currentSubviewPosition, size: size)) 69 | 70 | containerSize.width = max(containerSize.width, newContainerWidth) 71 | currentSubviewPosition.x += size.width 72 | 73 | verticalViewSpacing.formUnion(subview.spacing) 74 | 75 | let newContainerHeight = currentSubviewPosition.y + size.height 76 | containerSize.height = max(containerSize.height, newContainerHeight) 77 | } else { 78 | currentSubviewPosition.x = 0 79 | currentSubviewPosition.y = containerSize.height 80 | 81 | let verticalSpacing = self.verticalSpacing ?? subview.spacing.distance(to: verticalViewSpacing, along: .vertical) 82 | verticalViewSpacing = ViewSpacing() 83 | 84 | currentSubviewPosition.y += verticalSpacing 85 | 86 | subviewFrameRows.append(currentSubviewFrameRow) 87 | currentSubviewFrameRow = [] 88 | currentSubviewFrameRow.append(CGRect(origin: currentSubviewPosition, size: size)) 89 | 90 | let newContainerHeight = currentSubviewPosition.y + size.height 91 | containerSize.height = max(containerSize.height, newContainerHeight) 92 | 93 | containerSize.width = max(containerSize.width, size.width) 94 | 95 | currentSubviewPosition.x += size.width 96 | } 97 | } 98 | 99 | subviewFrameRows.append(currentSubviewFrameRow) 100 | currentSubviewFrameRow = [] 101 | 102 | subviewFrameRows = subviewFrameRows.map { rowFrames in 103 | let trailingSpacing = containerSize.width - (rowFrames.last?.maxX ?? 0) 104 | let rowHeight = rowFrames.max(by: { $0.height < $1.height })?.height ?? 10 105 | let xOffset: CGFloat 106 | switch alignment.horizontal { 107 | case .leading: 108 | xOffset = 0 109 | 110 | case .center: 111 | xOffset = trailingSpacing / 2 112 | 113 | case .trailing: 114 | xOffset = trailingSpacing 115 | 116 | default: 117 | xOffset = 0 118 | } 119 | 120 | return rowFrames.map { frame in 121 | let yOffset: CGFloat 122 | switch alignment.vertical { 123 | case .top: 124 | yOffset = 0 125 | 126 | case .center: 127 | yOffset = (rowHeight - frame.height) / 2 128 | 129 | case .bottom: 130 | yOffset = rowHeight - frame.height 131 | 132 | default: 133 | yOffset = 0 134 | } 135 | 136 | return frame.offsetBy(dx: xOffset, dy: yOffset) 137 | } 138 | } 139 | 140 | let subviewPositions = subviewFrameRows.flatMap { $0 } .map(\.origin) 141 | 142 | return FlowStackLayoutResult(containerSize: containerSize, subviewPositions: subviewPositions) 143 | } 144 | } 145 | --------------------------------------------------------------------------------