I am working on a niche workout app. The flow is similar to the built in app, but a little more complicated.
I have the following code at the top level:
#main
struct WatchApp: App {
#WKExtensionDelegateAdaptor(ExtensionDelegate.self) var delegate
#StateObject var coordinator = SessionCoordinator()
#StateObject var permissions = PermissionsViewModel()
var body: some Scene {
WindowGroup {
NavigationView {
switch coordinator.workout.state {
// ✅ Works on load
// ❌ Doesn't reload when user changes state back to `notStarted`
case .notStarted:
StartView()
.sheet(isPresented: $permissions.showPermissionsPrompt, content: {
PermissionsView(permissions: permissions)
})
// ✅ Works
case .countingDown:
CountdownView()
// ✅ Works
case .paddling, .paused:
WorkoutOrAlertView()
// ✅ Works
case .saving, .ended:
SummaryView()
}
}
.environmentObject(coordinator)
}
}
}
in the SummaryView there is a button which changes the state to notStarted
coordinator.workout.state = .notStarted
This does NOT reload the start view as expected from the case statement.
The coordinator and the workout are ObservableObjects:
class SessionCoordinator: ObservableObject {
#Published public var workout: WorkoutSession {
didSet {
DDLogVerbose("😵💫 workout changed")
}
}
class WorkoutSession: NSObject, ObservableObject {
....
#Published var state = WorkoutSessionState.notStarted {
didSet {
DDLogVerbose("Workout state changed from: \(oldValue) to: \(state)")
}
}
So in summary, everything works as you would expect:
✅ Start a workout
✅ Show countdown if required
✅ Run workout
✅ End workout and get a summary
❌ Changing state to .notStarted does not reload StartView()
So my questions are:
why doesn't this work?
how can I debug it further?
is this architecture with the top level switch the best way or should a different approach be used?
If you have a #Published property that holds a reference type, then this is only going to send objectWillChange when you set a new instance of the reference type. It doesn't matter if the property you changed is published - nothing in your code is listening to changes on that object.
If you genuinely want your coordinator to publish every time a property of the session changes, then you should manually observe and re-publish object will change. However, that is likely to lead to over-publishing and excessive re-rendering, particularly since this is a top-level object.
A better solution is to create a container view, which observes the session:
struct WorkoutView: View {
#ObservedObject var workoutSession: WorkoutSession
var body: some View {
switch workoutSession.state {
//... all your cases here
}
}
}
Then your original view would be:
NavigationView {
WorkoutView(workoutSession: coordinator.workout)
}
This means you'll get a new WorkoutView whenever the instance of the workout changes, and that workout view will monitor the state.
You'll need to either inject the permissions into the environment or pass that as an observed object as well so it can be picked up.
Oh - and using switch statements in a view like this? Fantastic. The best way!
Related
Context
I have two different realms that a user can switch between at any point they decide; in-memory or on-disk.
I store this Realm configuration on an observable object which publishes the change when required:
class RealmServiceConfig: ObservableObject {
static var config = CurrentValueSubject<Realm.Configuration?, Never>(nil)
}
I have a class that stores realm filters/sorts as #Published variables that a user can change at any point.
class Store: ObservableObject
{
static var shared = Store()
#Published var realmFilter: NSPredicate? = nil
#Published var realmSortKeyPath: String = "fieldOnMyRealmModel"
#Published var realmSortKeyPathAscending: Bool = false
}
I have two views:
ListView - This shows the live results of Objects in the current Realm.
As the filter/sort changes, the filteredObjects is recalculated thanks to the #Published values, but, it still points to the same config.
//import SwiftUI
//import Combine
//import RealmSwift
struct ListView: View
{
#ObservedObject var store = Store.shared
#ObservedResults(MyRealmModel.self, configuration: RealmServiceConfig.config.value) var unfilteredObjects
var body: some View {
let filteredObjects = unfilteredObjects
.filter(store.realmFilter ?? NSPredicate(value: true))
.sorted(by: [SortDescriptor(keyPath: "\(store.realmSortKeyPath)", ascending: store.realmSortKeyPathAscending)])
return VStack(spacing: 0) {
ScrollView {
VStack {
ForEach(filteredObjects) { object in
Text(object.name)
}
}
}
}
}
}
SettingsView - The user can change their Realm configuration between in-memory and on-disk here which updates the aforementioned RealmServiceConfig.config value
When navigating to SettingsView, the ListView is moved to the background, it is not removed from the view hierarchy.
Question
When a user changes the realm and therefore updates the RealmServiceConfig.config value, is it possible to force reload #ObservedResults with the latest configuration?
Based on the current setup, even though my configuration parameter points to the published value, this is never applied.
Workaround
I have implemented this on a model class that stores the Results<Object> in a #Published variable which allows me to react and update the query based on the config changes and pass the results through to the views.
It would just be cleaner to have this all contained in the SwiftUI view where it's needed.
Maybe this is not available yet/works against how #ObservedResults is meant to be used?
I have an app where there are multiple screens with textfields to create some new object. When the user selects "Create" on the last screen, an API call is performed which creates the new object.
From there I want to push the detail page of the newly created object, and (when the view is no longer visible) remove all the screens with textfields (as that is no longer relevant, and would only cause confusion. Luckily there is only one screen that should remain before the detailpage.
In UIKit, this would be performed by doing a push on the navigationController, and then editing the viewControllers array of the navigationController in the viewDidLoad of the new screen.
If I am correct, there is no way to edit the views in a SwiftUI NavigationView, so how can I perform this action in SwiftUI?
I solved it by adding an id to the NavigationView, and then setting this to another id in the viewmodel when it should reset.
Like this:
struct MyView: View {
#StateObject var viewModel = ViewModel()
var body: some View {
NavigationView {
// ...
}
.id(viewModel.id)
}
}
In the viewmodel:
class ViewModel: ObservableObject {
#Published var id = UUID().uuidString
func reset() {
id = UUID().uuidString
}
}
This resets the NavigationView as Swift thinks it's a different view because the id changed.
I created an ObservableObject that gathers data and a view that depends on this object. More specifically some parts of the UI depend on one property of it and other parts depend on other properties.
The way ObservableObject works is that if any of its Published property gets updated (event if it does not change) it sends an objectWillChange notification that triggers updates for subscribed views.
However, I did expect that only the parts of the views that depends on an ObservableObject property that actually changed would be updated, not the entire view because body computations are expensive.
Unfortunately this it not the behavior I observed during my experiments. For instance in the following code the List view depends only on the color dynamic property of the ObservedObject "preferences" and the TextField updates only the text property of the observed object. I observed with the debugPrint's that when I type text the List and its rows gets updated each time even if it does not depend on text.
struct ContentView: View {
#State private var list = ["foo", "bar", "baz"]
#StateObject private var preferences = Preferences()
var body: some View {
VStack {
List(list, id: \.self) { element in
Text(element)
.foregroundColor(preferences.color)
.debugPrint("Row view updated")
}
Spacer()
HStack {
TextField("Text", text: $preferences.text)
.debugPrint("Text field view updated")
Button("Toogle Color", action: toggleColor)
.debugPrint("Button view updated")
}
}
.padding()
.debugPrint("Content view updated")
}
func toggleColor() {
preferences.color = [.blue, .green, .orange, .red].randomElement()!
}
}
final class Preferences: ObservableObject {
#Published var color: Color = .blue
#Published var text: String = ""
}
extension View {
func debugPrint(_ elements: Any...) -> Self {
#if DEBUG
print(elements)
return self
#else
return self
#endif
}
}
Is this the correct and intended behavior? How the optimal behavior I described above could be obtained, i.e. not calling body when the ObservedObject changes but only the components that depends on specific properties?
I observed in bigger SwiftUI projects that this behavior can greatly slow down the application and is not easily debuggable because unique state is often enforced through heavy ObservedObjects (or StateObject) injected at the root view and used in many places. I Observed this behavior even with small lists of components with heavy UI layout.
Note 1: I was bugged by the StateObject definition which suggests that only the views that depend on those properties are updated (and not when the StateObject changes).
SwiftUI creates a new instance of the object only once for each
instance of the structure that declares the object. When published
properties of the observable object change, SwiftUI updates the parts
of any view that depend on those properties [...]
Note 2: The issue totally disappear if I define the List in its own component, the rendering is fast:
struct ListView: View {
let list: [String] // Also works with a #Binding
let color: Color // Also works if it would be `preferences`
var body: some View {
List {
ForEach(list, id: \.self) { element in
Row(text: element, color: color)
}
}
}
}
Why in this case the view updates are fast?
I am trying out this quick start for SwiftUI and Combine in order to try and understand how to connect my Realm database to Combine.
The example observes a RealmSwift.List and keeps a table populated with its data. This is is a linked list to a child class. I'm wondering how to observe a Results collection so I can keep track of any changes to an entire Realm class.
For example, let's say I have a Workspace class:
class Workspace: Object, ObjectKeyIdentifiable{
#objc dynamic var id = UUID().uuidString
#objc dynamic var name = ""
#objc dynamic var archived = false
}
In the state object, I can set up a Results<Workspace> variable like this:
class AppState: ObservableObject {
#Published var workspaces: Results<Workspace>?
var cancellables = Set<AnyCancellable>()
init(){
let realmPublisher = PassthroughSubject<Realm, Error>()
realmPublisher
.sink(receiveCompletion: { _ in }, receiveValue: { realm in
//Get the Results
self.workspaces = realm.objects(Workspace.self)
})
.store(in: &cancellables)
realmPublisher.send(try! Realm())
return
}
}
But when it comes time to observe the object, I can't because Results isn't an object (I assume).
struct ContentView: App {
#ObservedObject var state = AppState()
var view: some View {
ItemsView(workspaces: state.workspaces!)
}
var body: some Scene {
WindowGroup {
view.environmentObject(state)
}
}
}
struct ItemsView: View {
#ObservedObject var workspaces: Results<Workspace> //<!-- Error
var body: some View {
//...
}
}
Xcode gives a syntax error on the workspaces property:
Property type 'Results' does not match that of the 'wrappedValue' property of its wrapper type 'ObservedObject'
Is it possible to observe a set of Results just like we can have a notification listener on a collection of Results?
Technically, you could hook up a sink to state.workspaces (state.$workspaces.sink()), but in this case, I think you're overcomplicating the problem.
You already have an #ObservableObject in your ContentView (AppState) that is managing the results for you. So, change ItemsView to just take this as a parameter:
var workspaces: Results<Workspace>?
It doesn't need to be an #ObservedObject -- either way, whether it's getting observed in that view or it's parent view, it's going to get re-rendered. It does have to be optional here, since it's an optional value on your AppState, unless you want to keep passing it with the force unwrap (!), but that's generally a bad idea, since it'll crash if it ever is in fact nil.
Also, above, in your Realm code, make sure it's matching the tutorial that you were following. For example, you have Publisher.sink which should really be realmPublisher.sink
You are correct, Results is a struct, and therefore cannot be covered by #StateObject or #ObservedObject. Your workaround is suitable for now.
Once https://github.com/realm/realm-cocoa/pull/7045 is released, you will be able to use one of the new Realm property wrappers to embed your Results into the view directly. At the time of this posting, that would be #FetchRealmResults, but that is subject to change.
In a SwiftUI app, I have an ObservableObject that keeps track of user settings:
class UserSettings: ObservableObject {
#Published var setting: String?
}
I have a view model to control the state for my view:
class TestViewModel: ObservableObject {
#Published var state: String = ""
}
And I have my view. When the user setting changes, I want to get the view model to update the state of the view:
struct HomeView: View {
#EnvironmentObject var userSettings: UserSettings
#ObservedObject var viewModel = TestViewModel()
var body: some View {
Text(viewModel.state)
.onReceive(userSettings.$setting) { setting in
self.viewModel.state = setting
}
}
}
When the UserSettings.setting is changed in another view it causes onReceive on my view to get called in an infinite loop, and I don't understand why. I saw this question, and that loop makes sense to me because the state of the ObservableObject being observed is being changed on observation.
However, in my case I'm not changing the observed object (environment object) state. I'm observing the environment object and changing the view model state which redraws the view.
Is the view redrawing what's causing the issue here? Does onReceive get called everytime the view is redrawn?
Is there a better way of accomplishing what I'm trying to do?
EDIT: this is a greatly simplified version of my problem. In my app, the view model takes care of executing a network request based on the user's settings and updating the view's state such as displaying an error message or loading indicator.
Whenever you have an onReceive with an #ObservedObject that sets another (or the same) published value of the #ObservedObject you risk creating an infinite loop if those published attributes are being displayed somehow.
Make your onReceive verify that the received value is actually updating a value, and not merely setting the same value, otherwise it will be setting/redrawing infinitely. In this case, e.g.,:
.onReceive(userSettings.$setting) { setting in
if setting != self.viewModel.state {
self.viewModel.state = setting
}
}
From described scenario I don't see the reason to duplicate setting in view model. You can show the value directly from userSettings, as in
struct HomeView: View {
#EnvironmentObject var userSettings: UserSettings
#ObservedObject var viewModel = TestViewModel()
var body: some View {
Text(userSettings.setting)
}
}
You might be able to prevent infinite re-rendering of the view body by switching your #ObservedObject to #StateObject.