-
Notifications
You must be signed in to change notification settings - Fork 0
Lists Tables and Data
DesignFoundation ships seven data-display components: DFList, DFListRow, DFTable, DFDataTable, DFDataGrid, DFProgressBar, and DFSkeleton. This page documents each, including their initializers, style protocols, theming, accessibility behavior, and a component-selection guide at the end.
Related pages: Getting-Started · Theming · Layout · Primitives
DFList is a scrollable, themed list container with optional swipe-to-delete and drag-to-reorder support. It wraps SwiftUI's List with DF theming applied to selection tinting, separator color, and background.
Element must conform to Identifiable & Sendable.
public struct DFList<Element: Identifiable & Sendable, RowContent: View>: View {
public init(
_ data: [Element],
selection: Binding<Element.ID?>? = nil,
onDelete: ((IndexSet) -> Void)? = nil,
onMove: ((IndexSet, Int) -> Void)? = nil,
@ViewBuilder row: @escaping (Element) -> RowContent
)
}| Parameter | Type | Default | Description |
|---|---|---|---|
data |
[Element] |
required | The items to display. |
selection |
Binding<Element.ID?>? |
nil |
Optional selection binding. When non-nil, enables single-row selection. |
onDelete |
((IndexSet) -> Void)? |
nil |
When provided, enables swipe-to-delete. |
onMove |
((IndexSet, Int) -> Void)? |
nil |
When provided, enables drag-to-reorder. |
row |
@ViewBuilder (Element) -> RowContent |
required | A view builder returning a view for each element. |
- List background:
theme.colors.background - Row background:
theme.colors.surface - Selection tint:
theme.colors.primary - Separator color:
theme.colors.border
DFList(contacts) { contact in
DFListRow(contact.displayName, subtitle: contact.email, icon: "person.fill")
}@State private var selectedContact: Contact.ID? = nil
DFList(contacts, selection: $selectedContact) { contact in
DFListRow(contact.displayName, subtitle: contact.email)
}@State private var items = Item.samples
DFList(items, onDelete: { offsets in
items.remove(atOffsets: offsets)
}) { item in
DFListRow(item.title, subtitle: item.subtitle)
}DFList(tasks, onDelete: { tasks.remove(atOffsets: $0) },
onMove: { tasks.move(fromOffsets: $0, toOffset: $1) }) { task in
DFListRow(task.title, accessory: .checkmark(isOn: task.isComplete))
}ScrollView {
ForEach(sections) { section in
VStack(alignment: .leading, spacing: 0) {
DFText(section.title, style: .caption)
.foregroundColor(theme.colors.textSecondary)
.padding(.horizontal, theme.spacing.lg)
.padding(.vertical, theme.spacing.sm)
DFList(section.items) { item in
DFListRow(item.name)
}
}
}
}DFListRow is the standard list row. It composes icon, title, subtitle, and an accessory control into a consistent, accessible layout.
// 1. Title only
public init(_ title: String)
// 2. Title + subtitle
public init(_ title: String, subtitle: String)
// 3. Title + subtitle + icon (SF Symbol)
public init(_ title: String, subtitle: String? = nil, icon: String)
// 4. Title + subtitle + leading view (arbitrary)
public init<Leading: View>(
_ title: String,
subtitle: String? = nil,
@ViewBuilder leading: () -> Leading
)Apply with the accessory: label argument or .dfListRowAccessory(_:) modifier.
public enum DFListRowAccessory: Sendable {
case navigation // Trailing chevron
case checkmark(isOn: Bool) // Checkmark or blank
case toggle(Binding<Bool>) // Inline switch
case badge(DFBadgeContent) // Badge view
case custom(AnyView) // Arbitrary trailing view
}- Row height:
min(44, content)— at least 44 pt tall for HIG compliance - Horizontal padding:
theme.spacing.lg - Icon size: 20 × 20 pt (
DFIconSize.md) - Vertical padding:
theme.spacing.md
DFListRow uses .accessibilityElement(children: .combine) — screen readers announce icon + title + subtitle as a single phrase.
When accessory is .toggle, the toggle is focusable independently and announced separately.
// Minimal
DFListRow("Files")
// Title + subtitle
DFListRow("Backup", subtitle: "Last run 3 minutes ago")
// With icon
DFListRow("Messages", subtitle: "No new messages", icon: "message.fill")
// Navigation accessory
DFListRow("Account Settings")
.dfListRowAccessory(.navigation)
// Checkmark accessory
DFListRow("Use iCloud", accessory: .checkmark(isOn: useICloud))
// Toggle accessory
DFListRow("Dark mode", accessory: .toggle($isDarkMode))
// Badge accessory
DFListRow("Notifications", accessory: .badge(.count(5)))
// Custom leading content
DFListRow(
"Alex Nguyen",
subtitle: "Owner",
leading: {
DFAvatar(url: user.avatarURL, name: user.displayName, size: 36)
}
)DFTable renders a read-only, sortable, themed table. Columns are defined with DFTableColumn descriptors. Use DFDataTable (see below) for interactive selection, and DFDataGrid for editable cells.
public struct DFTableColumn<Row: Identifiable>: Sendable {
public let id: String
public let header: String
public let width: DFColumnWidth
public let sortable: Bool
public var cell: @Sendable (Row) -> AnyView
}
public enum DFColumnWidth: Sendable {
case fixed(CGFloat)
case flexible(min: CGFloat = 60, max: CGFloat = .infinity)
case fill // Takes remaining available width
}DFTable maintains its own sort state internally. When the user taps a sortable column header, it calls the onSort closure:
DFTable(
columns: columns,
rows: rows,
onSort: { columnId, ascending in
sortRows(by: columnId, ascending: ascending)
}
)struct Transaction: Identifiable, Sendable {
let id: UUID
let date: String
let description: String
let amount: String
let status: String
}
let columns: [DFTableColumn<Transaction>] = [
DFTableColumn(id: "date", header: "Date", width: .fixed(100), sortable: true) { AnyView(Text($0.date)) },
DFTableColumn(id: "description", header: "Description", width: .fill, sortable: true) { AnyView(Text($0.description)) },
DFTableColumn(id: "amount", header: "Amount", width: .fixed(90), sortable: false) { AnyView(Text($0.amount)) },
DFTableColumn(id: "status", header: "Status", width: .flexible(min: 80, max: 120), sortable: false) {
AnyView(DFBadge(text: $0.status, color: statusColor($0.status)))
},
]
DFTable(columns: columns, rows: transactions) { columnId, ascending in
transactions.sort(by: { compare($0, $1, by: columnId, ascending: ascending) })
}DFDataTable adds single-row or multi-row selection to the DFTable rendering. Platform-adaptive: on macOS it uses native table row selection styles; on iOS it highlights the selected row with theme.colors.primary.opacity(0.1).
public enum DFDataTableSelectionMode: Sendable {
case none
case single(Binding<String?>) // Selected row ID
case multiple(Binding<Set<String>>) // Selected row IDs
}On macOS, DFDataTable participates in focusable row navigation:
- Arrow keys move selection
- Space bar toggles selection in
.multiplemode - Return activates the selection action if provided
// Single selection with action on double-tap
DFDataTable(
columns: columns,
rows: invoices,
selection: .single($selectedInvoiceId)
) { invoiceId in
openInvoice(id: invoiceId)
}
// Multi-select with bulk action
DFDataTable(
columns: memberColumns,
rows: members,
selection: .multiple($selectedMemberIds)
)DFDataGrid is an editable, paginated, sortable table. It extends DFDataTable with inline cell editing and large-dataset strategies for when your data exceeds what SwiftUI can render synchronously.
public struct DFDataGridColumn<Row: Identifiable>: Sendable {
public let id: String
public let header: String
public let width: DFColumnWidth
public let sortable: Bool
public var editable: Bool
public var validator: ((String) -> DFValidationState)?
public var cell: @Sendable (Row) -> AnyView
public var editor: @Sendable (Row, @escaping (String) -> Void) -> AnyView
}public enum DFDataGridLargeDatasetStrategy: Sendable {
case pagination(pageSize: Int) // Paginated pages with prev/next controls
case virtualScrolling // LazyVStack-backed, rows de-allocated off screen
case none // All rows rendered; use for < 500 rows
}// In-memory data
public init<Row: Identifiable & Sendable>(
columns: [DFDataGridColumn<Row>],
rows: [Row],
selection: DFDataTableSelectionMode = .none,
largeDataStrategy: DFDataGridLargeDatasetStrategy = .none,
onEdit: ((Row, String, String) -> Void)? = nil,
onDelete: ((IndexSet) -> Void)? = nil
)
// Async data source
public init<Row: Identifiable & Sendable>(
columns: [DFDataGridColumn<Row>],
dataSource: DFDataGridDataSource<Row>,
selection: DFDataTableSelectionMode = .none,
largeDataStrategy: DFDataGridLargeDatasetStrategy = .pagination(pageSize: 50)
)| Parameter | Type | Default | Description |
|---|---|---|---|
columns |
[DFDataGridColumn<Row>] |
required | Column definitions |
rows |
[Row] |
required (sync init) | Data rows |
dataSource |
DFDataGridDataSource<Row> |
required (async init) | Async data provider |
selection |
DFDataTableSelectionMode |
.none |
Selection mode |
largeDataStrategy |
DFDataGridLargeDatasetStrategy |
.none |
Large data handling |
onEdit |
((Row, String, String) -> Void)? |
nil |
Callback: row, column ID, new value |
onDelete |
((IndexSet) -> Void)? |
nil |
Called with row indices to delete |
let columns: [DFDataGridColumn<User>] = [
DFDataGridColumn(
id: "name", header: "Name", width: .flexible(), sortable: true,
editable: true, validator: { v in v.isEmpty ? .error("Required") : .valid },
cell: { AnyView(Text($0.name)) },
editor: { user, commit in
AnyView(TextField("Name", text: .constant(user.name), onCommit: { commit(user.name) }))
}
),
DFDataGridColumn(
id: "email", header: "Email", width: .flexible(), sortable: true,
editable: true, validator: { v in v.contains("@") ? .valid : .error("Invalid") },
cell: { AnyView(Text($0.email)) },
editor: { user, commit in
AnyView(TextField("Email", text: .constant(user.email), onCommit: { commit(user.email) }))
}
),
DFDataGridColumn(
id: "role", header: "Role", width: .fixed(120), sortable: false,
editable: false, cell: { AnyView(DFBadge(text: $0.role)) }, editor: { _, _ in AnyView(EmptyView()) }
),
]
DFDataGrid(
columns: columns,
rows: users,
selection: .multiple($selectedUserIds),
onEdit: { user, columnId, newValue in updateUser(user, column: columnId, value: newValue) },
onDelete: { users.remove(atOffsets: $0) }
)DFDataGrid(
columns: logColumns,
dataSource: LogDataSource(api: logsAPI),
largeDataStrategy: .pagination(pageSize: 100)
)DFProgressBar shows linear or circular task completion state.
public init(value: Double, total: Double = 1.0, label: String? = nil)value must be in 0...total. Progress is clamped automatically.
| Style | Token | Description |
|---|---|---|
.linear |
.linear |
Full-width horizontal bar. Default. |
.linearLabeled |
.linearLabeled |
Horizontal bar with label above and percentage right. |
.circular |
.circular |
Ring progress indicator at 44 × 44 pt. |
.glass |
.glass |
Linear bar with material backing. iOS 26+. |
Apply with .dfProgressBarStyle(_:).
DFProgressBar applies .accessibilityValue("\(Int(value / total * 100))%") and .accessibilityLabel(label ?? "Progress") automatically.
// Simple linear
DFProgressBar(value: 0.68)
// Labeled
DFProgressBar(value: completedSteps, total: totalSteps, label: "Setup")
.dfProgressBarStyle(.linearLabeled)
// Circular
DFProgressBar(value: bytesDownloaded, total: totalBytes)
.dfProgressBarStyle(.circular)
// Themed color override
DFProgressBar(value: storageUsed, total: storageTotal)
.foregroundColor(storageUsed > 0.9 * storageTotal ? theme.colors.error : theme.colors.primary)
// Animated progress
DFProgressBar(value: progress)
.animation(.linear(duration: 0.3), value: progress)DFSkeleton is a shimmer-animated placeholder for content that is still loading. Use it to preserve layout space and reduce perceived load time.
public init(
width: CGFloat? = nil, // nil = fills available width
height: CGFloat,
shape: DFSkeletonShape = .roundedRect
)
public enum DFSkeletonShape: Sendable {
case rectangle
case roundedRect // uses theme.radius.sm
case circle
case pill // capsule shape
}The shimmer uses a left-to-right gradient sweep animating at theme.animation.slow. It is automatically disabled when accessibilityReduceMotion is enabled — the shimmer is replaced by a static low-opacity fill.
| Style | Token | Description |
|---|---|---|
.default |
default | Gradient shimmer over theme.colors.border fill. |
.dim |
.dim |
Darker, lower-contrast shimmer for light surfaces. |
.glass |
.glass |
Material-backed shimmer. iOS 26+. |
// Single text line
DFSkeleton(width: 180, height: 16)
// Circle avatar
DFSkeleton(width: 44, height: 44, shape: .circle)
// Full-width paragraph block
DFSkeleton(height: 12)
DFSkeleton(width: 260, height: 12)
DFSkeleton(width: 200, height: 12)
// Card skeleton composite
DFCard {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
DFSkeleton(width: 44, height: 44, shape: .circle)
VStack(alignment: .leading, spacing: 6) {
DFSkeleton(width: 120, height: 14)
DFSkeleton(width: 80, height: 12)
}
}
DFSkeleton(height: 12)
DFSkeleton(width: 220, height: 12)
DFSkeleton(width: 180, height: 12)
}
}
// List row skeletons while loading
if isLoading {
ForEach(0..<5) { _ in
HStack(spacing: 12) {
DFSkeleton(width: 44, height: 44, shape: .circle)
VStack(alignment: .leading, spacing: 6) {
DFSkeleton(width: 160, height: 14)
DFSkeleton(width: 100, height: 12)
}
}
.padding(.horizontal, theme.spacing.lg)
}
} else {
DFList(contacts) { contact in
DFListRow(contact.name, subtitle: contact.email, icon: "person.fill")
}
}| Requirement | Component |
|---|---|
| Simple scrollable list with swipe-to-delete | DFList |
| Standard list row with icon, title, subtitle | DFListRow |
| Read-only sortable table | DFTable |
| Sortable table with row selection | DFDataTable |
| Editable table with validation, pagination | DFDataGrid |
| Task or upload progress | DFProgressBar |
| Loading state placeholder | DFSkeleton |
See also: Layout · Primitives · Theming · Forms-and-Validation · Getting-Started