Skip to content
Pluck Threads preview
An animated render of the SwiftUI source on this page.

Pluck Threads

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

Interactive

Taut threads that ring like strings when you swipe across them, each at its own pitch.

Backgrounds · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • background
  • lines
  • threads
  • string
  • pluck
  • interactive
  • touch

The actual source

PluckThreads.swift
// Pluck Threads · syxUI · https://syxui.dev/components/pluck-threads
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// Taut threads that ring like strings when you swipe across them, each at
/// its own pitch.
///
/// Every thread is a real second-order simulation — a fundamental and a
/// third-harmonic damped oscillator, each with position *and* velocity, not
/// a keyframe curve. Dragging near a thread bends it toward the finger
/// directly; the moment the finger moves past it, that bend becomes a
/// velocity kick and the thread rings down on its own. `ThreadsSim` is a
/// plain, non-observed class held in `@State` and mutated from inside the
/// `Canvas` closure, per §0.2 of the backgrounds contract.
///
/// At rest a thread is two points — a straight line, no per-sample cost —
/// and the whole `TimelineView` pauses once every thread has rung down and
/// nothing is touching it (§0.4): an untouched screen costs nothing.
struct PluckThreads: View {
    var threads: Int = 18
    /// Sets the base pitch, 1-6 Hz.
    var tension: Double = 1
    var decay: Double = 0.9
    var maxPull: CGFloat = 34
    var color: Color = Color(red: 0.561, green: 0.643, blue: 0.847)
    var orientation: String = "horizontal"
    /// Preview override. When set, plucks are scripted and evaluated in
    /// closed form rather than simulated — see `previewDisplacement`.
    var phase: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var sim = ThreadsSim()
    @State private var touchPoint: CGPoint? = nil
    @State private var isResting = false

    // Preview-only scripting (mechanism B, closed-form). Threads are plucked
    // in a tight window near loop start so even the last one has time to
    // ring fully down before the loop wraps.
    private let previewPeriodSeconds: Double = 2.4
    private let previewPokeStart: Double = 0.03
    private let previewPokeStep: Double = 0.006
    private let previewPullFraction: Double = 0.7

    var body: some View {
        TimelineView(.animation(paused: reduceMotion || phase != nil || isResting)) { timeline in
            Canvas { context, size in
                sim.ensureCount(threads)

                if phase == nil, !reduceMotion {
                    sim.step(
                        now: timeline.date,
                        touch: touchPoint,
                        orientation: orientation,
                        size: size,
                        maxPull: maxPull,
                        tension: tension,
                        decay: decay
                    )
                    let settled = sim.isSettled && touchPoint == nil
                    if settled != isResting {
                        Task { @MainActor in isResting = settled }
                    }
                }

                draw(context: context, size: size)
            }
        }
        .contentShape(Rectangle())
        .gesture(dragGesture)
        .accessibilityHidden(true)
    }

    // MARK: - Drawing

    private func draw(context: GraphicsContext, size: CGSize) {
        let count = max(threads, 1)
        let vertical = orientation == "vertical"
        let alongLength = vertical ? size.height : size.width
        let crossLength = vertical ? size.width : size.height
        let sampleCount = max(24, Int(alongLength / 8))

        for index in 0..<count {
            let restCross = (CGFloat(index) + 0.5) / CGFloat(count) * crossLength
            var path = Path()

            // Idle threads cost two points, not `sampleCount` — an untouched
            // 18-thread board is 18 line segments, not 864.
            if phase == nil, sim.isThreadAtRest(index) {
                path.move(to: threadPoint(along: 0, cross: restCross, vertical: vertical))
                path.addLine(to: threadPoint(along: alongLength, cross: restCross, vertical: vertical))
            } else {
                for step in 0...sampleCount {
                    let s = Double(step) / Double(sampleCount)
                    let displacement = phase == nil
                        ? sim.displacement(threadIndex: index, s: s)
                        : previewDisplacement(threadIndex: index, s: s, loopT: phase!)
                    let along = CGFloat(s) * alongLength
                    let cross = restCross + CGFloat(displacement)
                    let point = threadPoint(along: along, cross: cross, vertical: vertical)
                    if step == 0 { path.move(to: point) } else { path.addLine(to: point) }
                }
            }

            context.stroke(path, with: .color(color), lineWidth: 1)
        }
    }

