Search code examples
iosswiftswiftuiswiftui-list

SwiftUI Expand Lists One-At-A-Time, Auto Collapse


I'm trying to build a view with several collapsed lists, only the headers showing at first. When you tap on a header, its list should expand. Then, with the first list is expanded, if you tap on the header of another list, the first list should automatically collapse while the second list expands. So on and so forth, so only one list is visible at a time.

The code below works great to show multiple lists at the same time and they all expand and collapse with a tap, but I can't figure out what to do to make the already open lists collapse when I tap to expand a collapsed list.

Here's the code (sorry, kind of long):

import SwiftUI

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 SubtaskCell: View {
  let task: Subtask
  
  var body: some View {
    HStack {
      Image(systemName: "circle")
        .foregroundColor(Color.primary.opacity(0.2))
      Text(task.title)
    }
  }
}

struct TaskCell: View {
  var task: Task

  @State private var isExpanded = false

  var body: some View {
    content
      .padding(.leading)
      .frame(maxWidth: .infinity)
  }
  
  private var content: some View {
    VStack(alignment: .leading, spacing: 8) {
      header
      if isExpanded {
        Group {
          List(task.subtask) { subtask in
            SubtaskCell(task: subtask)
          }
        }
        .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()
      }
    }
  }
}

struct ContentView: View {
  
  //sample data
  private let tasks: [Task] = [
    Task(
      title: "Create playground",
      subtask: [
        Subtask(title: "Cover image"),
        Subtask(title: "Screenshots"),
      ]
    ),
    Task(
      title: "Write article",
      subtask: [
        Subtask(title: "Cover image"),
        Subtask(title: "Screenshots"),
      ]
    ),
    Task(
      title: "Prepare assets",
      subtask: [
        Subtask(title: "Cover image"),
        Subtask(title: "Screenshots"),
      ]
    ),
    Task(
      title: "Publish article",
      subtask: [
        Subtask(title: "Cover image"),
        Subtask(title: "Screenshots"),
      ]
    ),
  ]
  
  var body: some View {
    NavigationView {
      VStack(alignment: .leading) {
        ForEach(tasks) { task in
          TaskCell(task: task)
            .animation(.default)
        }
        Spacer()
      }
    }
  }
}

Thanks ahead for any help!

EDIT: Here's the collapse functionality to go with the accepted solution below: Update the onTapGesture in private var header: some View to look like this:

    .onTapGesture {
      withAnimation {
        if task.isExpanded {
          viewmodel.collapse(task)
        } else {
          viewmodel.expand(task)
        }
      }
    }

Then add the collapse function to class Viewmodel

  func collapse(_ taks: TaskModel) {
    var tasks = self.tasks
    tasks = tasks.map {
      var tempVar = $0
      tempVar.isExpanded = false
      return tempVar
    }
    self.tasks = tasks
  }

That's it! Fully working as requested!


Solution

  • I think the best way to achieve this is to move the logic to a viewmodel.

    struct TaskModel: Identifiable {
       let id: String = UUID().uuidString
       let title: String
       let subtask: [Subtask]
       var isExpanded: Bool = false // moved state variable to the model
    }
    
    struct Subtask: Identifiable {
        let id: String = UUID().uuidString
        let title: String
    }
    
    struct SubtaskCell: View {
        let task: Subtask
        
        var body: some View {
            HStack {
                Image(systemName: "circle")
                    .foregroundColor(Color.primary.opacity(0.2))
                Text(task.title)
            }
        }
    }
    
    struct TaskCell: View {
        var task: TaskModel
        @EnvironmentObject private var viewmodel: Viewmodel //removed state here and added viewmodel from environment
        
        var body: some View {
            content
                .padding(.leading)
                .frame(maxWidth: .infinity)
        }
        
        private var content: some View {
            VStack(alignment: .leading, spacing: 8) {
                header
                if task.isExpanded {
                    Group {
                        List(task.subtask) { subtask in
                            SubtaskCell(task: subtask)
                        }
                    }
                    .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 {
                    viewmodel.expand(task) //handle expand / collapse here
                }
            }
        }
    }
    
    struct ContentView: View {
        @StateObject private var viewmodel: Viewmodel = Viewmodel() //Create viewmodel here
        
        var body: some View {
            NavigationView {
                VStack(alignment: .leading) {
                    ForEach(viewmodel.tasks) { task in //use viewmodel tasks here
                        TaskCell(task: task)
                            .animation(.default)
                            .environmentObject(viewmodel)
                    }
                    Spacer()
                }
            }
        }
    }
    
    class Viewmodel: ObservableObject{
    @Published var tasks: [TaskModel] = [
        TaskModel(
            title: "Create playground",
            subtask: [
                Subtask(title: "Cover image"),
                Subtask(title: "Screenshots"),
            ]
        ),
        TaskModel(
            title: "Write article",
            subtask: [
                Subtask(title: "Cover image"),
                Subtask(title: "Screenshots"),
            ]
        ),
        TaskModel(
            title: "Prepare assets",
            subtask: [
                Subtask(title: "Cover image"),
                Subtask(title: "Screenshots"),
            ]
        ),
        TaskModel(
            title: "Publish article",
            subtask: [
                Subtask(title: "Cover image"),
                Subtask(title: "Screenshots"),
            ]
        ),
    ]
    
    func expand(_ task: TaskModel){
        //copy tasks to local variable to avoid refreshing multiple times
        var tasks = self.tasks
        
        //create new task array with isExpanded set
        tasks = tasks.map{
            var tempVar = $0
            tempVar.isExpanded = $0.id == task.id
            return tempVar
        }
        
        // assign array to update view
        self.tasks = tasks
    }
    }
    

    Notes:

    • Renamed your task model as it is a very bad idea to name somthing with a name that is allready used by the language
    • This only handles expanding. But implementing collapsing shouldn´t be to hard :)

    Edit:

    if you dont want a viewmodel you can use a binding as alternative:

    Add to your containerview:

        @State private var selectedId: String?
    

    change body to:

    NavigationView {
      VStack(alignment: .leading) {
        ForEach(tasks) { task in
            TaskCell(task: task, selectedId: $selectedId)
            .animation(.default)
        }
        Spacer()
      }
    }
    

    and change your TaskCell to:

    struct TaskCell: View {
      var task: TaskModel
    
        @Binding var selectedId: String?
    
      var body: some View {
        content
          .padding(.leading)
          .frame(maxWidth: .infinity)
      }
      
      private var content: some View {
        VStack(alignment: .leading, spacing: 8) {
          header
            if selectedId == task.id {
            Group {
              List(task.subtask) { subtask in
                SubtaskCell(task: subtask)
              }
            }
            .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 {
              selectedId = selectedId == task.id ? nil : task.id
          }
        }
      }
    }