0

I want to achieve a "chip" view where the first chips take a max width of 2/3 of the screen. If there are more chips a count like +2 should be displayed.

To achieve this I was thinking to kind of truncate an HStack. However, I would need to able to check the size of the chip before adding it to the HStack.

private func generateTags(in g: GeometryProxy) -> some View {
    var width = CGFloat.zero
    
    [...]        

    return HStack {
        ForEach(Array(tags.enumerated()), id: \.offset) { index, tag in
            if width < g.size.width * 0.5 {
                tagView(tag: tag)
                    .measureSize { size in
                        width += size.width
                    }
            }
        }
        
        Spacer()
    }
}

extension View {
  func measureSize(perform action: @escaping (CGSize) -> Void) -> some View {
    self.modifier(MeasureSizeModifier())
      .onPreferenceChange(SizePreferenceKey.self, perform: action)
  } 
}

struct SizePreferenceKey: PreferenceKey {
  static var defaultValue: CGSize = .zero

  static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
    value = nextValue()
  }
}

struct MeasureSizeModifier: ViewModifier {
  func body(content: Content) -> some View {
    content.background(GeometryReader { geometry in
    Color.clear.preference(key: SizePreferenceKey.self,
                         value: geometry.size)
    })
  }
}

enter image description here

You can actually ignore the map pin and camera icon. What I want to achieve are chips that use the full width but show a truncated info (e.g. +2) if there are more chips that do not fit.

gpichler
  • 2,181
  • 2
  • 27
  • 52

1 Answers1

2

Here is a solution to your problem using the new Layout protocol. I created a custom OverflowHStackLayout that does all the work.

It displays all subviews as long as they fit in the given space. The last subview in OverflowHStackLayout has to be the overflow label (which can be fully customised).

The tricky part was to report the nr of overflow items back to the view, which is solved now by using a LayoutValueKey.

If you want to dig into Layout yourself there is nothing better than: https://swiftui-lab.com/layout-protocol-part-1/

Have fun with it :)

enter image description here

struct Chip: Identifiable {
    let id = UUID()
    let icon: String
    let name: String
}

let chips = [
    Chip(icon: "", name: "Awesome"),
    Chip(icon: "️", name: "Weather"),
    Chip(icon: "", name: "Work"),
    Chip(icon: "", name: "Party"),
    Chip(icon: "⚽️", name: "Soccer"),
    Chip(icon: "", name: "Food"),
    Chip(icon: "", name: "Awesome"),
    Chip(icon: "️", name: "Weather"),
    Chip(icon: "", name: "Work"),
]


struct ContentView: View {
    
    @State private var overflowCount = 0 // keeps track of the nr of overflow items
    @State private var width = 500.0 // for testing only

    var body: some View {
        VStack {
            GeometryReader { geo in
                HStack {
                    
                    // Custom Layout, last subview is the overflow label
                    OverflowHStackLayout {
                        ForEach(chips) { chip in
                            ChipView(chip: chip)
                                .padding(.leading, 5)
                            
                        }
                        // Last view in this stack: Overflow label, using layoutvaluekey
                        Text("+ \(overflowCount)")
                            .font(.caption).bold()
                            .padding(.leading, 5)
                            .layoutValue(key: OverflowCounter.self, value: $overflowCount)
                    }
                    
                    // ... the rest is standard
                    Spacer()
                    
                    // pin And Camera View
                    Divider()
                    HStack {
                        ChipView(chip: Chip(icon: "", name: ""))
                        ChipView(chip: Chip(icon: "", name: ""))
                    }
                    .frame(width: geo.size.width / 3)
                }
            }
            // for testing only
            .border(.blue)
            .frame(width: width)
            Slider(value: $width, in: 100...1500)
        }
        .padding()
    }
}

struct ChipView: View {
    let chip: Chip
    var body: some View {
        HStack(spacing: 4) {
            Text(chip.icon)
            if chip.name.isEmpty == false {
                Text(chip.name)
                    .lineLimit(1)
                    .font(.caption)
                    .bold()
            }
        }
        .padding(6)
        .background(
            Capsule()
                .fill(.gray).opacity(0.2)
        )
    }
}


// LayoutValueKey to report nr of overflow items back to view
struct OverflowCounter: LayoutValueKey {
    static let defaultValue: Binding<Int>? = nil
}

// Custom Layout Struct, last subview is the overflow label
struct OverflowHStackLayout: Layout {
    
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {

        var totalHeight: CGFloat = 0
        var totalWidth: CGFloat = subviews.last?.sizeThatFits(.unspecified).width ?? 0
        
        let sizes = subviews.map { $0.sizeThatFits(.unspecified) }

        for size in sizes.dropLast() {
            if totalWidth + size.width <= (proposal.width ?? 0) {
                totalWidth += size.width
                totalHeight = max(totalHeight, size.height)
            }
        }

        return CGSize(width: totalWidth, height: totalHeight)
    }

    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {

        var leadingX = bounds.minX
        var runningWidth: CGFloat = subviews.last?.sizeThatFits(.unspecified).width ?? 0
        var overflowItemsCount = 0
        
        let sizes = subviews.map { $0.sizeThatFits(.unspecified) }

        for index in subviews.indices.dropLast() {
            if runningWidth + sizes[index].width <= (proposal.width ?? 0) {
                subviews[index].place(
                    at: CGPoint(x: leadingX, y: bounds.midY),
                    anchor: .leading,
                    proposal: ProposedViewSize(sizes[index])
                )
                runningWidth += sizes[index].width
                leadingX += sizes[index].width
            } else {
                overflowItemsCount += 1
                // place overflowing items out of screen
                subviews[index].place(at: CGPoint(x: -10000, y: -10000), proposal: .unspecified)
            }
        }
        
        if let last = subviews.indices.last {
            // if view has overflown, place last subview which is the overflow label
            if overflowItemsCount > 0 {
                subviews[last].place(
                    at: CGPoint(x: leadingX, y: bounds.midY),
                    anchor: .leading,
                    proposal: ProposedViewSize(sizes[last])
                )
                DispatchQueue.main.async {
                    subviews[last][OverflowCounter.self]?.wrappedValue = overflowItemsCount
                }

            } else {
                // place overflow label out of screen
                subviews[last].place(at: CGPoint(x: -10000, y: -10000), proposal: .unspecified)
            }
        }
    }
}
ChrisR
  • 9,523
  • 1
  • 8
  • 26