SwiftUI & Interface Design

Chapter 7
Collections and Data Presentation

Certificate in IOS Development
SwiftUI & Interface Design   •   Certificate

Collections and Data Presentation

Introduction

Most useful applications present collections of information: messages, products, tasks, contacts, search results, or records downloaded from a server. In SwiftUI, collection presentation is not simply a matter of placing many views on a screen. A professional interface must make data understandable, responsive, accessible, and resilient when data is loading, missing, changing, or incomplete.

This chapter examines the principal SwiftUI tools for presenting collections: List, LazyVGrid, LazyHGrid, sections, search, sorting, filtering, selection, swipe actions, refresh behavior, and loading and empty states. The discussion uses a simple task-management domain, but the design principles apply to many applications.

Learning Objectives

By the end of this chapter, you should be able to:

  • Choose between List, stacks, and lazy grids for different collection layouts.
  • Build identifiable, sectioned, and efficiently rendered data collections.
  • Implement searching, sorting, filtering, and selection.
  • Add swipe actions and pull-to-refresh behavior safely.
  • Design loading, error, empty, and populated states.
  • Recognize performance, accessibility, and data-consistency requirements.
  • Apply these techniques in a coherent SwiftUI screen.

The Collection Presentation Model

A collection interface has three related parts:

  1. Data model — the values being presented, such as tasks or products.
  2. Transformation — operations such as filtering, sorting, grouping, and searching.
  3. Presentation — rows, cells, sections, grids, controls, and state messages.

A useful design principle is to keep these responsibilities conceptually separate. The model should describe domain data; transformation logic should determine which values are visible and in what order; views should describe how visible values appear.

For example, a task may be represented as follows:

struct Task: Identifiable, Hashable {
    let id: UUID
    var title: String
    var dueDate: Date?
    var isCompleted: Bool
    var priority: Priority

    enum Priority: String, CaseIterable, Hashable {
        case low, normal, high
    }
}

Identifiable supplies a stable id, which SwiftUI uses to associate a data element with its rendered view. Stable identity is essential when rows are inserted, deleted, reordered, or updated. Using an array index as identity can cause incorrect animations, state being attached to the wrong row, and confusing accessibility behavior.

Hashable is useful for selection and other operations that require set membership. It should represent meaningful identity and value equality. If an object has a stable identifier, that identifier should not be changed while the object is displayed.

Choosing a Collection Container

List

List is the standard choice for vertically scrolling, row-oriented information. It provides platform-integrated behavior such as scrolling, row separators, editing support, swipe actions, selection, and accessibility conventions.

struct TaskListView: View {
    let tasks: [Task]

    var body: some View {
        List(tasks) { task in
            TaskRow(task: task)
        }
        .navigationTitle("Tasks")
    }
}

A List is appropriate when users scan one item per row, when rows need native editing behavior, or when the collection may be large. It is not a general-purpose replacement for every stack. Highly customized layouts may be easier with ScrollView and lazy stacks.

Lazy stacks

LazyVStack and LazyHStack create child views as they become necessary for display. They are useful when the interface requires custom backgrounds, cards, or a nonstandard layout.

ScrollView {
    LazyVStack(alignment: .leading, spacing: 12) {
        ForEach(tasks) { task in
            TaskCard(task: task)
        }
    }
    .padding()
}

“Lazy” refers primarily to view creation, not to all data processing. If code sorts, filters, or decodes a very large array before the view is built, laziness does not remove that cost.

Lazy grids

Use LazyVGrid for columns and LazyHGrid for rows. A grid is suitable for products, photographs, categories, and dashboards where each item has a compact visual representation.

let columns = [
    GridItem(.adaptive(minimum: 140), spacing: 16)
]

var body: some View {
    ScrollView {
        LazyVGrid(columns: columns, spacing: 16) {
            ForEach(products) { product in
                ProductCard(product: product)
            }
        }
        .padding()
    }
}

.adaptive(minimum:) allows SwiftUI to choose the number of columns based on available width. .fixed gives a precise item size, while .flexible distributes available space. Grid cells should remain understandable when their width changes; avoid placing essential text in a cell that becomes too narrow.

RequirementSuitable container
Standard vertical rowsList
Highly customized vertical contentScrollView + LazyVStack
Cards or thumbnails in columnsScrollView + LazyVGrid
Small, non-scrolling contentVStack or HStack

Sections and Grouping

Sections communicate structure. A task list might group items by date or completion status.

List {
    Section("Today") {
        ForEach(todayTasks) { task in
            TaskRow(task: task)
        }
    }

    Section("Upcoming") {
        ForEach(upcomingTasks) { task in
            TaskRow(task: task)
        }
    }
}

A section should represent a meaningful grouping, not merely divide a long screen into arbitrary blocks. Section headers must remain understandable when read by VoiceOver. If the collection is empty, avoid displaying a heading with no explanation; use a deliberate empty state instead.

For dynamic sections, a section model can be useful:

struct TaskSection: Identifiable {
    let id: String
    let title: String
    let tasks: [Task]
}

Grouping should normally occur after filtering and sorting, because the visible collection—not the unfiltered source—determines which groups should appear.

Searching, Filtering, and Sorting

These are different operations:

  • Searching matches user-entered text against one or more fields.
  • Filtering restricts data using a rule, such as “incomplete tasks.”
  • Sorting establishes an order, such as earliest due date first.

A predictable pipeline is:

source data → search → filters → sort → group → presentation

The exact implementation may vary, but the order should be intentional and documented. For example, sorting before filtering can be wasteful if a large portion of the data will be removed.

SwiftUI provides .searchable for search input:

struct TaskBrowser: View {
    @State private var searchText = ""
    @State private var showCompleted = true
    let tasks: [Task]

    var visibleTasks: [Task] {
        tasks
            .filter { task in
                showCompleted || !task.isCompleted
            }
            .filter { task in
                searchText.isEmpty ||
                task.title.localizedCaseInsensitiveContains(searchText)
            }
            .sorted { lhs, rhs in
                lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending
            }
    }

    var body: some View {
        List(visibleTasks) { task in
            TaskRow(task: task)
        }
        .searchable(text: $searchText, prompt: "Search tasks")
        .toolbar {
            Toggle("Show completed", isOn: $showCompleted)
        }
    }
}

localizedCaseInsensitiveContains is preferable to a simple case-sensitive comparison for ordinary user search. For more sophisticated search, consider tokenization, diacritic handling, ranking, and server-side search. Search interfaces should not imply that a locally loaded subset represents the complete server database.

Sorting should be stable from the user’s perspective. If two tasks have the same priority or date, add a deterministic secondary key such as title or identifier. Optional values require an explicit policy:

let sortedTasks = tasks.sorted {
    switch ($0.dueDate, $1.dueDate) {
    case let (left?, right?):
        return left < right
    case (_?, nil):
        return true       // undated tasks appear last
    case (nil, _?):
        return false
    case (nil, nil):
        return $0.title < $1.title
    }
}

There is no universally correct position for missing values. The important requirement is to choose and communicate a consistent rule.

Performance of transformations

Computed properties are clear, but they may be evaluated repeatedly as the view updates. For modest collections, this is normally acceptable. For large data sets, expensive work should be moved into a model or performed off the main actor, and the result should be published back to the UI. Database-backed frameworks such as SwiftData can perform predicates and sorting closer to the data source, reducing memory and processing costs.

Avoid filtering inside every row. Compute the visible collection once, then use ForEach to render it. Also avoid performing network requests from a row’s body; view evaluation is not a reliable one-time lifecycle event.

Selection and Editing

Selection is useful when users act on one or more records. In a list, selection can be bound to a value or a set of values.

struct SelectableTasksView: View {
    let tasks: [Task]
    @State private var selection = Set<Task.ID>()

    var body: some View {
        List(tasks, selection: $selection) { task in
            TaskRow(task: task)
        }
        .toolbar {
            Text("\(selection.count) selected")
        }
    }
}

The selection type must match the list’s identity type. A set supports multi-selection; a single optional identifier supports single selection. The interface should make selection mode discoverable and should provide an obvious action for completing, deleting, or exporting selected items.

Selection creates a data-consistency issue: an item may be deleted or filtered out while selected. After mutations, remove invalid identifiers from the selection set. Do not assume that a selected index remains valid after sorting or filtering.

For editing and deletion:

List {
    ForEach(tasks) { task in
        TaskRow(task: task)
    }
    .onDelete { offsets in
        deleteTasks(at: offsets)
    }
    .onMove { offsets, destination in
        moveTasks(from: offsets, to: destination)
    }
}
.toolbar { EditButton() }

The offsets refer to the currently displayed collection. If the list is filtered, map those offsets to the displayed values before mutating the source collection. Deleting source elements using offsets from a different array is a common bug.

Swipe Actions

Swipe actions provide contextual commands without permanently occupying row space.

.swipeActions(edge: .trailing, allowsFullSwipe: true) {
    Button(role: .destructive) {
        delete(task)
    } label: {
        Label("Delete", systemImage: "trash")
    }

    Button {
        archive(task)
    } label: {
        Label("Archive", systemImage: "archivebox")
    }
    .tint(.indigo)
}

Destructive actions should use role: .destructive, have an unambiguous label, and be recoverable where practical. A full-swipe gesture should not trigger an irreversible high-impact action without confirmation or undo. Swipe actions are not the only access path: provide equivalent commands through menus, detail screens, or accessibility actions.

Refresh Behavior and Asynchronous State

Pull-to-refresh is added with .refreshable:

List(visibleTasks) { task in
    TaskRow(task: task)
}
.refreshable {
    await viewModel.reload()
}

The asynchronous operation should represent genuine work and should update observable state. A refresh control is not a substitute for error handling. Users need to know whether data was successfully refreshed, remains stale, or failed.

A practical model distinguishes loading states rather than using a single Boolean:

enum LoadState<Value> {
    case idle
    case loading
    case loaded(Value)
    case failed(Error)
}

In a production application, the model may also retain previously loaded data while a refresh is in progress. This avoids replacing useful content with a blank spinner. For example, a refresh can show existing rows with a subtle progress indicator, while the initial load can show a full loading placeholder.

Concurrency considerations include:

  • Update UI-observed properties on the main actor.
  • Cancel obsolete searches when a new query arrives.
  • Prevent overlapping refresh operations unless they are deliberately supported.
  • Handle authorization, connectivity, decoding, and server errors distinctly.
  • Do not display a successful empty state when the request actually failed.

Loading, Empty, and Error States

A complete collection screen normally has at least four states:

StateMeaningAppropriate presentation
LoadingNo usable data has arrivedProgress indicator or skeleton placeholder
EmptyRequest succeeded, but no items matchExplanation and useful next action
ErrorData could not be obtainedError message and retry action
PopulatedItems are availableCollection with controls

These states have different meanings and must not be collapsed into one generic blank screen.

@ViewBuilder
var content: some View {
    switch viewModel.state {
    case .idle, .loading:
        ProgressView("Loading tasks…")

    case .loaded(let tasks) where tasks.isEmpty:
        ContentUnavailableView(
            "No Tasks",
            systemImage: "checklist",
            description: Text("Create a task to get started.")
        )

    case .loaded(let tasks):
        TaskList(tasks: tasks)

    case .failed:
        ContentUnavailableView {
            Label("Unable to Load Tasks", systemImage: "exclamationmark.triangle")
        } description: {
            Text("Check your connection and try again.")
        } actions: {
            Button("Retry") {
                Task { await viewModel.reload() }
            }
        }
    }
}

A filtered empty state is different from a genuinely empty database. “No results for ‘budget’” should offer a way to clear the search or filters. “No tasks yet” should offer creation. Empty-state wording should describe the condition and the next action.

Accessibility and Inclusive Data Presentation

Collection interfaces must work with Dynamic Type, VoiceOver, reduced motion, and different input methods.

Important practices include:

  • Use semantic Labels rather than unlabeled icons.
  • Ensure each row has a meaningful accessibility label and value.
  • Do not communicate status only through color; include text, symbols, or accessibility traits.
  • Test large text sizes so important information is not clipped.
  • Maintain logical reading order in grids.
  • Provide accessible alternatives to swipe-only actions.
  • Ensure touch targets are comfortably usable.
  • Avoid excessive animation when users request reduced motion.

A row containing a title, due date, and completion state should be read in an order that conveys meaning. Decorative images should be hidden from accessibility when they add no information; informative images need descriptions.

Performance and Data Integrity

Performance is a system property involving data access, transformation, view construction, image decoding, and interaction.

Stable identity

Use stable model identifiers. Do not use id: \.self for mutable values whose hash or equality can change while displayed. Do not use array indices as identity when items can move.

View cost

Keep row bodies lightweight. Defer expensive image processing, use appropriate image caching, and avoid deeply nested layouts when a simpler semantic structure will work. Lazy containers reduce initial view construction but do not make every operation free.

Data volume

For small collections, in-memory arrays and computed transformations are suitable. For large or persistent collections, use pagination, database predicates, incremental loading, and server-side filtering where appropriate. Pagination should communicate progress and avoid duplicate records when the underlying data changes.

Main-thread discipline

Rendering and user interaction must remain responsive. Network requests, file operations, parsing, and expensive transformations should not block the main actor. Swift concurrency provides structured mechanisms such as async/await, task cancellation, and actor isolation, but the design must still identify which operations are expensive.

Consistency

When users edit, delete, refresh, or filter data, the source of truth must be updated consistently. A row should not merely disappear visually while remaining in persistent storage. Optimistic updates can make interfaces feel responsive, but they require rollback when a server operation fails.

Illustrative Case: A Course Task Dashboard

Background

Consider an illustrative certificate-level project: a course task dashboard for students. Each task has a title, due date, priority, and completion status. Students need to locate tasks quickly, focus on incomplete work, and manage a growing list on both iPhone and iPad.

Problem

The initial design displays every task in insertion order. It has no search, provides no explanation when the course has no tasks, and shows a blank screen during network failures. On a large device, a single-column list also wastes space.

Analysis

The dashboard requires two presentation modes. A list is best for scanning due dates and completion states, while a grid can present course categories. The task transformation pipeline should search titles, filter completion status, sort by due date, and group tasks into overdue, today, and upcoming sections.

The design must also account for missing due dates. Undated tasks should appear after dated tasks and should be grouped separately if that distinction matters to students. Deletion must be reversible or confirmed. Refresh should preserve existing tasks while obtaining updated records.

Approach and solution

The view model owns the source tasks and loading state. The view derives visible tasks from the current search text and filter settings. The list uses stable task identifiers and sections. A toolbar exposes filtering and selection. Swipe actions offer complete and delete, with an undo mechanism for deletion. The initial loading state uses a progress indicator; an empty successful response offers “Add Task”; a filtered empty result offers “Clear Filters”; and a failed request offers retry.

On iPad, a grid of course categories can lead to the task list, but tasks themselves remain in a list because due dates and status are more legible horizontally than in small cards. This illustrates an important principle: responsive design is not simply making every collection a grid on larger screens; it is choosing the representation that best supports the task.

Outcome

The revised design reduces search time, makes system status visible, and provides a clear recovery path for failures. Stable identity prevents incorrect row animations, while derived visible data keeps filtering logic separate from row layout. Accessibility testing reveals that the completion icon needs a text label and that swipe actions require equivalent commands in the task detail screen.

Learning points

  • Match the container to the user’s information task.
  • Treat loading, empty, error, and filtered-empty states as distinct.
  • Keep transformations deterministic and separate from presentation.
  • Design for mutation, cancellation, accessibility, and recovery—not only the ideal populated screen.

Contemporary Practice and Ethical Considerations

Modern applications often combine local collections with remote services, analytics, and recommendation systems. Search and filtering may involve personal data, so developers should minimize collection, explain how data is used, and avoid exposing private records through suggestions or previews. A task title may contain sensitive educational, health, or workplace information.

AI-assisted search or categorization can be useful, but it should not silently change the meaning of user data. Users should be able to inspect, correct, and undo automated classifications. Ranking systems should not hide important records merely because they are less likely to be clicked. When data is synchronized across devices, conflict resolution should be understandable and should avoid silently overwriting a user’s changes.

Current SwiftUI practice also emphasizes adaptive layouts, observation-driven models, Swift concurrency, persistent data frameworks, and platform-specific interaction conventions. These tools improve implementation, but they do not replace careful information architecture or user testing.

Best-Practice Checklist

Before shipping a collection screen, verify that:

  • Every item has stable identity.
  • The chosen container matches the interaction and information density.
  • Search, filters, sorting, and grouping produce predictable results.
  • Optional values have explicit ordering and display rules.
  • Large data sets do not cause unnecessary main-thread work.
  • Selection remains valid after filtering and deletion.
  • Destructive actions are marked, recoverable, or confirmed.
  • Refresh handles success, failure, cancellation, and stale data.
  • Loading, empty, error, and populated states are distinct.
  • The interface works with Dynamic Type and VoiceOver.
  • Swipe actions have accessible alternatives.
  • Privacy and synchronization behavior are appropriate for the data.

Chapter Summary

SwiftUI collection design combines a data model, a transformation pipeline, and a presentation container. List is effective for structured rows and native editing behavior; lazy stacks support customized scrolling layouts; and lazy grids provide adaptive columns for compact visual items. Sections add meaning when they represent genuine groups.

Searching, filtering, sorting, and grouping should be deliberate and deterministic. Stable identity is essential for correct updates, animations, selection, and accessibility. Selection, editing, swipe actions, and refresh all require careful handling of mutations and asynchronous behavior. A professional collection screen distinguishes initial loading, successful emptiness, filtered emptiness, errors, and populated content. Finally, performance, accessibility, privacy, and recovery are not optional refinements: they are part of reliable interface design.

Key Terms

Stable identity: A persistent identifier that allows SwiftUI to associate data with the correct view.

Collection: A set or sequence of related data items presented to a user.

