I’m trying to show alerts in a sheet in SwiftUI. I have Cancel and Save buttons on the sheet and both of them are dismissed after the action is done.If there is an error on saving, an alert is pop upped. However, the sheet can not be dismissed after the alert is shown. Both save and cancel can not be dismissed after the alert dismiss is tapped. I can not figure out the reason. Any help would be appreciated. Thank you.
Related code
.navigationBarItems(
leading:
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Cancel")
.foregroundColor(Color("OrangeColor"))
.font(.custom("Montserrat-Medium", size: 18))
},
trailing:
Button(action: {
if selectedBook == nil {
errorInfo = AlertInfo( id: .bookNotSelectedError,
title: "Please choose a book",
message: "")
}
if quote.isEmpty {
errorInfo = AlertInfo( id: .quoteEmptyError,
title: "Please choose a quote",
message: "")
}
if let book = selectedBook {
// Save operations
}
self.presentationMode.wrappedValue.dismiss()
})
{
Text("Save")
.foregroundColor(Color("OrangeColor"))
.font(.custom("Montserrat-Medium", size: 18))
}
.alert(item: $errorInfo, content: { info in
Alert(title: Text(info.title),
message: Text(info.message))
})
)
Alert Info Struct
struct AlertInfo: Identifiable {
enum AlertType {
case saveError
case bookNotSelectedError
case quoteEmptyError
case totalPageError
case currentPageError
}
let id: AlertType
let title: String
let message: String
}
Your SAVE Button checks for errors but then always calls dismiss() – so the Alert shows up, but vanishes immediately.
Also you have to check through the errors using ..else if...
This is how it should work:
Button(action: {
if selectedBook == nil {
errorInfo = AlertInfo( id: .bookNotSelectedError,
title: "Please choose a book",
message: "")
}
else if quote.isEmpty {
errorInfo = AlertInfo( id: .quoteEmptyError,
title: "Please choose a quote",
message: "")
}
else if let book = selectedBook {
// only call dismiss() after save was successful
presentationMode.wrappedValue.dismiss()
}
// NO dismiss here!
})
{
Text("Save")
}
Please also note that Alert and alert(item:content:) are deprecated.
Related
I have an app where I have several buttons whose actions are shielded by a confirmation dialog. For example:
#State private var confirmDeleteAll: Bool = false
var body: some View {
// ...
Button {
confirmDeleteAll = true
} label: {
Label("Delete all", systemImage: "trash")
}
.confirmationDialog("Delete all data", isPresented: $confirmDeleteAll) {
Button("Delete all", role: .destructive, action: deleteAll)
} message: {
Text("This will wipe all data in the app")
}
// ...
}
These all work fine, and the button in the confirmation dialog shows up in red as expected (seen here on an iPad):
But as I have a load of these, I'd like to refactor the code to make the pattern a little simpler. My principle in the refactor is:
Create a button with the ultimate action to take defined in the button's action argument.
Apply a custom button style which replaces the button's action with displaying a confirmation dialog - inside which is a destructive button that, when pressed, performs the supplied action
That gives me code that looks like
struct ConfirmationButtonStyle: PrimitiveButtonStyle {
var title: LocalizedStringKey
var message: LocalizedStringKey
init(_ title: LocalizedStringKey,
message: LocalizedStringKey = "") {
self.title = title
self.message = message
}
#State private var showConfirmationDialog: Bool = false
func makeBody(configuration: Configuration) -> some View {
Button(role: configuration.role) {
showConfirmationDialog = true
} label: {
configuration.label
}
.confirmationDialog(title,
isPresented: $showConfirmationDialog) {
Button(role: .destructive, action: configuration.trigger) {
configuration.label
}
} message: {
Text(message)
}
}
}
extension PrimitiveButtonStyle where Self == ConfirmationButtonStyle {
static func confirm(
_ title: LocalizedStringKey,
message: LocalizedStringKey
) -> some PrimitiveButtonStyle {
ConfirmationButtonStyle(title, message: message)
}
}
My refactored button then becomes:
Button(action: deleteAll) {
Label("Delete all", systemImage: "trash")
}
.buttonStyle(.confirm(
"Delete all data",
message: "This will wipe all data in the app"
))
From a functional standpoint this works fine. Visually, though, despite the confirmationDialog's button clearly being marked as destructive, its colour reverts to my application's accentColor, which in this case is purple:
From a code point of view I can't see why the destructive role would not be observed. Am I missing something basic here?
I have a navigation view with a list in my content view. In my list there is a ForEach that is passed an array. If I pass the ForEach just the array, everything works fine. If I filter that array, it will pop back anytime a change is made to a variable inside the array. I have a similar question that I already posted which has an answer (to use .stack navigation style). The navigation view is utilizing the .stack style. You can see in the code below that the array is filtered to show books that haven't been finished. In BookView there is a toggle that will set if the book is finished or not. My question, is there a way to stop it from popping back when using a filtered list?
NavigationView {
List {
ForEach(user.books.filter({$0.finished == false})) { book in
NavigationLink(destination: {
BookView(book: $user.books.first(where: {$0.id == book.id})!)
}, label: {
BookListItem(book: book)
})
}
.listStyle(.plain)
.searchable(text: $search, placement: .automatic, prompt: "Search")
.navigationTitle("Books")
.toolbar(content: {
ToolbarItemGroup(placement: .bottomBar, content: {
HStack {
Button(action: {
settings.toggle()
}, label: {
Text("Settings")
})
Spacer()
Button(action: {
addBook.toggle()
}, label: {
Text("Add Book")
})
}
})
})
.sheet(isPresented: $addBook, onDismiss: {}, content: {
AddBookView(user: $user)
})
.sheet(isPresented: $settings, onDismiss: {}, content: {
SettingsView(user: $user)
})
.sheet(isPresented: $editBook, onDismiss: {
//Clear editing book
editingBook = nil
}, content: {
EditBookView(user: $user,
book: $user.books.first(where: {$0.id == editingBook!.id})!,
sheet: $editBook,
name: editingBook!.name,
kind: editingBook!.kind,
author: editingBook!.author ?? "",
iconColor: editingBook!.iconColor)
})
}
.navigationViewStyle(.stack)
This is the Book model to help replicate the issue.
struct Book: Identifiable, Codable, Hashable {
enum Kind: String, Hashable, CaseIterable, Codable {
case fiction = "Fiction"
case nonfiction = "Nonfiction"
}
//Needs at creation
var name: String //Book name
var kind: Kind //Book kind
var finished: Bool //If book is finished
}
Toggle in the BookView
Toggle("Finished Reading", isOn: $book.finished)
Weirdly enough, when I used a computed property to search through the array, it will work okay. You can type something in the search bar, click on an item from the now filtered list, set the book to finished, and it won't pop back. It only seems to be an issue when I'm trying to filter by if the books are finished or not.
var searchedBooks: [Book] {
if search.isEmpty {
return user.books
} else {
//Lowercase the words when filtering
return filteredBooks.filter({
$0.name.lowercased().contains(search.lowercased().trimmed()) || //See if any of the book names contain what's in the search
$0.kind.rawValue.lowercased().contains(search.lowercased().trimmed()) //Check for fiction or nonfiction
})
}
}
I am using swipeActions in ForEach loop of a List and toolbar buttons to show different sheets on my SwiftUI view. But every first time I swipe left and click the Edit button, the object of that line is nil. If I do the same swipe and click thing again, everything goes well. Anyone else had this kind of bug before? Thank you.
Here is the related code:
struct LanguagesView: View {
#State var activeSheet: ActiveSheet?
#State var toBeEdit: MeLanguage?
var body: some View {
NavigationView {
List {
ForEach(self.meLanguages, id: \.id) { lan in
HStack {
Text("\(lan.wrappedName)")
.font(.headline)
}.swipeActions(allowsFullSwipe: false) {
Button(
action: {
self.activeSheet = .editLanguage
self.toBeEdit = lan
},
label: { Label("Edit", systemImage: "pencil") }
) .tint(.indigo)
}
}
}
.sheet(item: $activeSheet,
onDismiss: {
self.toBeEdit = nil
}
){
item in
switch item {
case .addLanguage:
AddLanguage()
case .sortLanguages:
SortLanguagesView()
case .editLanguage:
if self.toBeEdit != nil {
EditLanguageView( meLanguage: self.toBeEdit! )
}
else {
Text("self.toBeEdit is nil")
}
default:
Text("No such button on ContentView.")
}
}
.toolbar {
ToolbarItemGroup {
HStack {
Text("\(self.meLanguages.count) Languages on Card").font(.headline)
self.barButtons
}
}
}
}
}
var barButtons: some View {
HStack {
Button(
action: {
self.activeSheet = .sortLanguages
},
label: { Label("Sort Languages", systemImage: "arrow.up.arrow.down.circle")
}
).id("sortLanguages")
Button(
action: {
self.activeSheet = .addLanguage
},
label: { Label("Add Language",
systemImage: "plus")
.imageScale(.large)
}
)
}
}
}
If I only think of the sheet triggered by swipeActions Edit button, the code below works perfectly. But I still need other sheets triggered by ToolBar buttons.
.sheet(item: self.$toBeEdit, content: { toBeEdit in
EditLanguageView( meLanguage: toBeEdit)
})
After more searching I realised it's not something about SwipeActions. This is actually similar to this question:
SwiftUI presenting sheet with Binding variable doesn't work when first presented
So I added an hidden Text after the ForEach loop and the problem solved.
ForEach(self.meLanguages, id: \.id) { ... }
if self.toBeEdit != nil {
Text("\(self.toBeEdit!.wrappedName)").hidden()
}
The following code shows a "Delete file" button. When the user presses it, a confirmation alert dialog appears. When the user presses "Delete" on that first dialog, I want to show a second alert dialog confirming that the file has been deleted. However, it never shows up, even though debugging the code confirmed that the second "return Alert..." statement is actually being executed as expected. Is there a way to make the second alert show up too?
import SwiftUI
enum alertShownType {
case alertNone, alertDeleteFile,alertFileDeleted
}
struct ContentView: View {
#State var showingAlert = false
#State var alertShown: alertShownType = alertShownType.alertNone
var body: some View {
Button(action: {
self.alertShown = alertShownType.alertDeleteFile
self.showingAlert = true
})
{
Text("Delete file")
}.padding(EdgeInsets(top: 0, leading: 0, bottom: 10, trailing:0))
.alert(isPresented: $showingAlert, content: {
if (alertShown == alertShownType.alertDeleteFile)
{
return Alert(title: Text("Delete file"),
message: Text("Are you sure?"),
primaryButton: .destructive(Text("Delete")) {
// Delete the file
....
// Show the next alert
alertShown = alertShownType.alertFileDeleted
showingAlert = true
},
secondaryButton: .cancel())
}
else // alertFileDeleted
{
return Alert(title: Text("File deleted"), message:
Text("Done!"),
dismissButton: .default(Text("OK")))
}
})
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
You just need
public func alert<Item>(item: Binding<Item?>, content: (Item) -> Alert)
Not required #State var showingAlert = false flag.
Setup you code like this
enum alertShownType : Identifiable {
case alertNone,
alertDeleteFile,
alertFileDeleted
var id : Int { get {
hashValue
}}
}
struct ContentViewAlerts: View {
#State var alertShown: alertShownType?
var body: some View {
Button(action: {
self.alertShown = alertShownType.alertDeleteFile
})
{
Text("Delete file")
}.padding(EdgeInsets(top: 0, leading: 0, bottom: 10, trailing:0))
.alert(item: $alertShown, content: { alertType in
if (alertShown == alertShownType.alertDeleteFile)
{
return Alert(title: Text("Delete file"),
message: Text("Are you sure?"),
primaryButton: .destructive(Text("Delete")) {
// Delete the file
// ....
// Show the next alert
alertShown = alertShownType.alertFileDeleted
},
secondaryButton: .cancel())
}
else // alertFileDeleted
{
return Alert(title: Text("File deleted"), message:
Text("Done!"),
dismissButton: .default(Text("OK")))
}
})
}
}
Currently using Xcode 13 and IOS 15.
I am using CoreData to display a list of upcoming trips. On each trip I have 2 swipe actions, one to delete the trip, and the second one is to edit the trip. When the Edit button is selected I then trigger the showingEditTripScreen to true so that the EditTripScreen sheet is shown. I am passing the trip into this sheet to be edited. The problem is that no matter what Trip from the ForEach row is selected, it is always the first trip data that is being sent to the EditTripScreen. Am I doing this properly, or is their another solution.
Thanks
ForEach(tripVM.trips, id: \.id) { trip in
TripCardView(trip: trip)
.listRowSeparator(.hidden)
//.padding(.horizontal)
.swipeActions(allowsFullSwipe: false) {
// Edit Trip
Button {
showingEditTripScreen = true
} label: {
Label("Edit", systemImage: "pencil.circle.fill")
}
.tint(.green)
// Delete Trip
Button {
tripVM.deleteTrip(trip: trip)
tripVM.getAllTrips()
} label: {
Label("Delete", systemImage: "trash.circle.fill")
}
.tint(.red)
}
.sheet(isPresented: $showingEditTripScreen, onDismiss: {
}, content: {
EditTripScreen(trip: trip)
})
}
You're adding sheet for each cell of your table. So when you set showingEditTripScreen variable to true, all sheets for visible views gets triggered, and only one of them will be shown, maybe the first one or just a random one.
Instead you need to store selected trip and use sheet(item:onDismiss:content:), which will pass you unwrapped item to your content. And this sheet should be single one for your list, not need to add it to each item.
Also onDismiss is an optional parameter, you don't need to pass it if you're not using it. editingTrip will be set to nil when you dismiss it automatically.
#State
var editingTrip: Trip?
var body: some View {
ForEach(tripVM.trips, id: \.id) { trip in
TripCardView(trip: trip)
.listRowSeparator(.hidden)
//.padding(.horizontal)
.swipeActions(allowsFullSwipe: false) {
// Edit Trip
Button {
editingTrip = trip
} label: {
Label("Edit", systemImage: "pencil.circle.fill")
}
.tint(.green)
// Delete Trip
Button {
tripVM.deleteTrip(trip: trip)
tripVM.getAllTrips()
} label: {
Label("Delete", systemImage: "trash.circle.fill")
}
.tint(.red)
}
}
.sheet(item: $editingTrip, content: { editingTrip in
EditTripScreen(trip: editingTrip)
})
}