SwiftUI - List items are not deselected - list

I recently switched to Xcode 12/iOS 14, I noticed that my List selections are no longer deselected when navigating to a new view and then back. Upon returning, the list item that was selected is still highlighted. As far as I know there isn't a deselectRowAtIndexPath option for SwiftUI. I even tried resigning the first responder but that did nothing.
let someArray = ["one", "two", "three", "four", "five"]
var body: some View {
NavigationView {
VStack {
Text("zero")
List(someArray, id: \.self) { item in
NavigationLink(
destination: Text(item)) {
Text(item)
}
}
}
}
}

Put .id(UUID()) on your List. This will always reload the list and remove the selection.

Try adding a UUID to the actual NavigationLink, not the list. The list will use your newly added UUID to identify which link to deselect.

Related

List animations ignoring Animation parameter

I've been trying to get changes to my list elements to animate correctly. However, items in a list don't seem to animate as specified.
In this simple example, an element is removed. There is an animation, within 1 second the element is removed. However, it completely ignores the duration and delay.
struct ContentView: View {
#State var items = [1, 2, 3, 4, 5]
var body: some View {
VStack {
List {
ForEach(items, id: \.self) { item in
Text("Item \(item)")
}
}
Button {
withAnimation(Animation.easeInOut(duration: 5).delay(1)) {
print("removing element")
items.removeFirst()
}
} label: {
Text("Remove element")
}
}
}
}
If I remove the List and just have a VStack of items, the Animation parameter is processed correctly.
If I remove the withAnimation, it doesn't animate at all. So it is triggering it.
I would say that this is expected as this is how underlying UITableViewController is working, it doesn't have capabilities to customise animations that way.
I think that if you want to do something custom you need to start with LazyVStack in ScrollView, that will give you more space for creation and it is quite likely that this would work (I haven't tried it, but from the logical point it should).

How to trigger a NavigationLink programmatically in a LazyVGrid

I have a LazyVGrid inside a NavigationView.
NavigationView {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(items) { item in
NavigationLink(tag: item, selection: $displayedItem) {
DetailView(item)
} label: {
GridItemView(item)
}
}
}
}
}
The referenced variables are defined as follows on the view:
#State var displayedItem: Item?
let columns: [GridItem] = Array(repeating: .init(.flexible()), count: 2)
Now I want to show the detail view for a specific item. I do this by simply assigning this item to the displayedItem property:
func showDetailView(for item: Item) {
displayedItem = item
}
This works great when the respective item is visible on the LazyVGrid at the moment when I call this function. However, when the item is not visible, I first need to scroll to the item for the NavigationLink to fire. I know why this is happening (because the items are loaded lazily, it's a lazy grid after all), but I don't know how to make the LazyVGrid load the specific item when I need it.
What I've tried:
I have also tried to programmatically scroll to the target item by wrapping the entire ScrollView inside a ScrollViewReader and appending the following modifier:
.onChange(of: displayedItem) { item in
if let item = item {
scrollProxy.scrollTo(item.id)
}
}
Unfortunately, this has the same problem: Scrolling to a given item doesn't work until the item is loaded.
Question:
Is there any way to make this work, i.e. to trigger a NavigationLink for an item that is not currently visible in the LazyVGrid? (It's important for me as I need this functionality to deep-link to a specific item's DetailView.)
An possible approach can be like in this topic - use one link somewhere in background of ScrollView and activate it by tapGesture/button from user or assigning corresponding value programmatically.
Tested with Xcode 13.4 / iOS 15.5
Main part:
ScrollView {
LazyVGrid(columns: columns) {
ForEach(items) { item in
RoundedRectangle(cornerRadius: 16).fill(.yellow)
.frame(maxWidth: .infinity).aspectRatio(1, contentMode: .fit)
.overlay(Text("Item \(item.value)"))
.onTapGesture {
selectedItem = item
}
}
}
}
.padding(.horizontal)
.background(
NavigationLink(destination: DetailView(item: selectedItem), isActive: isActive) {
EmptyView()
}
)
.toolbar {
Button("Random") { selectedItem = items.randomElement() }
}
Test module on GitHub

Strange issue of "Modifying state during view update, this will cause undefined behavior"

Goal: have a SwiftUI architecture where the "add new item" and "edit existing item" are solved by the same view (EditItemView). However, for some reason, when I do this, the runtime agent complains of "Modifying state during view update, this will cause undefined behavior".
This is the code I want to use, which ensures that the EDITING of the item and ADDING a new item are handled by the same EditItemView:
var body: some View {
NavigationView
{
ScrollView
{
LazyVGrid(columns: my_columns)
{
ForEach(items, id: \.id)
{
let item = $0
// THIS LINE TO EDIT AN EXISTING ITEM
NavigationLink(destination: EditItemView(item: item))
{
ItemView(item: item)
}
}
}
}
.navigationBarItems(trailing:
// THIS LINE TO ADD A NEW ITEM:
NavigationLink(destination: EditItemView(item: Data.singleton.createItem(name: "New item", value: 5.0))
{
Image(systemName: "plus")
}
)
}
}
It doesn't work, leading to the issue highlighted above. I am forced to separate the functionality for Edit and Add into two distinct Views, which then works:
var body: some View {
NavigationView
{
ScrollView
{
LazyVGrid(columns: my_columns)
{
ForEach(items, id: \.id)
{
let item = $0
// THIS LINE TO EDIT AN EXISTING ITEM
NavigationLink(destination: EditItemView(item: item))
{
ItemView(item: item)
}
}
}
}
.sheet(isPresented: $isPresented)
{
// FORCED TO USE SEPARATE VIEW
AddItemView { name, value in
_ = Data.singleton.createItem(name: name, value: value)
self.isPresented = false
}
}
.navigationBarItems(trailing: Button(action: { self.isPresented.toggle()}) { Image(systemName: "plus")})
}
}
I don't understand why the code in the first version is considered to modify the state while updating view, because to me, it's sequential: new Item is created and THEN a view is shown for that Item.
Any ideas?
The destination of NavigationLink is not rendered lazily, meaning it'll get rendered when the NavigationLink itself is rendered -- not when clicked through.
The sheet code, depending on platform and SwiftUI version, may have the same issue, but apparently does not in the version you're using. Or, the closure you provide to AddItemView isn't run immediately -- since you didn't include the code, it's not clear.
To solve the issue in the first method, you can use the following SO answer which provides a lazy NavigationLink: https://stackoverflow.com/a/61234030/560942

Why does Context Menu display the old state even though the List has correctly been updated?

I'm facing an issue where the displayed Context Menu shows the wrong data, even though the List underneath displays the correct one. The issue is that once triggering the action on the context menu of the first item, you'll see how the List re-renders and shows the correct data but if you trigger the context menu again for the first item, it won't show the correct state. If you open the context menu for the second item, it will display the correct state, but if you now select "Two", and open the same context menu, the State will be wrong (it'll display only 1 selected when it should show 1 & 2, like the List displays it).
It feels like it's off by one (like presenting the previous state instead of the latest one) and I'm not sure if it's just a bug or I'm using it wrong.
Here's a snippet of code to reproduce the issue:
#main
struct ContextMenuBugApp: App {
let availableItems = ["One", "Two", "Three", "Four", "Five"]
#State var selectedItems: [String] = []
var body: some Scene {
WindowGroup {
List {
ForEach(availableItems, id: \.self) { item in
HStack {
let isAlreadySelected = selectedItems.contains(item)
Text("Row \(item), selected: \(isAlreadySelected ? "true" : "false")")
}.contextMenu {
ForEach(availableItems, id: \.self) { item in
let isAlreadySelected = selectedItems.contains(item)
Button {
isAlreadySelected ? selectedItems.removeAll(where: { $0 == item }) : selectedItems.append(item)
} label: {
Label(item, systemImage: isAlreadySelected ? "checkmark.circle.fill" : "")
}
}
}
}
}
}
}
}
Video demonstrating the issue: https://twitter.com/xmollv/status/1412397838319898637
Thanks!
Edit:
It seems to be an iOS 15 regression (at least on Release Candidate), it works fine on iOS 14.6.
you can force the contextMenu to redraw with background view with arbitrary id. i.e.:
#main
struct ContextMenuBugApp: App {
let availableItems = ["One", "Two", "Three", "Four", "Five"]
#State var selectedItems: [String] = []
func isAlreadySelected(_ item: String) -> Bool {
selectedItems.contains(item)
}
var body: some Scene {
WindowGroup {
List {
ForEach(availableItems, id: \.self) { item in
HStack {
Text("Row \(item), selected: \(isAlreadySelected(item) ? "true" : "false")")
}
.background(
Color.clear
.contextMenu {
ForEach(availableItems, id: \.self) { item in
Button {
isAlreadySelected(item) ? selectedItems.removeAll(where: { $0 == item }) : selectedItems.append(item)
} label: {
Label(item, systemImage: isAlreadySelected(item) ? "checkmark.circle.fill" : "")
}
}
}.id(selectedItems.count)
)
}
}
}
}
}
If it doesn’t work, you can try just putting id to contextMenu without the background (this could be based on iOS version, it didn’t work before, so be careful and test prior iOS)

SwiftUI Form Picker only shows once

I am playing around with SwiftUI, and am currently building a Form using a Picker.
import SwiftUI
struct ContentView: View {
private let names = ["Bill", "Peter", "Johan", "Kevin"]
#State private var favoritePerson = "Bill"
var body: some View {
NavigationView {
Form {
Picker("Favorite person", selection: $favoritePerson) {
ForEach(names, id: \.self) { name in
Text(name)
}
}
}
.navigationBarTitle("Form", displayMode: .inline)
}
}
}
The first time you tap on the "Favorite person" row, the picker shows up fine, and tapping on one of the names brings you back to the form. But tapping on the form row a second time doesn't do anything: you don't go to the picker, the row stays highlighted but nothing happens. If this is a SwiftUI bug, is there a known workaround? (I already needed to use a small navigation bar title to work around the Picker UI bug where otherwise its content moves up when it's shown ☹️)
this issue is just one with the simulator. If you build the app on a physical iOS device, it no longer becomes an issue. It's like that bug with Navigation Link that would only work once.
I have the same problem in Xcode 11.4 and also in the real device.
Picker change didn't call CreateTab, it only worked in initialize.
Picker("Numbers", selection: $selectorIndex) {
ForEach(0 ..< formData.tabs.count) { index in
Text(formData.tabs[index].name).tag(index)
}
}
.pickerStyle(SegmentedPickerStyle())
.onReceive([self.selectorIndex].publisher.first()) { (value) in
print(value)
CreateTab(tabs: formData.tabs, index: self.selectorIndex)
}