SwiftUI 3 MacOs Table single selection and double click open sheet - xcode

import SwiftUI
struct ContentView: View {
#State private var items: [ItemModel] = Array(0...100).map { ItemModel(id: $0, title: "item \($0)", age: $0) }
#State private var selection = Set<ItemModel.ID>()
#State private var sorting = [KeyPathComparator(\ItemModel.age)]
var body: some View {
Table(items, selection: $selection, sortOrder: $sorting) {
TableColumn("id", value: \.id) { Text("\($0.id)") }
TableColumn("title", value: \.title)
TableColumn("age", value: \.age) { Text("\($0.age)") }
}
.onChange(of: sorting) {
items.sort(using: $0)
}
.font(.caption)
.frame(width: 960, height: 540)
}
}
struct ItemModel: Identifiable {
var id: Int
var title: String
var age: Int
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
this is a working example of a Table sorted on Model.age, and support multi selection,
I want single selection and open sheet on double click on a row, is that possible?
also how do I get the selected item object?
thank you 🙏

You must change Set<Value.ID> for Value.ID for only one row selection, and make TapGesture in Text.
#State private var selection = Set<ItemModel.ID>() // <-- Use this for multiple rows selections
#State private var selection : ItemModel.ID? // <--- Use this for only one row selection
struct ContentView: View {
#State private var items: [ItemModel] = Array(0...100).map { ItemModel(id: $0, title: "item \($0)", age: $0) }
//#State private var selection = Set<ItemModel.ID>() <-- Use this for multiple rows selections
#State private var selection : ItemModel.ID? // <--- Use this for only one row selection
#State private var sorting = [KeyPathComparator(\ItemModel.age)]
#State private var showRow = false
var editRow: some View {
VStack {
Text(items[selection!].title)
.font(.title)
Text("Selected: \(selection.debugDescription)")
Button("Dismiss") {
showRow.toggle()
}.padding()
}
.frame(minWidth:400, minHeight: 400)
}
var body: some View {
VStack {
Table(items, selection: $selection, sortOrder: $sorting) {
TableColumn("id", value: \.id) {
Text("\($0.id)")
.onTapGesture(count: 2, perform: {
if selection != nil {
showRow.toggle()
}
})
}
TableColumn("title") { itemModel in
Text(itemModel.title)
.onTapGesture(count: 2, perform: {
if selection != nil {
showRow.toggle()
}
})
}
TableColumn("age", value: \.age) { Text("\($0.age)") }
}
.onChange(of: sorting) {
items.sort(using: $0)
}
.font(.caption)
.frame(width: 960, height: 540)
}
.sheet(isPresented: $showRow) {
editRow
}
}
}

Like Adam comments, the other answer has a number of problems with the selection region and response time.
You do have to set var selection as ItemModel.ID? but you also have to handle click actions differently.
It's important to note that this will only work from Big Sur on.
The way I handle different actions for single and double clicks is this:
.gesture(TapGesture(count: 2).onEnded {
print("double clicked")
})
.simultaneousGesture(TapGesture().onEnded {
print("single clicked")
})
For your example:
struct ContentView: View {
#State private var items: [ItemModel] = Array(0...100).map { ItemModel(id: $0, title: "item \($0)", age: $0) }
#State private var selection = ItemModel.ID?
#State private var sorting = [KeyPathComparator(\ItemModel.age)]
#State private var isShowingSheet: Bool = false
var body: some View {
Table(items, selection: $selection, sortOrder: $sorting) {
TableColumn("id", value: \.id) {
Text("\($0.id)").gesture(TapGesture(count: 2).onEnded {
self.
}).simultaneousGesture(TapGesture().onEnded {
self.selection = $0.id
})
}
TableColumn("title", value: \.title)
TableColumn("age", value: \.age) { Text("\($0.age)") }
}
.onChange(of: sorting) {
items.sort(using: $0)
}
.font(.caption)
.frame(width: 960, height: 540).sheet(isPresented: self.$isShowingSheet) {
Button("Close Sheet") { self.isShowingSheet = false } // <-- You may want to allow click to close sheet.
Text("Sheet Content Here")
}
}
}
If you want to allow single and double click in the entire row, you need to have the TableColumn content fill the entire width of the column and apply the modifiers on the rest of the TableColumn contents.

Regarding the double click of a table row: Apple introduced a new context menu modifier contextMenu(forSelectionType:menu:primaryAction:) with SwiftUI 4 at WWDC 2022. With this, a primaryAction can be provided that is performed when the user double clicks on a Table row.
#State private var selection: ItemModel.ID?
var body: some View {
Table(items, selection: $selection, sortOrder: $sortOrder) {
TableColumn("id", value: \.id)
TableColumn("title", value: \.title)
TableColumn("age", value: \.age)
}
.contextMenu(forSelectionType: ItemModel.ID.self) { items in
// ...
} primaryAction: { items in
// This is executed when the row is double clicked
}
}

Related

Allow custom tap gesture in List but maintain default selection gesture

I'm trying to create a List that allows multiple selection. Each row can be edited but the issue is that since there's a tap gesture on the Text element, the list is unable to select the item.
Here's some code:
import SwiftUI
struct Person: Identifiable {
let id: UUID
let name: String
init(_ name: String) {
self.id = UUID()
self.name = name
}
}
struct ContentView: View {
#State private var persons = [Person("Peter"), Person("Jack"), Person("Sophia"), Person("Helen")]
#State private var selectedPersons = Set<Person.ID>()
var body: some View {
VStack {
List(selection: $selectedPersons) {
ForEach(persons) { person in
PersonView(person: person, selection: $selectedPersons) { newValue in
// ...
}
}
}
}
.padding()
}
}
struct PersonView: View {
var person: Person
#Binding var selection: Set<Person.ID>
var onCommit: (String) -> Void = { newValue in }
#State private var isEditing = false
#State private var newValue = ""
#FocusState private var isInputActive: Bool
var body: some View {
if isEditing {
TextField("", text: $newValue, onCommit: {
onCommit(newValue)
isEditing = false
})
.focused($isInputActive)
.labelsHidden()
}
else {
Text(person.name)
.onTapGesture {
if selection.contains(person.id), selection.count == 1 {
newValue = person.name
isEditing = true
isInputActive = true
}
}
}
}
}
Right now, you need to tap on the row anywhere but on the text to select it. Then, if you tap on the text it'll go in edit mode.
Is there a way to let the list do its selection? I tried wrapping the tap gesture in simultaneousGesture but that didn't work.
Thanks!

Select the first item on a list and highlight it when item changes

I have a simple notes app that uses a list on the left and a texteditor on the right. The list has the titles of the notes, and the texteditor its text. When the user changes the text on the note, the list gets sorted to display the most current note (by date) on top, as its first item.
Started with Ventura, if I'm working on a note other than the first one, then that note (the item in the list) jumps to the top when the text is changed, however, if the first item in the list not visible (I'm working on a note that is way down), then when I change the text the item, it jumps to the top, but you don't jump with it. You are now in this state where you have to scroll up to get to the top and reselect the first item.
I tried using DispatchQueue.main.async to force to reselect the item onchange, but regardless of what I try, it doesn't scroll to the top, even when the selected note id is the correct one.
I ran out of ideas or things to try. How can I go back to the first item once the text is changed?
Here's the code:
struct NoteItem: Codable, Hashable, Identifiable {
let id: Int
var text: String
var date = Date()
var dateText: String {
dateFormatter.dateFormat = "EEEE, MMM d yyyy, h:mm a"
return dateFormatter.string(from: date)
}
var tags: [String] = []
}
final class DataModel: ObservableObject {
#AppStorage("notes") public var notes: [NoteItem] = []
}
struct AllNotes: View {
#EnvironmentObject private var data: DataModel
#State var noteText: String = ""
#State var selectedNoteId: UUID?
var body: some View {
NavigationView {
List(data.notes) { note in
NavigationLink(
destination: NoteView(note: note),
tag: note.id,
selection: $selectedNoteId
) {
VStack(alignment: .leading) {
Text(note.text.components(separatedBy: NSCharacterSet.newlines).first!)
Text(note.dateText).font(.body).fontWeight(.light)
}
.padding(.vertical, 8)
}
}
.listStyle(InsetListStyle())
}
.navigationTitle("A title")
.toolbar {
ToolbarItem(placement: .navigation) {
Button(action: {
data.notes.append(NoteItem(id: UUID(), text: "New Note", date: Date(), tags: []))
}) {
Image(systemName: "square.and.pencil")
}
}
}
.onAppear {
DispatchQueue.main.async {
selectedNoteId = data.notes.first?.id
}
}
.onChange(of: data.notes) { notes in
if selectedNoteId == nil || !notes.contains(where: { $0.id == selectedNoteId }) {
selectedNoteId = data.notes.first?.id
}
}
}
}
struct NoteView: View {
#EnvironmentObject private var data: DataModel
var note: NoteItem
#State var text: String = ""
var body: some View {
HStack {
VStack(alignment: .leading) {
TextEditor(text: $text).padding().font(.body)
.onChange(of: text, perform: { value in
guard let index = data.notes.firstIndex(of: note) else { return }
data.notes[index].text = value
data.sortList()
})
Spacer()
}
Spacer()
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

SwiftUI presenting sheet with Binding variable doesn't work when first presented

I'm trying to present a View in a sheet with a #Binding String variable that just shows/binds this variable in a TextField.
In my main ContentView I have an Array of Strings which I display with a ForEach looping over the indices of the Array, showing a Button each with the text of the looped-over-element.
The Buttons action is simple: set an #State "index"-variable to the pressed Buttons' Element-index and show the sheet.
Here is my ContentView:
struct ContentView: View {
#State var array = ["first", "second", "third"]
#State var showIndex = 0
#State var showSheet = false
var body: some View {
VStack {
ForEach (0 ..< array.count, id:\.self) { i in
Button("\(array[i])") {
showIndex = i
showSheet = true
}
}
// Text("\(showIndex)") // if I uncomment this line, it works!
}
.sheet(isPresented: $showSheet, content: {
SheetView(text: $array[showIndex])
})
.padding()
}
}
And here is the SheetView:
struct SheetView: View {
#Binding var text: String
#Environment(\.presentationMode) var presentationMode
var body: some View {
VStack {
TextField("text:", text: $text)
Button("dismiss") {
presentationMode.wrappedValue.dismiss()
}
}.padding()
}
}
The problem is, when I first open the app and press on the "second" Button, the sheet opens and displays "first" in the TextField. I can then dismiss the Sheet and press the "second" Button again with the same result.
If I then press the "third" or "first" Button everything works from then on. Pressing any Button results in the correct behaviour.
Preview
Interestingly, if I uncomment the line with the Text showing the showIndex-variable, it works from the first time on.
Is this a bug, or am I doing something wrong here?
You should use custom Binding, custom Struct for solving the issue, it is complex issue. See the Example:
struct ContentView: View {
#State private var array: [String] = ["first", "second", "third"]
#State private var customStruct: CustomStruct?
var body: some View {
VStack {
ForEach (array.indices, id:\.self) { index in
Button(action: { customStruct = CustomStruct(int: index) }, label: {
Text(array[index]).frame(width: 100)
})
}
}
.frame(width: 300, height: 300, alignment: .center)
.background(Color.gray.opacity(0.5))
.sheet(item: $customStruct, content: { item in SheetView(text: Binding.init(get: { () -> String in return array[item.int] },
set: { (newValue) in array[item.int] = newValue }) ) })
}
}
struct CustomStruct: Identifiable {
let id: UUID = UUID()
var int: Int
}
struct SheetView: View {
#Binding var text: String
#Environment(\.presentationMode) var presentationMode
var body: some View {
VStack {
TextField("text:", text: $text)
Button("dismiss") {
presentationMode.wrappedValue.dismiss()
}
}.padding()
}
}
I had this happen to me before. I believe it is a bug, in that until it is used in the UI, it doesn't seem to get set in the ForEach. I fixed it essentially in the same way you did, with a bit of subtlety. Use it in each Button as part of the Label but hide it like so:
Button(action: {
showIndex = i
showSheet = true
}, label: {
HStack {
Text("\(array[i])")
Text(showIndex.description)
.hidden()
}
})
This doesn't change your UI, but you use it so it gets properly updated. I can't seem to find where I had the issue in my app, and I have changed the UI to get away from this, but I can't remember how I did it. I will update this if I can find it. This is a bit of a kludge, but it works.
Passing a binding to the index fix the issue like this
struct ContentView: View {
#State var array = ["First", "Second", "Third"]
#State var showIndex: Int = 0
#State var showSheet = false
var body: some View {
VStack {
ForEach (0 ..< array.count, id:\.self) { i in
Button(action:{
showIndex = i
showSheet.toggle()
})
{
Text("\(array[i])")
}.sheet(isPresented: $showSheet){
SheetView(text: $array, index: $showIndex)
}
}
}
.padding()
}
}
struct SheetView: View {
#Binding var text: [String]
#Binding var index: Int
#Environment(\.presentationMode) var presentationMode
var body: some View {
VStack {
TextField("text:", text: $text[index])
Button("dismiss") {
presentationMode.wrappedValue.dismiss()
}
}.padding()
}
}
In SwiftUI2 when calling isPresented if you don't pass bindings you're going to have some weird issues.
This is a simple tweak if you want to keep it with the isPresented and make it work but i would advise you to use the item with a costum struct like the answer of swiftPunk
This is how I would do it. You'll lose your form edits if you don't use #State variables.
This Code is Untested
struct SheetView: View {
#Binding var text: String
#State var draft: String
#Environment(\.presentationMode) var presentationMode
init(text: Binding<String>) {
self._text = text
self._draft = State(initialValue: text.wrappedValue)
}
var body: some View {
VStack {
TextField("text:", text: $draft)
Button("dismiss") {
text = draft
presentationMode.wrappedValue.dismiss()
}
}.padding()
}
}

why is a passed parameter displaying the previous content of an EnvironmentObject

this is a Macos app where the parsclass is setup in a previous view that contains the YardageRowView below. That previous view is responsible for changing the contents of the parsclass. This is working is other views that use a NavigationLink to display the views.
When the parsclass is changed, this view is refreshed, but the previous value is put in the text field on the holeValueTestView.
I cannot comprehend how the value is not being passed into the holeValueTestView correctly
This is a view shown as a .sheet, and if I dismiss it and display it again, everything is fine.
if you create a macOS project called YardageSample and replace the ContentView.swift and YardageSampleApp.swift with the two files below, you can see that the display in red changes and the black numbers do not change until you click Done and redisplay the .sheet
//
// YardageSampleApp.swift
// YardageSample
//
// Created by Brian Quick on 2021-04-12.
//
import SwiftUI
#main
struct YardageSampleApp: App {
#StateObject var parsclass = parsClass()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(parsclass)
}
}
}
//
// ContentView.swift
// YardageSample
//
// Created by Brian Quick on 2021-04-12.
//
import SwiftUI
struct ContentView: View {
#StateObject var parsclass = parsClass()
enum ActiveSheet : String , Identifiable {
case CourseMaintenance
var id: String {
return self.rawValue
}
}
#State var activeSheet : ActiveSheet? = nil
var body: some View {
Button(action: {
self.activeSheet = .CourseMaintenance
}) {
Text("Course Maintenance")
}
.sheet(item: $activeSheet) { sheet in
switch sheet {
case .CourseMaintenance:
CourseMaintenance()
}
}.frame(width: 200, height: 200, alignment: /*#START_MENU_TOKEN#*/.center/*#END_MENU_TOKEN#*/)
}
}
class parsClass: ObservableObject {
#Published var pars = [parsRec]()
init() {
self.pars = [parsRec]()
self.pars.append(parsRec())
}
func create(newpars: [parsRec]) {
pars.removeAll()
pars = newpars
}
}
class parsRec: Identifiable, Codable {
var id = UUID()
var Hole = 1
var Yardage = 1
}
struct CourseMaintenance: View {
#EnvironmentObject var parsclass: parsClass
#Environment(\.presentationMode) var presentationMode
var body: some View {
VStack {
Button(action: {presentationMode.wrappedValue.dismiss()}, label: {
Text("Done")
})
Button(action: {switchScores(number: 1)}, label: {
Text("Button 1")
})
Button(action: {switchScores(number: 2)}, label: {
Text("Button 2")
})
Button(action: {switchScores(number: 3)}, label: {
Text("Button 3")
})
CourseDetail().environmentObject(parsclass)
}.frame(width: 400, height: 400, alignment: .center)
}
func switchScores(number: Int) {
var newparRecs = [parsRec]()
for i in 0..<17 {
let myrec = parsRec()
myrec.Hole = i
myrec.Yardage = number
newparRecs.append(myrec)
}
parsclass.create(newpars: newparRecs)
}
}
struct CourseDetail: View {
#EnvironmentObject var parsclass: parsClass
var body: some View {
HStack(spacing: 0) {
ForEach(parsclass.pars.indices, id: \.self) { indice in
// this displays the previous value
holeValueTestView(value: String(parsclass.pars[indice].Yardage))
// this displays the correct value after parsclass has changed
Text(String(parsclass.pars[indice].Yardage))
.foregroundColor(.red)
}
}
}
}
struct holeValueTestView: View {
#State var value: String
var body: some View {
//TextField(String(value), text: $value)
Text(value)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
There are a couple of issues going on:
You have multiple instances of parsClass. One is defined in YardageSampleApp and passed into the view hierarchy as a #EnvironmentObject. The second is defined in ContentView as a #StateObject. Make sure you're only using one.
On holeValueTestView, you defined value as a #State variable. That gets set initially when the view is created by its parent and then it maintains its own state. So, when the environmentObject changed, because it was in charge of its own state at this point, it didn't update the value. You can simply remove #State and see the behavior that you want.
struct ContentView: View {
#EnvironmentObject var parsclass : parsClass //<-- Here
enum ActiveSheet : String , Identifiable {
case CourseMaintenance
var id: String {
return self.rawValue
}
}
#State var activeSheet : ActiveSheet? = nil
var body: some View {
Button(action: {
self.activeSheet = .CourseMaintenance
}) {
Text("Course Maintenance")
}
.sheet(item: $activeSheet) { sheet in
switch sheet {
case .CourseMaintenance:
CourseMaintenance()
}
}.frame(width: 200, height: 200, alignment: .center)
}
}
struct holeValueTestView: View {
var value: String //<-- Here
var body: some View {
Text(value)
}
}
As a side note:
In Swift, normally type names are capitalized. If you want to write idiomatic Swift, you would change your parsClass to ParsClass for example.

SwiftUI List how to identify what item is selected on macOS

Here is what I have based on this answer. The code currently allows the user to select a cell but I cannot distinguish which cell is selected or execute any code in response to the selection. In summary, how can I execute code based on the selected cell's name and execute on click. The cell currently highlights in blue where clicked, but I want to identify it and act accordingly based on that selection. Note: I am not looking to select the cell in editing mode. Also, how can I programmatically select a cell without click?
struct OtherView: View {
#State var list: [String]
#State var selectKeeper = Set<String>()
var body: some View {
NavigationView {
List(list, id: \.self, selection: $selectKeeper) { item in
Text(item)
}
}
}
}
Here is a gif demoing the selection
I found a workaround, but the text itself has to be clicked- clicking the cell does nothing:
struct OtherView: View {
#State var list: [String]
#State var selectKeeper = Set<String>()
var body: some View {
NavigationView {
List(list, id: \.self, selection: $selectKeeper) { item in
Text(item)
.onTapGesture {
print(item)
}
}
}
}
}
List selection works in Edit mode, so here is some demo of selection usage
struct OtherView: View {
#State var list: [String] = ["Phil Swanson", "Karen Gibbons", "Grant Kilman", "Wanda Green"]
#State var selectKeeper = Set<String>()
var body: some View {
NavigationView {
List(list, id: \.self, selection: $selectKeeper) { item in
if self.selectKeeper.contains(item) {
Text(item).bold()
} else {
Text(item)
}
}.navigationBarItems(trailing: HStack {
if self.selectKeeper.count != 0 {
Button("Send") {
print("Sending selected... \(self.selectKeeper)")
}
}
EditButton()
})
}
}
}
To spare you the labour pains:
import SwiftUI
struct ContentView: View {
#State private var selection: String?
let list: [String] = ["First", "Second", "Third"]
var body: some View {
NavigationView {
HStack {
List(selection: $selection) {
ForEach(list, id: \.self) { item in
VStack {
Text(item)
}
}
}
TextField("Option", text: Binding(self.$selection) ?? .constant(""))
}
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 100, maxHeight: .infinity)
}
}
}
This solution deals with the problem #Isaac addressed.
screenshot
my 2 cents for custom selection and actions
works in swift 5.1 / iOS14:
see:
https://gist.github.com/ingconti/49497419e5edd84a5f3be52397c76eb4
I left a lot of debug "print".. to better understand.
You can react to the selected item by using onChange on your selection variable.
You can set the selection manually by simply setting the variable. This will also trigger onChange.
Here's some example code that will run directly in Playgrounds:
import SwiftUI
import PlaygroundSupport
struct OtherView: View {
#State var list: [String] = ["a", "b", "c"]
#State var selection: String?
var body: some View {
NavigationView {
VStack {
List(list, id: \.self, selection: $selection) { item in
Text(item)
}
.onChange(of: selection) {s in
// React to changes here
print(s)
}
Button("Select b") {
// Example of setting selection programmatically
selection = "b"
}
}
}
}
}
let view = OtherView()
PlaygroundPage.current.setLiveView(view)

Resources