
Tile Wave
From syxUI — written for both platforms, not translated between them.
InteractiveA tile grid where every tap flips tiles outward in a wave, like falling dominoes.
Backgrounds · SwiftUI · Flutter · iOS 17 · Flutter 3.27
- background
- grid
- tiles
- wave
- flip
- interactive
- touch
The actual source
// Tile Wave · syxUI · https://syxui.dev/components/tile-wave
// Free in any project. Keep this line and credit syxUI where a person can read it.
import SwiftUI
/// A tile grid where every tap flips tiles outward in a wave, like falling
/// dominoes.
///
/// Taps live in a fixed 4-slot ring buffer; each tile computes its own delay
/// from `distance(tile, tap) / waveSpeed` under the chosen `metric`, so the
/// wavefront shape — circular, square, or diamond — falls out of one
/// distance function. Overlapping waves take the **max** of their envelopes
/// per tile, so they never double-flip.
struct TileWave: View {
var tileSize: CGFloat = 34
var gap: CGFloat = 2
var waveSpeed: CGFloat = 900
var flipDuration: Double = 0.45
var tileColor: Color = Color(red: 0.122, green: 0.435, blue: 0.922)
/// `"euclidean"` (circular), `"chebyshev"` (square), or `"manhattan"` (diamond).
var metric: String = "euclidean"
/// Preview override. When set, one scripted tap plays instead of the
/// field driving its own clock — see `previews/Animate.swift`.
var phase: Double? = nil
/// When true and untouched for 4s, the field taps itself along a slow
/// Lissajous path so a hero section can demo the effect unattended. Off
/// by default — it must never animate behind real content uninvited.
var idleDemo: Bool = false
private let tapLife: Double = 1.6
private let restOpacity: Double = 0.35
private let ringSlots = 4
private let dragEmitInterval: Double = 0.14
private let previewPeriod: Double = 2.0
private let idleDelay: Double = 4.0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var taps: [Tap] = Array(repeating: .expired, count: 4)
@State private var cursor = 0
@State private var isDown = false
@State private var lastEmit: Double = -.greatestFiniteMagnitude
@State private var isActive = false
@State private var idleArmedAt: Double = Date().timeIntervalSinceReferenceDate
@State private var lastSize: CGSize = .zero
private struct Tap {
var point: CGPoint
var time: Double
static let expired = Tap(point: .zero, time: -.greatestFiniteMagnitude)
}
var body: some View {
TimelineView(.animation(paused: phase != nil || !isActive)) { timeline in
Canvas { context, size in
lastSize = size
let now = phase.map { $0 * previewPeriod } ?? timeline.date.timeIntervalSinceReferenceDate
let field = phase != nil ? [scriptedTap(size: size)] : taps
draw(context: context, size: size, now: now, field: field)
}
}
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
guard phase == nil, !reduceMotion else { return }
let now = Date().timeIntervalSinceReferenceDate
if !isDown {
isDown = true
registerTap(at: value.location, now: now)
} else if now - lastEmit >= dragEmitInterval {
registerTap(at: value.location, now: now)
}
}
.onEnded { _ in isDown = false }
)
.onContinuousHover(coordinateSpace: .local) { hoverPhase in
guard phase == nil, !reduceMotion, !isDown else { return }
if case .active(let location) = hoverPhase {
let now = Date().timeIntervalSinceReferenceDate
if now - lastEmit >= dragEmitInterval {
registerTap(at: location, now: now)
}
}
}
.accessibilityHidden(true)
.task(id: idleDemo) { await runIdleDemo() }
}
// MARK: - Field
private func draw(context: GraphicsContext, size: CGSize, now: Double, field: [Tap]) {
let pitch = tileSize + gap
guard pitch > 0 else { return }
let columns = Int(size.width / pitch) + 2
let rows = Int(size.height / pitch) + 2
let cornerRadius = tileSize * 0.2
for row in 0..<rows {
for column in 0..<columns {
let center = CGPoint(
x: CGFloat(column) * pitch + pitch / 2,
y: CGFloat(row) * pitch + pitch / 2
)
var envelope: Double = 0
for tap in field {
let age = now - tap.time
guard age >= 0, age < tapLife else { continue }
let distance = metricDistance(center, tap.point)
let delay = Double(distance / waveSpeed)
let localAge = age - delay
guard localAge >= 0, localAge < flipDuration else { continue }
let u = localAge / flipDuration
envelope = max(envelope, sin(.pi * u))
}
let widthScale = 1 - 0.85 * envelope
let opacity = restOpacity + (1 - restOpacity) * envelope
let width = tileSize * CGFloat(widthScale)
let rect = CGRect(
x: center.x - width / 2, y: center.y - tileSize / 2,
width: width, height: tileSize
)
context.fill(
Path(roundedRect: rect, cornerRadius: cornerRadius),
with: .color(tileColor.opacity(opacity))
)
}
}
}
private func metricDistance(_ a: CGPoint, _ b: CGPoint) -> CGFloat {
let dx = abs(a.x - b.x)
let dy = abs(a.y - b.y)
switch metric {
case "chebyshev": return max(dx, dy)
case "manhattan": return dx + dy
default: return hypot(dx, dy)
}
}
private func scriptedTap(size: CGSize) -> Tap {
// Mechanism A (§0.5): one scripted tap at phase 0.05. `flipDuration`
// plus the crossing time comfortably fits under the 2.0s loop.
Tap(point: CGPoint(x: 0.42 * size.width, y: 0.38 * size.height), time: 0.05 * previewPeriod)
}
// MARK: - Interaction
private func registerTap(at point: CGPoint, now: Double) {
taps[cursor % ringSlots] = Tap(point: point, time: now)
cursor += 1
lastEmit = now
isActive = true
idleArmedAt = now
scheduleSettleCheck(after: tapLife + 0.1)
}
private func scheduleSettleCheck(after delay: Double) {
Task {
try? await Task.sleep(nanoseconds: UInt64(max(delay, 0) * 1_000_000_000))
await MainActor.run {
let now = Date().timeIntervalSinceReferenceDate
let stillLive = taps.contains { now - $0.time < tapLife }
if !stillLive && !isDown { isActive = false }
}
}
}
private func runIdleDemo() async {
guard idleDemo, phase == nil, !reduceMotion else { return }
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 500_000_000)
guard idleDemo, !isDown else { continue }
let now = Date().timeIntervalSinceReferenceDate
guard now - idleArmedAt >= idleDelay, lastSize != .zero else { continue }
let t = (now - idleArmedAt - idleDelay) / 3.0
let unit = CGPoint(x: 0.5 + 0.30 * sin(2 * .pi * t), y: 0.5 + 0.22 * sin(4 * .pi * t + 0.7))
registerTap(at: CGPoint(x: unit.x * lastSize.width, y: unit.y * lastSize.height), now: now)
}
}
}
#Preview {
TileWave()
.frame(width: 420, height: 300)
}
SwiftUI note. Tiles = area / (tileSize + gap)^2; at the default 34pt on 390x844 that is 11x24 = 264 rounded-rect fills — trivially cheap, and the safest effect in this batch. Rounded rects cost noticeably more than circles, so below ~20pt tiles on a full screen (~1,700 tiles) switch to plain rects instead. Touch-down, drag, and `.onContinuousHover` all feed the same 4-slot tap ring buffer, so a Mac cursor paints a moving front exactly like a finger. With nothing touching it, `previews/Animate.swift` scripts one tap via `phase`; set `idleDemo` to have the field tap itself on a Lissajous path after 4s of real idle time.
Dependencies
- SwiftUI
- No external dependencies
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27


