Skip to content

Commit

Permalink
Fix alignment with dynamic spacing
Browse files Browse the repository at this point in the history
  • Loading branch information
dkk committed Apr 22, 2021
1 parent cb2500a commit 29a7f39
Show file tree
Hide file tree
Showing 4 changed files with 128 additions and 72 deletions.
76 changes: 47 additions & 29 deletions Sources/WrappingHStack/InternalWrappingHStack.swift
Expand Up @@ -7,55 +7,73 @@ struct InternalWrappingHStack: View {
var spacing: WrappingHStack.Spacing
var content: [WrappingHStack.ViewType]

var firstItems: [Int] {
return content.enumerated().reduce((firstItems: [], currentLineWidth: width)) { (result, contentIterator) -> (firstItems: [Int], currentLineWidth: CGFloat) in
var (firstItems, currentLineWidth) = result

switch contentIterator.element {
case .newLine:
return (firstItems + [contentIterator.offset], 0)
case .any(let anyView):
#if os(iOS)
let hostingController = UIHostingController(rootView: HStack(spacing: spacing.estimatedSpacing) { anyView })
#else
let hostingController = NSHostingController(rootView: HStack(spacing: spacing.estimatedSpacing) { anyView })
#endif
var firstItemOfEachLane: [Int] {
return content
.enumerated()
.reduce((firstItems: [], currentLineWidth: width)) { (result, contentIterator) -> (firstItemOfEachLane: [Int], currentLineWidth: CGFloat) in
var (firstItemOfEachLane, currentLineWidth) = result

let itemWidth = hostingController.view.intrinsicContentSize.width

if result.currentLineWidth + itemWidth + spacing.estimatedSpacing > width {
currentLineWidth = itemWidth
firstItems.append(contentIterator.offset)
} else {
currentLineWidth += itemWidth + spacing.estimatedSpacing
switch contentIterator.element {
case .newLine:
return (firstItemOfEachLane + [contentIterator.offset], width)
case .any(let anyView):
#if os(iOS)
let hostingController = UIHostingController(rootView: HStack(spacing: spacing.estimatedSpacing) { anyView })
#else
let hostingController = NSHostingController(rootView: HStack(spacing: spacing.estimatedSpacing) { anyView })
#endif

let itemWidth = hostingController.view.intrinsicContentSize.width

if result.currentLineWidth + itemWidth + spacing.estimatedSpacing > width {
currentLineWidth = itemWidth
firstItemOfEachLane.append(contentIterator.offset)
} else {
currentLineWidth += itemWidth + spacing.estimatedSpacing
}
return (firstItemOfEachLane, currentLineWidth)
}
return (firstItems, currentLineWidth)
}
}.0
}.0
}

var totalLanes: Int {
firstItems.count
firstItemOfEachLane.count
}

func startOf(lane i: Int) -> Int {
firstItems[i]
firstItemOfEachLane[i]
}

func endOf(lane i: Int) -> Int {
i == totalLanes - 1 ? content.count - 1 : firstItems[i + 1] - 1
i == totalLanes - 1 ? content.count - 1 : firstItemOfEachLane[i + 1] - 1
}

func hasExactlyOneElement(lane i: Int) -> Bool {
startOf(lane: i) == endOf(lane: i)
}

func shouldHaveSideSpacers(lane i: Int) -> Bool {
if case .constant = spacing {
return true
}
if case .dynamic = spacing, hasExactlyOneElement(lane: i) {
return true
}
return false
}

var body: some View {
VStack(alignment: alignment, spacing: 0) {
ForEach(0 ..< totalLanes, id: \.self) { laneIndex in
HStack(spacing: 0) {
if case .constant = spacing, alignment == .center || alignment == .trailing {
if alignment == .center || alignment == .trailing, shouldHaveSideSpacers(lane: laneIndex) {
Spacer(minLength: 0)
}

ForEach(startOf(lane: laneIndex) ... endOf(lane: laneIndex), id: \.self) {
if case .dynamicIncludingBorders = spacing, startOf(lane: laneIndex) == $0 {
if case .dynamicIncludingBorders = spacing,
startOf(lane: laneIndex) == $0
{
Spacer(minLength: spacing.estimatedSpacing)
}

Expand All @@ -75,7 +93,7 @@ struct InternalWrappingHStack: View {
}
}

if case .constant = spacing, alignment == .center || alignment == .leading {
if alignment == .center || alignment == .leading, shouldHaveSideSpacers(lane: laneIndex) {
Spacer(minLength: 0)
}
}
Expand Down
23 changes: 16 additions & 7 deletions Sources/WrappingHStack/WrappingHStack.swift
@@ -1,9 +1,13 @@
import SwiftUI

/// WrappingHStack is a UI Element that works in a very similar way to HStack, but automatically positions overflowing elements on next lines.
/// It can be customized by using alignment (controls the alignment of the items, it will get ignored when combined with a `.dynamic` spacing
/// for all but last lines with single elements), spacing (use `.constant` for fixed spacing and `.dynamic` to have the items fill the width
/// of the WrappingHSTack)
/// WrappingHStack is a UI Element that works in a very similar way to HStack,
/// but automatically positions overflowing elements on next lines.
/// It can be customized by using alignment (controls the alignment of the
/// items, it may get ignored when combined with `dynamicIncludingBorders`
/// or `.dynamic` spacing), spacing (use `.constant` for fixed spacing,
/// `.dynamic` to have the items fill the width of the WrappingHSTack and
/// `.dynamicIncludingBorders` to fill the full width with equal spacing
/// between items and from the items to the border.)
public struct WrappingHStack: View {
private struct CGFloatPreferenceKey: PreferenceKey {
static var defaultValue = CGFloat.zero
Expand Down Expand Up @@ -81,9 +85,14 @@ public extension WrappingHStack {
/// - Parameters:
/// - data: The items to show
/// - id: The `KeyPath` to use as id for the items
/// - alignment: Controls the alignment of the items. This will get ignored when combined with a `.dynamic` spacing for all
/// but last lines with single elements
/// - spacing: Use `.constant` for fixed spacing and `.dynamic` to have the items fill the width of the WrappingHSTack
/// - alignment: Controls the alignment of the items. This may get
/// ignored when combined with `.dynamicIncludingBorders` or
/// `.dynamic` spacing.
/// - spacing: Use `.constant` for fixed spacing, `.dynamic` to have
/// the items fill the width of the WrappingHSTack and
/// `.dynamicIncludingBorders` to fill the full width with equal spacing
/// between items and from the items to the border.
/// - content: The content and behavior of the view.
init<Data: RandomAccessCollection, Content: View>(_ data: Data, id: KeyPath<Data.Element, Data.Element> = \.self, alignment: HorizontalAlignment = .leading, spacing: Spacing = .constant(8), content: @escaping (Data.Element) -> Content) {
self.spacing = spacing
self.alignment = alignment
Expand Down
87 changes: 52 additions & 35 deletions WrappingHStackExample/WrappingHStackExample/ContentView.swift
Expand Up @@ -2,53 +2,70 @@ import SwiftUI
import WrappingHStack

struct ExampleView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Leading:")
.font(.headline)
WrappingHStack(1...7, id:\.self, alignment: .leading, spacing: .constant(0)) {
Text("Item: \($0)")
.padding(.all, 12)
.background(RoundedRectangle(cornerRadius: 10).stroke())
}
enum ExampleType: String, CaseIterable {
case leading, center, trailing, dynamicLeading, dynamicCenter, dynamicTrailing, dynamicIncludingBorders
}

@State var exampleType: ExampleType

func example(alignment: HorizontalAlignment, spacing: WrappingHStack.Spacing) -> some View {
WrappingHStack(alignment: alignment, spacing: spacing) {
Text("WrappingHStack")

Text("Center:")
.font(.headline)
WrappingHStack(1...7, id:\.self, alignment: .center, spacing: .constant(0)) {
Text("Item: \($0)")
.padding(.all, 12)
.background(RoundedRectangle(cornerRadius: 10).stroke())
}
Image(systemName: "scribble")
.font(.title)
.frame(width: 20, height: 20)
.background(Color.purple)

Text("Trailing:")
.font(.headline)
WrappingHStack(1...7, id:\.self, alignment: .trailing, spacing: .constant(0)) {
Text("Item: \($0)")
.padding(.all, 12)
.background(RoundedRectangle(cornerRadius: 10).stroke())
}
Text("1234567898")
.bold()

Text("Dynamic:")
.font(.headline)
WrappingHStack(1...7, id:\.self, alignment: .leading, spacing: .dynamic(minSpacing: 0)) {
Text("bcdefghijklmnopqrs")
.font(.title)

WrappingHStack(1...9, id:\.self, alignment: alignment, spacing: spacing) {
Text("Item: \($0)")
.padding(.all, 12)
.background(RoundedRectangle(cornerRadius: 10).stroke())
}
}.frame(width: 380, height: 150)
}
}


var body: some View {
switch exampleType {
case .leading:
example(alignment: .leading, spacing: .constant(0))

Text("Dynamic Including Borders:")
.font(.headline)
WrappingHStack(1...7, id:\.self, alignment: .leading, spacing: .dynamicIncludingBorders(minSpacing: 0)) {
Text("Item: \($0)")
.padding(.all, 12)
.background(RoundedRectangle(cornerRadius: 10).stroke())
}
case .center:
example(alignment: .center, spacing: .constant(0))

case .trailing:
example(alignment: .trailing, spacing: .constant(0))

case .dynamicLeading:
example(alignment: .leading, spacing: .dynamic(minSpacing: 0))

case .dynamicCenter:
example(alignment: .center, spacing: .dynamic(minSpacing: 0))

case .dynamicTrailing:
example(alignment: .trailing, spacing: .dynamic(minSpacing: 0))

case .dynamicIncludingBorders:
example(alignment: .leading, spacing: .dynamicIncludingBorders(minSpacing: 0))
}
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ExampleView()
Group {
ForEach(ExampleView.ExampleType.allCases, id: \.self) {
ExampleView(exampleType: $0)
.previewDisplayName($0.rawValue)
}
}
.previewLayout(.fixed(width: 380, height: 250))
}
}
Expand Up @@ -4,7 +4,19 @@ import SwiftUI
struct WrappingHStackExampleApp: App {
var body: some Scene {
WindowGroup {
ExampleView()
ScrollView {
VStack(alignment: .leading) {
ForEach(ExampleView.ExampleType.allCases, id: \.self) {
Text($0.rawValue)
.font(.title)
.padding(.horizontal)

ExampleView(exampleType: $0)

Divider()
}
}
}
}
}
}

0 comments on commit 29a7f39

Please sign in to comment.