Reputation: 631
Apologies if the title is confusing. So, I'm implementing a chat app where there's a list of ChatRow
s that would, upon clicking, entering into a MessageView
. When a user sends a message, the list of ChatRow
s 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 ChatRow
s 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.
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
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
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