macOS SwiftUI Table with contextMenu - macos

Using SwiftUI's new Table container, how can I add a context menu that appears when Control-clicking a row?
I can add the contextMenu modifier to the content of the TableColumn, but then I will have to add it to each individual column. And it only works above the specific text, not on the entire row:
I tried adding the modifier to the TableColumn itself, but it shows a compile error:
Value of type 'TableColumn<RowValue, Never, Text, Text>' has no member 'contextMenu'
Here's what I have in terms of source code, with the contextMenu modifier in the content of the TableColumn:
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Item.name, ascending: true)])
private var items: FetchedResults<Item>
#State
private var sortOrder = [KeyPathComparator(\Item.name)]
#State
private var selection = Set<Item.ID>()
var body: some View {
NavigationView {
Table(items, selection: $selection, sortOrder: $items.sortDescriptors) {
TableColumn("Column 1") {
Text("Item at \($0.name!)")
.contextMenu {
Button(action: {}) { Text("Action 1") }
Divider()
Button(action: {}) { Text("Action 2") }
Button(action: {}) { Text("Action 3") }
}
}
TableColumn("Column 2") {
Text($0.id.debugDescription)
}
}
.toolbar {
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
if selection.isEmpty {
Text("Select an item")
} else if selection.count == 1 {
Text("Selected \(items.first(where: { $0.id == selection.first! })!.id.debugDescription)")
} else {
Text("Selected \(selection.count)")
}
}
}
}
So, how can I add a context menu to the entire row inside the Table?

You can make cell view with the following extensions and use it for each column cell, then it will be clickable in any row place
Text("Item at \($0.name!)")
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) // << this !!
.contentShape(Rectangle()) // << this !!
.contextMenu {
Button(action: {}) { Text("Action 1") }
Divider()
Button(action: {}) { Text("Action 2") }
Button(action: {}) { Text("Action 3") }
}

Related

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

How to animate a rectangle traveling between buttons headings?

I have a group of buttons that behave like a segmented picker. As you tap a button, it updates an enum in state. I'd like to show an indicator on top that runs between buttons instead of show/hide.
This is what I have:
struct ContentView: View {
enum PageItem: String, CaseIterable, Identifiable {
case page1
case page2
var id: String { rawValue }
var title: LocalizedStringKey {
switch self {
case .page1:
return "Page 1"
case .page2:
return "Page 2"
}
}
}
#Namespace private var pagePickerAnimation
#State private var selectedPage: PageItem = .page1
var body: some View {
HStack(spacing: 16) {
ForEach(PageItem.allCases) { page in
Button {
selectedPage = page
} label: {
VStack {
if page == selectedPage {
Rectangle()
.fill(Color(.label))
.frame(maxWidth: .infinity)
.frame(height: 1)
.matchedGeometryEffect(id: "pageIndicator", in: pagePickerAnimation)
}
Text(page.title)
.padding(.vertical, 8)
.padding(.horizontal, 12)
}
.contentShape(Rectangle())
}
}
}
.padding()
}
}
I thought the matchedGeometryEffect would help do this but I might be using it wrong or a better way exists. How can I achieve this where the black line on top of the button animates over one button and moves over the other?
You missed the animation:
struct ContentView: View {
#Namespace private var pagePickerAnimation
#State private var selectedPage: PageItem = .page1
var body: some View {
HStack(spacing: 16) {
ForEach(PageItem.allCases) { page in
Button { selectedPage = page } label: {
VStack {
if page == selectedPage {
Rectangle()
.fill(Color(.label))
.frame(maxWidth: .infinity)
.frame(height: 1)
.matchedGeometryEffect(id: "pageIndicator", in: pagePickerAnimation)
}
Text(page.title)
.padding(.vertical, 8)
.padding(.horizontal, 12)
}
.contentShape(Rectangle())
}
}
}
.padding()
.animation(.default, value: selectedPage) // <<: here
}
}
enum PageItem: String, CaseIterable, Identifiable {
case page1
case page2
var id: String { rawValue }
var title: LocalizedStringKey {
switch self {
case .page1:
return "Page 1"
case .page2:
return "Page 2"
}
}
}

Updating the contents of an array from a different view

I'm writing a macOS app in Swiftui, for Big Sur and newer. It's a three pane navigationview app, where the left most pane has the list of options (All Notes in this case), the middle pane is a list of the actual items (title and date), and the last one is a TextEditor where the user adds text.
Each pane is a view that calls the the next view via a NavigationLink. Here's the basic code for that.
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] = []
}
struct ContentView: View {
#State var selection: Set<Int> = [0]
var body: some View {
NavigationView {
List(selection: self.$selection) {
NavigationLink(destination: AllNotes()) {
Label("All Notes", systemImage: "doc.plaintext")
}
.tag(0)
}
.listStyle(SidebarListStyle())
.frame(minWidth: 100, idealWidth: 150, maxWidth: 200, maxHeight: .infinity)
Text("Select a note...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
struct AllNotes: View {
#State var items: [NoteItem] = {
guard let data = UserDefaults.standard.data(forKey: "notes") else { return [] }
if let json = try? JSONDecoder().decode([NoteItem].self, from: data) {
return json
}
return []
}()
#State var noteText: String = ""
var body: some View {
NavigationView {
List(items) { item in
NavigationLink(destination: NoteView()) {
VStack(alignment: .leading) {
Text(item.text.components(separatedBy: NSCharacterSet.newlines).first!)
Text(item.dateText).font(.body).fontWeight(.light)
}
.padding(.vertical, 8)
}
}
.listStyle(InsetListStyle())
Text("Select a note...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.navigationTitle("A title")
.toolbar {
ToolbarItem(placement: .navigation) {
Button(action: {
NewNote()
}) {
Image(systemName: "square.and.pencil")
}
}
}
}
struct NoteView: View {
#State var text: String = ""
var body: some View {
HStack {
VStack(alignment: .leading) {
TextEditor(text: $text).padding().font(.body)
.onChange(of: text, perform: { value in
print("Value of text modified to = \(text)")
})
Spacer()
}
Spacer()
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white)
}
}
When I create a new note, how can I save the text the user added on the TextEditor in NoteView in the array loaded in AllNotes so I could save the new text? Ideally there is a SaveNote() function that would happen on TextEditor .onChange. But again, given that the array lives in AllNotes, how can I update it from other views?
Thanks for the help. Newbie here!
use EnvironmentObject in App
import SwiftUI
#main
struct NotesApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(DataModel())
}
}
}
now DataModel is a class conforming to ObservableObject
import SwiftUI
final class DataModel: ObservableObject {
#AppStorage("notes") public var notes: [NoteItem] = []
}
any data related stuff should be done in DataModel not in View, plus you can access it and update it from anywhere, declare it like this in your ContentView or any child View
NoteView
import SwiftUI
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
})
Spacer()
}
Spacer()
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white)
.onAppear() {
print(data.notes.count)
}
}
}
AppStorage is the better way to use UserDefaults but AppStorage does not work with custom Objects yet (I think it does for iOS 15), so you need to add this extension to make it work.
import SwiftUI
struct NoteItem: Codable, Hashable, Identifiable {
let id: UUID
var text: String
var date = Date()
var dateText: String {
let df = DateFormatter()
df.dateFormat = "EEEE, MMM d yyyy, h:mm a"
return df.string(from: date)
}
var tags: [String] = []
}
extension Array: RawRepresentable where Element: Codable {
public init?(rawValue: String) {
guard let data = rawValue.data(using: .utf8),
let result = try? JSONDecoder().decode([Element].self, from: data)
else {
return nil
}
self = result
}
public var rawValue: String {
guard let data = try? JSONEncoder().encode(self),
let result = String(data: data, encoding: .utf8)
else {
return "[]"
}
return result
}
}
Now I changed AllNotes view to work with new changes
struct AllNotes: View {
#EnvironmentObject private var data: DataModel
#State var noteText: String = ""
var body: some View {
NavigationView {
List(data.notes) { note in
NavigationLink(destination: NoteView(note: note)) {
VStack(alignment: .leading) {
Text(note.text.components(separatedBy: NSCharacterSet.newlines).first!)
Text(note.dateText).font(.body).fontWeight(.light)
}
.padding(.vertical, 8)
}
}
.listStyle(InsetListStyle())
Text("Select a note...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.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")
}
}
}
}
}

How to toggle the visibility of the third pane of NavigationView?

Assuming the following NavigationView:
Struct ContentView: View {
#State var showRigthPane: Bool = true
var body: some View {
NavigationView {
Sidebar()
MiddlePane()
RightPane()
}.toolbar {
ToolbarItem(placement: .navigation) {
Button(action: toggleSidebar, label: {Image(systemName: "sidebar.left")})
}
ToolbarItem(placement: .primaryAction) {
Button(action: self.toggleRightPane, label: { Image() })
}
}
}
private func toggleRightPane() {
// ?
}
// collapsing sidebar - this works
private func toggleSidebar() {
NSApp.keyWindow?.initialFirstResponder?.tryToPerform(
#selector(NSSplitViewController.toggleSidebar(_:)), with: nil)
}
}
How can I implement the toggleRightPane() function to toggle the visibility of the right pane?
Updated to use a calculated property returning two different navigation views. Still odd behavior with sidebar, but with a work-around it is functional. Hopefully someone can figure out the sidebar behavior.
struct ToggleThirdPaneView: View {
#State var showRigthPane: Bool = true
var body: some View {
VStack {
navigationView
}
.navigationTitle("Show and Hide")
}
var navigationView : some View {
if showRigthPane {
return AnyView(NavigationView {
VStack {
Text("left")
}
.toolbar {
Button(action: { showRigthPane.toggle() }) {
Label("Add Item", systemImage: showRigthPane ? "rectangle.split.3x1" : "rectangle.split.2x1")
}
}
Text("middle")
}
)
} else {
return AnyView(NavigationView {
VStack {
Text("left")
}
.toolbar {
Button(action: { showRigthPane.toggle() }) {
Label("Add Item", systemImage: showRigthPane ? "rectangle.split.3x1" : "rectangle.split.2x1")
}
}
Text("middle")
Text("right")
})
}
}
}
Try the following (cannot test)
struct ContentView: View {
#State private var showRigthPane = true
var body: some View {
NavigationView {
Sidebar()
MiddlePane()
if showRigthPane { // << here !!
RightPane()
}
}.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: self.toggleRightPane, label: { Image() })
}
}
}
private func toggleRightPane() {
withAnimation {
self.showRigthPane.toggle() // << here !!
}
}
}

How do I enable the sort option with the Edit/Done Button in SwiftUI?

When I tap on the Edit button it functions correctly to change the list to an active state with a delete icon beside each item. However, the sort icon does not show on the right side of each item as expected.
This leads me to believe that I have overlooked a key element in the following code. What else is required to enable the sort option?
struct ContentView: View {
#Environment(\.managedObjectContext) var moc
#FetchRequest(entity: Task.entity(), sortDescriptors:[
NSSortDescriptor(keyPath: \Task.isComplete, ascending: true)
]) var tasks: FetchedResults<Task>
#State private var showingAddScreen = false
var body: some View {
NavigationView {
List {
ForEach(tasks, id: \.self) { task in
HStack {
Image(systemName: task.isComplete ? "square.fill" : "square")
.padding()
.onTapGesture {
task.isComplete.toggle()
try? self.moc.save()
print("Done button tapped")
}
Text(task.name ?? "Unknown Task")
Spacer()
Image("timer")
.onTapGesture {
print("Timer button tappped")
}
}
}
.onDelete(perform: deleteTask)
}
.navigationBarTitle("To Do List", displayMode: .inline)
.navigationBarItems(leading: EditButton(), trailing: Button(action: {
self.showingAddScreen.toggle()
}) {
Image(systemName: "plus")
})
.sheet(isPresented: $showingAddScreen) {
AddTaskView().environment(\.managedObjectContext, self.moc)
}
}
}
func deleteTask(at offsets: IndexSet) {
for offset in offsets {
let task = tasks[offset]
moc.delete(task)
}
try? moc.save()
}
}
What else is required to enable the sort option?
it appears whenever .onMove modifier is provided, ie. add
.onDelete(perform: deleteTask)
.onMove { sourceIndices, destinationIndex in
// << your code here
}

Resources