    // MARK: - Preview scripted plucks (mechanism B — closed-form)

    /// Each oscillator is a linear damped system, so a scripted pluck can be
    /// evaluated directly at `loopT` with the exact impulse-response formula
    /// — no integration needed for a deterministic preview frame.
    private func previewDisplacement(threadIndex: Int, s: Double, loopT: Double) -> Double {
        let t0 = previewPokeStart + Double(threadIndex) * previewPokeStep
        let dt = (loopT - t0) * previewPeriodSeconds
        guard dt > 0 else { return 0 }
        let f1 = threadFrequency(threadIndex, tension: tension)
        let amount = previewPullFraction * Double(maxPull)
        let fundamental = impulseResponse(amount: amount, frequency: f1, tau: decay, t: dt)
        let harmonic3 = impulseResponse(amount: amount / 3, frequency: f1 * 3, tau: decay, t: dt)
        return fundamental * sin(.pi * s) + harmonic3 * sin(3 * .pi * s)
    }

    // MARK: - Gesture

    private var dragGesture: some Gesture {
        DragGesture(minimumDistance: 0)
            .onChanged { value in
                guard phase == nil, !reduceMotion else { return }
                touchPoint = value.location
                isResting = false
            }
            .onEnded { _ in
                touchPoint = nil
            }
    }
}

private func threadPoint(along: CGFloat, cross: CGFloat, vertical: Bool) -> CGPoint {
    vertical ? CGPoint(x: cross, y: along) : CGPoint(x: along, y: cross)
}

/// `index` 0-based; lower threads ring lower, higher ones ring higher, so a
/// swipe across the stack reads as a glissando.
private func threadFrequency(_ index: Int, tension: Double) -> Double {
    tension * 2.0 * (1 + Double(index) * 0.06)
}

/// Exact impulse response of a velocity-kicked damped oscillator, peaking
/// near `amount`. Shared by the live release kick and the preview's
/// closed-form evaluation, so both agree on what "ringing" looks like.
private func impulseResponse(amount: Double, frequency: Double, tau: Double, t: Double) -> Double {
    let omega = 2 * .pi * frequency
    let zeta = min(1 / (max(tau, 0.05) * omega), 0.95)
    let omegaD = omega * (max(1 - zeta * zeta, 0.0001)).squareRoot()
    let v0 = amount * omega
    return v0 / omegaD * exp(-zeta * omega * t) * sin(omegaD * t)
}

/// A plain, non-observed class holding one damped oscillator pair per
/// thread. Nothing here is `@Observable` — it is mutated directly from
/// inside the `Canvas` closure per the contract's stateful-simulation
/// pattern, so a hold, a release or a step cannot itself trigger a
/// re-entrant body evaluation.
private final class ThreadsSim {
    struct Oscillator {
        var x: Double = 0
        var v: Double = 0
    }
    struct ThreadState {
        var fundamental = Oscillator()
        var harmonic3 = Oscillator()
        var isHeld = false
        var heldAmount: Double = 0
        var heldAtFraction: Double = 0.5
    }

    private(set) var threads: [ThreadState] = []
    private var lastStep: Date?
    private var tensionCache: Double = 1

    func ensureCount(_ count: Int) {
        let clamped = max(count, 0)
        guard threads.count != clamped else { return }
        threads = Array(repeating: ThreadState(), count: clamped)
    }

