Last active
August 7, 2024 14:10
-
-
Save johnkodes/38218ee1e6e7975e199a113f1ed695c2 to your computer and use it in GitHub Desktop.
SwiftUI Flow Layout
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// Inspiration https://onmyway133.com/posts/how-to-make-tag-flow-layout-using-layout-protocol-in-swiftui/ | |
// swiftlint:disable:next file_types_order | |
public struct FlowLayout: Layout { | |
// inspired by https://onmyway133.com/posts/how-to-make-tag-flow-layout-using-layout-protocol-in-swiftui/ | |
private var horizontalSpacing: CGFloat | |
private var verticalSpacing: CGFloat | |
public init( | |
horizontalSpacing: CGFloat = 8, | |
verticalSpacing: CGFloat = 12 | |
) { | |
self.horizontalSpacing = horizontalSpacing | |
self.verticalSpacing = verticalSpacing | |
} | |
// size of the container | |
public func sizeThatFits( | |
proposal: ProposedViewSize, | |
subviews: Subviews, | |
cache _: inout () | |
) -> CGSize { | |
let childViewArranger = FlowLayoutChildViewArranger( | |
parentContainerSize: proposal.replacingUnspecifiedDimensions(), | |
subviews: subviews, | |
horizontalSpacing: horizontalSpacing, | |
verticalSpacing: verticalSpacing | |
) | |
let result = childViewArranger.makeArrangement() | |
return result.size | |
} | |
// childviews arrangement | |
public func placeSubviews( | |
in bounds: CGRect, | |
proposal: ProposedViewSize, | |
subviews: Subviews, | |
cache _: inout () | |
) { | |
let childViewArranger = FlowLayoutChildViewArranger( | |
parentContainerSize: proposal.replacingUnspecifiedDimensions(), | |
subviews: subviews, | |
horizontalSpacing: horizontalSpacing, | |
verticalSpacing: verticalSpacing | |
) | |
let result = childViewArranger.makeArrangement() | |
for (index, subviewFrame) in result.childViewFrames.enumerated() { | |
let point = CGPoint( | |
x: bounds.minX + subviewFrame.origin.x, | |
y: bounds.minY + subviewFrame.origin.y | |
) | |
subviews[index].place( | |
at: point, | |
anchor: .topLeading, | |
proposal: ProposedViewSize(subviewFrame.size) | |
) | |
} | |
} | |
} | |
private struct FlowLayoutChildViewArranger { | |
var parentContainerSize: CGSize | |
var subviews: LayoutSubviews | |
var horizontalSpacing: CGFloat | |
var verticalSpacing: CGFloat | |
struct FlowChildViewArrangement { | |
var size: CGSize | |
var childViewFrames: [CGRect] | |
} | |
/// Loops over subviews and place them in the parent, that they are not overflowing the parent container | |
func makeArrangement() -> FlowChildViewArrangement { | |
var childViewFrames = [CGRect]() | |
var currentRowFrames = [CGRect]() | |
var currentRowWidth: CGFloat = 0 | |
var yOffset: CGFloat = 0 | |
// Function to center and add frames for the current row | |
func centerAndAddCurrentRowFrames() { | |
let remainingSpace = parentContainerSize.width - currentRowWidth | |
let leftPadding = remainingSpace / 2 | |
for var frame in currentRowFrames { | |
frame.origin.x += leftPadding | |
childViewFrames.append(frame) | |
} | |
yOffset += (currentRowFrames.map { $0.height }.max() ?? 0) + verticalSpacing | |
currentRowFrames.removeAll() | |
currentRowWidth = 0 | |
} | |
for childView in subviews { | |
let currentChildViewSize = childView.sizeThatFits(ProposedViewSize(parentContainerSize)) | |
if currentRowWidth + currentChildViewSize.width > parentContainerSize.width && !currentRowFrames.isEmpty { | |
centerAndAddCurrentRowFrames() | |
} | |
let origin = CGPoint(x: currentRowWidth, y: yOffset) | |
let currentChildViewFrame = CGRect(origin: origin, size: currentChildViewSize) | |
currentRowFrames.append(currentChildViewFrame) | |
currentRowWidth += currentChildViewSize.width + horizontalSpacing | |
} | |
// Center and add frames for the last row | |
if !currentRowFrames.isEmpty { | |
centerAndAddCurrentRowFrames() | |
} | |
let maxWidth = parentContainerSize.width | |
let totalHeight = yOffset - verticalSpacing // Subtract last added vertical spacing | |
return FlowChildViewArrangement( | |
size: CGSize(width: maxWidth, height: totalHeight), | |
childViewFrames: childViewFrames | |
) | |
} | |
} | |
private enum Constants { | |
static let configurationVertical: CGFloat = 12 | |
static let configurationHorizontal: CGFloat = 15 | |
} | |
public struct MultiSelectionButtonStyle: ButtonStyle { | |
private let isSelected: Bool | |
public init(isSelected: Bool) { | |
self.isSelected = isSelected | |
} | |
public func makeBody(configuration: Configuration) -> some View { | |
configuration.label | |
.padding(.vertical, 10) | |
.padding(.horizontal, 10) | |
} | |
} | |
#if DEBUG | |
#Preview { | |
// swiftlint:disable no_magic_numbers | |
VStack(alignment: .center) { | |
FlowLayout { | |
ForEach(0...10, id: \.self) { number in | |
Button {} label: { | |
Text("Item \(number) \(String(repeating: "x", count: Int.random(in: 0...4)))") | |
} | |
.padding() | |
.foregroundColor(.white) | |
.background(.blue) | |
} | |
} | |
.padding() | |
FlowLayout(horizontalSpacing: 0, verticalSpacing: 20) { | |
ForEach(0...10, id: \.self) { number in | |
Button {} label: { | |
Text("Item \(number) \(String(repeating: "x", count: Int.random(in: 0...4)))") | |
} | |
.padding() | |
.foregroundColor(.white) | |
.background(.blue) | |
} | |
} | |
.padding() | |
} | |
// swiftlint:enable no_magic_numbers | |
} | |
#endif |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Centering of the elements can be achieved by: