├── .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 |
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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------