Unexpected (automatic) navigation with SwiftUI on watchOS

Viewed 165

I have a simple watchOS 6.2.8 SwiftUI application in which I present a list of messages to the user.

These messages are modelled as classes and have a title, body and category name. I also have a Category object which is a view on these messages that only shows a specific category name.

I specifically mention watchOS 6.2.8 because it seems SwiftUI behaves a bit different there than on other platforms.

class Message: Identifiable {
    let identifier: String
    let date: Date
    let title: String
    let body: String
    let category: String
    
    var id: String {
        identifier
    }

    init(identifier: String, date: Date, title: String, body: String, category: String) {
        self.identifier = identifier
        self.date = date
        self.title = title
        self.body = body
        self.category = category
    }
}

class Category: ObservableObject, Identifiable {
    let name: String
    @Published var messages: [Message] = []
    
    var id: String {
        name
    }

    init(name: String, messages: [Message] = []) {
        self.name = name
        self.messages = messages
    }
}

Category itself is an @ObservableObject and publishes messages, so that when a category gets updated (like in the background), the view that is displaying the category message list will also update. (This works great)

To store these messages I have a simple MessageStore, which is an @ObservableObject that looks like this:

class MessageStore: ObservableObject {
    @Published var messages: [Message] = []
    @Published var categories: [Category] = []

    static let sharedInstance = MessageStore()

    func insert(message: Message) throws { ... mutage messages and categories ... }
    func delete(message: Message) throws { ... mutage messages and categories ... }
}

(For simplicity I use a singleton, because there are problems with environment objects not being passed properly on watchOS)

The story keeps messages and categories in sync. When a new Message is added that has a category name set, it will also create or update a Category object in the categories list.

In my main view I present two things:

  • An All Messages NavigationLink that goes to a view to display all messages
  • For each Category I display a NavigationLink that goes to a view to display messages just in that specific category.

enter image description here

This all works, amazingly. But there is one really odd thing happening that I do not understand. (First SwiftUI project)

When I go into the All Messages list and delete all messages containing to a specific category, something unexpected happen when I navigate back to the main view.

First I observe that the category is properly removed from the list.

But then, the main view automatically quickly navigates to the All Messages list and then back.

The last part is driving me .. crazy .. I don't understand why this is happening. From a data perspective everyting looks good - the messages have been deleted and the category too. The final UI state, after the automagical navigation, also looks good - the message count for All Messages is correct and the category with now zero messages is not shown in the list anymore.

Here is the code for the main ContentView and also for the AllMessagesView. If helpful I can post the complete code here of course.

struct AllMessagesView: View {
    @ObservedObject var messageStore = MessageStore.sharedInstance

    @ViewBuilder
    var body: some View {
        if messageStore.messages.count == 0 {
            Text("No messages").multilineTextAlignment(.center)
                .navigationBarTitle("All Messages")
        } else {
            List {
                ForEach(messageStore.messages) { message in
                    MessageCellView(message: message)
                }.onDelete(perform: deleteMessages)
            }
            .navigationBarTitle("All Messages")
        }
    }

    func deleteMessages(at offsets: IndexSet) {
        for index in offsets {
            do {
                try messageStore.delete(message: messageStore.messages[index])
            } catch {
                NSLog("Failed to delete message: \(error.localizedDescription)")
            }
        }
    }
}

//

struct CategoryMessagesView: View {
    @ObservedObject var messageStore = MessageStore.sharedInstance

    @ObservedObject var category: Category

    var body: some View {
        Group {
            if category.messages.count == 0 {
                Text("No messages in category “\(category.name)”").multilineTextAlignment(.center)
            } else {
                List {
                    ForEach(category.messages) { message in
                        MessageCellView(message: message)
                    }.onDelete(perform: deleteMessages)
                }
            }
        }.navigationBarTitle(category.name)
    }

    func deleteMessages(at offsets: IndexSet) {
        for index in offsets {
            do {
                try messageStore.delete(message: category.messages[index])
            } catch {
                NSLog("Cannot delete message: \(error.localizedDescription)")
            }
        }
    }
}

struct ContentView: View {
    @ObservedObject var messageStore = MessageStore.sharedInstance

    var body: some View {
        List {
            Section {
                NavigationLink(destination: AllMessagesView()) {
                    HStack {
                        Image(systemName: "tray.2")
                        Text("All Messages")
                        Spacer()
                        Text("\(messageStore.messages.count)")
                            .font(messageCountFont())
                            .bold()
                            .layoutPriority(1)
                            .foregroundColor(.green)
                    }
                }
            }
            
            Section {
                Group {
                    if messageStore.categories.count > 0 {
                        Section {
                            ForEach(messageStore.categories) { category in
                                NavigationLink(destination: CategoryMessagesView(category: category)) {
                                    HStack {
                                        Image(systemName: "tray") // .foregroundColor(.green)
                                        Text("\(category.name)").lineLimit(1).truncationMode(.tail)
                                        Spacer()
                                        Text("\(category.messages.count)")
                                            .font(self.messageCountFont())
                                            .bold()
                                            .layoutPriority(1)
                                            .foregroundColor(.green)
                                    }
                                }
                            }
                        }
                    } else {
                        EmptyView()
                    }
                }
            }
        }
    }
    
    // TODO This is pretty inefficient
    func messageCountFont() -> Font {
        let font = UIFont.preferredFont(forTextStyle: .caption1)
        return Font(font.withSize(font.pointSize * 0.75))
    }
}

Apologies, I know this is a lot of code, but I feel I need to give enough context and visibility to show what is going on here.

Full project at https://github.com/st3fan/LearningSwiftUI/tree/master/MasterDetail - I don't think more code is relevant, but if it is, let me know and I'll move it into the question here.

1 Answers

The problem is in updated ForEach which result in recreating List and thus breaks link. This looks like SwiftUI defect, so worth submitting feedback to Apple.

The tested workaround is to move All Messages navigation link out of list (looks a bit different but might be appropriate). Tested with Xcode 12 / watchOS 7.0

struct ContentView: View {
    @ObservedObject var messageStore = MessageStore.sharedInstance

    var body: some View {
        VStack {
            NavigationLink(destination: AllMessagesView()) {
                HStack {
                    Image(systemName: "tray.2")
                    Text("All Messages")
                    Spacer()
                    Text("\(messageStore.messages.count)")
                        .font(messageCountFont())
                        .bold()
                        .layoutPriority(1)
                        .foregroundColor(.green)
                }
            }
            List {
                ForEach(messageStore.categories) { category in
                    NavigationLink(destination: CategoryMessagesView(category: category)) {
                        HStack {
                            Image(systemName: "tray") // .foregroundColor(.green)
                            Text("\(category.name)").lineLimit(1).truncationMode(.tail)
                            Spacer()
                            Text("\(category.messages.count)")
                                .font(self.messageCountFont())
                                .bold()
                                .layoutPriority(1)
                                .foregroundColor(.green)
                        }
                    }
                }
            }
        }
    }

    // ... other code
Related