Part 4 — Tags, ratings, and richer rows

작성자

카테고리:

← 피드로
DEV Community · Nerd Snipe · 2026-07-21 개발(SW)

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

  1. DFChip — segment tags (Lead, Customer, Partner, VIP) with a filter row above the list.
  2. DFRatingView — a relationship score per contact, both read-only (list) and interactive (detail).
  3. 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.
  4. DFEntityCard — a “starred contacts” grid on the dashboard, the card-context sibling of DFEntityRow.
  5. 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

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다