SwiftUI EditButton action on Done - swift

How do you set an action to perform when the user hits the EditButton when it appears as "Done"? Is it even possible to do so?
Note that this is not the same as performing an action at each of the individual edits the user might do (like onDelete or onMove). How do you set an action to perform when the user is finished making all changes and ready to commit them?
It's necessary because I'm making all changes on a temporary copy of the model and will not commit the changes to the actual model until the user hits "Done". I am also providing a "Cancel" Button to discard the changes.
struct MyView: View {
#Environment(\.editMode) var mode
var body: some View {
VStack {
HStack {
if self.mode?.value == .active {
Button(action: {
// discard changes here
self.mode?.value = .inactive
}) {
Text("Cancel")
}
}
Spacer()
EditButton()
// how to commit changes??
}
// controls to change the model
}
}
}
Is it even possible to set an action for "Done" on the EditButton or would I have to provide my own button to act like an EditButton? I could certainly do that, but it seems like it should be easy to do with the EditButton.

New answer for XCode 12/iOS 14 using the onChange modifier.
This approach uses the SwiftUI editMode key path available via #Environment along with the new onChange modifier to determine when the view enters and exits editing mode.
In your view do the following:
#Environment(\.editMode) private var editMode
...
.onChange(of: editMode!.wrappedValue, perform: { value in
if value.isEditing {
// Entering edit mode (e.g. 'Edit' tapped)
} else {
// Leaving edit mode (e.g. 'Done' tapped)
}
})

You can use the onDisappear() modifier to perform the action, on a view that you show only on edit mode. There is an example on how to do it in the tutorial of SwiftUI "Working with UI Controls":
if self.mode?.wrappedValue == .inactive {
ProfileSummary(profile: profile)
} else {
ProfileEditor(profile: $draftProfile)
.onDisappear {
self.draftProfile = self.profile
}
}
In your sample code above, since you do not have a view shown in edit mode, you can add state to check if you have clicked on "Cancel" or "Done" as below:
struct MyView: View {
#Environment(\.editMode) var mode
#State var isCancelled = false
var body: some View {
VStack {
HStack {
if self.mode?.wrappedValue == .active {
Button(action: {
// discard changes here
self.mode?.wrappedValue = .inactive
self.isCancelled = true
}) {
Text("Cancel")
}
.onAppear() {
self.isCancelled = false
}
.onDisappear() {
if (self.isCancelled) {
print("Cancel")
} else {
print("Done")
}
}
}
Spacer()
EditButton()
// how to commit changes??
}
// controls to change the model
}
}
}

From what I understand, the EditButton is meant to put the entire environment in edit mode. That means going into a mode where, for example, you rearrange or delete items in a list. Or where text fields become editable. Those sorts of things. "Done" means "I'm done being in edit mode", not "I want to apply my changes." You would want a different "Apply changes," "Save," "Confirm" or whatever button to do those things.

Your last comment contains a subtle change to things. Until then it sounded like you wanted both a "Done" and "Cancel" button - something very few iOS apps do.
But if you want a "Done" button with a "double-checking things, are you sure" you should be able to code such a thing by (possibly) manually going into edit mode and detecting when it's no longer in it. Save a "before" state, add a popup to when it is about to be changed, and you might give the user a rollback option.
FYI - I'm very old school. Not just PC, but mainframe. Not just Microsoft, but SAP. I certainly understand wanting to give the user one last chance before a destructive change - but not too many iOS apps do that. :-)

Related

Button remains tappable after removing from view hierarchy - SwiftUI

I'm trying to show and hide a view based on a certain state. But even after that view is removed from the hierarchy, it still remains tappable for a few moments, leading to phantom button presses. This is occurring only in iOS 16 to my knowledge.
Note that this only occurs when using the .zIndex modifier, which I need in order to transition the view out smoothly. The bug occurs with or without a transition modifier, however.
Minimum working example (tap the show button, then tap the hide button multiple times. If it worked correctly, the hide button handler should only trigger once, since it is removed from the hierarchy. In reality it can be triggered many times)
struct ContentView: View {
#State var show = false
var body: some View {
ZStack {
Button {
print("show")
show = true
} label: {
Text("show")
.foregroundColor(.white)
.padding()
.background(Color.blue.cornerRadius(8))
}
if show {
Button {
// this can be triggered multiple times if you tap fast
print("hide")
show = false
} label: {
Text("hide")
.foregroundColor(.white)
.padding(64)
.background(Color.red.cornerRadius(8))
}
.zIndex(1) // if we remove the zindex, it won't happen. but then we lose the ability to transition this view out.
}
}
}
}
Has anyone else experience this bug? I don't know a workaround besides removing zIndex, is there a way to fix it without losing transitions?
I filed a feedback for this FB11753719

SwiftUI TextField not Refreshing on Variable Change

I have TextField inside a List inside a NavigationView, declared like this:
NavigationView {
List {
Section("Item Name") {
TextField("", text: $itemName)
.textInputAutocapitalization(.sentences)
.disableAutocorrection(false)
.textFieldStyle(.plain)
}
}
}
In the .onAppear method of the NavigationView, I set the textfield's text variable, which is declared with #State, like this:
#State var itemName: String = ""
and:
.onAppear {
itemName = "Hello, World!"
}
The text is set properly, but the textfield somehow doesn’t refresh. I can tell because I can access the textfield's text property and get the updated value, and when the user taps the text field to edit it, the text suddenly appears.
It seems to me to be a problem with the textfield updating its view when the variable changes. Do I have to call a SwiftUI equivalent of UIKit's layoutSubviews? Or am I declaring my itemName variable wrong?
Minimal Reproducible Example (MRE):
struct ItemView: View {
#State var itemName: String = ""
var body: some View {
NavigationView {
List {
Section("Item Name") {
TextField("", text: $itemName)
.textInputAutocapitalization(.sentences)
.disableAutocorrection(false)
.textFieldStyle(.plain)
}
}
.navigationTitle("Item")
}
.onAppear {
itemName = "Hello, World!"
}
}
}
Any help would be appreciated.
It worked fine in Preview, but I took the next step and tested it in the Simulator, and it failed. That lead me to realize what the issue was. We see this a great deal with animations that are started from .onAppear(). The view is set up and .onAppear() called before the view is actually on screen, causing a failure to update from the .onAppear(). Think of it almost as a race condition. The view has to be set up and on screen before the update can be called, and it needs an extra cycle to do this. As a result the fix is simple:
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now()){
itemName = "Hello, World!"
}
}
Wrap it in a DispatchQueue.main.asyncAfter(deadline:). This gives it time to avoid the issue.
I also was able to reproduce it. I agree with Yrb, it seems like the state mutation in onAppear happens during the TextField's update and therefore the text field isn't picking up the new value from the state.
Another way I was able to fix it is to move the state mutation from the NavigationView's .onAppear modifier to the TextField's .onAppear modifier. If you do that, you won't need DispatchQueue.main.async because SwiftUI will correctly synchronize the onAppear block's execution with the text field's update.

focusedSceneValue fixed by searchable

I'm getting this odd issue where focusedSceneValue doesn't work on macOS Monterey Beta 6, but adding searchable to one of the views fixes it.
Here's how to reproduce:
First paste this code into ContentView:
struct ContentView: View {
#State var search = ""
var body: some View {
NavigationView {
Text("First")
.focusedSceneValue(\.customAction) {
print("Pressed!")
}
Text("Second")
.searchable(text: $search) // Comment this line and focusedSceneValue breaks
}
}
}
struct ActionKey: FocusedValueKey {
typealias Value = () -> Void
}
extension FocusedValues {
var customAction: (() -> Void)? {
get { self[ActionKey.self] }
set { self[ActionKey.self] = newValue }
}
}
And then paste this into your app file:
struct CustomCommands: Commands {
#FocusedValue(\.customAction) var action
var body: some Commands {
CommandGroup(before: .newItem) {
Button(action: {
action?()
}) {
Text("Press me")
}
.disabled(action == nil)
}
}
}
And add this into the body of your Scene:
.commands {
CustomCommands()
}
This just adds a menu item to File, and the menu item is enabled only if a variable named action is not nil.
action is supposed to be assigned in the focusedSceneValue line in ContentView. This should always happen, as long as the ContentView is visible.
This code only works if I add the searchable modifier to one of the views however. If I don't add it, then the menu item is permanently disabled.
Is anybody else able to reproduce this?
There seems to be a bug with implementing focusedSceneValue (SwiftUI and focusedSceneValue on macOS, what am I doing wrong?).
From what you have shared, it seems that the .searchable modifier provides a focus for a view in the scene so that the focusedSceneValue is updated when the focus changes.
In the focusedSceneValue documentation, it recommends using this method "for values that must be visible regardless of where focus is located in the active scene." Implicitly, it could be that focusedSceneValue only works when an active scene has focus somewhere within.
This appears to be fixed in Monterey 12.1 (21C52), so the .searchable workaround should no longer be needed.

SwiftUI .onDisappear

For the application I am designing, I have a prominent header that we want to appear on our Homepage. I want it to disappear when I navigate into a child view, a dynamic view embedded within a custom SwiftUI element I created entitled a CategoryRow.
I've got it working, sort of, where the header disappears upon navigation into NavigationLink that I have embedded within the CategoryRow. Only problem is, the response time is a bit slow, and it seems as if the onDisappear event within the NavigationLink only fires sometimes. Here's a gif that demonstrates the exact behavior I am facing:
Here's some of the relevant code I have within my parent view, which I have entitled FeedView:
#State private var showHeader = true // showHeader will be a state variable passed between the parent and child in order to show/hide the header
var body: some View {
VStack(spacing: 0) {
if showHeader { // update and show/hide the header embedded in the HStack on state change
HStack(spacing: 0) {
// header
}
}
NavigationView {
List(categories.keys.sorted(), id: \String.self) {
// we pass the state variable over to the CategoryRow class to be used in appear/disappear
key in CategoryRow(nameOfCategory: "\(key)".uppercased(), posts: self.posts[key]!, showHeader: self.$showHeader)
}
}
(Child View; a CategoryRow element):
#Binding var showHeader: Bool // bind the passed in showHeader variable so we can pass it back to the parent when updates need to happen
// ...
NavigationLink(destination: ExpandMediaView(post: post)
.onAppear { self.showHeader = false } // after testing, this onAppear ALWAYS manages to fire
.onDisappear { self.showHeader = true } // why does this only fire sometimes?
)
The code only hits the .onDisappear portion of the NavigationLink sometimes, which means sometimes the header reappears on the parent class, and sometimes it doesn't. On top of this, the show/hiding of it is about a millisecond behind the page navigation. Has anyone worked through an issue like this before?

Dismiss navigation view when Core Data object is deleted

I'm attempting to use SwiftUI and CoreData to build a macOS application. This application's main window has a NavigationView, with list items bound to a fetch request, and selecting any of these items populates the detail view. The navigation view goes kind of like this:
NavigationView {
VStack(spacing: 0) {
List(fetchRequest) { DetailRow(model: $0) }
.listStyle(SidebarListStyle())
HStack {
Button(action: add) { Text("+") }
Button(action: remove) { Text("-") }
}
}
Text("Select a model object")
}.navigationViewStyle(DoubleColumnNavigationViewStyle())
DetailRow is a NavigationLink that also defines the detail view:
NavigationLink(destination: ModelDetail(model: model)) {
Text(model.name)
}
I believe that the contents of ModelDetail isn't very important; either way, I'm fairly flexible with it.
In the navigation view, the "-" button, which calls the remove method, should delete the currently-selected model object and return to the default, empty detail view. Unfortunately, I'm struggling to come up with the right way to do this. I believe that I need the following interactions to happen:
subview communicates to navigation view which model object is currently selected
user clicks "-" button, navigation view's remove method deletes currently selected object
subview notices that its model object is being deleted
→ subview calls PresentationMode.dismiss()
Step 3 is the one I'm struggling with. Everything is working out alright so far without using view-model classes on top of the Core Data classes, but I feel stuck trying to figure out how to get the subview to call dismiss(). This needs to happen from the detail view, because it gets the PresentationMode from the environment, and the NavigationView changes it.
While I can get a Binding to the model's isDeleted property through #ObservedObject, I don't know how I can actually react to that change; Binding appears to use publishers under the hood, but they don't expose a publisher that I could hook up to with onPublish, for instance.
KVO over isDeleted might be possible, but listening from a value type isn't great; there's no good place to remove the observer, which could become problematic were the app to run for too long.
What's the guidance for this type of problem?
Heres my solution.
This is my NoteDetailView. It allows deletion from this view, or the "master" view in the Navigation hierarchy. This solution works on Mac, iPad, and iPhone.
I added an optional dateDeleted to my Entity. When a record is deleted, I simply add a value of Date() to this attribute and save the context. In my FetchRequests, I simply predicate for dateDeleted = nil. I'm going to add a trash can and stuff to my app later so people can view or permanently empty their trash.
Then I use a state variable and a notification to clear my View. You can change the code up for the functionality you want:
struct NoteDetailView: View {
var note: Note
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#Environment(\.managedObjectContext) var managedObjectContext
#State var noteBody: String = ""
#State var showEditNoteView: Bool = false
#State var showEmptyView: Bool = false
init(note: Note) {
self.note = note
self._noteBody = State(initialValue: note.body)
}
var body: some View {
VStack {
if (!showEmptyView) {
Text("NOT DELETED")
}
else {
EmptyView()
}
}
.navigationBarTitle(!showEmptyView ? note.title : "")
.navigationBarItems(trailing:
HStack {
if (!showEmptyView) {
Button(action: {
self.showEditNoteView.toggle()
}, label: {
NavBarImage(image: "pencil")
})
.sheet(isPresented: $showEditNoteView, content: {
EditNoteView(note: self.note).environment(\.managedObjectContext, self.managedObjectContext)
})
}
}
)
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave)) { _ in
if (self.note.dateDeleted != nil) {
self.showEmptyView = true
self.presentationMode.wrappedValue.dismiss()
}
}
}
}