iOS 16 NavigationLinks in nested Lists unclickable - swiftui

I am running xCode 14.2 on iOS 16.2 simulator and iOS 16.1.2 device.
I have, in my app, NavigationLinks in sublists that are implemented as nested Lists. After updating my xCode, suddenly the NavigationLinks have become unclickable. It looks like something happened to the touch target where the NavigationLink itself cannot be clicked, and only some tiny background sliver is clickable.
Here is sample code reproducing the issue:
import SwiftUI
#available(iOS 16.0, *)
struct ContentView: View {
var body: some View {
NavigationStack {
List {
List {
NavigationLink("Mint") { ColorDetail(color: .mint) }
NavigationLink("Pink") { ColorDetail(color: .pink) }
NavigationLink("Teal") { ColorDetail(color: .teal) }
}.listStyle(.plain)
List {
NavigationLink("Red") { ColorDetail(color: .red) }
NavigationLink("Blue") { ColorDetail(color: .blue) }
NavigationLink("Black") { ColorDetail(color: .black) }
}.listStyle(.plain)
}.listStyle(.plain)
.navigationTitle("Colors")
}
}
}
struct ColorDetail: View {
var color: Color
var body: some View {
color.navigationTitle(color.description)
}
}
Here is a screencast of what it looks like: https://imgur.com/a/SrJ1IbO. Basically, the bulk of the color label is unclickable, but the edges are clickable. But even when they are clicked, they behave funkily, with multiple links being triggered. This happens with both NavigationStack and NavigationView.
Could someone shed some insight into why this is happening and how to fix it? It works great on < iOS 15
EDIT:
I tried going away from nested lists to use sections instead. But it looks to me like as soon as a list item gets a little complicated, the navigation completely breaks. Here is an example where I add a title to each list item, but each navigation link should still go to its own ColorDetail view. However, the navigation doesn't work as you'd expect:
struct ContentView: View {
var body: some View {
NavigationStack {
List {
ForEach(Range(1...3), id: \.self) { num in
Section {
VStack {
Text("Title: \(num)")
NavigationLink("Mint") { ColorDetail(color: .mint) }
NavigationLink("Pink") { ColorDetail(color: .pink) }
NavigationLink("Teal") { ColorDetail(color: .teal) }
}
}
}
}.listStyle(.plain).navigationTitle("Colors")
}
}
}

List is suitable for re-usable row.
One of your NavigationLink is unique. So please switch to use ScrollView.
Apple is still develop SwiftUI and change it frequently, still not stable as my experience.

You can't nest lists. You got lucky in iOS 15 where you got the desired outcome, but that was a side effect. That doesn't work, as you see. Your better option is to have one List, and then use Section in place of your other Lists. Since you use the .plain listStyle, it will all render as one list.
struct ContentView: View {
var body: some View {
NavigationStack {
List {
Section {
NavigationLink("Mint") { ColorDetail(color: .mint) }
NavigationLink("Pink") { ColorDetail(color: .pink) }
NavigationLink("Teal") { ColorDetail(color: .teal) }
}
Section {
NavigationLink("Red") { ColorDetail(color: .red) }
NavigationLink("Blue") { ColorDetail(color: .blue) }
NavigationLink("Black") { ColorDetail(color: .black) }
}
}.listStyle(.plain)
.navigationTitle("Colors")
}
}
}

You are overlapping the lists on the view, the UI is drawing them on top of each other. In you would like to create sections for your colors with in the NavigationStack, I have created this sample for to provide an idea how you can use NavigationStack with list of items.
import SwiftUI
struct MyColor: Hashable, Identifiable {
var id: UUID
var color: Color
}
struct MySection: Hashable, Identifiable {
var id: UUID
var name: String
var myColors: [MyColor]
static let sections = [
MySection(id: UUID(), name: "First Section", myColors: [MyColor(id: UUID(), color: .mint),
MyColor(id: UUID(), color: .pink),
MyColor(id: UUID(), color: .teal)]),
MySection(id: UUID(), name: "Second Section", myColors: [MyColor(id: UUID(), color: .red),
MyColor(id: UUID(), color: .blue),
MyColor(id: UUID(), color: .black)])
]
}
struct ContentView: View {
let sections: [MySection] = MySection.sections
var body: some View {
NavigationStack {
List {
// For each section
ForEach(sections) { section in
// Create a section
Section(section.name) {
// create navigation link for each color in section
ForEach(section.myColors) { item in
// Navigation link, provide a hashable value
NavigationLink(value: item) {
Text(item.color.description)
}
}
}
}
}
// when you see menu item coming in you go to item detail
.navigationDestination(for: MyColor.self) { item in
ColorDetail(color: item.color)
}
.navigationTitle("Colors")
}
}
}
struct ColorDetail: View {
var color: Color
var body: some View {
color.navigationTitle(color.description)
}
}

Related

LazyVStack with animation causing menu to disappear

If an action in the menu rendered in LazyVStack causes the item wrapping it to disappear, when the item appears again via some other data change, the menu won’t display. This only happens if 1) the views are displayed in an LazyVStack and 2) the visibility change happens with some animation.
Here’s a small toy example:
import SwiftUI
import CoreMotion
struct Item: Equatable {
var id: String
var archived = false
}
struct ItemView: View {
let item: Item
let onChange: () -> Void
var body: some View {
HStack {
Text("item \(item.id)")
Menu {
Button {
onChange()
} label: {
Text("Switch")
}
} label: {
Text("menu")
}
}
}
}
class GroupOfItem: ObservableObject {
#Published var items: [Item] = [
Item(id: "a"),
Item(id: "b")
]
}
struct ContentView: View {
#State private var toggle = false
#StateObject private var groupOfItem = GroupOfItem()
var body: some View {
Toggle(isOn: $toggle) {
Text("toggle")
}
ScrollView {
LazyVStack {
ForEach(groupOfItem.items.filter { $0.archived == toggle }, id: \.id) { item in
ItemView(item: item) {
withAnimation {
groupOfItem.items[groupOfItem.items.firstIndex(of: item)!] = Item(id: item.id, archived: !item.archived)
}
}
}
}
}
}
}
Tapping on “switch” in the menu will cause the item to disappear with some animation, and switching the toggle will make it appear. However, notice that the menu is gone.
Is there a way to workaround this? VStack is not performant enough, List has its own quirks and without the animation the experience feels very jarring.
Could you work around this with a contextMenu? While not identical, it seems to work in OK in a LazyVStack
struct ItemView: View {
let item: Item
let onChange: () -> Void
var body: some View {
HStack {
Text("item \(item.id)")
Text("menu").foregroundColor(.accentColor)
.padding()
.contextMenu {
Button("Switch") {
onChange()
}
}
}
}
}

listRowBackground removes selection style

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:

Row separators not showing when section separators hidden

When using a combination of listRowSeparator and listSectionSeparator being hidden, list row separators don't show up when appending items to the end of the list.
Here is a simple sample that reproduces the problem:
import SwiftUI
struct Item: Identifiable {
let id: Int
let text: String
}
struct ContentView: View {
#State var items: [Item] = []
var body: some View {
VStack {
Button {
items.append(Item(id: items.count, text: "\(items.count)"))
} label: {
Text("Append")
}
List {
Section {
ForEach(items) { item in
Text(item.text)
.listRowSeparator(.visible)
.listRowSeparatorTint(Color.red)
}
}
// Comment out this for row separators to work
.listSectionSeparator(.hidden, edges: .all)
}
.listStyle(.plain)
}
}
}
This is happening on iOS 15, only when using plain list style, and only when appending to the end of the list.
Am I doing something wrong, or is there a workaround for this problem?
Looks like a SwiftUI bug, the below combination can be considered as workaround
Section {
ForEach(items) { item in
Text(item.text)
.listRowSeparatorTint(Color.red)
.listRowSeparator(.visible, edges: .bottom)
}
}
.listSectionSeparator(.hidden, edges: .top)
, which gives

SwiftUI List messed up after delete action on iOS 15

It seems that there is a problem in SwiftUI with List and deleting items. The items in the list and data get out of sync.
This is the code sample that reproduces the problem:
import SwiftUI
struct ContentView: View {
#State var popupShown = false
var body: some View {
VStack {
Button("Show list") { popupShown.toggle() }
if popupShown {
MainListView()
}
}
.animation(.easeInOut, value: popupShown)
}
}
struct MainListView: View {
#State var texts = (0...10).map(String.init)
func delete(at positions: IndexSet) {
positions.forEach { texts.remove(at: $0) }
}
var body: some View {
List {
ForEach(texts, id: \.self) { Text($0) }
.onDelete { delete(at: $0) }
}
.frame(width: 300, height: 300)
}
}
If you perform a delete action on the first row and scroll to the last row, the data and list contents are not in sync anymore.
This is only happening when animation is attached to it. Removing .animation(.easeInOut, value: popupShown) workarounds the issue.
This code sample works as expected on iOS 14 and doesn't work on iOS 15.
Is there a workaround for this problem other then removing animation?
It isn't the animation(). The clue was seeing It appears that having the .animation outside of the conditional causes the problem. Moving it to the view itself corrected it to some extent. However, there is a problem with this ForEach construct: ForEach(texts, id: \.self). As soon as you start deleting elements of your array, the UI gets confused as to what to show where. You should ALWAYS use an Identifiable element in a ForEach. See the example code below:
struct ListDeleteView: View {
#State var popupShown = false
var body: some View {
VStack {
Button("Show list") { popupShown.toggle() }
if popupShown {
MainListView()
.animation(.easeInOut, value: popupShown)
}
}
}
}
struct MainListView: View {
#State var texts = (0...10).map({ TextMessage(message: $0.description) })
func delete(at positions: IndexSet) {
texts.remove(atOffsets: positions)
}
var body: some View {
List {
ForEach(texts) { Text($0.message) }
.onDelete { delete(at: $0) }
}
.frame(width: 300, height: 300)
}
}
struct TextMessage: Identifiable {
let id = UUID()
let message: String
}

Corrupted Navigation Views

I'm pretty sure this is a bug in SwiftUI, but I wondered if anyone has encountered it and figured out a workaround. My normal use case is to have a search field appear, but I've simplified it to the point where a simple text string exhibits the bug.
Create a single-view app, copy this into ContentView, and run it. Tap the search icon twice, then scroll the view; you'll see the text scrolling UNDER the title.
import SwiftUI
struct ContentView: View {
private var items = (0 ... 50).map {String($0)}
#State private var condition = false
var searchButton: some View {
Button(action: {self.condition.toggle()}) {
Image(systemName: "magnifyingglass").imageScale(.large)
}
}
var body: some View {
NavigationView {
VStack {
if condition {
Text("Peekaboo")
}
List {
ForEach(items, id: \.self) {item in
HStack {
Text(item)
}
}
}
}
.navigationBarTitle("List of Items")
.navigationBarItems(leading: searchButton)
}
}
}
Maybe it is a bug, submit feedback to Apple, but currently this is how NavigationView behaves - it collapses navigation bar only if its top content is List/ScrollView/Form. So to solve the issue move your VStack either into a List or out of NavigationView
1)
var body: some View {
NavigationView {
List {
if condition {
Text("Peekaboo")
}
ForEach(items, id: \.self) {item in
2)
var body: some View {
VStack {
if condition {
Text("Peekaboo")
}
NavigationView {
List {
It seems that a View cannot cope with variable number of views.
A workaround this strange behavior is this:
import SwiftUI
struct ContentView: View {
private var items = (0 ... 50).map {String($0)}
#State private var condition = false
var searchButton: some View {
Button(action: {self.condition.toggle()}) {
Image(systemName: "magnifyingglass").imageScale(.large)
}
}
var body: some View {
NavigationView {
VStack {
if condition {
Text("Peekaboo")
} else {
Text("")
}
// or use this Text(condition ? "Peekaboo" : "")
List {
ForEach(items, id: \.self) {item in
HStack {
Text(item)
}
}
}
}
.navigationBarTitle("List of Items")
.navigationBarItems(leading: searchButton)
}
}
}
Let me know if it works, if not let us know what device/system you are using. Tested with Xcode 11.6 beta, Mac 10.15.5, target ios 13.5 and mac catalyst.