Skip to content

Instantly share code, notes, and snippets.

@zef
Created January 16, 2020 22:50
Show Gist options
  • Star 12 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save zef/e48e44a3a673c36b5a0c3d0eefb676ce to your computer and use it in GitHub Desktop.
Save zef/e48e44a3a673c36b5a0c3d0eefb676ce to your computer and use it in GitHub Desktop.
A layout similar to a CollectionViewFlowLayout, but in SwiftUI.
// Created by Zef Houssney on 1/9/20.
// This takes some inspiration from https://gist.github.com/chriseidhof/3c6ea3fb2102052d1898d8ea27fbee07
// but uses a different approach.
// Instead of trying to calculate the position of each item and placing them manually using calculated coordinates,
// this calculates the amount of space each item wants to take up, then splits the items into rows that should fit
// within the available width.
// Then, the views are distributed into rows, made up of an HStack for each row inside a VStack.
//
// This seems non-conventional, but I was having trouble getting the frame of my top-level view to be respected when returning
// a GeometryReader and doing this conventionally.
//
// This should also make it pretty easy to use different alignment types for the stacks, providing more flexibility
// without having to re-implement those alignments.
import SwiftUI
struct CollectionView<Items, Content>: View where Items: RandomAccessCollection, Items.Element: Identifiable, Content: View {
var items: Items
var content: (Items.Element) -> Content
var horizontalSpacing: CGFloat
var verticalSpacing: CGFloat
init(_ items: Items, horizontalSpacing: CGFloat = 8, verticalSpacing: CGFloat = 8, content: @escaping (Items.Element) -> Content) {
self.items = items
self.content = content
self.horizontalSpacing = horizontalSpacing
self.verticalSpacing = verticalSpacing
}
@State var itemSizes = [SizePreference]()
@State var width: CGFloat = 0
struct Row: Identifiable {
let id: Int
var items: [Items.Element]
}
func rows(width: CGFloat) -> [Row] {
guard itemSizes.count == items.count else {
// if itemSizes isn't yet set, return a row for each item.
return items.enumerated().map { index, item in
return Row(id: index, items: [item])
}
}
var currentRowIndex = 0
var rowWidth: CGFloat = 0
var rows = [Row]()
for (item, size) in zip(items, itemSizes) {
let thisWidth = size.size.width
if (width - rowWidth - horizontalSpacing - thisWidth ) >= 0, !rows.isEmpty {
var row = rows.removeLast()
row.items.append(item)
rows.append(row)
rowWidth += horizontalSpacing + thisWidth
} else {
rows.append(Row(id: currentRowIndex, items: [item]))
currentRowIndex += 1
rowWidth = thisWidth
}
}
return rows
}
var unsizedItems: [Items.Element] {
return itemSizes.count == items.count ? [] : Array(items)
}
var body: some View {
VStack(alignment: .leading, spacing: self.verticalSpacing) {
ForEach(self.rows(width: width)) { row in
HStack(alignment: .top, spacing: self.horizontalSpacing) {
ForEach(row.items) { element in
self.content(element)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(GeometryReader { proxy in
// this is a phantom view that is used to calculate the item sizes,
// once calculated, they disappear from this collection and will be split into `rows` that are used above.
ZStack {
Color.clear.preference(key: SizePreferenceKey.self, value: proxy.size)
ForEach(self.unsizedItems) { element in
SizePreferenceReader(id: element.id, content: self.content(element))
}
}
.onPreferenceChange(SizePreferenceListKey.self) { sizes in
if sizes.count == self.items.count {
// wait until all sizes are calculated before assigning itemSizes
self.itemSizes = sizes
}
}
.onPreferenceChange(SizePreferenceKey.self) { size in
self.width = size.width
}
})
}
}
// used for storing the list of item sizes needed to display the items in rows
struct SizePreference: Equatable {
let id: AnyHashable
let size: CGSize
}
struct SizePreferenceListKey: PreferenceKey {
typealias Value = [SizePreference]
static var defaultValue = [SizePreference]()
static func reduce(value: inout [SizePreference], nextValue: () -> [SizePreference]) {
value.append(contentsOf: nextValue())
}
}
// used to store the overall width available to the CollectionView
struct SizePreferenceKey: PreferenceKey {
typealias Value = CGSize
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
struct SizePreferenceReader<ID: Hashable, V: View>: View {
var id: ID
var content: V
var body: some View {
content.background(GeometryReader { proxy in
Color.clear.preference(key: SizePreferenceListKey.self, value: [SizePreference(id: self.id, size: proxy.size)])
})
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment