From an architecture point of view, is it a good idea to use Published properties to control UI elements in a SwiftUI view hierarchy?
Consider the following problem,
I have a store as such
#MainActor final class SomeStore: ObservableObject {
#Published var shouldShowSuccessAlert: Boolean = false
#Published var someData: String = false
//Called by some networking library
public func networkRequestDidComplete(with data: String) {
self.someData = data
self.shouldShowSuccessAlert = false
}
}
//App
struct SomeApp: App {
#StateObject private var someStore: SomeStore = SomeStore()
var body: some Scene {
WindowGroup {
SomeView(shouldShowSuccessAlert: self.someStore.shouldShowSuccessAlert)
}
}
}
//View
struct SomeView: View {
#ObservedObject private var someStore: SomeStore
var body: some View {
if self.someStore.shouldShowSuccessAlert {
//some arbitrary alertView
AlertView(onComplete: {
self.someStore.shouldShowSuccessAlert = false
})
}
}
}
The only problem I can see is that changing this one Boolean will cause the entire view hierarchy to re-render. If SomeView has a deep view hierarchy, all of its descendants will be re-rendered. In React, for example, there is a concept of a PureComponent that you can use to prevent re-renders of views (called components in React) only if the properties they use change (by using reference equality checks) in order to improve performance. Is there something similar in SwiftUI?
Are there any other patterns that can be used like absorbing this boolean into the view's state and then controlled locally within the view?
Related
(You can skip this part and just look at the code.) I'm creating a complicated form. The form creates, say, a Post object, but I want to be able to create several Comment objects at the same time. So I have a Post form and a Comment form. In my Post form, I can fill out the title, description, etc., and I can add several Comment forms as I create more comments. Each form has an #ObservedObject viewModel of its own type. So I have one parent Post #ObservedObject viewModel, and another #ObservedObject viewModel for the array of the Comment objects which is also a #ObservedObject viewModel.
I hope that made some sense -- here is code to minimally reproduce the issue (unrelated to Posts/Comments). The objective is to make the count of the "Childish" viewModels at the parent level count up like how they count up for the "Child" view.
import Combine
import SwiftUI
final class ParentScreenViewModel: ObservableObject {
#Published var childScreenViewModel = ChildScreenViewModel()
}
struct ParentScreen: View {
#StateObject private var viewModel = ParentScreenViewModel()
var body: some View {
Form {
NavigationLink(destination: ChildScreen(viewModel: viewModel.childScreenViewModel)) {
Text("ChildishVMs")
Spacer()
Text("\(viewModel.childScreenViewModel.myViewModelArray.count)") // FIXME: this count is never updated
}
}
}
}
struct ParentScreen_Previews: PreviewProvider {
static var previews: some View {
ParentScreen()
}
}
// MARK: - ChildScreenViewModel
final class ChildScreenViewModel: ObservableObject {
#Published var myViewModelArray: [ChildishViewModel] = []
func appendAnObservedObject() {
objectWillChange.send() // FIXME: does not work
myViewModelArray.append(ChildishViewModel())
}
}
struct ChildScreen: View {
#ObservedObject private var viewModel: ChildScreenViewModel
init(viewModel: ChildScreenViewModel = ChildScreenViewModel()) {
self.viewModel = viewModel
}
var body: some View {
Button {
viewModel.appendAnObservedObject()
} label: {
Text("Append a ChildishVM (current num: \(viewModel.myViewModelArray.count))")
}
}
}
struct ChildScreen_Previews: PreviewProvider {
static var previews: some View {
ChildScreen()
}
}
final class ChildishViewModel: ObservableObject {
#Published var myProperty = "hey!"
}
ParentView:
ChildView:
I can't run this in previews either -- seems to need to be run in the simulator. There are lots of questions similar to this one but not quite like it (e.g. the common answer of manually subscribing to the child's changes using Combine does not work). Would using #EnvironmentObject help somehow? Thanks!
First get rid of the view model objects, we don't use those in SwiftUI. The View data struct is already the model for the actual views on screen e.g. UILabels, UITables etc. that SwiftUI updates for us. It takes advantage of value semantics to resolve consistency bugs you typically get with objects, see Choosing Between Structures and Classes. SwiftUI structs uses property wrappers like #State to make these super-fast structs have features like objects. If you use actual objects on top of the View structs then you are slowing down SwiftUI and re-introducing the consistency bugs that Swift and SwiftUI were designed to eliminate - which seems to me is exactly the problem you are facing. So it of course is not a good idea to use Combine to resolve consistency issues between objects it'll only make the problem worse.
So with that out of the way, you just need correct some mistakes in your design. Model types should be structs (these can be arrays or nested structs) and have a single model object to manage the life-cycle and side effects of the struct. You can have structs within structs and use bindings to pass them between your Views when you need write access, if you don't then its simply a let and SwiftUI will automatically call body whenever a View is init with a different let from last time.
Here is a basic example:
struct Post: Identifiable {
let id = UUID()
var text = ""
}
class Model: ObservableObject {
#Published var posts: [Post] = []
// func load
// func save
// func delete a post by ID
}
struct ModelController {
static let shared = ModelController()
let model = Model()
//static var preview: ModelController {
// ...
//}()
}
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(ModelController.shared.model)
}
}
}
struct ContentView: View {
#EnvironmentObject var model: Model
var body: some View {
ForEach($model.posts) { $post in
ContentView2(post: post)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environmentObject(ModelController.shared.preview)
}
}
struct ConventView2: View {
#Binding var post: Post
var body: some View {
TextField("Enter Text", text: $post.text)
}
}
For a more detail check out Apple's Fruta and Scrumdinger samples.
In SwiftUI, I am trying to create some binding between a parent ViewModel and a child ViewModel, here is a simplified example of my scenario:
The parent component:
class ParentViewModel : ObservableObject {
#Published var name = "John Doe"
func updateName() {
self.name = "Jonnie Deer"
}
}
struct ParentView: View {
#StateObject var viewModel = ParentViewModel()
var body: some View {
VStack {
Text(viewModel.name)
ChildView(name: $viewModel.name)
// tapping the button the text on parent view is updated but not in child view
Button("Update", action: viewModel.updateName)
}
}
}
The child component:
class ChildViewModel : ObservableObject {
var name: Binding<String>
var displayName: String {
get {
return "Hello " + name.wrappedValue
}
}
init(name: Binding<String>) {
self.name = name
}
}
struct ChildView: View {
#StateObject var viewModel: ChildViewModel
var body: some View {
Text(viewModel.displayName)
}
init(name: Binding<String>) {
_viewModel = StateObject(wrappedValue: ChildViewModel(name: name))
}
}
So, as stated in the comments, when I tap the button on the parent component the name is not getting updated in ChildView, as if the binding is lost...
Is there any other way to update view model with the updated value? say something like getDerivedStateFromProps in React (becuase when tapping the button the ChildView::init method is called with the new name.
Thanks.
Apple is very big on the concept of a Single Source of Truth(SSoT), and keeping it in mind will keep you from getting into the weeds in code like this. The problem you are having is that while you are using a Binding to instantiate the child view, you are turning around and using it as a #StateObject. When you do that, you are breaking the connection as #StateObject is supposed to sit at the top of the SSoT hierarchy. It designates your SSoT. Otherwise, you have two SSoTs, so you can only update one. The view model in ChildView should be an #ObservedObject so that it connects back up the hierarchy. Also, you can directly instantiate the ChildViewModel when you call ChildView. The initializer just serves to decouple things. Your views would look like this:
struct ParentView: View {
#StateObject var viewModel = ParentViewModel()
var body: some View {
VStack {
Text(viewModel.name)
// You can directly use the ChildViewModel to instantiate the ChildView
ChildView(viewModel: ChildViewModel(name: $viewModel.name))
Button("Update", action: viewModel.updateName)
}
}
}
struct ChildView: View {
// Make this an #ObservedObject not a #StateObject
#ObservedObject var viewModel: ChildViewModel
var body: some View {
Text(viewModel.displayName)
}
}
Neither view model is changed.
Get rid of the view model objects and do #State var name = “John” in ParentView and #Binding var name: String in ChildView. And pass $name into ChildView’s init which gives you write access as if ParentView was a view model object.
By using #State and #Binding you get the reference type semantics you want inside a value type which is the power of SwiftUI. If you just use objects you lose that benefit and have more work to do.
We usually only use ObservableObject for model data but we can also use it for loaders/fetchers where we want to tie some controller behaviour to the view lifecycle but for data transient to a view we always use #State and #Binding. You can extract related vars into their own struct and use mutating funcs for other logic and thus have a single #State struct used by body instead of multiple. This way it can still be testable like a view model object in UIKit would be.
I'm trying to simplify the ContentView within a project and I'm struggling to understand how to move #State based logic into its own file and have ContentView adapt to any changes. Currently I have dynamic views that display themselves based on #Binding actions which I'm passing the $binding down the view hierarchy to have buttons toggle the bool values.
Here's my current attempt. I'm not sure how in SwiftUI to change the view state of SheetPresenter from a nested view without passing the $binding all the way down the view stack. Ideally I'd like it to look like ContentView.overlay(sheetPresenter($isOpen, $present).
Also, I'm learning SwiftUI so if this isn't the best approach please provide guidance.
class SheetPresenter: ObservableObject {
#Published var present: Present = .none
#State var isOpen: Bool = false
enum Present {
case none, login, register
}
#ViewBuilder
func makeView(with presenter: Present) -> some View {
switch presenter {
case .none:
EmptyView()
case .login:
BottomSheetView(isOpen: $isOpen, maxHeight: UIConfig.Utils.screenHeight * 0.75) {
LoginScreen()
}
case .register:
BottomSheetView(isOpen: $isOpen, maxHeight: UIConfig.Utils.screenHeight * 0.75) {
RegisterScreen()
}
}
}
}
if you don't want to pass $binding all the way down the view you can create a StateObject variable in the top view and pass it with .environmentObject(). and access it from any view with EnvironmentObject
struct testApp: App {
#StateObject var s1: sViewModel = sViewModel()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(s1)
}
}
}
You are correct this is not the best approach, however it is a common mistake. In SwiftUI we actually use #State for transient data owned by the view. This means using a value type like a struct, not classes. This is explained at 4:18 in Data Essentials in SwiftUI from WWDC 2020.
EditorConfig can maintain invariants on its properties and be tested
independently. And because EditorConfig is a value type, any change to
a property of EditorConfig, like its progress, is visible as a change
to EditorConfig itself.
struct EditorConfig {
var isEditorPresented = false
var note = ""
var progress: Double = 0
mutating func present(initialProgress: Double) {
progress = initialProgress
note = ""
isEditorPresented = true
}
}
struct BookView: View {
#State private var editorConfig = EditorConfig()
func presentEditor() { editorConfig.present(…) }
var body: some View {
…
Button(action: presentEditor) { … }
…
}
}
Then you just use $editorConfig.isEditorPresented as the boolean binding in .sheet or .overlay.
Worth also taking a look at sheet(item:onDismiss:content:) which makes it much simpler to show an item because no boolean is required it uses an optional #State which you can set to nil to dismiss.
I have this rather common situation where a List is displaying some items from an #ObservedObject data store as NavigationLinks.
On selecting a NavigationLink a DetailView is presented. This view has a simple Toggle conected to a #Published var on its ViewModel class.
When the DetailView appears (onAppear:) its View Model sets the published var that controls the Toggle to true and also triggers an async request that will update the main data store, causing the List in the previous screen to update too.
The problem is that when this happens (the List is reloaded from an action triggered in the Detail View) multiple instances of the DetailViewModel seem to retained and the DetailView starts receiving events from the wrong Publishers.
In the first time the Detail screen is reached the behaviour is correct (as shown in the code below), the toggle is set to true and the store is updated, however, on navigating back to the List and then again to another DetailView the toggle is set to true on appearing but this time when the code that reloads the store executes, the toggle is set back to false.
My understanding is that when the List is reloaded and a new DetailView and ViewModel are created (as the destination of the NavigationLink) the initial value from the isOn variable that controls the Toggle (which is false) is somehow triggering an update to the Toggle of the currently displayed screen.
Am I missing something here?
import SwiftUI
import Combine
class Store: ObservableObject {
static var shared: Store = .init()
#Published var items = ["one", "two"]
private init() { }
}
struct ContentView: View {
#ObservedObject var store = Store.shared
var body: some View {
NavigationView {
List(store.items, id: \.self) { item in
NavigationLink(item, destination: ItemDetailView())
}
}
}
}
struct ItemDetailView: View {
#ObservedObject var viewModel = ItemDetailViewModel()
var body: some View {
VStack {
Toggle("A toggle", isOn: $viewModel.isOn)
Text(viewModel.title)
Spacer()
} .onAppear(perform: viewModel.onAppear)
}
}
class ItemDetailViewModel: ObservableObject {
#ObservedObject var store: Store = .shared
#Published var isOn = false
var title: String {
store.items[0]
}
func onAppear() {
isOn = true
asyncOperationThatUpdatesTheStoreData()
}
private func asyncOperationThatUpdatesTheStoreData() {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.store.items = ["three", "four"]
}
}
}
You're controlling lifetimes and objects in a way that's not a pattern in this UI framework. The VieModel is not going to magically republish a singleton value type; it's going to instantiate with that value and then mutate its state without ever checking in with the shared instance again, unless it is rebuilt.
class Store: ObservableObject {
static var shared: Store = .init()
struct ContentView: View {
#ObservedObject var store = Store.shared
struct ItemDetailView: View {
#ObservedObject var viewModel = ItemDetailViewModel()
class ViewModel: ObservableObject {
#Published var items: [String] = Store.shared.items
There are lots of potential viable patterns. For example:
Create class RootStore: ObservableObject and house it as an #StateObject in App. The lifetime of a StateObject is the lifetime of that view hierarchy's lifetime. You can expose it (a) directly to child views by .environmentObject(store) or (b) create a container object, such as Factory that vends ViewModels and pass that via the environment without exposing the store to views, or (c) do both a and b.
If you reference the store in another class, hold a weak reference weak var store: Store?. If you keep state in that RootStore on #Published, then you can subscribe directly to that publisher or to the RootStore's own ObjectWillChangePublisher. You could also use other Combine publishers like CurrentValueSubject to achieve the same as #Published.
For code examples, see
Setting a StateObject value from child view causes NavigationView to pop all views
SwiftUI #Published Property Being Updated In DetailView
I'm working on a SwiftUI project where I have a centralized app state architecture (similar to Redux). This app state class is of type ObservableObject and bound to the SwiftUI view classes directly using #EnvironmentObject.
The above works well for small apps. But as the view hierarchy becomes more and more complex, performance issues start to kick in. The reason is, that ObservableObject fires an update to each view that has subscribed even though the view may only need one single property.
My idea to solve this problem is to put a model view between the global app state and the view. The model view should have a subset of properties of the global app state, the ones used by a specific view. It should subscribe to the global app state and receive notification for every change. But the model view itself should only trigger an update to the view for a change of the subset of the global app state.
So I would have to bridge between two observable objects (the global app state and the model view). How can this be done?
Here's a sketch:
class AppState: ObservableObject {
#Published var propertyA: Int
#Published var propertyB: Int
}
class ModelView: ObservableObject {
#Published var propertyA: Int
}
struct DemoView: View {
#ObservedObject private var modelView: ModelView
var body: some View {
Text("Property A has value \($modelView.propertyA)")
}
}
Here is possible approach
class ModelView: ObservableObject {
#Published var propertyA: Int = 0
private var subscribers = Set<AnyCancellable>()
init(global: AppState) {
global.$propertyA
.receive(on: RunLoop.main)
.assign(to: \.propertyA, on: self)
.store(in: &subscribers)
}
}
The "bridge" you mentioned is often referred to as Derived State.
Here's an approach to implement a redux Connect component. It re-renders when the derived state changes...
public typealias StateSelector<State, SubState> = (State) -> SubState
public struct Connect<State, SubState: Equatable, Content: View>: View {
// MARK: Public
public var store: Store<State>
public var selector: StateSelector<State, SubState>
public var content: (SubState) -> Content
public init(
store: Store<State>,
selector: #escaping StateSelector<State, SubState>,
content: #escaping (SubState) -> Content)
{
self.store = store
self.selector = selector
self.content = content
}
public var body: some View {
Group {
(state ?? selector(store.state)).map(content)
}.onReceive(store.uniqueSubStatePublisher(selector)) { state in
self.state = state
}
}
// MARK: Private
#SwiftUI.State private var state: SubState?
}
To use it, you pass in the store and the "selector" which transform the application state to the derived state:
struct MyView: View {
var index: Int
// Define your derived state
struct MyDerivedState: Equatable {
var age: Int
var name: String
}
// Inject your store
#EnvironmentObject var store: AppStore
// Connect to the store
var body: some View {
Connect(store: store, selector: selector, content: body)
}
// Render something using the selected state
private func body(_ state: MyDerivedState) -> some View {
Text("Hello \(state.name)!")
}
// Setup a state selector
private func selector(_ state: AppState) -> MyDerivedState {
.init(age: state.age, name: state.names[index])
}
}
you can see the full implementation here