The alert shows when it shouldn't
I'm incorporating Apple's HIG to my app. As such, I want to show the user an alert when an important piece of data is about to be deleted.
MRE - your pals
In this sample app user manages one's friendships.
While cleaning one's social circle, user might accidentally delete a cool Dude. When this happens, i.e. when there is at least one Dude with isCool == true, the app shall show an alert. If that's not the case, it should just delete all the dudes denoted in selectedDudesIDs.
The problem
Currently, the app achieves its main goal - does not allow to delete a cool Dude without confirmation. However, for some reason, an empty alert is being shown to the user when not cool Dudes are selected.
MRE code
That's the code for a Swift Playground that illustrates the issue, "batteries included".
import SwiftUI
import PlaygroundSupport
struct TestView: View {
#State private var selectedDudesIDs = Set<Dude.ID>()
#State private var editMode: EditMode = .inactive
#State private var dudes: [Dude] = [Dude(name: "John", isCool: false), Dude(name: "Paul", isCool: true)]
// MARK: deleting alert
/// A boolean flag initiating deletion.
///
/// Its property observer determines whether there are any cool dudes, i.d. `dude.isCool == true` among the ones to be deleted.
#State private var isDeletingDudes: Bool = false {
willSet {
let coolDudesAmongDeleted = dudes.filter { dude in
selectedDudesIDs.contains(dude.id)
}.filter { dude in
dude.isCool
}
if !coolDudesAmongDeleted.isEmpty {
coolDudesToBeDeletedCount = coolDudesAmongDeleted.count
}
}
}
#State private var coolDudesToBeDeletedCount: Int?
/// Removes dudes with selected IDs from your `dudes`.
func endFriendship() {
dudes = dudes.filter { dude in
!selectedDudesIDs.contains(dude.id)
}
}
var body: some View {
VStack {
HStack {
EditButton()
Spacer()
}
Text("Your pals")
.font(.title)
List(dudes, selection: $selectedDudesIDs) { dude in
Text(dude.name)
}
if editMode.isEditing && !selectedDudesIDs.isEmpty {
Button(role: .destructive) {
isDeletingDudes = true
} label: {
Text("They ain't my pals no more")
}
}
}
.environment(\.editMode, $editMode)
// gimme some proportions
.padding()
.frame(minWidth: 500*0.9, minHeight: 500*1.6)
.alert("End Friendship", isPresented: $isDeletingDudes, presenting: coolDudesToBeDeletedCount) { count in
Button(role: .destructive) {
endFriendship()
coolDudesToBeDeletedCount = nil
} label: {
Text("End Friendships")
}
Button(role: .cancel) {
coolDudesToBeDeletedCount = nil
} label: {
Text("Cancel")
}
} message: { _ in
Text("You're are about to end friendship with at least one cool dude.")
}
}
}
struct Dude: Identifiable {
var id: String { self.name }
let name: String
var isCool: Bool
}
let view = TestView()
PlaygroundPage.current.setLiveView(view)
MRE in action
A 27 seconds long clip illustrating the current behavior.
Here, the user knows two Dudes - John is just a friend and Paul is a cool friend. The alert should not be shown when deleting John.
Why do I think it's not my fault
The documentation of alert property wrapper reads: For the alert to appear, both isPresented must be true and data must not be nil.
In this case, the alert is shown despite data (coolDudesToBeDeletedCount in this case) being nil. I've inspected it using a property observer on this variable and it's nil until one actually selects a cool Dude.
Also, the data parameter is of type T?, which is a generic Optional - and Int? definitely fits the role.
Wrap up
Is there a fault in my program's design or are the docs wrong? Either way, how could I achieve the result I'm after?
How to show the alert only when it's necessary?
Yeah, it looks like the doc is misleading. only isPresented controls the visibility of the alert and presenting is to call the closure. if presenting is nil then the closure code will not execute.
workaround: create a var showAlert to control the visibility of the alert.
import SwiftUI
struct Dude: Identifiable {
var id: String { self.name }
let name: String
var isCool: Bool
}
struct ContentView: View {
#State private var selectedDudesIDs = Set<Dude.ID>()
#State private var editMode: EditMode = .inactive
#State private var dudes: [Dude] = [Dude(name: "John", isCool: false), Dude(name: "Paul", isCool: true)]
// MARK: deleting alert
/// A boolean flag initiating deletion.
///
/// Its property observer determines whether there are any cool dudes, i.d. `dude.isCool == true` among the ones to be deleted.
#State private var isDeletingDudes: Bool = false {
willSet {
let coolDudesAmongDeleted = dudes.filter { dude in
selectedDudesIDs.contains(dude.id)
}.filter { dude in
dude.isCool
}
print(coolDudesAmongDeleted)
if !coolDudesAmongDeleted.isEmpty {
coolDudesToBeDeletedCount = coolDudesAmongDeleted.count
showAlert = true
}else{
showAlert = false
endFriendship()
}
}
}
#State private var showAlert: Bool = false
#State private var coolDudesToBeDeletedCount: Int?
/// Removes dudes with selected IDs from your `dudes`.
func endFriendship() {
dudes = dudes.filter { dude in
!selectedDudesIDs.contains(dude.id)
}
}
var body: some View {
VStack {
HStack {
EditButton()
.padding(.leading, 24)
Spacer()
}
Text("Your pals")
.font(.title)
List(dudes, selection: $selectedDudesIDs) { dude in
Text(dude.name)
}
if editMode.isEditing && !selectedDudesIDs.isEmpty {
Button(role: .destructive) {
isDeletingDudes = true
} label: {
Text("They ain't my pals no more")
}
}
}
.environment(\.editMode, $editMode)
// gimme some proportions
.padding()
.frame(minWidth: 500*0.9, minHeight: 500*1.6)
.alert("End Friendship", isPresented: $showAlert, presenting: coolDudesToBeDeletedCount) { count in
Button(role: .destructive) {
endFriendship()
coolDudesToBeDeletedCount = nil
} label: {
Text("End Friendships")
}
Button(role: .cancel) {
coolDudesToBeDeletedCount = nil
} label: {
Text("Cancel")
}
} message: { _ in
Text("You're are about to end friendship with at least one cool dude.")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Related
I´ve got a list of Fetched Core Data Items, displayed as a NavigationLink inside a ForEach Loop.
Each of those Elements can be deleted by Swipe or Context Menu.
However when I add an additional confirmationDialog, and move the actual delete action into that one, the wrong item gets deleted (until the actual selected Item is the last one).
Without the confirmationDialog, and the delete Action inside the Button, it works fine.
Does anyone have any idea why?
Thank you!
import Foundation
import SwiftUI
struct IngredientsList: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(sortDescriptors: []) private var ingredients: FetchedResults<Ingredient>
#State private var DeleteDialogue = false
var body: some View {
VStack{
List{
ForEach(ingredients){ingredients in
NavigationLink{
RecipeIngredientsDetailed(ingredients: ingredients, editMode: true, createmode: true)
} label: {
Text(ingredients.ingredientname ?? "")
}
.swipeActions(){
Button(role: .destructive){
DeleteDialogue = true
} label:{
Text("Delete")
}
}
.contextMenu(){
Button(role: .destructive){
DeleteDialogue = true
} label:{
Text("Delete")
}
}
.confirmationDialog("Are you sure?", isPresented: $DeleteDialogue){
Button("Delete Ingredient"){
viewContext.delete(ingredients)
do{
try viewContext.save()
} catch{
}
}
} message: {
Text("This will remove the Ingredient from all Recipes!")
}
}
}
}
}
}
Its because you are using the same boolean for every item. Try making custom View struct for each row, that has its own boolean, e.g.
struct IngredientsList: View {
#FetchRequest(sortDescriptors: []) private var ingredients: FetchedResults<Ingredient>
var body: some View {
List{
ForEach(ingredients){ ingredient in
IngredientRow(ingredient: ingredient)
}
}
}
}
struct IngredientRow: View {
#Environment(\.managedObjectContext) private var viewContext
#State var confirm = false
#ObservedObject var ingredient: Ingredient
var body: some View {
NavigationLink{
IngredientDetail(ingredient: ingredient)
} label: {
Text(ingredients.ingredientname ?? "")
}
.swipeActions {
Button(role: .destructive){
confirm = true
} label: {
Text("Delete")
}
}
.contextMenu {
Button(role: .destructive){
confirm = true
} label:{
Text("Delete")
}
}
.confirmationDialog("Are you sure?", isPresented: $confirm){
Button("Delete Ingredient"){
viewContext.delete(ingredient)
do {
try viewContext.save()
} catch {
}
}
} message: {
Text("This will remove the Ingredient from all Recipes!")
}
}
}
And btw, without a valid sortDescriptor the list might not behave correctly.
Here is a simple list view of "Topic" struct items. The goal is to present an editor view when a row of the list is tapped. In this code, tapping a row is expected to cause the selected topic to be stored as "tappedTopic" in an #State var and sets a Boolean #State var that causes the EditorV to be presented.
When the code as shown is run and a line is tapped, its topic name prints properly in the Print statement in the Button action, but then the app crashes because self.tappedTopic! finds tappedTopic to be nil in the EditTopicV(...) line.
If the line "tlVM.objectWillChange.send()" is uncommented, the code runs fine. Why is this needed?
And a second puzzle: in the case where the code runs fine, with the objectWillChange.send() uncommented, a print statement in the EditTopicV init() shows that it runs twice. Why?
Any help would be greatly appreciated. I am using Xcode 13.2.1 and my deployment target is set to iOS 15.1.
Topic.swift:
struct Topic: Identifiable {
var name: String = "Default"
var iconName: String = "circle"
var id: String { name }
}
TopicListV.swift:
struct TopicListV: View {
#ObservedObject var tlVM: TopicListVM
#State var tappedTopic: Topic? = nil
#State var doEditTappedTopic = false
var body: some View {
VStack(alignment: .leading) {
List {
ForEach(tlVM.topics) { topic in
Button(action: {
tappedTopic = topic
// why is the following line needed?
tlVM.objectWillChange.send()
doEditTappedTopic = true
print("Tapped topic = \(tappedTopic!.name)")
}) {
Label(topic.name, systemImage: topic.iconName)
.padding(10)
}
}
}
Spacer()
}
.sheet(isPresented: $doEditTappedTopic) {
EditTopicV(tlVM: tlVM, originalTopic: self.tappedTopic!)
}
}
}
EditTopicV.swift (Editor View):
struct EditTopicV: View {
#ObservedObject var tlVM: TopicListVM
#Environment(\.presentationMode) var presentationMode
let originalTopic: Topic
#State private var editTopic: Topic
#State private var ic = "circle"
let iconList = ["circle", "leaf", "photo"]
init(tlVM: TopicListVM, originalTopic: Topic) {
print("DBG: EditTopicV: originalTopic = \(originalTopic)")
self.tlVM = tlVM
self.originalTopic = originalTopic
self._editTopic = .init(initialValue: originalTopic)
}
var body: some View {
VStack(alignment: .leading) {
HStack {
Button("Cancel") {
presentationMode.wrappedValue.dismiss()
}
Spacer()
Button("Save") {
editTopic.iconName = editTopic.iconName.lowercased()
tlVM.change(topic: originalTopic, to: editTopic)
presentationMode.wrappedValue.dismiss()
}
}
HStack {
Text("Name:")
TextField("name", text: $editTopic.name)
Spacer()
}
Picker("Color Theme", selection: $editTopic.iconName) {
ForEach(iconList, id: \.self) { icon in
Text(icon).tag(icon)
}
}
.pickerStyle(.segmented)
Spacer()
}
.padding()
}
}
TopicListVM.swift (Observable Object View Model):
class TopicListVM: ObservableObject {
#Published var topics = [Topic]()
func append(topic: Topic) {
topics.append(topic)
}
func change(topic: Topic, to newTopic: Topic) {
if let index = topics.firstIndex(where: { $0.name == topic.name }) {
topics[index] = newTopic
}
}
static func ex1() -> TopicListVM {
let tvm = TopicListVM()
tvm.append(topic: Topic(name: "leaves", iconName: "leaf"))
tvm.append(topic: Topic(name: "photos", iconName: "photo"))
tvm.append(topic: Topic(name: "shapes", iconName: "circle"))
return tvm
}
}
Here's what the list looks like:
Using sheet(isPresented:) has the tendency to cause issues like this because SwiftUI calculates the destination view in a sequence that doesn't always seem to make sense. In your case, using objectWillSend on the view model, even though it shouldn't have any effect, seems to delay the calculation of your force-unwrapped variable and avoids the crash.
To solve this, use the sheet(item:) form:
.sheet(item: $tappedTopic) { item in
EditTopicV(tlVM: tlVM, originalTopic: item)
}
Then, your item gets passed in the closure safely and there's no reason for a force unwrap.
You can also capture tappedTopic for a similar result, but you still have to force unwrap it, which is generally something we want to avoid:
.sheet(isPresented: $doEditTappedTopic) { [tappedTopic] in
EditTopicV(tlVM: tlVM, originalTopic: tappedTopic!)
}
I have a SwiftUI app with SwiftUI App lifecycle that includes a master-detail type
list driven from CoreData. I have the standard list in ContentView and NavigationLinks
to the DetailView. I pass a Core Data entity object to the Detailview.
My struggle is setting-up bindings to TextFields in the DetailView for data entry
and for editing. I tried to create an initializer which I could not make work. I have
only been able to make it work with the following. Assigning the initial values
inside the body does not seem like the best way to do this, though it does work.
Since the Core Data entities are ObservableObjects I thought I should be able to
directly access and update bound variables, but I could not find any way to reference
a binding to Core Data in a ForEach loop.
Is there a way to do this that is more appropriate than my code below?
Simplified Example:
struct DetailView: View {
var thing: Thing
var count: Int
#State var localName: String = ""
#State private var localComment: String = ""
#State private var localDate: Date = Date()
//this does not work - cannot assign String? to State<String>
// init(t: Thing) {
// self._localName = t.name
// self._localComment = t.comment
// self._localDate = Date()
// }
var body: some View {
//this is the question - is this safe?
DispatchQueue.main.async {
self.localName = self.thing.name ?? "no name"
self.localComment = self.thing.comment ?? "No Comment"
self.localDate = self.thing.date ?? Date()
}
return VStack {
Text("\(thing.count)")
.font(.title)
Text(thing.name ?? "no what?")
TextField("name", text: $localName)
Text(thing.comment ?? "no comment?")
TextField("comment", text: $localComment)
Text("\(thing.date ?? Date())")
//TextField("date", text: $localDate)
}.padding()
}
}
And for completeness, the ContentView:
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Thing.date, ascending: false)])
private var things : FetchedResults<Thing>
#State private var count: Int = 0
#State private var coverDeletedDetail = false
var body: some View {
NavigationView {
List {
ForEach(things) { thing in
NavigationLink(destination: DetailView(thing: thing, count: self.count + 1)) {
HStack {
Image(systemName: "gear")
.resizable()
.frame(width: 40, height: 40)
.onTapGesture(count: 1, perform: {
updateThing(thing)
})
Text(thing.name ?? "untitled")
Text("\(thing.count)")
}
}
}
.onDelete(perform: deleteThings)
if UIDevice.current.userInterfaceIdiom == .pad {
NavigationLink(destination: WelcomeView(), isActive: self.$coverDeletedDetail) {
Text("")
}
}
}
.navigationTitle("Thing List")
.navigationBarItems(trailing: Button("Add Task") {
addThing()
})
}
}
private func updateThing(_ thing: FetchedResults<Thing>.Element) {
withAnimation {
thing.name = "Updated Name"
thing.comment = "Updated Comment"
saveContext()
}
}
private func deleteThings(offsets: IndexSet) {
withAnimation {
offsets.map { things[$0] }.forEach(viewContext.delete)
saveContext()
self.coverDeletedDetail = true
}
}
private func addThing() {
withAnimation {
let newThing = Thing(context: viewContext)
newThing.name = "New Thing"
newThing.comment = "New Comment"
newThing.date = Date()
newThing.count = Int64(self.count + 1)
self.count = self.count + 1
saveContext()
}
}
func saveContext() {
do {
try viewContext.save()
} catch {
print(error)
}
}
}
And Core Data:
extension Thing {
#nonobjc public class func fetchRequest() -> NSFetchRequest<Thing> {
return NSFetchRequest<Thing>(entityName: "Thing")
}
#NSManaged public var comment: String?
#NSManaged public var count: Int64
#NSManaged public var date: Date?
#NSManaged public var name: String?
}
extension Thing : Identifiable {
}
Any guidance would be appreciated. Xcode 12.2 iOS 14.2
You already mentioned it. CoreData works great with SwiftUI.
Just make your Thing as ObservableObject
#ObservedObject var thing: Thing
and then you can pass values from thing as Binding. This works in ForEach aswell
TextField("name", text: $thing.localName)
For others - note that I had to use the Binding extension above since NSManagedObjects are optionals. Thus as davidev stated:
TextField("name", text: Binding($thing.name, "no name"))
And ObservedObject, not Observable
In attempting to learn SwiftUI, I am working on an iOS app that displays a list view of "observation sessions" and allows users to create new sessions from a "New" button. It requires an intermediate step of selecting a configuration that the new session will be based on.
I am able to show reasonable session list and configuration list screens, but my attempts to handle the selected configuration are failing.
The closure sent to the configurations list screen is called successfully as evidenced by a print statement that correctly displays the configuration name. But the remainder of the handler that is supposed to present a third view type fails to work (i.e. it doesn't present the view). In addition, I am getting a warning where I attempt to present the new view that "Result of call to 'sheet(isPresented:onDismiss:content:)' is unused". I'm hoping somebody can explain to me what I'm doing wrong. This is in Xcode 12.3, targeting iOS 14 in the simulator. Here is the SessionListView code where the problem is exhibited:
import SwiftUI
struct SessionsListView: View {
#ObservedObject var dataManager: DataManager
#State private var isPresented = false
#State private var isObserving = false
var body: some View {
VStack {
List {
ForEach(dataManager.allSavedSessions) {session in
NavigationLink(
// Navigate to a detail view
destination: SessionDetailView(session: session),
label: {
Text("\(session.name)")
})
}
}
Spacer()
Button("New Session") {
isPresented = true
}
.padding()
.font(.headline)
.sheet(isPresented: $isPresented) {
// Present a configuration list view where user must select configuration to use for new session
// Requires a closure that's called upon selection in the configuration list view, to handle the selection
NavigationView {
ConfigurationsListView(dataManager: dataManager, selectionHandler: { config in
isPresented = false
isObserving = true
handleConfigSelection(config)
})
.navigationTitle("Configurations")
.navigationBarItems(trailing: Button("Cancel") {
isPresented = false
})
}
}
}
}
private func handleConfigSelection(_ config: SessionConfiguration) {
// Use the selected configuration to start an observations session
print("Selected \(config.name). Will attempt to show sheet from \(self)")
isPresented = false
isObserving = true
self.sheet(isPresented: $isObserving) { // displaying warning: "Result of call to 'sheet(isPresented:onDismiss:content:)' is unused"
NavigationView {
ObservationsView(configuration: config)
.navigationBarItems(trailing: Button(action: {}) {
Text("Done")
})
}
}
}
}
Here's the code I'm using in this simplified demo for the model types.
ObservationSession:
struct ObservationSession: Identifiable {
let id: UUID = UUID()
let name: String
}
SessionConfiguration:
import Foundation
struct ObservationSession: Identifiable {
let id: UUID = UUID()
let name: String
}
DataManager:
import Foundation
class DataManager: ObservableObject {
var allSavedSessions: [ObservationSession] {
return [ObservationSession(name: "Field mouse droppings"), ObservationSession(name: "Squirrels running up trees"), ObservationSession(name: "Squirrel behavior in urban landscapes")]
}
var allSavedConfigurations: [SessionConfiguration] {
return [SessionConfiguration(name: "Squirrel Behavior"), SessionConfiguration(name: "Squirrel/Tree Interaction"), SessionConfiguration(name: "Mouse Behavior")]
}
}
After a night's sleep I figured out an approach that seems to work.
I added a "currentConfiguration" property to my DataManager class of type SessionConfiguration, and set that property in the ConfigurationsListView when a user selects a configuration from the list. Then the SessionsListView can either present the ConfigurationsListView or an ObservationsView depending on a variable that tracks the flow:
import SwiftUI
enum SessionListPresentationFlow {
case configuration
case observation
}
struct SessionsListView: View {
#ObservedObject var dataManager: DataManager
#State private var isPresented = false
#State var flow: SessionListPresentationFlow = .configuration
var body: some View {
VStack {
List {
ForEach(dataManager.allSavedSessions) {session in
NavigationLink(
// Navigate to a detail view
destination: SessionDetailView(session: session),
label: {
Text("\(session.name)")
})
}
}
Spacer()
Button("New Session") {
isPresented = true
}
.padding()
.font(.headline)
.sheet(isPresented: $isPresented, onDismiss: {
if flow == .observation {
flow = .configuration
} else {
flow = .configuration
}
dataManager.currentConfiguration = nil
isPresented = false
}) {
// Present a view for the appropriate flow
viewForCurrentFlow()
}
}
}
#ViewBuilder private func viewForCurrentFlow() -> some View {
if flow == .configuration {
NavigationView {
ConfigurationsListView(dataManager: dataManager, selectionHandler: { config in
isPresented = false
handleConfigSelection(config)
})
.navigationTitle("Configurations")
.navigationBarItems(trailing: Button("Cancel") {
isPresented = false
flow = .observation
})
}
} else if flow == .observation, let config = dataManager.currentConfiguration {
NavigationView {
ObservationsView(configuration: config)
.navigationBarItems(leading: Button(action: {isPresented = false}) {
Text("Done")
})
}
} else {
EmptyView()
}
}
private func handleConfigSelection(_ config: SessionConfiguration) {
flow = .observation
isPresented = true
}
}
I have this (simpilied) section of code for a SwiftUI display:
struct ContentView: View {
private var errorMessage: String?
#State private var showErrors: Bool = false
var errorAlert: Alert {
Alert(title: Text("Error!"),
message: Text(errorMessage ?? "oops!"),
dismissButton: .default(Text("Ok")))
}
init() {}
var body: some View {
VStack {
Text("Hello, World!")
Button(action: {
self.showErrors.toggle()
}) {
Text("Do it!")
}
}
.alert(isPresented: $showErrors) { errorAlert }
}
mutating func display(errors: [String]) {
errorMessage = errors.joined(separator: "\n")
showErrors.toggle()
}
}
When the view is displayed and I tape the "Do it!" button then the alert is displayed as expected.
However if I call the display(errors:...) function the error message is set, but the display does not put up an alert.
I'm guessing this is something to do with the button being inside the view and the function being outside, but I'm at a loss as to how to fix it. It should be easy considering the amount of functionality that any app would have that needs to update a display like this.
Ok, some more reading and a refactor switched to using an observable view model like this:
class ContentViewModel: ObservableObject {
var message: String? = nil {
didSet {
displayMessage = message != nil
}
}
#Published var displayMessage: Bool = false
}
struct ContentView: View {
#ObservedObject private var viewModel: ContentViewModel
var errorAlert: Alert {
Alert(title: Text("Error!"), message: Text(viewModel.message ?? "oops!"), dismissButton: .default(Text("Ok")))
}
init(viewModel: ContentViewModel) {
self.viewModel = viewModel
}
var body: some View {
VStack {
Button(action: {
self.viewModel.displayMessage.toggle()
}) {
Text("Do it!")
}
}
.alert(isPresented: $viewModel.displayMessage) { errorAlert }
}
}
Which is now working as expected. So the takeaway from this is that using observable view models is more useful even in simpler code like this.