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