├── Docs └── Resources │ └── wrapping-hstack-macos.png ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ └── WrappingStack.xcscheme ├── .github └── workflows │ └── test.yml ├── Package.swift ├── Sources └── WrappingStack │ ├── Helpers │ ├── TightHeightGeometryReader.swift │ ├── SizeReader.swift │ └── Lines.swift │ └── WrappingHStack.swift ├── LICENSE ├── Tests └── WrappingStackTests │ └── LinesTests.swift ├── .gitignore └── Readme.md /Docs/Resources/wrapping-hstack-macos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diniska/swiftui-wrapping-stack/HEAD/Docs/Resources/wrapping-hstack-macos.png -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | 3 | on: 4 | push: 5 | branches: [ void ] 6 | pull_request: 7 | branches: [ void ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Build for macOS 17 | run: swift build -v 18 | - name: Run tests for macOS 19 | run: swift test -v 20 | - name: Build for iOS 21 | run: xcodebuild build -sdk iphoneos -scheme 'WrappingStack' -destination 'name=iPhone 8' 22 | - name: Run tests for iOS 23 | run: xcodebuild test -destination 'name=iPhone 8' -scheme "WrappingStack" 24 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 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: "WrappingStack", 8 | platforms: [ 9 | .iOS(.v11), 10 | .watchOS(.v6), 11 | .tvOS(.v11), 12 | .macOS(.v10_10) 13 | ], 14 | products: [ 15 | .library( 16 | name: "WrappingStack", 17 | targets: ["WrappingStack"]), 18 | ], 19 | targets: [ 20 | .target( 21 | name: "WrappingStack", 22 | dependencies: []), 23 | .testTarget( 24 | name: "WrappingStackTests", 25 | dependencies: ["WrappingStack"]), 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /Sources/WrappingStack/Helpers/TightHeightGeometryReader.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) && canImport(Combine) 2 | 3 | import SwiftUI 4 | 5 | @available(iOS 14, tvOS 14, macOS 11, *) 6 | struct TightHeightGeometryReader: View { 7 | var alignment: Alignment 8 | @State private var height: CGFloat = 0 9 | 10 | var content: (GeometryProxy) -> Content 11 | 12 | init( 13 | alignment: Alignment = .topLeading, 14 | @ViewBuilder content: @escaping (GeometryProxy) -> Content 15 | ) { 16 | self.alignment = alignment 17 | self.content = content 18 | } 19 | 20 | var body: some View { 21 | GeometryReader { geometry in 22 | content(geometry) 23 | .onSizeChange { size in 24 | if self.height != size.height { 25 | self.height = size.height 26 | } 27 | } 28 | .frame(maxWidth: .infinity, alignment: alignment) 29 | } 30 | .frame(height: height) 31 | } 32 | } 33 | 34 | #endif 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Denis 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 | -------------------------------------------------------------------------------- /Sources/WrappingStack/Helpers/SizeReader.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) && canImport(Combine) 2 | 3 | import SwiftUI 4 | 5 | @available(iOS 14, tvOS 14, macOS 11, *) 6 | extension View { 7 | func onSizeChange(perform action: @escaping (CGSize) -> ()) -> some View { 8 | modifier(SizeReader(onChange: action)) 9 | } 10 | } 11 | 12 | @available(iOS 14, tvOS 14, macOS 11, *) 13 | private struct SizeReader: ViewModifier { 14 | var onChange: (CGSize) -> () 15 | 16 | func body(content: Content) -> some View { 17 | content 18 | .background( 19 | GeometryReader { geometry in 20 | Color.clear 21 | .preference(key: SizePreferenceKey.self, value: geometry.size) 22 | } 23 | ) 24 | .onPreferenceChange(SizePreferenceKey.self, perform: onChange) 25 | } 26 | } 27 | 28 | @available(iOS 14, tvOS 14, macOS 11, *) 29 | private struct SizePreferenceKey: PreferenceKey { 30 | static var defaultValue: CGSize = .zero 31 | static func reduce(value: inout CGSize, nextValue: () -> CGSize) {} 32 | } 33 | 34 | #endif 35 | -------------------------------------------------------------------------------- /Sources/WrappingStack/Helpers/Lines.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Lines { 4 | typealias Element = S.Element 5 | typealias Index = S.Index 6 | 7 | var elements: S 8 | var spacing: Weight 9 | var length: (Element) -> Weight 10 | 11 | func split(lengthLimit: Weight) -> [Range] { 12 | var currentLength: Weight = .zero 13 | var numberOfElementsInCurrentLine = 0 14 | var result: [Range] = [] 15 | var lineStart = elements.startIndex 16 | 17 | for element in elements { 18 | let elementLength = length(element) 19 | let newLength = currentLength + elementLength 20 | 21 | // element could safely be added to the line 22 | // or line is empty 23 | if newLength <= lengthLimit || numberOfElementsInCurrentLine == 0 { 24 | currentLength = newLength + spacing 25 | numberOfElementsInCurrentLine += 1 26 | } else { // moving element to the next line 27 | currentLength = elementLength + spacing 28 | let lineEnd = elements.index(lineStart, offsetBy: numberOfElementsInCurrentLine) 29 | result.append(lineStart ..< lineEnd) 30 | numberOfElementsInCurrentLine = 1 31 | lineStart = lineEnd 32 | } 33 | } 34 | 35 | if lineStart != elements.endIndex { 36 | result.append(lineStart ..< elements.endIndex) 37 | } 38 | return result 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Tests/WrappingStackTests/LinesTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import WrappingStack 3 | 4 | final class LinesTests: XCTestCase { 5 | func testSingleLargeElementIsPutOnTheFirstLine() { 6 | let lines = Lines(elements: [200], spacing: 10) 7 | let split = lines.split(lengthLimit: 100) 8 | XCTAssertEqual(split, [0 ..< 1]) 9 | } 10 | 11 | func testMovesElementToSecondLineWhenFirstLineIsTakenBySingleElement() { 12 | let lines = Lines(elements: [200, 5], spacing: 10) 13 | let split = lines.split(lengthLimit: 100) 14 | XCTAssertEqual(split, [0 ..< 1, 1 ..< 2]) 15 | } 16 | 17 | func testFitsTwoElementOnASingleLineWithoutSpacing() { 18 | let lines = Lines(elements: [5, 5]) 19 | let split = lines.split(lengthLimit: 10) 20 | XCTAssertEqual(split, [0 ..< 2]) 21 | } 22 | 23 | func testDoesNotFitsTwoElementOnASingleLineWhenOverflowsDueToSpacing() { 24 | let lines = Lines(elements: [5, 5], spacing: 1) 25 | let split = lines.split(lengthLimit: 10) 26 | XCTAssertEqual(split, [0 ..< 1, 1 ..< 2]) 27 | } 28 | 29 | func testDoesNotAddSpacingAtTheBeginningOfALine() { 30 | let lines = Lines(elements: [5, 5], spacing: 10) 31 | let split = lines.split(lengthLimit: 5) 32 | XCTAssertEqual(split, [0 ..< 1, 1 ..< 2]) 33 | } 34 | 35 | func testDoesNotMoveElementToAThirdLineWhenItsASingleElementOnTheSecond() { 36 | let lines = Lines(elements: [100, 100]) 37 | let split = lines.split(lengthLimit: 5) 38 | XCTAssertEqual(split, [0 ..< 1, 1 ..< 2]) 39 | } 40 | } 41 | 42 | private extension Lines where Element == Weight { 43 | init(elements: S, spacing: Weight = .zero) { 44 | self.init(elements: elements, spacing: spacing, length: { $0 }) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # SwiftUI WrappingStack 2 | 3 | ![Swift 5.3](https://img.shields.io/badge/Swift-5.3-FA5B2C) ![Xcode 12.5+](https://img.shields.io/badge/Xcode-12.5-44B3F6) ![iOS 9.0+](https://img.shields.io/badge/iOS-9.0-178DF6) ![iPadOS 9.0+](https://img.shields.io/badge/iPadOS-9.0-178DF6) ![MacOS 10.10+](https://img.shields.io/badge/MacOS-10.10-178DF6) [![Build & Test](https://github.com/diniska/swiftui-wrapping-stack/actions/workflows/test.yml/badge.svg)](https://github.com/diniska/swiftui-wrapping-stack/actions/workflows/test.yml) [![codebeat badge](https://codebeat.co/badges/a0d5ffba-ab99-456e-aa75-3a30dd46fda4)](https://codebeat.co/projects/github-com-diniska-swiftui-wrapping-stack-void) 4 | 5 | A SwiftUI Views for wrapping HStack elements into multiple lines. 6 | 7 | ## List of supported views 8 | 9 | * `WrappingHStack` - provides `HStack` that supports line wrapping 10 | 11 | ## How to use 12 | ### Step 1 13 | Add a dependency using Swift Package Manager to your project: [https://github.com/diniska/swiftui-wrapping-stack](https://github.com/diniska/swiftui-wrapping-stack) 14 | 15 | ### Step 2 16 | Import the dependency 17 | 18 | ```swift 19 | import WrappingStack 20 | ``` 21 | 22 | ### Step 3 23 | Replace `HStack` with `WrappingHStack` in your view structure. It is compatible with `ForEach`. 24 | 25 | ```swift 26 | struct MyView: View { 27 | 28 | let elements = ["Cat 🐱", "Dog 🐶", "Sun 🌞", "Moon 🌕", "Tree 🌳"] 29 | 30 | var body: some View { 31 | WrappingHStack(id: \.self) { // use the same id is in the `ForEach` below 32 | ForEach(elements, id: \.self) { element in 33 | Text(element) 34 | .padding() 35 | .background(Color.gray) 36 | .cornerRadius(6) 37 | } 38 | } 39 | .frame(width: 300) // limiting the width for demo purpose. This line is not needed in real code 40 | } 41 | 42 | } 43 | ``` 44 | 45 | The result of the code above: 46 | 47 | ![WrappingHStack for macOS](./Docs/Resources/wrapping-hstack-macos.png) 48 | 49 | 50 | ## Customization 51 | 52 | Customize appearance using the next parameters. All the default SwiftUI modifiers can be applied as well. 53 | 54 | ### `WrappingHStack` parameters 55 | 56 | Parameter name | Description 57 | ---------------|-------------- 58 | `alignment` | horizontal and vertical alignment. `.center` is used by default. Vertical alignment is applied to every row 59 | `horizontalSpacing` | horizontal spacing between elements 60 | `verticalSpacing` | vertical spacing between the lines 61 | 62 | ## Performance considerations 63 | 64 | The code written in a way to cache the elements representing views sizes, it doesn't re-calculate the size for different views with the same id. 65 | 66 | * huge numbers of elements are not recommended, although the same applies to `HStack` where `LazyHStack` is a better alternative for the long rows. If you have a large number of elements - double-check the memory and performance on a real device 67 | * it is pretty good in terms of CPU consumption as every element calculates its size only once. 68 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/WrappingStack.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 47 | 53 | 54 | 55 | 56 | 57 | 67 | 68 | 74 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /Sources/WrappingStack/WrappingHStack.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) && canImport(Combine) 2 | 3 | import SwiftUI 4 | 5 | /// An HStack that grows vertically when single line overflows 6 | @available(iOS 14, tvOS 14, macOS 11, *) 7 | public struct WrappingHStack: View { 8 | 9 | public let data: Data 10 | public var content: (Data.Element) -> Content 11 | public var id: KeyPath 12 | public var alignment: Alignment 13 | public var horizontalSpacing: CGFloat 14 | public var verticalSpacing: CGFloat 15 | 16 | @State private var sizes: [ID: CGSize] = [:] 17 | @State private var calculatedSizesKeys: Set = [] 18 | 19 | private let idsForCalculatingSizes: Set 20 | private var dataForCalculatingSizes: [Data.Element] { 21 | var result: [Data.Element] = [] 22 | var idsToProcess: Set = idsForCalculatingSizes 23 | idsToProcess.subtract(calculatedSizesKeys) 24 | 25 | data.forEach { item in 26 | let itemId = item[keyPath: id] 27 | if idsToProcess.contains(itemId) { 28 | idsToProcess.remove(itemId) 29 | result.append(item) 30 | } 31 | } 32 | return result 33 | } 34 | 35 | /// Creates a new WrappingHStack 36 | /// 37 | /// - Parameters: 38 | /// - id: a keypath of element identifier 39 | /// - alignment: horizontal and vertical alignment. Vertical alignment is applied to every row 40 | /// - horizontalSpacing: horizontal spacing between elements 41 | /// - verticalSpacing: vertical spacing between the lines 42 | /// - create: a method that creates an array of elements 43 | public init( 44 | id: KeyPath, 45 | alignment: Alignment = .center, 46 | horizontalSpacing: CGFloat = 0, 47 | verticalSpacing: CGFloat = 0, 48 | @ViewBuilder content create: () -> ForEach 49 | ) { 50 | let forEach = create() 51 | data = forEach.data 52 | content = forEach.content 53 | idsForCalculatingSizes = Set(data.map { $0[keyPath: id] }) 54 | self.id = id 55 | self.alignment = alignment 56 | self.horizontalSpacing = horizontalSpacing 57 | self.verticalSpacing = verticalSpacing 58 | } 59 | 60 | private func splitIntoLines(maxWidth: CGFloat) -> [Range] { 61 | let lines = Lines(elements: data, spacing: horizontalSpacing) { element in 62 | sizes[element[keyPath: id]]?.width ?? 0 63 | } 64 | return lines.split(lengthLimit: maxWidth) 65 | } 66 | 67 | public var body: some View { 68 | if calculatedSizesKeys.isSuperset(of: idsForCalculatingSizes) { 69 | // All sizes are calculated, displaying the view 70 | laidOutContent 71 | } else { 72 | // Calculating sizes 73 | sizeCalculatorView 74 | } 75 | } 76 | 77 | private var laidOutContent: some View { 78 | TightHeightGeometryReader(alignment: alignment) { geometry in 79 | let splited = splitIntoLines(maxWidth: geometry.size.width) 80 | 81 | // All sizes are known 82 | VStack(alignment: alignment.horizontal, spacing: verticalSpacing) { 83 | ForEach(Array(splited.enumerated()), id: \.offset) { list in 84 | HStack(alignment: alignment.vertical, spacing: horizontalSpacing) { 85 | ForEach(data[list.element], id: id) { 86 | content($0) 87 | } 88 | } 89 | } 90 | } 91 | } 92 | } 93 | 94 | private var sizeCalculatorView: some View { 95 | VStack { 96 | ForEach(dataForCalculatingSizes, id: id) { d in 97 | content(d) 98 | .onSizeChange { size in 99 | let key = d[keyPath: id] 100 | sizes[key] = size 101 | calculatedSizesKeys.insert(key) 102 | } 103 | } 104 | } 105 | } 106 | } 107 | 108 | @available(iOS 14, tvOS 14, macOS 11, *) 109 | extension WrappingHStack where ID == Data.Element.ID, Data.Element: Identifiable { 110 | /// Creates a new WrappingHStack 111 | /// 112 | /// - Parameters: 113 | /// - alignment: horizontal and vertical alignment. Vertical alignment is applied to every row 114 | /// - horizontalSpacing: horizontal spacing between elements 115 | /// - verticalSpacing: vertical spacing between the lines 116 | /// - create: a method that creates an array of elements 117 | public init( 118 | alignment: Alignment = .center, 119 | horizontalSpacing: CGFloat = 0, 120 | verticalSpacing: CGFloat = 0, 121 | @ViewBuilder content create: () -> ForEach 122 | ) { 123 | self.init(id: \.id, 124 | alignment: alignment, 125 | horizontalSpacing: horizontalSpacing, 126 | verticalSpacing: verticalSpacing, 127 | content: create) 128 | } 129 | } 130 | 131 | #if DEBUG 132 | 133 | @available(iOS 14, tvOS 14, macOS 11, *) 134 | struct WrappingHStack_Previews: PreviewProvider { 135 | static var previews: some View { 136 | WrappingHStack( 137 | id: \.self, 138 | alignment: .trailing, 139 | horizontalSpacing: 8, 140 | verticalSpacing: 8 141 | ) { 142 | ForEach(["Cat 🐱", "Dog 🐶", "Sun 🌞", "Moon 🌕", "Tree 🌳"], id: \.self) { element in 143 | Text(element) 144 | .padding() 145 | .background(Color.secondary.opacity(0.2)) 146 | .cornerRadius(6) 147 | .fixedSize() 148 | } 149 | } 150 | .padding() 151 | .frame(width: 300) 152 | } 153 | } 154 | 155 | #endif 156 | 157 | #endif 158 | --------------------------------------------------------------------------------