Declarative Godot development.
A SwiftUI-style library for building Godot games and apps using SwiftGodot. Integrates with LDtk and Aseprite.
đź“• API Documentation
Add SwiftGodotBuilder to your Package.swift:
dependencies: [
.package(url: "https://github.com/johnsusek/SwiftGodotBuilder", branch: "main")
],
targets: [
.target(name: "YourTarget", dependencies: ["SwiftGodotBuilder"])
]Then import and use:
import SwiftGodot
import SwiftGodotBuilder
@Godot
final class Game: Node2D {
override func _ready() {
addChild(node: GameView().toNode())
}
}
struct GameView: GView {
var body: some GView {
Node2D$ {
Label$().text("Hello World")
}
}
}// $ syntax - shorthand for GNode<T>
Sprite2D$()
CharacterBody2D$()
Label$()
// With children
Node2D$ {
Sprite2D$()
CollisionShape2D$()
}
// Named nodes
CharacterBody2D$("Player") {
Sprite2D$()
}
// Custom initializer
GNode<CustomNode>("Name", make: { CustomNode(config: config) }) {
// children
}// Dynamic member lookup - set any property
Sprite2D$()
.position(Vector2(100, 200))
.scale(Vector2(2, 2))
.rotation(45)
.modulate(.red)
.zIndex(10)
// Configure closure for complex setup
Sprite2D$().configure { sprite in
sprite.texture = myTexture
sprite.centered = true
}// Load into property
Sprite2D$()
.res(\.texture, "player.png")
.res(\.material, "shader_material.tres")
// Custom resource loading
Sprite2D$()
.withResource("shader.gdshader", as: Shader.self) { node, shader in
let material = ShaderMaterial()
material.shader = shader
node.material = material
}// No arguments
Button$()
.onSignal(\.pressed) { node in
print("Pressed!")
}
// With arguments
Area2D$()
.onSignal(\.bodyEntered) { node, body in
print("Body entered: \(body)")
}
// Multiple arguments
Area2D$()
.onSignal(\.bodyShapeEntered) { node, bodyRid, body, bodyShapeIndex, localShapeIndex in
// Handle collision
}Node2D$()
.onReady { node in
print("Node ready!")
}
.onProcess { node, delta in
node.position.x += 100 * Float(delta)
}
.onPhysicsProcess { node, delta in
// Physics updates
}Create reusable components with slots.
// Component with a content slot
struct LabeledCell<Content: GView>: GView {
let label: String
let content: Content
init(_ label: String, @GViewBuilder content: () -> Content) {
self.label = label
self.content = content()
}
var body: some GView {
VBoxContainer$ {
Control$ { content }.minSize([64, 64])
Label$().text(label).horizontalAlignment(.center)
}
}
}
// Single child
LabeledCell("Health") {
ProgressBar$().value(80)
}
// Multiple children (automatically grouped)
LabeledCell("Stats") {
Label$().text("HP: 100")
Label$().text("MP: 50")
}struct PlayerView: GView {
@State var health: Int = 100
@State var position: Vector2 = .zero
@State var playerNode: CharacterBody2D?
var body: some GView {
CharacterBody2D$ {
Sprite2D$()
ProgressBar$()
.value($health) // One-way binding
}
.position($position) // Bind to property
.ref($playerNode) // Capture node reference
.onProcess { node, delta in
health -= 1 // Modify state
}
}
}// One-way bind to property
ProgressBar$().value($health)
// Bind with formatter
Label$().bind(\.text, to: $score) { "Score: \($0)" }
// Bind to sub-property
Sprite2D$().bind(\.x, to: $position, \.x)
// Multi-state binding
Label$().bind(\.text, to: $health, $maxHealth) { "\($0)/\($1)" }
// Two-way bindings (form controls)
LineEdit$().text($username)
Slider$().value($volume)
CheckBox$().pressed($isEnabled)
OptionButton$().selected($selectedIndex)// ForEach - dynamic lists
struct InventoryView: GView {
@State var items: [Item] = []
var body: some GView {
VBoxContainer$ {
ForEach($items) { item in
HBoxContainer$ {
Label$().text(item.wrappedValue.name)
Button$().text("X").onSignal(\.pressed) { _ in
items.removeAll { $0.id == item.wrappedValue.id }
}
}
}
}
}
}// If - conditional rendering
struct MenuView: GView {
@State var showSettings = false
var body: some GView {
VBoxContainer$ {
If($showSettings) {
SettingsPanel()
}
.Else {
MainMenu()
}
}
}
}
// If modes
If($condition) { /* ... */ } // .hide (default) - toggle visible
If($condition) { /* ... */ }.mode(.remove) // addChild/removeChild
If($condition) { /* ... */ }.mode(.destroy) // queueFree/rebuild// Switch/Case - multi-way branching
enum Page { case mainMenu, levelSelect, settings }
struct GameView: GView {
@State var currentPage: Page = .mainMenu
var body: some GView {
VBoxContainer$ {
Switch($currentPage) {
Case(.mainMenu) {
Label$().text("Main Menu")
Button$().text("Start").onSignal(\.pressed) { _ in
currentPage = .levelSelect
}
}
Case(.levelSelect) {
Label$().text("Level Select")
Button$().text("Back").onSignal(\.pressed) { _ in
currentPage = .mainMenu
}
}
Case(.settings) {
Label$().text("Settings")
}
}
.default {
Label$().text("Unknown page")
}
}
}
}// Define computed properties as computed vars on the struct
@State var score = 0
var scoreText: GState<String> {
$score.computed { "Score: \($0)" }
}
var isHighScore: GState<Bool> {
$score.computed { $0 > 1000 }
}
Label$().text(scoreText)
If(isHighScore) {
Label$().text("New High Score!").modulate(.yellow)
}
// Combine multiple states
@State var health = 80
@State var maxHealth = 100
@State var playerName = "Hero"
var statusText: GState<String> {
$health.computed(with: $maxHealth, $playerName) { hp, maxHp, name in
"\(name): \(hp)/\(maxHp) HP"
}
}
Label$().text(statusText)// Watch and react to state changes
Node2D$().watch($health) { node, health in
node.modulate = health < 20 ? .red : .white
}struct GameState {
var health: Int = 100
var score: Int = 0
}
enum GameEvent {
case takeDamage(Int)
case addScore(Int)
}
func gameReducer(state: inout GameState, event: GameEvent) {
switch event {
case .takeDamage(let amount):
state.health = max(0, state.health - amount)
case .addScore(let points):
state.score += points
}
}
let store = Store(initialState: GameState(), reducer: gameReducer)
// Use in views
ProgressBar$().value(store.state(\.health))
Label$().text(store.state(\.score)) { "Score: \($0)" }
// Send events
store.commit(.takeDamage(10))
store.commit(.addScore(100))Modify parent state from children by emitting events instead of using callbacks.
enum GameEvent {
case playerDied
case scoreChanged(Int)
case itemCollected(String)
}
// Subscribe via modifier
Node2D$()
.onEvent(GameEvent.self) { node, event in
switch event {
case .playerDied: print("Game Over")
case .scoreChanged(let score): print("Score: \(score)")
case .itemCollected(let item): print("Got: \(item)")
}
}
// Subscribe with filter
Node2D$()
.onEvent(GameEvent.self, match: { event in
if case .scoreChanged = event { return true }
return false
}) { node, event in
// Handle only score changes
}
// Publish via ServiceLocator
let bus = ServiceLocator.resolve(GameEvent.self)
bus.publish(.scoreChanged(100))
// Or use EmittableEvent protocol
enum GameEvent: EmittableEvent {
case playerDied
}
GameEvent.playerDied.emit()Screenplay-style dialog trees.
// Define speakers
let Guard = Speaker("Guard")
let Merchant = Speaker("Merchant")
// Create dialogs with branches
Dialog(id: "guard") {
Branch("main") {
Guard ~ "Halt! The path ahead is dangerous."
Choice("I can handle it.") {
Guard ~ "Ha! I like your spirit!"
}
Choice("Any tips?") {
Guard ~ "Watch for patterns."
Jump("tips") // Jump to another branch
}
Choice("Bye.") {
End
}
}
Branch("tips") {
Guard ~ "Use checkpoints!"
Guard ~ "Good luck out there."
}
}
// Conditional choices
Choice("Pay 10 gold", when: { game.gold >= 10 }) {
Emit("payGold", ["amount": 10])
Merchant ~ "Thank you!"
}
// Conditional blocks - runtime evaluated
When({ game.hasKey }) {
Guard ~ "You have the key!"
Jump("unlocked")
}
// Per-NPC state via DialogState
func makeDialog(state: DialogState) -> DialogDefinition {
Dialog(id: "guard") {
Branch("main") {
When({ state.isFirstVisit }) {
Guard ~ "Welcome, stranger!"
}
When({ state.visitCount > 1 }) {
Guard ~ "Back again?"
}
}
}
}
// Create dialog state (track visit counts in your game state)
let state = DialogState(visitCount: myGameState.getVisitCount(for: "guard"))
// Run dialog
let runner = DialogRunner(dialog: myDialog)
runner.onLine = { line in print("\(line.speaker): \(line.text)") }
runner.onChoices = { choices in /* show UI */ }
runner.onEnd = { /* close dialog */ }
runner.start()
runner.advance() // Next line
runner.selectChoice(0) // Pick choice
// Handle Emit() events
.onEvent(DialogBusEvent.self) { _, event in
if case .emitted(let name, let data) = event, name == "payGold" {
player.gold -= data?["amount"] as? Int ?? 0
}
}// Anchor/offset presets (non-container parents)
Control$()
.anchors(.center)
.offsets(.topRight)
.anchorsAndOffsets(.fullRect, margin: 10)
.anchor(top: 0, right: 1, bottom: 1, left: 0)
.offset(top: 12, right: -12, bottom: -12, left: 12)
// Container size flags (for VBox/HBox parents)
Button$()
.sizeH(.expandFill)
.sizeV(.shrinkCenter)
.size(.expandFill, .shrinkCenter)
.size(.expandFill) // Both axes// Flat dictionary - auto-categorized by property name
Label$()
.theme(Theme([
"Label": [
"colors": ["fontColor": Color.white],
"fontSizes": ["fontSize": 16]
]
]))
// Or create and reuse a Theme
let theme = Theme([
"Label": [
"colors": ["fontColor": Color.white],
"fontSizes": ["fontSize": 16]
]
])
Control$().theme(theme)Declarative StyleBox builders for UI styling.
// StyleBoxFlat$ - for solid colors, borders, shadows
PanelContainer$ {
Label$().text("Styled Panel")
}
.panelStyle(
StyleBoxFlat$()
.bgColor(.black.withAlpha(0.9))
.borderColor(.cyan)
.borderWidth(2)
.cornerRadius(8)
.shadowColor(.black.withAlpha(0.5))
.shadowSize(12)
)
// Generic styleBox modifier
Control$()
.styleBox("panel", StyleBoxFlat$().bgColor(.red))
.styleBox("focus", StyleBoxFlat$().borderColor(.white))
#### Available StyleBox Builders
- `StyleBoxFlat$()` - Solid colors, borders, rounded corners, shadows
- `StyleBoxTexture$(texture:)` - Texture-based styling
- `StyleBoxLine$()` - Simple line/border styling
- `StyleBoxEmpty$()` - Invisible (no background)
#### Convenience Methods (StyleBoxFlat$ only)
- `.borderWidth(_:)` - Sets all 4 border widths
- `.cornerRadius(_:)` - Sets all 4 corner radii
- `.contentMargin(_:)` - Sets all 4 content margins
- `.expandMargin(_:)` - Sets all 4 expand margins
### Collision (2D)
Named layer helpers.
```swift
CharacterBody2D$()
.collisionLayer(.alpha)
.collisionMask([.beta, .gamma])
// Available layers: .alpha, .beta, .gamma, .delta, .epsilon, .zeta, .eta, .theta,
// .iota, .kappa, .lambda, .mu, .nu, .xi, .omicron, .pi, .rho, .sigma, .tau,
// .upsilon, .phi, .chi, .psi, .omega
// Custom layers
CharacterBody2D$()
.collisionMask(wallLayer | enemyLayer)Node2D$()
.group("enemies")
.group("damageable", persistent: true)
.groups(["enemies", "damageable"])Node2D$()
.fromScene("enemy.tscn") { child in
// Configure instanced scene
}let pos = Vector2(100, 200)
let pos: Vector2 = [100, 200] // Array literal
let doubled = pos * 2
let scaled = pos * 1.5// Create colors with alpha
let semiTransparent = Color.black.withAlpha(0.9)
let glowColor = Color.cyan.withAlpha(0.5)
### Shape Extensions
```swift
let rect = RectangleShape2D(w: 50, h: 100)
let circle = CircleShape2D(radius: 25)
let capsule = CapsuleShape2D(radius: 10, height: 50)
let segment = SegmentShape2D(a: [0, 0], b: [100, 100])
let ray = SeparationRayShape2D(length: 100)
let boundary = WorldBoundaryShape2D(normal: [0, -1], distance: 0)// Typed queries
let sprites: [Sprite2D] = node.getChildren()
let firstSprite: Sprite2D? = node.getChild()
let enemySprite: Sprite2D? = node.getNode("Enemy")
// Group queries
let enemies: [Enemy] = node.getNodes(inGroup: "enemies")
// Parent chain
let parents: [Node2D] = node.getParents()
// Metadata queries (recursive)
let spawns: [Node2D] = root.queryMeta(key: "type", value: "spawn")
let valuable: [Node2D] = root.queryMeta(key: "value", value: 100)
let markers: [Node2D] = root.queryMetaKey("marker")
// Get typed metadata
let coinValue: Int? = node.getMetaValue("coin_value")if let tree = Engine.getSceneTree() {
// ...
}
Engine.onNextFrame {
print("Next frame!")
}
Engine.onNextPhysicsFrame {
print("Next physics frame!")
}// Define presets as extensions
extension ParticleConfig {
static let explosion = ParticleConfig(
amount: 20,
lifetime: 0.8,
explosiveness: 1.0,
direction: Vector2(x: 0, y: -1),
spread: 180,
initialVelocityMin: 100,
initialVelocityMax: 200,
gravity: Vector2(x: 0, y: 400),
color: Color(r: 1.0, g: 0.5, b: 0.0)
)
}
// Apply to particles
let config = ParticleConfig.explosion
CPUParticles2D$()
.amount(config.amount)
.lifetime(config.lifetime)
.explosiveness(config.explosiveness)
.direction(config.direction)
.spread(config.spread)
.initialVelocityMin(config.initialVelocityMin)
.initialVelocityMax(config.initialVelocityMax)
.gravity(config.gravity)
.color(config.color)// Define actions
Actions {
Action("jump") {
Key(.space)
JoyButton(.a, device: 0)
}
Action("shoot") {
MouseButton(1)
Key(.leftCtrl)
}
// Analog axes
ActionRecipes.axisUD(
namePrefix: "move",
device: 0,
axis: .leftY,
dz: 0.2,
keyDown: .s,
keyUp: .w
)
ActionRecipes.axisLR(
namePrefix: "move",
device: 0,
axis: .leftX,
dz: 0.2,
keyLeft: .a,
keyRight: .d
)
}
.install(clearExisting: true)
// Runtime polling
if Action("jump").isJustPressed {
player.jump()
}
if Action("shoot").isPressed {
player.shoot(Action("shoot").strength)
}
let horizontal = RuntimeAction.axis(negative: "move_left", positive: "move_right")
let movement = RuntimeAction.vector(
negativeX: "move_left",
positiveX: "move_right",
negativeY: "move_up",
positiveY: "move_down"
)Call bindProps() in _ready() in a @Godot class to activate all property wrappers.
@Godot
final class Player: CharacterBody2D {
@Child("Sprite") var sprite: Sprite2D?
@Child("Health", deep: true) var healthBar: ProgressBar?
@Children var buttons: [Button]
@Ancestor var level: Level?
@Sibling("AudioPlayer") var audio: AudioStreamPlayer?
@Autoload("GameManager") var gameManager: GameManager?
@Group("enemies") var enemies: [Enemy]
@Service var events: EventBus<GameEvent>?
@Prefs("musicVolume", default: 0.5) var volume: Double
@OnSignal("StartButton", \Button.pressed)
func onStartPressed(_ sender: Button) {
print("Started!")
}
override func _ready() {
bindProps()
sprite?.visible = true
enemies.forEach { print($0) }
// Refresh group query
let currentEnemies = $enemies()
}
}Complete workflow for loading LDtk levels and bridging enums.
// Define type-safe enums (auto-generates LDExported.json on build)
enum Item: String, LDExported {
case knife = "Knife"
case boots = "Boots"
case potion = "Potion"
}
struct GameView: GView {
let project: LDProject
@State var inventory: [Item] = []
@State var health: Int = 100
var body: some GView {
Node2D$ {
LDLevelView(project, level: "Level_0")
// Called on load for each type
.onSpawn("Player") { entity, level, project in
// Collision layers from LDtk
let wallLayer = project.collisionLayer(for: "walls", in: level)
// Typed fields
let startItems: [Item] = entity.field("starting_items")?.asEnumArray() ?? []
inventory.append(contentsOf: startItems)
// Use entity data to construct player
return CharacterBody2D$ {
Sprite2D$()
.res(\.texture, "player.png")
.anchor([16, 22], within: entity.size, pivot: entity.pivotVector)
CollisionShape2D$()
.shape(RectangleShape2D(w: 16, h: 22))
}
.position(entity.position)
.collisionMask(wallLayer)
}
// Post-process all entities
.onSpawned { node, entity in
// Add label to node
node.addChild(node: Label$().text(entity.identifier).toNode())
}
}
}
}
// Usage
let project = LDProject.load("res://game.ldtk")!
addChild(node: GameView(project: project).toNode())All LDtk field types are supported:
// Single values
entity.field("health")?.asInt() -> Int?
entity.field("speed")?.asFloat() -> Double?
entity.field("locked")?.asBool() -> Bool?
entity.field("name")?.asString() -> String?
entity.field("tint")?.asColor() -> Color?
entity.field("destination")?.asPoint() -> LDPoint?
entity.field("spawn_pos")?.asVector2(gridSize: 16) -> Vector2?
entity.field("target")?.asEntityRef() -> LDEntityRef?
entity.field("item_type")?.asEnum<Item>() -> Item?
// Arrays
entity.field("scores")?.asIntArray() -> [Int]?
entity.field("distances")?.asFloatArray() -> [Double]?
entity.field("flags")?.asBoolArray() -> [Bool]?
entity.field("tags")?.asStringArray() -> [String]?
entity.field("waypoints")?.asPointArray() -> [LDPoint]?
entity.field("patrol")?.asVector2Array(gridSize: 16) -> [Vector2]?
entity.field("palette")?.asColorArray() -> [Color]?
entity.field("targets")?.asEntityRefArray() -> [LDEntityRef]?
entity.field("loot")?.asEnumArray<Item>() -> [Item]?
entity.field("values")?.asArray() -> [LDFieldValue]? // Raw array// Get physics layer bit for IntGrid group name
let wallLayer = project.collisionLayer(for: "walls", in: level)
let platformLayer = project.collisionLayer(for: "platforms", in: level)
CharacterBody2D$()
.collisionMask(wallLayer | platformLayer)Runtime SVG rendering with vertex manipulation effects.
// Basic usage
SVGSprite$()
.path("icon.svg")
.size(16) // Default is 32
.colors([.red, .darkRed, .crimson]) // Per-element colors
.stroke(.white, width: 2)
// Mixing effect types - chaining works fine
SVGSprite$()
.colorCycle([.red, .orange, .yellow]) // color effect
.inflate(amount: 3.0) // vertex effect
// Multiple vertex effects - use svgEffects builder
SVGSprite$()
.path("icon.svg")
.svgEffects {
SVGInflate(amount: 3.0) // applied first
SVGNoise(amount: 1.5) // applied to inflated result
}
// With reactive bindings
@State var meltProgress: Double = 0
SVGSprite$()
.path("enemy.svg")
.melt(progress: $meltProgress) // Animate on deathCan be freely chained with each other and with vertex effects.
.pulse(speed, amplitude, baseSize), .colorCycle(colors, speed), .strokeCycle(colors, speed), .dualColorCycle(fill:, stroke:)
Modify vertex positions. Use svgEffects { } to combine multiple.
.wobble(amount, speed), .inflate(amount, speed), .skew(amount, speed, animated), .noise(amount, speed), .ripple(amplitude, frequency, speed), .twist(amount, speed), .wave(amplitude, frequency, speed), .explode(progress, scale), .scatter(progress, scale, rotate), .melt(progress, scale, waviness)
// Load Aseprite animations
let sprite = AseSprite(
"character.json",
layer: "Body",
options: .init(
timing: .delaysGCD,
trimming: .applyPivotOrCenter
),
autoplay: "Idle"
)
// Builder pattern
AseSprite$(path: "player", layer: "Main")
.configure { sprite in
sprite.play(anim: "Walk")
}Real-time synthesis of retro sound effects from .bfxr files.
// Basic sound playback
BfxrSound$("res://sounds/jump.bfxr")
.onReady { node in
node.playSound()
}
// With reactive bindings
struct GameView: GView {
@State var pitch: Double = 1.0
var body: some GView {
Node2D$ {
BfxrSound$("res://sounds/laser.bfxr")
.ref($laserSound)
.frequencyStart($pitch)
}
}
}struct GameUI: GView {
@ObservableState var transitionState = TransitionState()
var body: some GView {
CanvasLayer$ {
// Game UI here
}
TransitionManager(state: $transitionState, screenSize: [428, 240])
}
}
// Simple fade
transitionState.fadeTransition(onMidpoint: {
loadNextLevel()
})
// Wipe with custom duration
transitionState.wipeTransition(duration: 0.8)
// Iris centered on player
let playerCenter: Vector2 = [0.3, 0.6] // normalized 0-1
transitionState.irisOutTransition(center: playerCenter)
// Hold at midpoint for minimum duration
transitionState.fadeTransition(holdDuration: 0.5, onMidpoint: {
loadLevel()
})
// Wait for async work to complete
transitionState.fadeTransition(waitForResume: true, onMidpoint: {
loadLevelAsync {
transitionState.resume() // Continue transition when ready
}
})
// Both: minimum hold time + wait for async
transitionState.irisOutTransition(
holdDuration: 0.3,
waitForResume: true,
onMidpoint: { startLoading() },
onComplete: { print("Done!") }
)- fade - Screen fades to black and back
- wipe - Horizontal wipe across screen
- irisOut - Circle shrinks to point, then expands
.onEvent(TransitionEvent.self) { _, event in
switch event {
case .started(let type): print("Started \(type)")
case .midpoint: print("Midpoint reached")
case .completed(let type): print("Completed \(type)")
}
}// One-shot tween
btn.tween(.scale([1.1, 1.1]), duration: 0.1)
.ease(.out).trans(.quad)
// Fade out and remove
enemy.tween(.alpha(0.0), duration: 0.3)
.onFinished { enemy.queueFree() }
// Managed tween (kills previous)
@State var currentTween: TweenHandle?
currentTween = btn.tween(.scale([1.1, 1.1]), duration: 0.1, killing: currentTween)
.trans(.quad).ease(.out)// Bounce effect
btn.tween { seq in
seq.to(.scale([1.0, 0.8]), duration: 0.05)
.trans(.quad).ease(.out)
.to(.scale([1.0, 1.15]), duration: 0.08)
.trans(.quad).ease(.out)
.to(.scale([1.0, 1.0]), duration: 0.12)
.trans(.bounce).ease(.out)
}
// Looping pulse
icon.tween { seq in
seq.to(.scale([1.05, 1.05]), duration: 0.5)
.trans(.sine).ease(.inOut)
.to(.scale([1.0, 1.0]), duration: 0.5)
.trans(.sine).ease(.inOut)
}
.loop()
// Loop specific number of times
node.tween { seq in
seq.to(.rotation(Float.pi * 2), duration: 1.0)
}
.loop(3)- Scale:
.scale(Vector2),.scaleX(Float),.scaleY(Float) - Position:
.position(Vector2),.positionX(Float),.positionY(Float),.globalPosition(Vector2) - Rotation:
.rotation(Float),.rotationDegrees(Float) - Color:
.modulate(Color),.alpha(Float),.selfModulate(Color),.selfAlpha(Float) - Size:
.size(Vector2),.minSize(Vector2),.pivotOffset(Vector2) - Other:
.skew(Float),.volumeDb(Float),.pitchScale(Float) - Custom:
.custom(property: String, value: Variant)
Animate properties in response to state changes.
// Toggle animation - animate between two values based on bool state
@State var isHovered = false
Button$()
.tweenToggle($isHovered, Anim.Scale.self,
whenTrue: [1.1, 1.1], whenFalse: [1.0, 1.0],
duration: 0.1)
.onSignal(\.mouseEntered) { _ in isHovered = true }
.onSignal(\.mouseExited) { _ in isHovered = false }
// Conditional animation - run different animations based on state value
@State var selectedTab = 0
TabButton$()
.tweenWhen($selectedTab, equals: 0) { btn in
btn.tween(.scale([1.1, 1.1]), duration: 0.1).ease(.out)
} otherwise: { btn in
btn.tween(.scale([1.0, 1.0]), duration: 0.1).ease(.out)
}
// On change - custom handler for any state change
@State var health = 100
HealthBar$()
.tweenOnChange($health) { bar, newHealth in
bar.tween(.scaleX(Float(newHealth) / 100.0), duration: 0.2).ease(.out)
}