Unexpected List Behavior in SwiftUI on iOS 15 - swiftui

I am populating a List in a SwiftUI app by making a call to the ViewModel file and passing the current user in the onAppear(). This filters results pulled from a database to only show results related to the user. Everything below has worked well until I installed iOS 15 on device. The code works as expected in the iOS Simulator in iOS 14, iOS 15, and on device with iOS 14. The issue occurs with iOS 15 on device. The message printed is below.
ForEach<Array, String, NavigationLink<OfferRowView,
ModifiedContent<OfferDetailView,
_EnvironmentKeyWritingModifier>>>: the ID occurs multiple times within the collection, this will give undefined results!
The view does not load the items in the list. I've printed to test to see if they are duplicated and they are not.
View File
struct OfferHistoryView: View {
let db = Firestore.firestore()
#EnvironmentObject var authSession: AuthSession
#EnvironmentObject var offerHistoryViewModel: OfferHistoryViewModel
var body: some View {
return VStack {
List {
ForEach(self.offerHistoryViewModel.offerRowViewModels, id: \.id) { offerRowViewModel in
NavigationLink(destination: OfferDetailView(offerDetailViewModel: OfferDetailViewModel(offer: offerRowViewModel.offer, listing: offerRowViewModel.listing ?? testListing1))
.environmentObject(authSession)
) {
OfferRowView(offerRowViewModel: offerRowViewModel)
}
} // ForEach
} // List
.navigationBarTitle("Offer History")
} // VStack
.onAppear(perform: {
self.offerHistoryViewModel.startCombine(currentUserUid: self.authSession.currentUserUid)
})
} // View
}
View Model File
class OfferHistoryViewModel: ObservableObject {
var offerRepository: OfferRepository
// Published Properties
#Published var offerRowViewModels = [OfferRowViewModel]()
// Combine Cancellable
private var cancellables = Set<AnyCancellable>()
// Intitalizer
init(offerRepository: OfferRepository) {
self.offerRepository = offerRepository
}
// Starting Combine - Filter results for offers created by the current user only.
func startCombine(currentUserUid: String) {
offerRepository
.$offers
.receive(on: RunLoop.main)
.map { offers in
offers
.filter { offer in
(currentUserUid != "" ? offer.userId == currentUserUid : false)
}
.map { offer in
OfferRowViewModel(offer: offer, listingRepository: ListingRepository())
}
}
.assign(to: \.offerRowViewModels, on: self)
.store(in: &cancellables)
}
}
Any help would be greatly appreciated.

Related

SwiftUI Navigation popping back when modifying list binding property in a pushed view

