Skip to content

Instantly share code, notes, and snippets.

@rogelin
Forked from groue/ObservableState.swift
Created May 21, 2024 15:29
Show Gist options
  • Save rogelin/305b828420477febc2d2020768c6918e to your computer and use it in GitHub Desktop.
Save rogelin/305b828420477febc2d2020768c6918e to your computer and use it in GitHub Desktop.
WithBindable
import SwiftUI
/// Supplies an observable object to a view’s hierarchy.
///
/// The purpose of `WithBindable` is to make it possible to instantiate
/// observable objects from environment values, while keeping the object
/// alive as long as the view is rendered.
///
/// For example:
///
/// ```swift
/// @Observable MyModel {
/// init(myService: Service) { ... }
/// }
///
/// struct MyView: View {
/// @Environment(\.myService) var myService
///
/// var body: some View {
/// WithBindable {
/// MyModel(myService: myService)
/// } content: { myModel in
/// ContentView(myModel: myModel)
/// }
/// }
/// }
///
/// private struct ContentView: View {
/// @Bindable var myModel: MyModel
///
/// var body: some View { ... }
/// }
/// ```
///
/// `WithBindable` makes sure a single instance of the observable object
/// is created for the whole lifetime of the view. It is thus possible to
/// perform work from the object initializer.
struct WithBindable<Value: AnyObject & Observable, Content: View>: View {
@ObservableState var state: Value
private let content: (Value) -> Content
init(
_ makeValue: @escaping () -> Value,
content: @escaping (Value) -> Content)
{
self._state = ObservableState(wrappedValue: makeValue())
self.content = content
}
var body: some View {
content(state)
}
}
/// A property wrapper type that instantiates an observable object.
///
/// It's like `@State`, except that just like `@StateObject` its
/// initializer accepts an autoclosure so that a single instance of the
/// observable object is created for the whole lifetime of the view.
///
/// For example:
///
/// ```swift
/// @Observable MyModel {
/// init() { ... }
/// }
///
/// struct MyView: View {
/// @ObservableState var myModel = MyModel()
///
/// var body: some View { ... }
/// }
/// ```
@propertyWrapper
struct ObservableState<Value: AnyObject & Observable>: DynamicProperty {
@StateObject private var container = ValueContainer<Value>()
let makeValue: () -> Value
init(wrappedValue: @autoclosure @escaping () -> Value) {
self.makeValue = wrappedValue
}
var wrappedValue: Value {
container.value ?? makeValue()
}
var projectedValue: Wrapper {
Wrapper(value: wrappedValue)
}
func update() {
if container.value == nil {
container.value = makeValue()
}
}
@dynamicMemberLookup
struct Wrapper {
let value: Value
subscript<Subject>(
dynamicMember keyPath: ReferenceWritableKeyPath<Value, Subject>
) -> Binding<Subject> {
Binding(
get: { value[keyPath: keyPath] },
set: { value[keyPath: keyPath] = $0 })
}
}
}
/// The object that is instantiated once with `@StateObject` and takes care
/// of the lifetime of the value.
private final class ValueContainer<Value: Observable>: ObservableObject {
// No need to make it @Published because Value is Observable.
var value: Value?
}
// MARK: - Preview
#if DEBUG
@Observable
private final class DemoModel {
static var instanceCount = 0
var text: String
init() {
// Preview crashes if DemoModel is instantiated more than once.
// This helps asserting the desired behavior of the preview, which
// is that not more than two instances are created.
precondition(DemoModel.instanceCount < 2)
DemoModel.instanceCount += 1
self.text = ""
}
}
#Preview {
struct ContainerView: View {
@State var counter: Int = 1
var body: some View {
Form {
Section {
Text(verbatim: "Container body rendered \(counter) times")
.contentTransition(.numericText())
.animation(.default, value: counter)
} header: {
Text(verbatim: "Container View")
}
Section {
ContentView1()
} header: {
Text(verbatim: "@ObservableState demo")
}
Section {
WithBindable {
DemoModel()
} content: { model in
ContentView2(model: model)
}
} header: {
Text(verbatim: "WithBindable demo")
}
Button {
counter += 1
} label: {
Text(verbatim: "Force container body re-evaluation")
}
}
.headerProminence(.increased)
}
}
// ObservableState demo
struct ContentView1: View {
@ObservableState var model = DemoModel()
var body: some View {
TextField("", text: $model.text, prompt: Text(verbatim: "Enter text here"))
WitnessView(model: model)
}
}
// WithBindable demo
struct ContentView2: View {
@Bindable var model: DemoModel
var body: some View {
TextField("", text: $model.text, prompt: Text(verbatim: "Enter text here"))
WitnessView(model: model)
}
}
// Must update when model changes.
struct WitnessView: View {
var model: DemoModel
var body: some View {
Text(verbatim: "Model.text is '\(model.text)'")
}
}
return ContainerView()
}
#endif
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment