Add `ref` and `effect` hooks, `Updatable` protocol (#41)

This also cleans up and refines some of the `Example` code and removes unused `key` property from `AnyNode`, which greatly improves error-reporting of Swift type-checker when wrong props are passed to `node` function.

Resolves #10 
Resolves #9 
Resolves #16

* Add Ref class and RefComponent protocol
* Add AnyRef protocol, add ref to AnyNode.init
* Cleanup hooks, add Target class to Gluon module
* Fix tests, avoid optionality in Target class
* Improve doc comments on Hooks.effect overload
* Implement effects scheduling with finalizers
* Add HookedComponents protocol, Hooks as class
* Cleanup after rebased on `master`
* Fix tests failing after Hooks refactoring
* Remove `key` argument from AnyNode.init, cleanups
* Efficient Reduceable state fully working w/ tests
* Fix use of comments for Equatable AnyNode operator
* Cleanup comments in Hooks and MHC
* Add TimerCounter, fix host not unmounting children
* Rename Reduceable to Updatable, fix effects bugs
* Fix TestRenderer compilation issues
* Remove `print` remnants in Timer Example code
This commit is contained in:
Max Desiatov 2019-02-18 09:00:20 +00:00 committed by GitHub
parent 3715feefef
commit 22cce69d3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 623 additions and 305 deletions

View File

@ -13,10 +13,11 @@
607FACDB1AFB9204008FA782 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 607FACD91AFB9204008FA782 /* Main.storyboard */; };
607FACDD1AFB9204008FA782 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDC1AFB9204008FA782 /* Images.xcassets */; };
607FACE01AFB9204008FA782 /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDE1AFB9204008FA782 /* LaunchScreen.xib */; };
D11DB6432219C03000013FC3 /* Timer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11DB6422219C03000013FC3 /* Timer.swift */; };
D1BFAF772215795900845EA0 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BFAF762215795900845EA0 /* Router.swift */; };
D1BFAF792215800A00845EA0 /* Counter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BFAF782215800A00845EA0 /* Counter.swift */; };
D1BFAF7B22158B4000845EA0 /* List.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BFAF7A22158B4000845EA0 /* List.swift */; };
D1DEEC2922009E8000C525EE /* NavRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1DEEC2822009E8000C525EE /* NavRouter.swift */; };
D1DEEC2922009E8000C525EE /* ModalRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1DEEC2822009E8000C525EE /* ModalRouter.swift */; };
D1E6BCD221AD4CD6002769E3 /* GluonTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1E6BCD121AD4CD6002769E3 /* GluonTest.swift */; };
D1F2C3272214407B008358DC /* TableModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F2C3262214407B008358DC /* TableModal.swift */; };
D1F7185322159E09004E5951 /* Controls.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F7185222159E09004E5951 /* Controls.swift */; };
@ -52,10 +53,11 @@
8CADEBB8BFF6F2621CA49E8F /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = "<group>"; };
C1B4A8D80770145FDAFEAE64 /* Pods_Gluon_Tests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Gluon_Tests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
C6DA99382B6892EAB361742F /* LICENSE */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = LICENSE; path = ../LICENSE; sourceTree = "<group>"; };
D11DB6422219C03000013FC3 /* Timer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timer.swift; sourceTree = "<group>"; };
D1BFAF762215795900845EA0 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = "<group>"; };
D1BFAF782215800A00845EA0 /* Counter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Counter.swift; sourceTree = "<group>"; };
D1BFAF7A22158B4000845EA0 /* List.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = List.swift; sourceTree = "<group>"; };
D1DEEC2822009E8000C525EE /* NavRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavRouter.swift; sourceTree = "<group>"; };
D1DEEC2822009E8000C525EE /* ModalRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalRouter.swift; sourceTree = "<group>"; };
D1E6BCD121AD4CD6002769E3 /* GluonTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GluonTest.swift; sourceTree = "<group>"; };
D1F2C3262214407B008358DC /* TableModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableModal.swift; sourceTree = "<group>"; };
D1F7185222159E09004E5951 /* Controls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Controls.swift; sourceTree = "<group>"; };
@ -171,15 +173,16 @@
D1F7185122159D6E004E5951 /* Examples */ = {
isa = PBXGroup;
children = (
D1BFAF7A22158B4000845EA0 /* List.swift */,
D1DEEC2822009E8000C525EE /* NavRouter.swift */,
D1F2C3262214407B008358DC /* TableModal.swift */,
D1BFAF782215800A00845EA0 /* Counter.swift */,
D1F7185E2215A5D0004E5951 /* Constraints.swift */,
D1F7185222159E09004E5951 /* Controls.swift */,
D1F7185422159EAD004E5951 /* DatePickers.swift */,
D1F7185C2215A4A1004E5951 /* LayerProps.swift */,
D1F7185E2215A5D0004E5951 /* Constraints.swift */,
D1BFAF7A22158B4000845EA0 /* List.swift */,
D1F718602215A617004E5951 /* Modals.swift */,
D1DEEC2822009E8000C525EE /* ModalRouter.swift */,
D1F2C3262214407B008358DC /* TableModal.swift */,
D11DB6422219C03000013FC3 /* Timer.swift */,
);
path = Examples;
sourceTree = "<group>";
@ -324,8 +327,9 @@
D1F2C3272214407B008358DC /* TableModal.swift in Sources */,
607FACD81AFB9204008FA782 /* ViewController.swift in Sources */,
D1F7185F2215A5D0004E5951 /* Constraints.swift in Sources */,
D11DB6432219C03000013FC3 /* Timer.swift in Sources */,
D1F7185322159E09004E5951 /* Controls.swift in Sources */,
D1DEEC2922009E8000C525EE /* NavRouter.swift in Sources */,
D1DEEC2922009E8000C525EE /* ModalRouter.swift in Sources */,
D1BFAF772215795900845EA0 /* Router.swift in Sources */,
607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */,
D1F7185D2215A4A1004E5951 /* LayerProps.swift in Sources */,

View File

@ -17,7 +17,7 @@ struct Constraints: LeafComponent {
return StackView.node(.init(
axis: .vertical,
distribution: .fillEqually,
Edges.equal(to: .parent)
Edges.equal(to: .safeArea)
), [
Slider.node(.init(
value: left.value,

View File

@ -89,7 +89,7 @@ struct Controls: LeafComponent {
alignment: .center,
axis: .vertical,
distribution: .fillEqually,
Edges.equal(to: .parent)
Edges.equal(to: .safeArea)
),
children
)

View File

@ -20,7 +20,7 @@ struct Counter: LeafComponent {
alignment: .center,
axis: .vertical,
distribution: .fillEqually,
Edges.equal(to: .parent)
Edges.equal(to: .safeArea)
), [
Button.node(
.init(onPress: Handler { count.set { $0 + 1 } }),

View File

@ -27,7 +27,7 @@ struct DatePickers: LeafComponent {
return StackView.node(.init(
axis: .vertical,
distribution: .fillEqually,
Edges.equal(to: .parent)
Edges.equal(to: .safeArea)
), [
Label.node(
labelProps,
@ -35,7 +35,7 @@ struct DatePickers: LeafComponent {
),
Label.node(
labelProps,
"This picker doesn't animate state changes in the next picker"
"This picker doesn't animate state changes in the next picker:"
),
DatePicker.node(
.init(
@ -46,7 +46,7 @@ struct DatePickers: LeafComponent {
),
Label.node(
labelProps,
"This picker animates state changes in the previous picker"
"This picker animates state changes in the previous picker:"
),
DatePicker.node(
.init(

View File

@ -18,7 +18,7 @@ struct LayerProps: LeafComponent {
StackView.node(.init(
axis: .vertical,
distribution: .fillEqually,
Edges.equal(to: .parent)
Edges.equal(to: .safeArea)
), [
Slider.node(.init(
value: state.value,

View File

@ -8,7 +8,7 @@
import Gluon
struct NavRouter: NavigationRouter {
struct ModalRouter: NavigationRouter {
enum Route {
case first
case second

View File

@ -16,7 +16,7 @@ struct NavigationModal: PureLeafComponent {
static func render(props: Props) -> AnyNode {
return props.isPresented.value ?
ModalPresenter.node(
NavigationPresenter<NavRouter>.node(
NavigationPresenter<ModalRouter>.node(
.init(
initial: .first,
prefersLargeTitles: true,
@ -82,7 +82,7 @@ struct Modals: LeafComponent {
alignment: .center,
axis: .vertical,
distribution: .fillEqually,
Edges.equal(to: .parent)
Edges.equal(to: .safeArea)
),
[
Button.node(

View File

@ -0,0 +1,53 @@
//
// Timer.swift
// Gluon_Example
//
// Created by Max Desiatov on 17/02/2019.
// Copyright © 2019 Gluon. All rights reserved.
//
import Foundation
import Gluon
struct TimerCounter: LeafComponent {
typealias Props = Null
static func render(props: Null, hooks: Hooks) -> AnyNode {
let count = hooks.state(0)
let timer = hooks.ref(type: Timer.self)
let interval = hooks.state(1.0)
hooks.effect(interval.value) { () -> () -> () in
timer.value = Timer.scheduledTimer(
withTimeInterval: interval.value,
repeats: true
) { _ in
count.set { $0 + 1 }
}
return {
timer.value?.invalidate()
}
}
return StackView.node(
.init(
alignment: .center,
axis: .vertical,
distribution: .fillEqually,
Edges.equal(to: .safeArea)
), [
Label.node(
.init(alignment: .center),
"Adjust timer interval in seconds: \(interval.value)"
),
Stepper.node(
.init(
value: interval.value,
valueHandler: Handler(interval.set)
)
),
Label.node(.init(alignment: .center), "\(count.value)"),
]
)
}
}

View File

@ -16,6 +16,7 @@ enum AppRoute: String, CaseIterable {
case modals = "Modal Presentation"
case datePicker = "Date Picker"
case layerProps = "Layer Props"
case timer
}
extension AppRoute: CustomStringConvertible {
@ -53,12 +54,14 @@ struct Router: NavigationRouter {
result = DatePickers.node()
case .layerProps:
result = LayerProps.node()
case .timer:
result = TimerCounter.node()
}
return NavigationItem.node(
.init(title: route.description),
View.node(
.init(Style(backgroundColor: .white, Edges.equal(to: .safeArea))),
.init(Style(backgroundColor: .white, Edges.equal(to: .parent))),
result
)
)

View File

@ -41,13 +41,16 @@
D163F9D621F27F8C00BA464B /* GluonViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D163F9D521F27F8C00BA464B /* GluonViewController.swift */; };
D16FCB7C21FCB3870033C5C0 /* TableViewBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = D16FCB7B21FCB3870033C5C0 /* TableViewBox.swift */; };
D16FCB7D21FCB38D0033C5C0 /* ListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D16FCB7921FCB3160033C5C0 /* ListView.swift */; };
D16FCB8121FDD2550033C5C0 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = D16FCB8021FDD2550033C5C0 /* Weak.swift */; };
D1A1A7692210679B0094EA4F /* Target.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A1A7682210679B0094EA4F /* Target.swift */; };
D1A1A76B22106D300094EA4F /* Effect.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A1A76A22106D300094EA4F /* Effect.swift */; };
D1BDBBE621E0F07800FBBCDF /* MountedNull.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BDBBE521E0F07800FBBCDF /* MountedNull.swift */; };
D1C3A72121F0C47200C6B884 /* XAxisConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1C3A72021F0C47200C6B884 /* XAxisConstraint.swift */; };
D1C3A72421F0DE5300C6B884 /* Insets.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1C3A72221F0DC6300C6B884 /* Insets.swift */; };
D1CB7D2921E136010075C0C3 /* SegmentedControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1CB7D2821E136010075C0C3 /* SegmentedControl.swift */; };
D1CB7D2B21E136520075C0C3 /* SegmentedControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1CB7D2A21E136520075C0C3 /* SegmentedControl.swift */; };
D1CB7D2E21E29CC80075C0C3 /* NavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1CB7D2D21E29CC80075C0C3 /* NavigationController.swift */; };
D1D792EB220F79140042A632 /* State.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D792EA220F79130042A632 /* State.swift */; };
D1D792ED220F79B30042A632 /* Ref.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D792EC220F79B30042A632 /* Ref.swift */; };
D1E8755521EF2ED200D4A7BA /* HooksTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1E8755421EF2ED200D4A7BA /* HooksTests.swift */; };
D1EC92F021F5B8000026C207 /* ListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EC92EF21F5B8000026C207 /* ListView.swift */; };
D1EF77522205C579008829EC /* Constraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EF77512205C579008829EC /* Constraint.swift */; };
@ -99,7 +102,6 @@
OBJ_120 /* Null.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_71 /* Null.swift */; };
OBJ_121 /* Renderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_72 /* Renderer.swift */; };
OBJ_122 /* StackReconciler.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_73 /* StackReconciler.swift */; };
OBJ_123 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_74 /* Store.swift */; };
OBJ_124 /* Unique.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_75 /* Unique.swift */; };
OBJ_132 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_6 /* Package.swift */; };
OBJ_144 /* TestRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_11 /* TestRenderer.swift */; };
@ -197,13 +199,16 @@
D163F9D521F27F8C00BA464B /* GluonViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GluonViewController.swift; sourceTree = "<group>"; };
D16FCB7921FCB3160033C5C0 /* ListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListView.swift; sourceTree = "<group>"; };
D16FCB7B21FCB3870033C5C0 /* TableViewBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewBox.swift; sourceTree = "<group>"; };
D16FCB8021FDD2550033C5C0 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = "<group>"; };
D1A1A7682210679B0094EA4F /* Target.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Target.swift; sourceTree = "<group>"; };
D1A1A76A22106D300094EA4F /* Effect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Effect.swift; sourceTree = "<group>"; };
D1BDBBE521E0F07800FBBCDF /* MountedNull.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MountedNull.swift; sourceTree = "<group>"; };
D1C3A72021F0C47200C6B884 /* XAxisConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XAxisConstraint.swift; sourceTree = "<group>"; };
D1C3A72221F0DC6300C6B884 /* Insets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Insets.swift; sourceTree = "<group>"; };
D1CB7D2821E136010075C0C3 /* SegmentedControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedControl.swift; sourceTree = "<group>"; };
D1CB7D2A21E136520075C0C3 /* SegmentedControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedControl.swift; sourceTree = "<group>"; };
D1CB7D2D21E29CC80075C0C3 /* NavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationController.swift; sourceTree = "<group>"; };
D1D792EA220F79130042A632 /* State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = State.swift; sourceTree = "<group>"; };
D1D792EC220F79B30042A632 /* Ref.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ref.swift; sourceTree = "<group>"; };
D1E8755421EF2ED200D4A7BA /* HooksTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HooksTests.swift; sourceTree = "<group>"; };
D1EC92EF21F5B8000026C207 /* ListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListView.swift; sourceTree = "<group>"; };
D1EF77512205C579008829EC /* Constraint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Constraint.swift; sourceTree = "<group>"; };
@ -287,7 +292,6 @@
OBJ_71 /* Null.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Null.swift; sourceTree = "<group>"; };
OBJ_72 /* Renderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Renderer.swift; sourceTree = "<group>"; };
OBJ_73 /* StackReconciler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackReconciler.swift; sourceTree = "<group>"; };
OBJ_74 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
OBJ_75 /* Unique.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Unique.swift; sourceTree = "<group>"; };
OBJ_79 /* ReconcilerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReconcilerTests.swift; sourceTree = "<group>"; };
OBJ_8 /* Package.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Package.xcconfig; sourceTree = "<group>"; };
@ -395,6 +399,17 @@
path = Host;
sourceTree = "<group>";
};
D1D792E7220F784A0042A632 /* Hooks */ = {
isa = PBXGroup;
children = (
D1D792EA220F79130042A632 /* State.swift */,
OBJ_45 /* Hooks.swift */,
D1D792EC220F79B30042A632 /* Ref.swift */,
D1A1A76A22106D300094EA4F /* Effect.swift */,
);
path = Hooks;
sourceTree = "<group>";
};
D1EF77502205C579008829EC /* Constraint */ = {
isa = PBXGroup;
children = (
@ -493,15 +508,14 @@
OBJ_41 /* AnyNode.swift */,
OBJ_42 /* Components.swift */,
OBJ_43 /* Default.swift */,
OBJ_45 /* Hooks.swift */,
D1D792E7220F784A0042A632 /* Hooks */,
OBJ_46 /* Components */,
OBJ_67 /* MountedComponents */,
OBJ_71 /* Null.swift */,
OBJ_72 /* Renderer.swift */,
OBJ_73 /* StackReconciler.swift */,
OBJ_74 /* Store.swift */,
OBJ_75 /* Unique.swift */,
D16FCB8021FDD2550033C5C0 /* Weak.swift */,
D1A1A7682210679B0094EA4F /* Target.swift */,
);
name = Gluon;
path = Sources/Gluon;
@ -845,8 +859,10 @@
buildActionMask = 0;
files = (
D1EF776E2205C74D008829EC /* SizeConstraint.swift in Sources */,
D1D792ED220F79B30042A632 /* Ref.swift in Sources */,
D1EF77602205C685008829EC /* Right.swift in Sources */,
D1EF775E2205C679008829EC /* Left.swift in Sources */,
D1D792EB220F79140042A632 /* State.swift in Sources */,
OBJ_93 /* AnyEquatable.swift in Sources */,
OBJ_94 /* AnyNode.swift in Sources */,
D1EF77582205C60D008829EC /* Height.swift in Sources */,
@ -872,12 +888,14 @@
D1EF776A2205C717008829EC /* FirstBaseline.swift in Sources */,
OBJ_107 /* TabPresenter.swift in Sources */,
D1BDBBE621E0F07800FBBCDF /* MountedNull.swift in Sources */,
D1A1A7692210679B0094EA4F /* Target.swift in Sources */,
OBJ_108 /* Color.swift in Sources */,
OBJ_109 /* Event.swift in Sources */,
D1CB7D2E21E29CC80075C0C3 /* NavigationController.swift in Sources */,
OBJ_110 /* Rectangle.swift in Sources */,
OBJ_111 /* Second.swift in Sources */,
OBJ_112 /* Style.swift in Sources */,
D1A1A76B22106D300094EA4F /* Effect.swift in Sources */,
D1C3A72421F0DE5300C6B884 /* Insets.swift in Sources */,
D1EF77702205F6FA008829EC /* Center.swift in Sources */,
OBJ_113 /* TextAlignment.swift in Sources */,
@ -885,7 +903,6 @@
D1CB7D2921E136010075C0C3 /* SegmentedControl.swift in Sources */,
OBJ_115 /* StackView.swift in Sources */,
OBJ_116 /* View.swift in Sources */,
D16FCB8121FDD2550033C5C0 /* Weak.swift in Sources */,
D1EF77542205C583008829EC /* Edges.swift in Sources */,
OBJ_117 /* MountedComponent.swift in Sources */,
D1EF77852208BB10008829EC /* TabItem.swift in Sources */,
@ -896,7 +913,6 @@
OBJ_120 /* Null.swift in Sources */,
OBJ_121 /* Renderer.swift in Sources */,
OBJ_122 /* StackReconciler.swift in Sources */,
OBJ_123 /* Store.swift in Sources */,
OBJ_124 /* Unique.swift in Sources */,
D1EF77682205C6F6008829EC /* CenterY.swift in Sources */,
D1EF77862208BB13008829EC /* NavigationItem.swift in Sources */,

View File

@ -341,7 +341,12 @@ struct State<T> {
func set(_ value: T)
// or update the state with a pure function
func set(_ updater: (T) -> T)
func set(_ transformer: @escaping (T) -> T)
// or efficiently update the state in place with a mutating function
// (helps avoiding expensive memory allocations when state contains
// large arrays/dictionaries or other copy-on-write value)
func set(_ updater: @escaping (inout T) -> ())
}
```

View File

@ -6,15 +6,16 @@
//
public struct AnyNode: Equatable {
/// Equatable can't be automatically derived for `type` property?
// Equatable can't be automatically derived for `type` property?
public static func ==(lhs: AnyNode, rhs: AnyNode) -> Bool {
return lhs.key == rhs.key &&
return
lhs.ref === rhs.ref &&
lhs.type == rhs.type &&
lhs.children == rhs.children &&
lhs.props == rhs.props
}
let key: String?
let ref: AnyObject?
public let props: AnyEquatable
public let children: AnyEquatable
let type: ComponentType
@ -39,7 +40,7 @@ public struct AnyNode: Equatable {
extension Null {
public static func node() -> AnyNode {
return AnyNode(
key: nil,
ref: nil,
props: AnyEquatable(Null()),
children: AnyEquatable(Null()),
type: .null
@ -47,12 +48,6 @@ extension Null {
}
}
extension Component {
public static func node(_ props: Props, _ children: Children) -> AnyNode {
return node(key: nil, props, children)
}
}
extension Component where Children == Null {
public static func node(_ props: Props) -> AnyNode {
return node(props, Null())
@ -94,23 +89,41 @@ extension Component where Props: Default, Props.DefaultValue == Props,
}
extension HostComponent {
public static func node(key: String?,
_ props: Props,
_ children: Children) -> AnyNode {
return AnyNode(key: key,
props: AnyEquatable(props),
children: AnyEquatable(children),
type: .host(self))
public static func node(_ props: Props, _ children: Children) -> AnyNode {
return AnyNode(
ref: nil,
props: AnyEquatable(props),
children: AnyEquatable(children),
type: .host(self)
)
}
}
extension CompositeComponent {
public static func node(key: String?,
_ props: Props,
_ children: Children) -> AnyNode {
return AnyNode(key: key,
props: AnyEquatable(props),
children: AnyEquatable(children),
type: .composite(self))
public static func node(
_ props: Props,
_ children: Children
) -> AnyNode {
return AnyNode(
ref: nil,
props: AnyEquatable(props),
children: AnyEquatable(children),
type: .composite(self)
)
}
}
extension RefComponent {
public static func node(
ref: Ref<RefType>,
_ props: Props,
_ children: Children
) -> AnyNode {
return AnyNode(
ref: ref,
props: AnyEquatable(props),
children: AnyEquatable(children),
type: .host(self)
)
}
}

View File

@ -13,6 +13,10 @@ public protocol AnyHostComponent {}
public protocol HostComponent: AnyHostComponent, Component {}
public protocol RefComponent: HostComponent {
associatedtype RefType
}
/// Type-erased version of `CompositeComponent` to work around
/// [PAT restrictions](http://www.russbishop.net/swift-associated-types). Users
/// of Gluon shouldn't ever need to conform to this protocol directly, use
@ -29,9 +33,10 @@ public protocol Component {
associatedtype Props: Equatable
associatedtype Children: Equatable
static func node(key: String?,
_ props: Props,
_ children: Children) -> AnyNode
static func node(
_ props: Props,
_ children: Children
) -> AnyNode
}
public protocol CompositeComponent: AnyCompositeComponent, Component {

View File

@ -44,7 +44,7 @@ public struct NavigationPresenter<T: NavigationRouter>: LeafComponent {
public static func render(props: Props, hooks: Hooks) -> AnyNode {
let stack = hooks.state([props.initial])
let pop = { stack.set(Array(stack.value.dropLast())) }
let pop = { stack.set { $0.remove(at: $0.count - 1) } }
return NavigationController.node(
.init(

View File

@ -1,45 +0,0 @@
//
// Hooks.swift
// Gluon
//
// Created by Max Desiatov on 06/11/2018.
//
public struct State<T> {
public let value: T
let setter: Handler<T>
init(_ value: T, _ setter: @escaping (T) -> ()) {
self.value = value
self.setter = Handler(setter)
}
public func set(_ value: T) {
setter.value(value)
}
public func set(_ updater: (T) -> T) {
setter.value(updater(value))
}
}
extension State: Equatable where T: Equatable {}
public struct Hooks {
var currentState: ((Any) -> (Any, Int))?
var queueState: ((_ state: Any, _ index: Int) -> ())?
public func state<T>(_ initial: T) -> State<T> {
guard let currentState = currentState,
let queueState = queueState else {
fatalError("""
attempt to use `state` hook outside of a `render` function,
or `render` is not called from a renderer
""")
}
let (value, index) = currentState(initial)
return State(value as? T ?? initial) { queueState($0, index) }
}
}

View File

@ -0,0 +1,62 @@
//
// Effect.swift
// Gluon
//
// Created by Max Desiatov on 10/02/2019.
//
extension Hooks {
/// Schedule an effect to be executed on every call to `render`.
public func effect(closure: @escaping () -> ()) {
scheduleEffect(nil, { closure(); return nil })
}
/** Schedule an effect to be executed on every call to `render`. The effect
closure should return a cleanup closure to be executed before the next
call to `render` or when a component is unmounted.
*/
public func effect(closure: @escaping () -> () -> ()) {
scheduleEffect(nil, closure)
}
/** Schedule an effect to be executed on calls to `render` when `observed`
value has changed from the previous call to `render`.
You can use this overload of `effect` to control when exactly the effect
`closure` is executed. For example, always pass `Null()` as `observed` so
that the effect is executed only once on the initial rendering.
Another use case is an effect that schedules a repeated timer with a specific
interval. You wouldn't want to reschedule the timer on every call to
component's `render` if the interval hasn't changed. Pass the interval as
`observed`, which will be compared to the previous value and trigger effect
execution (updating the timer interval or creating a new timer) when the
interval has changed.
*/
public func effect<T: Equatable>(_ observed: T, closure: @escaping () -> ()) {
scheduleEffect(AnyEquatable(observed), { closure(); return nil })
}
/** Schedule an effect to be executed on calls to `render` when `observed`
value has changed from the previous call to `render`. The effect
closure should return a cleanup closure to be executed before the next
call to `render` or when a component is unmounted.
You can use this overload of `effect` to control when exactly the effect
`closure` is executed with additional cleanup. For example, always pass
`Null()` as `observed` so that the effect is executed only once on the
initial rendering.
Another use case is an effect that subscribes to updates on a user model
fetched from the network and you need to correctly unsubscribe from updates
when a component is unmounted. You wouldn't to unsubscribe and resubscribe
every time a component rerendered if an observed user ID hasn't changed.
Pass the ID as `observed`, which will be compared to the previous value and
trigger effect execution (unsubscribing from updates on old user ID and
subscribing for new user ID) when the ID has changed.
*/
public func effect<T>(_ observed: T, closure: @escaping () -> () -> ())
where T: Equatable {
scheduleEffect(AnyEquatable(observed), closure)
}
}

View File

@ -0,0 +1,124 @@
//
// Hooks.swift
// Gluon
//
// Created by Max Desiatov on 06/11/2018.
//
typealias Finalizer = (() -> ())?
typealias Effect = () -> Finalizer
protocol HookedComponent: class {
/// State cells of this component indexed by order of `hooks.state` calls
var state: [Any] { get set }
/// Effect cells of this component indexed by order of `hooks.effect` calls
var effects: [(observed: AnyEquatable?, Effect)] { get set }
/// Finalizer cells of this component received from `Effect` evaluation.
/// Indices in this array exactly match indices in `effects` array.
var effectFinalizers: [Finalizer] { get set }
/// Ref cells of this component indexed by order of `hooks.ref` calls
var refs: [AnyObject] { get set }
}
/** Functions implemented directly in this class are parts of internal
implementation of `Hooks`. The public API is defined in extensions of `Hooks`
located in separate files in the same directory.
*/
public final class Hooks {
/** Closure assigned by the reconciler before every `render` call. Queues
a state update with this reconciler.
*/
let queueState: (_ index: Int, _ updater: Updater<Any>) -> ()
weak var component: HookedComponent?
private var stateIndex = 0
private var effectIndex = 0
private(set) var scheduledEffects = Set<Int>()
private var refIndex = 0
init(
component: HookedComponent,
queueState: @escaping (_ index: Int, _ updater: Updater<Any>) -> ()
) {
self.component = component
self.queueState = queueState
}
/** For a given initial state return a current value of this state
(initialized from `initial` if current was absent) and its index.
*/
func currentState(_ initial: Any) -> (current: Any, index: Int) {
defer { stateIndex += 1 }
guard let component = component else {
assertionFailure("hooks.state should only be called within `render`")
return (initial, stateIndex)
}
if component.state.count > stateIndex {
return (component.state[stateIndex], stateIndex)
} else {
component.state.append(initial)
return (initial, stateIndex)
}
}
/** Schedules effect exection with the current reconciler accessed via
`component`.
*/
func scheduleEffect(
_ observed: AnyEquatable?,
_ effect: @escaping Effect
) {
defer { effectIndex += 1 }
guard let component = component else {
assertionFailure("hooks.effect should only be called within `render`")
return
}
if component.effects.count > effectIndex {
guard component.effects[effectIndex].0 != observed else { return }
component.effects[effectIndex].0 = observed
component.effects[effectIndex].1 = effect
scheduledEffects.insert(effectIndex)
} else {
component.effects.append((observed, effect))
scheduledEffects.insert(effectIndex)
}
}
/** For a given initial value return a current ref
(initialized from `initial` if current was absent)
*/
func ref<T>(_ initial: Ref<T>) -> Ref<T> {
defer { stateIndex += 1 }
guard let component = component else {
assertionFailure("hooks.state should only be called within `render`")
return initial
}
if component.refs.count > refIndex {
guard let result = component.refs[refIndex] as? Ref<T> else {
assertionFailure(
"""
unexpected ref type during rendering, possible Rules of Hooks violation
"""
)
return initial
}
return result
} else {
component.refs.append(initial)
return initial
}
}
}

View File

@ -0,0 +1,24 @@
//
// Ref.swift
// Gluon
//
// Created by Max Desiatov on 09/02/2019.
//
public final class Ref<T> {
public var value: T
init(_ value: T) {
self.value = value
}
}
extension Hooks {
public func ref<T>(type: T.Type) -> Ref<T?> {
return ref(Ref(nil))
}
public func ref<T>(_ initial: T) -> Ref<T> {
return ref(Ref(initial))
}
}

View File

@ -0,0 +1,86 @@
//
// State.swift
// Gluon
//
// Created by Max Desiatov on 09/02/2019.
//
/// Formalizes an update to a value with a given action. Note that `update`
/// function is mutating here to allow efficient in-place updates. As long as
/// `Updatable` is implemented on a value type, this still allows freezing it
/// into an immutable value when needed.
public protocol Updatable {
associatedtype Action
mutating func update(_ action: Action)
}
typealias Updater<T> = (inout T) -> ()
/** Note that `set` functions are not `mutating`, they never update the
component's state in-place synchronously, but only schedule an update with
Gluon at a later time. A call to `render` is only scheduled on the component
that obtained this state with `hooks.state`.
*/
public struct State<T> {
public let value: T
/// A closure stored as `Handler` to enable `Equatable` implementation on
/// `State` derived by the compiler.
let updateHandler: Handler<Updater<T>>
init(_ value: T, _ updater: @escaping (Updater<T>) -> ()) {
self.value = value
updateHandler = Handler(updater)
}
/// set the state to a specified value
public func set(_ value: T) {
updateHandler.value { $0 = value }
}
/// update the state with a pure function
public func set(_ transformer: @escaping (T) -> T) {
updateHandler.value { $0 = transformer($0) }
}
/// efficiently update the state in place with a mutating function
/// (helps avoiding expensive memory allocations when state contains
/// large arrays/dictionaries or other copy-on-write value)
public func set(_ updater: @escaping (inout T) -> ()) {
updateHandler.value(updater)
}
}
extension State: Equatable where T: Equatable {}
extension State where T: Updatable {
/// For any `Reduceable` state you can dispatch an `Action` to reduce that
/// state to a different value.
public func set(_ action: T.Action) {
updateHandler.value { $0.update(action) }
}
}
extension Hooks {
/** Allows a component to have its own state and to be updated when the state
changes. Returns a very simple state container, which on initial call of
render contains `initial` as a value and values passed to `count.set`
on subsequent updates:
*/
public func state<T>(_ initial: T) -> State<T> {
let (value, index) = currentState(initial)
let queueState = self.queueState
return State(value as? T ?? initial) { (updater: Updater<T>) in
queueState(index) {
// There's no easy way to downcast elements of `[Any]` to `T`
// and apply `inout` updater without creating copies, working around
// that with pointers.
withUnsafeMutablePointer(to: &$0) {
$0.withMemoryRebound(to: T.self, capacity: 1) { updater(&$0[0]) }
}
}
}
}
}

View File

@ -26,7 +26,7 @@ public class MountedComponent<R: Renderer> {
}
extension AnyNode {
func makeMountedComponent<R: Renderer>(_ parentTarget: R.Target)
func makeMountedComponent<R: Renderer>(_ parentTarget: R.TargetType)
-> MountedComponent<R> {
switch type {
case let .host(type):

View File

@ -5,8 +5,10 @@
// Created by Max Desiatov on 03/12/2018.
//
import Dispatch
final class MountedCompositeComponent<R: Renderer>: MountedComponent<R>,
Hashable {
HookedComponent, Hashable {
static func ==(lhs: MountedCompositeComponent<R>,
rhs: MountedCompositeComponent<R>) -> Bool {
return lhs === rhs
@ -17,13 +19,18 @@ final class MountedCompositeComponent<R: Renderer>: MountedComponent<R>,
}
private var mountedChildren = [MountedComponent<R>]()
private let parentTarget: R.Target
private let parentTarget: R.TargetType
let type: AnyCompositeComponent.Type
// HookedComponent implementation
var state = [Any]()
var effects = [(observed: AnyEquatable?, Effect)]()
var effectFinalizers = [Finalizer]()
var refs = [AnyObject]()
init(_ node: AnyNode,
_ type: AnyCompositeComponent.Type,
_ parentTarget: R.Target) {
_ parentTarget: R.TargetType) {
self.type = type
self.parentTarget = parentTarget
@ -41,8 +48,12 @@ final class MountedCompositeComponent<R: Renderer>: MountedComponent<R>,
override func unmount(with reconciler: StackReconciler<R>) {
mountedChildren.forEach { $0.unmount(with: reconciler) }
// FIXME: Should call `hooks.effect` finalizers here after `hooks.effect`
// is implemented
DispatchQueue.main.async {
for f in self.effectFinalizers {
f?()
}
}
}
override func update(with reconciler: StackReconciler<R>) {

View File

@ -9,7 +9,6 @@
components by `StackReconciler`.
*/
public final class MountedHostComponent<R: Renderer>: MountedComponent<R> {
private var managedChildren = [Weak<R.Target>: MountedComponent<R>]()
private var mountedChildren = [MountedComponent<R>]()
/** Target of a closest ancestor host component. As a parent of this component
@ -17,18 +16,18 @@ public final class MountedHostComponent<R: Renderer>: MountedComponent<R> {
around the target of a host component to its closests descendent host
comoponents. Thus, a parent target is not always the same as a target of
a parent component. */
private let parentTarget: R.Target
private let parentTarget: R.TargetType
/** Target of this host component supplied by a renderer after mounting has
completed.
*/
private var target: R.Target?
private var target: R.TargetType?
public let type: AnyHostComponent.Type
init(_ node: AnyNode,
_ type: AnyHostComponent.Type,
_ parentTarget: R.Target) {
_ parentTarget: R.TargetType) {
self.type = type
self.parentTarget = parentTarget
@ -63,29 +62,33 @@ public final class MountedHostComponent<R: Renderer>: MountedComponent<R> {
override func unmount(with reconciler: StackReconciler<R>) {
guard let target = target else { return }
reconciler.renderer?.unmount(target: target, with: self)
reconciler.renderer?.unmount(target: target, with: self) {
self.mountedChildren.forEach { $0.unmount(with: reconciler) }
}
}
override func update(with reconciler: StackReconciler<R>) {
guard let target = target else { return }
target.node = node
reconciler.renderer?.update(target: target,
with: self)
switch node.children.value {
case var nodes as [AnyNode]:
switch (mountedChildren.isEmpty, nodes.isEmpty) {
// existing children, new children array is empty, unmount all existing
// if existing children present and new children array is empty
// then unmount all existing children
case (false, true):
mountedChildren.forEach { $0.unmount(with: reconciler) }
mountedChildren = []
// no existing children, mount all new
// if no existing children then mount all new children
case (true, false):
mountedChildren = nodes.map { $0.makeMountedComponent(target) }
mountedChildren.forEach { $0.mount(with: reconciler) }
// both arrays have items, reconcile by types and keys
// if both arrays have items then reconcile by types and keys
case (false, false):
var newChildren = [MountedComponent<R>]()

View File

@ -22,7 +22,7 @@ public protocol Renderer: class {
probably create its own type hierarchy to be able to reason about
all possible target types available on a specific platform.
*/
associatedtype Target: AnyObject
associatedtype TargetType: Target
/// Reconciler instance used by this renderer.
var reconciler: StackReconciler<Self>? { get }
@ -39,8 +39,10 @@ public protocol Renderer: class {
target.
- returns: The newly created target.
*/
func mountTarget(to parent: Target,
with component: MountedHost) -> Target?
func mountTarget(
to parent: TargetType,
with component: MountedHost
) -> TargetType?
/** Function called by a reconciler when an existing target instance should be
updated.
@ -55,8 +57,10 @@ public protocol Renderer: class {
children can be different from children passed on
previous updates or on target creation.
*/
func update(target: Target,
with component: MountedHost)
func update(
target: TargetType,
with component: MountedHost
)
/** Function called by a reconciler when an existing target instance should be
unmounted: removed from the parent and most likely destroyed.
@ -64,12 +68,15 @@ public protocol Renderer: class {
- parameter component: Type of the host component that renders to the
updated target.
*/
func unmount(target: Target,
with component: MountedHost)
func unmount(
target: TargetType,
with component: MountedHost,
completion: @escaping () -> ()
)
}
extension Renderer {
public func mount(with node: AnyNode, to parent: Target) -> Mounted {
public func mount(with node: AnyNode, to parent: TargetType) -> Mounted {
let result: Mounted = node.makeMountedComponent(parent)
if let reconciler = reconciler {
result.mount(with: reconciler)

View File

@ -10,12 +10,11 @@ import Dispatch
public final class StackReconciler<R: Renderer> {
private var queuedRerenders = Set<MountedCompositeComponent<R>>()
public let rootTarget: R.Target
public let rootTarget: R.TargetType
private let rootComponent: MountedComponent<R>
private(set) weak var renderer: R?
private var hooks = Hooks()
public init(node: AnyNode, target: R.Target, renderer: R) {
public init(node: AnyNode, target: R.TargetType, renderer: R) {
self.renderer = renderer
rootTarget = target
@ -24,12 +23,12 @@ public final class StackReconciler<R: Renderer> {
rootComponent.mount(with: self)
}
func queue(state: Any,
func queue(updater: (inout Any) -> (),
for component: MountedCompositeComponent<R>,
id: Int) {
let scheduleReconcile = queuedRerenders.isEmpty
component.state[id] = state
updater(&component.state[id])
queuedRerenders.insert(component)
guard scheduleReconcile else { return }
@ -48,26 +47,14 @@ public final class StackReconciler<R: Renderer> {
}
func render(component: MountedCompositeComponent<R>) -> AnyNode {
var stateIndex = 0
hooks.currentState = { [weak component] in
defer { stateIndex += 1 }
guard let component = component else { return ($0, stateIndex) }
if component.state.count > stateIndex {
return (component.state[stateIndex], stateIndex)
} else {
component.state.append($0)
return ($0, stateIndex)
}
}
// Avoiding an indirect reference cycle here: this closure can be
// owned by callbacks owned by node's target, which is strongly referenced
// by the reconciler.
hooks.queueState = { [weak self, weak component] in
let hooks = Hooks(
component: component
) { [weak self, weak component] id, updater in
guard let component = component else { return }
self?.queue(state: $0, for: component, id: $1)
self?.queue(updater: updater, for: component, id: id)
}
let result = component.type.render(
@ -76,8 +63,20 @@ public final class StackReconciler<R: Renderer> {
hooks: hooks
)
hooks.currentState = nil
hooks.queueState = nil
DispatchQueue.main.async {
for i in hooks.scheduledEffects {
if component.effectFinalizers.count > i {
component.effectFinalizers[i]?()
component.effectFinalizers[i] = component.effects[i].1()
} else {
component.effectFinalizers.append(component.effects[i].1())
}
}
}
// clean up `component` reference to enable assertions when hooks are called
// outside of `render`
hooks.component = nil
return result
}

View File

@ -1,84 +0,0 @@
//
// Store.swift
// Gluon
//
// Created by Max Desiatov on 12/10/2018.
//
/// Generic undo-redo manager that doesn't require rollback implementation in
/// `apply` of a wrapped store. It saves previous snapshots of state in a stack
/// and undo/redo actions allow traversing the state history. This might be
/// not very efficient in terms of memory, but only requires an initial
/// state and can recreate subsequent snapshots by calling `apply` with
/// a sequence of actions. This means you can add undo-redo history to
/// any `Store`.
public struct History<S: Store>: Store {
public enum Action {
case branch(S.Action)
case undo
case redo
}
private var wrapped: S
private var history = [S.State]()
private var historyIndex: Int
public var state: S.State {
return history[historyIndex]
}
public mutating func apply(action: Action) {
switch action {
case let .branch(wrappedAction):
wrapped.apply(action: wrappedAction)
history.removeSubrange(historyIndex..<history.count)
history.append(wrapped.state)
historyIndex = history.count - 1
case .undo:
guard historyIndex > 0 else { return }
historyIndex -= 1
case .redo:
guard historyIndex < history.count - 1 else { return }
historyIndex += 1
}
}
}
public protocol Store {
associatedtype State
associatedtype Action
var state: State { get }
mutating func apply(action: Action)
}
// FIXME: need context working for its implementation
// public final class StoreProvider<S>: Component<StoreProvider.Props, S>
// where S: Store & StateType {
// struct Props: Equatable {
// /// FIXME: should store comparison be optimised this way?
// let store: Unique<S>
// }
//
// func dispatch(action: S.Action) {
// setState { $0.apply(action: action) }
// }
//
// public func render() -> Node {
// // FIXME: create a context here
// return Node(children)
// }
// }
//
//typealias Dispatch<Action> = (Action) -> ()
//typealias Dispatcher<S: Store> =
// (state: S.State, dispatch: Dispatch<S.Action>)
// FIXME: when contexts are available read state and dispatch
// from the context and pass it to the mapper
// func storeAccess<S: Store>(_ mapper: (Dispatcher<S>) -> Node) {
//
// }

View File

@ -0,0 +1,14 @@
//
// Target.swift
// Gluon
//
// Created by Max Desiatov on 10/02/2019.
//
open class Target {
public internal(set) var node: AnyNode
public init(node: AnyNode) {
self.node = node
}
}

View File

@ -1,28 +0,0 @@
//
// Weak.swift
// Gluon
//
// Created by Max Desiatov on 27/01/2019.
//
public struct Weak<T: AnyObject> {
weak var value: T?
public init(value: T) {
self.value = value
}
}
extension Weak: Equatable {
public static func ==(lhs: Weak<T>, rhs: Weak<T>) -> Bool {
return lhs.value === rhs.value
}
}
extension Weak: Hashable {
public func hash(into hasher: inout Hasher) {
guard let value = value else { return }
hasher.combine(ObjectIdentifier(value))
}
}

View File

@ -34,13 +34,12 @@ public final class TestRenderer: Renderer {
public func update(
target: TestView,
with component: TestRenderer.MountedHost
) {
target.node = component.node
}
) {}
public func unmount(
target: TestView,
with component: TestRenderer.MountedHost
with component: TestRenderer.MountedHost,
completion: () -> ()
) {
target.removeFromSuperview()
}

View File

@ -10,13 +10,10 @@ import Gluon
/// A class that `TestRenderer` uses as a target.
/// When rendering to a `TestView` instance it is possible
/// to examine its `subviews` and `props` for testing.
public final class TestView {
public final class TestView: Target {
/// Subviews of this test view.
public private(set) var subviews: [TestView]
/// Props assigned to this test view.
public internal(set) var node: AnyNode
/// Parent `TestView` instance that owns this instance as a child
private weak var parent: TestView?
@ -30,8 +27,8 @@ public final class TestView {
*/
init(_ node: AnyNode,
_ subviews: [TestView] = []) {
self.node = node
self.subviews = subviews
super.init(node: node)
}
/** Add a subview to this test view.

View File

@ -104,6 +104,9 @@ final class GluonTableView: UITableView, Default {
final class TableViewBox<T: CellProvider>: ViewBox<GluonTableView> {
private let dataSource: DataSource<T>
// this delegate stays as a constant and doesn't create a reference cycle
// swiftlint:disable:next weak_delegate
private let delegate: Delegate<T>
var props: ListView<T>.Props {

View File

@ -11,7 +11,7 @@ import UIKit
class ViewControllerBox<T: UIViewController>: UITarget {
let containerViewController: T
init(_ viewController: T, _ node: AnyNode?) {
init(_ viewController: T, _ node: AnyNode) {
containerViewController = viewController
super.init(node: node)
}

View File

@ -20,7 +20,7 @@ extension ModalPresenter: UIHostComponent {
// FIXME: update presentation-related props on the target here
}
static func unmount(target: UITarget) {
target.viewController.dismiss(animated: true)
static func unmount(target: UITarget, completion: @escaping () -> ()) {
target.viewController.dismiss(animated: true) { completion() }
}
}

View File

@ -32,6 +32,18 @@ final class GluonNavigationController: UINavigationController {
}
}
// FIXME: is this reliable enough? Will this work for
// `GluonNavigationController` without a navigation bar? Can you even create
// one without a navigation bar?
extension GluonNavigationController: UINavigationBarDelegate {
func navigationBar(
_ navigationBar: UINavigationBar,
didPop item: UINavigationItem
) {
onPop()
}
}
extension NavigationController: UIHostComponent {
static func mountTarget(to parent: UITarget,
component: UIKitRenderer.MountedHost,
@ -55,8 +67,8 @@ extension NavigationController: UIHostComponent {
// FIXME: this `case` handler is duplicated with `UIViewComponent`,
// should this be generalised as a protocol?
case let box as ViewControllerBox<UIViewController>
where parent.node?.isSubtypeOf(ModalPresenter.self) ?? false:
guard let props = parent.node?.props.value as? ModalPresenter.Props else {
where parent.node.isSubtypeOf(ModalPresenter.self):
guard let props = parent.node.props.value as? ModalPresenter.Props else {
propsAssertionFailure()
return nil
}
@ -69,7 +81,7 @@ extension NavigationController: UIHostComponent {
}
case let box as ViewBox<UIView>:
result.viewController.willMove(toParent: box.viewController)
// FIXME: replace with constraints
// FIXME: replace with auto layout constraints
result.viewController.view.frame = box.view.frame
box.view.addSubview(result.viewController.view)
box.viewController.addChild(result.viewController)
@ -84,5 +96,7 @@ extension NavigationController: UIHostComponent {
static func update(target: UITarget, node: AnyNode) {}
static func unmount(target: UITarget) {}
static func unmount(target: UITarget, completion: () -> ()) {
completion()
}
}

View File

@ -33,7 +33,7 @@ extension NavigationItem: UIHostComponent {
}
guard
let parentProps = parent.node?.props.value as? NavigationController.Props
let parentProps = parent.node.props.value as? NavigationController.Props
else {
propsAssertionFailure()
return nil
@ -67,5 +67,5 @@ extension NavigationItem: UIHostComponent {
item.largeTitleDisplayMode = .init(mode: props.titleMode)
}
static func unmount(target: UITarget) {}
static func unmount(target: UITarget, completion: () -> ()) { completion() }
}

View File

@ -21,10 +21,9 @@ protocol UIHostComponent: AnyHostComponent {
_ renderer: UIKitRenderer
) -> UITarget?
static func update(target: UITarget,
node: AnyNode)
static func update(target: UITarget, node: AnyNode)
static func unmount(target: UITarget)
static func unmount(target: UITarget, completion: @escaping () -> ())
}
extension UIHostComponent {

View File

@ -96,11 +96,11 @@ extension UIViewComponent where Target == Target.DefaultValue,
let target = Target.defaultValue
let result: ViewBox<Target>
let parentRequiresViewController = parent.node?.isSubtypeOf(
let parentRequiresViewController = parent.node.isSubtypeOf(
ModalPresenter.self,
or: NavigationController.self,
or: AnyTabPresenter.self
) ?? false
)
// UIViewController parent target can't present a bare `ViewBox` target,
// it needs to be wrapped with `ContainerViewController` first.
@ -126,8 +126,8 @@ extension UIViewComponent where Target == Target.DefaultValue,
case let box as ViewBox<GluonTableCell>:
box.view.addSubview(target)
case let box as ViewControllerBox<GluonNavigationController>
where parent.node?.isSubtypeOf(NavigationController.self) ?? false:
guard let props = parent.node?.props.value
where parent.node.isSubtypeOf(NavigationController.self):
guard let props = parent.node.props.value
as? NavigationController.Props else {
propsAssertionFailure()
return nil
@ -138,9 +138,9 @@ extension UIViewComponent where Target == Target.DefaultValue,
animated: props.pushAnimated
)
case let box as ViewControllerBox<UIViewController>
where parent.node?.isSubtypeOf(ModalPresenter.self) ?? false:
where parent.node.isSubtypeOf(ModalPresenter.self):
guard
let props = parent.node?.props.value as? ModalPresenter.Props
let props = parent.node.props.value as? ModalPresenter.Props
else {
propsAssertionFailure()
return nil
@ -150,7 +150,7 @@ extension UIViewComponent where Target == Target.DefaultValue,
animated: props.presentAnimated,
completion: nil)
case let box as ViewControllerBox<UIViewController>
where parent.node?.isSubtypeOf(NavigationItem.self) ?? false:
where parent.node.isSubtypeOf(NavigationItem.self):
box.viewController.view.addSubview(target)
default:
parentAssertionFailure()
@ -182,12 +182,14 @@ extension UIViewComponent where Target == Target.DefaultValue,
update(view: target, props, children)
}
static func unmount(target: UITarget) {
static func unmount(target: UITarget, completion: () -> ()) {
switch target {
case let target as ViewBox<Target>:
target.view.removeFromSuperview()
default:
targetAssertionFailure()
}
completion()
}
}

View File

@ -28,13 +28,7 @@ struct HackyProvider: SimpleCellProvider {
typealias Model = [[Int]]
}
public class UITarget {
let node: AnyNode?
init(node: AnyNode?) {
self.node = node
}
class UITarget: Target {
var viewController: UIViewController {
fatalError("viewController should be overriden in UITarget subclass")
}
@ -74,8 +68,10 @@ final class UIKitRenderer: Renderer {
self)
}
func update(target: UITarget,
with component: UIKitRenderer.MountedHost) {
func update(
target: UITarget,
with component: UIKitRenderer.MountedHost
) {
guard let rendererComponent = component.type as? UIHostComponent.Type else {
typeAssertionFailure(for: component.type)
return
@ -85,13 +81,16 @@ final class UIKitRenderer: Renderer {
node: component.node)
}
func unmount(target: UITarget,
with component: UIKitRenderer.MountedHost) {
func unmount(
target: UITarget,
with component: UIKitRenderer.MountedHost,
completion: @escaping () -> ()
) {
guard let rendererComponent = component.type as? UIHostComponent.Type else {
typeAssertionFailure(for: component.type)
return
}
rendererComponent.unmount(target: target)
rendererComponent.unmount(target: target, completion: completion)
}
}

View File

@ -10,6 +10,22 @@ import XCTest
@testable import Gluon
extension Int: Updatable {
public enum Action {
case increment
case decrement
}
public mutating func update(_ action: Int.Action) {
switch action {
case .decrement:
self -= 1
case .increment:
self += 1
}
}
}
private extension Hooks {
func custom() -> State<Int> {
return state(42)
@ -22,14 +38,18 @@ struct Test: LeafComponent {
static func render(props: Null, hooks: Hooks) -> AnyNode {
let state1 = hooks.custom()
let state2 = hooks.custom()
let state3 = hooks.custom()
return StackView.node([
Button.node(.init(onPress: Handler { state1.set { $0 + 1 } }),
Button.node(.init(onPress: Handler { state1.set { $0 += 1 } }),
"Increment"),
Label.node("\(state1.value)"),
Button.node(.init(onPress: Handler { state2.set { $0 + 1 } }),
"Increment"),
Label.node("\(state2.value)"),
Button.node(.init(onPress: Handler { state3.set(.increment) }),
"Increment"),
Label.node("\(state3.value)"),
])
}
}
@ -41,20 +61,33 @@ final class HooksTests: XCTestCase {
let stack = root.subviews[0]
guard let buttonProps = stack.subviews[0].props(Button.Props.self),
let buttonHandler = buttonProps.handlers[.touchUpInside]?.value else {
guard
let button1Props = stack.subviews[0].props(Button.Props.self),
let button1Handler = button1Props.handlers[.touchUpInside]?.value,
let button2Props = stack.subviews[2].props(Button.Props.self),
let button2Handler = button2Props.handlers[.touchUpInside]?.value,
let button3Props = stack.subviews[4].props(Button.Props.self),
let button3Handler = button3Props.handlers[.touchUpInside]?.value
else {
XCTAssert(false, "components have no handlers")
return
}
buttonHandler(())
button1Handler(())
button2Handler(())
button2Handler(())
button3Handler(())
button3Handler(())
button3Handler(())
let e = expectation(description: "rerender")
DispatchQueue.main.async {
XCTAssertEqual(stack.subviews[1].node.children, AnyEquatable("43"))
XCTAssertEqual(stack.subviews[3].node.children, AnyEquatable("42"))
XCTAssertEqual(stack.subviews[3].node.children, AnyEquatable("44"))
XCTAssertEqual(stack.subviews[5].node.children, AnyEquatable("45"))
e.fulfill()
}