├── 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 |      [](https://github.com/diniska/swiftui-wrapping-stack/actions/workflows/test.yml) [](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 | 
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 |
--------------------------------------------------------------------------------