import { Button, Canvas, HStack, Navigation, NavigationStack, Script, ScrollView, Text, TimelineCanvas, useRef, useState, VStack } from "scripting"
function Example() {
// Drive a state variable from the screen so the Canvas closure re-runs when you change it.
// Every re-render replays the commands; nothing animates on its own in Phase 1.
const [hue, setHue] = useState(0)
return <NavigationStack>
<ScrollView>
<VStack
navigationTitle={"Canvas"}
navigationBarTitleDisplayMode={"inline"}
spacing={28}
padding
>
<Text font={"caption"} foregroundStyle={"secondaryLabel"}>
Canvas exposes a Web-Canvas-style 2D context. The `draw` closure is called every time
SwiftUI re-evaluates the view (state / layout changes). Issue commands on `ctx` —
`fillRect`, `arc`, `stroke`, etc. — and Swift replays them onto a real SwiftUI
GraphicsContext.
{"\n\n"}
Sizing is controlled by view modifiers (`frame`, `padding`, ...), NOT by canvas props.
The actual draw size is passed as the second arg to `draw`.
</Text>
{/* 1. Basic primitives: fillRect / arc / strokeRect with style strings */}
<VStack alignment={"leading"} spacing={8}>
<Text font={"headline"}>1. Primitives + colors</Text>
<Text font={"caption"} foregroundStyle={"secondaryLabel"}>
Color strings go through the same parser as the rest of the bridge — `"systemBlue"`,
hex `"#RRGGBB"`, `"rgba(...)"`, `"accentColor"` are all valid.
</Text>
<Canvas
frame={{ width: 320, height: 160 }}
draw={(ctx, size) => {
// Background
ctx.fillStyle = "systemGray6"
ctx.fillRect(0, 0, size.width, size.height)
// Blue square
ctx.fillStyle = "systemBlue"
ctx.fillRect(16, 24, 80, 80)
// Orange stroked square
ctx.strokeStyle = "systemOrange"
ctx.lineWidth = 6
ctx.strokeRect(120, 24, 80, 80)
// Red filled circle
ctx.fillStyle = "systemRed"
ctx.beginPath()
ctx.arc(264, 64, 36, 0, Math.PI * 2)
ctx.fill()
}}
/>
</VStack>
{/* 2. save/restore + transforms */}
<VStack alignment={"leading"} spacing={8}>
<Text font={"headline"}>2. save / restore + transforms</Text>
<Text font={"caption"} foregroundStyle={"secondaryLabel"}>
`save` pushes the entire context state (transform, opacity, clip, style) onto a stack;
`restore` pops it. A loop of `save → translate → rotate → fillRect → restore` keeps
each tick independent.
</Text>
<Canvas
frame={{ width: 320, height: 200 }}
draw={(ctx, size) => {
ctx.fillStyle = "systemBackground"
ctx.fillRect(0, 0, size.width, size.height)
const cx = size.width / 2
const cy = size.height / 2
const tickCount = 12
ctx.fillStyle = "label"
for (let i = 0; i < tickCount; i++) {
ctx.save()
ctx.translate(cx, cy)
ctx.rotate((Math.PI * 2 * i) / tickCount)
ctx.fillRect(-2, -64, 4, 16)
ctx.restore()
}
// Center dot — proves transforms were rolled back.
ctx.fillStyle = "systemBlue"
ctx.beginPath()
ctx.arc(cx, cy, 6, 0, Math.PI * 2)
ctx.fill()
}}
/>
</VStack>
{/* 3. Linear gradient + drawImage (SF symbol as image source) */}
<VStack alignment={"leading"} spacing={8}>
<Text font={"headline"}>3. Gradient + drawImage</Text>
<Text font={"caption"} foregroundStyle={"secondaryLabel"}>
{`\`createLinearGradient(x0, y0, x1, y1)\` returns a \`CanvasGradient\`; add stops and assign
it to \`fillStyle\`. \`drawImage\` accepts \`{ systemName }\`, \`{ filePath }\` or \`{ image: UIImage }\` —
the same source forms as the \`Image\` component (Phase 1 supports local sources only).`}
</Text>
<Canvas
frame={{ width: 320, height: 180 }}
draw={(ctx, size) => {
const g = ctx.createLinearGradient(0, 0, size.width, size.height)
g.addColorStop(0, "systemTeal")
g.addColorStop(1, "systemIndigo")
ctx.fillStyle = g
ctx.fillRect(0, 0, size.width, size.height)
ctx.fillStyle = "white"
ctx.globalAlpha = 0.9
ctx.font = 22
ctx.textAlign = "left"
ctx.textBaseline = "top"
ctx.fillText("Linear gradient", 16, 16)
ctx.globalAlpha = 1
ctx.drawImage({ systemName: "sparkles" }, size.width - 64, 16, 48, 48)
}}
/>
</VStack>
{/* 4. State-driven redraw */}
<VStack alignment={"leading"} spacing={8}>
<Text font={"headline"}>4. State-driven redraw</Text>
<Text font={"caption"} foregroundStyle={"secondaryLabel"}>
Tap the button to change a React state. SwiftUI re-evaluates the view, the draw closure
runs again with the new value, and the canvas redraws.
</Text>
<Canvas
frame={{ width: 320, height: 120 }}
draw={(ctx, size) => {
ctx.fillStyle = `hsl(${hue}, 80%, 92%)`
ctx.fillRect(0, 0, size.width, size.height)
ctx.fillStyle = `hsl(${hue}, 70%, 45%)`
ctx.beginPath()
ctx.arc(size.width / 2, size.height / 2, 36, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = "label"
ctx.font = 14
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText(`hue: ${hue}°`, size.width / 2, size.height / 2 + 56)
}}
/>
<HStack>
<Button
title={"Cycle hue"}
action={() => setHue((hue + 40) % 360)}
buttonStyle={"borderedProminent"}
controlSize={"small"}
/>
</HStack>
</VStack>
{/* 5. measureText — synchronously measure text for layout */}
<VStack alignment={"leading"} spacing={8}>
<Text font={"headline"}>5. measureText</Text>
<Text font={"caption"} foregroundStyle={"secondaryLabel"}>
`ctx.measureText(text)` returns a `TextMetrics` synchronously — useful for centering,
drawing a background pill behind a label, or laying out text by hand. Width and
ascent/descent are reported in the same units as draw coordinates.
</Text>
<Canvas
frame={{ width: 320, height: 140 }}
draw={(ctx, size) => {
ctx.fillStyle = "systemGray6"
ctx.fillRect(0, 0, size.width, size.height)
const label = "Tap to measure"
ctx.font = 22
const m = ctx.measureText(label)
const padX = 14
const padY = 8
const pillW = m.width + padX * 2
const pillH = m.actualBoundingBoxAscent + m.actualBoundingBoxDescent + padY * 2
const cx = size.width / 2
const cy = size.height / 2
// Background pill sized to the measured text
ctx.fillStyle = "systemBlue"
ctx.beginPath()
ctx.arc(cx - pillW / 2 + pillH / 2, cy, pillH / 2, Math.PI / 2, -Math.PI / 2)
ctx.lineTo(cx + pillW / 2 - pillH / 2, cy - pillH / 2)
ctx.arc(cx + pillW / 2 - pillH / 2, cy, pillH / 2, -Math.PI / 2, Math.PI / 2)
ctx.lineTo(cx - pillW / 2 + pillH / 2, cy + pillH / 2)
ctx.fill()
ctx.fillStyle = "white"
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText(label, cx, cy)
ctx.fillStyle = "secondaryLabel"
ctx.font = 11
ctx.textBaseline = "bottom"
ctx.fillText(
`width=${m.width.toFixed(1)} ascent=${m.actualBoundingBoxAscent.toFixed(1)}`,
cx,
size.height - 8,
)
}}
/>
</VStack>
{/* 6. shadow */}
<VStack alignment={"leading"} spacing={8}>
<Text font={"headline"}>6. Shadow</Text>
<Text font={"caption"} foregroundStyle={"secondaryLabel"}>
`shadowColor / shadowBlur / shadowOffsetX / shadowOffsetY` apply a drop shadow to
subsequent fills, strokes, text, and images.
</Text>
<Canvas
frame={{ width: 320, height: 160 }}
draw={(ctx, size) => {
ctx.fillStyle = "systemBackground"
ctx.fillRect(0, 0, size.width, size.height)
ctx.shadowColor = "rgba(0, 0, 0, 0.35)"
ctx.shadowBlur = 12
ctx.shadowOffsetX = 4
ctx.shadowOffsetY = 6
ctx.fillStyle = "systemBlue"
ctx.fillRect(24, 24, 100, 80)
ctx.fillStyle = "systemRed"
ctx.beginPath()
ctx.arc(200, 64, 36, 0, Math.PI * 2)
ctx.fill()
// Disable shadow for label
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.fillStyle = "label"
ctx.font = 13
ctx.textAlign = "center"
ctx.textBaseline = "bottom"
ctx.fillText("shadow on rect + circle", size.width / 2, size.height - 12)
}}
/>
</VStack>
{/* 7. blend modes */}
<VStack alignment={"leading"} spacing={8}>
<Text font={"headline"}>7. globalCompositeOperation</Text>
<Text font={"caption"} foregroundStyle={"secondaryLabel"}>
Set `ctx.globalCompositeOperation` to map onto a SwiftUI blend mode for subsequent
draws. Demo: three overlapping discs in `multiply`.
</Text>
<Canvas
frame={{ width: 320, height: 160 }}
draw={(ctx, size) => {
ctx.fillStyle = "white"
ctx.fillRect(0, 0, size.width, size.height)
ctx.globalCompositeOperation = "multiply"
const r = 56
const cy = size.height / 2
const offsets = [-44, 0, 44]
const colors = ["systemRed", "systemGreen", "systemBlue"]
for (let i = 0; i < 3; i++) {
ctx.fillStyle = colors[i]
ctx.beginPath()
ctx.arc(size.width / 2 + offsets[i], cy, r, 0, Math.PI * 2)
ctx.fill()
}
}}
/>
</VStack>
{/* 8. partial ellipse arc */}
<VStack alignment={"leading"} spacing={8}>
<Text font={"headline"}>8. Partial ellipse arc</Text>
<Text font={"caption"} foregroundStyle={"secondaryLabel"}>
`ellipse(x, y, rx, ry, rotation, start, end, ccw)` renders only the requested arc.
Demo: a tilted half-ellipse stroke + filled wedge.
</Text>
<Canvas
frame={{ width: 320, height: 180 }}
draw={(ctx, size) => {
ctx.fillStyle = "systemGray6"
ctx.fillRect(0, 0, size.width, size.height)
// Tilted ellipse, top half only (start=0, end=π, ccw=true so we sweep the upper arc).
ctx.strokeStyle = "systemPurple"
ctx.lineWidth = 6
ctx.beginPath()
ctx.ellipse(
size.width / 2, size.height / 2,
100, 50,
-Math.PI / 6,
0, Math.PI,
true,
)
ctx.stroke()
// Filled pie wedge: an arc to a point and back
ctx.fillStyle = "systemOrange"
ctx.beginPath()
ctx.moveTo(size.width / 2, size.height / 2)
ctx.ellipse(
size.width / 2, size.height / 2,
70, 30,
Math.PI / 8,
-Math.PI / 6, Math.PI / 3,
false,
)
ctx.closePath()
ctx.fill()
}}
/>
</VStack>
{/* 9. createPattern */}
<VStack alignment={"leading"} spacing={8}>
<Text font={"headline"}>9. createPattern</Text>
<Text font={"caption"} foregroundStyle={"secondaryLabel"}>
Tile an image as fill. SwiftUI tiles in both axes — `"repeat-x"` / `"repeat-y"` /
`"no-repeat"` map to the same behavior as `"repeat"` for now.
</Text>
<Canvas
frame={{ width: 320, height: 160 }}
draw={(ctx, size) => {
// SF Symbols are black-on-clear templates — paint a backdrop first so the
// transparent regions don't show through as the canvas's undefined "opaque" color.
ctx.fillStyle = "systemGray6"
ctx.fillRect(0, 0, size.width, size.height)
const pattern = ctx.createPattern({ systemName: "star.fill" }, "repeat")
ctx.fillStyle = pattern
ctx.fillRect(0, 0, size.width, size.height)
}}
/>
</VStack>
{/* 10. createConicGradient */}
<VStack alignment={"leading"} spacing={8}>
<Text font={"headline"}>10. createConicGradient</Text>
<Text font={"caption"} foregroundStyle={"secondaryLabel"}>
`createConicGradient(startAngle, x, y)` returns a SwiftUI `AngularGradient` — not in
classic Web Canvas, but included because the mapping is clean.
</Text>
<Canvas
frame={{ width: 320, height: 200 }}
draw={(ctx, size) => {
ctx.fillStyle = "systemBackground"
ctx.fillRect(0, 0, size.width, size.height)
const cx = size.width / 2
const cy = size.height / 2
const g = ctx.createConicGradient(0, cx, cy)
g.addColorStop(0, "systemRed")
g.addColorStop(0.17, "systemOrange")
g.addColorStop(0.33, "systemYellow")
g.addColorStop(0.5, "systemGreen")
g.addColorStop(0.67, "systemTeal")
g.addColorStop(0.83, "systemBlue")
g.addColorStop(1, "systemPurple")
ctx.fillStyle = g
ctx.beginPath()
ctx.arc(cx, cy, 84, 0, Math.PI * 2)
ctx.fill()
}}
/>
</VStack>
{/* 11. imageSmoothingEnabled + source-rect crop */}
<VStack alignment={"leading"} spacing={8}>
<Text font={"headline"}>11. imageSmoothingEnabled</Text>
<Text font={"caption"} foregroundStyle={"secondaryLabel"}>
Set `ctx.imageSmoothingEnabled = false` for nearest-neighbor scaling (pixel art look).
The 9-arg `drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)` form also crops the
source rect — works best with sufficiently large image sources (`filePath` /
in-memory `UIImage`); SF Symbols are tiny so source-crop won't show meaningful
content for them.
</Text>
<Canvas
frame={{ width: 320, height: 140 }}
draw={(ctx, size) => {
ctx.fillStyle = "systemGray6"
ctx.fillRect(0, 0, size.width, size.height)
// Left: smooth scaling (default)
ctx.imageSmoothingEnabled = true
ctx.drawImage({ systemName: "square.grid.3x3.fill" }, 20, 10, 120, 120)
// Right: nearest-neighbor
ctx.imageSmoothingEnabled = false
ctx.drawImage({ systemName: "square.grid.3x3.fill" }, size.width - 140, 10, 120, 120)
ctx.imageSmoothingEnabled = true
ctx.fillStyle = "label"
ctx.font = 11
ctx.textAlign = "center"
ctx.textBaseline = "bottom"
ctx.fillText("smoothing on", 80, size.height - 4)
ctx.fillText("smoothing off (pixelated)", size.width - 80, size.height - 4)
}}
/>
</VStack>
{/* 12. TimelineCanvas — per-frame animation via SwiftUI TimelineView */}
<TimelineCanvasDemo />
{/* 13. TimelineCanvas — particle system, ~100 particles */}
<TimelineCanvasParticleDemo />
</VStack>
</ScrollView>
</NavigationStack>
}
function TimelineCanvasDemo() {
// Pause / resume the timeline. The TimelineCanvas keeps the last frame visible while paused.
const [paused, setPaused] = useState(false)
// Bouncing ball — state lives in a ref so it survives across frames.
// (Don't put per-frame state in useState — that triggers React re-renders unnecessarily.)
const ball = useRef({ x: 60, y: 60, vx: 140, vy: 90, lastTime: 0 })
return <VStack alignment={"leading"} spacing={8}>
<Text font={"headline"}>12. TimelineCanvas — per-frame animation</Text>
<Text font={"caption"} foregroundStyle={"secondaryLabel"}>
Like Canvas, but driven by SwiftUI's TimelineView. The draw closure receives a third
argument — `time` in seconds since the view mounted — and fires every frame
(~60fps by default). Per-frame state goes in `useRef`.
</Text>
<TimelineCanvas
frame={{ width: 320, height: 180 }}
paused={paused}
draw={(ctx, size, time) => {
const s = ball.current
const dt = Math.min(0.05, time - s.lastTime) // clamp dt to avoid huge jumps on resume
s.lastTime = time
s.x += s.vx * dt
s.y += s.vy * dt
const r = 18
if (s.x < r) { s.x = r; s.vx = -s.vx }
if (s.x > size.width - r) { s.x = size.width - r; s.vx = -s.vx }
if (s.y < r) { s.y = r; s.vy = -s.vy }
if (s.y > size.height - r) { s.y = size.height - r; s.vy = -s.vy }
ctx.fillStyle = "systemGray6"
ctx.fillRect(0, 0, size.width, size.height)
ctx.fillStyle = "systemBlue"
ctx.beginPath()
ctx.arc(s.x, s.y, r, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = "secondaryLabel"
ctx.font = 11
ctx.fillText(`t = ${time.toFixed(2)}s`, 8, 16)
}}
/>
<HStack>
<Button title={paused ? "Resume" : "Pause"} action={() => setPaused(!paused)} />
</HStack>
</VStack>
}
function TimelineCanvasParticleDemo() {
// ~100 particles, each a small circle. Demonstrates that TimelineCanvas can drive
// hundreds of draw commands per frame; keep an eye on the displayed FPS to spot
// performance regressions.
//
// Tip: each particle's color string is pre-computed once and stored on the
// particle. Generating `hsla(...)` strings every frame would mean 100×
// toString + 100× Swift-side color parses per tick — measurable in the
// sub-millisecond range but enough to push a marginal scene under 60fps.
type Particle = { x: number; y: number; vx: number; vy: number; r: number; color: string }
const state = useRef<{
particles: Particle[]
lastTime: number
fpsSampleTime: number
fpsSampleCount: number
fps: number
} | null>(null)
if (state.current == null) {
const particles: Particle[] = []
for (let i = 0; i < 100; i++) {
const hue = Math.floor(Math.random() * 360)
particles.push({
x: 160 + (Math.random() - 0.5) * 100,
y: 90 + (Math.random() - 0.5) * 60,
vx: (Math.random() - 0.5) * 160,
vy: (Math.random() - 0.5) * 160,
r: 3 + Math.random() * 4,
color: `hsla(${hue}, 80%, 60%, 0.85)`,
})
}
state.current = { particles, lastTime: 0, fpsSampleTime: 0, fpsSampleCount: 0, fps: 0 }
}
return <VStack alignment={"leading"} spacing={8}>
<Text font={"headline"}>13. TimelineCanvas — particles + FPS readout</Text>
<Text font={"caption"} foregroundStyle={"secondaryLabel"}>
100 bouncing particles. The "FPS" overlay is computed from `time` deltas; if you see
it drop well below 60, your scene is too heavy for per-frame mode — switch to a
coarser `schedule` like `{"{ minimumInterval: 1/30 }"}`.
</Text>
<TimelineCanvas
frame={{ width: 320, height: 180 }}
draw={(ctx, size, time) => {
const s = state.current!
const dt = Math.min(0.05, s.lastTime === 0 ? 0 : time - s.lastTime)
s.lastTime = time
// FPS — update every 0.5s
s.fpsSampleCount++
if (time - s.fpsSampleTime > 0.5) {
s.fps = s.fpsSampleCount / (time - s.fpsSampleTime)
s.fpsSampleCount = 0
s.fpsSampleTime = time
}
ctx.fillStyle = "systemGray6"
ctx.fillRect(0, 0, size.width, size.height)
for (const p of s.particles) {
p.x += p.vx * dt
p.y += p.vy * dt
if (p.x < p.r) { p.x = p.r; p.vx = -p.vx }
if (p.x > size.width - p.r) { p.x = size.width - p.r; p.vx = -p.vx }
if (p.y < p.r) { p.y = p.r; p.vy = -p.vy }
if (p.y > size.height - p.r) { p.y = size.height - p.r; p.vy = -p.vy }
ctx.fillStyle = p.color
ctx.beginPath()
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
ctx.fill()
}
ctx.fillStyle = "label"
ctx.font = 12
ctx.fillText(`fps ~ ${s.fps.toFixed(0)}`, 8, 18)
}}
/>
</VStack>
}
async function run() {
await Navigation.present({ element: <Example /> })
Script.exit()
}
run()