#Published Realm object not triggering view redrawing in SwiftUI - ios

In our app we use a UserService that is a ObservableObject and passed as environment. A synced realm is opened and the app user (a RealmObject) is obtained using flexible sync.
When updating the users properties, such as his username, the view does not get redrawn. This is against my expectations since UserService contains a #Published property where the user (that is being edited) is stored. On the database it clearly shows the property being edited, however the view does not get redrawn, only when restarting the app the new properties are shown.
What would be the best way to have a UserService objects taking care of all user related logic (storing a user object (reference to it), containing functions to update, ...) and use this to display the active data of this user throughout the views?
Here is a MRE (the login logic is left out to reduce complexity):
import SwiftUI
import RealmSwift
class UserService2: ObservableObject {
var realm: Realm
#Published var ownUser: User
var userNotificationToken: NotificationToken?
init(realm: Realm, ownUser: User) {
self.realm = realm
self.ownUser = ownUser
userNotificationToken = ownUser.observe { change in
print(change) // just to see that the user object is actually live and being updated...
}
}
func changeName(newName: String) {
do {
try self.realm.write {
self.ownUser.userName = newName
}
} catch {
print("error")
}
}
}
struct TestView: View {
#EnvironmentObject var userService: UserService2
var body: some View {
VStack {
Text(userService.ownUser.userName ?? "no name")
Button {
userService.changeName(newName: Date().description)
} label: {
Text("change name")
}
}
}
}
struct ContentView: View {
var realm: Realm? = nil
init() {
let flexSyncConfig = app.currentUser!.flexibleSyncConfiguration(initialSubscriptions: { subs in
subs.append(
QuerySubscription<User>(name: "ownUserQuery") {
$0._id == "123"
})
})
do {
let realm = try Realm(configuration: flexSyncConfig)
self.realm = realm
} catch {
print("sth went wrong")
}
}
var body: some View {
if let realm = realm, let ownUser = realm.objects(User.self).where( { $0._id == "123" } ).first {
TestView()
.environmentObject(UserService2(realm: realm, ownUser: ownUser))
} else {
ProgressView()
}
}
}
The User Object looks like this
import Foundation
import RealmSwift
class User: Object, ObjectKeyIdentifiable {
#Persisted(primaryKey: true) var _id = UUID().uuidString
#Persisted var userName: String?
convenience init(_id: String? = nil, userName: String? = nil) {
self.init()
if let _id = _id {
self._id = _id
}
self.userName = userName
}
}
P.S. I assume I could observe changes on the object using realm and somehow force a view refresh, but I would find it much more clean using the already existing way to watch for changes and redraw views when needed using #Published...
P.P.S. This user object is created on the server using a trigger when someone authenticates. However, I assume this is not really relevant to this problem.

The issue here is the usage of a reference type as "Source of truth".
ObservableObject and SwiftUI Views use Combine Publishers to know when to refresh.
The #Published value sends the .objectWillChange publisher of the ObservableObject only when its wrapped value "changes". "changes" in this context means it gets replaced. So value types are preferred here, because if you change one of the properties the whole object will be replaced. This does not happen for reference types.
Multiple possible solutions here:
change the User class to a struct (Probably not wanted here, because this object implements Realm)
use the .objectWillChange.send() method yourself before altering the user
instead of altering the ownUservar replace it with a new one that contains the new information.
func changeName(newName: String) {
do {
self.objectWillChange.send() //add this
try self.realm.write {
self.ownUser.userName = newName
}
} catch {
print("error")
}
}

Related

How to use SwiftUI Picker to update a Realm to-one relationship?

