I'm developing a macOS App with a List View with selectable rows.
As there is unfortunately no editMode
on macOS, Single Click Selection of Cells is possible, but deselecting an already selected Cell doing the same does nothing.
The only option to deselect the Cell is to CMD + Click which is not very intuitive.
Minimum Example:
struct RowsView: View {
#State var selectKeeper: String?
let rows = ["1", "2", "3", "4", "5", "6", "7", "8"]
var body: some View {
List(rows, id: \.self, selection: $selectKeeper) { row in
Text(row)
}
}
}
struct RowsView_Previews: PreviewProvider {
static var previews: some View {
RowsView()
}
}
Clicking Row Nr 3 with a Single Click or even double Click does nothing and the row stays selected.
Attaching Binding directly
I have tried to attach the Binding directly as described in the excelent answer for a Picker here, but this does not seem to work for List on macOS:
...
var body: some View {
List(rows, id: \.self, selection: Binding($selectKeeper, deselectTo: nil)) { row in
Text(row)
}
}
...
public extension Binding where Value: Equatable {
init(_ source: Binding<Value>, deselectTo value: Value) {
self.init(get: { source.wrappedValue },
set: { source.wrappedValue = $0 == source.wrappedValue ? value : $0 }
)
}
}
Any ideas on how single click deselect can be made possible without rebuilding the selection mechanism?
For the record: XCode 13.2.1, macOS BigSur 11.6.2
We can block default click handling by using own gesture and manage selection manually.
Here is demo of possible approach. Tested with Xcode 13.2 / macOS 12.1
struct RowsView: View {
#State var selectKeeper: String?
let rows = ["1", "2", "3", "4", "5", "6", "7", "8"]
var body: some View {
List(rows, id: \.self, selection: $selectKeeper) { row in
Text(row)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.contentShape(Rectangle()) // handle click row-wide
.listRowInsets(EdgeInsets()) // remove default edges
.onTapGesture {
selectKeeper = selectKeeper == row ? nil : row // << here !!
}
.padding(.vertical, 4) // look&feel like default
}
}
}
I refer to this answer to a SO post titled "Select Multiple Items in a SwiftUI List".
From this answer I generated code to select multiple items in a list.
In my sample code I use a checkmark to illustrate whether a row is selected, but you could change this to suit your needs.
You'll need to change your #State wrapper to a Set, because you confirmed that you'll need to select a group of items. (An aside, Apple recommends that you mark #State property wrappers as private so as to reinforce their intended use locally.)
#State private var selectedItems: Set<String>?
let rows = ["1", "2", "3", "4", "5", "6", "7", "8"]
var body: some View {
List(rows, id: \.self) { item in
RowSelectable(selectedItems: $selectedItems, rowItem: item)
}
}
where RowSelectable is...
struct RowSelectable: View {
#Binding var selectedItems: Set<String>?
var rowItem: String
var isSelected: Bool {
return selectedItems?.contains(rowItem) == true
}
var body: some View {
HStack {
Text(rowItem)
if selectedItems?.contains(rowItem) == true {
Spacer()
Image(systemName: "checkmark")
}
}
.onTapGesture(count: 1) {
if self.isSelected {
selectedItems!.remove(rowItem)
}
else {
selectedItems!.insert(rowItem)
}
}
}
}
I haven't tested this yet, so let me know if it doesn't work and I'll check in Xcode.
Related
I'm using .searchable to embed a search bar into a list view. However, when the .searchable is nested in a NavigationStack (iOS 16 API), it is twinking when the page is loaded (shows up at first and disappears quickly). I hope both pages have a searchable feature.
I can reproduce this issue both on my device iPhone 12 and the simulator iPhone 14. Am I putting the modifier in an incorrect place?
struct ContentView: View {
#State private var selection = "2"
#State var items: [String] = ["0", "1", "2", "3", "4"]
#State var searchText = ""
var body: some View {
NavigationStack {
List {
ForEach(items, id: \.self) { item in
NavigationLink {
NestedListView(items: items)
} label: {
Text(item)
}
}
}
.searchable(text: $searchText)
}
}
}
struct NestedListView: View {
var items: [String]
#State var searchText = ""
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text(item)
}
}
.searchable(text: $searchText)
}
}
I am still new to SwiftUI framework I am learning with HackingWithSwift for NavigationStack. I followed all the steps just one difference is my result of code. I ran without any error just like in video however I could not navigate to my Detail destination.
struct ContentView: View {
var destinations = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]
var body: some View {
NavigationStack {
List(destinations, id: \.self) { i in
NavigationLink(value: i) {
Label("Row \(i)", systemImage: "\(i).circle")
}
}
.navigationDestination(for: Int.self) { i in
Text("Detail \(i)")
}
.navigationTitle("Navigation")
}
}
}
My simulator show a List of Rows with Labels but I am unable to navigate.
Could this by chance a bug because this is still new. Thank you in advance.
Your List data is an array of String, but the .navigationDestination value type is Int.
To fix your problem, modify the data type of .navigationDestination like below:
.navigationDestination(for: String.self) { I in //modify here
Text("Detail \(i)")
}
There is special List (and ForEach) syntax for a static array of Int, here it is:
struct NavTestView: View {
//let destinations = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]
var body: some View {
NavigationStack {
List(1..<10) { i in
NavigationLink(value: i) {
Label("Row \(i)", systemImage: "\(i).circle")
}
}
.navigationDestination(for: Int.self) { i in
Text("Detail \(i)")
}
.navigationTitle("Navigation")
}
}
}
If you move on to requiring a dynamic array, i.e. you add or remove items, you really shouldn't misuse the API with the id:\.self hack or it'll crash, you need to migrate to an array of struct that contains a property that is a unique identifier, even better conform the struct to Identifiable that provides an id property for List/ForEach to use automatically, e.g.
struct ListItem: Identifiable {
let id = UUID()
var number: Int
}
When using listRowBackground on a SwiftUI List there is no longer any highlighting of the selected item. Using a ButtonStyle for the NavigationLink does not work either.
Are there any sane workaround for this?
Example code:
struct ContentView: View {
struct ContentSection: Identifiable {
let id = UUID()
let title: String
let items: [String]
}
var sections = [
ContentSection(title: "Lorem", items: ["Dolor", "Sit", "Amed"]),
ContentSection(title: "Ipsum", items: ["Consectetur", "Adipiscing", "Elit"])
]
var body: some View {
NavigationView {
List {
ForEach(sections) { section in
Section {
ForEach(section.items, id: \.self) { item in
NavigationLink(destination: Text(item)) {
Text(item)
}
.listRowBackground(Color.orange.ignoresSafeArea())
}
} header: {
Text(section.title)
}
}
}
.listStyle(GroupedListStyle())
}
}
}
Although it is not documented in Apple's documentation, setting a .listRowBackground will wisely remove selection behaviour. What should happen if you set a background of Color.grey which matches the default selection color? Should Apple pick a different color now? How can they be sure the contrast is high enough for the user to tell if the selection is active?
Fortunately you can implement your own selection behaviour using List(selection:, content:) and then comparing the item being rendered in ForEach with the current selected item and changing the background yourself:
struct ContentView: View {
#State var selection: Int?
var body: some View {
List(selection: $selection) {
ForEach(1...5, id: \.self) { i in
Text(i, format: .number)
.listRowBackground(i == selection ? Color.red.opacity(0.5) : .white)
.tag(i)
}
}
}
}
Here it is in action:
I am trying to make a list of selectionable rows in Swift UI, the rows including a Picker. Everything works fine, except that the content of the Picker disappears when selected, see attached screenshot (but is actually visible when the window of the app is not active (i.e. when I click on another window))
I tried everything I could think of, I could not solve this issue. Below a minimal code to reproduce the problem. Anyone has any idea, how to get around this problem
SelectionList.swift
struct SelectionList: View {
#State var dudes: [String] = ["Tim", "Craig", "Phil"]
#State var selectedRow = Set<String>()
var body: some View {
return List(selection: $selectedRow) {
ForEach(self.dudes, id: \.self) { item in
SelectionRow()
}
}
}
}
SelectionRow.swift
struct SelectionRow: View {
#State var selectedFruit = 0
let fruits = ["Apples", "Oranges", "Bananas", "Pears"]
var body: some View {
Picker(selection: self.$selectedFruit, label: EmptyView()) {
ForEach(0 ..< fruits.count, id: \.self) {
Text(self.fruits[$0])
}
}
}
}
Consider the following project with two views. The first view presents the second one:
import SwiftUI
struct ContentView: View {
private let data = 0...1000
#State private var selection: Set<Int> = []
#State private var shouldShowSheet = false
var body: some View {
self.showSheet()
//self.showPush()
}
private func showSheet() -> some View {
Button(action: {
self.shouldShowSheet = true
}, label: {
Text("Selected: \(selection.count) items")
}).sheet(isPresented: self.$shouldShowSheet) {
EditFormView(selection: self.$selection)
}
}
private func showPush() -> some View {
NavigationView {
Button(action: {
self.shouldShowSheet = true
}, label: {
NavigationLink(destination: EditFormView(selection: self.$selection),
isActive: self.$shouldShowSheet,
label: {
Text("Selected: \(selection.count) items")
})
})
}
}
}
struct EditFormView: View {
private let data = 0...1000
#Binding var selection: Set<Int>
#State private var editMode: EditMode = .active
init(selection: Binding<Set<Int>>) {
self._selection = selection
}
var body: some View {
List(selection: self.$selection) {
ForEach(data, id: \.self) { value in
Text("\(value)")
}
}.environment(\.editMode, self.$editMode)
}
}
Steps to reproduce:
Create an app with the above two views
Run the app and present the sheet with the editable list
Select some items at random indexes, for example a handful at index 0-10 and another handful at index 90-100
Close the sheet by swiping down/tapping back button
Open the sheet again
Scroll to indexes 90-100 to view the selection in the reused cells
Expected:
The selected indexes as you had will be in “selected state”
Actual:
The selection you had before is not marked as selected in the UI, even though the binding passed to List contains those indexes.
This occurs both on the “sheet” presentation and the “navigation link” presentation.
If you select an item in the list, the “redraw” causes the original items that were originally not shown as selected to now be shown as selected.
Is there a way around this?
It looks like EditMode bug, worth submitting feedback to Apple. The possible solution is to use custom selection feature.
Here is a demo of approach (modified only part). Tested & worked with Xcode 11.4 / iOS 13.4
struct EditFormView: View {
private let data = 0...1000
#Binding var selection: Set<Int>
init(selection: Binding<Set<Int>>) {
self._selection = selection
}
var body: some View {
List(selection: self.$selection) {
ForEach(data, id: \.self) { value in
self.cell(for: value)
}
}
}
// also below can be separated into standalone view
private func cell(for value: Int) -> some View {
let selected = self.selection.contains(value)
return HStack {
Image(systemName: selected ? "checkmark.circle" : "circle")
.foregroundColor(selected ? Color.blue : nil)
.font(.system(size: 24))
.onTapGesture {
if selected {
self.selection.remove(value)
} else {
self.selection.insert(value)
}
}.padding(.trailing, 8)
Text("\(value)")
}
}
}