Part 4 — Tags, ratings, and richer rows
Level: Intermediate · Time: ~20 minutes · Builds on: Part 3 — Custom themes, multi-window, and polish · Series 2, Part 1 of 3
Three tutorials in, your Contacts app is themed, shelled, and polished. This is where it starts turning into something with a pulse: segment your contacts, score the relationship, and upgrade every row from "name and email" to something a sales team would actually recognize.
We're also switching on new vocabulary — DFChip, DFRatingView, and DFEntityRow/DFEntityCard — all shipped since the last tutorial. If you've been away for a bit, this is the update.
What we're adding
-
DFChip— segment tags (Lead,Customer,Partner,VIP) with a filter row above the list. -
DFRatingView— a relationship score per contact, both read-only (list) and interactive (detail). -
DFEntityRow— a themed, content-rich summary row purpose-built for exactly this shape (media + title/subtitle + trailing metadata), replacing the hand-assembledDFListRowfrom Part 1. -
DFEntityCard— a "starred contacts" grid on the dashboard, the card-context sibling ofDFEntityRow. - A first look at what's coming — this feature set exists because a real CRM needs it, and DesignFoundationPro's CRM vertical ships all four screens we're about to hand-build.
1. Tag your contacts with DFChip
Add a segment to the model. Nothing DF-specific yet.
enum Segment: String, CaseIterable, Identifiable, Hashable {
case lead, customer, partner, vip
var id: String { rawValue }
var label: String {
switch self {
case .lead: "Lead"
case .customer: "Customer"
case .partner: "Partner"
case .vip: "VIP"
}
}
}
struct Contact: Identifiable, Hashable {
let id = UUID()
var name: String
var email: String
var role: String
var segment: Segment = .lead
var relationshipScore: Double = 3.0 // 0...5, powers DFRatingView
var initials: String { name.split(separator: " ").compactMap(\.first).prefix(2).map(String.init).joined() }
}
Now a filter row using DFChip's .selectable variant. Note that isSelected: is a separate parameter from the variant — it's UI state, not part of what the chip is.
struct SegmentFilterRow: View {
@Binding var selected: Segment?
var body: some View {
HStack(spacing: 8) {
DFChip(.selectable("All"), isSelected: selected == nil)
.onTapGesture { selected = nil }
ForEach(Segment.allCases) { segment in
DFChip(.selectable(segment.label), isSelected: selected == segment)
.onTapGesture { selected = segment }
}
}
.padding(.horizontal)
}
}
DFChip ships three styles — .filled (default), .tinted, .outlined — plus four variants: .label, .labelWithIcon(_:systemImage:), .dismissible(_:onDismiss:), and the .selectable one we just used. A dismissible chip is one line:
DFChip(.dismissible("Beta tester", onDismiss: { removeBetaTag() }))
Pro moment. This is the exact filter pattern Pro's CRM contacts screen ships — segment chips wired to a live count badge per segment, persisted across sessions. You just built the free-tier version of a screen Pro gives you finished.
2. Score the relationship with DFRatingView
A read-only score on the list, an editable one on the detail screen — same component, different mode:.
On the list row (read-only, default mode):
DFRatingView(value: contact.relationshipScore, maxValue: 5)
On the detail screen (interactive — tap or VoiceOver-adjust to change it):
struct ContactDetailView: View {
@State var contact: Contact
var body: some View {
ScrollView {
VStack(spacing: 16) {
DFCard {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 16) {
DFAvatar(contact.initials)
VStack(alignment: .leading, spacing: 4) {
DFText(contact.name, scale: .headline)
DFText(contact.email, scale: .caption)
}
Spacer()
DFChip(.label(contact.segment.label)).dfChipStyle(.tinted)
}
DFDivider()
HStack {
DFText("Relationship", scale: .body)
Spacer()
DFRatingView(
value: contact.relationshipScore,
mode: .interactive(onChange: { contact.relationshipScore = $0 })
)
}
}
}
// ...rest of the detail screen from Part 1...
}
.padding()
}
.dfNavigationBar(title: contact.name) { }
}
}
Two style options ship: .stars (default, half-star fill) and .numeric (e.g. "4.8 ★" — better for dense table-style layouts). Interactive mode is fully accessible out of the box — VoiceOver users get an adjustable control, not just a static image.
DFRatingView(value: contact.relationshipScore).dfRatingViewStyle(.numeric)
Pro moment. Lead scoring shows up across Pro's CRM (deal cards) and Analytics (customer health) verticals — the same component, doing the same job, in screens that are already wired to real data models.
3. Swap DFListRow for DFEntityRow
Here's the API distinction worth internalizing: DFListRow is a plain structural row — arbitrary @ViewBuilder leading/trailing slots, no styling opinions, no built-in variety. It's what you reach for when you're building something bespoke. DFEntityRow is the themed, opinionated sibling — a fixed vocabulary (DFEntityMedia, DFEntityTrailing) purpose-built for exactly the "media + title/subtitle + trailing metadata" shape that a contact, an order, or a search result all share.
We built DFEntityRow because we kept re-deriving this exact composition by hand — which is precisely what you did in Part 1. Let's replace it:
struct ContactListView: View {
@Environment(ContactStore.self) private var store
@State private var selectedSegment: Segment?
@State private var showAdd = false
private var visible: [Contact] {
guard let selectedSegment else { return store.contacts }
return store.contacts.filter { $0.segment == selectedSegment }
}
var body: some View {
VStack(spacing: 0) {
SegmentFilterRow(selected: $selectedSegment)
.padding(.vertical, 8)
if visible.isEmpty {
DFEmptyState(icon: "person.2.slash", title: "No contacts in this segment")
} else {
DFList(visible) { contact in
NavigationLink(value: contact) {
DFEntityRow(
media: .avatarInitials(contact.initials),
title: contact.name,
subtitle: contact.email,
trailing: .chevron
)
}
}
}
}
.dfNavigationBar(title: "Contacts") {
DFButton("Add") { showAdd = true }.dfButtonStyle(.tinted)
}
.navigationDestination(for: Contact.self) { ContactDetailView(contact: $0) }
.dfSheet(isPresented: $showAdd) { AddContactView().environment(store) }
}
}
Compare the row construction to Part 1's DFListRow version: fewer lines, same visual result, and it now composes cleanly with DFRatingView/DFChip if you want to put them in the trailing: slot instead of a chevron — DFEntityTrailing also ships .badge(_:) and .text(_:).
DFEntityRow(
media: .avatarInitials(contact.initials),
title: contact.name,
subtitle: contact.email,
trailing: .badge(contact.segment.label)
)
DFEntityMedia currently ships .systemImage(_:) and .avatarInitials(_:) — a deliberately fixed set, not an arbitrary view slot. That trade-off is the whole point: less flexibility, more consistency, faster to write.
4. A "starred contacts" grid with DFEntityCard
DFEntityCard is DFEntityRow's grid-context sibling — same media/title/subtitle/trailing vocabulary, vertical layout, wrapped in DFCard. Perfect for a small dashboard strip of your VIP contacts.
struct StarredContactsGrid: View {
let contacts: [Contact]
var body: some View {
DFGrid(columns: .adaptive(minWidth: 140)) {
ForEach(contacts) { contact in
DFEntityCard(
media: .avatarInitials(contact.initials),
title: contact.name,
subtitle: contact.role,
trailing: .badge(contact.segment.label),
onTap: { /* navigate */ }
)
}
}
.padding()
}
}
DFGrid reads its spacing from the theme and takes either .fixed(Int) columns or, as above, .adaptive(minWidth:) — as many columns as fit. We'll come back to DFGrid in the next tutorial for the deals board.
Pro moment. This grid is a simplified stand-in for Pro's CRM "Pipeline" screen, which does the same card layout with drag-to-reorder stages, deal values, and inline actions — already wired to
CRMPreviewFixtures-shaped data you'd swap for your own models.
What you built
- Segment tags on every contact, filterable with a
DFChiprow. - A relationship score, read-only in the list and editable on the detail screen, via one component in two modes.
- A themed, purpose-built
DFEntityRowreplacing the hand-assembledDFListRowcomposition from Part 1 — same job, less code. - A starred-contacts grid using
DFEntityCard+DFGrid.
You've now touched four of the eight components DesignFoundation shipped since the original series — all free, all MIT.
Coming in Part 5
We're giving your contacts a deal value. DFPriceView and DFPriceSummaryView for a quote breakdown, DFQuantityStepper for line-item quantities, and a real deals board built on DFGrid — grouped by stage, filterable with the same DFChip row you just wrote.
→ Part 5 — Deals, pricing, and a real board
Free tier vs. Pro, so far
Everything in this tutorial — chips, ratings, entity rows/cards, grid — is in the free package. Pro is what happens when the same components are pre-assembled into full CRM screens with real navigation, filters, and data plumbing. If you're already thinking "I wish this segment-filter row also had persisted state and a count badge," that's the Pro CRM vertical talking.
Top comments (0)