Skip to content
Elastic Lattice preview
An animated render of the SwiftUI source on this page.

Elastic Lattice

From syxUI — written for both platforms, not translated between them.

Interactive

A 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

SpringLattice.swift
// 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)
}
iOS 17 · No dependencies

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

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27