How to expand and collapse cells in SwiftUI

Animations are a crucial but often neglected part of good UI. Correctly used can guide used in the app. In this article, you will learn how to make a list cell that expands on tap and apply animation to it.

We will create a simple Todo List with subtasks.

Models

First of all, let’s make a models for this data.

struct Task: Identifiable {
    let id: String = UUID().uuidString
    let title: String
    let subtask: [Subtask]
}

struct Subtask: Identifiable {
    let id: String = UUID().uuidString
    let title: String
}

Of course, it won’t be a fully working example. In the final application, you will probably need additional fields for selected state, deadline, etc.

Cells

Then let’s focus on cells. I decided to go with three separate cells designs. One for Subtask model:

struct SubtaskCell: View {
    let task: Subtask
    
    var body: some View {
        HStack {
            Image(systemName: "circle")
                .foregroundColor(Color.primary.opacity(0.2))
            Text(task.title)
        }
    }
}

one when a new/empty subtask:

struct EmptySubtaskCell: View {
    @State private var text: String = ""
    
    var body: some View {
        HStack {
            Image(systemName: "circle")
                .foregroundColor(Color.primary.opacity(0.2))
            TextField("new task", text: $text)
        }
    }
}

and one for a Task model:

struct TaskCell: View {
    @State private var isExpanded: Bool = false
    
    let task: Task
    
    var body: some View {
        content
            .padding(.leading)
            .frame(maxWidth: .infinity)
    }
    
    private var content: some View {
        VStack(alignment: .leading, spacing: 8) {
            header
            if isExpanded {
                Group {
                    ForEach(task.subtask) { subtask in
                        SubtaskCell(task: subtask)
                    }
                    EmptySubtaskCell()
                }
                .padding(.leading)
            }
            Divider()
        }
    }
    
    private var header: some View {
        HStack {
            Image(systemName: "square")
                .foregroundColor(Color.primary.opacity(0.2))
            Text(task.title)
        }
        .padding(.vertical, 4)
        .onTapGesture {
            withAnimation { isExpanded.toggle() }
        }
    }
}

That last one will support expanding and collapsing, so it’s a bit bigger. Let’s make a small dissection.

In the body, we load content and ensure that the cell takes the entire width of a given view.

var body: some View {
    content
        .padding(.leading)
        .frame(maxWidth: .infinity)
}

The TaskCell will store information about its state. You can find the isExpanded @State property wrapper on the top of it.

@State private var isExpanded: Bool = false

The cell’s content displays a header where the title and checkbox are displayed and subtasks if the cell is expanded. At the bottom, it also shows the Divider to separate one task from another.

private var content: some View {
    VStack(alignment: .leading, spacing: 8) {
        header
        if isExpanded {
            Group {
                ForEach(task.subtask) { subtask in
                    SubtaskCell(task: subtask)
                }
                EmptySubtaskCell()
            }
            .padding(.leading)
        }
        Divider()
    }
}

The cell’s header is similar to the SubtaskCell, but it displays a square image instead of a circle. It also has the onTapGesture method, which toggles the isExpanded state.

 private var header: some View {
    HStack {
        Image(systemName: "square")
            .foregroundColor(Color.primary.opacity(0.2))
        Text(task.title)
    }
    .padding(.vertical, 4)
    .onTapGesture { isExpanded.toggle() }
}

ToDo List

With all these cells, you are ready to create a final view.

struct TasksView: View {
    private let tasks: [Task] = [
        Task(title: "Create playground", subtask: []),
        Task(title: "Write article", subtask: []),
        Task(
            title: "Prepare assets",
            subtask: [
                Subtask(title: "Cover image"),
                Subtask(title: "Screenshots")
            ]
        ),
        Task(title: "Publish article", subtask: [])
    ]
    
    var body: some View {
        NavigationView {
            ScrollView {
                ForEach(tasks) { task in
                    TaskCell(task: task)
                        .animation(.default)
                }
                .navigationTitle("Todo List")
            }
        }
    }
}

At the top are some static Tasks hardcoded (for the production-ready application, you should store them in application state.)

Body list all tasks using the ForEach view. And how I achieved this animation effect, you may ask. It was as simple as adding the .animation(.default) view modifier to the TaskCell view.

Summary

By following this tutorial, you will achieve an effect presented at the top of the article. With the help of SwiftUI, adding animations that delights the user experience can be straightforward.

You can find the full code of this project below:

struct Task: Identifiable {
    let id: String = UUID().uuidString
    let title: String
    let subtask: [Subtask]
}

struct Subtask: Identifiable {
    let id: String = UUID().uuidString
    let title: String
}

struct TasksView: View {
    private let tasks: [Task] = [
        Task(title: "Create playground", subtask: []),
        Task(title: "Write article", subtask: []),
        Task(
            title: "Prepare assets",
            subtask: [
                Subtask(title: "Cover image"),
                Subtask(title: "Screenshots")
            ]
        ),
        Task(title: "Publish article", subtask: [])
    ]
    
    var body: some View {
        NavigationView {
            ScrollView {
                ForEach(tasks) { task in
                    TaskCell(task: task)
                        .animation(.default)
                }
                .navigationTitle("Todo List")
            }
        }
    }
}

struct TaskCell: View {
    @State private var isExpanded: Bool = false
    
    let task: Task
    
    var body: some View {
        content
            .padding(.leading)
            .frame(maxWidth: .infinity)
    }
    
    private var content: some View {
        VStack(alignment: .leading, spacing: 8) {
            header
            if isExpanded {
                Group {
                    ForEach(task.subtask) { subtask in
                        SubtaskCell(task: subtask)
                    }
                    EmptySubtaskCell()
                }
                .padding(.leading)
            }
            Divider()
        }
    }
    
    private var header: some View {
        HStack {
            Image(systemName: "square")
                .foregroundColor(Color.primary.opacity(0.2))
            Text(task.title)
        }
        .padding(.vertical, 4)
        .onTapGesture { isExpanded.toggle() }
    }
}

struct SubtaskCell: View {
    let task: Subtask
    
    var body: some View {
        HStack {
            Image(systemName: "circle")
                .foregroundColor(Color.primary.opacity(0.2))
            Text(task.title)
        }
    }
}

struct EmptySubtaskCell: View {
    @State private var text: String = ""
    
    var body: some View {
        HStack {
            Image(systemName: "circle")
                .foregroundColor(Color.primary.opacity(0.2))
            TextField("new task", text: $text)
        }
    }
}