SwiftUI: Adding item creates gap in NavigationView Lists - macos

I have a list which normally gets formatted nicely. When I update the array items and re-add the the element it introduces a big space in the list, adding additional height to some rows.
Here is the list:
Click the add button and the new item is added with additional height:
The strange thing is that if you play "track and field" with the add button and hit it many times quickly everything works OK.
Here is some code that demonstrates the problem:
import SwiftUI
var newIndex = 0
var newItems = ["Echo", "Tango", "Hotel", "Alpha", "November", "Kilo", "Yankee", "Oscar", "Uniform", "Indigo", "Romeo", "Whisky", "Lima", "Victor", "Charlie", "Foxtrot", "Delta", "Xray", "Papa", "Juliet", "Bravo", "Golf", "Quebec", "Mike", "Sierra", "Zulu"]
struct ContentView: View
{
#State var mainList:[String] = ["Echo", "Tango", "Hotel", "Alpha", "November", "Kilo", "Yankee", "Oscar", "Uniform", "Indigo", "Romeo", "Whisky", "Lima", "Victor", "Charlie", "Foxtrot", "Delta", "Xray", "Papa", "Juliet", "Bravo", "Golf", "Quebec", "Mike", "Sierra", "Zulu"].sorted()
#State var selectedItem:String? = "Echo"
var body: some View
{
NavigationView
{
List()
{
ForEach(mainList, id: \.self)
{item in
NavigationLink(destination: DetailView(item: item), tag: item, selection: $selectedItem, label:
{
Text("\(item)")
})
}
}
}
.toolbar
{
Button(action: {addNewItem()})
{
Label("Select", systemImage: "square.and.pencil")
}
}
}
func addNewItem()
{
if newIndex == newItems.count
{
newIndex = 0
}
let item = newItems[newIndex]
if mainList.contains(item)
{
mainList = mainList.filter({ $0 != item })
}
mainList.append(item)
selectedItem = item
newIndex += 1
}
}
struct DetailView: View
{
#State var item: String
var body: some View
{
Text(item)
}
}
I have found that in this case it is caused by the filter command, but I have seen this behaviour in many of my lists across my apps. I assume it is caused by removing and adding things to the same list.

try this:
Text("\(item)").fixedSize()
and attach the .toolbar to the List not the NavigationView.

It's quite a bug in Xcode Beta 1, 2 / macOS 12 Beta 1. The thing is, you use String itself as ID, which means if two strings are the same, they'll be treated as one item in List:
ForEach(mainList, id: \.self)
This may be what you want. However, in function addNewItem(), there comes:
mainList = mainList.filter({ $0 != item })
// ...
mainList.append(item)
A same item can be removed and appended. So the List will re-calculate the height, and move the same item from where it was to the bottom, which causes a bug.
One not perfect solution is that you can wrap the item in a struct which provides an explicit ID, like this:
struct Item: Identifiable {
let id = UUID()
let value: String
// Other properties in the future...
}
And each time you want to add a new item, a copy will be created:
items = items.filter { $0.value != item }
let newItem = Item(value: item)
items.append(newItem)
In this way, List won't see the old and the new as the same item and produce moving animation. Full code:
struct Item: Identifiable {
let id = UUID()
let value: String
// Other properties in the future...
}
var nextItemIndex = 0
let itemsRow = ["Echo", "Tango", "Hotel", "Alpha", "November", "Kilo", "Yankee", "Oscar", "Uniform", "Indigo", "Romeo", "Whisky", "Lima", "Victor", "Charlie", "Foxtrot", "Delta", "Xray", "Papa", "Juliet", "Bravo", "Golf", "Quebec", "Mike", "Sierra", "Zulu"]
struct ContentView: View {
#State private var items: [Item] = itemsRow.sorted().map { Item(value: $0) }
#State private var selected: UUID?
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink(destination: DetailView(value: item.value),
tag: item.id, selection: $selected) {
Text(item.value)
}
}
}
}
.toolbar {
Button(action: addNewItem) {
Label("Select", systemImage: "square.and.pencil")
}
}
}
}
extension ContentView {
private func addNewItem() {
let item = itemsRow[nextItemIndex]
nextItemIndex = (nextItemIndex + 1) % itemsRow.count
items = items.filter { $0.value != item }
let newItem = Item(value: item)
items.append(newItem)
selected = newItem.id
}
}
Let's see if it'll be fixed in the next macOS Beta.
UPDATE: It's fixed on macOS 12 Beta 3. Now you don't have to make a copy.

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

Save the selection state of the current selected item on a list

A macOS app that works with local files need to reload the files when it gets focused again. This is to load any new files that might have been placed there while the app was in the background.
The issue is that when I clear the list, reload the files (rebuilding the list), the first item is always selected, so if I was working on an item I'm being forced to select it again to continue working on it.
I tried keeping the current UUID of the selected note and passing that back, but I have no clue whether this is correct, or how to programmatically select an item in the list matching the UUID saved.
How do I keep the current selected item, and then go to it when I reload the data?
Code I tried:
struct DataItem: Codable, Hashable, Identifiable {
let id: UUID
var text: String
}
struct AllData: View {
#EnvironmentObject private var data: DataModel //array of DataItem's
#State var selectedItemId: UUID?
#State var currentItemId: UUID?
NavigationView {
List(data.prices.filter { searchText.isEmpty ? true : $0.text.localizedCaseInsensitiveContains(searchText) }) { price in
NavigationLink(
destination: PriceView(price: price, text: price.text),
tag: price.id,
selection: $selectedItemId
) {
VStack(alignment: .leading) {
Text(getTitle(titleText: price.text)).font(.body).fontWeight(.bold)
}
.padding(.vertical, 10)
}
}
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
self.currentItemId = self.selectedItemId
if getCurrentSaveDirectory(for: "savedDirectory") != "" {
if !isDirectoryEmpty() {
data.price.removeAll()
loadFiles(dataModel: data)
self.selectedItemId = self.currentItemId
}
}
}
}
I think using the current UUID of the selected note as you do is a good idea.
Without all the code it is difficult to test my answer, however you could
try this approach, using a List selection variable and adding the simple code in onReceive,
such as this example code:
struct AllData: View {
#EnvironmentObject private var data: DataModel //array of DataItem's
#State var selectedItemId: UUID?
#State var currentItemId: UUID?
#State var listSelection: DataItem? // <--- here selection
var body: some View {
NavigationView {
List(data.prices.filter { searchText.isEmpty ? true : $0.text.localizedCaseInsensitiveContains(searchText) },
selection: $listSelection) { price in // <--- here selection
NavigationLink(
destination: PriceView(price: price, text: price.text),
tag: price.id,
selection: $selectedItemId
) {
VStack(alignment: .leading) {
Text(getTitle(titleText: price.text)).font(.body).fontWeight(.bold)
}
.padding(.vertical, 10)
}
}
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
self.currentItemId = self.selectedItemId
if getCurrentSaveDirectory(for: "savedDirectory") != "" {
if !isDirectoryEmpty() {
data.price.removeAll()
loadFiles(dataModel: data)
self.selectedItemId = self.currentItemId
// --- here ---
if let theItem = data.first(where: {$0.id == selectedItemId}) {
listSelection = theItem
}
}
}
}
}
}

How do I select single items out of an array in SwiftUI?

I am new to coding and I want to select a single Item out of an array. So far I have built an array of items (pretty similar to what you can see on "Tinder" for example) but If I tab on one of the items all of the others will be filled blue as well. Does any one know how do modify the code so it will work? Thank you in advance.
import SwiftUI
struct WhatInterestsYouView: View {
let sportsSelectionItems = ["NFL", "CollegeFootball", "soccer", "baseball", "tennis", "Icehockey", "Gym", "running", "climbing"]
let layout = [GridItem(.adaptive(minimum: 80))]
#State private var sportsSelectionItemIsSelected:Bool = false
var body: some View {
LazyVGrid (columns: layout, spacing: 10) {
ForEach(sportsSelectionItems, id: \.self) { selectionItem in
Text(selectionItem)
.onTapGesture {
sportsSelectionItemIsSelected.toggle()
}
.font(.footnote)
.padding(.all, 5)
.background(RoundedRectangle(cornerRadius: 10).fill(sportsSelectionItemIsSelected ? Color.blue : Color.gray.opacity(0.1)))
}
}
}
}
struct WhatInterestsYouView_Previews: PreviewProvider {
static var previews: some View {
WhatInterestsYouView()
}
}
The problem is "sportsSelectionItemIsSelected:Bool" is just a flag common in the View and it is not bound to any item in the array. so once it becomes true, it remains true for all the items in array.
Instead, have the selected item of array stored and compare it with items in loop and fill it out with your color. like this,
#State private var selectedItem: String?
var body: some View {
LazyVGrid (columns: layout, spacing: 10) {
ForEach(sportsSelectionItems, id: \.self) { selectionItem in
Text(selectionItem)
.onTapGesture {
selectedItem = selectionItem
}
.font(.footnote)
.padding(.all, 5)
.background(RoundedRectangle(cornerRadius: 10).fill(selectedItem == selectionItem ? Color.blue : Color.gray.opacity(0.1)))
}
}
}

Deleting an item from a list based on the element UUID

I feel a bit embarrassed for asking this, but after more than a day trying I'm stuck. I've had a few changes on the code based on replies to other issues. The latest code essentially selects the items on a list based on the UUID.
This has caused my delete function to stop working since I was working with passing an Int as the selected element to be deleted. I was originally implementing things like this.
Code follows, I'm still trying to figure out my way around SwiftUI, but question is, how can I now delete items on a list (and on the array behind it) based on a UUID as opposed to the usual selected item.
In case it makes a difference, this is for macOS Big Sur.
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] = []
}
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")
}
}
ToolbarItem(placement: .automatic) {
Button(action: {
// Delete here????
}) {
Image(systemName: "trash")
}
}
}
.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
}
}
}
}
The original removeNote I had is the following:
func removeNote() {
if let selection = self.selectedItem,
let selectionIndex = data.notes.firstIndex(of: selection) {
print("delete item: \(selectionIndex)")
data.notes.remove(at: selectionIndex)
}
}
could you try this:
struct NoteItem: Codable, Hashable, Identifiable {
let id: UUID // <--- here
var text: String
var date = Date()
var dateText: String = ""
var tags: [String] = []
}
func removeNote() {
if let selection = selectedNoteId,
let selectionIndex = data.notes.firstIndex(where: { $0.id == selection }) {
print("delete item: \(selectionIndex)")
data.notes.remove(at: selectionIndex)
}
}

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

Resources