7b119e8d0a | ||
---|---|---|
.github | ||
Example | ||
Sources | ||
Tests | ||
Tokamak.xcodeproj | ||
ValidationTests | ||
tokamak.dev | ||
.editorconfig | ||
.gitignore | ||
.hound.yml | ||
.swiftformat | ||
.swiftlint.yml | ||
CHANGELOG.md | ||
CODE_OF_CONDUCT.md | ||
LICENSE | ||
Package.resolved | ||
Package.swift | ||
Package.xcconfig | ||
README.md | ||
Tokamak.podspec | ||
TokamakAppKit.podspec | ||
TokamakCounter.gif | ||
TokamakCounterAppKit.gif | ||
TokamakDemo.podspec | ||
TokamakNetworking.gif | ||
TokamakUIKit.podspec | ||
azure-pipelines.yml | ||
codecov.sh | ||
codecov.yml | ||
lint.sh | ||
test.sh |
README.md
Tokamak
React-like framework for native UI written in pure Swift 🛠⚛️📲
Tokamak provides a declarative, testable and scalable API for building UI components backed by fully native views. You can use it for your new iOS apps or add to existing apps with little effort and without rewriting the rest of the code or changing the app's overall architecture.
Important: SwiftUI compatibility
Current Tokamak API was designed and built at the end of 2018, months before SwiftUI was announced. We do think that SwiftUI is the future of development not only for Apple's platforms, but potentially other platforms too (Android, WebAssembly, Windows, or your platform of choice). Thus, the short-term goal of Tokamak is to make the API more compatible with SwiftUI. All contributions that help us in achieving this goal are much appreciated. You can consider the current Tokamak API as deprecated, but still usable for research and experimentation purposes.
Why yet another React-like library?
In the current version Tokamak recreates React Hooks API improving it with Swift's strong type system, high performance and efficient memory management thanks to being compiled to a native binary.
One of the strong points of React is that in general it makes app architecture more declarative, but still preserves a smooth learning curve when compared to more complex FRP patterns. In addition, its cross-platform reconciler core can be reused across many different platforms: on the web, for mobile apps, and even desktop. The downside is that it requires you to use JavaScript, which causes all sorts of different problems. It's also not very easy to integrate React Native with existing iOS apps written in Swift. On the other hand, none of the available libraries similar to React written in Swift provided a concise API for building component hierarchies, or allowed building stateful function-based components similar to what's possible with React Hooks. Some only port Redux, which requires a lot of boilerplate, some bake in assumptions about the usage of UIKit, which makes them restricted to a single platform.
In short, both plain UIKit MVC and React have advantages and disadvantages. The goal of Tokamak is to provide the best of both worlds for Swift: declarative architecture, cross-platform core, easy to learn and to integrate into existing apps.
When compared to standard UIKit MVC or other patterns built on top of it (MVVM, MVP, VIPER etc), Tokamak provides:
-
Declarative DSL for native UI: no more conflicts caused by Storyboards, no template languages or XML. Describe UI of your app concisely in Swift and get views native to iOS with full support for accessibility, auto layout and native navigation gestures.
-
Easy to use one-way data binding: tired of
didSet
, delegates, notifications or KVO? UI components automatically update in response to state changes. -
Clean composable architecture: components can be passed to other components as children with an established API focused on code reuse. You can easily embed Tokamak components within your existing UIKit code or vice versa: expose that code as Tokamak components. No need to decide whether you should subclass
UIView
orUIViewController
to make your UI composable. -
Off-screen rendering for unit-tests: no need to maintain slow and flaky UI tests that render everything on a simulator screen and simulate actual touch events to just test UI logic. Components written with Tokamak can be tested off-screen with tests completing in a fraction of a second. If your UI doesn't require any code specific to
UIKit
(and Tokamak provides helpers to achieve that) you can even run your UI-related unit-tests on Linux! -
Platform-independent core: our main goal is to eventually support as many platforms as possible. Starting with iOS/UIKit and basic support for macOS/AppKit, we plan to add renderers for WebAssembly/DOM and native Android in future versions. As the core API is cross-platform, UI components written with Tokamak won't need to change to become available on newly added platforms unless you need UI logic specific to a device or OS. And if they do, you can still cleanly separate platform-specific components thanks to easy composition.
-
Architecture proven to work: React has been available for years and gained a lot of traction and is still growing. We've seen so many apps successfully rebuilt with it and heard positive feedback on React itself, but we also see a lot of complaints about its overreliance on JavaScript. Tokamak makes architecture of React with its established patterns available to you in Swift.
Don't forget to check out Tokamak community on Spectrum and leave your feedback, comments and questions!
Table of contents
- Example code
- Example project
- Standard components
- Quick introduction
- Requirements
- Installation
- FAQ
- Acknowledgments
- Contributing
- Maintainers
- License
Example code
Counter
An example of a Tokamak component that binds a button to a label looks like this:
import Tokamak
struct Counter: LeafComponent {
struct Props: Equatable {
let countFrom: Int
}
static func render(props: Props, hooks: Hooks) -> AnyNode {
let count = hooks.state(props.countFrom)
return StackView.node(.init(
Edges.equal(to: .parent),
axis: .vertical,
distribution: .fillEqually), [
Button.node(Button.Props(
onPress: Handler { count.set { $0 + 1 } },
text: "Increment"
)),
Label.node(.init(alignment: .center, text: "\(count.value)"))
])
}
}
Then you can add this component to any iOS app as a view controller this way:
import Tokamak
import TokamakUIKit
final class ViewController: TokamakViewController {
override var node: AnyNode {
return Counter.node(.init(countFrom: 1))
}
}
Or similarly it can be added to a macOS app:
import Tokamak
import TokamakAppKit
final class ViewController: TokamakViewController {
override var node: AnyNode {
return View.node(
.init(Style([
Edges.equal(to: .parent),
Width.equal(to: 200),
Height.equal(to: 100),
])),
Counter.node(.init(countFrom: 1))
)
}
}
Note that we added explicit constraints to use this as a window's root view controller, and windows don't have a fixed predefined size by default.
Networking
Tokamak allows you to easily express asynchronous state changes. Here's
an example of loading content from https://httpbin.org/drip
URL:
Code that implements this component looks like this:
import Alamofire
import Tokamak
struct NetworkDemo: LeafComponent {
typealias Props = Null
enum State {
case initial
case loading
case finished(Result<String>)
}
static func render(props: Null, hooks: Hooks) -> AnyNode {
let state = hooks.state(State.initial)
let style = Style(Edges.equal(to: .safeArea, inset: 10))
switch state.value {
case .initial:
return Button.node(.init(
style,
onPress: Handler {
state.set(.loading)
Alamofire.request("https://httpbin.org/drip").responseString {
state.set(.finished($0.result))
}
},
text: "Load"
))
case .loading:
return View.node(
.init(Style(
[Size.equal(to: 100), Center.equal(to: .parent)],
backgroundColor: .black,
cornerRadius: 10
)),
Throbber.node(
.init(
style,
isAnimating: true,
variety: .whiteLarge
)
)
)
case let .finished(.success(value)):
return Label.node(.init(
style,
alignment: .center,
text: value
))
case let .finished(.failure(error)):
return Label.node(.init(
style,
alignment: .center,
text: error.localizedDescription
))
}
}
}
Example project
The best way to try Tokamak in action is to run the example project:
- Verify that you have CocoaPods and Xcode 10.1 or later installed:
pod --version
xcode-select -p
- Clone the repository
git clone https://github.com/MaxDesiatov/Tokamak
- Install the dependencies in the example project:
cd Tokamak/Example
pod install
- Open the
Example
workspace from Finder or from Terminal:
open -a Xcode *.xcworkspace
- Build executable target
TokamakDemo-iOS
for iOS andTokamakDemo-macOS
for macOS.
Standard components
Tokamak provides a few basic components that you can reuse in your apps. On iOS
these components are rendered to corresponding UIView
subclasses that you're
already used to, e.g. Button
component is rendered as UIButton
, Label
as
UILabel
etc. Check out the complete up to date
list
for more info.
Quick introduction
We try to keep Tokamak's API as simple as possible and the core algorithm with supporting protocols/structures currently fit in only ~600 lines of code. It's all built upon a few basic concepts:
Props
Props
describe a "configuration" of what you'd like to see on user's screen.
An example could be a struct
describing background color, layout, initial
value etc. Props
are immutable and
Equatable
, which
allows us to observe when they change. You always use struct
or enum
and
never use class
for props so that immutability is guaranteed. You wouldn't
ever need to provide your own Equatable
implementation for Props
as Swift
compiler is able to generate one for you automatically behind the
scenes.
Here's a simple Props
struct you could use for your own component like
Counter
from the example above:
struct Props: Equatable {
let countFrom: Int
}
Children
Sometimes "configuration" is described in a tree-like fashion. For example, a
list of views contains an array of subviews, which themselves can contain other
subviews. In Tokamak this is called Children
, which behave similar to
Props
but are important enough to be treated separately. Children
are also immutable and Equatable
, which allows us to observe those for changes
too.
Components
Component
is a protocol, which couples given Props
and
Children
on screen and provides some declaration how these are
rendered on screen:
protocol Component {
associatedtype Props: Equatable
associatedtype Children: Equatable
}
(Don't worry if you don't know what associatedtype
means, it's only a simple
requirement for components to provide these types and make them Equatable
. If
you do know what a PAT is, you
also shouldn't worry. 😄 Tokamak's API is built specifically to hide "sharp
edges" of PATs from the public API and to make it easy to use without requiring
advanced knowledge of Swift. This is similar to what Swift standard
library
has done, which is built on top of PATs but stays flexible and ergonomic).
Nodes
A node is a container for Props
, Children
and a type
conforming to Component
rendering this "configuration". If
you're familiar with React, nodes in Tokamak correspond to elements in
React. When Children
is an
array of nodes, we can indirectly form a tree describing the app's UI.
Corollary, nodes are immutable and Equatable
. You'd only need to use the
standard AnyNode
type provided by Tokamak:
struct AnyNode: Equatable {
// ... `Props` and `Children` stored here by Tokamak as private properties
}
Here's an example of an array of nodes used as Children
in the standard
StackView
component provided by Tokamak, which describe subviews of the stack
view.
struct StackView: Component {
struct Props: Equatable {
// ...
}
typealias Children = [AnyNode]
}
For every component Tokamak provides an easy way to create a node for it coupled with given props and children:
// this extension and its `node` function are defined for you by Tokamak
extension Component {
static func node(_ props: Props, _ children: Children) -> AnyNode {
// ...
}
}
For example, an empty vertical stack view is created like this:
StackView.node(.init(axis: .vertical), [])
Render function
The most simple component is a pure
function taking Props
and Children
as arguments and returning a node tree as a
result:
protocol PureComponent: Component {
// this is the function you define for your own components,
// Tokamak takes care of the rest
static func render(props: Props, children: Children) -> AnyNode
}
Tokamak calls render
on your components when their Props
or Children
passed from parent components change. You don't ever need to call render
yourself, pass different values as props or children to nodes returned from
parent render
and Tokamak will update only those views on screen that need to
be updated.
Note that render
function does not return other components, it returns
nodes that describe other components. It's a very important distiction,
which allows Tokamak to stay efficient and to avoid updating deep trees of
components.
Here's an example of a simple component that renders its child in a vertical
stack as many times as were passed via its Props
:
struct StackRepeater: PureComponent {
typealias Props = UInt
typealias Children = AnyNode
static func render(props x: UInt, children: AnyNode) -> AnyNode {
return StackView.node(
.init(axis: .vertical),
(0..<x).map { _ in children }
)
}
}
You can then use StackRepeater
in any other component by creating its node
and passing any other node as a child this way:
StackRepeater.node(5, Label.node("repeated"))
which will present a label on screen with text "repeated"
5 times.
Leaf components
Some of your components wouldn't need Children
at all, for those Tokamak
provides a PureLeafComponent
helper protocol that allows you to implement only
a single function with a simpler signature:
// Helpers provided by Tokamak:
struct Null: Equatable {}
protocol PureLeafComponent: PureComponent where Children == Null {
static func render(props: Props) -> AnyNode
}
extension PureLeafComponent {
static func render(props: Props, children: Children) -> AnyNode {
return render(props: props)
}
}
Thus your components can conform to PureLeafComponent
instead of
PureComponent
, which allows you to avoid children
argument in a render
function when you don't need it.
Hooks
Quite frequently you need components that are stateful or cause some other
side effects.
Hooks
provide a clear separation between declarative components and other
imperative code, such as state management, file I/O, networking etc.
The standard protocol CompositeComponent
allows Hooks
to be injected into
the render
function as an argument.
protocol CompositeComponent: Component {
static func render(
props: Props,
children: Children,
hooks: Hooks
) -> AnyNode
}
In fact, the standard PureComponent
is a special case of a
CompositeComponent
that doesn't use Hooks
during rendering:
// Helpers provided by Tokamak:
protocol PureComponent: CompositeComponent {
static func render(props: Props, children: Children) -> AnyNode
}
extension PureComponent {
static func render(
props: Props,
children: Children,
hooks: Hooks
) -> AnyNode {
return render(props: props, children: children)
}
}
One of the simplest hooks is state
. It allows a component to have its own
state and to be updated when the state changes. We've seen it used in the
Counter
example:
struct Counter: LeafComponent {
// ...
static func render(props: Props, hooks: Hooks) -> AnyNode {
// type signature for this constant is inferred automatically
// and is only added here for documentation purposes
let count: State<Int> = hooks.state(1)
// ...
}
}
It returns a very simple state container, which on initial call of render
contains 1
as a value and values passed to count.set(_: Int)
on subsequent
updates:
// values of this type are returned by `hooks.state`
struct State<T> {
let value: T
// set the state to a value you already have
func set(_ value: T)
// or update the state with a pure function
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) -> ())
}
Note that set
functions are not mutating
, they never update the component's
state in-place synchronously, but only schedule an update with Tokamak at a later
time. A call to render
is only scheduled on the component that obtained this
state with hooks.state
.
When you need state changes to update any of the descendant components, you can
pass the state value within props or children of nodes returned from render
.
In Counter
component the label's content is "bound" to
count
this way:
struct Counter: LeafComponent {
static func render(props: Null, hooks: Hooks) -> AnyNode {
let count = hooks.state(1)
return StackView.node([
Button.node(.init(
onPress: Handler { count.set { $0 + 1 } },
text: "Increment"
)),
Label.node("\(count.value)"),
])
}
}
Hooks provide a great way to compose side effects and also to keep them separate
from your component code. You can always create your own hook reusing existing
ones: just add it to your extension Hooks
wherever works best for you.
Renderers
When mapping Tokamak's architecture to what's previosly been established in iOS,
Component
corresponds to a "view-model" layer, while
Hooks
provide a reusable "controller" layer. A Renderer
is a
"view" layer in these terms, but it's fully managed by Tokamak. Not only this
greatly simplifies the code of your components and allows you to make it
declarative, it also completely decouples platform-specific code.
Note that Counter
component above doesn't contain a single
type from UIKit
module, although the component itself is passed to a specific
UIKitRenderer
(via its TokamakViewController
public API) to make it
available in an app that uses UIKit
. On other platforms you could use a
different renderer, while the component code could stay the same if its behavior
doesn't need to change for that environment. Otherwise you can adjust
component's behavior via Props
and pass different "initializing"
props depending on the renderer's platform.
Providing renderers for other platforms in the future is one of our top
priorities. Tokamak already provides basic support for macOS apps in
TokamakAppKit
module that allows you to render same standard components on iOS
and macOS without any changes applied to the component code and without
requiring Marzipan!
Requirements
- iOS 11.0 or later for
TokamakUIKit
- macOS 10.14 for
TokamakAppKit
- Xcode 10.1 or later
- Swift 4.2 or later
Installation
CocoaPods
CocoaPods is a dependency manager for Swift and Objective-C Cocoa projects. You can install it with the following command:
$ gem install cocoapods
Navigate to the project directory and create Podfile
with the following
command:
$ pod install
Inside of your Podfile
, specify the Tokamak
pod:
# Uncomment the next line to define a global platform for your project
# platform :ios, '11.0'
target 'YourApp' do
# Comment the next line if you don't want to use dynamic frameworks
use_frameworks!
# Pods for YourApp
pod 'TokamakUIKit', '~> 0.1'
end
Then, run the following command:
$ pod install
Open the the YourApp.xcworkspace
file that was created. This should be the
file you use everyday to create your app, instead of the YourApp.xcodeproj
file.
FAQ
What are "Rules of Hooks"?
Hooks are a great way to inject state and other side effects into pure functions. In some sense, you could consider Hooks an emulation of indexed monads or algebraic effects, which served as inspiration for Hooks in React. Unfortunately, due to Swift's current limitations, we can't express monads or algebraic effects natively, so Hooks need a few restrictions applied to make it work. Similar restrictions are also applied to Hooks in React:
- You can call Hooks from
render
function of any component. 👍 - You can call Hooks from your custom Hooks (defined by you in an
extension
ofHooks
). 🙌 - Don't call Hooks from a loop, condition or nested function/closure. 🚨
- Don't call Hooks from any function that's not a
static func render
on a component, or not a custom Hook. ⚠️
In a future version Tokamak will provide a linter able to catch violations of Rules of Hooks at compile time.
Why do Rules of Hooks exist?
Same as
React,
Tokamak maintains an array of "memory cells" for every stateful component to
hold the actual state. It needs to distinguish one Hooks call from another to
map those to corresponding cells during execution of a render
function of your
component. Consider this:
struct ConditionalCounter: LeafComponent {
typealias Props = Null
static func render(props: Props, hooks: Hooks) -> AnyNode {
// this code won't work as expected as it violates Rule 3:
// > Don't call Hooks from a condition
// state stored in "memory cell" 1
let condition = hooks.state(false)
if condition {
// state stored in "memory cell" 2
count = hooks.state(0)
} else {
// state, which should be stored in "memory cell" 3,
// but will be actually queried from "memory cell" 2
count = hooks.state(42)
}
return StackView.node([
Switch.node(.init(value: condition.value,
valueHandler: Handler(condition.set)))
Button.node(.init(
onPress: Handler { count.set { $0 + 1 } },
text: "Increment"
)),
Label.node("\(count.value)"),
])
}
}
How does Tokamak renderer know on subsequent calls to
ConditionalCounter.render
which state you're actually addressing? It relies on
the order of those calls, so if the order dynamically changes from one rendering
to another, you could unexpectedly get a value of the one state cell, when you
expected a value of a different state cell.
We encourage you to keep any hooks logic at the top level of a render
definition, which makes all side effects of a component clear upfront and is a
good practice anyway. If you do need conditions or loops applied, you can always
create a separate component and return a node conditionally or an array of nodes
for this new child component from render
of a parent component. The fixed
version of ConditionalCounter
would look like this:
struct ConditionalCounter: LeafComponent {
typealias Props = Null
static func render(props: Props, hooks: Hooks) -> AnyNode {
// this works as expected
let condition = hooks.state(false)
let count1 = hooks.state(0)
let count2 = hooks.state(42)
let actualState = condition ? count1 : count2
return StackView.node([
Switch.node(.init(value: condition.value,
valueHandler: Handler(condition.set)))
Button.node(.init(
onPress: Handler { actualState.set { $0 + 1 } },
text: "Increment"
)),
Label.node("\(actualState.value)"),
])
}
}
Why does Tokamak use value types and protocols instead of classes?
Swift developers focused on GUI might be used to classes thanks to abundance of
class hierarchies in UIKit
and AppKit
(although benefits of composition
over
inheritance
were previously highlighted by Apple). Unfortunately, while UIKit
is a
relatively fresh development, it still closely follows many patterns used in
AppKit
, which was itself built in late
80s. Both of these were developed with
Objective-C in mind, years before Swift became public and protocol-oriented
patterns were
established.
One of the main goals of Tokamak is to build a UI framework that feels native to Swift. Tokamak's API brings these benefits when compared to class-based APIs:
- no need to subclass
NSObject
to conform to commonly used protocols; - no need to use
override
and to remember to callsuper
; - no need for
required init
,convenience init
or to be concerned with strict class initialization rules; - you can't create a reference cycle with immutable values, no need for
weakSelf/strongSelf
dance when using callbacks; - you don't need to worry about modifying an object in a different scope accidentaly captured by reference: immutable values are implicitly copied and most of the copies are removed by the compiler during optimization;
- focus on composition over inheritance: no need to subclass
UIViewController
orUIView
and to worry about all of the above when you only need simple customization; - focus on functional and declarative programming, while still allowing to use imperative code when needed: value types guarantee lack of unexpected side effects in pure functions.
Is there anything like JSX available for Tokamak?
At the moment the answer is no, but we find that Tokamak's API allows you to
create nodes much more concisely when compared to React.createElement
syntax. In fact, with
Tokamak's .node
API you don't need closing element tags you'd have to write
with JSX. E.g. compare this:
Label.node("label!")
to this:
<Label>label!</Label>
We do agree that there's an overhead of .init
for props and a requirement of
props initializer arguments to be ordered. For the latter, we have a helpful
convention in Tokamak that all named arguments to props initializers should be
ordered alphabetically.
The main problem is that currently there's no easily extensible Swift parser or a macro system available that would allow something like JSX to be used for Tokamak. As soon is it becomes easy to implement, we'd definitely consider it as an option.
Why is render
function static
on Component
protocol?
With an alternative approach to API design of a framework like this we could
define components as plain functions, which wouldn't need to be static
:
func counter(hooks: Hooks) -> AnyNode {
// ...
}
The problem here is that we need equality comparison on components to be able
to define Equatable
on AnyNode
. This isn't available for plain functions:
let x = counter
let y = counter
// won't compile
x == y
// won't compile: reference equality is also not defined on functions,
// even though functions are reference types ¯\_(ツ)_/¯
x === y
Protocols and structs with static
functions allow us to work around this and
to formalise an hierarchy of different kinds of components with protocols and
Equatable
constraints:
// equality comparison is available for types
struct Counter {
static func render(hooks: Hooks) -> AnyNode {
// ...
}
}
// Tokamak does something like this internally for your components,
// consider following a pseudocode:
let xComponent = Counter.self
let yComponent = OtherComponent.self
var rendered: AnyNode?
if xComponent != yComponent {
rendered = xComponent.render()
}
We could remove static
from render
on Component
protocol, but this makes
possible adding and referencing instance properties from a non-static
version
of render
. Components could become inadvertently stateful that way, hiding the
fact that components are actually functions, not instances. Consider this
hypothetical API:
struct Counter {
// this makes `Counter` component stateful,
// but prevents observing state changes
var count = 0
// no `static` here, which makes `var` above accessible
func render() -> AnyNode {
return Label.node("\(count)")
}
}
Now there's direct access to component's state, but we aren't able to easily
schedule updates of the component tree when this state changes. We could require
authors of components to implement
didSet
on every instance property, but this is cumbersome and hard to enforce.
Marking render
as static
makes it harder to introduce unobservable local
state, while intended local state is managed with Hooks
.
Acknowledgments
- Thanks to the Swift community for building one of the best programming languages available!
- Thanks to React people for building a UI framework that is practical and elegant, while keeping it usable with JavaScript at the same time. 😄
- Thanks to Render, ReSwift, Katana UI and Komponents for inspiration!
Contributing
This project adheres to the Contributor Covenant Code of Conduct. By participating, you are expected to uphold this code. Please report unacceptable behavior to conduct@tokamak.dev.
Maintainers
Max Desiatov, Matvii Hodovaniuk
License
Tokamak is available under the Apache 2.0 license. See the LICENSE file for more info.