    /// Updates which threads are held by the current touch, releases any
    /// that just fell out of range, then integrates every oscillator with a
    /// fixed-dt catch-up loop clamped to a few substeps.
    func step(now: Date, touch: CGPoint?, orientation: String, size: CGSize, maxPull: CGFloat, tension: Double, decay: Double) {
        tensionCache = tension
        updateHold(touch: touch, orientation: orientation, size: size, maxPull: maxPull)

        let dt = 1.0 / 60
        let elapsed = lastStep.map { now.timeIntervalSince($0) } ?? dt
        lastStep = now
        let steps = max(1, min(Int((elapsed / dt).rounded()), 4))

        for _ in 0..<steps {
            for index in threads.indices {
                let f1 = threadFrequency(index, tension: tension)
                integrate(&threads[index].fundamental, frequency: f1, tau: decay, dt: dt)
                integrate(&threads[index].harmonic3, frequency: f1 * 3, tau: decay, dt: dt)
            }
        }
    }

    private func updateHold(touch: CGPoint?, orientation: String, size: CGSize, maxPull: CGFloat) {
        guard !threads.isEmpty else { return }
        let vertical = orientation == "vertical"
        let crossLength = vertical ? size.width : size.height
        let alongLength = vertical ? size.height : size.width
        let count = threads.count

        for index in threads.indices {
            let restCross = (CGFloat(index) + 0.5) / CGFloat(count) * crossLength

            if let touch {
                let touchCross = vertical ? touch.x : touch.y
                let touchAlong = vertical ? touch.y : touch.x
                let distance = touchCross - restCross
                if abs(distance) < maxPull / 2 {
                    threads[index].isHeld = true
                    threads[index].heldAmount = Double(max(-maxPull, min(maxPull, distance)))
                    threads[index].heldAtFraction = Double(max(0, min(1, touchAlong / max(alongLength, 1))))
                    continue
                }
            }
            if threads[index].isHeld {
                release(index)
            }
        }
    }

    private func release(_ index: Int) {
        let amount = threads[index].heldAmount
        let f1 = threadFrequency(index, tension: tensionCache)
        threads[index].fundamental.v += amount * 2 * .pi * f1
        threads[index].harmonic3.v += (amount / 3) * 2 * .pi * (f1 * 3)
        threads[index].isHeld = false
        threads[index].heldAmount = 0
    }

    private func integrate(_ oscillator: inout Oscillator, frequency: Double, tau: Double, dt: Double) {
        let omega = 2 * .pi * frequency
        let zeta = min(1 / (max(tau, 0.05) * omega), 0.95)
        let acceleration = -omega * omega * oscillator.x - 2 * zeta * omega * oscillator.v
        oscillator.v += acceleration * dt
        oscillator.x += oscillator.v * dt
    }

    /// A thread whose amplitude is under 0.2pt draws as a flat two-point
    /// line — the perf shortcut that makes an idle board nearly free.
    func isThreadAtRest(_ index: Int) -> Bool {
        guard threads.indices.contains(index) else { return true }
        let thread = threads[index]
        return !thread.isHeld && abs(thread.fundamental.x) < 0.2 && abs(thread.harmonic3.x) < 0.2
    }

    func displacement(threadIndex: Int, s: Double) -> Double {
        guard threads.indices.contains(threadIndex) else { return 0 }
        let thread = threads[threadIndex]
        var y = thread.fundamental.x * sin(.pi * s) + thread.harmonic3.x * sin(3 * .pi * s)
        if thread.isHeld {
            let width = 0.35
            let d = abs(s - thread.heldAtFraction)
            let bump = max(0, 1 - d / width)
            let smooth = bump * bump * (3 - 2 * bump)
            y += thread.heldAmount * smooth
        }
        return y
    }

    var isSettled: Bool {
        threads.allSatisfy {
            !$0.isHeld
                && abs($0.fundamental.x) < 0.15 && abs($0.fundamental.v) < 0.05
                && abs($0.harmonic3.x) < 0.15 && abs($0.harmonic3.v) < 0.05
        }
    }
}

#Preview {
    PluckThreads()
        .frame(width: 420, height: 300)
}
iOS 17 · No dependencies

SwiftUI note. threads x sampleCount line segments while ringing (18x48 = 864 at full ring), but an idle thread draws as two points, so an untouched board is 18 segments and the whole `TimelineView` pauses once nothing is ringing or touching it. Single-pointer drag only; a swipe still crosses and plucks every thread it passes, which is most of what multi-touch would buy here.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27