Allow custom tap gesture in List but maintain default selection gesture - macos

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!

Related

How to search a Table using SwiftUI on macOS?

In SwiftUI on iOS and iPadOS 15, we can add a search bar to filter a list using the searchable modifier:
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#State private var searchTerm = ""
#State private var selection = Set<Video.ID>()
private var fetchRequest: FetchRequest<Video>
private var searchResults: [Video] {
if searchTerm.isEmpty {
return fetchRequest.wrappedValue.filter { _ in true }
} else {
return fetchRequest.wrappedValue.filter { $0.matching(searchTerm) }
}
}
var body: some View {
NavigationView {
List {
ForEach(searchResults) { item in
VideoListCellView(video: item)
}
}.searchable(text: $searchTerm, prompt: "Video name") // <-- HERE
}
}
}
However, on macOS, the searchable modifier is not supported in the new Table container:
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(sortDescriptors: [SortDescriptor(\.addDate, order: .reverse)], animation: .default)
private var videos: FetchedResults<Video>
#State
private var selection = Set<Video.ID>()
var body: some View {
NavigationView {
Table(videos, selection: $selection, sortOrder: $videos.sortDescriptors) {
TableColumn("Title") {
Text($0.title)
}
TableColumn("Added") {
Text($0.addDate)
}.width(120)
TableColumn("Published") {
Text($0.publishedAt)
}.width(120)
TableColumn("Duration") {
Text($0.duration)
}.width(50)
}.searchable(text: $searchTerm, prompt: "Video name") // <-- GENERATES ERROR
}
}
}
Trying to use it generates a compile error in the var body: some View:
The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions
Is there another way to search a Table on macOS, or is this feature not supported yet?
The solution was to add the .searchable modifier to the NavigationView instead of the Table, as Scott suggested:
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(sortDescriptors: [SortDescriptor(\.addDate, order: .reverse)], animation: .default)
private var videos: FetchedResults<Video>
#State private var selection = Set<Video.ID>()
#State private var searchTerm = ""
private var searchResults: [Video] {
if searchTerm.isEmpty {
return videos.filter { _ in true }
} else {
return videos.filter { $0.matching(searchTerm) }
}
}
var body: some View {
NavigationView {
Table(searchResults, selection: $selection, sortOrder: $videos.sortDescriptors) {
TableColumn("Title", value: \.title) {
Text($0.title)
}
TableColumn("Added", value: \.addDate) {
Text($0.addDate)
}.width(120)
TableColumn("Published", value: \.publishedAt) {
Text($0.publishedAt)
}.width(120)
TableColumn("Duration") {
Text($0.duration)
}.width(50)
}
}.searchable(text: $searchTerm, prompt: "Video name") // <-- HERE
}
}
You can solve this by updating the predicate of the fetch request using a specific Binding variable.
The below solution is based on an example from the 2021 WWDC video Bring Core Data concurrency to Swift and SwiftUI where it was used on a List which is what I also used it for but I tested it on one of my tables and it works equally well.
#State private var searchText: String = ""
var query: Binding<String> {
Binding {
searchText
} set: { newValue in
searchText = newValue
if newValue.isEmpty {
videos.nsPredicate = NSPredicate(value: true)
} else {
videos.nsPredicate = NSPredicate(format: "name BEGINSWITH[c] %#", newValue)
}
}
}
And then you use pass this variable to .searchable
Table(videos, selection: $selection, sortOrder: $videos.sortDescriptors) {
// ...
}
.searchable(text: query, prompt: "Search instrument")
The downside of this solution is that a new fetch request is executed for each typed letter. I tried a quick fix by adding if newValue.count < 3 { return } in the else of the query set method and it works but it might be a bad restriction, maybe something more advanced can be implemented by using Combine.

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()
}
}

SwiftUI 3 MacOs Table single selection and double click open sheet

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
}
}

swiftui textfield is extremely laggy

I'm having issues where as more text is being typed into the textfield, the application (Mac) becomes more and more laggy.
class Action: ObservableObject, Identifiable {
var id = String(UUID().uuidString.prefix(7))
#Published var actionType = ActionType.display
#Published var arguments = [String:ValueType]()
}
struct AnalyticsKeyValuePairView: View {
var state: Action
let initialKey : String
#State var stateKey: String
#State var value: String
init(state: Action, initialKey: String) {
self.initialKey = initialKey
self.state = state
self._stateKey = State(initialValue: initialKey)
self._value = State(initialValue: state.arguments[initialKey]!.stringValue!)
}
var body: some View {
HStack {
TextField("key", text: $stateKey) { (b) in
} onCommit: {
if initialKey != stateKey {
state.setArgument(nil, forKey: initialKey)
}
state.setArgument(.string(value), forKey: stateKey)
}
Spacer()
TextField("value", text: $value) { (_) in
} onCommit: {
if initialKey != stateKey {
state.arguments[initialKey] = nil
}
state.setArgument(.string(value), forKey: stateKey)
}
}
}
in instruments you can see a lot core animation activity

How to access value from an item in ForEach list

How to access values from particular item on the list made with ForEach?
As you can see I was trying something like this (and many other options):
Text(item[2].onOff ? "On" : "Off")
I wanted to check the value of toggle of 2nd list item (for example) and update text on the screen saying if it's on or off.
And I know that it's something to do with #Binding and I was searching examples of this and trying few things, but I cannot make it to work. Maybe it is a beginner question. I would appreciate if someone could help me.
My ContentView:
struct ContentView: View {
// #Binding var onOff : Bool
#State private var onOff = false
#State private var test = false
var body: some View {
NavigationView {
List {
HStack {
Text("Is 2nd item on or off? ")
Text(onOff ? "On" : "Off")
// Text(item[2].onOff ? "On" : "Off")
}
ForEach((1...15), id: \.self) {item in
ListItemView()
}
}
.navigationBarTitle(Text("List"))
}
}
}
And ListItemView:
import SwiftUI
struct ListItemView: View {
#State private var onOff : Bool = false
// #Binding var onOff : Bool
var body: some View {
HStack {
Text("99")
.font(.title)
Text("List item")
Spacer()
Toggle(isOn: self.$onOff) {
Text("Label")
}
.labelsHidden()
}
}
}
I don't know what exactly you would like to achieve, but I made you a working example:
struct ListItemView: View {
#ObservedObject var model: ListItemModel
var body: some View {
HStack {
Text("99")
.font(.title)
Text("List item")
Spacer()
Toggle(isOn: self.$model.switchedOnOff) {
Text("Label")
}
.labelsHidden()
}
}
}
class ListItemModel: ObservableObject {
#Published var switchedOnOff: Bool = false
}
struct ContentView: View {
#State private var onOff = false
#State private var test = false
#State private var list = [
(id: 0, model: ListItemModel()),
(id: 1, model: ListItemModel()),
(id: 2, model: ListItemModel()),
(id: 3, model: ListItemModel()),
(id: 4, model: ListItemModel())
]
var body: some View {
NavigationView {
List {
HStack {
Text("Is 2nd item on or off? ")
Text(onOff ? "On" : "Off")
// Text(item[2].onOff ? "On" : "Off")
}
ForEach(self.list, id: \.id) {item in
ListItemView(model: item.model)
}
}
.navigationBarTitle(Text("List"))
}.onReceive(self.list[1].model.$switchedOnOff, perform: { switchedOnOff_second_item in
self.onOff = switchedOnOff_second_item
})
}
}
The #Published basically creates a Publisher, which the UI can listen to per onReceive().
Play around with this and you will figure out what these things do!
Good luck :)
import SwiftUI
struct ContentView: View {
#State private var onOffList = Array(repeating: true, count: 15)
var body: some View {
NavigationView {
List {
HStack {
Text("Is 2nd item on or off? ")
Text(onOffList[1] ? "On" : "Off")
}
ForEach((onOffList.indices), id: \.self) {idx in
ListItemView(onOff: self.$onOffList[idx])
}
}
.navigationBarTitle(Text("List"))
}
}
}
struct ListItemView: View {
#Binding var onOff : Bool
var body: some View {
HStack {
Text("99")
.font(.title)
Text("List item")
Spacer()
Toggle(isOn: $onOff) {
Text("Label")
}
.labelsHidden()
}
}
}
I understand that you are directing me to use ObservableObject. And probably it's the best way to go with final product. But I am still thinking about #Binding as I just need to pass values better between 2 views only. Maybe I still don't understand binding, but I came to this solution.
struct ContentView: View {
// #Binding var onOff : Bool
#State private var onOff = false
// #State private var test = false
var body: some View {
NavigationView {
List {
HStack {
Text("Is 2nd item on or off? ")
Text(onOff ? "On" : "Off")
// Text(self.item[2].$onOff ? "On" : "Off")
// Text(item[2].onOff ? "On" : "Off")
}
ForEach((1...15), id: \.self) {item in
ListItemView(onOff: self.$onOff)
}
}
.navigationBarTitle(Text("List"))
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
and ListItemView:
import SwiftUI
struct ListItemView: View {
// #State private var onOff : Bool = false
#Binding var onOff : Bool
var body: some View {
HStack {
Text("99")
.font(.title)
Text("List item")
Spacer()
Toggle(isOn: self.$onOff) {
Text("Label")
}
.labelsHidden()
}
}
}
What is happening now is text is being updated after I tap toggle. But I have 2 problems:
tapping 1 toggle changes all of them. I think it's because of this line:
ListItemView(onOff: self.$onOff)
I still cannot access value of just one row. In my understanding ForEach((1...15), id: .self) make each row have their own id, but I don't know how to access it later on.

Resources