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-assembled DFListRow from Part 1. DFEntityCard — a "starred contacts" grid on the dashboard, the card-context sibling of DFEntityRow. 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() } } Enter fullscreen mode Exit fullscreen mode 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) } } Enter fullscreen mode Exit fullscreen mode 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() })) Enter fullscreen mode Exit fullscreen mode 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) Enter fullscreen mode Exit fullscreen mode 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) { } } } Enter fullscreen mode Exit fullscreen mode 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) Enter fullscreen mode Exit fullscreen mode 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) } } } Enter fullscreen mode Exit fullscreen mode 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) ) Enter fullscreen mode Exit fullscreen mode 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() } } Enter fullscreen mode Exit fullscreen mode 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 DFChip row. A relationship score, read-only in the list and editable on the detail screen, via one component in two modes. A themed, purpose-built DFEntityRow replacing the hand-assembled DFListRow composition 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. → DesignFoundation Pro
Part 4 — Tags, ratings, and richer rows
Full Article
Original Source
Read the full article at Dev →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.