SwiftUI - ForEach deletion transition always applied to last item only - swift

I'm trying to add a delete animation to my ForEach, so that each Card inside it scales out when removed. This is what I have so far:
The problem is that no matter which Card is pressed, it's always the last one that animates. And sometimes, the text inside each card has a weird sliding/morphing animation. Here's my code:
/// Ran into this problem: "SwiftUI ForEach index out of range error when removing row"
/// `ObservableObject` solution from https://stackoverflow.com/a/62796050/14351818
class Card: ObservableObject, Identifiable {
let id = UUID()
#Published var name: String
init(name: String) {
self.name = name
}
}
struct ContentView: View {
#State var cards = [
Card(name: "Apple"),
Card(name: "Banana "),
Card(name: "Coupon"),
Card(name: "Dog"),
Card(name: "Eat")
]
var body: some View {
ScrollView(.horizontal) {
HStack {
ForEach(cards.indices, id: \.self) { index in
CardView(card: cards[index], removePressed: {
withAnimation(.easeOut) {
_ = cards.remove(at: index) /// remove the card
}
})
.transition(.scale)
}
}
}
}
}
struct CardView: View {
#ObservedObject var card: Card
var removePressed: (() -> Void)?
var body: some View {
Button(action: {
removePressed?() /// call the remove closure
}) {
VStack {
Text("Remove")
Text(card.name)
}
}
.foregroundColor(Color.white)
.font(.system(size: 24, weight: .medium))
.padding(40)
.background(Color.red)
}
}
How can I scale out the Card that is clicked, and not the last one?

The reason you're seeing this behavior is because you use an index as an id for ForEach. So, when an element is removed from the cards array, the only difference that ForEach sees is that the last index is gone.
You need to make sure that the id uniquely identifies each element of ForEach.
If you must use indices and have each element identified, you can either use the enumerated method or zip the array and its indices together. I like the latter:
ForEach(Array(zip(cards.indices, cards)), id: \.1) { (index, card) in
//...
}
The above uses the object itself as the ID, which requires conformance to Hashable. If you don't want that, you can use the id property directly:
ForEach(Array(zip(cards.indices, cards)), id: \.1.id) { (index, card) in
//...
}
For completeness, here's the enumerated version (technically, it's not an index, but rather an offset, but for 0-based arrays it's the same):
ForEach(Array(cards.enumerated()), id: \.1) { (index, card) in
//...
}

New Dev's answer was great, but I had something else that I needed. In my full code, I had a button inside each Card that scrolled the ScrollView to the end.
/// the ForEach
ForEach(Array(cards.enumerated()), id: \.1) { (index, card) in
CardView(
card: cards[index],
scrollToEndPressed: {
proxy.scrollTo(cards.count - 1, anchor: .center) /// trying to scroll to end... not working though.
},
removePressed: {
withAnimation(.easeOut) {
_ = cards.remove(at: index) /// remove the card
}
}
)
.transition(.scale)
}
/// CardView
struct CardView: View {
#ObservedObject var card: Card
var scrollToEndPressed: (() -> Void)?
var removePressed: (() -> Void)?
var body: some View {
VStack {
Button(action: {
scrollToEndPressed?() /// scroll to the end
}) {
VStack {
Text("Scroll to end")
}
}
Button(action: {
removePressed?() /// call the remove closure
}) {
VStack {
Text("Remove")
Text(card.name)
}
}
.foregroundColor(Color.white)
.font(.system(size: 24, weight: .medium))
.padding(40)
.background(Color.red)
}
}
}
With the above code, the "Scroll to end" button didn't work.
I fixed this by assigning an explicit ID to each CardView.
ForEach(Array(cards.enumerated()), id: \.1) { (index, card) in
CardView(card: cards[index], scrollToEndPressed: {
withAnimation(.easeOut) { /// also animate it
proxy.scrollTo(cards.last?.id ?? card.id, anchor: .center) /// scroll to the last card's ID
}
}, removePressed: {
withAnimation(.easeOut) {
_ = cards.remove(at: index) /// remove the card
}
})
.id(card.id) /// add ID
.transition(.scale)
}
Result:

I recommend you to rethink and use Card as struct rather than a class and confirm to Identifiable and Equatable.
struct Card: Hashable, Identifiable {
let id = UUID()
var name: String
init(name: String) {
self.name = name
}
}
And then create a View Model that holds your cards.
class CardViewModel: ObservableObject {
#Published
var cards: [Card] = [
Card(name: "Apple"),
Card(name: "Banana "),
Card(name: "Coupon"),
Card(name: "Dog"),
Card(name: "Eat")
]
}
Iterate over cardViewModel.cards and pass card to the CardView. Use removeAll method of Array instead of remove. It is safe since Cards are unique.
ForEach(viewModel.cards) { card in
CardView(card: card) {
withAnimation(.easeOut) {
cardViewModel.cards.removeAll { $0 == card}
}
}
}
A complete working exammple.
struct ContentView: View {
#ObservedObject var cardViewModel = CardViewModel()
var body: some View {
ScrollView(.horizontal) {
HStack {
ForEach(cardViewModel.cards) { card in
CardView(card: card) {
withAnimation(.easeOut) {
cardViewModel.cards.removeAll { $0 == card}
}
}
.transition(.scale)
}
}
}
}
}
struct CardView: View {
var card: Card
var removePressed: (() -> Void)?
var body: some View {
Button(action: {
removePressed?()
}) {
VStack {
Text("Remove")
Text(card.name)
}
}
.foregroundColor(Color.white)
.font(.system(size: 24, weight: .medium))
.padding(40)
.background(Color.red)
}
}
If for some reason you need index of card in ContentView, do this.
Accessing and manipulating array item in an EnvironmentObject
Removing items from a child view generated by For Each loop causes Fatal Error
Both of them are similar to this tutorial of Apple.
https://developer.apple.com/tutorials/swiftui/handling-user-input

Related

Index out of range when updating a #Published var

I am new to Swift/SwiftUI and am trying to build an app that works with the trello API.
There is a "TrelloApi" class that is available as an #EnvironmentObject in the entire app. The same class is also used to make API calls.
One board is viewed at a time. A board has many lists and each list has many cards.
Now I have an issue with my rendering where whenever I switch boards and any list in the new board has fewer cards in it than before, I get the following error in an onReceive handler where I need to do some checks to update the cards appearance:
Swift/ContiguousArrayBuffer.swift:575: Fatal error: Index out of range
2022-10-19 09:04:11.319982+0200 trello[97617:17713580] Swift/ContiguousArrayBuffer.swift:575: Fatal error: Index out of range
Models
struct BoardPrefs: Codable {
var backgroundImage: String? = "";
}
struct BasicBoard: Identifiable, Codable {
var id: String;
var name: String;
var prefs: BoardPrefs;
}
struct Board: Identifiable, Codable {
var id: String;
var name: String;
var prefs: BoardPrefs;
var lists: [List] = [];
var cards: [Card] = [];
var labels: [Label] = [];
}
struct List: Identifiable, Codable, Hashable {
var id: String;
var name: String;
var cards: [Card] = [];
private enum CodingKeys: String, CodingKey {
case id
case name
}
}
struct Card: Identifiable, Codable, Hashable {
var id: String;
var idList: String = "";
var labels: [Label] = [];
var idLabels: [String] = [];
var name: String;
var desc: String = "";
var due: String?;
var dueComplete: Bool = false;
}
TrelloApi.swift (HTTP call removed for simplicity)
class TrelloApi: ObservableObject {
let key: String;
let token: String;
#Published var board: Board;
#Published var boards: [BasicBoard];
init(key: String, token: String) {
self.key = key
self.token = token
self.board = Board(id: "", name: "", prefs: BoardPrefs())
self.boards = []
}
func getBoard(id: String, completion: #escaping (Board) -> Void = { board in }) {
if id == "board-1" {
self.board = Board(id: "board-1", name: "board-1", prefs: BoardPrefs(), lists: [
List(id: "board-1-list-1", name: "board-1-list-1", cards: [
Card(id: "b1-l1-card1", name: "b1-l1-card1"),
]),
List(id: "board-1-list-2", name: "board-1-list-2", cards: [
Card(id: "b1-l2-card1", name: "b1-l2-card1"),
Card(id: "b1-l2-card2", name: "b1-l2-card2"),
])
])
completion(self.board)
} else {
self.board = Board(id: "board-2", name: "board-2", prefs: BoardPrefs(), lists: [
List(id: "board-2-list-1", name: "board-2-list-1", cards: [
]),
List(id: "board-2-list-2", name: "board-2-list-2", cards: [
Card(id: "b2-l2-card1", name: "b2-l2-card1"),
])
])
completion(self.board)
}
}
}
ContentView.swift
struct ContentView: View {
#EnvironmentObject var trelloApi: TrelloApi;
var body: some View {
HStack {
VStack {
Text("Switch Board")
Button(action: {
trelloApi.getBoard(id: "board-1")
}) {
Text("board 1")
}
Button(action: {
trelloApi.getBoard(id: "board-2")
}) {
Text("board 2")
}
}
VStack {
ScrollView([.horizontal]) {
ScrollView([.vertical]) {
VStack(){
HStack(alignment: .top) {
ForEach($trelloApi.board.lists) { list in
TrelloListView(list: list)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding()
.frame(maxHeight: .infinity, alignment: .top)
}
}
}
}
}.onAppear {
trelloApi.getBoard(id: "board-1")
}
.frame(minWidth: 900, minHeight: 600, alignment: .top)
}
}
TrelloListView.swift
struct TrelloListView: View {
#EnvironmentObject var trelloApi: TrelloApi;
#Binding var list: List;
var body: some View {
VStack() {
Text(self.list.name)
Divider()
SwiftUI.List(self.$list.cards, id: \.id) { card in
CardView(card: card)
}
.listStyle(.plain)
.frame(minHeight: 200)
}
.padding(4)
.cornerRadius(8)
.frame(minWidth: 200)
}
}
CardView.swift
struct CardView: View {
#EnvironmentObject var trelloApi: TrelloApi;
#Binding var card: Card;
var body: some View {
VStack(alignment: .leading) {
HStack {
VStack(alignment: .leading, spacing: 0) {
Text(card.name)
.bold()
.font(.system(size: 14))
.multilineTextAlignment(.leading)
.lineLimit(1)
.foregroundColor(.white)
Text(card.desc)
.lineLimit(1)
.foregroundColor(.secondary)
}.padding()
Spacer()
}
}
.frame(alignment: .leading)
.onReceive(Just(card)) { newCard in
// CRASH LOCATION: "Index out of range" for self.card.labels
if self.card.labels != newCard.labels {
print("(check if card color should change based on labels)")
}
}
.cornerRadius(4)
}
}
I've highlighted the crash location with a comment. I don't pass any indexes in the ForEach or List and I am overwriting the entire trelloApi.board object, so I am not sure why I am getting this error.
I've tried using ForEach inside of the SwiftUI.List instead, but that also doesn't change anything.
The minimal reproducible code can also be found on my GitHub repo: https://github.com/Rukenshia/trello/tree/troubleshooting-board-switch-crash/trello
The exact issue is hard to track down, but here are some observations and recommandations.
The .onReceive() modifier you are using looks suspicious because you initialize the publisher yourself inline in the function call. You generally use .onReceive() to react to events published from publishers set up by another piece of code.
Moreover, you are using this .onReceive() to react to changes in a #Binding property, which is redundant since by definition a #Binding already triggers view updates when its value changes.
EDIT
This seems to be the issue that causes the crash in your app. Changing the .onReceive() to .onChange() seems to solve the problem:
.onChange(of: card) { newCard in
if self.card.labels != newCard.labels {
print("(check if card color should change based on labels)")
}
}
You also seem to duplicate some state:
.onReceive(Just(card)) { newCard in
self.due = newCard.dueDate
}
Here, you duplicated the due date, there is one copy in self.due and another copy in self.card.dueDate. In SwiftUI there should only be one source of truth and for you it would be the card property. You duplicated the state in the init: self.due = card.wrappedValue.dueDate. Accessing the .wrappedValue of a #Binding/State is a code smell and the sign that you are doing something wrong.
Lastly, ou use an anti-pattern which can be dangerous:
struct CardView: View {
#State private var isHovering: Bool
func init(isHovering: String) {
self._isHovering = State(initialValue: false)
}
var body: some View {
...
}
}
You should avoid initializing a #State property wrapper yourself in the view's init. A #State property must be initililized inline:
struct CardView: View {
#State private var isHovering: Bool = false
var body: some View {
...
}
}
If for some reason you have to customize the value of a #State property, you could use the .onAppear() or the newer .task() view modifier to change its value after the view creation:
struct CardView: View {
#State private var isHovering: Bool = false
var body: some View {
SomeView()
.onAppear {
isHovering = Bool.random()
}
}
}
As a general advice you should break up your views into smaller pieces. When a view depends on many #State properties and has lots of .onChange() or .onReceive() it is usually an indication that it is time to move the whole logic inside and ObservableObject or refactor into smaller components.

Change values in struct model

I'm stuck with adding items from struct to favorites. The idea:
I have a json with data for cards
On the main screen app shows a random card
The user could pick another random card or save it to favorites.
Code below.
Creating a CardModel (file 1):
struct CardModel: Hashable, Codable, Identifiable {
let id: Int
let topic: String
let category: String
var saved: Bool
}
Retrieving data from json and creating an array of structs (file 2):
var cardsModelArray: [CardModel] = load("LetsTalkTopics.json")
Func to pickup random item from the array (file 3):
func pickRandomCard() -> CardModel {
let randomCard = cardsModelArray.randomElement()!
return randomCard
}
Func to change the "saved" bool value (file 4)
func saveCard(card: CardModel) {
let index = card.id
cardsModelArray[index] = CardModel(id: index, topic: card.topic, category: card.category, saved: !card.saved)
}
View file (file 5, simplified)
import SwiftUI
struct StackOverFlow: View {
#State var currentCard = pickRandomCard()
var body: some View {
VStack{
CardViewStackOver(cards: currentCard)
Button("Show random card") {
currentCard = pickRandomCard()
}
Button("Save item") {
saveCard(card: currentCard)
}
}
}
struct StackOverFlow_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
StackOverFlow()
}
}
}
struct CardViewStackOver: View {
let cards: CardModel
var body: some View {
VStack {
Text(cards.topic)
Text(cards.category)
Text(String(cards.id))
HStack {
if cards.saved {
Image(systemName: "heart.fill")
.font(.title)
.padding(15)
.padding(.bottom, 20)
} else {
Image(systemName: "heart")
.font(.title)
.padding(15)
.padding(.bottom, 20)
}
}
}
}
}
But I definitely making something wrong, in a separate view I'm showing saved cards but it doesn't work (it shows some random cards, and some saved cards have duplicates). With my research, I found out that structs are immutable and when I'm trying to edit a value, basically swift creates a copy of it and makes changes in the copy. If so, what would be the right approach to create this favorite feature?
A solution that fixed the problem:
func saveCardMinus(currentCard: CardModel) {
var index = currentCard.id - 1
cardsModelArray[index].saved.toggle()
}
But I'm sure that the whole solution is bad. What is the right/more proper way to solve it?
(and btw, now I face another problem: the icon for bool value updates is not in real-time, you need to open this card again to see a new value (filled heart/unfilled heart))
You could use a Binding to pass the selected card on. But I restructured the whole code as there were multiple bad practices involved:
struct StackOverFlow: View {
//store the current selected index and the collection as state objects
#State var currentCardIndex: Int?
#State var cards: [CardModel] = []
var body: some View {
VStack{
//if there is an index show the card
if let index = currentCardIndex{
CardViewStackOver(card: $cards[index])
}
Button("Show random card") {
// just create a random index
currentCardIndex = (0..<cards.count).randomElement()
}
Button("Save / delete item") {
if let index = currentCardIndex{
//you save delete as favorite here
cards[index].saved.toggle()
}
}
}.onAppear{
//donĀ“t exactly know where this function lives
if cards.count == 0{
cards = load("LetsTalkTopics.json")
}
}
}
}
struct CardViewStackOver: View {
//use binding wrapper here
#Binding var card: CardModel
var body: some View {
VStack {
Text(card.topic)
Text(card.category)
Text(String(card.id))
HStack {
Image(systemName: card.saved ? "heart.fill" : "heart")
.font(.title)
.padding(15)
.padding(.bottom, 20)
.onTapGesture {
card.saved.toggle()
}
}
}
}
}

SwiftUI: NavigationView detail view pops backstack when previous's view List changes

I have a List of ids and scores in my first screen.
In the detail screen I click and call a callback that adds to the score and resorts the List by the score.
When I do this with an item at the top of the list, nothing happens. (Good)
When I do this with an item at the bottom of the list, the navigation view pops the backstack and lands me back on the first page. (Bad)
import SwiftUI
class IdAndScoreItem {
var id: Int
var score: Int
init(id: Int, score: Int) {
self.id = id
self.score = score
}
}
#main
struct CrazyBackStackProblemApp: App {
var body: some Scene {
WindowGroup {
NavigationView {
ListView()
}
.navigationViewStyle(.stack)
}
}
}
struct ListView: View {
#State var items = (1...50).map { IdAndScoreItem(id: $0, score: 0) }
func addScoreAndSort(item: IdAndScoreItem) {
items = items
.map {
if($0.id == item.id) { $0.score += 1 }
return $0
}
.sorted {
$0.score > $1.score
}
}
var body: some View {
List(items, id: \.id) { item in
NavigationLink {
ScoreClickerView(
onClick: { addScoreAndSort(item: item) }
)
} label: {
Text("id: \(item.id) score:\(item.score)")
}
}
}
}
struct ScoreClickerView: View {
var onClick: () -> Void
var body: some View {
Text("tap me to increase the score")
.onTapGesture {
onClick()
}
}
}
How can I make it so I reorder the list on the detail page, and that's reflected on the list page, but the navigation stack isn't popped (when I'm doing it on a list item at the bottom of the list). I tried added navigationStyle(.stack) to no avail.
Thanks for any and all help!
Resort changes order of IDs making list recreate content that leads to current NavigationLinks destroying, so navigating back.
A possible solution is to separate link from content - it can be done with introducing something like selection (tapped row) and one navigation link activated with that selection.
Tested with Xcode 14 / iOS 16
#State private var selectedItem: IdAndScoreItem? // selection !!
var isNavigate: Binding<Bool> { // link activator !!
Binding(get: { selectedItem != nil}, set: { _ in selectedItem = nil })
}
var body: some View {
List(items, id: \.id) { item in
Text("id: \(item.id) score:\(item.score)") // tappable row
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
selectedItem = item
}
}
.background(
NavigationLink(isActive: isNavigate) { // one link !!
ScoreClickerView {
if let item = selectedItem {
addScoreAndSort(item: item)
}
}
} label: {
EmptyView()
}
)
}
Do your sorting on onAppear. No need to sort on each click.
struct ListView: View {
#State var items = (1...50).map { IdAndScoreItem(id: $0, score: 0) }
func addScoreAndSort(item: IdAndScoreItem) {
item.score += 1
}
var body: some View {
List(items, id: \.id) { item in
NavigationLink {
ScoreClickerView(
onClick: { addScoreAndSort(item: item) }
)
} label: {
Text("id: \(item.id) score:\(item.score)")
}
}.onAppear { // <==== Here
items = items
.sorted {
$0.score > $1.score
}
}
}
}
Note : No need to use map here. since you are using class so it will update with reference.

SwiftUI nested LazyVStacks in a single ScrollView

I'm trying to build a comment thread. So top level comments can all have nested comments and so can they and so on and so forth. But I'm having issues around scrolling and also sometimes when expanding sections the whole view just jumps around, and can have a giant blank space at the bottom. The code looks like this:
struct ContentView: View {
var body: some View {
VStack {
HStack {
Text("Comments")
.font(.system(size: 34))
.fontWeight(.bold)
Spacer()
}
.padding()
CommentListView(commentIds: [0, 1, 2, 3], nestingLevel: 1)
}
}
}
struct CommentListView: View {
let commentIds: [Int]?
let nestingLevel: Int
var body: some View {
if let commentIds = commentIds {
LazyVStack(alignment: .leading) {
ForEach(commentIds, id: \.self) { id in
CommentItemView(viewModel: CommentItemViewModel(commentId: id), nestingLevel: nestingLevel)
}
}
.applyIf(nestingLevel == 1) {
$0.scrollable()
}
} else {
Spacer()
Text("No comments")
Spacer()
}
}
}
struct CommentItemView: View {
#StateObject var viewModel: CommentItemViewModel
let nestingLevel: Int
#State private var showComments = false
var body: some View {
VStack {
switch viewModel.viewState {
case .error:
Text("Error")
.fontWeight(.thin)
.font(.system(size: 12))
.italic()
case .loading:
Text("Loading")
.fontWeight(.thin)
.font(.system(size: 12))
.italic()
case .complete:
VStack {
Text(viewModel.text)
.padding(.bottom)
.padding(.leading, 20 * CGFloat(nestingLevel))
if let commentIds = viewModel.commentIds {
Button {
withAnimation {
showComments.toggle()
}
} label: {
Text(showComments ? "Hide comments" : "Show comments")
}
if showComments {
CommentListView(commentIds: commentIds, nestingLevel: nestingLevel + 1)
}
}
}
}
}
}
}
class CommentItemViewModel: ObservableObject {
#Published private(set) var text = ""
#Published private(set) var commentIds: [Int]? = [0, 1, 2, 3]
#Published private(set) var viewState: ViewState = .loading
private let commentId: Int
private var viewStateInternal: ViewState = .loading {
willSet {
withAnimation {
viewState = newValue
}
}
}
init(commentId: Int) {
self.commentId = commentId
fetchComment()
}
private func fetchComment() {
viewStateInternal = .complete
text = CommentValue.allCases[commentId].rawValue
}
}
Has anyone got a better way of doing this? I know List can now accept a KeyPath to child object and it can nest that way, but there's so limited design control over List that I didn't want to use it. Also, while this code is an example, the real code will have to load each comment from an API call, so List won't perform as well as LazyVStack in that regard.
Any help appreciated - including a complete overhaul of how to implement this sort of async loading nested view.

List scroll freeze on catalyst NavigationView

I've run in to an odd problem with NavigationView on macCatalyst. Here below is a simple app with a sidebar and a detail view. Selecting an item on the sidebar shows a detail view with a scrollable list.
Everything works fine for the first NavigationLink, the detail view displays and is freely scrollable. However, if I select a list item which triggers a link to a second detail view, scrolling starts, then freezes. The app still works, only the detail view scrolling is locked up.
The same code works fine on an iPad without any freeze. If I build for macOS, the NavigationLink in the detail view is non-functional.
Are there any known workarounds ?
This is what it looks like, after clicking on LinkedView, a short scroll then the view freezes. It is still possible to click on the back button or another item on the sidebar, but the list view is blocked.
Here is the code:
ContentView.swift
import SwiftUI
struct ContentView: View {
var names = [NamedItem(name: "One"), NamedItem(name: "Two"), NamedItem(name:"Three")]
var body: some View {
NavigationView {
List() {
ForEach(names.sorted(by: {$0.name < $1.name})) { item in
NavigationLink(destination: DetailListView(item: item)) {
Text(item.name)
}
}
}
.listStyle(SidebarListStyle())
Text("Detail view")
}
}
}
struct NamedItem: Identifiable {
let name: String
let id = UUID()
}
struct DetailListView: View {
var item: NamedItem
let sections = (0...4).map({NamedItem(name: "\($0)")})
var body: some View {
VStack {
List {
Text(item.name)
NavigationLink(destination: DetailListView(item: NamedItem(name: "LinkedView"))) {
listItem(" LinkedView", "Item")
.foregroundColor(Color.blue)
}
ForEach(sections) { section in
sectionDetails(section)
}
}
}
}
let info = (0...12).map({NamedItem(name: "\($0)")})
func sectionDetails(_ section: NamedItem) -> some View {
Section(header: Text("Section \(section.name)")) {
Group {
listItem("ID", "\(section.id)")
}
Text("")
ForEach(info) { ch in
listItem("Item \(ch.name)", "\(ch.id)")
}
}
}
func listItem(_ title: String, _ value: String, tooltip: String? = nil) -> some View {
HStack {
Text(title)
.frame(width: 200, alignment: .leading)
Text(value)
.padding(.leading, 10)
}
}
}
TestListApp.swift
import SwiftUI
#main
struct TestListApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
I had this very same problem with Mac Catalyst app. On real device (iPhone 7 with iOS 14.4.2) there was no problem but with Mac Catalyst (MacBook Pro with Big Sur 11.2.3) the scrolling in the navigation view stuck very randomly as you explained. I figured out that the issue was with Macbook's trackpad and was related to scroll indicators because with external mouse the issue was absent. So the easiest solution to this problem is to hide vertical scroll indicators in navigation view. At least it worked for me. Below is some code from root view 'ContentView' how I did it. It's unfortunate to lose scroll indicators with big data but at least the scrolling works.
import SwiftUI
struct TestView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination: NewView()) {
Text("Navigation Link to new view")
}
}
.onAppear {
UITableView.appearance().showsVerticalScrollIndicator = false
}
}
}
}
OK, so I managed to find a workaround, so thought I'd post this for help, until what seems to be a macCatalyst SwiftUI bug is fixed. I have posted a radar for the list freeze problem: FB8994665
The workaround is to use NavigationLink only to the first level of the series of pages which can be navigated (which gives me the sidebar and a toolbar), and from that point onwards use the NavigationStack package to mange links to other pages.
I ran in to a couple of other gotcha's with this arrangement.
Firstly the NavigationView toolbar loses its background when scrolling linked list views (unless the window is defocussed and refocussed), which seems to be another catalyst SwiftUI bug. I solved that by setting the toolbar background colour.
Second gotcha was that under macCatalyst the onTouch view modifier used in NavigationStack's PushView label did not work for most single clicks. It would only trigger consistently for double clicks. I fixed that by using a button to replace the label.
Here is the code, no more list freezes !
import SwiftUI
import NavigationStack
struct ContentView: View {
var names = [NamedItem(name: "One"), NamedItem(name: "Two"), NamedItem(name:"Three")]
#State private var isSelected: UUID? = nil
init() {
// Ensure toolbar is allways opaque
UINavigationBar.appearance().backgroundColor = UIColor.secondarySystemBackground
}
var body: some View {
NavigationView {
List {
ForEach(names.sorted(by: {$0.name < $1.name})) { item in
NavigationLink(destination: DetailStackView(item: item)) {
Text(item.name)
}
}
}
.listStyle(SidebarListStyle())
Text("Detail view")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.toolbar { Spacer() }
}
}
}
struct NamedItem: Identifiable {
let name: String
let id = UUID()
}
// Embed the list view in a NavigationStackView
struct DetailStackView: View {
var item: NamedItem
var body: some View {
NavigationStackView {
DetailListView(item: item)
}
}
}
struct DetailListView: View {
var item: NamedItem
let sections = (0...10).map({NamedItem(name: "\($0)")})
var linked = NamedItem(name: "LinkedView")
// Use a Navigation Stack instead of a NavigationLink
#State private var isSelected: UUID? = nil
#EnvironmentObject private var navigationStack: NavigationStack
var body: some View {
List {
Text(item.name)
PushView(destination: linkedDetailView,
tag: linked.id, selection: $isSelected) {
listLinkedItem(" LinkedView", "Item")
}
ForEach(sections) { section in
if section.name != "0" {
sectionDetails(section)
}
}
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(item.name)
}
// Ensure that the linked view has a toolbar button to return to this view
var linkedDetailView: some View {
DetailListView(item: linked)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
self.navigationStack.pop()
}, label: {
Image(systemName: "chevron.left")
})
}
}
}
let info = (0...12).map({NamedItem(name: "\($0)")})
func sectionDetails(_ section: NamedItem) -> some View {
Section(header: Text("Section \(section.name)")) {
Group {
listItem("ID", "\(section.id)")
}
Text("")
ForEach(info) { ch in
listItem("Item \(ch.name)", "\(ch.id)")
}
}
}
// Use a button to select the linked view with a single click
func listLinkedItem(_ title: String, _ value: String, tooltip: String? = nil) -> some View {
HStack {
Button(title, action: {
self.isSelected = linked.id
})
.foregroundColor(Color.blue)
Text(value)
.padding(.leading, 10)
}
}
func listItem(_ title: String, _ value: String, tooltip: String? = nil) -> some View {
HStack {
Text(title)
.frame(width: 200, alignment: .leading)
Text(value)
.padding(.leading, 10)
}
}
}
I have continued to experiment with NavigationStack and have made some modifications which will allow it to swap in and out List rows directly. This avoids the problems I was seeing with the NavigationBar background. The navigation bar is setup at the level above the NavigationStackView and changes to the title are passed via a PreferenceKey. The back button on the navigation bar hides if the stack is empty.
The following code makes use of PR#44 of swiftui-navigation-stack
import SwiftUI
struct ContentView: View {
var names = [NamedItem(name: "One"), NamedItem(name: "Two"), NamedItem(name:"Three")]
#State private var isSelected: UUID? = nil
var body: some View {
NavigationView {
List {
ForEach(names.sorted(by: {$0.name < $1.name})) { item in
NavigationLink(destination: DetailStackView(item: item)) {
Text(item.name)
}
}
}
.listStyle(SidebarListStyle())
Text("Detail view")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.toolbar { Spacer() }
}
}
}
struct NamedItem: Identifiable {
let name: String
let depth: Int
let id = UUID()
init(name:String, depth: Int = 0) {
self.name = name
self.depth = depth
}
var linked: NamedItem {
return NamedItem(name: "Linked \(depth+1)", depth:depth+1)
}
}
// Preference Key to send title back down to DetailStackView
struct ListTitleKey: PreferenceKey {
static var defaultValue: String = ""
static func reduce(value: inout String, nextValue: () -> String) {
value = nextValue()
}
}
extension View {
func listTitle(_ title: String) -> some View {
self.preference(key: ListTitleKey.self, value: title)
}
}
// Embed the list view in a NavigationStackView
struct DetailStackView: View {
var item: NamedItem
#ObservedObject var navigationStack = NavigationStack()
#State var toolbarTitle: String = ""
var body: some View {
List {
NavigationStackView(noGroup: true, navigationStack: navigationStack) {
DetailListView(item: item, linked: item.linked)
.listTitle(item.name)
}
}
.listStyle(PlainListStyle())
.animation(nil)
// Updated title
.onPreferenceChange(ListTitleKey.self) { value in
toolbarTitle = value
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("\(toolbarTitle) \(self.navigationStack.depth)")
.toolbar(content: {
ToolbarItem(id: "BackB", placement: .navigationBarLeading, showsByDefault: self.navigationStack.depth > 0) {
Button(action: {
self.navigationStack.pop()
}, label: {
Image(systemName: "chevron.left")
})
.opacity(self.navigationStack.depth > 0 ? 1.0 : 0.0)
}
})
}
}
struct DetailListView: View {
var item: NamedItem
var linked: NamedItem
let sections = (0...10).map({NamedItem(name: "\($0)")})
// Use a Navigation Stack instead of a NavigationLink
#State private var isSelected: UUID? = nil
#EnvironmentObject private var navigationStack: NavigationStack
var body: some View {
Text(item.name)
PushView(destination: linkedDetailView,
tag: linked.id, selection: $isSelected) {
listLinkedItem(" LinkedView", "Item")
}
ForEach(sections) { section in
if section.name != "0" {
sectionDetails(section)
}
}
}
// Ensure that the linked view has a toolbar button to return to this view
var linkedDetailView: some View {
DetailListView(item: linked, linked: linked.linked)
.listTitle(linked.name)
}
let info = (0...12).map({NamedItem(name: "\($0)")})
func sectionDetails(_ section: NamedItem) -> some View {
Section(header: Text("Section \(section.name)")) {
Group {
listItem("ID", "\(section.id)")
}
Text("")
ForEach(info) { ch in
listItem("Item \(ch.name)", "\(ch.id)")
}
}
}
func buttonAction() {
self.isSelected = linked.id
}
// Use a button to select the linked view with a single click
func listLinkedItem(_ title: String, _ value: String, tooltip: String? = nil) -> some View {
HStack {
Button(title, action: buttonAction)
.foregroundColor(Color.blue)
Text(value)
.padding(.leading, 10)
}
}
func listItem(_ title: String, _ value: String, tooltip: String? = nil) -> some View {
HStack {
Text(title)
.frame(width: 200, alignment: .leading)
Text(value)
.padding(.leading, 10)
}
}
}