When I update a binding property from an array in a pushed view 2+ layers down, the navigation pops back instantly after a change to the property.
Xcode 13.3 beta, iOS 15.
I created a simple demo and code is below.
Shopping Lists
List Edit
List section Edit
Updating the list title (one view deep) is fine, navigation stack stays same, and changes are published if I return. But when adjusting a section title (two deep) the navigation pops back as soon as I make a single change to the property.
I have a feeling I'm missing basic fundamentals here, and I have a feeling it must be related to the lists id? but I'm struggling to figure it out or work around it.
GIF
Code:
Models:
struct ShoppingList {
let id: String = UUID().uuidString
var title: String
var sections: [ShoppingListSection]
}
struct ShoppingListSection {
let id: String = UUID().uuidString
var title: String
}
View Model:
final class ShoppingListsViewModel: ObservableObject {
#Published var shoppingLists: [ShoppingList] = [
.init(
title: "Shopping List 01",
sections: [
.init(title: "Fresh food")
]
)
]
}
Content View:
struct ContentView: View {
var body: some View {
NavigationView {
ShoppingListsView()
}
}
}
ShoppingListsView
struct ShoppingListsView: View {
#StateObject private var viewModel = ShoppingListsViewModel()
var body: some View {
List($viewModel.shoppingLists, id: \.id) { $shoppingList in
NavigationLink(destination: ShoppingListEditView(shoppingList: $shoppingList)) {
Text(shoppingList.title)
}
}
.navigationBarTitle("Shopping Lists")
}
}
ShoppingListEditView
struct ShoppingListEditView: View {
#Binding var shoppingList: ShoppingList
var body: some View {
Form {
Section(header: Text("Title")) {
TextField("Title", text: $shoppingList.title)
}
Section(header: Text("Sections")) {
List($shoppingList.sections, id: \.id) { $section in
NavigationLink(destination: ShoppingListSectionEditView(section: $section)) {
Text(section.title)
}
}
}
}
.navigationBarTitle("Edit list")
}
}
ShoppingListSectionEditView
struct ShoppingListSectionEditView: View {
#Binding var section: ShoppingListSection
var body: some View {
Form {
Section(header: Text("Title")) {
TextField("title", text: $section.title)
}
}
.navigationBarTitle("Edit section")
}
}
try this, works for me:
struct ContentView: View {
var body: some View {
NavigationView {
ShoppingListsView()
}.navigationViewStyle(.stack) // <--- here
}
}
Try to make you object confirm to Identifiable and return value which unique and stable, for your case is ShoppingList.
Detail view seems will pop when object id changed.
The reason your stack is popping back to the root ShoppingListsView is that the change in the list is published and the root ShoppingListsView is registered to listen for updates to the #StateObject.
Therefore, any change to the list is listened to by ShoppingListsView, causing that view to be re-rendered and for all new views on the stack to be popped in order to render the root ShoppingListsView, which is listening for updates on the #StateObject.
The solution to this is to change the #StateObject to #EnvironmentObject
Please refactor your code to change ShoppingListsViewModel to use an #EnvironmentObject wrapper instead of a #StateObject wrapper
You may pass the environment object in to all your child views and also add a boolean #Published flag to track any updates to the data.
Then your ShoppingListView would look as below
struct ShoppingListsView: View {
#EnvironmentObject var viewModel = ShoppingListsViewModel()
var body: some View {
List($viewModel.shoppingLists, id: \.id) { $shoppingList in
NavigationLink(destination: ShoppingListEditView(shoppingList: $shoppingList)) {
Text(shoppingList.title)
}
}
.navigationBarTitle("Shopping Lists")
}
}
Don't forget to pass the viewModel in to all your child views.
That should fix your problem.

SwiftUI - How to pass data then initialise and edit data

I'm downloading data from Firebase and trying to edit it. It works, but with an issue. I am currently passing data to my EditViewModel with the .onAppear() method of my view. And reading the data from EditViewModel within my view.
class EditViewModel: ObservableObject {
#Published var title: String = ""
}
struct EditView: View {
#State var selected_item: ItemModel
#StateObject var editViewModel = EditViewModel()
var body: some View {
VStack {
TextField("Name of item", text: self.$editViewModel.title)
Divider()
}.onAppear {
DispatchQueue.main.async {
editViewModel.title = selected_item.title
}
}
}
}
I have given you the extremely short-hand version as it's much easier to follow.
However, I push to another view to select options from a list and pop back. As a result, everything is reset due to using the onAppear method. I have spent hours trying to use init() but I am struggling to get my application to even compile, getting errors in the process. I understand it's due to using the .onAppear method, but how can I use init() for this particular view/view-model?
I've search online but I've found the answers to not be useful, or different from what I wish to achieve.
Thank you.
You don't need to use State for input property - it is only for internal view usage. So as far as I understood your scenario, here is a possible solution:
struct EditView: View {
private var selected_item: ItemModel
#StateObject var editViewModel = EditViewModel()
init(selectedItem: ItemModel) {
selected_item = selectedItem
editViewModel.title = selectedItem.title
}
var body: some View {
VStack {
TextField("Name of item", text: self.$editViewModel.title)
Divider()
}.onAppear {
DispatchQueue.main.async {
editViewModel.title = selected_item.title
}
}
}
}

SwiftUI List is working but not the picker

I have this view. The list works fine by showing the data. The picker is not working. It does not display any data. Both use the same objects and functions. I do not know the reason for the picker not showing data. I want to use the picker. I placed the List just to try to determine the problem but I still don't know.
import SwiftUI
struct GameListPicker: View {
#ObservedObject var gameListViewModel = GameListViewModel()
#State private var selectedGameList = ""
var body: some View {
VStack{
List(gameListViewModel.gameList){gameList in
HStack {
Text(gameList.gameName)
}
}
.onAppear() {self.gameListViewModel.fetchData()}
Picker(selection: $selectedGameList, label: Text("")){
ForEach(gameListViewModel.gameList) { gameList in
Text(gameList.gameName)
}
}
.onAppear() {self.gameListViewModel.fetchData()}}
}
}
This is the GameListViewModel
import Foundation
import Firebase
class GameListViewModel: ObservableObject{
#Published var gameList = [GameListModel]()
let db = Firestore.firestore()
func fetchData() {
db.collection("GameData").addSnapshotListener {(querySnapshot, error) in
guard let documents = querySnapshot?.documents else {
print("No documents")
return
}
self.gameList = documents.map { queryDocumentSnapshot -> GameListModel in
let data = queryDocumentSnapshot.data()
let gameName = data["GameName"] as? String ?? ""
return GameListModel(id: gameName, gameName: gameName)
}
}
}
}
This is the GameListModel
import Foundation
struct GameListModel: Codable, Hashable,Identifiable {
var id: String
//var id: String = UUID().uuidString
var gameName: String
}
Thanks in advance for any help
Picker is not designed to be dynamic, so it is not refreshed when data updated, try to force-rebuild picker when data changed, like below
Picker(selection: $selectedGameList, label: Text("")){
ForEach(gameListViewModel.gameList) { gameList in
Text(gameList.gameName)
}
}.id(gameListViewModel.gameList) // << here !!

Why does a SwiftUI TextField inside a navigation bar only accept input one character at a time

