Force deselection of row in SwiftUI List view - swift

I am using a List with a selection Binding. My expectation is that clearing the selection should update the List to not have any selected rows but the row UI remains selected.
Here is the example code:
struct ContentView: View {
#State private var selection: String?
let names = [
"a",
"b",
"c",
"d"
]
var body: some View {
NavigationView {
VStack {
List(names, id: \.self, selection: $selection) { name in
Text(name)
}
Button("Deselect") {
self.selection = nil
}
}
}
}
}
I expect that when clearing the selection on button press, the list should update to not have any selection but it remains selected.

Working on iOS 16
import SwiftUI
struct listSelection: View {
#State private var selection: String?
let names = [
"a",
"b",
"c",
"d"
]
var body: some View {
NavigationView {
VStack {
List(names, id: \.self, selection: $selection) { name in
Text(name)
}
.id(UUID())
Button("Deselect") {
selection = nil
}
}
}
}
}

You can use listRowBackground modifier to set background based on selection.
struct ContentView: View {
#State private var selection: String?
let names = ["Munny", "Badnam", "Hui", "Tereliye"]
var body: some View {
NavigationView {
VStack {
List(names, id: \.self, selection: $selection) { name in
Text(name)
.listRowBackground(selection == name ? Color.black.opacity(0.2) : Color.white)
}
Button("Deselect") {
self.selection = .none
}
}
}
}
}

Single row selection is designed for when in edit mode, which you can turn on with the code below and it makes deselection work:
struct ListTestView2: View {
#State var editMode:EditMode = EditMode.active
#State private var selection: String?
let names = [
"a",
"b",
"c",
"d"
]
var body: some View {
NavigationView {
VStack {
List(names, id: \.self, selection: $selection) { name in
Text(name)
}
.environment(\.editMode, $editMode)
Button("Deselect") {
self.selection = nil
}
}
}
}
}

Related

Cannot select item in List

I am trying to build a (macOS) view with a sidebar and a main area. The main area contains various custom views rather than a single view with a detail ID. My code looks like this:
enum SidebarTargetID: Int, Identifiable, CaseIterable {
case status, campaigns
var id: Int {
rawValue
}
}
struct SidebarView: View {
#Binding var selection: SidebarTargetID?
var body: some View {
List(selection: $selection) {
Label("Status", systemImage: "heart").id(SidebarTargetID.status)
Label("Campaigns", systemImage: "heart").id(SidebarTargetID.campaigns)
}
}
}
struct ContentView: View {
#SceneStorage("sidebarSelection") private var selectedID: SidebarTargetID?
var body: some View {
NavigationView {
SidebarView(selection: selection)
switch selectedID {
case .status: StatusView()
case .campaigns: CampaignView()
default: StatusView()
}
}
}
private var selection: Binding<SidebarTargetID?> {
Binding(get: { selectedID ?? SidebarTargetID.status }, set: { selectedID = $0 })
}
}
The sidebar view, however, does not appear to be responding to its items being selected by clicking on them: no seleciton outline, no change of view in the main area.
Why is this? I have seen a ForEach being used in a List of Identifiable objects whose IDs activate the selection binding and do the selection stuff. What am I doing wrong?
EDIT
Tried this too, doesn't work.
enum SidebarTargetID: Int, Identifiable, CaseIterable {
case status, campaigns
var id: Int {
rawValue
}
}
struct SidebarView: View {
#Binding var selection: SidebarTargetID?
let sidebarItems: [SidebarTargetID] = [
.status, .campaigns
]
var body: some View {
List(selection: $selection) {
ForEach(sidebarItems) { sidebarItem in
SidebarLabel(sidebarItem: sidebarItem)
}
}
}
}
struct SidebarLabel: View {
var sidebarItem: SidebarTargetID
var body: some View {
switch sidebarItem {
case .status: Label("Status", systemImage: "leaf")
case .campaigns: Label("Campaigns", systemImage: "leaf")
}
}
}
Just two little things:
the List items want a .tag not an .id:
struct SidebarView: View {
#Binding var selection: SidebarTargetID?
var body: some View {
List(selection: $selection) {
Label("Status", systemImage: "heart")
.tag(SidebarTargetID.status) // replace .id with .tag
Label("Campaigns", systemImage: "heart")
.tag(SidebarTargetID.campaigns) // replace .id with .tag
}
}
}
you don't need the selection getter/setter. You can use #SceneStoragedirectly, it is a State var.
struct ContentView: View {
#SceneStorage("sidebarSelection") private var selectedID: SidebarTargetID?
var body: some View {
NavigationView {
SidebarView(selection: $selectedID) // replace selection with $selectedID
switch selectedID {
case .status: Text("StatusView()")
case .campaigns: Text("CampaignView()")
default: Text("StatusView()")
}
}
}
// no need for this
// private var selection: Binding<SidebarTargetID?> {
// Binding(get: { selectedID ?? SidebarTargetID.status }, set: { selectedID = $0 })
// }
}
Ok, found the problem:
struct SidebarView: View {
#Binding var selection: SidebarTargetID?
var sidebarItems: [SidebarTargetID] = [
.status, .campaigns
]
var body: some View {
List(sidebarItems, id: \.self, selection: $selection) { sidebarItem in
SidebarLabel(sidebarItem: sidebarItem)
}
}
}

SwiftUI: NavigationView/List with programmatic selection under macOS

I have a NavigationView/List combination that allows programmatic selection. The basic concept is similar to the one described here: https://www.hackingwithswift.com/quick-start/swiftui/how-to-use-programmatic-navigation-in-swiftui
With so many things, this works fine on iOS, but on macOS there's an issue: the EmptyView from the NavigationView becomes visible as soon as an item is selected:
Does anybody know how to remove this unwanted EmptyView()?
Here's a demo project:
struct ContentView: View {
#ObservedObject private var state = State()
var body: some View {
NavigationView {
VStack {
List(state.items, id: \.self, selection: $state.selected) { item in
Text(item)
.buttonStyle(.borderless)
}
// navigation to the detail view if an item is selected
if let selected = state.selected {
NavigationLink(
destination: Text(selected),
isActive: state.hasSelectionBinding
) {
EmptyView()
}
}
}
}
}
}
class State: ObservableObject {
#Published var items: [String] = ["Item 1", "Item 2", "Item 3"]
#Published var selected: String?
var hasSelectionBinding: Binding<Bool> {
Binding(
get: { self.selected != nil },
set: {
if $0 == false {
self.selected = nil
}
}
)
}
}
EDIT 1: I've tried putting the NavigationLink into a background modifier on the stack, but now the "Empty View" appears next to "Item 3":
var body: some View {
NavigationView {
VStack {
List(state.items, id: \.self, selection: $state.selected) { item in
Text(item)
.buttonStyle(.borderless)
}
}
.background(Group {
// navigation to the detail view if an item is selected
if let selected = state.selected {
NavigationLink(
destination: Text(selected),
isActive: state.hasSelectionBinding
) {
EmptyView()
}
}
})
}
}
you could try this workaround in your first ContentView code:
NavigationLink("", // <--- here
destination: Text(selected),
isActive: state.hasSelectionBinding
).position(x: 9999, y: 9999) // <--- here
No need for the EmptyView().
Note, I suggest you rename your State model, since SwiftUI already has a State struct.

NavigationLink in List using isActive pushes the wrong row

I'm trying to use NavigationLink's isActive variable to pop back to the root view controller.
The problem I'm experiencing is that using isActive pushes the wrong row when clicking on a list item. Remove the isActive variable and everything works as expected.
Here's some example code for demonstration purposes:
struct ContentView: View {
#State private var activateNavigationLink: Bool = false
var exampleData = ["a", "b", "c"]
var body: some View {
NavigationView {
List(exampleData, id: \.self) { item in
NavigationLink(
destination: SecondView(item: item), isActive: $activateNavigationLink) {
Text(item)
}
}
}
}
}
SecondView
struct SecondView: View {
var item: String
var body: some View {
Text(item)
}
}
This is driving me nuts. Any help would be greatly appreciated.
Because activateNavigationLink is just a Bool in your code, if it is true, every NavigationLink will register as active in your List. Right now, this is manifesting as the last item (C) getting pushed each time.
Instead, you'd need some system to store which item is active and then translate that to a boolean binding for the NavigationLink to use.
Here's one possible solution:
struct ContentView: View {
#State private var activeNavigationLink: String? = nil
var exampleData = ["a", "b", "c"]
func bindingForItem(item: String) -> Binding<Bool> {
.init {
activeNavigationLink == item
} set: { newValue in
activeNavigationLink = newValue ? item : nil
}
}
var body: some View {
NavigationView {
List(exampleData, id: \.self) { item in
NavigationLink(
destination: SecondView(item: item), isActive: bindingForItem(item: item)) {
Text(item)
}
}
}
}
}
You should not use activeNavigationLink on main view it should be used with cellView
struct ContentView: View {
var exampleData = ["a", "b", "c"]
var body: some View {
NavigationView {
List(exampleData, id: \.self) { item in
CellView(item: item)
}
}
}
}
CellView
struct CellView: View {
#State private var activateNavigationLink: Bool = false
var item: String
var body: some View {
NavigationLink(
destination: SecondView(item: item), isActive: $activateNavigationLink) {
Text(item)
}
}
}
SecondView
struct SecondView: View {
var item: String
var body: some View {
Text(item)
}
}

SwiftUI picker separate texts for selected item and selection view

I have a Picker embedded in a Form inside a NavigationView. I'd like to have a separate text for the chosen item in the main View and a more detailed descriptions when choosing items in the picker View.
This is what I tried so far:
struct Item {
let abbr: String
let desc: String
}
struct ContentView: View {
#State private var selectedIndex = 0
let items: [Item] = [
Item(abbr: "AA", desc: "aaaaa"),
Item(abbr: "BB", desc: "bbbbb"),
Item(abbr: "CC", desc: "ccccc"),
]
var body: some View {
NavigationView {
Form {
picker
}
}
}
var picker: some View {
Picker(selection: $selectedIndex, label: Text("Chosen item")) {
ForEach(0..<items.count) { index in
Group {
if self.selectedIndex == index {
Text(self.items[index].abbr)
} else {
Text(self.items[index].desc)
}
}
.tag(index)
}
.id(UUID())
}
}
}
Current solution
This is the picker in the main view:
And this is the selection view:
The problem is that with this solution in the selection view there is "BB" instead of "bbbbb".
This occurs because the "BB" text in both screens is produced by the very same Text view.
Expected result
The picker in the main view:
And in the selection view:
Is it possible in SwiftUI to have separate texts (views) for both screens?
Possible solution without a Picker
As mention in my comment, there is not yet a solution for a native implementation with the SwiftUI Picker. Instead, you can do it with SwiftUI Elements especially with a NavigationLink. Here is a sample code:
struct Item {
let abbr: String
let desc: String
}
struct ContentView: View {
#State private var selectedIndex = 0
let items: [Item] = [
Item(abbr: "AA", desc: "aaaaa"),
Item(abbr: "BB", desc: "bbbbb"),
Item(abbr: "CC", desc: "ccccc"),
]
var body: some View {
NavigationView {
Form {
NavigationLink(destination: (
DetailSelectionView(items: items, selectedItem: $selectedIndex)
), label: {
HStack {
Text("Chosen item")
Spacer()
Text(self.items[selectedIndex].abbr).foregroundColor(Color.gray)
}
})
}
}
}
}
struct DetailSelectionView: View {
var items: [Item]
#Binding var selectedItem: Int
var body: some View {
Form {
ForEach(0..<items.count) { index in
HStack {
Text(self.items[index].desc)
Spacer()
if self.selectedItem == index {
Image(systemName: "checkmark").foregroundColor(Color.blue)
}
}
.onTapGesture {
self.selectedItem = index
}
}
}
}
}
If there are any improvements feel free to edit the code snippet.
Expanding on JonasDeichelmann's answer I created my own picker:
struct CustomPicker<Item>: View where Item: Hashable {
#State var isLinkActive = false
#Binding var selection: Int
let title: String
let items: [Item]
let shortText: KeyPath<Item, String>
let longText: KeyPath<Item, String>
var body: some View {
NavigationLink(destination: selectionView, isActive: $isLinkActive, label: {
HStack {
Text(title)
Spacer()
Text(items[selection][keyPath: shortText])
.foregroundColor(Color.gray)
}
})
}
var selectionView: some View {
Form {
ForEach(0 ..< items.count) { index in
Button(action: {
self.selection = index
self.isLinkActive = false
}) {
HStack {
Text(self.items[index][keyPath: self.longText])
Spacer()
if self.selection == index {
Image(systemName: "checkmark")
.foregroundColor(Color.blue)
}
}
.contentShape(Rectangle())
.foregroundColor(.primary)
}
}
}
}
}
Then we have to make Item conform to Hashable:
struct Item: Hashable { ... }
And we can use it like this:
struct ContentView: View {
#State private var selectedIndex = 0
let items: [Item] = [
Item(abbr: "AA", desc: "aaaaa"),
Item(abbr: "BB", desc: "bbbbb"),
Item(abbr: "CC", desc: "ccccc"),
]
var body: some View {
NavigationView {
Form {
CustomPicker(selection: $selectedIndex, title: "Item", items: items,
shortText: \Item.abbr, longText: \Item.desc)
}
}
}
}
Note: Currently the picker's layout cannot be changed. If needed it can be made more generic using eg. #ViewBuilder.
I've had another try at a custom split picker.
Implementation
First, we need a struct as we'll use different items for selection, main screen and picker screen.
public struct PickerItem<
Selection: Hashable & LosslessStringConvertible,
Short: Hashable & LosslessStringConvertible,
Long: Hashable & LosslessStringConvertible
>: Hashable {
public let selection: Selection
public let short: Short
public let long: Long
public init(selection: Selection, short: Short, long: Long) {
self.selection = selection
self.short = short
self.long = long
}
}
Then, we create a custom view with an inner NavigationLink to simulate the behaviour of a Picker:
public struct SplitPicker<
Label: View,
Selection: Hashable & LosslessStringConvertible,
ShortValue: Hashable & LosslessStringConvertible,
LongValue: Hashable & LosslessStringConvertible
>: View {
public typealias Item = PickerItem<Selection, ShortValue, LongValue>
#State private var isLinkActive = false
#Binding private var selection: Selection
private let items: [Item]
private var showMultiLabels: Bool
private let label: () -> Label
public init(
selection: Binding<Selection>,
items: [Item],
showMultiLabels: Bool = false,
label: #escaping () -> Label
) {
self._selection = selection
self.items = items
self.showMultiLabels = showMultiLabels
self.label = label
}
public var body: some View {
NavigationLink(destination: selectionView, isActive: $isLinkActive) {
HStack {
label()
Spacer()
if let selectedItem = selectedItem {
Text(String(selectedItem.short))
.foregroundColor(Color.secondary)
}
}
}
}
}
private extension SplitPicker {
var selectedItem: Item? {
items.first { selection == $0.selection }
}
}
private extension SplitPicker {
var selectionView: some View {
Form {
ForEach(items, id: \.self) { item in
itemView(item: item)
}
}
}
}
private extension SplitPicker {
func itemView(item: Item) -> some View {
Button(action: {
selection = item.selection
isLinkActive = false
}) {
HStack {
if showMultiLabels {
itemMultiLabelView(item: item)
} else {
itemLabelView(item: item)
}
Spacer()
if item == selectedItem {
Image(systemName: "checkmark")
.font(Font.body.weight(.semibold))
.foregroundColor(.accentColor)
}
}
.contentShape(Rectangle())
}
}
}
private extension SplitPicker {
func itemLabelView(item: Item) -> some View {
HStack {
Text(String(item.long))
.foregroundColor(.primary)
Spacer()
}
}
}
private extension SplitPicker {
func itemMultiLabelView(item: Item) -> some View {
HStack {
HStack {
Text(String(item.short))
.foregroundColor(.primary)
Spacer()
}
.frame(maxWidth: 50)
Text(String(item.long))
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
Demo
struct ContentView: View {
#State private var selection = 2
let items = (1...5)
.map {
PickerItem(
selection: $0,
short: String($0),
long: "Long text of: \($0)"
)
}
var body: some View {
NavigationView {
Form {
Text("Selected index: \(selection)")
SplitPicker(selection: $selection, items: items) {
Text("Split picker")
}
}
}
}
}

How to set NavigationView default value?

I have the following code:
import SwiftUI
struct DetailView: View {
let text: String
var body: some View {
Text(text)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct ContentView: View {
private let names = ["One", "Two", "Three"]
#State private var selection: String? = "One"
var body: some View {
NavigationView {
List(selection: $selection) {
ForEach(names, id: \.self) { name in
NavigationLink(destination: DetailView(text: name)) {
Text(name)
}
}
}
DetailView(text: "Make a selection")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I thought that setting 'selection' to 'One' is the answer but it only makes 'One' highlighted (with grey color by the way).
The desired behaviour on startup is:
It should work adding the selection in the placeholder selection ?? "Make a selection", i.e:
struct ContentView: View {
private let names = ["One", "Two", "Three"]
#State private var selection: String? = "One"
var body: some View {
NavigationView {
List(selection: $selection) {
ForEach(names, id: \.self) { name in
NavigationLink(destination: DetailView(text: name)) {
Text(name)
}
}
}
DetailView(text: selection ?? "Make a selection")
}
}
}