List: A SwiftUI container for vertically arranged, platform-integrated rows.

LazyVStack: A vertically scrolling stack that creates child views as needed.

LazyVGrid: A lazily constructed grid arranged in columns.

Section: A labeled group of related collection items.

Search: Matching user-entered text against searchable data.

Filter: A rule that excludes items not meeting specified conditions.

Sort: An operation that establishes the order of items.

Grouping: Organizing items into meaningful categories or sections.

Selection: The item or items currently chosen for an operation.

Swipe action: A contextual command revealed by swiping a row.

Refresh: An operation that requests a newer version of data.

Loading state: The interface condition while data is being obtained.

Empty state: A successful condition in which no items are available or visible.

Error state: A condition in which data retrieval or processing failed.

Source of truth: The authoritative data representation from which the UI is derived.

Lazy rendering: Deferring construction of views until they are needed for display.

Optimistic update: Updating the interface before a remote operation has definitively succeeded.

Dynamic Type: Apple’s accessibility feature for user-controlled text sizing.

Review Questions

Short Answer Questions

  1. Why is stable identity important when displaying a mutable collection?
  2. Distinguish searching, filtering, and sorting.
  3. When is a LazyVGrid more suitable than a List?
  4. Why should loading and error states be represented separately?
  5. Name two accessibility requirements for collection interfaces.

Long Answer Questions

  1. Explain how List, LazyVStack, and LazyVGrid differ, and recommend a suitable container for three different application scenarios.
  2. Describe a robust transformation pipeline for searchable, filterable, sortable task data. Explain how optional due dates should be handled.
  3. Explain the relationship between selection, stable identity, filtering, deletion, and reordering.
  4. Discuss how a SwiftUI application should manage initial loading, refresh, stale data, failure, and retry.
  5. Evaluate the performance implications of lazy containers, computed transformations, image loading, and large persistent collections.

Analytical / Application Questions

  1. Design the states and user actions for a product catalog that has no products, has no products matching a search, cannot connect to the server, and is successfully populated.
  2. A filtered list deletes the wrong task when the user swipes a row. Diagnose the likely cause and propose a correct implementation strategy.
  3. For a student dashboard containing 10,000 records, recommend an architecture for search, filtering, sorting, pagination, refresh, and accessibility. Justify each decision.

Multiple Choice Questions with Answers

Question 1

Which SwiftUI container is generally most appropriate for a vertically scrolling set of standard rows with native editing behavior?

A. List

B. Canvas

C. ZStack

D. TabView

Correct Answer: A. List

Question 2

Why should collection items have stable identifiers?

A. To force every row to use the same color

B. To associate data changes with the correct rendered view

C. To prevent all scrolling

D. To convert arrays into dictionaries

Correct Answer: B. To associate data changes with the correct rendered view

Question 3

What does the searchable modifier primarily provide?

A. A mechanism for entering and observing search text

B. Automatic server-side database indexing

C. Automatic deletion of matching records

D. A replacement for all sorting logic

Correct Answer: A. A mechanism for entering and observing search text

Question 4

Which operation removes items that do not satisfy a condition?

A. Sorting

B. Grouping

C. Filtering

D. Selection

Correct Answer: C. Filtering

Question 5

What is a major advantage of LazyVGrid?

A. It provides adaptive, lazily constructed columns

B. It automatically persists all data

C. It guarantees zero memory use

D. It replaces accessibility testing

Correct Answer: A. It provides adaptive, lazily constructed columns

Question 6

Which state means that a request succeeded but produced no visible items?

A. Error state

B. Empty state

C. Loading state

D. Cancellation state

Correct Answer: B. Empty state

Question 7

What is the recommended role for an irreversible delete swipe action?

A. destructive

B. cancel

C. selected

D. search

Correct Answer: A. destructive

Question 8

Why can using array indices as row identity cause problems?

A. Indices cannot be stored in memory

B. Indices change meaning when items are inserted, removed, or reordered

C. Indices prevent a List from scrolling

D. Indices automatically sort strings

Correct Answer: B. Indices change meaning when items are inserted, removed, or reordered

Question 9

Which modifier is commonly used to support pull-to-refresh with an asynchronous operation?

A. .refreshable

B. . redacted

C. .matchedGeometryEffect

D. .containerRelativeFrame

Correct Answer: A. .refreshable

Question 10

A filtered empty result differs from a genuinely empty database because it should usually offer which action?

A. Disable all navigation

B. Clear the search or filters

C. Delete the database

D. Hide the empty message

Correct Answer: B. Clear the search or filters

SwiftUI & Interface Design
Chapter 7 of 14