SwiftUI’s evolution continues to simplify app development, and with SwiftUI 4.0, the new Table view brings an intuitive way to display tabular data.
Whether you’re building a productivity tool, managing records, or presenting structured lists, the Table API offers powerful capabilities with minimal code.
In this post, we’ll explore how to build dynamic tables using SwiftUI’s Table view, covering key features like multi-row selection, sorting, filtering, and expanding rows with the DisclosureTableRow.
By the end, you’ll be well-equipped to harness these new table features in your own SwiftUI apps.
Here is the GitHub Repo of full implementations and demonstration of all functionality.
This blog is also available as a Youtube video, feel free to check it out.
Before SwiftUI 4.0, developers had to rely on List
views or custom solutions to display tabular data. The introduction of Table
in macOS, iOS, and iPadOS provides a more intuitive and powerful way to handle data in a grid-like structure.
A SwiftUI Table
allows you to:
We’ll start by creating a Student
model, along with associated data for each student’s grade history. Our model will include a student’s name, ID, and grades for subjects like maths, science, and more.
// MARK: - Student Model
struct Student: Codable, Identifiable {
let id: String
let name: String
let gradeHistory: GradeHistory
var students: [Student] = []
enum CodingKeys: String, CodingKey {
case id, name
case gradeHistory = "grade_history"
case students
}
}
// MARK: - GradeHistory Model
struct GradeHistory: Codable, Identifiable{
let id: String?
let semester: String
let subjects: Subjects
init(
id: String? = UUID().uuidString,
semester: String,
subjects: Subjects
) {
self.id = id ?? UUID().uuidString
self.semester = semester
self.subjects = subjects
}
}
// MARK: - Subjects Model
struct Subjects: Codable, Identifiable {
let id: String?
let math: Int
let science: Int
let english: Int
let physics: Int
let computer: Int
let socialScience: Int
init(
id: String? = nil,
math: Int,
science: Int,
english: Int,
physics: Int,
computer: Int,
socialScience: Int
) {
self.id = id ?? UUID().uuidString
self.math = math
self.science = science
self.english = english
self.physics = physics
self.computer = computer
self.socialScience = socialScience
}
enum CodingKeys: String, CodingKey {
case id = "id"
case math = "Math"
case science = "Science"
case english = "English"
case physics = "Physics"
case computer = "Computer"
case socialScience = "Social Science"
}
}
To populate our table, we’ll create a list of sample student data, To do that I have added a JSON file that contains a long list of Student
data. and reading local files with one helper class that reads data from it and returns a decoded [Student]
struct.
Here is the StudentRepository
that uses that helper and gets our model.
class StudentRepository {
init() {
}
func getStudents() async -> Result<Students, Error> {
var students: Students = Students(students: [])
do {
students = try await JSONHelper
.readJSONFromFile(fileName: JSONHelper.templateName,
type: Students.self)
return Result.success(students)
} catch {
return Result.failure(error)
}
}
}
Now we will use this repository everywhere where we need students.
Now let’s create a simple table that displays the student’s name, ID, and their grades for each subject.
struct PlainTableView: View {
@State var viewModel: PlainTableViewModel
var body: some View {
Group {
Table(viewModel.students) {
TableColumn("Index") { student in
let index = (viewModel.students.firstIndex(
where: { $0.id == student
.id }) ?? 0)
Text("No. \(index + 1)")
}
TableColumn("Id", value: \.id)
TableColumn("Name", value: \.name)
.width(min: 150)
TableColumn("Math") { student in
Text("\(student.gradeHistory.subjects.math)")
.foregroundStyle(gradeColor(for: student.gradeHistory.subjects.math))
}
TableColumn("Science") { student in
Text("\(student.gradeHistory.subjects.science)")
.foregroundStyle(gradeColor(for: student.gradeHistory.subjects.science))
}
TableColumn("English") { student in
Text("\(student.gradeHistory.subjects.english)")
.foregroundStyle(gradeColor(for: student.gradeHistory.subjects.english))
}
TableColumn("Physics") { student in
Text("\(student.gradeHistory.subjects.physics)")
.foregroundStyle(gradeColor(for: student.gradeHistory.subjects.physics))
}
TableColumn("Computer") { student in
Text("\(student.gradeHistory.subjects.computer)")
.foregroundStyle(gradeColor(for: student.gradeHistory.subjects.computer))
}
TableColumn("Social Science") { student in
Text("\(student.gradeHistory.subjects.socialScience)")
.foregroundStyle(gradeColor(for: student.gradeHistory.subjects.socialScience))
}
}
.tint(Color.purple.opacity(0.7))
.navigationTitle("Plain Table")
.task {
await viewModel.fetchStudents()
}
}
}
// Helper function to set color based on grade
private func gradeColor(for grade: Int) -> Color {
switch grade {
case 90...100:
return .green
case 75..<90:
return .yellow
default:
return .red
}
}
}
Key Points:
TableColumn
.SwiftUI allows multi-row selection in tables, which is useful when users want to select multiple students for bulk actions.
Set
To track Selected RowsWe’ll track selected rows using a Set
of the student’s ID
: and to do that we’ll add that Set
to our viewModel
like following.
var selectedStudents: Set<Student.ID> = []
To make it a single selection, we will use options Student.ID?
instead Set
of ID
var selectedStudents: Student.ID? = nil
We bind the table’s selection
to the selectedStudents
set. And need to add EditButton
to enable edit mode for multiple selections like the following.
Table(students, selection: $selectedStudents) {
// TableColumns...
}
#if os(iOS)
.toolbar(content: {
EditButton()
})
#endif
With this setup, users can select multiple rows, and their IDs are stored in the selectedStudents
set.
Excited to see what we have done so far.
Here is how it looks.
That’s what we have done so far, so let’s move to the next thing.
Sorting allows users to view data in a preferred order. Let’s add sorting by student name and ID and subjects grade-wise.
Here we are going to sort Sutdents
by name ID and subject grade so that we can define KeyPathComparator
which sorts data with the provided path’s value like the following in our view.
var sortOrder = [
KeyPathComparator(\Student.name),
KeyPathComparator(\Student.gradeHistory.subjects.math)
]
TableColumns
We need to update table columns arguments to provide the right path to sort with like the following.
Table(viewModel.students,
sortOrder: $viewModel.sortOrder) {
TableColumn("Index") { student in
let index = (viewModel.students.firstIndex(
where: { $0.id == student
.id }) ?? 0)
Text("No. \(index + 1)")
}
TableColumn("Id", value: \.id)
TableColumn("Name", value: \.name)
.width(min: 150)
TableColumn("Math", value:\.gradeHistory.subjects.math) {
Text("\($0.gradeHistory.subjects.math)")
.foregroundStyle(gradeColor(for: $0.gradeHistory.subjects.math))
}
TableColumn("Science", value: \.gradeHistory.subjects.science) {
Text("\($0.gradeHistory.subjects.science)")
.foregroundStyle(gradeColor(for: $0.gradeHistory.subjects.science))
}
TableColumn("English", value: \.gradeHistory.subjects.english) {
Text("\($0.gradeHistory.subjects.english)")
.foregroundStyle(gradeColor(for: $0.gradeHistory.subjects.english))
}
TableColumn("Physics", value: \.gradeHistory.subjects.physics) {
Text("\($0.gradeHistory.subjects.physics)")
.foregroundStyle(gradeColor(for: $0.gradeHistory.subjects.physics))
}
TableColumn("Computer", value: \.gradeHistory.subjects.computer) {
Text("\($0.gradeHistory.subjects.computer)")
.foregroundStyle(gradeColor(for: $0.gradeHistory.subjects.computer))
}
TableColumn("Social Science", value: \.gradeHistory.subjects.socialScience) {
Text("\($0.gradeHistory.subjects.socialScience)")
.foregroundStyle(gradeColor(for: $0.gradeHistory.subjects.socialScience))
}
}
.onChange(of: viewModel.sortOrder) {
viewModel.students.sort(using: viewModel.sortOrder)
}
To manage sorting we’ll observe sortOrder
and on change of that sort our students
To do that, we’ll need to add the following code in our view.
.onChange(of: viewModel.sortOrder) {
viewModel.students.sort(using: viewModel.sortOrder)
}
It seems like magic, right? Let’s see how it works.
We can add filtering functionality to allow users to search for students by name, ID, or subject grades.
We add a Published
variable in our viewModel
to store the user’s search input:
We are adding just var
not adding Published
property wrapper as we are using the latest Observable
property wrapper on our viewModel
class. So all variables are default published.
var searchText: String = ""
We modify our students
to only show students that match the search text:
var _students: [Student] = []
var students: [Student] {
var data: [Student] = _students
if !searchText.isEmpty {
data = _students.filter { student in
student.name.lowercased().contains(searchText.lowercased()) ||
student.id.lowercased().contains(searchText.lowercased()) ||
"\(student.gradeHistory.subjects.math)".contains(searchText) ||
"\(student.gradeHistory.subjects.science)".contains(searchText) ||
"\(student.gradeHistory.subjects.english)".contains(searchText) ||
"\(student.gradeHistory.subjects.physics)".contains(searchText) ||
"\(student.gradeHistory.subjects.computer)".contains(searchText) ||
"\(student.gradeHistory.subjects.socialScience)".contains(searchText)
}
}
return data
}
Step 3: Add a Searchable Modifier
we add the searchable
modifier to the Table
view:
Table(viewModel.students,
columns: {
///TableColumn...
})
.searchable(text: $viewModel.searchText, prompt: "Search by Name id & grades")
This adds a search bar to the table, allowing users to filter data in real-time.
Interesting, isn’t it?
To demonstrate an expandable row, I have added var students: [Student] = []
in side the Student
model.
To show detailed student’s students data, We’ll use DisclosureTableRow
.
DisclosureTableRow
We’ll wrap the student’s students in DisclosureTableRow
, which reveals student details in the same table with one indent ahead, and it will make the row collapsible.
Table(
of: Student.self,
columns: {
/// TableColumn will remin as it is
}, rows: {
ForEach(viewModel.students) { student in
if student.students.isEmpty {
TableRow(student)
} else {
DisclosureTableRow(student) {
ForEach(student.students)
}
}
}
}
)
Here is the result:
Now let’s move to the last but not the least thing.
Adding a context menu enhances interactivity by providing quick access to common actions. For each row in the table, we can add a context menu with options such as:
Implementation Example
To implement the context menu, use the .contextMenu
modifier:
Table(
of: Student.self,
columns: {
/// TableColumn...
}, rows: {
ForEach(viewModel.students) { student in
TableRow(student)
.contextMenu {
Button("Edit") {
// TODO open editor in inspector
}
Button("See Details") {
viewModel
.showNavigationDetailScreen(student)
}
Divider()
Button("Delete", role: .destructive) {
viewModel.onDelete(student)
}
}
}
}
)
Here is the output:
SwiftUI’s Table view makes it incredibly easy to display and manage structured data.
In this tutorial, we covered how to:
DisclosureTableRow
for expanding rows to show additional information.With these tools, you can create powerful, interactive table views that offer rich functionality to users in your SwiftUI apps.
Here is the GitHub Repo with full implementations and a demonstration of all functionality.