How to identify selected SwiftUI list item - swift

In a macOS SwiftUI app, I have a List of items with context menus. When a menu selection is made, the app needs to act on the correct list item. (The context menu can apply to any item, not just the selected one.)
I have a solution that works fairly well, but it has a strange bug. When you right click (or Command+click) on an item, the app sets a variable indicating which item was clicked, and also sets a flag. The flag triggers a sheet requesting confirmation of the action. The problem is that the first time you select a menu item, the sheet doesn’t use the saved item as it should. You can see because the item’s name is not in the “Ok to delete” prompt. If you close that first sheet and select another item, it works correctly, and it works for for every subsequent item from then on, even the first one you tried. It doesn’t matter which item you try first, or whether you select the item first, or anything.
import SwiftUI
struct ContentView: View {
#State private var actionTarget = Value(name: "")
#State private var isDeleting = false
#State private var selection = Value(name: "")
struct Value: Identifiable, Hashable {
let id = UUID()
var name: String
}
let values = [Value(name: "One"), Value(name: "Two"), Value(name: "Three")]
var body: some View {
List(values, selection: $selection) { value in
Text (value.name)
.tag(value)
.contextMenu(ContextMenu {
Button {
actionTarget = value
isDeleting = true
} label: { Text("Delete \(value.name)") }
})
}
.sheet(isPresented: $isDeleting) {
Text("Ok to delete \"\(actionTarget.name)?\"")
.frame(width: 300)
.padding()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { isDeleting = false }
}
ToolbarItem(placement: .destructiveAction) {
Button {
//TODO: Delete
isDeleting = false
} label: { Text("Delete") }
}
}
}
}
}

This is a bug in SwiftUI.
You can work around it by using a different version of the sheet modifier, the one that takes a Binding<Item?>. That also has the advantage that it leads you to a better data model. In your model as posted, you have separate isDeleting and actionTarget variables which can be out of sync. Instead, use a single optional variable holding the Value to be deleted, or nil if there is no deletion to be confirmed.
struct ContentView: View {
#State private var deleteRequest: Value? = nil
#State private var selection: Value? = nil
struct Value: Identifiable, Hashable {
let id = UUID()
var name: String
}
let values = [Value(name: "One"), Value(name: "Two"), Value(name: "Three")]
var body: some View {
List(values, selection: $selection) { value in
Text(value.name)
.tag(value)
.contextMenu(ContextMenu {
Button {
deleteRequest = value
} label: { Text("Delete \(value.name)") }
})
}
.sheet(
item: $deleteRequest,
onDismiss: { deleteRequest = nil }
) { item in
Text("Ok to delete \"\(item.name)?\"")
.frame(width: 300)
.padding()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
deleteRequest = nil
}
}
ToolbarItem(placement: .destructiveAction) {
Button {
print("TODO: delete \(item)")
deleteRequest = nil
} label: { Text("Delete") }
}
}
}
}
}
But the use of a toolbar inside the sheet doesn't look like a normal macOS confirmation sheet. Instead, you should use confirmationDialog.
struct ContentView: View {
#State private var deleteRequest: Value? = nil
#State private var selection: Value? = nil
struct Value: Identifiable, Hashable {
let id = UUID()
var name: String
}
let values = [Value(name: "One"), Value(name: "Two"), Value(name: "Three")]
var body: some View {
List(values, selection: $selection) { value in
Text(value.name)
.tag(value)
.contextMenu(ContextMenu {
Button {
deleteRequest = value
} label: { Text("Delete \(value.name)") }
})
}
.confirmationDialog(
"OK to delete \(deleteRequest?.name ?? "(nil)")?",
isPresented: .constant(deleteRequest != nil),
presenting: deleteRequest,
actions: { item in
Button("Cancel", role: .cancel) { deleteRequest = nil }
Button("Delete", role: .destructive) {
print("TODO: delete \(item)")
deleteRequest = nil
}
}
)
}
}

Related

displaying alert on picker select SwiftUI

i want to display an alert box when a user selects something in a form picker the user then have to confirm their choice before the value changes.
right now my code looks like this(just from some tutorial):
NavigationView {
Form {
Section {
Picker("Strength", selection: $selectedStrength) {
ForEach(strengths, id: \.self) {
Text($0)
}
}
}
}
}
I have tried using onChange() but ideally the check should be before the value changes.
If you want to ask the user to confirm before the selection changes you would need to implement a custom binding with a second var. With this you would be able to cancel the selection if necessary.
struct Test: View{
#State private var selectedStrength: String = ""
#State private var askForStrength: String = ""
#State private var ask: Bool = false
let strengths = ["1","2","3"]
var body: some View{
NavigationView {
Form {
Section {
Picker("Strength", selection: Binding(get: {selectedStrength}, set: {
//assign the selection to the temp var
askForStrength = $0
// show the Alert
ask = true
})) {
ForEach(strengths, id: \.self) {
Text($0)
}
}
}
}
}.alert(isPresented: $ask) {
// Here ask the user if selection is correct and apply the temp var to the selection
Alert(title: Text("select?"), message: Text("Do you want to select \(askForStrength)"), primaryButton: .default(Text("select"), action: {selectedStrength = askForStrength}), secondaryButton: .cancel())
}
}
}
You can do it this way; having an alert after the value is clicked and a temp variable for storing pre-selected data. Code is below the image:
import SwiftUI
struct ContentView: View {
let animals = ["dog", "cat", "pig"]
#State var selected = ""
#State var finalResult = ""
#State var alert = false
var body: some View {
NavigationView {
Form {
Section {
Picker("Animals", selection: $selected) {
ForEach(animals, id: \.self) {
Text($0)
}
}
.onChange(of: selected) { _ in
alert.toggle()
}
Text("You have confirmed to select this: \(finalResult)")
}
.alert("Confirm selection?", isPresented: $alert) {
Button("Confirm", role: .destructive) {
finalResult = selected
}
}
}
}
}
}

SwiftUI: Can only Edit the first item in a ForEach List

I have a list of bookmarks saved in Core Data. When i try to click on the edit button for a particular bookmark, only the values of the first bookmark on the list will fill up the textfields in the editing sheet no matter which bookmark i pick.
This is a macOS 10.15 app.
ContentView
#State private var showEditView = false
ForEach(vm.myBookmarks) { myBookmark in
Text(myBookmark.name)
Text(myBookmark.url)
Button {
showEditView = true
} label: {
Image("pencil")
}
.sheet(isPresented: $showEditView) {
EditBookmarkView(name: myBookmark.name, url: myBookmark.url, isVisible: $showEditView, bm: myBookmark)
}
}
EditBookmarkView
struct EditBookmarkView: View {
#Environment(\.presentationMode) var presentationMode
#Binding var isVisible: Bool
#ObservedObject var vm: EditBookmarkViewModel
let name: String
let url: String
var bm: MyBookmarkViewModel
init(name: String, url: String, isVisible: Binding<Bool>, bm: MyBookmarkViewModel) {
self.bm = bm
self.vm = EditBookmarkViewModel(bookmarkVM: bm)
_isVisible = isVisible
self.name = name
self.url = url
}
var body: some View {
TextField("Edit Name", text: $vm.name)
Spacer()
TextField("Edit url", text: $vm.url)
Button("Update") {
vm.save()
}
}
}
You create a sheet for each item in ForEach, and only one sheet can be represented at a time. So showEditView shows the sheet related to the first view in the hierarchy and it in turn captures the first item in your list.
Instead you can you use sheet which accepts item binding: sheet is presented when the item is not nil. You only need one such sheet, so add it to your list, not to each item.
When you finish editing, set selectedBookmark to nil to hide the sheet.
Full working example:
struct Bookmark: Identifiable {
let id: Int
}
struct ContentView: View {
#State private var selectedBookmark: Bookmark?
let items = (0..<10).map { Bookmark(id: $0) }
var body: some View {
ForEach(items) { myBookmark in
HStack {
Text(String(describing: myBookmark))
Button {
selectedBookmark = myBookmark
} label: {
Image(systemName: "pencil").foregroundColor(.red)
}
}
}
.sheet(item: $selectedBookmark) { selectedBookmark in
let _ = print(String(describing: selectedBookmark))
Text(String(describing: selectedBookmark))
}
}
}

