Add Snake Game to Example list (#56)

* Init snake game

* Replace [Any] state with [UnsafeMutableRawPointer]

* Broke ux

* Add Width init function

* Update Snake Game

* Remove ability to switch move direction to opposite

* Fix game board bound

* Add game over condition

* Add color to snake target

* Refactor code

* Add Gamepad to Snake Game

* Remove infinity loop

* Refactor code

* Refactor code

* Add Gameboard to Snake

* Reduce render function size

* Add Gamemenu

* Rename Gamemenu to GameMenu

* Format code

* Refactor code
This commit is contained in:
matvii 2019-02-27 18:38:31 +02:00 committed by GitHub
parent a570352abc
commit 604a60d251
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 628 additions and 17 deletions

View File

@ -13,6 +13,14 @@
607FACDD1AFB9204008FA782 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDC1AFB9204008FA782 /* Images.xcassets */; };
607FACE01AFB9204008FA782 /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDE1AFB9204008FA782 /* LaunchScreen.xib */; };
A62AC6542223F5CD009B3B25 /* TextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62AC6532223F5CD009B3B25 /* TextField.swift */; };
A62AC65622243CC3009B3B25 /* SnakeGame.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62AC65522243CC3009B3B25 /* SnakeGame.swift */; };
A62AC65922243DCB009B3B25 /* Cell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62AC65822243DCA009B3B25 /* Cell.swift */; };
A62AC65B22244A5A009B3B25 /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62AC65A22244A5A009B3B25 /* Game.swift */; };
A62AC65F22244A98009B3B25 /* Snake.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62AC65E22244A98009B3B25 /* Snake.swift */; };
A6654358222690DA00F01C04 /* Gamepad.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6654357222690DA00F01C04 /* Gamepad.swift */; };
A665435A22269F9F00F01C04 /* StartGame.swift in Sources */ = {isa = PBXBuildFile; fileRef = A665435922269F9F00F01C04 /* StartGame.swift */; };
A67717202226DC7C0028A6F3 /* Gameboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A677171F2226DC7C0028A6F3 /* Gameboard.swift */; };
A67717222226E7FD0028A6F3 /* Menu.swift in Sources */ = {isa = PBXBuildFile; fileRef = A67717212226E7FD0028A6F3 /* Menu.swift */; };
A6D5AF87221B131400DBF186 /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6D5AF86221B131400DBF186 /* Image.swift */; };
C449B806DFEE55B6CEE6478C /* libPods-TokamakDemo.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B96B435A9D67621D318616E /* libPods-TokamakDemo.a */; };
D11DB6432219C03000013FC3 /* Timer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11DB6422219C03000013FC3 /* Timer.swift */; };
@ -42,6 +50,14 @@
607FACEA1AFB9204008FA782 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
8CADEBB8BFF6F2621CA49E8F /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = "<group>"; };
A62AC6532223F5CD009B3B25 /* TextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextField.swift; sourceTree = "<group>"; };
A62AC65522243CC3009B3B25 /* SnakeGame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnakeGame.swift; sourceTree = "<group>"; };
A62AC65822243DCA009B3B25 /* Cell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cell.swift; sourceTree = "<group>"; };
A62AC65A22244A5A009B3B25 /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; };
A62AC65E22244A98009B3B25 /* Snake.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Snake.swift; sourceTree = "<group>"; };
A6654357222690DA00F01C04 /* Gamepad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gamepad.swift; sourceTree = "<group>"; };
A665435922269F9F00F01C04 /* StartGame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartGame.swift; sourceTree = "<group>"; };
A677171F2226DC7C0028A6F3 /* Gameboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gameboard.swift; sourceTree = "<group>"; };
A67717212226E7FD0028A6F3 /* Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Menu.swift; sourceTree = "<group>"; };
A6D5AF86221B131400DBF186 /* Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = "<group>"; };
A9EEF813955DAEEFE1D52ED4 /* Pods-TokamakDemo.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TokamakDemo.debug.xcconfig"; path = "Pods/Target Support Files/Pods-TokamakDemo/Pods-TokamakDemo.debug.xcconfig"; sourceTree = "<group>"; };
C6DA99382B6892EAB361742F /* LICENSE */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = LICENSE; path = ../LICENSE; sourceTree = "<group>"; };
@ -152,9 +168,25 @@
name = Frameworks;
sourceTree = "<group>";
};
A62AC65722243DAC009B3B25 /* SnakeGame */ = {
isa = PBXGroup;
children = (
A62AC65522243CC3009B3B25 /* SnakeGame.swift */,
A62AC65822243DCA009B3B25 /* Cell.swift */,
A62AC65A22244A5A009B3B25 /* Game.swift */,
A62AC65E22244A98009B3B25 /* Snake.swift */,
A6654357222690DA00F01C04 /* Gamepad.swift */,
A665435922269F9F00F01C04 /* StartGame.swift */,
A677171F2226DC7C0028A6F3 /* Gameboard.swift */,
A67717212226E7FD0028A6F3 /* Menu.swift */,
);
path = SnakeGame;
sourceTree = "<group>";
};
D1F7185122159D6E004E5951 /* Components */ = {
isa = PBXGroup;
children = (
A62AC65722243DAC009B3B25 /* SnakeGame */,
D1BFAF782215800A00845EA0 /* Counter.swift */,
D1F7185E2215A5D0004E5951 /* Constraints.swift */,
D1F7185222159E09004E5951 /* Controls.swift */,
@ -281,16 +313,24 @@
607FACD81AFB9204008FA782 /* ViewController.swift in Sources */,
A62AC6542223F5CD009B3B25 /* TextField.swift in Sources */,
D1F7185F2215A5D0004E5951 /* Constraints.swift in Sources */,
A62AC65922243DCB009B3B25 /* Cell.swift in Sources */,
D11DB6432219C03000013FC3 /* Timer.swift in Sources */,
A62AC65622243CC3009B3B25 /* SnakeGame.swift in Sources */,
D1F7185322159E09004E5951 /* Controls.swift in Sources */,
D1DEEC2922009E8000C525EE /* ModalRouter.swift in Sources */,
A6D5AF87221B131400DBF186 /* Image.swift in Sources */,
D1BFAF772215795900845EA0 /* Router.swift in Sources */,
A62AC65F22244A98009B3B25 /* Snake.swift in Sources */,
607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */,
D1F7185D2215A4A1004E5951 /* LayerProps.swift in Sources */,
A62AC65B22244A5A009B3B25 /* Game.swift in Sources */,
D1BFAF792215800A00845EA0 /* Counter.swift in Sources */,
D1F718612215A617004E5951 /* Modals.swift in Sources */,
A67717222226E7FD0028A6F3 /* Menu.swift in Sources */,
A665435A22269F9F00F01C04 /* StartGame.swift in Sources */,
A67717202226DC7C0028A6F3 /* Gameboard.swift in Sources */,
D1BB3D302223F6B400C30062 /* Animation.swift in Sources */,
A6654358222690DA00F01C04 /* Gamepad.swift in Sources */,
D1F7185522159EAD004E5951 /* DatePickers.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View File

@ -0,0 +1,33 @@
//
// GameCell.swift
// TokamakDemo
//
// Created by Matvii Hodovaniuk on 2/25/19.
// Copyright © 2019 Tokamak. All rights reserved.
//
import Tokamak
struct GameCell: PureLeafComponent {
struct Props: Equatable {
let color: Color
let size: Double
let location: Point
}
static func render(props: Props) -> AnyNode {
let location = props.location
let size = props.size
return View.node(
.init(
Style(
Rectangle(
Point(x: size * location.x, y: size * location.y),
Size(width: size, height: size)
),
backgroundColor: props.color
)
)
)
}
}

View File

@ -0,0 +1,106 @@
//
// Game.swift
// TokamakDemo
//
// Created by Matvii Hodovaniuk on 2/25/19.
// Copyright © 2019 Tokamak. All rights reserved.
//
import Tokamak
struct Game: Equatable {
enum State {
case initial
case gameOver
case isPlaying
}
var state = State.initial
enum Direction {
case up
case down
case left
case right
}
var currentDirection = Direction.up
var snake: [Point] = [
Point(x: 10.0, y: 10.0),
Point(x: 10.0, y: 11.0),
Point(x: 10.0, y: 12.0),
]
var target: Point = Point(x: 0.0, y: 1.0)
let mapSize: Size
private func moveHead(_ point: Point) -> Point {
var x = snake[0].x
var y = snake[0].y
switch currentDirection {
case .left:
x -= 1
if x < 0 {
x = mapSize.width - 1
}
case .up:
y -= 1
if y < 0 {
y = mapSize.height - 1
}
case .right:
x += 1
if x >= mapSize.width {
x = 0
}
case .down:
y += 1
if y >= mapSize.height {
y = 0
}
}
return Point(x: x, y: y)
}
public init(mapSize: Size) {
self.mapSize = mapSize
}
public init(state: State, mapSize: Size) {
self.state = state
self.mapSize = mapSize
}
}
extension Game {
mutating func tick() {
let head = moveHead(snake[0])
let isHeadOnTarget = head == target
if snake.contains(head) {
state = .gameOver
return
}
snake.insert(head, at: 0)
if !snake.isEmpty && !isHeadOnTarget {
snake.removeLast()
}
if isHeadOnTarget {
var newTarget = target
repeat {
newTarget = Point(
x: Double(Int.random(in: 0..<Int(mapSize.width))),
y: Double(Int.random(in: 0..<Int(mapSize.height)))
)
} while snake.contains(newTarget) || newTarget == target
target = newTarget
}
}
}

View File

@ -0,0 +1,61 @@
//
// Gameboard.swift
// TokamakDemo
//
// Created by Matvii Hodovaniuk on 2/27/19.
// Copyright © 2019 Tokamak. All rights reserved.
//
import Tokamak
struct Gameboard: PureComponent {
struct Props: Equatable {
let game: State<Game>
let cellSize: Double
}
typealias Children = [AnyNode]
static func render(props: Props, children: Children) -> AnyNode {
let game = props.game
return View.node(
[
View.node(
.init(Style(
[
Center.equal(to: .parent),
Width.equal(
to: props.cellSize * game.value.mapSize.width
),
Height.equal(
to: props.cellSize * game.value.mapSize.height
),
],
borderColor: .black,
borderWidth: 2
)),
[
View.node(
GameCell.node(.init(
color: .red,
size: props.cellSize,
location: game.value.target
))
),
View.node(
game.value.snake.map { (location) -> AnyNode in
let props = GameCell.Props(
color: .black,
size: props.cellSize,
location: location
)
return GameCell.node(props)
}
),
]
),
]
)
}
}

View File

@ -0,0 +1,75 @@
//
// Gamepad.swift
// TokamakDemo
//
// Created by Matvii Hodovaniuk on 2/27/19.
// Copyright © 2019 Tokamak. All rights reserved.
//
import Tokamak
struct Gamepad: PureComponent {
struct Props: Equatable {
let game: State<Game>
}
typealias Children = [AnyNode]
static func render(props: Props, children: Children) -> AnyNode {
let game = props.game
let isVerticalMoveEnabled = ![.up, .down]
.contains(game.value.currentDirection)
let isHorizontalMoveEnabled = ![.left, .right]
.contains(game.value.currentDirection)
return StackView.node(.init(
alignment: .center,
axis: .vertical,
distribution: .fillEqually
), [
Button.node(
.init(
Style(Width.equal(to: .parent, multiplier: 0.5)),
isEnabled: isVerticalMoveEnabled,
onPress: Handler {
game.set { $0.currentDirection = .up }
}
),
"⬆️"
),
StackView.node(.init(
[Width.equal(to: .parent)],
axis: .horizontal,
distribution: .fillEqually
), [
Button.node(
.init(
isEnabled: isHorizontalMoveEnabled,
onPress: Handler {
game.set { $0.currentDirection = .left }
}
),
"⬅️"
),
Button.node(
.init(
isEnabled: isHorizontalMoveEnabled,
onPress: Handler {
game.set { $0.currentDirection = .right }
}
),
"➡️"
),
]),
Button.node(
.init(
Style(Width.equal(to: .parent, multiplier: 0.5)),
isEnabled: isVerticalMoveEnabled,
onPress: Handler { game.set { $0.currentDirection = .down } }
),
"⬇️"
),
] + children)
}
}

View File

@ -0,0 +1,57 @@
//
// Menu.swift
// TokamakDemo
//
// Created by Matvii Hodovaniuk on 2/27/19.
// Copyright © 2019 Tokamak. All rights reserved.
//
import Tokamak
struct GameMenu: PureComponent {
struct Props: Equatable {
let game: State<Game>
}
typealias Children = [AnyNode]
static func render(props: Props, children: Children) -> AnyNode {
let game = props.game
let restartedGameState = Game(
state: .isPlaying,
mapSize: game.value.mapSize
)
switch game.value.state {
case .isPlaying:
return View.node()
case .gameOver:
return StackView.node(
.init(
Edges.equal(to: .parent),
axis: .vertical,
distribution: .fillEqually,
spacing: 10.0
),
Button.node(
.init(onPress: Handler { game.set { $0 = restartedGameState } }),
"Game over! Restart the game"
)
)
case .initial:
return StackView.node(
.init(
Edges.equal(to: .parent),
axis: .vertical,
distribution: .fillEqually,
spacing: 10.0
),
Button.node(
.init(onPress: Handler { game.set { $0.state = .isPlaying } }),
"Start the game"
)
)
}
}
}

View File

@ -0,0 +1,78 @@
//
// Snake.swift
// TokamakDemo
//
// Created by Matvii Hodovaniuk on 2/25/19.
// Copyright © 2019 Tokamak. All rights reserved.
//
import Tokamak
struct Snake: LeafComponent {
struct Props: Equatable {
let cellSize: Double
let mapSizeInCells: Size
}
static func render(props: Props, hooks: Hooks) -> AnyNode {
let game = hooks.state(
Game(
mapSize: props.mapSizeInCells
)
)
let timer = hooks.ref(type: Timer.self)
let speed = hooks.state(10.0)
hooks.finalizedEffect([
AnyEquatable(game.value.state),
AnyEquatable(speed.value),
]) {
guard game.value.state == .isPlaying else { return {} }
timer.value = Timer.scheduledTimer(
withTimeInterval: 1 / speed.value,
repeats: true
) { _ in
game.set { $0.tick() }
}
return {
timer.value?.invalidate()
}
}
switch game.value.state {
case .isPlaying:
return StackView.node(
.init(
Edges.equal(to: .safeArea),
axis: .vertical,
distribution: .fillEqually,
spacing: 10.0
), [
Gameboard.node(.init(game: game, cellSize: props.cellSize)),
Gamepad.node(
.init(game: game),
[
Stepper.node(
.init(
maximumValue: 100.0,
minimumValue: 1.0,
stepValue: 1.0,
value: speed.value,
valueHandler: Handler(speed.set)
)
),
Label.node(
.init(alignment: .center),
"\(speed.value)X"
),
]
),
]
)
case .gameOver, .initial:
return GameMenu.node(.init(game: game))
}
}
}

View File

@ -0,0 +1,30 @@
//
// SnakeGame.swift
// TokamakDemo
//
// Created by Matvii Hodovaniuk on 2/25/19.
// Copyright © 2019 Tokamak. All rights reserved.
//
import Tokamak
struct SnakeGame: LeafComponent {
typealias Props = Null
static func render(props: Props, hooks: Hooks) -> AnyNode {
let snakeProps = Snake.Props(
cellSize: 10,
mapSizeInCells: Size(width: 20.0, height: 20.0)
)
return View.node(
.init(
Style(
Edges.equal(to: .safeArea),
backgroundColor: .white
)
),
Snake.node(snakeProps)
)
}
}

View File

@ -0,0 +1,69 @@
//
// StartGame.swift
// TokamakDemo
//
// Created by Matvii Hodovaniuk on 2/27/19.
// Copyright © 2019 Tokamak. All rights reserved.
//
import Tokamak
struct StartGame: PureLeafComponent {
struct Props: Equatable {
let game: State<Game>
}
static func render(props: StartGame.Props) -> AnyNode {
let game = props.game
let isVerticalMoveEnabled = ![.up, .down]
.contains(game.value.currentDirection)
let isHorizontalMoveEnabled = ![.left, .right]
.contains(game.value.currentDirection)
return StackView.node(.init(
axis: .vertical,
distribution: .fillEqually
), [
Button.node(
.init(
isEnabled: isVerticalMoveEnabled,
onPress: Handler {
game.set { $0.currentDirection = .up }
}
),
"⬆️"
),
StackView.node(.init(
axis: .horizontal,
distribution: .fillEqually
), [
Button.node(
.init(
isEnabled: isHorizontalMoveEnabled,
onPress: Handler {
game.set { $0.currentDirection = .left }
}
),
"⬅️"
),
Button.node(
.init(
isEnabled: isHorizontalMoveEnabled,
onPress: Handler {
game.set { $0.currentDirection = .right }
}
),
"➡️"
),
]),
Button.node(
.init(
isEnabled: isVerticalMoveEnabled,
onPress: Handler { game.set { $0.currentDirection = .down } }
),
"⬇️"
),
])
}
}

View File

@ -20,6 +20,7 @@ enum AppRoute: String, CaseIterable {
case image
case textField = "Text Field"
case animation
case snakeGame = "Snake Game"
}
extension AppRoute: CustomStringConvertible {
@ -65,6 +66,8 @@ struct Router: NavigationRouter {
result = TextFieldExample.node()
case .animation:
result = Animation.node()
case .snakeGame:
result = SnakeGame.node()
}
return NavigationItem.node(

View File

@ -86,7 +86,7 @@ public protocol PureComponent: CompositeComponent {
static func render(props: Props, children: Children) -> AnyNode
}
extension PureComponent {
public extension PureComponent {
static func render(
props: Props,
children: Children,

View File

@ -29,4 +29,10 @@ public struct Width: Equatable {
target: target, constant: constant, multiplier: multiplier
))
}
public static func equal(to constant: Double) -> Constraint {
return .width(Width(
target: .own, constant: constant, multiplier: 0
))
}
}

View File

@ -10,7 +10,7 @@ typealias Effect = () -> Finalizer
protocol HookedComponent: class {
/// State cells of this component indexed by order of `hooks.state` calls
var state: [Any] { get set }
var state: [UnsafeMutableRawPointer] { get set }
/// Effect cells of this component indexed by order of `hooks.effect` calls
var effects: [(observed: AnyEquatable?, Effect)] { get set }
@ -31,7 +31,10 @@ 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>) -> ()
let queueState: (
_ index: Int,
_ updater: (UnsafeMutableRawPointer) -> ()
) -> ()
weak var component: HookedComponent?
@ -44,7 +47,10 @@ public final class Hooks {
init(
component: HookedComponent,
queueState: @escaping (_ index: Int, _ updater: Updater<Any>) -> ()
queueState: @escaping (
_ index: Int,
_ updater: (UnsafeMutableRawPointer) -> ()
) -> ()
) {
self.component = component
self.queueState = queueState
@ -53,7 +59,7 @@ public final class Hooks {
/** 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) {
func currentState<T>(_ initial: T) -> (current: T, index: Int) {
defer { stateIndex += 1 }
guard let component = component else {
@ -62,9 +68,13 @@ public final class Hooks {
}
if component.state.count > stateIndex {
return (component.state[stateIndex], stateIndex)
let pointer = component.state[stateIndex]
return (pointer.assumingMemoryBound(to: T.self).pointee, stateIndex)
} else {
component.state.append(initial)
let pointer = UnsafeMutablePointer<T>.allocate(capacity: 1)
pointer.initialize(to: initial)
component.state.append(UnsafeMutableRawPointer(pointer))
return (initial, stateIndex)
}
}

View File

@ -54,14 +54,9 @@ extension Hooks {
let (value, index) = currentState(initial)
let queueState = self.queueState
return State(value as? T ?? initial) { (updater: Updater<T>) in
return State(value) { (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]) }
}
updater(&$0.assumingMemoryBound(to: T.self).pointee)
}
}
}

View File

@ -23,7 +23,11 @@ final class MountedCompositeComponent<R: Renderer>: MountedComponent<R>,
let type: AnyCompositeComponent.Type
// HookedComponent implementation
var state = [Any]()
/// There's no easy way to downcast elements of `[Any]` to `T`
/// and apply `inout` updater without creating copies, working around
/// that with pointers.
var state = [UnsafeMutableRawPointer]()
var effects = [(observed: AnyEquatable?, Effect)]()
var effectFinalizers = [Finalizer]()
var refs = [AnyObject]()
@ -37,6 +41,12 @@ final class MountedCompositeComponent<R: Renderer>: MountedComponent<R>,
super.init(node)
}
deinit {
for s in state {
s.deallocate()
}
}
override func mount(with reconciler: StackReconciler<R>) {
let renderedNode = reconciler.render(component: self)

View File

@ -23,12 +23,12 @@ public final class StackReconciler<R: Renderer> {
rootComponent.mount(with: self)
}
func queue(updater: (inout Any) -> (),
func queue(updater: (UnsafeMutableRawPointer) -> (),
for component: MountedCompositeComponent<R>,
id: Int) {
let scheduleReconcile = queuedRerenders.isEmpty
updater(&component.state[id])
updater(component.state[id])
queuedRerenders.insert(component)
guard scheduleReconcile else { return }

View File

@ -10,6 +10,31 @@ import XCTest
@testable import Tokamak
struct Game {
enum State {
case initial
case gameOver
case isPlaying
}
var state = State.initial
enum Direction {
case up
case down
case left
case right
}
var currentDirection = Direction.up
var points = [Point]()
var target = Point(x: 42, y: 42)
let mapSize = Size(width: 42, height: 42)
}
extension Button: RefComponent {
public typealias RefTarget = TestView
}
@ -26,6 +51,7 @@ struct Test: LeafComponent {
static func render(props: Null, hooks: Hooks) -> AnyNode {
let state1 = hooks.custom()
let state2 = hooks.custom()
let state3 = hooks.state(Game())
let ref = hooks.ref(type: TestView.self)
return StackView.node([
@ -38,6 +64,14 @@ struct Test: LeafComponent {
Button.node(.init(onPress: Handler { state2.set { $0 + 1 } }),
"Increment"),
Label.node("\(state2.value)"),
Button.node(
.init(
onPress: Handler {
state3.set { $0.points.append(Point(x: 42, y: 42)) }
}
),
"Increment"
),
])
}
}
@ -54,6 +88,8 @@ final class HooksTests: XCTestCase {
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,
let button1Ref = stack.subviews[0].node.ref as? Ref<TestView?>
else {
XCTAssert(false, "components have no handlers")
@ -67,6 +103,8 @@ final class HooksTests: XCTestCase {
button2Handler(())
button2Handler(())
button3Handler(())
let e = expectation(description: "rerender")
DispatchQueue.main.async {