animating a panel

drive a spring and a smooth damper to make a button that bounces when clicked

by the end of this guide you will have a button that springs upward and scales out when you click it, bursts a spray of particles, and settles back to rest on its own. you will wire a SmoothFloat and a SpringFloat through an AnimationSet, feed both into a PanelTransform, add a self-driving ParticleField overlay via Hud.Particles, and learn the exact rebuild discipline that keeps the panel ticking while the motion runs and idle otherwise.

this article assumes you have read your first panel and your first counter for the GooPanel<Container> / Build() / Rebuild() basics, and build method for the dirty-flag and OnUpdate cycle those steps ride on.

the core loop

goo has no implicit animation system. the pattern is always the same four steps:

  1. store a damper or spring as a field on your panel.
  2. mutate its target from wherever input arrives. inside a goo event handler (OnClick, OnMouseMove, and the rest) the panel rebuilds itself automatically afterward, so you don't call Rebuild() by hand; from outside one (a timer, a network callback, another component's tick) call Rebuild() yourself.
  3. advance it in a Tick(float dt) override and return true while it is still moving.
  4. sample .Current inside Build() and feed it into the tree.

Tick is called every frame before the build gate. returning true requests a rebuild. returning false lets the panel idle, with one exception: on the frame motion stops (this tick returns false right after the previous tick returned true), goo rebuilds one extra time so the final settled value paints. after that, the panel does no further work until the next input event.

set up the fields

create Code/Demos/SpringButtonUI.cs. declare a SmoothFloat for scale and a SpringFloat for the bounce offset:

using Goo;
using Goo.Animation;
using Goo.Components;
using Sandbox.UI;
using PanelTransform = Goo.PanelTransform;   // disambiguate from Sandbox.UI.PanelTransform

namespace Sandbox;

public sealed class SpringButtonUI : GooPanel<Container>
{
    SmoothFloat _scale  = new( initial: 1f, smoothTime: 0.08f );
    SpringFloat _bounce = new( initial: 0f, frequency: 6f, damping: 0.5f );
    readonly AnimationSet _anims = new();

    protected override void OnEnabled()
    {
        base.OnEnabled();
        _anims.Clear();
        _anims.Add( dt => _scale.Tick( dt ) );
        _anims.Add( dt => _bounce.Tick( dt ) );
    }
}

SmoothFloat approaches its target smoothly and tracks velocity, so rapid re-clicks snap it back without a glitch. SpringFloat can overshoot: a damping of 0.5 means the button bounces past its target before settling. frequency controls how fast the spring oscillates.

registering animators from OnEnabled (not per-frame) and calling _anims.Clear() first prevents double-registration on hotload or re-enable.

tick and build

add the Tick override and a Build that samples both animators:

protected override bool Tick( float dt ) => _anims.UpdateAll( dt );

protected override Container Build() => new Container
{
    Width           = 200f,
    Height          = 200f,
    JustifyContent  = Justify.Center,
    AlignItems      = Align.Center,
    Children =
    {
        new Container
        {
            Width           = 90f,
            Height          = 90f,
            BorderRadius    = 12,
            BackgroundColor = Theme.AccentBlue,
            JustifyContent  = Justify.Center,
            AlignItems      = Align.Center,
            Transform       = PanelTransform.Scale( _scale.Current )
                                            .TranslateY( Px.Of( -_bounce.Current * 30f ) ),
            OnClick         = _ => Poke(),
            Children        = { new Text( "click me" ) { FontColor = Color.White } },
        },
    },
};

_scale.Current and _bounce.Current are sampled fresh every time Build() runs. the transform chain is Scale first, then TranslateY: the button grows and lifts simultaneously. Px.Of wraps the float in the Length the engine expects for an absolute pixel offset.

wire the click

void Poke()
{
    _scale.Target   = _scale.Target > 1.01f ? 1f : 1.35f;
    _bounce.Current = 1f;
    _bounce.Target  = 0f;
}

toggling _scale.Target between 1 and 1.35 means a second click while still expanded springs back to normal size. setting _bounce.Current = 1f directly gives the spring an instant kick rather than a target change, which produces the pop feel. Poke runs inside OnClick, a goo event handler, so the panel rebuilds itself right after it returns: AutoRebuildOnEvents defaults to true on every GooPanel, so there's no Rebuild() call to write here. after that first rebuild, Tick keeps returning true until both animators settle, and goo rebuilds each of those frames too.

press play, drop SpringButtonUI on a screen panel, and click. the button scales out and pops upward, bounces once or twice, then idles.

what just happened

the button is three layers:

the animations reference covers AnimationSet, tweens, Animator, and AgePhase - all follow the same advance-in-Tick, sample-in-Build posture.

burst on click

a spring bounce sells the pop, but a shower of particles makes the click read as physical. add a ParticleField as a field, spawn into it from Poke, and mount it with Hud.Particles so it draws itself every frame with no extra Tick work of its own:

using Goo;
using Goo.Animation;
using Goo.Components;
using Sandbox.UI;
using PanelTransform = Goo.PanelTransform;   // disambiguate from Sandbox.UI.PanelTransform

namespace Sandbox;

public sealed class SpringButtonUI : GooPanel<Container>
{
    SmoothFloat _scale  = new( initial: 1f, smoothTime: 0.08f );
    SpringFloat _bounce = new( initial: 0f, frequency: 6f, damping: 0.5f );
    readonly AnimationSet _anims = new();
    readonly ParticleField _fx = new();

    protected override void OnEnabled()
    {
        base.OnEnabled();
        _anims.Clear();
        _anims.Add( dt => _scale.Tick( dt ) );
        _anims.Add( dt => _bounce.Tick( dt ) );
    }

    protected override bool Tick( float dt ) => _anims.UpdateAll( dt );

    protected override Container Build() => Layout.Stack() with
    {
        Width           = 200f,
        Height          = 200f,
        JustifyContent  = Justify.Center,
        AlignItems      = Align.Center,
        Children =
        {
            new Container
            {
                Width           = 90f,
                Height          = 90f,
                BorderRadius    = 12,
                BackgroundColor = Theme.AccentBlue,
                JustifyContent  = Justify.Center,
                AlignItems      = Align.Center,
                Transform       = PanelTransform.Scale( _scale.Current )
                                                .TranslateY( Px.Of( -_bounce.Current * 30f ) ),
                OnClick         = _ => Poke(),
                Children        = { new Text( "click me" ) { FontColor = Color.White } },
            },
            Hud.Particles( _fx ),
        },
    };

    void Poke()
    {
        _scale.Target   = _scale.Target > 1.01f ? 1f : 1.35f;
        _bounce.Current = 1f;
        _bounce.Target  = 0f;
        _fx.Burst( new Vector2( 100f, 130f ), count: 16, speed: 220f, color: Theme.AccentBlue );
    }
}

Layout.Stack() sets Position = Relative so an absolute overlay resolves against this container instead of escaping to a further ancestor. Hud.Particles returns a full-bleed, pointer-through container that steps _fx and draws its live particles from its own paint callback: as long as its draw callback is set, it self-dirties every frame regardless of whether any particles are currently live, so it needs no Tick override of its own, and it composes cleanly alongside the spring/scale pair that Tick still drives. (100, 130) approximates the button's resting center in the 200x200 stack; nudge it if you resize the button or the host.

measure the rebuild cost

the core loop promises that goo idles once the spring and bounce settle. Perf lets you check that promise instead of taking it on faith:

Perf.Enabled = true;
Perf.Reset();
// click the button a few times, let it settle, then:
Log.Info( Perf.Report() );
Perf.Enabled = false;

Perf.Report() breaks down time per phase (build, diff, apply) across every rebuild since the last Reset(), plus a per-op-kind table. with SpringButtonUI idle, the rebuild count stops climbing the moment Tick settles; every poke and the handful of frames it takes to settle again are the only rebuilds that show up. leave Perf.Enabled off in shipped code: it costs a branch per rebuild and per op even when disabled.

see also