Skip to content

Instantly share code, notes, and snippets.

@Amzd
Last active October 7, 2024 22:25
Show Gist options
  • Save Amzd/8f0d4d94fcbb6c9548e7cf0c1493eaff to your computer and use it in GitHub Desktop.
Save Amzd/8f0d4d94fcbb6c9548e7cf0c1493eaff to your computer and use it in GitHub Desktop.
StateObject that works in iOS 13
import Combine
import PublishedObject // https://github.com/Amzd/PublishedObject
import SwiftUI
/// A property wrapper type that instantiates an observable object.
@propertyWrapper
public struct StateObject<ObjectType: ObservableObject>: DynamicProperty
where ObjectType.ObjectWillChangePublisher == ObservableObjectPublisher {
/// Wrapper that helps with initialising without actually having an ObservableObject yet
private class ObservedObjectWrapper: ObservableObject {
@PublishedObject var wrappedObject: ObjectType? = nil
init() {}
}
private var thunk: () -> ObjectType
@ObservedObject private var observedObject = ObservedObjectWrapper()
@State private var state = ObservedObjectWrapper()
public var wrappedValue: ObjectType {
if state.wrappedObject == nil {
// There is no State yet so we need to initialise the object
state.wrappedObject = thunk()
// and start observing it
observedObject.wrappedObject = state.wrappedObject
} else if observedObject.wrappedObject == nil {
// Retrieve the object from State and observe it in ObservedObject
observedObject.wrappedObject = state.wrappedObject
}
return state.wrappedObject!
}
public var projectedValue: ObservedObject<ObjectType>.Wrapper {
ObservedObject(wrappedValue: wrappedValue).projectedValue
}
public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType) {
self.thunk = thunk
}
public mutating func update() {
// Not sure what this does but we'll just forward it
_state.update()
_observedObject.update()
}
}
@Amzd
Copy link
Author

Amzd commented Aug 10, 2021

I think I have @_exported import SwiftUI somewhere in my project so I did not get those errors

@yigitkerem
Copy link

Builds now and seems to work okay. Is there a way for me to make the app use the original StateObject when available and fallback to your patch if it is not? I was thinking of renaming the library to prevent override but I'm not sure how I can dynamically change.

@Amzd
Copy link
Author

Amzd commented Aug 10, 2021

Currently don’t have access to a Mac but you can probably do a couple if #available checks inside a separate property wrapper to switch between wrappers (you can do SwiftUI.StateObject to get the SwiftUI type)

@malhal
Copy link

malhal commented Aug 24, 2021

Update func is supposed to init the object

@pd95
Copy link

pd95 commented Oct 3, 2021

Yes, update function should reinitialise the observed object. (as it gets reset whenever StateObject.init is called)

I have found a "hacky solution" to detect this case: ObservedObject has an internal property _seed which is some internal state.
Whenever the value is 1 our StateObject should recreate a new object.

    public mutating func update() {
        // Not sure what this does but we'll just forward it
        _state.update()
        _observedObject.update()

        // HACK! We rely on the internal _seed variable of `ObservedObject` to learn when we should initialize
        let mirror = Mirror(reflecting: _observedObject)
        guard let seed = mirror.descendant("_seed") as? Int else {
            return
        }
        if seed == 1 {
            state.wrappedObject = thunk()
            observedObject.wrappedObject = state.wrappedObject
        }
    }

@Amzd
Copy link
Author

Amzd commented Nov 1, 2021

Correct me if I'm wrong, but since we call the _state.update() that should reinit its ObservedObjectWrapper causing state.wrappedObject and observedObject.wrappedObject to not be the same object? @pd95 @malhal

If that's the case I think the best would be to just change the wrappedValue getter to always update the observedObject.wrappedObject?

    public var wrappedValue: ObjectType {
        if state.wrappedObject == nil {
            // There is no State yet so we need to initialise the object
            state.wrappedObject = thunk()
-        }
-       if observedObject.wrappedObject == nil {
            // Retrieve the object from State and observe it in ObservedObject
            observedObject.wrappedObject = state.wrappedObject
        }
        return state.wrappedObject!
    }

I know the init happens when you get wrappedValue vs when update is called but I don't like relying on undocumented behaviour.

@TuenTuenna
Copy link

you are a legend!

@BugMonkey
Copy link

[Is there a way to conditionally use @StateObject while targeting iOS 13 but use apple's @StateObject in iOS 14

@malhal
Copy link

malhal commented Mar 15, 2022

If you are using Combine's ObservableObject as the @StateObject you could use Async/await to replace the functionality and that is backwards compatible with iOS 13. Could be tricky without the task(priority:_:) modifier though.

@calvingit
Copy link

[Is there a way to conditionally use @StateObject while targeting iOS 13 but use apple's @StateObject in iOS 14

try this?

@available(iOS 13, obsoleted: 14)

@eccentricyan
Copy link

eccentricyan commented May 17, 2022

@available(iOS 13, obsoleted: 14)

@calvingit this not work
I insert a break point, it also called iOS 13 method...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment