Ollin

Timeline

a Timeline<Vector2> walks a dot around a square, with one easing per side and a hold at each corner, while a second timeline grows and shrinks its size, and progress is tracked below with a tick per keyframe

Run
swift run --package-path Examples Example-Motion-Timeline
Source
Examples/Motion/Timeline/Sketch.swift
import Ollin

/// `Timeline` sequences a value through timed keyframes, each leg with its own
/// easing: start here, glide there, hold, glide on. Where `@Eased` chases a
/// single target, a timeline follows a script. Here a `Timeline<Vector2>`
/// walks a dot around a square, every side on a different curve with a pause
/// at each corner, while a shorter `Timeline<Double>` breathes the dot's size
/// in counterpoint (four breaths per lap, so the pair loops together). Stored
/// timelines advance themselves once per frame, so `draw()` only reads
/// `.value`; the bar underneath tracks `progress`, with a tick per keyframe.
@main
final class TimelineTour: Sketch {
    static let corners = [
        Vector2(280, 300), Vector2(800, 300),
        Vector2(800, 820), Vector2(280, 820),
    ]

    /// The script's segment lengths, in order: travel a side, hold a corner.
    static let segments = [1.0, 0.3, 1.0, 0.3, 1.0, 0.3, 1.0, 0.3]

    // One lap of the square (5.2s), a different character per side.
    let path = Timeline(TimelineTour.corners[0])
        .to(TimelineTour.corners[1], in: 1.0, curve: .easeInOut)
        .hold(for: 0.3)
        .to(TimelineTour.corners[2], in: 1.0, curve: .easeOutBounce)
        .hold(for: 0.3)
        .to(TimelineTour.corners[3], in: 1.0, curve: .easeOutBack)
        .hold(for: 0.3)
        .to(TimelineTour.corners[0], in: 1.0, curve: .easeOutElastic)
        .hold(for: 0.3)

    // A breath every 1.3s: four per lap, so both timelines loop in step.
    let size = Timeline(26.0)
        .to(40, in: 0.65, curve: .easeInOut)
        .to(26, in: 0.65, curve: .easeInOut)

    override func setup() {
        path.loops = true
        size.loops = true
    }

    override func draw() {
        background(.white)

        // The route: the square being traveled, corners marked.
        stroke(Color(white: 0.88)); strokeWeight(2 * scale); noFill()
        drawPolyline(Self.corners, closed: true)
        for corner in Self.corners {
            drawCircle(center: corner, radius: 9 * scale)
        }

        // Each side's easing, labeled at its midpoint.
        noStroke(); fill(Color(white: 0.62)); textSize(21 * scale)
        textAlign(.center, .middle)
        drawText("easeInOut", width / 2, 252)
        drawText("easeOutBack", width / 2, 868)
        textAlign(.left, .middle)
        drawText("easeOutBounce", 828, height / 2)
        textAlign(.right, .middle)
        drawText("easeOutElastic", 252, height / 2)

        // Progress through the whole script, a tick at every keyframe.
        let barY = 972.0
        let barLeft = Self.corners[0].x, barRight = Self.corners[1].x
        stroke(Color(white: 0.88)); strokeWeight(3 * scale)
        drawLine(barLeft, barY, barRight, barY)
        var elapsed = 0.0
        for segment in Self.segments {
            elapsed += segment
            let x = lerp(barLeft, barRight, elapsed / path.duration)
            drawLine(x, barY - 7 * scale, x, barY + 7 * scale)
        }
        stroke(.coral)
        drawLine(barLeft, barY, lerp(barLeft, barRight, path.progress), barY)

        // The performer, script-positioned and script-sized.
        noStroke(); fill(.coral)
        drawCircle(center: path.value, radius: size.value * scale)
    }
}

↑↓ move ↩ open