Skip to content

Instantly share code, notes, and snippets.

@johnkodes
Last active August 7, 2024 14:10
Show Gist options
  • Save johnkodes/38218ee1e6e7975e199a113f1ed695c2 to your computer and use it in GitHub Desktop.
Save johnkodes/38218ee1e6e7975e199a113f1ed695c2 to your computer and use it in GitHub Desktop.
SwiftUI Flow Layout
/// 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
@johnkodes
Copy link
Author

johnkodes commented Aug 7, 2024

Centering of the elements can be achieved by:

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
    )
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment