This question already has answers here:
How to define a protocol as a type for a #ObservedObject property?
(6 answers)
Closed 7 months ago.
I created a protocol JSONService that conforms to the ObservableObject.
protocol JSONService {
var screenModel: ScreenModel? { get set }
func load(_ resourceName: String) async throws
}
Next, I created the LocalService which conforms to the ObservableObject as shown below:
class LocalService: JSONService, ObservableObject {
#Published var screenModel: ScreenModel?
func load(_ resourceName: String) async throws {
// some code
}
}
Now, when I created a property in my View (SwiftUI) then I get an error:
struct ContentView: View {
#StateObject private var jsonService: any JSONService
Type 'any JSONService' cannot conform to 'ObservableObject'
How can I use #StateObject with protocols?
You just add the conformance to the protocol.
protocol JSONService: ObservableObject, AnyObject {
var screenModel: ScreenModel? { get set }
func load(_ resourceName: String) async throws
}
Then you can add the generic to the View
struct GenericOOView<JS: JSONService>: View {
#StateObject private var jsonService: JS
init(jsonService: JS){
_jsonService = StateObject(wrappedValue: jsonService)
}
var body: some View {
Text("Hello, World!")
}
}
Related
See my code below. My problem is if i change accessToken via UserService.shared.currentStore.accessToken = xxx, SwiftUI doesn't publish, and there's no update on StoreBanner at all.
//SceneDelegate
let contentView = ContentView()
.environmentObject(UserService.shared)
//Define
class UserService: ObservableObject {
#Published var currentStore = Store.defaultValues()
static let shared = UserService()
}
struct Store: Codable, Hashable {
var storeName: String = ""
var accessToken: String = ""
}
//Use it
struct StoreBanner: View {
var body: some View {
Group {
if UserService.shared.currentStore.accessToken.isNotEmpty {
ShopifyLinkedBanner()
} else {
ShopifyLinkBanner()
}
}
}
}
You're trying to use UserService inside StoreBanner without using a property wrapper to tell the view to respond to updates. Without the #ObservedObject property wrapper, the View doesn't have a mechanism to know that any of the #Published properties have been updated.
Try this:
struct StoreBanner: View {
#ObservedObject private var userService = UserService.shared
var body: some View {
Group {
if userService.currentStore.accessToken.isNotEmpty {
ShopifyLinkedBanner()
} else {
ShopifyLinkBanner()
}
}
}
}
This should work assuming you set accessToken somewhere in your code on the same instance of UserService.
I am working with SwiftUI and am using MVVM with my VM acting as my EnvironmentObjects.
I first create a AuthSession environment object which has a string for currentUserId stored.
I then create another environment object for Offers that is trying to include the AuthSession environment object so I can can filter results pulled from a database with Combine. When I add an #EnvironmentObject property to the Offer view model I get an error stating that AuthSession is not passed. This makes sense since it's not a view.
My question is, is it best to sort the results in the view or is there a way to add an EnvironmentObject to another EnvironmentObject? I know there is an answer here, but this model answer is not using VM as the EOs.
App File
#main
struct The_ExchangeApp: App {
// #EnvironmentObjects
#StateObject private var authListener = AuthSession()
#StateObject private var offerHistoryViewModel = OfferHistoryViewModel(offerRepository: OfferRepository())
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(authListener)
.environmentObject(offerHistoryViewModel)
}
}
}
AuthSession.swift
class AuthSession: ObservableObject {
#Published var currentUser: User?
#Published var loggedIn = false
#Published var currentUserUid = ""
// Intitalizer
init() {
self.getCurrentUserUid()
}
}
OfferHistoryViewModel.swift - The error is called just after the .filter in startCombine().
class OfferHistoryViewModel: ObservableObject {
// MARK: ++++++++++++++++++++++++++++++++++++++ Properties ++++++++++++++++++++++++++++++++++++++
// Access to AuthSession for filtering offer made by the current user.
#EnvironmentObject var authSession: AuthSession
// Properties
var offerRepository: OfferRepository
// Published Properties
#Published var offerRowViewModels = [OfferRowViewModel]()
// Combine Cancellable
private var cancellables = Set<AnyCancellable>()
// MARK: ++++++++++++++++++++++++++++++++++++++ Methods ++++++++++++++++++++++++++++++++++++++
// Intitalizer
init(offerRepository: OfferRepository) {
self.offerRepository = offerRepository
self.startCombine()
}
// Starting Combine - Filter results for offers created by the current user only.
func startCombine() {
offerRepository
.$offers
.receive(on: RunLoop.main)
.map { offers in
offers
.filter { offer in
(self.authSession.currentUserUid != "" ? offer.userId == self.authSession.currentUserUid : false) // ERROR IS CALLED HERE
}
.map { offer in
OfferRowViewModel(offer: offer)
}
}
.assign(to: \.offerRowViewModels, on: self)
.store(in: &cancellables)
}
}
Error
Thread 1: Fatal error: No ObservableObject of type AuthSession found. A View.environmentObject(_:) for AuthSession may be missing as an ancestor of this view.
I solved this by passing currentUserUid from AuthSession from my view to the view model. The view model changes to the following.
class OfferHistoryViewModel: ObservableObject {
// MARK: ++++++++++++++++++++++++++++++++++++++ Properties ++++++++++++++++++++++++++++++++++++++
var offerRepository: OfferRepository
// Published Properties
#Published var offerRowViewModels = [OfferRowViewModel]()
// Combine Cancellable
private var cancellables = Set<AnyCancellable>()
// MARK: ++++++++++++++++++++++++++++++++++++++ Methods ++++++++++++++++++++++++++++++++++++++
// 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)
}
}
.assign(to: \.offerRowViewModels, on: self)
.store(in: &cancellables)
}
}
Then in the view I pass the currentUserUid in onAppear.
struct OfferHistoryView: View {
// MARK: ++++++++++++++++++++++++++++++++++++++ Properties ++++++++++++++++++++++++++++++++++++++
#EnvironmentObject var authSession: AuthSession
#EnvironmentObject var offerHistoryViewModel: OfferHistoryViewModel
// MARK: ++++++++++++++++++++++++++++++++++++++ View ++++++++++++++++++++++++++++++++++++++
var body: some View {
// BuildView
} // View
.onAppear(perform: {
self.offerHistoryViewModel.startCombine(currentUserUid: self.authSession.currentUserUid)
})
}
}
This works well for me and I hope it helps someone else.
If you want to pass an #EnvironmentObject to a View presented as a sheet, you'll notice that this sheet gets recreated every single time any #Published property in the #EnvironmentObject is updated.
Minimum example that demonstrates the problem:
import SwiftUI
class Store: ObservableObject {
#Published var name = "Kevin"
#Published var age = 38
}
struct ContentView: View {
#EnvironmentObject private var store: Store
#State private var showProfile = false
var body: some View {
VStack {
Text("Hello, \(store.name), you're \(store.age) years old")
Button("Edit profile") {
self.showProfile = true
}
}
.sheet(isPresented: $showProfile) {
ProfileView()
.environmentObject(self.store)
}
}
}
struct ProfileView: View {
#EnvironmentObject private var store: Store
#ObservedObject private var viewModel = ViewModel()
var body: some View {
VStack {
Text("Hello, \(store.name), you're \(store.age) years old")
Button("Change age") {
self.store.age += 1
}
}
}
}
class ViewModel: ObservableObject {
init() {
print("HERE")
}
}
If you run this code, you'll notice that "HERE" gets logged every single time you press the button in the sheet, meaning that the ViewModel got recreated. This can be a huge problem as you might imagine, I expect the ViewModel to not get recreated but retain its state. It's causing huge problems in my app.
As far as I am aware, what I am doing in my code is the normal way to pass the #EnvironmentObject to a sheet. Is there a way to prevent the ProfileView from getting recreated any time something in the Store changes?
This is because the view gets recreated when a state variable changes. And in your view you instantiate the viewModel as ViewModel().
Try passing the observed object as a param and it won't hit "HERE" anymore:
struct ContentView: View {
#EnvironmentObject private var store: Store
#State private var showProfile = false
#ObservedObject private var viewModel = ViewModel()
var body: some View {
VStack {
Text("Hello, \(store.name), you're \(store.age) years old")
Button("Edit profile") {
self.showProfile = true
}
}
.sheet(isPresented: $showProfile) {
ProfileView(viewModel: self.viewModel)
.environmentObject(self.store)
}
}
}
struct ProfileView: View {
#EnvironmentObject private var store: Store
#ObservedObject var viewModel: ViewModel
var body: some View {
VStack {
Text("Hello, \(store.name), you're \(store.age) years old")
Button("Change age") {
self.store.age += 1
}
}
}
}
If your Deployment Target is iOS14 and above, have you tried replacing #ObservedObject with #StateObject in ProfileView? This will help in keeping the state, it will only be created once, even if the Model View instantiaton happens inside the View's body.
A very nice article about this issue can be found her.
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
I have multiple classes that I want to use with a budget picker view. They all have this budgetable protocol defined.
import SwiftUI
struct BudgetPickerView: View {
#EnvironmentObject var userData: UserData
#State var budgetable: Budgetable
...
}
import Foundation
protocol Budgetable
{
var budgetId: String { get set }
}
For example this Allocation class
import Foundation
import Combine
class Allocation: ObservableObject, Identifiable, Budgetable {
let objectWillChange = ObservableObjectPublisher()
let id: String?
var amount: String { willSet { self.objectWillChange.send() } }
var budgetId: String { willSet { self.objectWillChange.send() } }
init(id: String? = nil, amount: String, budgetId: String) {
self.id = id
self.amount = amount.removePrefix("-")
self.budgetId = budgetId
}
}
However, when I try to pass an allocation into my budget picker view I get an error
NavigationLink(destination: BudgetPickerView(budgetable: allocation))...
Cannot convert return expression of type 'NavigationLink>, BudgetPickerView>' to return type 'some View'
Expression type 'BudgetPickerView' is ambiguous without more context
Change as bellow code
struct BudgetPickerView: View {
#EnvironmentObject var userData: UserData
var budgetable: Budgetable
var body: some View {
...
}
}
and
NavigationLink(destination: BudgetPickerView(budgetable: allocation).EnvironmentObject(UserData()))
By SwiftUI concept you are not allowed to work with #State outside of View, but the following works well (having other your parts unchanged)
struct BudgetPickerView: View {
#State private var budgetable: Budgetable
init(budgetable: Budgetable) {
_budgetable = State<Budgetable>(initialValue: budgetable)
}
var body: some View {
Text("Hello, World!")
}
}
struct TestBudgetPickerView: View {
var body: some View {
NavigationView {
NavigationLink(destination:
BudgetPickerView(budgetable: Allocation(amount: "10", budgetId: "1")))
{ Text("Item") }
}
}
}
BTW, just incase, again by design #State is intended to hold temporary-view-state-only data, not a model. For model is more preferable to use ObservableObject. In your case Budgetable looks like a model.