Skip to content

Commit c13bd4d

Browse files
committed
Work on implementing conditional wrapping of pill views. Add more view modifiers and pill options
1 parent 9dbfaa0 commit c13bd4d

File tree

2 files changed

+157
-24
lines changed

2 files changed

+157
-24
lines changed

Example/PillPickerViewExample/PillPickerViewExample.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ struct ContentView: View {
5252

5353
/// PillPickerView usage example
5454
PillPickerView(items: colorPills, selectedPills: $selectedColors)
55+
.pillStackStyle(StackStyle.noWrap)
5556

5657
Text("Selected Colors:")
5758
.font(.system(size: 20, weight: .semibold, design: .rounded))

Sources/PillPickerView.swift

Lines changed: 156 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ public protocol Pill: Equatable, Hashable {
3838
var title: String { get }
3939
}
4040

41+
// MARK: - Enums
42+
43+
public enum StackStyle {
44+
case wrap
45+
case noWrap
46+
}
47+
4148
// MARK: - Pill customizations
4249

4350
public struct PillOptions {
@@ -52,8 +59,8 @@ public struct PillOptions {
5259
/// when animating in/out in its parent view
5360
public var animation: Animation = .spring()
5461

55-
/// Width of the pill
56-
public var width: CGFloat = 50
62+
/// Minimum width of the pill
63+
public var minWidth: CGFloat = 50
5764

5865
/// Height of the pill
5966
public var height: CGFloat = 15
@@ -78,6 +85,22 @@ public struct PillOptions {
7885
/// Padding of elements inside PillItem
7986
public var padding: CGFloat = 5
8087

88+
/// Whether pills should wrap to new line or not
89+
public var stackStyle: StackStyle = StackStyle.noWrap
90+
91+
/// Spacing applied vertically between pill rows
92+
public var verticalSpacing: CGFloat = 5
93+
94+
/// Spacing applied horizontally between pills
95+
public var horizontalSpacing: CGFloat = 2
96+
97+
/// The alignment of the pills in the `PillPickerView`
98+
/// when not wrapping the content
99+
public var staticAlignment: HorizontalAlignment = .leading
100+
101+
/// The alignment of the pills in the `PillPickerView`
102+
/// when it is wrapping the content
103+
public var wrappingAlignment: Alignment = .topLeading
81104
}
82105

83106
// MARK: - Main view
@@ -109,13 +132,16 @@ public struct PillPickerView<T: Pill>: View {
109132
// MARK: - Body
110133

111134
public var body: some View {
112-
FlowStack(items: items, viewGenerator: { item in
113-
PillView(
114-
options: options,
115-
item: item,
116-
selectedPills: $selectedPills
117-
)
118-
})
135+
switch options.stackStyle {
136+
case StackStyle.noWrap:
137+
StaticStack(options: options, items: items, viewGenerator: { item in
138+
PillView(options: options, item: item, selectedPills: $selectedPills)
139+
})
140+
case StackStyle.wrap:
141+
FlowStack(options: options, items: items, viewGenerator: { item in
142+
PillView(options: options, item: item, selectedPills: $selectedPills)
143+
})
144+
}
119145
}
120146
}
121147

@@ -164,9 +190,9 @@ public extension PillPickerView {
164190
}
165191

166192
/// The minimum width of each pill
167-
func pillWidth(_ value: CGFloat) -> PillPickerView {
193+
func pillMinWidth(_ value: CGFloat) -> PillPickerView {
168194
var view = self
169-
view.options.width = value
195+
view.options.minWidth = value
170196
return view
171197
}
172198

@@ -199,12 +225,49 @@ public extension PillPickerView {
199225
return view
200226
}
201227

228+
/// Padding of content inside each pill
202229
func pillPadding(_ value: CGFloat) -> PillPickerView {
203230
var view = self
204231
view.options.padding = value
205232
return view
206233
}
207234

235+
/// The stack style the PillPickerView uses, either wrapping
236+
/// the pills to new lines or having them statically placed
237+
func pillStackStyle(_ value: StackStyle) -> PillPickerView {
238+
var view = self
239+
view.options.stackStyle = value
240+
return view
241+
}
242+
243+
/// Set the vertical spacing of pills inside PillPickerView
244+
func pillViewVerticalSpacing(_ value: CGFloat) -> PillPickerView {
245+
var view = self
246+
view.options.verticalSpacing = value
247+
return view
248+
}
249+
250+
/// Set the horizontal spacing of pills inside PillPickerView
251+
func pillViewHorizontalSpacing(_ value: CGFloat) -> PillPickerView {
252+
var view = self
253+
view.options.horizontalSpacing = value
254+
return view
255+
}
256+
257+
/// Set alignment of pills when statically placed
258+
func pillViewStaticAlignment(_ value: HorizontalAlignment) -> PillPickerView {
259+
var view = self
260+
view.options.staticAlignment = value
261+
return view
262+
}
263+
264+
/// Set alignment of pills when dynamically placed and wrapping
265+
func pillViewWrappingAlignment(_ value: Alignment) -> PillPickerView {
266+
var view = self
267+
view.options.wrappingAlignment = value
268+
return view
269+
}
270+
208271
}
209272

210273
// MARK: - Child views
@@ -250,7 +313,7 @@ struct PillView<T: Pill>: View {
250313
}
251314
}
252315
.padding(options.padding)
253-
.frame(minWidth: options.width)
316+
.frame(minWidth: options.minWidth)
254317
})
255318
.buttonStyle(
256319
PillItemStyle(
@@ -322,7 +385,70 @@ struct PillItemStyle: ButtonStyle {
322385
}
323386
}
324387

388+
// MARK: - StaticStack
389+
390+
391+
/// Stack of pills not wrapping to a new line
392+
struct StaticStack<T, V>: View where T: Hashable, V: View {
393+
394+
/// Alias for function type generating content
395+
typealias ContentGenerator = (T) -> V
396+
397+
let options: PillOptions
398+
399+
/// Collection of items passed to view
400+
var items: [T]
401+
402+
/// Content generator function
403+
var viewGenerator: ContentGenerator
404+
405+
/// Chunk size which `items` is divided into
406+
@State private var chunkSize: Int = 1
407+
408+
private func calculateChunkSize(geometry: GeometryProxy) {
409+
let availableWidth = geometry.size.width
410+
let itemWidth: CGFloat = 100
411+
412+
chunkSize = max(Int(availableWidth / itemWidth), 1)
413+
}
414+
415+
var body: some View {
416+
GeometryReader { geometry in
417+
VStack(alignment: options.staticAlignment, spacing: options.verticalSpacing) {
418+
ForEach(items.chunked(into: chunkSize), id: \.self) { chunk in
419+
HStack(spacing: options.horizontalSpacing) {
420+
ForEach(chunk, id: \.self) { item in
421+
viewGenerator(item)
422+
}
423+
}
424+
}
425+
}
426+
.onAppear {
427+
calculateChunkSize(geometry: geometry)
428+
}
429+
430+
/// Dynamically generate chunk size based on
431+
/// screen direction and dimension
432+
.onChange(of: geometry.size.width) { _ in
433+
calculateChunkSize(geometry: geometry)
434+
}
435+
}
436+
}
437+
}
438+
439+
// MARK: - Extension
325440

441+
extension Array {
442+
443+
/// This method takes an integer `size`
444+
/// and returns a two-dimensional array ([[Element]])
445+
/// where the original array is divided into chunks of the specified size.
446+
func chunked(into size: Int) -> [[Element]] {
447+
stride(from: 0, to: count, by: size).map {
448+
Array(self[$0..<Swift.min($0 + size, count)])
449+
}
450+
}
451+
}
326452

327453
// MARK: - FlowStack
328454

@@ -336,17 +462,13 @@ public struct FlowStack<T, V>: View where T: Hashable, V: View {
336462
/// Alias for function type generating content
337463
typealias ContentGenerator = (T) -> V
338464

465+
let options: PillOptions
466+
339467
/// Collection of items passed to view
340468
var items: [T]
341469

342470
/// Content generator function
343471
var viewGenerator: ContentGenerator
344-
345-
/// Horizontal spacing of each item
346-
var horizontalSpacing: CGFloat = 2
347-
348-
/// Vertical spacing for each item
349-
var verticalSpacing: CGFloat = 0
350472

351473
/// Current total height calculated
352474
@State private var totalHeight = CGFloat.zero
@@ -359,7 +481,7 @@ public struct FlowStack<T, V>: View where T: Hashable, V: View {
359481
generateContent(in: geometry)
360482
}
361483
}
362-
.frame(height: totalHeight)
484+
.frame(maxHeight: totalHeight)
363485
}
364486