First item in a List is always selected

I have a list of items, I want to make it possible to navigate to the details view. However, the first element in the list is always passed to this view, what could be the problem?
struct ContentView: View {
var array: [Object] = [Object(id: .init(),property: 1),Object(id: .init(),property: 2),Object(id: .init(),property: 3)]
#State var showAlert = false
#State var showDetailsView = false
var body: some View {
NavigationView{
List{
ForEach(array){ item in
VStack{
Text(String(item.property))
}.onTapGesture(){ self.showAlert.toggle()}
.alert(isPresented: $showAlert){
Alert(title: Text("show details view?"), message: Text(""),
primaryButton: .default (Text("Show")) {
showDetailsView.toggle()
},
secondaryButton: .cancel()
)
}
.fullScreenCover(isPresented: $showDetailsView){ DetailsView(property: item.property) }
}
}
}
}
}
struct Object: Identifiable {
let id: UUID
var property: Int
}
struct DetailsView: View {
var property: Int?
var body: some View {
Text(String(property!))
}
}
I will get this result regardless of which item in the list I select:
In this scenario we can pass clicked item like baton from ForEach to Alert to FullScreen to Details. And, of course, we should move corresponding modifiers out of cycle, they don't need to be applied to each row.
Here is a modified code. Tested with Xcode 12.1 / iOS 14.1.
struct ContentView: View {
var array: [Object] = [Object(id: .init(),property: 1),Object(id: .init(),property: 2),Object(id: .init(),property: 3)]
#State var alertItem: Object?
#State var selectedItem: Object?
var body: some View {
NavigationView{
List{
ForEach(array){ item in
VStack{
Text(String(item.property))
}.onTapGesture(){ self.alertItem = item}
}
}
.alert(item: $alertItem) { item in
Alert(title: Text("show details view?"), message: Text(""),
primaryButton: .default (Text("Show")) {
selectedItem = item
},
secondaryButton: .cancel()
)
}
.fullScreenCover(item: $selectedItem){ DetailsView(property: $0.property) }
}
}
}

SwiftUI: How to Save value from a TextField in core data

I am building a very simple SwiftUI App, following a tutorial.
I created a View that contains a list of items which is #fetched from CoreData, and the list is shown with no problem.
Then I added a modal window with a TextField and a Button to save the data.
This is the code of the modal:
import SwiftUI
struct AddCategoryView: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#Environment(\.managedObjectContext) var context
#State public var name: String = ""
#State public var id = UUID()
var body: some View {
VStack {
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Close")
}
Text("Add a new Category")
.font(.largeTitle)
TextField("Enter category name", text: $name)
.padding(.all)
.textFieldStyle(RoundedBorderTextFieldStyle())
Button(action: {
let category = Category(context: self.context)
category.name = self.name
category.id = self.id
do {
try self.context.save()
} catch {
self.name = "There is an error!"
}
}) {
Text("SAVE \(self.name)")
}
}
}
}
struct AddCategoryView_Previews: PreviewProvider {
static var previews: some View {
AddCategoryView()
}
}
In this line,
Text("SAVE \(self.name)")
I am printing (for debugging) the value of the variable name, and I can see that the variables changes to the value that is in the catch statement
self.name = "There is an error!"
So I know that the saving is failing. But I have no idea why.
In the List View I have a button that will open this modal; I changed the value of this Button from
self.showAddModal.toggle()
TO
let category = Category(context: self.context)
category.name = "New Category"
do {
try self.context.save()
} catch {
self.name = "There is an error!"
}
And it works! I can see that the List View is updated with the value.
This is the code for the List View
struct CategoriesView: View {
#Environment(\.managedObjectContext) var context
#FetchRequest(entity: Category.entity(), sortDescriptors: []) var categories: FetchedResults<Category>
#State private var showAddModal = false;
#State public var name = ""
var body: some View {
VStack {
Text("\(name)")
List {
ForEach(categories, id: \.id) { category in
VStack {
Text(category.name ?? "Unknown")
}
}
}
Button(action: {
let category = Category(context: self.context)
category.name = "New Category"
do {
try self.context.save()
} catch {
self.name = "There is an error!"
}
}) {
Text("+")
.font(.system(.largeTitle))
.fontWeight(.bold)
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.frame(width: 48, height: 48)
.background(Color.purple)
.cornerRadius(24)
.padding(.all)
.shadow(color: Color.black.opacity(0.3),
radius: 3,
x: 3,
y: 3)
}.sheet(isPresented: $showAddModal) {
AddCategoryView()
}
}
}
}
struct CategoriesView_Previews: PreviewProvider {
static var previews: some View {
let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
return CategoriesView().environment(\.managedObjectContext, context)
}
}
I have spent the last 2 hours looking and googling for a solution, but I cannot find out what is wrong in my code. I also tried on the simulator but got the same error; I cannot save the core data from the TextField.
Thanks in advance!
Ok I finally found the solution, which maybe is not the right one but at least it works.
.sheet(isPresented: $showAddModal) {
AddCategoryView().environment(\.managedObjectContext, self.managedObjectContext)
}
Yep, just passing the Environment with the context to the Modal View fixed the issue. I am not sure this is the correct way to do it.

List selection as Set<String> - how to use?

Am playing around with SwiftUI and am obviously not getting it.
Basic example which works and is just displaying the selected name.
struct ContentView: View {
let names = ["Joe", "Jim", "Paul"]
#State var selectedName = Set<String>()
var body: some View {
VStack {
List(names, id: \.self, selection: $selectedName) { name in
Text(name)
}
if !selectedName.isEmpty {
Text(selectedName.first!) // <-- this line
}
}
}
}
What I want is a textfield where that name can be changed. Tried many ways but getting another error every time.
TextField("Name", text: $selectedName)
Gives this error: Cannot convert value of type 'Binding<Set<String>>' to expected argument type 'Binding<String>'
TextField("Name", text: $selectedName.first!)
Cannot force unwrap value of non-optional type 'Binding<((String) throws -> Bool) throws -> String?>'
How would I do this?
You may make a binding by yourself:
TextField("Name", text: Binding<String>(get: {self.selectedName.first!}, set: { _ in}) )
Obviously you can't pass Binding<Set<String>> to Binding<String>. Here gives you an idea or solution to change selectedName variable using TextField:
I added a new variable which is Binding<String>. Then I change the selectedName inside the TextField's onCommit closure.
struct ContentView: View {
let names = ["Joe", "Jim", "Paul"]
#State var selectedName = Set<String>()
#State var textFieldName = ""
var body: some View {
VStack {
List(names, id: \.self, selection: $selectedName) { name in
Text(name)
}
if !selectedName.isEmpty {
Text(selectedName.first!)
}
Text(textFieldName)
TextField("Name", text: $textFieldName, onEditingChanged: { (Bool) in
//onEditing
}) {
//onCommit
self.selectedName.insert(self.textFieldName)
}
}
}
}
Ok, here is my alternate if I'd needed to edit some value of names having in one screen and list and edit field and make them all synchronised and not confuse each other.
Here is full testable module (tested on Xcode 11.2/iOS 13.2). As I tested it for iOS there are API requirement for put List into EditMode to process selection, so this included.
struct TestChangeSelectedItem: View {
#State var names = ["Joe", "Jim", "Paul"] // made modifiable
#State var selectedName: String? = nil // only one can be edited, so single selection
#State var editMode: EditMode = .active // Tested for iOS, so it is needed
var body: some View {
VStack {
List(selection: $selectedName) {
ForEach(names, id: \.self) { name in
Text(name)
}
}
.environment(\.editMode, $editMode) // Tested for iOS, so it is needed
if selectedName != nil {
Divider()
Text(selectedName!) // Left to see updates for selection
editor(for: selectedName!) // Separated to make more clear
}
}
}
private func editor(for selection: String) -> some View {
let index = names.firstIndex(of: selection)!
var editedValue = selection // local to avoid cycling in refresh
return HStack {
Text("New name:")
TextField("Name", text: Binding<String>(get: { editedValue }, set: { editedValue = $0}), onCommit: {
self.names[index] = editedValue
self.selectedName = editedValue
})
}
}
}
struct TestChangeSelectedItem_Previews: PreviewProvider {
static var previews: some View {
TestChangeSelectedItem()
}
}