
Gooey Blobs
From syxUI — written for both platforms, not translated between them.
InteractiveSoft blobs that fuse into one liquid mass, with a new one born wherever you tap.
Backgrounds · SwiftUI · Flutter · iOS 17 · Flutter 3.27
- background
- metaball
- blob
- liquid
- gooey
- interactive
- touch
The actual source
GooeyBlobs.swift
// Gooey Blobs · syxUI · https://syxui.dev/components/gooey-blobs
// Free in any project. Keep this line and credit syxUI where a person can read it.
import SwiftUI
/// Soft blobs that fuse into one liquid mass, with a new one born wherever you tap.
///
/// The metaball look comes from exactly two `GraphicsContext` filters stacked on
/// one offscreen layer: a blur to spread each circle's alpha into its
/// neighbours, then `.alphaThreshold` to harden that shared haze back into a
/// crisp liquid edge. No shader, no Metal — this is the sanctioned SwiftUI
/// metaball primitive.
///
/// The ambient blobs are a pure function of `current` (Lissajous paths with
/// integer frequency ratios, so the loop closes exactly at t=1). Only the
/// tap-spawned blobs carry real state — a fixed-size ring buffer of six,
/// mutated from inside the `Canvas` closure per §0.2 of the backgrounds
/// contract — because their birth, drift and death depend on wall-clock time
/// and gesture history that no pure function of `phase` alone can express.
struct GooeyBlobs: View {
var blobCount: Int = 6
var blobRadius: CGFloat = 62
var blur: CGFloat = 26
var threshold: Double = 0.5
var tint: Color = Color(red: 0.357, green: 0.549, blue: 1.0)
var background: Color = Color(red: 0.031, green: 0.039, blue: 0.071)
/// Preview override. When set, the component renders that point of one
/// loop instead of driving its own clock, and taps are scripted rather
/// than gestured (see `previewSpawnRect`).
var phase: Double? = nil
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var animated: Double = 0
@State private var taps = TapRingBuffer()
@State private var isDown = false
private var current: Double { phase ?? animated }
// Preview-only scripting constants (mechanism A, §0.5). Tuned so the one
// scripted spawn fully grows, holds and shrinks before the loop wraps —
// the live values (4s hold, 0.8s shrink) are far too slow for a ~2s loop.
private let previewLoopSeconds: Double = 2.0
private let previewSpawnAt: Double = 0.06
private let previewGrow: Double = 0.2
private let previewHold: Double = 0.7
private let previewShrink: Double = 0.6
private let previewSpawnPoint = UnitPoint(x: 0.60, y: 0.56)
var body: some View {
Canvas { context, size in
context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(background))
var rects: [CGRect] = []
for index in 0..<max(blobCount, 0) {
rects.append(permanentRect(index: index, size: size, t: current))
}
if phase == nil {
let now = Date.now
if !reduceMotion {
taps.step(now: now)
}
rects.append(contentsOf: taps.liveRects(baseRadius: blobRadius, now: now))
} else if let spawned = previewSpawnRect(size: size, t: current) {
rects.append(spawned)
}
context.drawLayer { layer in
layer.addFilter(.blur(radius: blur))
layer.addFilter(.alphaThreshold(min: threshold, color: tint))
for rect in rects {
layer.fill(Path(ellipseIn: rect), with: .color(.white))
}
}
}
.onAppear(perform: start)
.gesture(dragGesture)
.accessibilityHidden(true)
}
// MARK: - Ambient permanent blobs
/// One permanent blob's rect at loop-fraction `t`. Frequencies are small
/// integers so `f(0) == f(1)` for every blob — the drift loops seamlessly
/// with no cross-fade needed.
private func permanentRect(index: Int, size: CGSize, t: Double) -> CGRect {
let fx = 1.0 + Double(index % 3)
let fy = 1.0 + Double((index + 2) % 4)
let offset = Double(index) * 0.618_033_988_75
let cx = 0.5 + 0.30 * sin(2 * .pi * (fx * t + offset))
let cy = 0.5 + 0.24 * sin(2 * .pi * (fy * t + offset * 1.3))
let breathe = 1 + 0.06 * sin(2 * .pi * (2 * t + offset))
let radius = blobRadius * breathe
let centre = CGPoint(x: cx * size.width, y: cy * size.height)
return CGRect(x: centre.x - radius, y: centre.y - radius, width: radius * 2, height: radius * 2)
}
// MARK: - Preview scripted spawn (mechanism A)
private func previewSpawnRect(size: CGSize, t: Double) -> CGRect? {
guard t >= previewSpawnAt else { return nil }
let age = (t - previewSpawnAt) * previewLoopSeconds
let radius = spawnEnvelope(age: age, base: blobRadius, grow: previewGrow, hold: previewHold, shrink: previewShrink)
guard radius > 0.5 else { return nil }
let centre = CGPoint(x: previewSpawnPoint.x * size.width, y: previewSpawnPoint.y * size.height)
return CGRect(x: centre.x - radius, y: centre.y - radius, width: radius * 2, height: radius * 2)
}
// MARK: - Gesture
private var dragGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
guard phase == nil, !reduceMotion else { return }
if !isDown {
isDown = true
taps.spawn(at: value.location, now: .now, baseRadius: blobRadius)
} else {
taps.updateDrag(to: value.location)
}
}
.onEnded { _ in
isDown = false
taps.endDrag()
}
}
private func start() {
guard phase == nil, !reduceMotion else { return }
withAnimation(.linear(duration: 9).repeatForever(autoreverses: false)) {
animated = 1
}
}
}
/// Radius envelope shared by the live ring buffer and the preview script: a
/// spring-overshoot grow, a flat hold, then an ease-in shrink to zero.
private func spawnEnvelope(age: Double, base: CGFloat, grow: Double, hold: Double, shrink: Double) -> CGFloat {
guard age >= 0 else { return 0 }
if age < grow {
let p = age / grow
return base * CGFloat(easeOutBack(p))
}
if age < grow + hold {
return base
}
let p = min((age - grow - hold) / shrink, 1)
return base * CGFloat(1 - p * p)
}
/// Standard overshoot-then-settle ease, so a spawn inflates past its resting
/// size before easing back — the bit of "boing" that reads as liquid rather
/// than a static circle fading in.
private func easeOutBack(_ x: Double) -> Double {
let c1 = 1.70158
let c3 = c1 + 1
let m = x - 1
return 1 + c3 * m * m * m + c1 * m * m
}
/// A plain, non-observed class holding the tap-spawned blobs. Nothing here is
/// `@Observable`; it is mutated directly from inside the `Canvas` closure per
/// the contract's stateful-simulation pattern, so a spawn or a drag update
/// cannot itself trigger a re-entrant body evaluation.
///
/// Six fixed ring-buffer slots plus one `evicted` slot: a seventh spawn while
/// all six are still alive hands its slot's occupant a fast 0.4s shrink
/// (`evicted`) instead of popping it, so the buffer never allocates and never
/// visibly snaps a blob out of existence.
private final class TapRingBuffer {
private struct Tap {
var centre: CGPoint
var born: Date
}
private struct Evicted {
var centre: CGPoint
var radiusAtEviction: CGFloat
var evictedAt: Date
}
private let growTime: Double = 0.25
private let holdTime: Double = 4.0
private let shrinkTime: Double = 0.8
private let evictShrinkTime: Double = 0.4
private var slots: [Tap?] = Array(repeating: nil, count: 6)
private var cursor = 0
private var draggedSlot: Int?
private var dragTarget: CGPoint?
private var evicted: Evicted?
private var lastStep: Date?
func spawn(at point: CGPoint, now: Date, baseRadius: CGFloat) {
let slot = cursor % slots.count
if let old = slots[slot], now.timeIntervalSince(old.born) < growTime + holdTime {
let radius = spawnEnvelope(age: now.timeIntervalSince(old.born), base: baseRadius, grow: growTime, hold: holdTime, shrink: shrinkTime)
evicted = Evicted(centre: old.centre, radiusAtEviction: radius, evictedAt: now)
}
slots[slot] = Tap(centre: point, born: now)
draggedSlot = slot
dragTarget = point
cursor += 1
}
func updateDrag(to point: CGPoint) {
dragTarget = point
}
func endDrag() {
draggedSlot = nil
dragTarget = nil
}
/// Fixed-dt catch-up loop, clamped to a few substeps so a background/
/// foreground round-trip cannot make the drag lag explode into a jump.
func step(now: Date) {
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 {
if let slot = draggedSlot, var tap = slots[slot], let target = dragTarget {
let smoothing = 1 - exp(-dt / 0.12)
tap.centre.x += (target.x - tap.centre.x) * smoothing
tap.centre.y += (target.y - tap.centre.y) * smoothing
slots[slot] = tap
}
}
for index in slots.indices where index != draggedSlot {
if let tap = slots[index], now.timeIntervalSince(tap.born) > growTime + holdTime + shrinkTime {
slots[index] = nil
}
}
if let e = evicted, now.timeIntervalSince(e.evictedAt) > evictShrinkTime {
evicted = nil
}
}
func liveRects(baseRadius: CGFloat, now: Date) -> [CGRect] {
var rects: [CGRect] = []
for tap in slots.compactMap({ $0 }) {
let age = now.timeIntervalSince(tap.born)
let radius = spawnEnvelope(age: age, base: baseRadius, grow: growTime, hold: holdTime, shrink: shrinkTime)
guard radius > 0.5 else { continue }
rects.append(CGRect(x: tap.centre.x - radius, y: tap.centre.y - radius, width: radius * 2, height: radius * 2))
}
if let e = evicted {
let p = min(now.timeIntervalSince(e.evictedAt) / evictShrinkTime, 1)
let radius = e.radiusAtEviction * (1 - CGFloat(p))
if radius > 0.5 {
rects.append(CGRect(x: e.centre.x - radius, y: e.centre.y - radius, width: radius * 2, height: radius * 2))
}
}
return rects
}
}
#Preview {
GooeyBlobs()
.frame(width: 420, height: 300)
}
iOS 17 · No dependencies
SwiftUI note. Cost is two full-screen offscreen passes (blur, then alphaThreshold) — blob count is nearly free, screen area is not. Keep `blur` at 30 or below; for more softness on an older device, render the layer at half resolution and upscale rather than raising sigma. With `phase` set the component skips gesture state entirely and computes a single scripted spawn 6% into the loop, so the committed preview fuses a new blob into the ambient field with no touch present.
Dependencies
- SwiftUI
- No external dependencies
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27


