Home > Software design >  SwiftUI Expand Lists One-At-A-Time, Auto Collapse
SwiftUI Expand Lists One-At-A-Time, Auto Collapse

Time:03-05

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!

CodePudding user response:

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
      }
    }
  }
}
  • Related