Disable a `contextMenu` button conditionally depending on the list item

I'm using SwiftData with SwiftUI. I want to disable a contextMenu button on a list item conditionally depending on the list item itself.

E.g. I have a parent-child model as one-to-many relationship. On ParentListView, I need to disable the contextMenu button if parent.children is not empty.

Once user deletes the children in another ChildrenListView, switching back to the ParentView should now enable the delete button.

struct ParentListView: View {
  @Environment(\.modelContext) private var modelContext
  private var parents: [Parent]

  // Intention: only allow delete if parent.children is empty
  // And parent.children can be updated in another ChildrenView
  @State private var disableDelete = true

  var body: some View {
    List(parents) { parent in
      MyListItemView(parent)
        .contextMenu {
          Button {
            if parent.children.isEmpty {
              modelContext.delete(parent)
            }
          } label: {
            Text("Delete")
          }
          .disabled(disableDelete)
          // TODO: this doesn't work. Question: (a) why? (b) how to fix?
          .onAppear {
            if parent.children.isEmpty {
              disableDelete = false
            } else {
              disableDelete = true
            }
          }
        }
    }
  }
}

Question:

  1. The onAppear seems to be triggered upon rendering the whole ParentListView, not the contextMenu button. Why? (and what's the general rule when this can happen?)
  2. how to fix it? I think I'm flexible in terms of UI presentation. E.g. it doesn't have to be a contextMenu, it can be a button next to the list item, a command menu or something else suit for the job.

For context, I'm trying to find a workaround for SwiftData crash on deletion with relation by manually implementing the deleteRule: .deny.

Appendix: MRE

ExampleModel.swift

import SwiftData

@Model
final class Parent {
  @Attribute(.unique) var name: String
  @Relationship(deleteRule: .deny, inverse: \Child.parent)
  var children: [Child] = []
  init(name: String) {
    self.name = name
  }
}

@Model
final class Child {
  @Attribute(.unique) var name: String
  var parent: Parent

  init(name: String, parent: Parent) {
    self.name = name
    self.parent = parent
  }
}

ExampleView.swift

import SwiftData
import SwiftUI

struct ExampleView: View {
  @Environment(\.modelContext) private var modelContext
  @Query private var parents: [Parent]
  @Query private var children: [Child]
  @State private var disableDelete = true

  var body: some View {
    VStack {
      List(parents) { parent in
        Text(parent.name)
          .contextMenu {
            Button {
              modelContext.delete(parent)
            } label: {
              Text("Delete")
            }
            // TODO: this doesn't work. Question: (a) why? (b) how to fix?
            .disabled(disableDelete)
            .onAppear {
              if !parent.children.isEmpty {
                disableDelete = true
              } else {
                disableDelete = false
              }
            }
          }
      }

      Spacer()

      Button {
        addNewParent()
      } label: {
        Text("Add parent")
          .frame(maxWidth: .infinity)
          .bold()
      }
      .background()


      Divider()

      List(children) { child in
        Text("\(child.name) from: \(child.parent.name)")
          .contextMenu {
            Button {
              modelContext.delete(child)
            } label: {
              Text("Delete")
            }
          }
      }

      Spacer()

      Button {
        addNewChild()
      } label: {
        Text("Add child")
          .frame(maxWidth: .infinity)
          .bold()
      }
      .background()
    }
  }

  func addNewParent() {
    let newParent = Parent(
      name: "New Parent " + Int.random(in: 1...100).description
    )
    modelContext.insert(newParent)
  }

  func addNewChild() {
    let newChild = Child(
      name: "New Child " + Int.random(in: 1...100).description
      ,
      parent: parents.randomElement()!
    )
    modelContext.insert(newChild)
  }
}

#Preview {
  ExampleView()
    .modelContainer(
      for: [
        Parent.self,
        Child.self
      ], inMemory: true
    )
}

How to reproduce:

  1. AddParent
  2. Right click parent, expect delete button enabled
  3. AddChild
  4. Right click parent, expect delete button disabled

Screenshot: https://i.stack.imgur.com/AAvD6.png

Replies

Did you try this simpler code:

          .disabled(!parent.children.isEmpty)

suppressing .onAppear ?

  • .disabled(!parent.children.isEmpty)
    

    Only seems to capture the initial state. it doesn't enable/disable the button when parent.children is updated.

Add a Comment

Have you tried adding .onChange in addition to .onAppear?

List(parents) { parent in
  Text(parent.name)
    .contextMenu {
      Button {
        modelContext.delete(parent)
      } label: {
        Text("Delete")
      }
      .onAppear {
        disableDelete = !parent.children.isEmpty
      }
      .onChange(of: parent.children) {
        disableDelete = !parent.children.isEmpty
      }
    }
}
  • Thanks for the answer! This doesn't immediately solves it but leads to more insights. Adding some print statement it seems that the onAppear and onChange is effectively acting on the Text(parent.name), not the contextMenu Button. .onChange does print differently upon children change, but then all list items is now sharing the same disableDelete. This approach won't work as it would require an array of disableDelete to track each Text(parent.name).

Add a Comment