Skip to content
Gooey Blobs preview
An animated render of the SwiftUI source on this page.

Gooey Blobs

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

Interactive

Soft 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

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27