
Pluck Threads
From syxUI — written for both platforms, not translated between them.
InteractiveTaut 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
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27


