Skip to content

Instantly share code, notes, and snippets.

@couchdeveloper
Last active September 22, 2021 09:47
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save couchdeveloper/003925233be98db7fcf60bf4a19f6522 to your computer and use it in GitHub Desktop.
Save couchdeveloper/003925233be98db7fcf60bf4a19f6522 to your computer and use it in GitHub Desktop.
TagsView
// Shows how to implement a view rendering a colleciton of "tags"
// which get wrapped to new rows as required.
import SwiftUI
struct WrappingHStack<Content: View, T: Identifiable & Hashable>: View {
typealias Row = [T]
typealias Rows = [Row]
struct Layout: Equatable {
/// An alignment position along the horizontal axis.
let cellAlignment: VerticalAlignment
/// The distance between adjacent cells in a row
let cellSpacing: CGFloat?
/// The distance between adjacent rows
let rowSpacing: CGFloat
/// The width of the view.
let width: CGFloat
}
private let data: [T]
private let content: (T) -> Content
private let layout: Layout
@State private var rows: Rows = Rows()
@State private var cellSizes: [CGSize] = [CGSize]()
/// Initialises a WrappingHStack instance.
/// - Parameters:
/// - data: An array of elements of type `T` whose elements are used to initialise a "cell" view.
/// - cellAlignment: An alignment position along the horizontal axis. If not specified the default is `firstTextBaseline`.
/// - cellSpacing: The spacing between the cell views, or `nil` if you want the view to choose a default distance.
/// - rowSpacing: The spacing between the rows, or `nil` if you want the view to choose a default distance.
/// - width: The available width for laying out the cells.
/// - content: Returns a cell view.
init(data: [T], cellAlignment: VerticalAlignment = .firstTextBaseline, cellSpacing: CGFloat? = nil, rowSpacing: CGFloat? = nil, width: CGFloat, content: @escaping (T) -> Content) {
self.data = data
self.content = content
self.layout = .init(
cellAlignment: cellAlignment,
cellSpacing: cellSpacing,
rowSpacing: rowSpacing ?? 4,
width: width)
}
var body: some View {
if self.layout.width > 10 {
VStack {
CellSizes(data: data, content: content, cellSizes: $cellSizes)
CellsView(rows: rows, layout: layout, content: content)
//.border(Color.green)
}
.onChange(of: cellSizes) { sizes in
self.rows = calculateRows(layout: layout)
}
.onChange(of: layout) { layout in
self.rows = calculateRows(layout: layout)
}
}
}
// Will be called when the layout changes. This happens whenever the
// orientation of the device changes or when the content views changes
// its size. This function is quite inexpensive, since the cell sizes will
// not be calclulated.
private func calculateRows(layout: Layout) -> Rows {
guard layout.width > 10 else {
return []
}
let cellSpacing = layout.cellSpacing ?? 4
let dataAndSize = zip(data, cellSizes)
var rows = [[T]]()
var availableSpace = layout.width
var elements = ArraySlice(dataAndSize)
while let first = elements.first {
var row = [first.0]
availableSpace -= first.1.width + cellSpacing
elements = elements.dropFirst()
while let next = elements.first, (next.1.width + cellSpacing) <= availableSpace {
row.append(next.0)
availableSpace -= next.1.width + cellSpacing
elements = elements.dropFirst()
}
rows.append(row)
availableSpace = layout.width
}
return rows
}
}
extension WrappingHStack {
struct CellSizes: View, Equatable {
let data: [T]
let content: (T) -> Content
@Binding var cellSizes: [CGSize]
// Populates a HStack with the calculated cell content. The size of each cell
// will be stored through a view preference accessible with key
// `SizeStorePreferenceKey`. Once the cells are layout, the completion
// callback `result` will be called with an array of CGSize
// representing the cell sizes as its argument. This should be used to store
// the size array in some state variable.
// Returns the hidden HStack. This HStack will never be rendered on screen.
// Will be called only when data or content changes. This is likely the
// most expensive part, since it requires calculating the size of each
// cell.
// TODO: figure out the default spacing between adjacent cells
var body: some View {
// Note: the HStack is required to layout the cells as _siblings_ which
// is required for the SizeStorePreferenceKey's reduce function to be
// invoked.
HStack() {
ForEach(data, id: \.id) { element in
content(element)
.calculateSize()
}
}
.onPreferenceChange(SizeStorePreferenceKey.self) { sizes in
cellSizes = sizes
}
.frame(width: 0, height: 0)
.hidden()
}
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.data == rhs.data
}
}
}
extension WrappingHStack {
struct CellsView: View {
let rows: [[T]]
let layout: Layout
let content: (T) -> Content
var body: some View {
VStack(alignment: .leading, spacing: layout.rowSpacing) {
ForEach(rows, id: \.self) { row in
HStack(alignment: layout.cellAlignment, spacing: layout.cellSpacing ?? 4) {
ForEach(row, id: \.id) { value in
if layout.cellSpacing == nil && value != row.first && value != row.last {
content(value)
.frame(maxWidth: .infinity)
} else {
content(value)
}
}
}
}
}
.frame(maxWidth: layout.width)
}
}
}
fileprivate struct SizeStorePreferenceKey: PreferenceKey {
static var defaultValue: [CGSize] = []
static func reduce(value: inout [CGSize], nextValue: () -> [CGSize]) {
value += nextValue()
}
}
fileprivate struct SizeStoreModifier: ViewModifier {
func body(content: Content) -> some View {
content
.background(
GeometryReader { geometry in
Color.clear
.preference(key: SizeStorePreferenceKey.self,
value: [geometry.size]
)
}
)
}
}
fileprivate struct RowStorePreferenceKey<T>: PreferenceKey {
typealias Row = [T]
typealias Value = [Row]
static var defaultValue: Value { Array<Row>() }
static func reduce(value: inout Value, nextValue: () -> Value) {
value = nextValue()
}
}
fileprivate extension View {
func calculateSize() -> some View {
modifier(SizeStoreModifier())
}
}
// MARK: - Preview
struct Cell: View {
let label: String
let fontSize: CGFloat
let backColor: Color
var body: some View {
Text(label)
.font(.system(size: fontSize))
.lineLimit(1)
.fixedSize()
.foregroundColor(.black)
.padding(.horizontal, 8)
.background {
GeometryReader { proxy in
RoundedRectangle(cornerRadius: proxy.size.width * 0.5)
.fill(backColor)
}
}
}
}
struct ContentView: View {
struct Element: Identifiable, Equatable, Hashable {
struct Color: Equatable, Hashable {
var red: Double
var green: Double
var blue: Double
}
let title: String
var id: String { title }
let color: Color = .random()
}
let cells = "nunc id cursus metus eleifend mi in nulla posuere sollicitudin aliquam ultrices sagittis orci a scelerisque purus semper eget"
.split(separator: " ")
.map { label in
Element(title: String(label))
}
@State private var width: Double = 1
var body: some View {
VStack {
GeometryReader() { proxy in
if proxy.size.width > 0 {
WrappingHStack(data: cells,
//cellSpacing: 8,
width: proxy.size.width * width) { cell in
Cell(label: cell.title, fontSize: 12, backColor: Color(color: cell.color))
}
}
//Text("Width: \(proxy.size.width * width)")
}
Spacer()
Slider.init(value: $width)
Text("Width: \(width)")
.padding()
}
}
}
extension ContentView.Element.Color {
static func random() -> ContentView.Element.Color {
ContentView.Element.Color(red: Double.random(in: 0.2...0.9),
green: Double.random(in: 0.5...0.9),
blue: Double.random(in: 0.2...0.9))
}
}
extension SwiftUI.Color {
init(color: ContentView.Element.Color) {
self.init(red: color.red, green: color.green, blue: color.blue)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
ContentView()
.frame(width: .infinity)
.navigationTitle("WrappedHStack")
}
.navigationViewStyle(.stack)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment