20
Nov
2020
Flow Layout / Tag Cloud in SwiftUI
Reading time: 2 min
A Flow Layout is a container that orders its views sequentially, breaking into a new "line" according to the available width of the screen. You can compare it to a left-aligned block of text, where every word is a View
. A common use for this layout is to create a tag cloud.
The end result looks something like this:
This component is available as a Swift Package in this repo.
There are a few points to go over before we jump to the code:
- The layout algorithm behaves differently if your
FlowLayout
is nested in aVStack
or a scrollable parent, such asScrollView
or aList
. Therefore, there's theMode
enum andmode
property. - The
binding
property is there to allow you to have a way to refresh the content of the entire layout via a state change. - The layout computes the positioning of its subviews on the fly, using the
alignmentGuide
method. - Total height of the layout is updated once its re-drawn, using a
GeometryReader
.
So, here's the code:
struct FlowLayout<B, T: Hashable, V: View>: View {
let mode: Mode
@Binding var binding: B
let items: [T]
let viewMapping: (T) -> V
@State private var totalHeight: CGFloat
init(mode: Mode, binding: Binding<B>, items: [T], viewMapping: @escaping (T) -> V) {
self.mode = mode
_binding = binding
self.items = items
self.viewMapping = viewMapping
_totalHeight = State(initialValue: (mode == .scrollable) ? .zero : .infinity)
}
var body: some View {
let stack = VStack {
GeometryReader { geometry in
self.content(in: geometry)
}
}
return Group {
if mode == .scrollable {
stack.frame(height: totalHeight)
} else {
stack.frame(maxHeight: totalHeight)
}
}
}
private func content(in g: GeometryProxy) -> some View {
var width = CGFloat.zero
var height = CGFloat.zero
return ZStack(alignment: .topLeading) {
ForEach(self.items, id: \.self) { item in
self.viewMapping(item)
.padding([.horizontal, .vertical], 4)
.alignmentGuide(.leading, computeValue: { d in
if (abs(width - d.width) > g.size.width) {
width = 0
height -= d.height
}
let result = width
if item == self.items.last {
width = 0
} else {
width -= d.width
}
return result
})
.alignmentGuide(.top, computeValue: { d in
let result = height
if item == self.items.last {
height = 0
}
return result
})
}
}
.background(viewHeightReader($totalHeight))
}
private func viewHeightReader(_ binding: Binding<CGFloat>) -> some View {
return GeometryReader { geo -> Color in
DispatchQueue.main.async {
binding.wrappedValue = geo.frame(in: .local).size.height
}
return .clear
}
}
enum Mode {
case scrollable, vstack
}
}
struct FlowLayout_Previews: PreviewProvider {
static var previews: some View {
FlowLayoutView(mode: .scrollable,
binding: .constant(5),
items: ["Some long item here", "And then some longer one",
"Short", "Items", "Here", "And", "A", "Few", "More",
"And then a very very very long one"]) {
Text($0)
.font(.system(size: 12))
.foregroundColor(.black)
.padding()
.background(RoundedRectangle(cornerRadius: 4)
.border(Color.gray)
.foregroundColor(Color.gray))
}.padding()
}
}