├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── Fit │ ├── Fit.swift │ ├── FitLayout.swift │ ├── ItemSpacing.swift │ ├── LayoutCache.swift │ ├── LineBreak.swift │ ├── LineCache.swift │ ├── LineSpacing.swift │ ├── LineStyle.swift │ └── PrivacyInfo.xcprivacy └── Tests └── FitTests └── FitTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2024 Oleh Korchytskyi 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Fit", 7 | platforms: [ 8 | .iOS(.v16), 9 | .macOS(.v13), 10 | .tvOS(.v16), 11 | .watchOS(.v9) 12 | ], 13 | products: [ 14 | .library( 15 | name: "Fit", 16 | targets: ["Fit"] 17 | ), 18 | ], 19 | targets: [ 20 | .target( 21 | name: "Fit" 22 | ), 23 | .testTarget( 24 | name: "FitTests", 25 | dependencies: ["Fit"] 26 | ), 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | 6 | 7 | 8 | Twitter: @OKorchytskyi 9 | 10 |

11 | 12 | **Fit** allows you to lay out your views into lines without ever bothering to group or measure them, all thanks to the implementation of the **Layout** protocol. 13 | 14 | 15 | ## Usage 16 | 17 | 18 | Add your views just like you would do it with all other **SwiftUI** stacks: 19 | 20 | ```swift 21 | import Fit 22 | 23 | Fit { 24 | Text("Tags:") 25 | 26 | ForEach(tags) { tag in 27 | TagView(tag: tag) 28 | } 29 | 30 | TextField("New tag", text: $input) 31 | } 32 | ``` 33 | ![tag_list_example](https://github.com/OlehKorchytskyi/Fit/assets/4789347/270802ea-6bea-455a-afa6-084ba48ffdba) 34 | 35 | > [!NOTE] 36 | > Full code for this example: [Tag List Example](https://github.com/OlehKorchytskyi/FitPlayground/blob/main/FitPlayground/FitPlayground/Examples/TagListExample.swift). 37 | 38 | ## Customisation 39 | 40 | **Fit** provides multiple ways to customise your layout: 41 | 42 | #### Line Alignment and Spacing 43 | 44 | ```swift 45 | // .leading (default), .center or .trailing 46 | Fit(lineAlignment: .center, lineSpacing: 12) { 47 | // views 48 | } 49 | ``` 50 | > [!NOTE] 51 | > The default line spacing is **.viewSpacing**, which merges spacing of all items in the line and then uses vertical distance, [ViewSpacing.distance](https://developer.apple.com/documentation/swiftui/viewspacing/distance(to:along:)), to add spacing between to lines. 52 | 53 | #### Item Alignment and Spacing 54 | 55 | You can align items in the same way you would do in **HStack**: 56 | 57 | ```swift 58 | // fixed item spacing 59 | Fit(itemAlignment: .firstTextBaseline, itemSpacing: 8) { 60 | // views 61 | } 62 | 63 | // view's preferred spacing 64 | Fit(itemAlignment: .firstTextBaseline, itemSpacing: .viewSpacing(minimum: 8)) { 65 | // views 66 | } 67 | ``` 68 | 69 | #### Line-Break 70 | 71 | Use view modifier to add line-break before or after a particular view: 72 | 73 | ```swift 74 | Fit { 75 | Text("Title:") 76 | .fit(lineBreak: .after) 77 | // next views 78 | } 79 | ``` 80 | 81 | #### Per-line styling 82 | 83 | You can define secific style for each line with **LineStyle**: 84 | ```swift 85 | let conveyorBeltStyle: LineStyle = .lineSpecific { style, line in 86 | // reverse every second line 87 | style.reversed = (line.index + 1).isMultiple(of: 2) 88 | // if the line is reversed, it should start from the trailing edge 89 | style.alignment = style.reversed ? .trailing : .leading 90 | } 91 | 92 | Fit(lineStyle: conveyorBeltStyle) { 93 | // views 94 | } 95 | ``` 96 | ![conveyor_belt_example](https://github.com/OlehKorchytskyi/Fit/assets/4789347/586f3c5c-f769-4ee0-a93d-2f6316ee9444) 97 | 98 | > [!NOTE] 99 | > Full code for this example: [Conveyor Belt Example](https://github.com/OlehKorchytskyi/FitPlayground/blob/main/FitPlayground/FitPlayground/Examples/ConveyorBeltExample.swift). 100 | 101 | It is also possible to create a variable style: 102 | 103 | ```swift 104 | var fancyAlignJustified: LineStyle { 105 | .lineSpecific { style, line in 106 | if stretch { 107 | style.stretched = line.percentageFilled >= threshold 108 | } 109 | } 110 | } 111 | 112 | Fit(lineStyle: fancyAlignJustified) { 113 | // views 114 | } 115 | ``` 116 | ![stretching_example](https://github.com/OlehKorchytskyi/Fit/assets/4789347/a926cac6-9ac5-431a-88cc-a62e4d7ee456) 117 | 118 | > [!NOTE] 119 | > Full code for this example: [Long Text Example](https://github.com/OlehKorchytskyi/FitPlayground/blob/main/FitPlayground/FitPlayground/Examples/LongTextExample.swift). 120 | 121 | ## Installing 122 | 123 | Use **Swift Package Manager** to get **Fit**: 124 | ```swift 125 | .package(url: "https://github.com/OlehKorchytskyi/Fit", from: "1.0.2") 126 | ``` 127 | 128 | Import **Fit** into your Swift code: 129 | 130 | ```swift 131 | import Fit 132 | ``` 133 | 134 | 135 | 136 | ## License 137 | 138 | MIT License. 139 | 140 | Copyright (c) 2024 Oleh Korchytskyi. 141 | 142 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 143 | 144 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 145 | 146 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 147 | -------------------------------------------------------------------------------- /Sources/Fit/Fit.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | 4 | 5 | /// Automatically forms lines from the elements, using **.sizeThatFits(proposal)** to determine each element size. 6 | /// 7 | /// Add your views just like you would do it with all other **SwiftUI** stacks: 8 | /// 9 | /// ```swift 10 | /// Fit { 11 | /// ForEach(items) { item in 12 | /// ItemView(item) 13 | /// } 14 | /// } 15 | /// ``` 16 | /// 17 | public struct Fit { 18 | 19 | public let lineStyle: LineStyle 20 | public let lineSpacing: LineSpacing 21 | 22 | public let itemAlignment: VerticalAlignment 23 | public let itemSpacing: ItemSpacing 24 | 25 | /// Initialises ``Fit`` with a custom ``LineStyle``. 26 | /// - Parameters: 27 | /// - lineStyle: description of a line style represented by ``LineStyle``; 28 | /// - lineSpacing: configuration of spacing between lines represented by ``LineSpacing``; 29 | /// - itemAlignment: vertical items alignment, behaves in the same way as HStack alignment; 30 | /// - itemSpacing: configuration of spacing between items represented by ``ItemSpacing``. 31 | public init( 32 | lineStyle: LineStyle, 33 | lineSpacing: LineSpacing = .viewSpacing, 34 | itemAlignment: VerticalAlignment = .center, 35 | itemSpacing: ItemSpacing = .viewSpacing 36 | ) { 37 | self.lineStyle = lineStyle 38 | self.lineSpacing = lineSpacing 39 | 40 | self.itemAlignment = itemAlignment 41 | self.itemSpacing = itemSpacing 42 | } 43 | 44 | /// Initialises ``Fit`` with a set of static attributes. 45 | /// - Parameters: 46 | /// - lineAlignment: alignment of the lines in the container; 47 | /// - lineSpacing: configuration of spacing between lines represented by ``LineSpacing``; 48 | /// - itemAlignment: vertical items alignment, behaves in the same way as HStack alignment; 49 | /// - itemSpacing: configuration of spacing between items represented by ``ItemSpacing``; 50 | /// - stretched: determines container should be stretched to fill available space. 51 | public init( 52 | lineAlignment: LineAlignment = .leading, 53 | lineSpacing: LineSpacing = .viewSpacing, 54 | itemAlignment: VerticalAlignment = .center, 55 | itemSpacing: ItemSpacing = .viewSpacing, 56 | stretched: Bool = false 57 | ) { 58 | self.lineStyle = LineStyle(alignment: lineAlignment, reversed: false, stretched: stretched) 59 | self.lineSpacing = lineSpacing 60 | 61 | self.itemAlignment = itemAlignment 62 | self.itemSpacing = itemSpacing 63 | } 64 | 65 | // MARK: Forming the container 66 | 67 | func prepareLines(_ subviews: Subviews, inContainer proposal: ProposedViewSize, cache: inout LayoutCache) { 68 | 69 | let container = proposal.replacingUnspecifiedDimensions() 70 | let availableSpace = container.width 71 | 72 | var indices = subviews.indices 73 | 74 | // Preparing reassignable attributes 75 | var currentIndex = indices.removeFirst() 76 | var currentItem = subviews[currentIndex] 77 | var currentDimensions = currentItem.dimensions(in: proposal) 78 | var currentSizeThatFits = currentItem.sizeThatFits(proposal) 79 | var currentSpacing = currentItem.spacing 80 | 81 | // Caching attributes for the first item 82 | cache.dimensions.append(currentDimensions) 83 | cache.sizes.append(currentSizeThatFits) 84 | cache.proposals.append(ProposedViewSize(currentSizeThatFits)) 85 | 86 | // Creating the function which creates new line from the attributes 87 | func newLineFromCurrentSubview() -> LayoutCache.Line { 88 | cache.distances.append(0) // adding zero spacing for the first item in the line 89 | return LayoutCache.Line( 90 | index: cache.lines.count, 91 | leadingItem: currentIndex, 92 | dimensions: currentDimensions, 93 | spacing: currentSpacing, 94 | alignment: itemAlignment, 95 | availableSpace: availableSpace 96 | ) 97 | } 98 | 99 | // Creating first line 100 | var currentLine = newLineFromCurrentSubview() 101 | 102 | func cacheCurrentLine() { 103 | cache.cacheLine(currentLine, lineSpacing: lineSpacing) 104 | } 105 | 106 | if indices.isEmpty { 107 | cacheCurrentLine() 108 | } else { 109 | var forceNewLineLater = false 110 | if let lineBreak = currentItem[LineBreakKey.self], lineBreak.after { 111 | // Check if the current item has attached request 112 | // to create new line after appending 113 | forceNewLineLater = lineBreak.condition(currentLine) 114 | } 115 | 116 | while indices.isEmpty == false { 117 | currentIndex = indices.removeFirst() 118 | currentItem = subviews[currentIndex] 119 | currentDimensions = currentItem.dimensions(in: proposal) 120 | currentSizeThatFits = currentItem.sizeThatFits(proposal) 121 | currentSpacing = currentItem.spacing 122 | 123 | cache.dimensions.append(currentDimensions) 124 | cache.sizes.append(currentSizeThatFits) 125 | cache.proposals.append(ProposedViewSize(currentSizeThatFits)) 126 | 127 | var startNewLine = forceNewLineLater 128 | forceNewLineLater = false 129 | 130 | if let lineBreak = currentItem[LineBreakKey.self] { 131 | // Check if the current item has attached request to: 132 | if lineBreak.before { 133 | // Create new line before appending 134 | startNewLine = lineBreak.condition(currentLine) 135 | } else { 136 | // Create new line after appending 137 | forceNewLineLater = true 138 | } 139 | } 140 | 141 | if startNewLine { 142 | cacheCurrentLine() 143 | currentLine = newLineFromCurrentSubview() 144 | } else if currentLine.appendIfPossible( 145 | currentIndex, 146 | dimensions: currentDimensions, 147 | spacing: currentSpacing, 148 | spacingRule: itemSpacing, 149 | distances: &cache.distances 150 | ) { 151 | // Did successfully fit current item into the current line. 152 | } else { 153 | // Cannot fit current item into the current line. 154 | // Cache current line and creating a new one 155 | cacheCurrentLine() 156 | currentLine = newLineFromCurrentSubview() 157 | } 158 | 159 | if indices.isEmpty { 160 | // Caching current line if there is no items left 161 | cacheCurrentLine() 162 | } 163 | } 164 | } 165 | 166 | cache.sizeThatFits = cache.lines.reduce(into: CGSize.zero) { size, line in 167 | let style = lineStyle.specific(for: line) 168 | cache.specificLineStyles.append(style) 169 | 170 | // accounting for the space between lines 171 | size.height += cache.lineDistances[line.index] 172 | 173 | size.width = style.stretched ? container.width : max(size.width, line.lineLength) 174 | size.height += line.lineHeight 175 | } 176 | } 177 | 178 | // MARK: - Placing Lines 179 | 180 | func placeLines(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout LayoutCache) { 181 | 182 | var verticalOffset: CGFloat = bounds.minY 183 | 184 | for line in cache.lines { 185 | 186 | // Extract distance to the previous line 187 | // Note: always 0 for the line 188 | let lineSpacing = cache.lineDistances[line.index] 189 | 190 | // Apply spacing lines before placing lien items 191 | verticalOffset += lineSpacing 192 | 193 | // Extract already specified style for current line 194 | let style = cache.specificLineStyles[line.index] 195 | 196 | // Determine where the line will start to place items 197 | var horizontalOffset = horizontalStart(for: line, withStyle: style, in: bounds, subviews: subviews, cache: cache) 198 | // // Remember line start point, in terms of Fit container bounds 199 | // cache.lines[line.index].localHorizontalStart = horizontalOffset - bounds.minX 200 | 201 | // Use the lowest baseline as a general baseline every item lays on 202 | let generalBaseline = max(0, line.baseline.lowest) 203 | 204 | // Determine the order of the indices that will be iterated through 205 | let indices = style.reversed ? line.indices.reversed() : line.indices 206 | 207 | for index in indices { 208 | 209 | let subview = subviews[index] 210 | 211 | let size = cache.sizes[index] 212 | let sizeProposal = cache.proposals[index] 213 | 214 | // Extract distance to the previous item 215 | // Note: always 0 for the first item in the row 216 | var itemSpacing = cache.distances[index] 217 | 218 | if style.stretched { 219 | // For the stretched lines, add additional spacing to the distance 220 | // to fill the available space left 221 | itemSpacing += line.maximumStretch(to: index) 222 | } 223 | 224 | if style.reversed == false { 225 | // If NOT reversed: 226 | // Apply spacing between items before placing an item 227 | horizontalOffset += itemSpacing 228 | } 229 | 230 | var itemPosition = CGPoint(x: horizontalOffset, y: verticalOffset) 231 | 232 | let dimensions = cache.dimensions[index] 233 | let itemBaseline = dimensions[itemAlignment] 234 | 235 | // Push item down by the general baseline length 236 | // and offset it by the item personal baseline length 237 | itemPosition.y += generalBaseline - itemBaseline 238 | 239 | subview.place(at: itemPosition, proposal: sizeProposal) 240 | let location = CGPoint(x: itemPosition.x - bounds.minX, 241 | y: itemPosition.y - bounds.minY) 242 | cache.cacheLocation(location, at: index) 243 | 244 | // Account for the placed item width 245 | horizontalOffset += size.width 246 | 247 | if style.reversed { 248 | // If it IS reversed: 249 | // Apply spacing between items after placing an item 250 | horizontalOffset += itemSpacing 251 | } 252 | 253 | } 254 | 255 | verticalOffset += line.lineHeight 256 | 257 | } 258 | 259 | cache.locationsProposal = proposal 260 | } 261 | 262 | @inlinable 263 | func horizontalStart(for line: LayoutCache.Line, withStyle specificStyle: LineStyle, in bounds: CGRect, subviews: Subviews, cache: LayoutCache) -> CGFloat { 264 | 265 | if specificStyle.stretched { 266 | return bounds.minX 267 | } 268 | 269 | switch specificStyle.alignment { 270 | case .leading: 271 | return bounds.minX 272 | case .center: 273 | return bounds.midX - line.lineLength / 2 274 | case .trailing: 275 | return bounds.maxX - line.lineLength 276 | } 277 | } 278 | 279 | } 280 | 281 | 282 | -------------------------------------------------------------------------------- /Sources/Fit/FitLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FitLayout.swift 3 | // 4 | // 5 | // Created by Oleh Korchytskyi on 18.01.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | extension Fit: Layout { 12 | 13 | public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout LayoutCache) -> CGSize { 14 | guard subviews.isEmpty == false else { return .zero } 15 | cache.validate(forProposedContainer: proposal) { 16 | prepareLines(subviews, inContainer: proposal, cache: &$0) 17 | } 18 | return cache.sizeThatFits 19 | } 20 | 21 | public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout LayoutCache) { 22 | guard cache.lines.isEmpty == false else { return } 23 | 24 | if cache.locations.count == subviews.count { 25 | 26 | for index in subviews.indices { 27 | let subview = subviews[index] 28 | let cachedLocation = cache.locations[index] 29 | let sizeProposal = cache.proposals[index] 30 | 31 | subview.place(at: CGPoint(x: cachedLocation.x + bounds.minX, 32 | y: cachedLocation.y + bounds.minY), proposal: sizeProposal) 33 | } 34 | 35 | return 36 | } 37 | 38 | cache.prepareToCachePlacementLocations(subviews.count) 39 | 40 | placeLines(in: bounds, proposal: proposal, subviews: subviews, cache: &cache) 41 | } 42 | 43 | public static var layoutProperties: LayoutProperties { 44 | var properties = LayoutProperties() 45 | properties.stackOrientation = nil 46 | return properties 47 | } 48 | 49 | // Note: runs before .placeSubviews(...) 50 | public func spacing(subviews: Subviews, cache: inout LayoutCache) -> ViewSpacing { 51 | guard subviews.isEmpty == false else { return ViewSpacing() } 52 | guard subviews.count > 1 else { return subviews[0].spacing } 53 | 54 | if cache.isClean, let spacing = cache.spacing { 55 | return spacing 56 | } 57 | 58 | let spacing = subviews.dropFirst().reduce(into: subviews[0].spacing) { 59 | $0.formUnion($1.spacing) 60 | } 61 | 62 | cache.spacing = spacing 63 | 64 | return spacing 65 | } 66 | 67 | 68 | // MARK: - Caching 69 | 70 | public func makeCache(subviews: Subviews) -> LayoutCache { 71 | LayoutCache(capacity: subviews.count) 72 | } 73 | 74 | public func updateCache(_ cache: inout LayoutCache, subviews: Subviews) { 75 | cache.isDirty = true 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/Fit/ItemSpacing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemSpacing.swift 3 | // 4 | // 5 | // Created by Oleh Korchytskyi on 09.01.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | /// Defines an item spacing rule in the ``Fit`` layout. 12 | /// 13 | /// Also can be constructed using Integer or Float literal: 14 | /// ``` 15 | /// let spacing: ItemSpacing = 8 // .fixed(8) 16 | /// let spacing: ItemSpacing = 10.5 // .fixed(10.5) 17 | /// ``` 18 | public enum ItemSpacing: ExpressibleByFloatLiteral, ExpressibleByIntegerLiteral { 19 | /// Uses **ViewSpacing** distance to determine preferred spacing, but not less then specified minimum. 20 | case viewSpacing(minimum: CGFloat) 21 | 22 | /// Fixed spacing beween subviews. 23 | case fixed(CGFloat) 24 | 25 | /// Uses **ViewSpacing** distance to determine preferred spacing. 26 | public static var viewSpacing: ItemSpacing { 27 | .viewSpacing(minimum: -.infinity) 28 | } 29 | 30 | @inline(__always) 31 | func distance(between leadingViewSpacing: ViewSpacing, and trailingViewSpacing: ViewSpacing) -> CGFloat { 32 | switch self { 33 | case .viewSpacing(minimum: let minimumSpacing): 34 | max(minimumSpacing, leadingViewSpacing.distance(to: trailingViewSpacing, along: .horizontal)) 35 | case .fixed(let spacing): 36 | spacing 37 | } 38 | } 39 | 40 | // MARK: ExpressibleByFloatLiteral 41 | /// Creates **.fixed** ``ItemSpacing``. 42 | /// - Parameter value: spacing distance. 43 | public init(floatLiteral value: Double) { 44 | self = .fixed(value) 45 | } 46 | 47 | // MARK: ExpressibleByIntegerLiteral 48 | /// Creates **.fixed** ``ItemSpacing``. 49 | /// - Parameter value: spacing distance. 50 | public init(integerLiteral value: Int) { 51 | self = .fixed(CGFloat(value)) 52 | } 53 | 54 | } 55 | 56 | -------------------------------------------------------------------------------- /Sources/Fit/LayoutCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LayoutCache.swift 3 | // 4 | // 5 | // Created by Oleh Korchytskyi on 12.01.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | extension Fit { 12 | 13 | /// Cache for ``Fit`` implementation of Layout protocol. 14 | public struct LayoutCache { 15 | 16 | var sizeThatFits: CGSize = .zero 17 | 18 | var spacing: ViewSpacing? 19 | 20 | var sizes: [CGSize] = [] 21 | var proposals: [ProposedViewSize] = [] 22 | var dimensions: [ViewDimensions] = [] 23 | 24 | /// Distances to the previous subview. 25 | var distances: [CGFloat] = [] 26 | /// Distances to the previous line. 27 | var lineDistances: [CGFloat] = [] 28 | 29 | private(set) var lines: [Line] = [] 30 | var specificLineStyles: [LineStyle] = [] 31 | 32 | /// Cached locations after placing subviews 33 | private(set) var locations: [CGPoint] = [] 34 | var locationsProposal: ProposedViewSize? = nil 35 | 36 | init(capacity: Int) { 37 | sizeThatFits = .zero 38 | 39 | sizes.reserveCapacity(capacity) 40 | proposals.reserveCapacity(capacity) 41 | dimensions.reserveCapacity(capacity) 42 | 43 | distances.reserveCapacity(capacity) 44 | 45 | specificLineStyles.reserveCapacity(capacity) 46 | 47 | locations.reserveCapacity(capacity) 48 | } 49 | 50 | // MARK: - Cached Lines 51 | @inline(__always) 52 | mutating func cacheLine(_ line: Line, lineSpacing: LineSpacing) { 53 | let distance: CGFloat = if let lastLineSpacing = lines.last?.spacing { 54 | lineSpacing.distance(between: lastLineSpacing, and: line.spacing) 55 | } else { 56 | 0 57 | } 58 | 59 | lineDistances.append(distance) 60 | lines.append(line) 61 | } 62 | 63 | // MARK: - Cached Locations 64 | @inline(__always) 65 | mutating func prepareToCachePlacementLocations(_ capacity: Int) { 66 | let zeroLocation: CGPoint = .zero 67 | locations = Array(repeating: zeroLocation, count: capacity) 68 | } 69 | 70 | @inline(__always) 71 | mutating func cacheLocation(_ location: CGPoint, at index: Int) { 72 | locations[index] = location 73 | } 74 | 75 | // MARK: - Reseting cache 76 | 77 | var isDirty: Bool = false 78 | var isClean: Bool { isDirty == false } 79 | var proposedContainer: ProposedViewSize? 80 | 81 | @inline(__always) 82 | mutating func validate(forProposedContainer proposal: ProposedViewSize, afterReset performUpdate: (inout Self) -> Void) { 83 | // If cache is dirty, or container changed size 84 | if isDirty || proposedContainer?.width != proposal.width { 85 | // reset all buffers 86 | reset() 87 | // perform updated for the caller 88 | performUpdate(&self) 89 | 90 | // clean the cache 91 | isDirty = false 92 | 93 | // remember the size proposal to compare during the next validation 94 | proposedContainer = proposal 95 | } 96 | } 97 | 98 | @inline(__always) 99 | mutating func reset() { 100 | sizeThatFits = .zero 101 | 102 | spacing = nil 103 | 104 | sizes.removeAll(keepingCapacity: true) 105 | proposals.removeAll(keepingCapacity: true) 106 | dimensions.removeAll(keepingCapacity: true) 107 | 108 | distances.removeAll(keepingCapacity: true) 109 | lineDistances.removeAll(keepingCapacity: true) 110 | 111 | specificLineStyles.removeAll(keepingCapacity: true) 112 | 113 | lines.removeAll() 114 | 115 | locations.removeAll(keepingCapacity: true) 116 | locationsProposal = nil 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Sources/Fit/LineBreak.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LineBreak.swift 3 | // 4 | // 5 | // Created by Oleh Korchytskyi on 10.01.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | struct LineBreakKey: LayoutValueKey { 12 | static let defaultValue: LineBreak? = nil 13 | } 14 | 15 | /// Represent a demand to break the line **.before** or **.after** the particular element. 16 | /// 17 | /// ```swift 18 | /// Fit { 19 | /// Image(systemName: "swift") 20 | /// Text("SwiftUI") 21 | /// .fit(lineBreak: .after) 22 | /// 23 | /// ForEach(items) { item in 24 | /// ItemView(item) 25 | /// } 26 | /// } 27 | /// ``` 28 | public struct LineBreak { 29 | 30 | let after: Bool 31 | var before: Bool { after == false} 32 | 33 | /// A piece of information derived from the layout cache, representing current line 34 | public typealias LineInfo = Fit.LayoutCache.Line 35 | 36 | let condition: (LineInfo) -> Bool 37 | 38 | init(after: Bool, condition: @escaping (LineInfo) -> Bool) { 39 | self.after = after 40 | self.condition = condition 41 | } 42 | } 43 | 44 | extension LineBreak { 45 | 46 | public static let noBreak = LineBreak(after: false, condition: { _ in false }) 47 | 48 | /// A demand to place the element on the next line. 49 | public static let before = LineBreak(after: false, condition: { _ in true }) 50 | 51 | /// A conditional demand to place the element on the next line. 52 | /// - Parameter condition: a required condition that needs to be met for performing a line brake. 53 | /// - Returns: a line brake demand. 54 | public static func before(when condition: @escaping (LineInfo) -> Bool) -> LineBreak { 55 | LineBreak(after: false, condition: condition) 56 | } 57 | 58 | /// A demand to line brake after current element. 59 | public static let after = LineBreak(after: true, condition: { _ in true }) 60 | /// A conditional demand to line brake after current element. 61 | /// - Parameter condition: a required condition that needs to be met for performing a line brake. 62 | /// - Returns: a line brake demand 63 | public static func after(when condition: @escaping (LineInfo) -> Bool) -> LineBreak { 64 | LineBreak(after: true, condition: condition) 65 | } 66 | 67 | } 68 | 69 | public extension LineBreak.LineInfo { 70 | /// Number of items added so far. 71 | var itemsAdded: Int { indices.count } 72 | 73 | /// Percentage of offered space filled (e.g. 0.5). 74 | var percentageFilled: Double { lineLength / availableSpaceOffered } 75 | } 76 | 77 | public extension View { 78 | 79 | /// Attaches a line brake demand to the view in the ``Fit`` layout. 80 | /// - Parameter lineBreak: a line brake configuration. 81 | /// - Returns: a view with attached line brake demand. 82 | func fit(lineBreak: LineBreak) -> some View { 83 | layoutValue(key: LineBreakKey.self, value: lineBreak) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/Fit/LineCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LineCache.swift 3 | // 4 | // 5 | // Created by Oleh Korchytskyi on 06.06.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | extension Fit.LayoutCache { 12 | 13 | /// Represents the group of items forming the line. 14 | public struct Line { 15 | 16 | public let index: Int 17 | 18 | private(set) var indices: [Int] 19 | 20 | let itemAlignment: VerticalAlignment 21 | 22 | var baseline: Baseline 23 | 24 | /// Expected line height, accounting for added items and alignments. 25 | var lineHeight: CGFloat 26 | 27 | /// Expected line length, accounting for added items and spacing in between. 28 | public private(set) var lineLength: CGFloat 29 | 30 | /// Represents how much of the available space was offered by the container during the layout process. 31 | public private(set) var availableSpaceOffered: CGFloat 32 | /// Represents how much of the available space is left. 33 | public private(set) var availableSpace: CGFloat 34 | 35 | private(set) var firstItemSpacing: ViewSpacing 36 | private(set) var firstItemDimensions: ViewDimensions 37 | 38 | private(set) var lastItemSpacing: ViewSpacing 39 | private(set) var lastItemDimensions: ViewDimensions 40 | 41 | var localHorizontalStart: CGFloat = 0 42 | 43 | /// Spacing of the line. 44 | var spacing: ViewSpacing 45 | 46 | @inline(__always) 47 | init(index: Int, leadingItem itemIndex: Int, dimensions: ViewDimensions, spacing: ViewSpacing, alignment: VerticalAlignment, availableSpace: CGFloat) { 48 | self.index = index 49 | 50 | indices = [itemIndex] 51 | 52 | itemAlignment = alignment 53 | 54 | let itemBaseline = dimensions[alignment] 55 | 56 | baseline = Baseline(initial: itemBaseline, itemHeight: dimensions.height) 57 | lineHeight = baseline.height 58 | 59 | lineLength = dimensions.width 60 | 61 | firstItemSpacing = spacing 62 | lastItemSpacing = spacing 63 | 64 | firstItemDimensions = dimensions 65 | lastItemDimensions = dimensions 66 | 67 | self.availableSpaceOffered = availableSpace 68 | self.availableSpace = max(0, availableSpace - dimensions.width) 69 | 70 | self.spacing = spacing 71 | } 72 | 73 | @inline(__always) 74 | mutating func appendIfPossible( 75 | _ itemIndex: Int, 76 | dimensions: ViewDimensions, 77 | spacing: ViewSpacing, 78 | spacingRule: ItemSpacing, 79 | distances: inout [CGFloat] 80 | ) -> Bool { 81 | let distance = spacingRule.distance(between: lastItemSpacing, and: spacing) 82 | let spaceOccupied = distance + dimensions.width 83 | 84 | guard spaceOccupied <= availableSpace else { return false } 85 | 86 | indices.append(itemIndex) 87 | distances.append(distance) 88 | 89 | availableSpace -= spaceOccupied 90 | lineLength += spaceOccupied 91 | 92 | lastItemSpacing = spacing 93 | lastItemDimensions = dimensions 94 | 95 | let itemBaseline = dimensions[itemAlignment] 96 | 97 | baseline.appendItem(dimensions.height, baseline: itemBaseline) 98 | lineHeight = baseline.height 99 | 100 | self.spacing.formUnion(spacing, edges: .vertical) 101 | 102 | return true 103 | } 104 | 105 | @inline(__always) 106 | func maximumStretch(to itemIndex: Int) -> CGFloat { 107 | guard indices.first != itemIndex else { return 0 } 108 | guard indices.count > 1 else { return 0 } 109 | return availableSpace / CGFloat(indices.count - 1) 110 | } 111 | 112 | } 113 | } 114 | 115 | extension Fit.LayoutCache.Line { 116 | struct Baseline { 117 | private(set) var highest: CGFloat 118 | private(set) var lowest: CGFloat 119 | 120 | private(set) var space: (up: CGFloat, down: CGFloat) 121 | private(set) var height: CGFloat 122 | 123 | init(initial baseline: CGFloat, itemHeight: CGFloat) { 124 | self.highest = baseline 125 | self.lowest = baseline 126 | 127 | let space = Self.space(for: itemHeight, baseline: baseline, lowestBaseline: baseline, highestBaseline: baseline) 128 | self.space = space 129 | 130 | self.height = space.up + space.down 131 | } 132 | 133 | @inline(__always) 134 | mutating func appendItem(_ itemHeight: CGFloat, baseline: CGFloat) { 135 | highest = min(highest, baseline) 136 | lowest = max(lowest, baseline) 137 | 138 | let newSpace = Self.space(for: itemHeight, baseline: baseline, lowestBaseline: lowest, highestBaseline: highest) 139 | space = (max(space.up, newSpace.up), 140 | max(space.down, newSpace.down)) 141 | 142 | self.height = space.up + space.down 143 | } 144 | 145 | @inline(__always) 146 | static func space(for height: CGFloat, baseline: CGFloat, lowestBaseline: CGFloat, highestBaseline: CGFloat) -> (up: CGFloat, down: CGFloat) { 147 | return ( 148 | // Up: 149 | // Lowest baseline will push it's item above by its length 150 | abs(max(lowestBaseline, 0)) + 151 | // Highest baseline will create a space, equal to its length, above the item 152 | abs(min(highestBaseline, 0)), 153 | // Down: 154 | max(0, height - max(0, baseline)) 155 | ) 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /Sources/Fit/LineSpacing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LineSpacing.swift 3 | // 4 | // 5 | // Created by Oleh Korchytskyi on 09.06.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | /// Defines the line spacing rule in the ``Fit`` layout. 12 | /// 13 | /// Also can be constructed using Integer or Float literal: 14 | /// ``` 15 | /// let spacing: LineSpacing = 8 // .fixed(8) 16 | /// let spacing: LineSpacing = 10.5 // .fixed(10.5) 17 | /// ``` 18 | public enum LineSpacing: ExpressibleByFloatLiteral, ExpressibleByIntegerLiteral { 19 | /// Uses **ViewSpacing** distance to determine preferred spacing, but not less then specified minimum. 20 | case viewSpacing(minimum: CGFloat) 21 | 22 | /// Fixed spacing between lines. 23 | case fixed(CGFloat) 24 | 25 | /// Uses **ViewSpacing** distance to determine preferred spacing. 26 | public static var viewSpacing: LineSpacing { 27 | .viewSpacing(minimum: -.infinity) 28 | } 29 | 30 | @inline(__always) 31 | func distance(between topViewSpacing: ViewSpacing, and bottomViewSpacing: ViewSpacing) -> CGFloat { 32 | switch self { 33 | case .viewSpacing(minimum: let minimumSpacing): 34 | max(minimumSpacing, topViewSpacing.distance(to: bottomViewSpacing, along: .vertical)) 35 | case .fixed(let spacing): 36 | spacing 37 | } 38 | } 39 | 40 | // MARK: ExpressibleByFloatLiteral 41 | /// Creates **.fixed** ``LineSpacing``. 42 | /// - Parameter value: spacing distance. 43 | public init(floatLiteral value: Double) { 44 | self = .fixed(value) 45 | } 46 | 47 | // MARK: ExpressibleByIntegerLiteral 48 | /// Creates **.fixed** ``LineSpacing``. 49 | /// - Parameter value: spacing distance. 50 | public init(integerLiteral value: Int) { 51 | self = .fixed(CGFloat(value)) 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /Sources/Fit/LineStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LineStyle.swift 3 | // 4 | // 5 | // Created by Oleh Korchytskyi on 18.01.2024. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | 12 | /// An alignment position along the horizontal axis for the line in ``Fit`` layout. 13 | public enum LineAlignment { 14 | case leading, center, trailing 15 | } 16 | 17 | /// Configure static or dynamic style for the ``Fit`` layout lines. 18 | /// 19 | /// Use public initialiser to define a static style: 20 | /// ```swift 21 | /// let style = LineStyle(alignment: .trailing) 22 | /// ``` 23 | /// 24 | /// To specify a custom dynamic style for each line use **.lineSpecific** static method: 25 | /// ```swift 26 | /// let customStyle: LineStyle = .lineSpecific { style, line in 27 | /// // reverse every second line 28 | /// style.reversed = (line.index + 1).isMultiple(of: 2) 29 | /// // if the line is reversed, it should start from the trailing edge 30 | /// style.alignment = style.reversed ? .trailing : .leading 31 | /// } 32 | /// ``` 33 | /// 34 | public struct LineStyle { 35 | 36 | public var alignment: LineAlignment = .leading 37 | 38 | /// Reversed lines starts to layout its elements starting from the last one 39 | public var reversed: Bool = false 40 | /// Stretched lines add an additional spacing between elements to fill the available space 41 | public var stretched: Bool = false 42 | /// A piece of information derived from the layout cache, representing current line 43 | public typealias LineInfo = Fit.LayoutCache.Line 44 | 45 | let specifier: (inout LineStyle, LineInfo) -> Void 46 | 47 | public init(alignment: LineAlignment = .leading, reversed: Bool = false, stretched: Bool = false) { 48 | self.alignment = alignment 49 | self.reversed = reversed 50 | self.stretched = stretched 51 | self.specifier = { _,_ in } 52 | } 53 | 54 | init(specifier: @escaping (inout LineStyle, LineInfo) -> Void) { 55 | self.specifier = specifier 56 | } 57 | 58 | /// Creates a dynamic style for the line. 59 | /// 60 | /// ```swift 61 | /// let customStyle: LineStyle = .lineSpecific { style, line in 62 | /// // reverse every second line 63 | /// style.reversed = (line.index + 1).isMultiple(of: 2) 64 | /// // if the line is reversed, it should start from the trailing edge 65 | /// style.alignment = style.reversed ? .trailing : .leading 66 | /// } 67 | /// ``` 68 | /// 69 | /// - Parameter specifier: a closure which will be called to specify the style for the particular line. 70 | /// - Returns: line style with saved specifier closure. 71 | /// 72 | /// Specifier can be called multiple times if layout engine will decide to reset the cache during the layout process. 73 | /// 74 | public static func lineSpecific(_ specifier: @escaping (inout LineStyle, LineInfo) -> Void) -> LineStyle { 75 | LineStyle(specifier: specifier) 76 | } 77 | 78 | 79 | func specific(for line: LineInfo) -> LineStyle { 80 | var style = self 81 | specifier(&style, line) 82 | return style 83 | } 84 | } 85 | 86 | 87 | -------------------------------------------------------------------------------- /Sources/Fit/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyTracking 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Tests/FitTests/FitTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Fit 3 | 4 | final class FitTests: XCTestCase { 5 | func testExample() throws { 6 | // XCTest Documentation 7 | // https://developer.apple.com/documentation/xctest 8 | 9 | // Defining Test Cases and Test Methods 10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods 11 | } 12 | } 13 | --------------------------------------------------------------------------------