PipEvangelist
PipEvangelist

Reputation: 631

NavigationLink pops out upon List update ONLY when List is not scrolled to the top

Apologies if the title is confusing. So, I'm implementing a chat app where there's a list of ChatRows that would, upon clicking, entering into a MessageView. When a user sends a message, the list of ChatRows may reorder because I order them in a way such that ones contain the latest messages are placed on the top.

The code looks roughly like this (let me know if more detail is needed):

struct ContentView: View {
    @EnvironmentObject var chatsManager: ChatsManager
    @EnvironmentObject var messagesManager: MessagesManager

     var body: some View {
         NavigationView{
             VStack{
                 // Some Views
                 VStack{
                     if chatsManager.chats.isEmpty{
                      Text("you have no chats for now").frame(maxHeight:.infinity, alignment: .top)
                     }
                     else {
                         List() {
                             ForEach($chatsManager.chats, id: \.id){ $chat in
                                 NavigationLink (destination:
                                           MessageView(chat: chat)
                                           .onAppear{messagesManager.fetchMessages(from: chat.id)}
                                 ){ ChatRow(chat: $chat) }
                             }
                     }.listStyle(.plain)
                 }
             }
         }.navigationBarTitle("").navigationBarHidden(true)
             
        }.navigationViewStyle(.stack)
            
     }
}

A very weird thing is that, if I click into chats that are within the viewport when List is scrolled to the top, everything would work perfectly (no auto popping-back, the List is updated properly when manually popped back).

But if I scroll down the list when the top few ChatRows get scrolled away from the screen, I get popped back if I send any messages.

What I learned from searching the web was that List lazy loads elements, so that might be the cause of the problem. But I couldn't figure out a way to solve it.

Code to reproduce the problem

Just copy the following into one file and run.

Observe how things behave differently when you click into the first chat and click the button vs clicking into the last chat and click the button.

import SwiftUI
import Combine

struct DebugView: View {
    
    @StateObject var chatsManager = ChatsManager()
    
     var body: some View {
         NavigationView{
             VStack{
                 HStack {
                 Text("Chats")
                 }.padding()
                 VStack{
                         List() {
                             ForEach($chatsManager.chats, id: \.id){ $chat in
                                         NavigationLink (destination:
                                                   ChatDetailView(chat: chat)
                                 ){ DemoChatRow(chat: $chat) }}

                     }.listStyle(.plain)
             }
         }.navigationBarTitle("").navigationBarHidden(true)
             
        }.navigationViewStyle(.stack)
             .environmentObject(chatsManager)
     }
}


struct DemoChatRow: View {
    @Binding var chat: Chat
    var body: some View {
        VStack{
            Text(chat.name)
            Text(chat.lastMessageTimeStamp, style: .time)
        }
        .frame(height: 50)
    }
}


struct ChatDetailView: View {
    var chat: Chat
    @EnvironmentObject var chatsManager: ChatsManager
    var body: some View {
        Button(action: {
            chatsManager.updateDate(for: chat.id)
        } ) {
            Text("Click to update the current chat to now")
        }
    }
}



class ChatsManager: ObservableObject {
    @Published var chats = [
        Chat(id: "GroupChat 1", name: "GroupChat 1", lastMessageTimeStamp: Date()),
        Chat(id: "GroupChat 2", name: "GroupChat 2", lastMessageTimeStamp: Date()),
        Chat(id: "GroupChat 3", name: "GroupChat 3", lastMessageTimeStamp: Date()),
        Chat(id: "GroupChat 4", name: "GroupChat 4", lastMessageTimeStamp: Date()),
        Chat(id: "GroupChat 5", name: "GroupChat 5", lastMessageTimeStamp: Date()),
        Chat(id: "GroupChat 6", name: "GroupChat 6", lastMessageTimeStamp: Date()),
        Chat(id: "GroupChat 7", name: "GroupChat 7", lastMessageTimeStamp: Date()),
        Chat(id: "GroupChat 8", name: "GroupChat 8", lastMessageTimeStamp: Date()),
        Chat(id: "GroupChat 9", name: "GroupChat 9", lastMessageTimeStamp: Date()),
        Chat(id: "GroupChat 10", name: "GroupChat 10", lastMessageTimeStamp: Date()),
        Chat(id: "GroupChat2 5", name: "GroupChat2 5", lastMessageTimeStamp: Date()),
        Chat(id: "GroupChat2 6", name: "GroupChat2 6", lastMessageTimeStamp: Date()),
        Chat(id: "GroupChat2 7", name: "GroupChat2 7", lastMessageTimeStamp: Date()),
        Chat(id: "GroupChat2 8", name: "GroupChat2 8", lastMessageTimeStamp: Date()),
        Chat(id: "GroupChat2 9", name: "GroupChat2 9", lastMessageTimeStamp: Date()),
        Chat(id: "GroupChat2 10", name: "GroupChat2 10", lastMessageTimeStamp: Date())].sorted(by: {$0.lastMessageTimeStamp.compare($1.lastMessageTimeStamp) == .orderedDescending})
    
    func updateDate(for chatID: String) {
        if let idx = chats.firstIndex(where: {$0.id == chatID}) {
            self.chats[idx] = Chat(id: chatID, name: self.chats[idx].name, lastMessageTimeStamp: Date())
         }
        self.chats.sort(by: {$0.lastMessageTimeStamp.compare($1.lastMessageTimeStamp) == .orderedDescending})
    }
    
        
}




struct Chat: Identifiable, Hashable {
    var id: String
    var name: String
    var lastMessageTimeStamp: Date
    
    static func == (lhs: Chat, rhs: Chat) -> Bool {
        return lhs.id == rhs.id
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }

}


struct DebugView_Previews: PreviewProvider {
    static var previews: some View {
        DebugView().environmentObject(ChatsManager())
    }
}

Upvotes: 1

Views: 254

Answers (2)

jan
jan

Reputation: 41

Although the answer from @jnpdc works, you lose all NavigationLink features, like the selected state etc.

A simple fix for this is to wrap your List in a ScrollViewReader, and maintain the scroll position in the background. Simply observe the active object and when it changes, scroll to the object's id.

struct ContentView: View {

    @EnvironmentObject var chatsManager: ChatsManager

    var body: some View {
        NavigationView {
            ScrollViewReader { proxy in
                List {
                    // list content
                }
                .onChange(of: chatsManager.activeChat, perform: { _ in
                    proxy.scrollTo(loc.internalName, anchor: .center)
                })
            }
        }
    }
}

P.S. This issue is solved in any case with the iOS 16 APIs.

Upvotes: 0

jnpdx
jnpdx

Reputation: 52407

You're correct that this relates to the fact that the List lazily loads elements -- once the NavigationLink is off the screen, if the Chat element changes, the View ends up getting popped off the stack.

The standard solution to this is to add a hidden NavigationLink to your hierarchy that has an isActive property that controls whether or not it is active or not. Unfortunately, it requires a little more boilerplate code than the convenient list element binding that was introduced in Swift 5.5.

Your code might look something like this:

struct DebugView: View {
    
    @StateObject var chatsManager = ChatsManager()
    @State private var activeChat : String?
    
    private func activeChatBinding(id: String?) -> Binding<Bool> {
        .init {
            activeChat != nil && activeChat == id
        } set: { newValue in
            activeChat = newValue ? id : nil
        }
    }
    
    private func bindingForChat(id: String) -> Binding<Chat> {
        .init {
            chatsManager.chats.first { $0.id == id }!
        } set: { newValue in
            chatsManager.chats = chatsManager.chats.map { $0.id == id ? newValue : $0 }
        }
    }
    
    var body: some View {
        NavigationView{
            VStack{
                HStack {
                    Text("Chats")
                }.padding()
                VStack{
                    List() {
                        ForEach($chatsManager.chats, id: \.id) { $chat in
                            Button(action: {
                                activeChat = chat.id
                            }) {
                                DemoChatRow(chat: $chat)
                            }
                        }
                    }.listStyle(.plain)
                }
                .background {
                    NavigationLink("", isActive: activeChatBinding(id: activeChat)) {
                        if let activeChat = activeChat {
                            ChatDetailView(chat: bindingForChat(id: activeChat).wrappedValue)
                        } else {
                            EmptyView()
                        }
                    }
                }
            }.navigationBarTitle("").navigationBarHidden(true)
            
        }.navigationViewStyle(.stack)
            .environmentObject(chatsManager)
    }
}

Note: I kept the Binding that you have to DemoChatRow even though it looks like it's just a one-way connection here in the demo code, making the assumption that in your real code, you need two-way communication there

Upvotes: 1

Related Questions