I have a problem with the sortOrder in Tables with FetchedResults as datasource. Currently I'm using XCode 13 beta, Deployment Target macOS 12.0. Here is my code:
struct MyTable: View {
#FetchRequest(fetchRequest: Person.fetchRequest(), animation: .default)
var people: FetchedResults<Person>
#State private var sortOrder = [KeyPathComparator(\Person.familyName)]
#State private var selectedPersonID: Person.ID?
var body: some View {
Table(people, selection: $selectedPersonID, sortOrder: $sortOrder) {
TableColumn("Nachname", value: \.familyName)
TableColumn("Vorname", value: \.givenName)
TableColumn("Adresse", value:\.address)
TableColumn("Telefon 1", value:\.phone1)
TableColumn("Telefon 2", value:\.phone2)
}
.onChange(of: sortOrder) {
// --> PROBLEM: how can I implement the sortOrder with NSSortDescriptor ? <--
}
}
}
My question is: How can I implement the sort functionality in the .onChange-Method? The first attempt using people.sort(using: $0) failed. I also tried to parse the fetchedResults<Person> people into an Array:
var peopleArray: [Person] {
Array(people)
}
But then I get an immutable value peopleArray on which I cannot sort with the .sort(using:)-Method.
Thanks for any help!
Nico
I know, the question is old, but perhaps there's someone looking for the answer.
#FetchRequest(
fetchRequest: Person.fetchRequest(),
sortDescriptors: [SortDescriptor(\. familyName, order: .forward)],
animation: .default
)
var people: FetchedResults<Person>
var body: some View {
Table(people, selection: $selectedPersonID, sortOrder: $people.sortDescriptors) {
TableColumn("Nachname", value: \.familyName)
TableColumn("Vorname", value: \.givenName)
TableColumn("Adresse", value:\.address)
TableColumn("Telefon 1", value:\.phone1)
TableColumn("Telefon 2", value:\.phone2)
}
}
Related
I'm trying to move away from having a TextField in the toolbar by using the new .searchable. But there seems to be a problem I can't solve. When you type the text you want to search, I can filter the list with that text, but when I place the mouse cursor on the first item and try to move down the list with the arrow key, with each arrow key press, the focus goes back to the search field, making it impossible to navigate up and down the list with the keyboard.
Maybe I'm not implementing it right, or maybe it doesn't work yet with macOS, either way, this is the code I'm using:
struct AllNotes: View {
#EnvironmentObject private var data: DataModel
#State var selectedNoteId: UUID?
#State var searchText: String = ""
var body: some View {
NavigationView {
List(data.notes.filter { searchText.isEmpty ? true : $0.text.localizedCaseInsensitiveContains(searchText) }) { note in
NavigationLink(
destination: NoteView(note: note, text: note.text),
tag: note.id,
selection: $selectedNoteId
) {
VStack(alignment: .leading) {
Text(getFirstLine(noteText: note.text)).font(.body).fontWeight(.bold)
}
}
}
.searchable(
text: $searchText,
placement: .toolbar,
prompt: "Search..."
)
.listStyle(InsetListStyle())
.toolbar {
// a few other buttons
}
}
}
}
The DataModel is simple a struct of NoteItem:
struct NoteItem: Codable, Hashable, Identifiable {
let id: UUID
var text: String
var changed: Bool = false
}
Am I missing anything? Am I implementing this right?
EDIT:
Based on suggestions from Apple and other sites, .searchable should be added under the navigation view. So I moved that there. The default behavior, as described by Apple, of adding it to the end of the toolbar is still happening, but that's ok. However the problem still persists, the focus jumps back to the search field each time you click on a list item.
struct AllNotes: View {
#EnvironmentObject private var data: DataModel
#State var selectedNoteId: UUID?
#State var searchText: String = ""
var body: some View {
NavigationView {
List(data.notes.filter { searchText.isEmpty ? true : $0.text.localizedCaseInsensitiveContains(searchText) }) { note in
NavigationLink(
destination: NoteView(note: note, text: note.text),
tag: note.id,
selection: $selectedNoteId
) {
VStack(alignment: .leading) {
Text(getFirstLine(noteText: note.text)).font(.body).fontWeight(.bold)
}
}
}
.listStyle(InsetListStyle())
.toolbar {
// a few other buttons
}
}
.searchable(
text: $searchText,
placement: .toolbar,
prompt: "Search..."
)
}
}
I think the problem is because you are showing the list in the sidebar but have the search field in the toolbar. So you could try moving the search field to the sidebar which does fix the problem with navigating items with arrow keys but I wasn't able to tab back to the search field. And InsetListStyle didn't seem compatible with searching so I commented that. And by the way, you are missing the default detail view for your NavigationView so you need to add that. Also your View structure needed tweaked so you pass the filtered results into the child View E.g.
struct NoteView: View {
let note: NoteItem
//let text: String
var body: some View {
Text(note.text)
}
}
struct NotesView: View {
#State private var selectedNoteId: UUID?
let notes: [NoteItem]
var body: some View {
List(notes) { note in
NavigationLink(
destination: NoteView(note: note), //text: note.text),
tag: note.id,
selection: $selectedNoteId
) {
VStack(alignment: .leading) {
Text(note.text).font(.body).fontWeight(.bold)
}
}
}
// .listStyle(InsetListStyle())
}
}
struct SearchView: View {
#EnvironmentObject private var data: DataModel
#State var searchText: String = ""
var body: some View {
NavigationView {
NotesView(notes: filteredNotes)
Text("Make a selection")
// .toolbar {
// // a few other buttons
// }
}
.searchable(
text: $searchText,
placement: .sidebar,
prompt: "Search..."
)
}
var filteredNotes: [NoteItem] {
data.notes.filter { searchText.isEmpty ? true : $0.text.localizedCaseInsensitiveContains(searchText)
}
}
}
struct ContentView: View {
#StateObject var model = DataModel()
var body: some View {
SearchView()
.environmentObject(model)
}
}
class DataModel: ObservableObject {
#Published var notes: [NoteItem] = [NoteItem(text: "Test1"), NoteItem(text: "Test2")]
}
struct NoteItem: Codable, Hashable, Identifiable {
let id = UUID()
var text: String
var changed: Bool = false
}
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.
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
}
}
I have an array of SplitItem objects I am trying to do a for loop on them and show a textfield. I keep getting the error Use of unresolved identifier '$item' for this code,
import SwiftUI
struct ContentView: View {
#State var selectedAccount:Int = -1
#State var splitItems:[SplitItem] = [
SplitItem(account: 0, amount: "1.00", ledger: .Accounts),
SplitItem(account: 0, amount: "2.00", ledger: .Accounts),
SplitItem(account: 0, amount: "3.00", ledger: .Budgets),
SplitItem(account: 0, amount: "4.00", ledger: .Budgets)
]
var body: some View {
VStack {
ForEach(self.splitItems) { item in
TextField(item.amount, text: $item.amount)
}
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct SplitItem: Identifiable {
#State var id:UUID = UUID()
#State var account:Int
#State var amount:String
#State var ledger:LedgerType
}
enum LedgerType:Int {case Accounts=0,Budgets=1}
if I change the text: $item.amount to text: item.$amount it compiles but the resulting textfield does not let me change it. The same thing if I change the for loop to indices and try to bind based on the index,
ForEach(self.splitItems.indices) { index in
TextField(self.splitItems[index].amount, text: self.$splitItems[index].amount)
}
it has no problem showing a Text(item.amount) its only when I try to bind do I have a problem. I think it has something to do with it being an array because if I try to bind a single splititem not in an array to a textfield it works just fine.
edit I also tried making a subview with the textfield and calling that from the foreach loop but I got the same error.
also this is swiftui for Mac not iOS.
It is not Mac nor iOS specific.
First of all, use the state as the single source of truth for a given view only.
In your case, move your data and business logic to your model, represented by some ObservableObject. In your View use #ObservedObject property wrapper.
Your (simplified) data structure and model could be defined as
struct Item: Identifiable {
let id = UUID()
var txt = ""
}
class Model: ObservableObject {
#Published var items = [Item(txt: "alfa"), Item(txt: "beta")]
}
Now you are ready tu use this model in View. When the state value changes, the view invalidates its appearance and recomputes the body, and the same is true for ObservedObject.
struct ContentView: View {
#ObservedObject var model = Model()
var body: some View {
VStack {
ForEach(model.items.indices) { idx in
VStack {
Text(self.model.items[idx].txt.capitalized).bold()
TextField("label", text: self.$model.items[idx].txt).frame(idealWidth: 200)
}
}
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
When I try to put 2 pikers with a different number of rows on-screen with different observers.
if I select in one number of the row that not exists in the second,
when I move to the second picker app crash with this message:
"Fatal error: Index out of range"
public enum kTrackType {
case audio
case text
}
class kTrack: NSObject, Identifiable {
public var id = UUID()
public var trakcId: String
public var title: String
public var type: kTrackType
public init(id: String, title: String, type: kTrackType) {
self.trakcId = id
self.title = title
self.type = type
}
}
and this is the main struct:
struct SelectedAudioAndSubtileView: View {
let geometry: GeometryProxy
#State var subtitlesList = [kTrack(id: "t0", title: "None", type: kTrackType.text),
kTrack(id: "t1", title: "En", type: kTrackType.text),
kTrack(id: "t2", title: "Rus", type: kTrackType.text),
kTrack(id: "t3", title: "Spn", type: kTrackType.text)]
#State var multiAudioList = [kTrack(id: "s0", title: "En", type: kTrackType.audio),
kTrack(id: "s1", title: "Rus", type: kTrackType.audio)]
#Binding var showSubtitlesPicker: Bool
#State private var selectedAudioPicker: Int = 0
#State private var selectedSubtitlePicker: Int = 0
#State private var selectedType = 0
var body: some View {
VStack {
Picker(selection: $selectedType, label: EmptyView()) {
Text("Audio").tag(0)
Text("Subtitle").tag(1)
}
.pickerStyle(SegmentedPickerStyle())
Text(self.selectedType == 0 ? "Select Audio" : "Select Subtitle")
Divider()
if selectedType == 0 {
Picker(selection: self.$selectedAudioPicker, label: Text("")) {
ForEach(self.multiAudioList, id: \.id){ name in
Text(name.title)
}
}
} else {
Picker(selection: self.$selectedSubtitlePicker, label: Text("")) {
ForEach(self.subtitlesList, id: \.id){ name in
Text(name.title)
}
}
}
Divider()
}
.background(Color(#colorLiteral(red: 0.9686274529, green: 0.78039217, blue: 0.3450980484, alpha: 1)))
.offset(y: geometry.size.height - 330)
}
After recheck, the crash happened also if you have same rows in 2 pickers!
Here is the situation :
a) the selectedValue should match the tag value, therefore in ForEach, it's better to use index not the id so that you can add tag for each items.
b) the ForEach structure is a complex one and usually to be reused for performance. So in order to force it refresh, id() modifier can be added to extra ForEach structures. There must be one ForEach without id which provides the real underlying data layer.
if selectedType == 0 {
Picker (selection: self.$selectedAudioPicker, label: Text("")) {
ForEach(0..<self.multiAudioList.count){ index in
Text(self.multiAudioList[index].title).tag(index)
}
}
} else if selectedType == 1 {
Picker(selection: self.$selectedSubtitlePicker, label: Text("")) {
ForEach(0..<self.subtitlesList.count){ index in
Text(self.subtitlesList[index].title).tag(index)
}.id(0)
}
}