I have the following Realm schema where a Race is done on a Track:
final class Race: Object, ObjectKeyIdentifiable {
#Persisted(primaryKey: true) var _id: ObjectId
#Persisted var track: Track?
#Persisted var duration: Int = 45
}
final class Track: Object, ObjectKeyIdentifiable {
#Persisted(primaryKey: true) var _id: ObjectId
#Persisted var name: String = "Imola"
#Persisted var country: String = "🇮🇹"
#Persisted(originProperty: "tracks") var group: LinkingObjects<TrackGroup>
}
final class TrackGroup: Object, ObjectKeyIdentifiable {
#Persisted(primaryKey: true) var _id: ObjectId
#Persisted var tracks = RealmSwift.List<Track>()
}
In my ContentView I have an Add Button that opens a sheet (AddRaceView). The new Race is already created when the sheet appears. Now, I want to use a Picker for the Track selection for our newly created Race.
The following code does not update the Track for the editable Race, and I do not understand why:
struct AddRaceView: View {
#ObservedRealmObject var race: Race
#ObservedRealmObject var trackGroup: TrackGroup
var body: some View {
Form {
chooseTrackSection
raceDurationSection
}
}
#State private var trackPickerVisible = false
var chooseTrackSection: some View {
Section(header: Text("Track")) {
Button {
withAnimation(.easeIn) {
self.trackPickerVisible.toggle()
}
} label: {
HStack {
Text(race.track?.name ?? "")
Spacer()
Image(systemName: "arrow.turn.right.down")
}
}
if trackPickerVisible {
// HERE: Selection is not processed.
Picker(selection: $race.track, label: Text("Track")) {
ForEach(trackGroup.tracks) {
Text($0.name)
}
}
.pickerStyle(.wheel)
}
}
}
Updating other values in Race (like duration) does work! When Track is a String for example, I can use the Picker to make a selection. The problem must be connected to the fact that I'm trying to change a Realm object/relationship.
There are three things that need to be taken into account:
Picker needs to know where to find the values. The value can be specified manually by adding .tag(value) to the elements. While ForEach provides implicit tags for objects that conform to Identifiable, the type doesn't match in your case (needs to be Optional<Track> instead of Track).
The Picker compares all tag values to the selection to find out which item is currently selected. The comparison fails if the objects are not from the same Realm instance. Unfortunately, there isn't currently any way to specify a Realm for ObservedResults or an ObservedRealmObject.
Referencing objects from a frozen Realm doesn't work, so they (or their Realm) have to be thawed first.
Code:
// Fetch the Tracks from the Race's Realm by finding the TrackGroup by primaryKey
if let tracks = race.realm?.thaw().object(ofType: TrackGroup.self, forPrimaryKey: trackGroup._id)?.tracks {
Picker("Track", selection: $race.track) {
ForEach(tracks) { track in
Text(track.name)
.tag(Optional(track)) // Specify the value, making it optional to exactly match the expected type
}
}
}

Cannot get UserDefautls updated values in realtime

I'm new to swift and I cannot get the UserDefaults updated values in the same session. Only after the application restarts.
Here is what I mean in some code:
//This is where I have the userdefaults
#ObservedObject var userSettingsController = UserSettingsController
//These are the auxiliar vars I created to help me achieve the conditional renders I need. I'm looking to get rid of these and use the usersettings updated values
#State private var showMap = false
#State private var showTutorial = true
//Partial code of my view, where I'm using the variables
if(!self.userSettingsController.showActionSheet && self.showMap) {
showMapView()
.onTapGesture {
if (self.userSettingsController.showNextDeparturesTutorial && self.showTutorial {
self.showTutorial = false
self.userSettingsController.showNextDeparturesTutorial.toggle()
} else {
//code that has nothing to do with the question
No, here is my UserSettings and UserSettingsController classes:
UserSettings
import Foundation
struct UserSettings {
var settings: UserDefaults
init() {
self.settings = UserDefaults.standard
self.settings.register(
defaults: [
"userCity": "",
"showUserCityActionSheet": true,
"showNextDeparturesTutorial": true,
])
}
}
UserSettingsController
import Foundation
import Combine
import SwiftUI
class UserSettingsController: ObservableObject {
#Published var userSettings = UserSettings()
var userCity : String {
get {
return self.userSettings.settings.string(forKey: "userCity") ?? ""
}
set {
self.userSettings.settings.set(newValue, forKey: "userCity")
}
}
var showUserCityActionSheet: Bool {
get {
return self.userSettings.settings.bool(forKey: "showUserCityActionSheet")
}
set {
self.userSettings.settings.set(newValue, forKey: "showUserCityActionSheet")
}
}
var showNextDeparturesTutorial: Bool {
get {
return self.userSettings.settings.bool(forKey: "showNextDeparturesTutorial")
}
set {
self.userSettings.settings.set(newValue, forKey: "showNextDeparturesTutorial")
}
}
}
My question is, how can I get the updated values of the UserDefault values showNextDeparturesTutorial and showActionSheet in realtime? I've already tried to store them in other variables but to no avail.
Thanks.
EDIT
I accepted #Asperi answer because it was the most efficient one considering my project.
However, #pawello2222 answer would also solve my problem.
Thanks, all.
The possible solution is to activate ObservableObject publisher explicitly in every setter, like below
var userCity : String {
get {
return self.userSettings.settings.string(forKey: "userCity") ?? ""
}
set {
self.userSettings.settings.set(newValue, forKey: "userCity")
self.objectWillChange.send() // << this one !!
}
}
You can make your variables #Published. This way their changes will be detected by your View.
Every time you modify some of these variables it will be saved to UserDefaults as well. And when you init your UserSettingsController you have to load values from UserDefaults first:
class UserSettingsController: ObservableObject {
private let userSettings = UserDefaults.standard
#Published var showNextDeparturesTutorial: Bool {
didSet {
self.userSettings.set(showNextDeparturesTutorial, forKey: "showNextDeparturesTutorial")
}
}
init() {
_showNextDeparturesTutorial = .init(initialValue: userSettings.bool(forKey: "showNextDeparturesTutorial"))
}
}
struct ContentView: View {
#ObservedObject var userSettingsController = UserSettingsController()
...
}
The problem is not the UserDefaults, it is that SwiftUI is not detecting any changes in the data since your data resides in the UserDefaults database and SwiftUI can’t see the changes.
The #Published on the userSettings variable is no use here since it is an object, and in the current version of SwiftUI/Combine, it only detects changes of the object being referenced, instead of changes within the object. E.g. if you assigned a different defaults object to UserDefaults it would fire its ObjectWillChange publisher.
You would be better off storing your settings values in actual variables, and using didSet to persist them to the User Defaults Database on each change. You would then only need to load them on startup to get the data back from User Defaults.

How to delete data from SwiftUI List and Realm

The following code properly displays all of the 'Users' from Realm database in a SwiftUI List. My issue is deleting records when I swipe a row.
When I swipe a row and tap the delete button, I immediately get an uncaught exception error, the List does not update but I know the right item gets deleted from the Realm database since the next time I run the app the selected record doesn't show up.
Here is my code.
SwiftUI Code
import RealmSwift
struct ContentView: View {
#State private var allUsers: Results<User> = realm.objects(User.self)
var body: some View {
VStack{
Text("Second Tab")
List{
ForEach(allUsers, id:\.self) { user in
HStack{
Text(user.name)
Text("\(user.age)")
}
}.onDelete(perform: deleteRow)
}
}
}
private func deleteRow(with indexSet: IndexSet){
indexSet.forEach ({ index in
try! realm.write {
realm.delete(self.allUsers[index])
}
})
}
}
Realm Model
import RealmSwift
class User:Object{
#objc dynamic var name:String = ""
#objc dynamic var age:Int = 0
#objc dynamic var createdAt = NSDate()
#objc dynamic var userID = UUID().uuidString
override static func primaryKey() -> String? {
return "userID"
}
}
ERROR
Terminating app due to uncaught exception 'RLMException', reason: 'Index 4 is out of bounds (must be less than 4).'
Of course, the 4 changes depending on how many items are in the Realm database, in this case, I had 5 records when I swiped and tapped the delete button.
My expectation was that the List was going to update every time the allUsers #State variable changes, I know my issue is not fully understanding how binding works.
What am I doing wrong?
My expectation was that the List was going to update every time the
allUsers #State variable changes
It is correct, but state was not changed... The following should work
private func deleteRow(with indexSet: IndexSet){
indexSet.forEach ({ index in
try! realm.write {
realm.delete(self.allUsers[index])
}
})
self.allUsers = realm.objects(User.self) // << refetch !!
}
Note: the below is just assigning initial state value
#State private var allUsers: Results<User> = realm.objects(User.self)
This is a very common bug when working with Realm. It happens in ''traditional'' view controllers too.
The only solid solution I found, and it has considerably improved applications stability is to always use interface items instead of realm object.
Moreover, in real life, we need to do some processes, load avatar images from server, add some check boxes, selections or whatever, and we often don't need all properties of objects to be displayed. Somehow, it's a MVVM approach.
By the way, you can call realm.write before the loop, not sure, but I think it's good to minimise context switching.
Using this technique, either with SwiftUI or UIViewControllers, will solve RLM crashes for good.
struct ContentView: View {
struct UserItem: Hashable {
var id: String
var name: String
var age: Int
}
private var allUsers: Results<User> = realm.objects(User.self)
#State private var userItems: [UserItem] = []
func updateItems() {
userItems = allUsers.map {
UserItem(id: $0.userID, name: $0.name, age: $0.age)
}
}
var body: some View {
VStack {
Text("Second Tab")
List{
ForEach(userItems, id:\.self) { user in
HStack{
Text(user.name)
Text("\(user.age)")
}
}
.onDelete(perform: deleteRow)
}
}.onAppear() {
updateItems()
}
}
private func deleteRow(with indexSet: IndexSet){
try! realm.write {
indexSet.forEach {
realm.delete(self.allUsers[$0])
}
}
updateItems()
}
}

Swift struct error (Cannot use mutating member on immutable value: 'self' is immutable) using Firebase

I have a custom struct called myObjectHolder and it has an array of a custom struct (called myObject) called myArray.
I try to fetch the data I store in Firebase-Firesrtore and to append it to the array (using a function that converts the Firebase document to myObject).
I try to do it like this:
struct myObjectHolder {
var myArray = [myObject]()
private func fetchMyObjects() {
let db = Firestore.firestore().collection("myObjects").getDocuments { (querySnapshot, err) in
for document in querySnapshot!.documents {
self.myArray.append(self.myObjectFromDocument(document: document)) //Error
}
}
}
For some reason, when I try to append the new myObject to myArray, I receive this error message:
Cannot use mutating member on immutable value: 'self' is immutable
Does anybody know how can I resolve it? (I am using SwiftUI it matters)
Thank you!
What other have said before is correct: you cannot modify a struct, hence you should use a class - or make the struct modifiable. For more details, check out https://chris.eidhof.nl/post/structs-and-mutation-in-swift/
Since you've mentioned Firestore specifically, let me suggest you use an MVVM architecture:
Put your data access logic into a view model:
import Foundation
import FirebaseFirestore
import FirebaseFirestoreSwift
class ThingViewModel: ObservableObject {
#Published var things = [Thing]()
private var db = Firestore.firestore()
func fetchData() {
db.collection("things").addSnapshotListener { (querySnapshot, error) in
guard let documents = querySnapshot?.documents else {
print("No documents")
return
}
self.things = documents.map { queryDocumentSnapshot -> Thing in
return queryDocumentSnapshot.data(as: Thing.self)
}
}
}
}
In your view, instantiate the view model and observe the published things array:
struct ThingsListView: View {
#ObservedObject var viewModel = ThingsViewModel()
var body: some View {
NavigationView {
List(viewModel.things) { thing in
VStack(alignment: .leading) {
Text(thing.name)
.font(.headline)
}
}
.navigationBarTitle("Things")
.onAppear() {
self.viewModel.fetchData()
}
}
}
}
Also, it's good practice to use UpperCamelCase for Swift structs and classes: https://github.com/raywenderlich/swift-style-guide#naming
Use class instead of struct is one solution - and if you think struct suits your design then use
struct myObjectHolder {
var myArray = [myObject]()
private mutating func fetchMyObjects() {
let db = Firestore.firestore().collection("myObjects").getDocuments { (querySnapshot, err) in
for document in querySnapshot!.documents {
self.myArray.append(self.myObjectFromDocument(document: document)) //Error
}
}
}
I've just changed it from struct to class and it worked perfectly.

How to use Combine on a SwiftUI View

This question relates to this one: How to observe a TextField value with SwiftUI and Combine?
But what I am asking is a bit more general.
Here is my code:
struct MyPropertyStruct {
var text: String
}
class TestModel : ObservableObject {
#Published var myproperty = MyPropertyStruct(text: "initialText")
func saveTextToFile(text: String) {
print("this function saves text to file")
}
}
struct ContentView: View {
#ObservedObject var testModel = TestModel()
var body: some View {
TextField("", text: $testModel.myproperty.text)
}
}
Scenario: As the user types into the textfield, the saveTextToFile function should be called. Since this is saving to a file, it should be slowed-down/throttled.
So My question is:
Where is the proper place to put the combine operations in the code below.
What Combine code do I put to accomplish: (A) The string must not contain spaces. (B) The string must be 5 characters long. (C) The String must be debounced/slown down
I wanted to use the response here to be a general pattern of: How should we handle combine stuff in a SwiftUI app (not UIKit app).
You should do what you want in your ViewModel. Your view model is the TestModel class (which I suggest you rename it in TestViewModel). It's where you are supposed to put the logic between the model and the view. The ViewModel should prepare the model to be ready for the visualization. And that is the right place to put your combine logic (if it's related to the view, of course).
Now we can use your specific example to actually make an example. To be honest there are a couple of slight different solutions depending on what you really want to achieve. But for now I'll try to be as generic as possible and then you can tell me if the solution is fine or it needs some refinements:
struct MyPropertyStruct {
var text: String
}
class TestViewModel : ObservableObject {
#Published var myproperty = MyPropertyStruct(text: "initialText")
private var canc: AnyCancellable!
init() {
canc = $myproperty.debounce(for: 0.5, scheduler: DispatchQueue.main).sink { [unowned self] newText in
let strToSave = self.cleanText(text: newText.text)
if strToSave != newText.text {
//a cleaning has actually happened, so we must change our text to reflect the cleaning
self.myproperty.text = strToSave
}
self.saveTextToFile(text: strToSave)
}
}
deinit {
canc.cancel()
}
private func cleanText(text: String) -> String {
//remove all the spaces
let resultStr = String(text.unicodeScalars.filter {
$0 != " "
})
//take up to 5 characters
return String(resultStr.prefix(5))
}
private func saveTextToFile(text: String) {
print("text saved")
}
}
struct ContentView: View {
#ObservedObject var testModel = TestViewModel()
var body: some View {
TextField("", text: $testModel.myproperty.text)
}
}
You should attach your own subscriber to the TextField publisher and use the debounce publisher to delay the cleaning of the string and the calling to the saving method. According to the documentation:
debounce(for:scheduler:options:)
Use this operator when you want to wait for a pause in the delivery of
events from the upstream publisher. For example, call debounce on the
publisher from a text field to only receive elements when the user
pauses or stops typing. When they start typing again, the debounce
holds event delivery until the next pause.
When the user stops typing the debounce publisher waits for the specified time (in my example here above 0.5 secs) and then it calls its subscriber with the new value.
The solution above delays both the saving of the string and the TextField update. This means that users will see the original string (the one with spaces and maybe longer than 5 characters) for a while, before the update happens. And that's why, at the beginning of this answer, I said that there were a couple of different solutions depending on the needs. If, indeed, we want to delay just the saving of the string, but we want the users to be forbidden to input space characters or string longer that 5 characters, we can use two subscribers (I'll post just the code that changes, i.e. the TestViewModel class):
class TestViewModel : ObservableObject {
#Published var myproperty = MyPropertyStruct(text: "initialText")
private var saveCanc: AnyCancellable!
private var updateCanc: AnyCancellable!
init() {
saveCanc = $myproperty.debounce(for: 0.5, scheduler: DispatchQueue.main)
.map { [unowned self] in self.cleanText(text: $0.text) }
.sink { [unowned self] newText in
self.saveTextToFile(text: self.cleanText(text: newText))
}
updateCanc = $myproperty.sink { [unowned self] newText in
let strToSave = self.cleanText(text: newText.text)
if strToSave != newText.text {
//a cleaning has actually happened, so we must change our text to reflect the cleaning
DispatchQueue.main.async {
self.myproperty.text = strToSave
}
}
}
}
deinit {
saveCanc.cancel()
updateCanc.cancel()
}
private func cleanText(text: String) -> String {
//remove all the spaces
let resultStr = String(text.unicodeScalars.filter {
$0 != " "
})
//take up to 5 characters
return String(resultStr.prefix(5))
}
private func saveTextToFile(text: String) {
print("text saved: \(text)")
}
}

Resources