365487
// MARK: - Content Generation
@@ -368,11 +490,11 @@ public struct FlowStack<T, V>: View where T: Hashable, V: View {
368490
var width = CGFloat.zero
369491
var height = CGFloat.zero
370492

371-
return ZStack(alignment: .topLeading) {
493+
return ZStack(alignment: options.wrappingAlignment) {
372494
ForEach(items, id: \.self) { item in
373495
viewGenerator(item)
374-
.padding(.horizontal, horizontalSpacing)
375-
.padding(.vertical, verticalSpacing)
496+
.padding(.horizontal, options.horizontalSpacing)
497+
.padding(.vertical, options.verticalSpacing)
376498
.alignmentGuide(.leading, computeValue: { dimension in
377499
return calculateLeadingAlignment(dimension: dimension, item: item)
378500
})
@@ -385,8 +507,12 @@ public struct FlowStack<T, V>: View where T: Hashable, V: View {
385507

386508
// MARK: - Alignment calculations
387509

510+
/// Checks if adding the item's width to the current width value exceeds the
511+
/// available width (given by `geometry.size.width`). If it does, it resets width
512+
/// to 0 and subtracts the item's height from height to move to the next row.
513+
/// Otherwise, it returns the current `width` value and updates `width` by subtracting the item's width.
388514
func calculateLeadingAlignment(dimension: ViewDimensions, item: T) -> CGFloat {
389-
if abs(width - dimension.width) > geometry.size.width {
515+
if abs(width - (dimension.width + dimension.width / 2)) > geometry.size.width {
390516
width = 0
391517
height -= dimension.height
392518
}
@@ -399,6 +525,9 @@ public struct FlowStack<T, V>: View where T: Hashable, V: View {
399525
return result
400526
}
401527

528+
/// Used to calculate the top (vertical) alignment for each item.
529+
/// It receives the item itself and returns the current height value.
530+
/// If the item is the last one, it resets `height` to 0.
402531
func calculateTopAlignment(item: T) -> CGFloat {
403532
let result = height
404533
if item == items.last {
@@ -410,7 +539,10 @@ public struct FlowStack<T, V>: View where T: Hashable, V: View {
410539
}
411540

412541
// MARK: - Height Calculation
413-
542+
543+
/// Used to calculate the total height of the view. It wraps the ZStack
544+
/// in a GeometryReader to obtain the height of the content and updates
545+
/// the `totalHeight` state variable accordingly.
414546
private func viewHeightReader(_ binding: Binding<CGFloat>) -> some View {
415547
return GeometryReader { geometry -> Color in
416548
let rect = geometry.frame(in: .local)

0 commit comments

Comments
 (0)