I want to allow the user to filter data in a long list to more easily find matching titles.
I have placed a TextView inside my navigation bar:
.navigationBarTitle(Text("Library"))
.navigationBarItems(trailing: TextField("search", text: $modelData.searchString)
I have an observable object which responds to changes in the search string:
class DataModel: ObservableObject {
#Published var modelData: [PDFSummary]
#Published var searchString = "" {
didSet {
if searchString == "" {
modelData = Realm.studyHallRealm.objects(PDFSummary.self).sorted(by: { $0.name < $1.name })
} else {
modelData = Realm.studyHallRealm.objects(PDFSummary.self).sorted(by: { $0.name < $1.name }).filter({ $0.name.lowercased().contains(searchString.lowercased()) })
}
}
}
Everything works fine, except I have to tap on the field after entering each letter. For some reason the focus is taken away from the field after each letter is entered (unless I tap on a suggested autocorrect - the whole string is correctly added to the string at once)
The problem is in rebuilt NavigationView completely that result in dropped text field focus.
Here is working approach. Tested with Xcode 11.4 / iOS 13.4
The idea is to avoid rebuild NavigationView based on knowledge that SwiftUI engine updates only modified views, so using decomposition we make modifications local and transfer desired values only between subviews directly not affecting top NavigationView, as a result the last kept stand.
class QueryModel: ObservableObject {
#Published var query: String = ""
}
struct ContentView: View {
// No QueryModel environment object here -
// implicitly passed down. !!! MUST !!!
var body: some View {
NavigationView {
ResultsView()
.navigationBarTitle(Text("Library"))
.navigationBarItems(trailing: SearchItem())
}
}
}
struct ResultsView: View {
#EnvironmentObject var qm: QueryModel // << injected here from top
var body: some View {
VStack {
Text("Search: \(qm.query)") // receive query string
}
}
}
struct SearchItem: View {
#EnvironmentObject var qm: QueryModel // << injected here from top
#State private var query = "" // updates only local view
var body: some View {
let text = Binding(get: { self.query }, set: {
self.query = $0; self.qm.query = $0; // transfer query string
})
return TextField("search", text: text)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environmentObject(QueryModel())
}
}

SwiftUI - onReceive not being called for an ObservableObject property change when the property is updated after view is loaded

I have a view that displays a few photos that are loaded from an API in a scroll view. I want to defer fetching the images until the view is displayed. My view, simplified looks something like this:
struct DetailView : View {
#ObservedObject var viewModel: DetailViewModel
init(viewModel: DetailViewModel) {
self.viewModel = viewModel
}
var body: some View {
GeometryReader { geometry in
ZStack {
Color("peachLight").edgesIgnoringSafeArea(.all)
if self.viewModel.errorMessage != nil {
ErrorView(error: self.viewModel.errorMessage!)
} else if self.viewModel.imageUrls.count == 0 {
VStack {
Text("Loading").foregroundColor(Color("blueDark"))
Text("\(self.viewModel.imageUrls.count)").foregroundColor(Color("blueDark"))
}
} else {
VStack {
UIScrollViewWrapper {
HStack {
ForEach(self.viewModel.imageUrls, id: \.self) { imageUrl in
LoadableImage(url: imageUrl)
.scaledToFill()
}.frame(width: geometry.size.width, height: self.scrollViewHeight)
}.edgesIgnoringSafeArea(.all)
}.frame(width: geometry.size.width, height: self.scrollViewHeight)
Spacer()
}
}
}
}.onAppear(perform: { self.viewModel.fetchDetails() })
.onReceive(viewModel.objectWillChange, perform: {
print("Received new value from view model")
print("\(self.viewModel.imageUrls)")
})
}
}
my view model looks like this:
import Foundation
import Combine
class DetailViewModel : ObservableObject {
#Published var imageUrls: [String] = []
#Published var errorMessage : String?
private var fetcher: Fetchable
private var resourceId : String
init(fetcher: Fetchable, resource: Resource) {
self.resourceId = resource.id
// self.fetchDetails() <-- uncommenting this line results in onReceive being called + a view update
}
// this is a stubbed version of my data fetch that performs the same way as my actual
// data call in regards to ObservableObject updates
// MARK - Data Fetching Stub
func fetchDetails() {
if let path = Bundle.main.path(forResource: "detail", ofType: "json") {
do {
let data = try Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe)
let parsedData = try JSONDecoder().decode(DetailResponse.self, from: data)
self.imageUrls = parsedData.photos // <-- this doesn't trigger a change, and even manually calling self.objectWillChange.send() here doesn't trigger onReceive/view update
print("setting image urls to \(parsedData.photos)")
} catch {
print("error decoding")
}
}
}
}
If I fetch my data within the init method of my view model, the onReceive block on my view IS called when the #Published imageUrls property is set. However, when I remove the fetch from the init method and call from the view using:
.onAppear(perform: { self.viewModel.fetchDetails() })
the onReceive for viewModel.objectWillChange is NOT called, even though the data is updated. I don't know why this is the case and would really appreciate any help here.
Use instead
.onReceive(viewModel.$imageUrls, perform: { newUrls in
print("Received new value from view model")
print("\(newUrls)")
})
I tested this as I found the same issue, and it seems like only value types can be used with onReceive
use enums, strings, etc.
it doesn't work with reference types because I guess technically a reference type doesn't change reference location and simply points elsewhere when changed? idk haha but ya
as a solution, you can set a viewModel #published property which is like a state enum, make changes to that when you have new data, and then on receive can access that...hope that makes sense, let me know if not