Search code examples
swiftswiftuiswiftui-listswiftui-sheet

Sheet displayed several times in SwiftUI


Short description: in detailView I've got a List of a related entity. For each item, there's a button to open the edit sheet for this item.

List {
  if (book.booksBorrowers != nil) {
    ForEach (Array(book.booksBorrowers! as! Set<Borrowers>), id: \.self) { borrower in
      HStack {
        Text(borrower.firstName ?? "unbekannter Vorname")
        Text(borrower.lastName ?? "unbekannter Nachname")
        Text(String(format: "%.0f", borrower.age))
        Spacer()
        Button {
          showingBorrowerEditScreen.toggle()
        } label: {
          Image(systemName: "pencil")
            .frame(width: 20.0, height: 20.0)
        }.multilineTextAlignment(.center).buttonStyle(.borderless)
          .sheet(isPresented: $showingBorrowerEditScreen) {
          EditBorrowerView(
            aBorrower: borrower,
            firstName: borrower.firstName!,
            lastName: borrower.lastName!,
            age: Int(borrower.age)
            
          ).environment(\.managedObjectContext, self.viewContext)
        }
      }
    }.onDelete(perform: deleteBorrower)
  }
}.listStyle(.inset(alternatesRowBackgrounds: true))

On clicking one of the edit button within the list, a sheet appears with an edit form, prefilled with the values of the selected list item.

struct EditBorrowerView: View {
  @Environment(\.managedObjectContext) var moc
  @Environment(\.dismiss) var dismiss
  
  @State private var firstName = ""
  @State private var lastName = ""
  @State private var age = 0.0
  
  @StateObject var aBorrower: Borrowers

  init(aBorrower: Borrowers, firstName: String, lastName: String, age: Int) {
    self._aBorrower = StateObject(wrappedValue: aBorrower)
    self._firstName = State(initialValue: aBorrower.firstName ?? "")
    self._lastName = State(initialValue: aBorrower.lastName ?? "")
    self._age = State(initialValue: Double(aBorrower.age))
  }
  let formatter: NumberFormatter = {
    let formatter = NumberFormatter()
    formatter.numberStyle = .decimal
    formatter.minimumFractionDigits = 0
    formatter.maximumFractionDigits = 0
    return formatter
  }()

  var body: some View {
    VStack {
      Text("Ausleiher bearbeiten").font(.title)
      Form {
        VStack {
          TextField("Vorname", text: $firstName)
          TextField("Nachname", text: $lastName)
          HStack {
            Slider(value: $age, in: 0...99, step: 1)
            TextField("Alter", value: $age, formatter: formatter)
          }
        }
      }
      HStack {
        Button("Save") {
          // save the edited book
          aBorrower.firstName = firstName
          aBorrower.lastName = lastName
          aBorrower.age = Double(age)
          
          try? moc.save()
          dismiss()
        }
        Button("Cancel") {
          dismiss()
        }
      }
    }.padding(10)
  }
}

But now, when clicking the edit button of a row

  • a) only the content of the first list item is shown in the displayed form
  • b) clicking cancel in the sheet, the sheet will appear for every item in the list before disappearing, containing the respective values.

A small debugging with print() shows, that on clicking the edit button the correct values are set first (e.g. I click on third item, values of third item are passed), but additionally, all list items are passed to the sheet, too.


First image as example of upper question

Second image as example of upper question


Solution

  • If you have a .sheet(isPresented:) modifier in a view that is repeated in a loop, but your boolean state variable is outside the loop, then that one state variable is used for multiple copies of the sheet. Sometimes that may manifest as a sheet showing with details different to the row you clicked on; other times you might see multiple sheets. It sounds like the side effects you're seeing are related to this.

    There are a couple of ways you can get around this.

    Option 1 - sheet(item:)

    Replace your showingBorrowerEditScreen with a borrowerToEdit state variable that is an optional object, defaulting to nil:

    @State private var borrowerToEdit: Borrower? = nil
    

    In your button action, set this to the borrower of the current row in the loop:

    Button {
      borrowerToEdit = borrower
    } label: {
      // etc
    

    And finally, use the item: form of the sheet modifier outside the ForEach loop. Note that this form takes a block with a reference to the borrower object concerned.

    ForEach(...) { borrower in
      // etc.
    }
    .sheet(item: $borrowerToEdit) { borrower in 
      EditBorrowerView(
        aBorrower: borrower,
        firstName: borrower.firstName!,
        lastName: borrower.lastName!,
        age: Int(borrower.age)            
      )
    }
    

    Option 2 - individual subviews

    If you want to stick with booleans to represent whether a modal sheet should be used, you'll need to isolate that boolean to its own subview, by extracting the row details. For example:

    List {
      if (book.booksBorrowers != nil) {
        ForEach (Array(book.booksBorrowers! as! Set<Borrowers>), id: \.self) { borrower in
          BorrowerListRow(borrower: borrower)
          .onDelete(perform: deleteBorrower)
        }
      }
    }
    
    struct BorrowerListRow: View {
      @ObservedObject var borrower: Borrower
      @State private var showingBorrowerEditScreen = false
    
      var body: some View {
        HStack {
          Text(borrower.firstName ?? "unbekannter Vorname")
          Text(borrower.lastName ?? "unbekannter Nachname")
          Text(String(format: "%.0f", borrower.age))
          Spacer()
          Button {
            showingBorrowerEditScreen.toggle()
          } label: {
            Image(systemName: "pencil")
              .frame(width: 20.0, height: 20.0)
          }.multilineTextAlignment(.center).buttonStyle(.borderless)
            .sheet(isPresented: $showingBorrowerEditScreen) {
              EditBorrowerView(
                aBorrower: borrower,
                firstName: borrower.firstName!,
                lastName: borrower.lastName!,
                age: Int(borrower.age)
              ).environment(\.managedObjectContext, self.viewContext)
            }
        }
      }
    }
    

    That way, each row has its own "should I be showing a modal" Boolean, so there can be no confusion about which row "owns" the active sheet, and only one will be able to be shown at a time.


    Which option you go for is partly down to personal preference. I tend to favour option 1 in my own code, as it feels more that the modal is "owned" by the list rather than a row, and it reinforces the idea that you can only edit one borrower at a time. But either approach should eliminate the problems you're currently seeing.