Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updated ReminderForm to use more standard SwiftUI #13

Closed
wants to merge 6 commits into from

Conversation

malhal
Copy link

@malhal malhal commented Feb 17, 2025

Replaced all of ReminderForm's State(wrappedValue:) with a Binding. This made it possible to remove ReminderForm's custom init. Added ReminderFormConfig for the form's data and logic. Change from .sheet(item:) which is for static content to .sheet(isPresented:) which is for dynamic. Replaced the Environment var dismiss with isPresented = false.

There are other instances of State(wrappedValue:) in the project which could also be replaced with Binding.

malhal and others added 2 commits February 17, 2025 12:41
Replaced all of ReminderForm's State(wrappedValue:) with a Binding. This made it possible to remove ReminderForm's custom init. Added ReminderFormConfig for the form's data and logic. Change from .sheet(item:) which is for static content to .sheet(isPresented:) which is for dynamic. Replaced the Environment dismiss with isPresented = false.

There are other instances of State(wrappedValue:) in the project which could also be replaced with Binding.
* Fix dynamic queries example

* Update Sources/SharingGRDB/Documentation.docc/Articles/DynamicQueries.md

* Update Sources/SharingGRDB/Documentation.docc/Articles/DynamicQueries.md

---------

Co-authored-by: Stephen Celis <stephen.celis@gmail.com>
@mbrandonw
Copy link
Member

Hi @malhal! Thanks for taking the time to PR this. We do have some questions though.

Change from .sheet(item:) which is for static content to .sheet(isPresented:) which is for dynamic.

This is not our impression. The .sheet(item:) modifier is inherently dynamic since it allows the content of the sheet to depend on the unwrapped item value, whereas sheet(isPresented:) is inherently static since its trailing closure is not provided any data for the sheet. In order for the sheet(isPresented:) modifier to have dynamic content you must hold onto extra state next to the boolean state and manage it independently.

And this is what you have done with the ReminderFormConfig:

struct ReminderFormConfig {
  // Data for sheet
  var isEditing = false
}

…but this is not ideal. You are now responsible for setting up all of this state when the isEditing flips to true, and ideally resetting it all back to its default values when setting isEditing to false. And this is what you are doing with the present method:

mutating func present(
  remindersList: RemindersList, 
  reminder: Reminder = Reminder(listID: 0), 
  selectedTags: [Tag] = []
) {
  isEditing = true
  self.reminder = reminder
  self.remindersList = remindersList
  self.selectedTags = selectedTags
}

But in our opinion this is pretty dangerous to use. It is completely possible to invoke this method in the wrong way, for example in your showDetails method nothing is stopping you from doing this:

private func showDetails() {
  editReminderFormConfig.present(remindersList: remindersList)
}

But this now presents a ReminderForm with a fresh reminder, not the one we want to edit. Or we could have not realized that we need to pass along the currently selected tags:

private func showDetails() {
  editReminderFormConfig.present(remindersList: remindersList, reminder: reminder)
}

…and now we run the risk of losing the tags on this reminder if the user hits “Save”.

And further, right now cancel only resets the boolean:

mutating func cancel() {
  isEditing = false
}

But it would probably be better to reset everything back to their defaults. If you don’t, you run the risk of presenting the form with data from the last time the form was presented.

None of these problems are possible when you model the state as a single optional field:

@State var editReminder: Reminder?

All you have to do is set the value:

editReminder = reminder

…and that both drives navigation and gives the data to the sheet for it to do its job. And then dismissing is a matter of nil out the state, and that both drives the navigation and clears out all of the state so that we don’t accidentally use it later.

We also think it’s a little strange that ReminderRow and RemindersListDetail now hold onto this ReminderFormConfig value when they don’t actually care about any of that state. Only the form does. That has also pushed some work that used to be encapsulated in just the form outward, such as the fact that the ReminderRow now needs to query for tags to supply to the form:

private func showDetails() {
  guard let reminderID = reminder.id else { return }
  do {
    let tags = try _database.wrappedValue.read { db in
      try Tag.all()
        .joining(optional: Tag.hasMany(ReminderTag.self))
        .filter(Column("reminderID").detached == reminderID)
        .order(Column("name"))
        .fetchAll(db)
    }
    editReminderFormConfig.present(remindersList: remindersList, reminder: reminder, selectedTags: tags)
  } catch {
    reportIssue(error)
  } 
}

This is all work that used to be in just the ReminderForm view, but now if there are other places in the app that can present this form we have to repeat this work each time by loading the tags. It seemed a bit better to keep this hidden away inside the form.

And lastly, this refactor using ReminderFormConfig also doesn’t play nicely in a future where we might have multiple different places we can navigate to. Like say we want to show an alert to confirm deleting. We could of course model this as a new boolean:

@State var isDeleteAlertPresented = false

But now there are two booleans controlling navigation and we only ever expect at most one to be true at a time. In fact, SwiftUI/UIKit require this. And so it would be better if we then refactored our navigation state to be an enum so that we could have compile time guarantees that only one destination can be active at a time:

@State var destination: Destination?
enum Destination {
  case edit(Reminder)
  case delete
}

However, this doesn’t really work with ReminderFormConfig since it has the isEditing baked directly into it. And so we will be forced to model these domains in a less than ideal manner.

Replaced the Environment var dismiss with isPresented = false.

Can you explain more why you have done this? They are functionally equivalent since all dismiss() does is look through the view hierarchy to see which binding is driving navigation, and nils out the state (or sets it to false in the case of boolean bindings). But the isEditing = false style requires us to manage extra state that doesn’t seem worth it.


All-in-all we are not sure these changes are really “standard SwiftUI”, as you say. If you know of some documentation that backs up some of these claims we’d love to see it.

However, the initializer of ReminderForm is definitely gnarly, and needlessly so. We missed it during our clean up pass before releasing, but it is possible to massively simplify it. I’ve opened a PR here #15 and just merged it.

If you’d like you could rebase your branch onto main in order to see what the diff is like with those changes. My impression is that it will be more lines of code and less precise of domain modeling.

@malhal
Copy link
Author

malhal commented Feb 17, 2025

The EditorConfig pattern can be seen here:

https://developer.apple.com/videos/play/wwdc2020/10040?time=214

@malhal
Copy link
Author

malhal commented Feb 17, 2025

I agree the config's func params could be tightened up. It is quite complicated given the list, reminder and tags are all independent value types rather than objects that have relationships.

I took a quick look at the PR and I don't think it is correct because it is still attempting to link States together instead of using Binding but will rebase and test.

@mbrandonw
Copy link
Member

mbrandonw commented Feb 17, 2025

The EditorConfig pattern can be seen here:

https://developer.apple.com/videos/play/wwdc2020/10040?time=214

I personally just don’t think this is a technique that should be followed. It has all of the problems I mentioned above:

  • There are multiple steps to implement the present method on EditorConfig just to prepare data for presenting. It’s easy to get these steps wrong or invoke present incorrectly, all of which is not possible with a single piece of optional state.
  • The video mentions that EditorConfig is able to manage its own invariants, which seems nice, but our technique makes it so that we enforce the invariants at compile time. To present the sheet we construct state, to dismiss we nil out the state. There’s no extra work needed to maintain invariants.
  • SwiftUI prefers to dismiss things by just writing nil or false to a binding, but doing so with EditorConfig leaves behind a bunch of other data untouched. What does this data represent when the ProgressEditor is not presented and why is it still hanging around?
  • If data needs to be prepared for EditorConfig, such as making API requests, database requests, etc., then that work will be repeated for each place that wants to present the ProgressEditor.
  • If BookView later has other places it can navigate to it will have no choice but to represent that state has independent pieces of boolean or optional state. This leads to exponentially more state than necessary. For example, if it can navigate to 4 different places, that is 2^4 = 16 different combinations of true/false state, but we know that at most one can be true at a time, hence only 5 valid states. Why deal with 11 invalid states in your app when you can use the power of Swift enums instead?
  • This isn’t a concern in the WWDC video, but in the case of our Reminders app, the ReminderRow does not need any of the data inside the ReminderFormConfig, and so why does it have access to it?

So that is a lot of downsides to use this config type, and I also just don't see any upsides whatsoever. What are the benefits to using this extra type of imprecisely modeled domain? What is it "fixing" in the current code?

I agree the config's func params could be tightened up. It is quite complicated given the list, reminder and tags are all independent value types rather than objects that have relationships.

While relationships in SwiftData can be powerful, there is also a cost to them. For one, they force models to be classes instead of structs. We feel it is a great benefit that things like Reminder, Tag, etc. can be structs instead of references. Our whole reason for coalescing around GRDB was due to its embrace of value types over reference types, and so that cannot be a reason to change the code in this feature.

I took a quick look at the PR and I don't think it is correct because it is still attempting to link States together instead of using Binding but will rebase and test.

Can you explain more why you think Binding is necessary here? It seems that local state that only the ReminderForm cares is the perfect use case for @State.

@malhal
Copy link
Author

malhal commented Feb 17, 2025

The issue with data hanging around is just the new value-type world we live in. Does seem weird to those used to objects. There is no need to nil out values. When cancelling the sheet, it needs to keep the values or there would be a visual glitch, that is why it can't use the item API.

@malhal
Copy link
Author

malhal commented Feb 17, 2025

As it was shown in the video there should only be once source of truth. If the State is init with initial reminder that uses supplied remindersList.id then if the View is re-init with different reminderList then the reminder won't be in the correct list.

There is an alternative, you pass in the reminderID as a let and fetch the reminder into local state with a query. Also you could optimise the reminder list's query by only fetching the attributes of reminder the list actually shows and not what the editor needs. However going down this path of the Views fetching data usually will run into issues compared to having a model layer like CoreData and SwiftData provides. The reason is with those frameworks it is possible for multiple views to show the unsaved data.

@mbrandonw
Copy link
Member

mbrandonw commented Feb 17, 2025

The issue with data hanging around is just the new value-type world we live in.

I’m sorry, but we don’t agree that it’s the world we live in. The main thrust of our open source work is specifically to show that we don’t have to be constrained by limitations in SwiftUI. We build tools that allow using enums to model state, and this repo even shows that SwiftData like tooling can exist in @Observable models and even UIKit.

When cancelling the sheet, it needs to keep the values or there would be a visual glitch, that is why it can't use the item API.

This is not true. Sheets show the view with the last non-nil value while it is animating away, and it has always been that way.

In fact, it is this “config” style of presenting/dismissing from the WWDC video that has the visual glitch. Suppose that you did want to clean up the state when dismissing by updating the cancel() method like so:

mutating func cancel() {
  self = Self()
}

That seems to solve the problem handily. The boolean is flipped back to false as well as all the secondary state.

However, doing so will cause the view to clear out its state while it dismisses since the sheet has no choice but to show the empty state. Here is what your version of the app looks like if I make the above seemingly innocuous change:

Simulator.Screen.Recording.-.iPhone.16.Pro.-.2025-02-17.at.12.12.27.mp4

Notice that the state in the view clears out as it is dismissing. And so you either need to hold onto extra state that doesn’t represent anything real, or you need to introduce a visual glitch into the app.

As it was shown in the video there should only be once source of truth. If the State is init with initial reminder that uses supplied remindersList.id then if the View is re-init with different reminderList then the reminder won't be in the correct list.

Our current way of doing things also has a single “source of truth”, but you just have to know that only the initial value of @State is taken by SwiftUI, and after that the view controls the value until its identity changes. But that’s just standard SwiftUI.

The situation you are describing of changing the reminder and that causing the form to show the incorrect data is simply not possible. Sheets are driven by Identifiable data, and when SwiftUI detects the ID changing it dismisses the current sheet and presents a whole new one.

So that means if somewhere in our app we do something like this:

editReminder = try await database.read { db in
  try Reminder.fetchOne(db, id: 9)
}

…while the editReminder state is already non-nil, the sheet will correctly re-present with the correct reminder. Everything is handled well by SwiftUI.

Also you could optimise the reminder list's query by only fetching the attributes of reminder the list actually shows and not what the editor needs.

That seems unrelated to the conversation at hand, but if you want to PR that change we would happily take it.

However going down this path of the Views fetching data usually will run into issues compared to having a model layer like CoreData and SwiftData provides.

I’m not sure I understand this. What are the issues that we will encounter, and are those issues present in this app or any of the case studies and demos in the repo?

The reason is with those frameworks it is possible for multiple views to show the unsaved data.

That isn’t needed in this demo, and so seems strange to contort our code in strange ways to support it. Also, if we needed multiple views to display an unsaved Reminder, that sounds like a great use for a binding. We can pass around the Binding<Reminder> to anyone that wants to see the freshest draft data until we are ready to save.

@malhal
Copy link
Author

malhal commented Feb 17, 2025

The State values should not be cleaned up. The same way you wouldn't set an int back to zero after doing a calculation. That is what I meant by value-type world.

Unfortunately Binding can only be passed down. If there was a completely separate part of the UI that needed to see the unsaved data then that is why it would retrieve it from a context like a MOC.

* Update examples/docs to use `DatabasePool` for non-in-memory

* wip
@mbrandonw
Copy link
Member

The State values should not be cleaned up.

If you do not clean up the state you can easily introduce a bug that shows stale data. For example, if a view can both present the form for editing an existing reminder and creating a new reminder, then you might have a sequence of events like this:

// Present form for existing reminder:
config.present(reminderList: reminderList, reminder: reminder)

// Later somewhere else in the view, dismiss form:
config.cancel()

// Later somewhere else in the view, present form for new reminder:
config.present(reminderList: reminderList)

Unfortunately this is a bug. The new reminder form will be presented with the data from the existing reminder when present was invoked. You really have no choice but to clean up the state if you don't want bugs like this, and then of course as I showed above that does introduce a visual glitch.

The same way you wouldn't set an int back to zero after doing a calculation. That is what I meant by value-type world.

Typically a lexical scope would end the lifetime of an integer not being used anymore:

do {
  let number = compute()
  print(number)
}
number  // 🛑 Cannot find 'number' in scope

And that is what I am advocating for here. When a view is dismissed its “scope” has ended, and so its state should be inaccessible.

Unfortunately Binding can only be passed down. If there was a completely separate part of the UI that needed to see the unsaved data then that is why it would retrieve it from a context like a MOC.

This too seems unrelated to your PR and the broader discussion. This is a theoretical feature that we do not have in our demo and it's not concretely stated how it is meant to work. But for what it is worth, I guarantee that no matter its specification it certainly would be possible to implement without all the drawbacks that this config pattern brings.

Replaced all of ReminderForm's State(wrappedValue:) with a Binding. This made it possible to remove ReminderForm's custom init. Added ReminderFormConfig for the form's data and logic. Change from .sheet(item:) which is for static content to .sheet(isPresented:) which is for dynamic. Replaced the Environment dismiss with isPresented = false.

There are other instances of State(wrappedValue:) in the project which could also be replaced with Binding.
# Conflicts:
#	Examples/Reminders/ReminderForm.swift
@mbrandonw
Copy link
Member

Hi @malhal, it doesn't seem like your merge happened cleanly. The diff is showing a lot of things from other commits to the repo. Do you want to proceed with fixing up this PR?

@malhal
Copy link
Author

malhal commented Feb 20, 2025

We can just close it. Then perhaps if you change your mind about the config pattern we can revisit making the changes again in the future.

@mbrandonw
Copy link
Member

If you are able to address the issues I brought up above we could for sure consider adopting your changes. But as I showed above, this extra config type makes it possible to easily introduce bugs and visual glitches to navigation, and goes against how SwiftUI is intended to work.

We really don’t think it’s fair to assume that Apple is demonstrating a pattern in that WWDC video to always follow, but rather showing how to do something in a simple, if flawed, way.

@malhal
Copy link
Author

malhal commented Feb 21, 2025

Regarding the risky present func, this:

But in our opinion this is pretty dangerous to use. It is completely possible to invoke this method in the wrong way, for example in your showDetails method nothing is stopping you from doing this:

private func showDetails() {
  editReminderFormConfig.present(remindersList: remindersList)
}

And this:

// Present form for existing reminder:
config.present(reminderList: reminderList, reminder: reminder)

// Later somewhere else in the view, dismiss form:
config.cancel()

// Later somewhere else in the view, present form for new reminder:
config.present(reminderList: reminderList)

Unfortunately this is a bug. The new reminder form will be presented with the data from the existing reminder when present was invoked. You really have no choice but to clean up the state if you don't want bugs like this, and then of course as I showed above that does introduce a visual glitch.

Was my fault by having the default params in the func:

mutating func present(remindersList: RemindersList, reminder: Reminder = Reminder(listID: 0), selectedTags: [Tag] = []) 

Should instead be this

mutating func present(remindersList: RemindersList, reminder: Reminder, selectedTags: [Tag]) {

If the caller wants to create a new reminder instead of supplying an existing one they would init the empty one like this:

config.present(remindersList: remindersList, reminder: Reminder(listID: remindersList.id), selectedTags: [])

However, there are likely still ways the config can be misused, especially since all the vars are public. There may be ways to tighten that up. I'm also experimenting with a closure in the config struct, so the save action can be declared where present is called, will report back on how that works out.

Regarding when I introduced the issue of a visual glitch:
When I said: "When cancelling the sheet, it needs to keep the values or there would be a visual glitch, that is why it can't use the item API." That was my mistake in the explanation. The real the reason we can't use the item API is because of the TextField binding. If it's bound to a text var that is in the optional struct, when the struct is set to nil, the binding is invalid and the TextField crashes before the sheet dismisses.

@malhal
Copy link
Author

malhal commented Feb 21, 2025

Edit: it turns out storing closures in State is a bad idea (.e.g the successAction) because if the closure uses self to use another State then it will cause a retain cycle that prevent View storage being deallocated if the View is removed without the closure being nilled.

Thought I'd share an example of a variation of the config pattern that tightens up the API by replacing the baked in var isEditing: Bool with a computed binding for the sheet that requires that beginEditing has been called with a successAction closure. One weakness is the binding is only designed to handle the set false case, so I report a warning in the invalid set true case.

struct Editor {
    private var successAction: ((String) -> ())?
    var text = ""
    var isEditing: Bool { successAction != nil }
    
    mutating func beginEditing(initialText: String, successAction: @escaping (String) -> ()) {
        self.text = initialText
        self.successAction = successAction
    }
    
    mutating func endEditing(success: Bool) {
        if success {
            successAction?(text)
        }
        successAction = nil
    }
}

struct ContentView: View {
    @State var items: [ListItem] = [ListItem(text: "Test 1"), ListItem(text: "Test 2")]
    @State var editor = Editor()
    
    var editorIsEditing: Binding<Bool> {
        Binding {
            editor.isEditing
        } set: { newValue in
            // when sheet is dismissed
            if !newValue { // only support setting false
                if editor.isEditing { // EditorView might have already ended editing
                    editor.endEditing(success: false)
                }
            }
            else {
                print("use editor.present(...)")
            }
        }
    }
    
    var body: some View {
        NavigationView {
            List($items, editActions: [EditActions.all]) { $item in
                Button(item.text) {
                    editor.beginEditing(initialText: item.text) { editedText in
                        item.text = editedText // automatically updates the correct item.
                    }
                }
            }
            .sheet(isPresented: editorIsEditing) {
                EditorView(editor: $editor)
            }
            .toolbar {
                EditButton()
                Button("Add") {
                    editor.beginEditing(initialText: "") { editedText in
                        items.append(ListItem(text: editedText))
                    }
                }
            }
        }
    }
}

struct EditorView: View {
    @Binding var editor: Editor
    
    var body: some View {
        NavigationStack {
            Form {
                TextField("Text", text: $editor.text)
            }
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel", role: .cancel) {
                        editor.endEditing(success: false)
                    }
                }
                ToolbarItem(placement: .primaryAction) {
                    Button("Save") {
                        editor.endEditing(success: true)
                    }
                }
            }
        }
    }
}

struct ListItem: Identifiable {
    let id = UUID()
    var text = ""
}

@malhal
Copy link
Author

malhal commented Feb 21, 2025

Sorry to bombard you with comments but I just finished this version which goes back to the baked in isEditing but it resets the editor state when the sheet dismisses, using the onDismiss sheet action.

struct Editor {
    private var action: ((Result<String, Error>) -> ())?
    var text = ""
    var isEditing = false
    
    mutating func beginEditing(initialText: String, action: @escaping (Result<String, Error>) -> ()) {
        self.text = initialText
        self.action = action
        isEditing = true
    }
    
    mutating func endEditing(cancel: Bool = false) {
        action?(cancel ? .failure(CancellationError()) : .success(text))
        isEditing = false
    }
}

struct ContentView: View {
    @State var items: [ListItem] = [ListItem(text: "Test 1"), ListItem(text: "Test 2")]
    @State var editor = Editor()
    
    var body: some View {
        NavigationView {
            List($items, editActions: [EditActions.all]) { $item in
                Button(item.text) {
                    editor.beginEditing(initialText: item.text) { result in
                        if case let .success(text) = result {
                            item.text = text
                        }
                    }
                }
            }
            .sheet(isPresented: $editor.isEditing) {
                // onDismiss
                editor = Editor()
            } content: {
                EditorView(editor: $editor)
            }
            .toolbar {
                EditButton()
                Button("Add") {
                    editor.beginEditing(initialText: "") { result in
                        if case let .success(text) = result {
                            items.append(ListItem(text: text))
                        }
                    }
                }
            }
        }
    }
}

struct EditorView: View {
    @Binding var editor: Editor
    
    var body: some View {
        NavigationStack {
            Form {
                TextField("Text", text: $editor.text)
            }
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel", role: .cancel) {
                        editor.endEditing(cancel: true)
                    }
                }
                ToolbarItem(placement: .primaryAction) {
                    Button("Save") {
                        editor.endEditing()
                    }
                }
            }
        }
    }
}

struct ListItem: Identifiable {
    let id = UUID()
    var text = ""
}

I think its cleaner than the computed binding but has the weakness var isEditing can be set true without calling beginEditing(...). Perhaps this could help with that:

struct Editor {
    private var action: ((Result<String, Error>) -> ())?
    var text = ""
    var isEditing = false
        didSet {
            if isEditing && action == nil {
                print("you must use beginEditing")
                isEditing = oldValue
            }
        }
    }
}

Have a nice weekend!

@mbrandonw
Copy link
Member

Hi @malhal, thanks for continuing to share your ideas. However, there are still bugs with your code, and in fact now they are even more pernicious than before. If the data source of the ContentView changes while the EditorView is presented, you run the risk of updating the wrong item when “Save” is tapped, or, worse, a crash.

To see this, update the Button's action closure in the ForEach like so:

Button(item.text) {
   editor.beginEditing(initialText: item.text) { result in
     if case let .success(text) = result {
       item.text = text
     }
   }
+  Task {
+    try await Task.sleep(for: .seconds(1))
+    items.removeFirst()
+  }
 }

This emulates the data source changing by removing the first item a second after opening the sheet.

If you open the first item, make an edit, then hit “Save”, you will find that the changes were applied to the second item. And so now the data has been corrupted. Further, if you were to open the second item, make an edit, and then hit “Save”, you will get a crash.

This is happening because you are escaping the $item binding that ForEach gives you and holding onto it in the beginEditing trailing closure. That is dangerous to do, and probably should just never be done. However, the impreciseness of the Editor type has forced you into this situation.

And even beyond those problems, there are still a lot more problems with this code (both versions). You note some of the problems:

One weakness is the binding is only designed to handle the set false case, so I report a warning in the invalid set true case.

I think its cleaner than the computed binding but has the weakness var isEditing can be set true without calling beginEditing(...). Perhaps this could help with that:

Both of these comments are two sides of the same coin. This is what Curtis is referring to in the WWDC video when he says “EditorConfig can maintain invariants on its properties”. That sounds nice on the surface, but a runtime invariant is never as strong as a compile-time invariant. With compile-time invariants you do not need to do extra work to clean up after the state.

Whereas maintaining code like this is a pain and prone to bugs:

var isEditing = false {
  didSet {
    if isEditing && action == nil {
      print("you must use beginEditing")
      isEditing = oldValue
    }
  }
}

Also, that little line being printed to the console will be easy to miss, and so it will be confusing why doing something like editor.isEditing = true isn’t work. You could use a fatalError instead to be a lot noisier, but then that is really annoying and potentially catastrophic in production. The fact is that the domain should be modeled better to not even allow code like this to be written.

This imprecision of the domain also bleeds out in the sheet view modifier:

.sheet(isPresented: $editor.isEditing) {
  // onDismiss
  editor = Editor()
} content: {

One must know to override the onDismiss closure in order to clear out state, and if you don’t do this it’s a major bug. This works against the grain of SwiftUI because SwiftUI should be able to just write false to the binding, and that should be the end of the story.

Here is how we think this code should be written:

struct ContentViewV2: View {
  @State var items: [ListItem] = [ListItem(text: "Test 1"), ListItem(text: "Test 2")]
  @State var addItem: ListItem?
  @State var editItem: ListItem?

  var body: some View {
    NavigationView {
      List($items, editActions: [EditActions.all]) { $item in
        Button(item.text) {
          editItem = item
        }
      }
      .sheet(item: $addItem) { item in
        EditorViewV2(item: item) { item in
          items.append(item)
        }
      }
      .sheet(item: $editItem) { item in
        EditorViewV2(item: item) { item in
          guard let index = items.firstIndex(where: { $0.id == item.id })
          else { return }
          items[index] = item
        }
      }
      .toolbar {
        EditButton()
        Button("Add") {
          addItem = ListItem()
        }
      }
    }
  }
}

struct EditorViewV2: View {
  @State var item: ListItem
  let onSave: (ListItem) -> Void
  @Environment(\.dismiss) var dismiss

  var body: some View {
    NavigationStack {
      Form {
        TextField("Text", text: $item.text)
      }
      .toolbar {
        ToolbarItem(placement: .cancellationAction) {
          Button("Cancel", role: .cancel) {
            dismiss()
          }
        }
        ToolbarItem(placement: .primaryAction) {
          Button("Save") {
            dismiss()
            onSave(item)
          }
        }
      }
    }
  }
}
  • This code is 20% shorter than yours (60 lines vs 75 lines).
  • It does not require an extra Editor type to manage state.
  • The domain is modeled concisely: the “Edit item” screen is presented when the editItem state is non-nil, and otherwise it is dismissed.
  • We do not have to add extra logic to maintain this extra state and make sure that is correct.
  • It does not have any of the bugs in your code, such as accidentally editing the wrong item or crashing.

And to be honest, even this isn’t how we would like to do things. Having the separate addItem and editItem state is not ideal because both should never be non-nil at the same time. And so the way we would really go about this is to bundle those values up into an enum:

-@State var addItem: ListItem?
-@State var editItem: ListItem?
+@State var destination: Destination?
+enum Destination {
+  case add(ListItem)
+  case edit(ListItem)
+}

And then you can do this

-.sheet(item: $addItem) { item in
+.sheet(item: $destination.addItem) { item in 
   EditorViewV2(item: item) { item in
     items.append(item)
   }
 }
-.sheet(item: $editItem) { item in
+.sheet(item: $destination.editItem) { item in 
   EditorViewV2(item: item) { item in
     guard let index = items.firstIndex(where: { $0.id == item.id })
     else { return }
     items[index] = item
   }
 } 

The only catch is that you have to use our SwiftUINavigation library because Swift does not allow dynamic member lookup via enum cases. We provide the tools to do that, and once you do that you now have compile time guarantees that only one single destination can ever be active at a time.

We also don't like that one has to search for the index of an item in order to update it. However, this is necessary to avoid the crash that your code currently has. But we have another library that helps with this that allows us to write code like this:

-guard let index = items.firstIndex(where: { $0.id == item.id })
-else { return }
-items[index] = item
+items[id: item.id] = item

With those changes applied our code would actually be even shorter and more correct.

And regarding this:

That was my mistake in the explanation. The real the reason we can't use the item API is because of the TextField binding. If it's bound to a text var that is in the optional struct, when the struct is set to nil, the binding is invalid and the TextField crashes before the sheet dismisses.

This is also not true. There is no such crash, and the sheet(item:) is a perfectly fine API to use as I have shown above.


I think at this point it would be very helpful if you could address the various feedback I have given throughout all of my posts in this thread. So far it has all been ignored. I’m just trying to understand what tangible problem you think the current code in the Reminders app has, and how your suggestions are actually fixing those problems. And I personally don’t think “It’s not done the ‘Apple’ way” is a problem. I’d rather see a description of an exact user-facing bug that is possible, or a way of changing the code to making it shorter, more succinct, and more correct.

@malhal
Copy link
Author

malhal commented Feb 21, 2025

I addressed some of your feedback earlier. The tangible problem is what I said in my first post - a concern about the misuse of @State and I suggested to use @Binding. Specifically I'm talking about when a View is expected to only be init once so a param is assumed to not change so it can be fed into State's initial value. An example of a bug caused by this is by making the init happen again, e.g. in your latest example, open the sheet for the first row and wait for this:

                .task {
                    try? await Task.sleep(for: .seconds(5))
                    editItem?.text = "Changed"
                }

The sheet's closure is called again but the editor does not update with the changed item. I believe we should design for View structs being init many times and not rely on them being init once. If you disagree then that's fine and we can close this thread. I don't currently have a working solution to this problem either of us are happy with just now anyway.

Regarding the sheet(item:) crash, it does not happen the way you used it. I was talking about when a TextField is bound to the sheet's $item, when item is to nil to hide the sheet it crashes because the TextField's binding to item.text is invalid. A workaround might be to use a computed binding that uses your find by ID routine, I will try next week.

@mbrandonw
Copy link
Member

The tangible problem is what I said in my first post - a concern about the misuse of @State and I suggested to use @Binding.

We just don't consider it a misuse. It is well understood that one can only seed data to @State at the moment the identity of the view is set. By choosing to not leverage this knowledge, one is forced to introduce problems far worse than what can happen with seeding @State via an initializer.

An example of a bug caused by this is by making the init happen again, e.g. in your latest example, open the sheet for the first row and wait for this:

This is true, but if one understands how @State works it's easy to avoid.

But also, this problem is exactly why in our SwiftUINavigation library we provide a version of sheet(item:) that hands the trailing closure a binding of the unwrapped item:

// sheet(item: Binding<Item?>) { (Binding<Item>) … }
.sheet(item: $editItem) { $item in 
  EditorViewV2(item: $item)
}

This is another way to fix the problem, allowing the parent to make mutations to the item that the present view can see.

I don't currently have a working solution to this problem either of us are happy with just now anyway.

The main point I am trying to get across is that no matter what, if you have an imprecisely modeled domain you are open to these kinds of bugs. The only way to squash the bugs is to make the impossible states unrepresentable, which means embracing optionals to drive navigation. Not a boolean plus some additional state.

Apple's sample code may use imprecisely modeled domains because it's easy, but I just don't think we should look to those samples as the perfect example of production code. The techniques they show are useful to get a project off the ground, but have a lot of downsides and I don't think it's appropriate to follow it in larger, more complex applications.

Regarding the sheet(item:) crash, it does not happen the way you used it. I was talking about when a TextField is bound to the sheet's $item, when item is to nil to hide the sheet it crashes because the TextField's binding to item.text is invalid. A workaround might be to use a computed binding that uses your find by ID routine, I will try next week.

That sounds exactly how it is being used now. I have an optional piece of state with a String inside, I present a sheet when the state is non-nil, and that sheet has a TextField that binds to the string. Do you have a code sample that reproduces the problem?

@malhal
Copy link
Author

malhal commented Feb 25, 2025

I found a report by Stephen about a crash with Toggle and optional Binding so I added an my example of TextField crashing as a comment.

I tried using .sheet(item:) and computed bindings (in both item: and isPresented:) and just couldn't get something I was happy with. Although my attempts didn't do this, I discovered an issue with using .sheet in a List row which I posted on the dev forums here which might interest you because ReminderRow currently does it. I also discovered retain cycles when using closures in a @State struct like in my earlier examples so I have stopped trying that. I discovered it by checking for View storage deallocation using a dummy @StateObject looking for deinit when the View is removed.

Below is example I am happy with at the moment but I understand it doesn't model the domain as precisely as you would like. It has struct Editor, used by 2 states for the edit existing and the add new, 2 x .sheet(isPresented:) that use the same EditView but are supplied custom implementations of an onSave closure, which either apply the edit or add the new item. Finally there is an .onChange to check if the editing item has been removed from the items and dismiss the sheet.

struct ListItem: Identifiable {
    let id = UUID()
    var text = ""
}

struct Editor {
    var item = ListItem()
    var isEditing = false
    
    mutating func beginEditing(item: ListItem) {
        self.item = item
        isEditing = true
    }
}

struct ContentView: View {
    @State var items: [ListItem] = [ListItem(text: "Test 1"), ListItem(text: "Test 2")]
    @State var add = Editor()
    
    var body: some View {
        NavigationStack {
            ItemList(items: $items)
                .sheet(isPresented: $add.isEditing) {
                    EditorView(editor: $add) {
                        items.append(add.item)
                    }
                }
                .toolbar {
                    EditButton()
                    Button("Add") {
                        add.beginEditing(item: ListItem())
                    }
                }
                .task {
                    // simulate removing first row to test dismissing sheet while editing
                    try? await Task.sleep(for: .seconds(5))
                    items.removeFirst()
                }
        }
    }
    
    struct ItemList: View {
        @Binding var items: [ListItem]
        @State var edit = Editor()
        
        var body: some View {
            List($items, editActions: [EditActions.all]) { $item in
                Button(item.text) {
                    edit.beginEditing(item: item)
                }
            }
            .onChange(of: items.map(\.id)) { _, ids in
                // dismiss sheet if the item being edited is deleted
                if items.contains(where: { $0.id == edit.item.id }) { return }
                edit.isEditing = false
            }
            .sheet(isPresented: $edit.isEditing) {
                EditorView(editor: $edit) {
                    if let index = items.firstIndex(where: { $0.id == edit.item.id } ) {
                        items[index] = edit.item
                    }
                }
            }
        }
    }
    
    struct EditorView: View {
        @Binding var editor: Editor
        let onSave: () -> ()
        
        var body: some View {
            NavigationStack {
                Form {
                    TextField("Text", text: $editor.item.text)
                }
                .toolbar {
                    ToolbarItem(placement: .cancellationAction) {
                        Button("Cancel", role: .cancel) {
                            editor.isEditing = false
                        }
                    }
                    ToolbarItem(placement: .primaryAction) {
                        Button("Save") {
                            onSave()
                            editor.isEditing = false
                        }
                    }
                }
            }
        }
    }
}

My fork is now well behind so I think we should just close this pull request now.

@mbrandonw
Copy link
Member

I found a report by Stephen about a crash with Toggle and optional Binding so I added an my example of TextField crashing as a comment.

FWIW, this crash has everything to do with the failable Binding initializer and nothing to do with the sheet(item:) modifier like you previously claimed. You also previously mentioned that sheet(item:) causes visual glitches, which isn't true. There is no reason to avoid sheet(item:).

Below is example I am happy with at the moment but I understand it doesn't model the domain as precisely as you would like.

If this is how you like to do things, then that is great!

But we don't personally think it's worth maintaining that much extra code (now 90 lines vs our 60) to have an imprecisely modeled domain. You are still open to the possibility of both editor.isEditing values being true at the same time, which isn't a valid thing to do in SwiftUI. And so why allow it?

We hope in the future you will approach these kinds of conversations by asking questions and trying to get an understanding of why we have done things the way we have rather than assuming we are wrong and that we are using non-"standard" SwiftUI.

@malhal
Copy link
Author

malhal commented Feb 25, 2025

It can be allowed because multiple isEditing can be true at the same time and SwiftUI queues the sheets up. I don't think it is feasible to precisely model sheet presentation given alerts/fullsreencover/confirmation dialogs spread through out the View hierarchy would all interfere anyway.

State(wrappedValue:) instead of Binding is non-standard SwiftUI but now I understand why you are doing it and sorry for not asking first instead of jumping into the code.

@malhal malhal closed this Feb 25, 2025
@stephencelis
Copy link
Member

It can be allowed because multiple isEditing can be true at the same time and SwiftUI queues the sheets up.

@malhal An error is logged to the console if both flip to true, which I think is strong enough evidence that you shouldn't be doing this, and indeed if you concisely model your domain it will be impossible to see this error at runtime.

I don't think it is feasible to precisely model sheet presentation given alerts/fullsreencover/confirmation dialogs spread through out the View hierarchy would all interfere anyway.

It certainly is feasible. We've demonstrated that.

State(wrappedValue:) instead of Binding is non-standard SwiftUI but now I understand why you are doing it and sorry for not asking first instead of jumping into the code.

That's not correct. State(wrappedValue:) is equally as "standard" as using a Binding as a source of truth. They're all tools that Apple provides, documents, and demonstrates.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants