
Elastic Lattice
From syxUI — written for both platforms, not translated between them.
InteractiveA sprung dot mesh that dents under your finger and sends the wave through its neighbours.
Backgrounds · SwiftUI · Flutter · iOS 17 · Flutter 3.27
- background
- lattice
- mesh
- spring
- physics
- interactive
- touch
- animated
The actual source
// Elastic Lattice · syxUI · https://syxui.dev/components/spring-lattice
// Free in any project. Keep this line and credit syxUI where a person can read it.
import SwiftUI
/// A sprung dot mesh that dents under a finger and sends the disturbance
/// through its neighbours like cloth.
///
/// Each node has a stiff anchor spring back to its rest position and a link
/// spring to its 4 grid neighbours (§0.2's plain, non-observed simulation
/// class). A drag grabs nodes within `pullRadius` with a falloff spring; a
/// quick tap instead kicks the 4 nodes around it with a radial velocity
/// impulse — a hammer strike rather than a pull. Touch energy lives entirely
/// in node velocity, so any number of taps compose for free and decay
/// through the damping term alone.
struct SpringLattice: View {
var spacing: CGFloat = 26
/// How fast the wave crosses the mesh — scales both spring constants.
var springiness: Double = 0.55
var damping: Double = 0.92
var pullRadius: CGFloat = 80
var color: Color = Color(red: 0.239, green: 0.239, blue: 0.275)
var showLinks: Bool = true
/// Preview override. When set, the lattice resets and deterministically
/// replays from a scripted impulse instead of driving its own clock —
/// see `previews/Animate.swift`.
var phase: Double? = nil
/// When true and untouched for 4s, the lattice strikes 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
// Baseline spring constants at the default `springiness` — not exposed
// individually, `springiness` scales both together.
private let baseAnchor: Double = 30
private let baseLink: Double = 240
private let baseSpringiness: Double = 0.55
private let kGrab: Double = 900
private let impulseSpeed: CGFloat = 900
private let settleSpeed: Double = 0.5
private let previewPeriod: Double = 2.3
private let previewImpulseAt: Double = 0.05
private let previewImpulsePoint = CGPoint(x: 0.36, y: 0.42)
private let quickTapWindow: Double = 0.12
private let quickTapSlop: CGFloat = 6
private let idleDelay: Double = 4.0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var sim = Sim()
@State private var touchPoint: CGPoint? = nil
@State private var isDown = false
@State private var downAt: Date = .distantPast
@State private var downLocation: CGPoint = .zero
@State private var isActive = false
@State private var settlePending = false
@State private var idleArmedAt: Double = Date().timeIntervalSinceReferenceDate
@State private var lastSize: CGSize = .zero
private var kAnchor: Double { baseAnchor * springiness / baseSpringiness }
private var kLink: Double { baseLink * springiness / baseSpringiness }
/// A plain class stepped from inside `Canvas`. Not `@Observable` — nothing
/// should observe it, so mutating it every frame cannot invalidate the
/// view or cause a re-entrant body evaluation (§0.2).
private final class Sim {
var size: CGSize = .zero
var columns = 0
var rows = 0
var rest: [CGPoint] = []
var position: [CGPoint] = []
var velocity: [CGVector] = []
var lastDate: Date?
func configure(size: CGSize, spacing: CGFloat) {
guard spacing > 0, size.width > 0, size.height > 0, size != self.size else { return }
self.size = size
columns = Int(size.width / spacing) + 2
rows = Int(size.height / spacing) + 2
rest.removeAll(keepingCapacity: true)
rest.reserveCapacity(columns * rows)
for row in 0..<rows {
for column in 0..<columns {
rest.append(CGPoint(x: CGFloat(column) * spacing, y: CGFloat(row) * spacing))
}
}
position = rest
velocity = Array(repeating: .zero, count: rest.count)
lastDate = nil
}
func nodeIndex(_ column: Int, _ row: Int) -> Int? {
guard column >= 0, column < columns, row >= 0, row < rows else { return nil }
return row * columns + column
}
var maxSpeed: Double {
velocity.reduce(0) { max($0, Double(hypot($1.dx, $1.dy))) }
}
func applyImpulse(near point: CGPoint, spacing: CGFloat, speed: CGFloat) {
guard spacing > 0 else { return }
let column = Int(point.x / spacing)
let row = Int(point.y / spacing)
for dc in 0...1 {
for dr in 0...1 {
guard let j = nodeIndex(column + dc, row + dr) else { continue }
let dx = position[j].x - point.x
let dy = position[j].y - point.y
let len = max(hypot(dx, dy), 0.5)
velocity[j].dx += dx / len * speed
velocity[j].dy += dy / len * speed
}
}
}
/// Fixed-dt catch-up loop for live use, clamped to 4 substeps so a
/// backgrounded app cannot explode on resume.
func stepLive(to date: Date, kAnchor: Double, kLink: Double, damping: Double, kGrab: Double, pullRadius: CGFloat, grabTarget: CGPoint?) {
let dt = 1.0 / 60.0
let elapsed = lastDate.map { date.timeIntervalSince($0) } ?? dt
lastDate = date
let steps = min(max(Int((elapsed / dt).rounded()), 1), 4)
for _ in 0..<steps {
substep(dt: dt, kAnchor: kAnchor, kLink: kLink, damping: damping, kGrab: kGrab, pullRadius: pullRadius, grabTarget: grabTarget)
}
}
/// Deterministic replay for the preview: always starts at rest and
/// steps forward with a fixed dt, so the same `phase` always produces
/// the same frame.
func replay(to targetTime: Double, kAnchor: Double, kLink: Double, damping: Double, impulseAt: Double, impulsePoint: CGPoint, impulseSpeed: CGFloat, spacing: CGFloat) {
position = rest
velocity = Array(repeating: .zero, count: rest.count)
let dt = 1.0 / 60.0
var t = 0.0
var firedImpulse = false
while t < targetTime {
if !firedImpulse, t >= impulseAt {
applyImpulse(near: impulsePoint, spacing: spacing, speed: impulseSpeed)
firedImpulse = true
}
substep(dt: dt, kAnchor: kAnchor, kLink: kLink, damping: damping, kGrab: 0, pullRadius: 0, grabTarget: nil)
t += dt
}
}
private func substep(dt: Double, kAnchor: Double, kLink: Double, damping: Double, kGrab: Double, pullRadius: CGFloat, grabTarget: CGPoint?) {
guard !position.isEmpty else { return }
for i in 0..<position.count {
let row = i / columns
let column = i % columns
var fx = Double(rest[i].x - position[i].x) * kAnchor
var fy = Double(rest[i].y - position[i].y) * kAnchor
for (dc, dr) in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
guard let j = nodeIndex(column + dc, row + dr) else { continue }
fx += Double(position[j].x - position[i].x) * kLink
fy += Double(position[j].y - position[i].y) * kLink
}
if let target = grabTarget, pullRadius > 0 {
let restDistance = hypot(rest[i].x - target.x, rest[i].y - target.y)
if restDistance < pullRadius {
let falloff = pow(1 - Double(restDistance / pullRadius), 2)
fx += Double(target.x - position[i].x) * kGrab * falloff
fy += Double(target.y - position[i].y) * kGrab * falloff
}
}
velocity[i].dx = CGFloat((Double(velocity[i].dx) + fx * dt) * damping)
velocity[i].dy = CGFloat((Double(velocity[i].dy) + fy * dt) * damping)
position[i].x += velocity[i].dx * CGFloat(dt)
position[i].y += velocity[i].dy * CGFloat(dt)
}
}
}
var body: some View {
TimelineView(.animation(paused: phase != nil || !isActive)) { timeline in
Canvas { context, size in
sim.configure(size: size, spacing: spacing)
lastSize = size
if let phase {
sim.replay(
to: phase * previewPeriod,
kAnchor: kAnchor, kLink: kLink, damping: damping,
impulseAt: previewImpulseAt * previewPeriod,
impulsePoint: CGPoint(x: previewImpulsePoint.x * size.width, y: previewImpulsePoint.y * size.height),
impulseSpeed: impulseSpeed,
spacing: spacing
)
} else if !reduceMotion {
sim.stepLive(
to: timeline.date,
kAnchor: kAnchor, kLink: kLink, damping: damping,
kGrab: kGrab, pullRadius: pullRadius, grabTarget: touchPoint
)
settleIfNeeded()
}
draw(context: context)
}
}
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
guard phase == nil, !reduceMotion else { return }
if !isDown {
isDown = true
downAt = Date()
downLocation = value.location
isActive = true
idleArmedAt = Date().timeIntervalSinceReferenceDate
}
touchPoint = value.location
}
.onEnded { value in
guard phase == nil, !reduceMotion else { return }
let heldFor = Date().timeIntervalSince(downAt)
let travelled = hypot(value.location.x - downLocation.x, value.location.y - downLocation.y)
if heldFor < quickTapWindow, travelled < quickTapSlop {
sim.applyImpulse(near: value.location, spacing: spacing, speed: impulseSpeed)
}
isDown = false
touchPoint = nil
isActive = true
}
)
.onContinuousHover(coordinateSpace: .local) { hoverPhase in
guard phase == nil, !reduceMotion, !isDown else { return }
switch hoverPhase {
case .active(let location):
touchPoint = location
isActive = true
idleArmedAt = Date().timeIntervalSinceReferenceDate
case .ended:
touchPoint = nil
}
}
.accessibilityHidden(true)
.task(id: idleDemo) { await runIdleDemo() }
}
// MARK: - Drawing
private func draw(context: GraphicsContext) {
guard !sim.position.isEmpty else { return }
if showLinks {
var links = Path()
for row in 0..<sim.rows {
for column in 0..<sim.columns {
guard let i = sim.nodeIndex(column, row) else { continue }
if let right = sim.nodeIndex(column + 1, row) {
links.move(to: sim.position[i])
links.addLine(to: sim.position[right])
}
if let down = sim.nodeIndex(column, row + 1) {
links.move(to: sim.position[i])
links.addLine(to: sim.position[down])
}
}
}
context.stroke(links, with: .color(color.opacity(0.35)), lineWidth: 0.75)
}
var dots = Path()
let radius: CGFloat = 1.6
for point in sim.position {
dots.addEllipse(in: CGRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2))
}
context.fill(dots, with: .color(color))
}
// MARK: - Interaction
private func settleIfNeeded() {
guard isActive, !settlePending, touchPoint == nil else { return }
guard sim.maxSpeed < settleSpeed else { return }
settlePending = true
DispatchQueue.main.async {
isActive = false
settlePending = 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))
let point = CGPoint(x: unit.x * lastSize.width, y: unit.y * lastSize.height)
await MainActor.run {
sim.applyImpulse(near: point, spacing: spacing, speed: impulseSpeed)
isActive = true
idleArmedAt = now
}
}
}
}
#Preview {
SpringLattice()
.frame(width: 420, height: 300)
}
SwiftUI note. Nodes = area / spacing^2 (595 at 390x844 @ 26pt) and the integrator is ~12 flops per node, so the sim is free — the two batched draws (dots + links) are the whole cost. Halving `spacing` quadruples both; 14pt on a full screen is the ceiling at ~2,000 nodes and ~4,000 links. Never stroke links individually. Touch-down, drag, and `.onContinuousHover` all write the same `touchPoint` that drives the grab spring, so a Mac cursor dents the mesh exactly like a finger. With nothing touching it, `previews/Animate.swift` deterministically replays one scripted impulse via `phase` (mechanism B) — it does not loop seamlessly on its own, which is why the loop is 32 frames at 14fps: long enough for the mesh to visibly settle before the wrap. Set `idleDemo` to have the lattice strike itself along 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


