Instantly share code, notes, and snippets.
Last active
September 22, 2021 09:47
-
Star
(1)
1
You must be signed in to star a gist -
Fork
(0)
0
You must be signed in to fork a gist
-
Save couchdeveloper/003925233be98db7fcf60bf4a19f6522 to your computer and use it in GitHub Desktop.
TagsView
This file contains 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
// 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