Skip to content

Commit

Permalink
Add @ObservedObject implementation (#171)
Browse files Browse the repository at this point in the history
This pulls a fork of OpenCombine that can be compiled with the same SwiftWasm snapshot we use in `main`.

The only caveat is that this doesn't work for `ObservableObject`s that are subclasses of other classes with `@Published` properties. This issue needs to be fixed in [the Runtime library fork](https://github.com/MaxDesiatov/Runtime). Since this is a rare case, and fixing it wouldn't change this `@ObservedObject` implementation, I think it's ready for review as is.
  • Loading branch information
MaxDesiatov authored Jul 16, 2020
1 parent 8f23ac9 commit ffa686c
Show file tree
Hide file tree
Showing 8 changed files with 130 additions and 25 deletions.
11 changes: 10 additions & 1 deletion Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,21 @@
"version": null
}
},
{
"package": "OpenCombine",
"repositoryURL": "https://github.com/MaxDesiatov/OpenCombine.git",
"state": {
"branch": "observable-object",
"revision": "3c3a181acad7ab44a64d7c41140eb843222bb2aa",
"version": null
}
},
{
"package": "Runtime",
"repositoryURL": "https://github.com/MaxDesiatov/Runtime.git",
"state": {
"branch": "wasi-build",
"revision": "a617ead8a125a97e69d6100e4d27922006e82e0a",
"revision": "a9309b4822d6dd0e4a8e92351ee9e3d210e19b4e",
"version": null
}
}
Expand Down
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ let package = Package(
// .package(url: /* package url */, from: "1.0.0"),
.package(url: "https://github.com/kateinoigakukun/JavaScriptKit.git", .revision("47f2bb1")),
.package(url: "https://github.com/MaxDesiatov/Runtime.git", .branch("wasi-build")),
.package(url: "https://github.com/MaxDesiatov/OpenCombine.git", .branch("observable-object")),
],
targets: [
// Targets are the basic building blocks of a package. A target can define
Expand All @@ -34,7 +35,7 @@ let package = Package(
// in packages which this package depends on.
.target(
name: "TokamakCore",
dependencies: ["Runtime"]
dependencies: ["OpenCombine", "Runtime"]
),
.target(
name: "TokamakDemo",
Expand Down
2 changes: 2 additions & 0 deletions Sources/TokamakCore/MountedViews/MountedCompositeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
// Created by Max Desiatov on 03/12/2018.
//

import OpenCombine
import Runtime

final class MountedCompositeView<R: Renderer>: MountedView<R>, Hashable {
Expand All @@ -31,6 +32,7 @@ final class MountedCompositeView<R: Renderer>: MountedView<R>, Hashable {
private let parentTarget: R.TargetType

var state = [Any]()
var subscriptions = [AnyCancellable]()
var environmentValues: EnvironmentValues

init(_ view: AnyView, _ parentTarget: R.TargetType,
Expand Down
53 changes: 53 additions & 0 deletions Sources/TokamakCore/ObservedObject.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright 2020 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import OpenCombine

public typealias ObservableObject = OpenCombine.ObservableObject
public typealias Published = OpenCombine.Published

protocol ObservedProperty {
var objectWillChange: AnyPublisher<(), Never> { get }
}

@propertyWrapper
public struct ObservedObject<ObjectType>: ObservedProperty where ObjectType: ObservableObject {
@dynamicMemberLookup
public struct Wrapper {
let root: ObjectType
public subscript<Subject>(
dynamicMember keyPath: ReferenceWritableKeyPath<ObjectType, Subject>
) -> Binding<Subject> {
.init(
get: {
self.root[keyPath: keyPath]
}, set: {
self.root[keyPath: keyPath] = $0
}
)
}
}

public var wrappedValue: ObjectType { projectedValue.root }

public init(wrappedValue: ObjectType) {
projectedValue = Wrapper(root: wrappedValue)
}

public let projectedValue: Wrapper

var objectWillChange: AnyPublisher<(), Never> {
wrappedValue.objectWillChange.map { _ in }.eraseToAnyPublisher()
}
}
65 changes: 48 additions & 17 deletions Sources/TokamakCore/StackReconciler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,20 @@ public final class StackReconciler<R: Renderer> {
rootView.mount(with: self)
}

func queueUpdate(
private func queueStateUpdate(
for mountedView: MountedCompositeView<R>,
id: Int,
updater: (inout Any) -> ()
) {
let scheduleReconcile = queuedRerenders.isEmpty

updater(&mountedView.state[id])
queueUpdate(for: mountedView)
}

private func queueUpdate(for mountedView: MountedCompositeView<R>) {
let shouldSchedule = queuedRerenders.isEmpty
queuedRerenders.insert(mountedView)

guard scheduleReconcile else { return }
guard shouldSchedule else { return }

scheduler { [weak self] in self?.updateStateAndReconcile() }
}
Expand All @@ -63,30 +66,58 @@ public final class StackReconciler<R: Renderer> {
queuedRerenders.removeAll()
}

func render(compositeView: MountedCompositeView<R>) -> some View {
private func setupState(
id: Int,
for property: PropertyInfo,
of compositeView: MountedCompositeView<R>
) {
// swiftlint:disable force_try
let info = try! typeInfo(of: compositeView.view.type)
let stateProperties = info.properties.filter { $0.type is ValueStorage.Type }

for (id, stateProperty) in stateProperties.enumerated() {
// `ValueStorage` properties were already filtered out, so safe to assume the value's type
// swiftlint:disable:next force_cast
var state = try! stateProperty.get(from: compositeView.view.view) as! ValueStorage
// `ValueStorage` property already filtered out, so safe to assume the value's type
// swiftlint:disable:next force_cast
var state = try! property.get(from: compositeView.view.view) as! ValueStorage

if compositeView.state.count == id {
compositeView.state.append(state.anyInitialValue)
}
if compositeView.state.count == id {
compositeView.state.append(state.anyInitialValue)
}

if state.getter == nil || state.setter == nil {
state.getter = { compositeView.state[id] }

// Avoiding an indirect reference cycle here: this closure can be
// owned by callbacks owned by view's target, which is strongly referenced
// by the reconciler.
state.setter = { [weak self, weak compositeView] newValue in
guard let view = compositeView else { return }
self?.queueUpdate(for: view, id: id) { $0 = newValue }
self?.queueStateUpdate(for: view, id: id) { $0 = newValue }
}
}
try! property.set(value: state, on: &compositeView.view.view)
}

private func setupSubscription(
for property: PropertyInfo,
of compositeView: MountedCompositeView<R>
) {
// `ObservedProperty` property already filtered out, so safe to assume the value's type
// swiftlint:disable:next force_cast
let observed = try! property.get(from: compositeView.view.view) as! ObservedProperty

observed.objectWillChange.sink { [weak self] _ in
self?.queueUpdate(for: compositeView)
}.store(in: &compositeView.subscriptions)
}

func render(compositeView: MountedCompositeView<R>) -> some View {
let info = try! typeInfo(of: compositeView.view.type)

let needsSubscriptions = compositeView.subscriptions.isEmpty

for (id, property) in info.properties.enumerated() {
if property.type is ValueStorage.Type {
setupState(id: id, for: property, of: compositeView)
} else if needsSubscriptions && property.type is ObservedProperty.Type {
setupSubscription(for: property, of: compositeView)
}
try! stateProperty.set(value: state, on: &compositeView.view.view)
}

let result = compositeView.view.bodyClosure(compositeView.view.view)
Expand Down
3 changes: 3 additions & 0 deletions Sources/TokamakDOM/Views/HTML.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public typealias View = TokamakCore.View
public typealias AnyView = TokamakCore.AnyView
public typealias EmptyView = TokamakCore.EmptyView
public typealias State = TokamakCore.State
public typealias ObservableObject = TokamakCore.ObservableObject
public typealias Published = TokamakCore.Published
public typealias ObservedObject = TokamakCore.ObservedObject

protocol AnyHTML {
var innerHTML: String? { get }
Expand Down
16 changes: 11 additions & 5 deletions Sources/TokamakDemo/Counter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,22 @@ import SwiftUI
import TokamakDOM
#endif

public struct Counter: View {
@State public var count: Int
final class Count: ObservableObject {
@Published var value: Int

init(value: Int) { self.value = value }
}

struct Counter: View {
@ObservedObject var count: Count

let limit: Int

public var body: some View {
if count < limit {
if count.value < limit {
VStack {
Button("Increment") { count += 1 }
Text("\(count)")
Button("Increment") { count.value += 1 }
Text("\(count.value)")
}
.onAppear { print("Counter.VStack onAppear") }
.onDisappear { print("Counter.VStack onDisappear") }
Expand Down
2 changes: 1 addition & 1 deletion Sources/TokamakDemo/TokamakDemo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ struct TokamakDemoView: View {
}
VStack {
Group {
Counter(count: 5, limit: 15)
Counter(count: Count(value: 5), limit: 15)
.padding()
.background(Color(red: 0.9, green: 0.9, blue: 0.9, opacity: 1.0))
.border(Color.red, width: 3)
Expand Down

0 comments on commit ffa686c

Please sign in to comment.