Panning the swarm canvas (ClientScreen.kt) glided smoothly, but zooming —
via mouse scroll or pinch — rendered as a series of visible steps. Ben,
watching a demo render, described it as jerky, and noted it was “smooth
when things reset” (i.e. during a fitSwarm call).
DisplayNodes held two pieces of canvas transform state, scale and
targetOffset, but only targetOffset was spring-animated
(animateFloatAsState → animatedOffsetX/animatedOffsetY, fed into
graphicsLayer). scale was written directly from the scroll and pinch
gesture handlers and read straight into graphicsLayer(scaleX = scale, ...)
with no interpolation, so every gesture delta was an instant jump. A fitSwarm
request set scale once and animated only the offset, which is why a fit
read as one smooth move while a user-driven zoom read as a stair-step — the
“smooth when things reset” clue pointed straight at the asymmetry.
targetScale and added
rememberAnimatedScale(targetScale: Float): Float, a small composable
wrapping animateFloatAsState with the same spring parameters targetOffset
already uses. graphicsLayer and the tap-projection math (used for
connection-arc hit-testing) now read the animated value, so hit-testing
stays consistent with what’s actually on screen mid-animation.targetScale at every write site
(scroll, pinch, fit-to-swarm), including the pinch gesture handler, which
previously had no clamp at all.rememberAnimatedScale was extracted as its own composable (rather than
inlined in DisplayNodes) specifically so the animation behavior is
unit-testable without needing to construct DisplayNodes’s full
Koin-injected dependency graph (ClientNodeManager, ScreenCore).When a composable holds two pieces of state that drive the same visual
transform (here, pan and zoom feeding one graphicsLayer), give them
matching animation treatment from the start — an interpolated value next to
a raw one is an easy asymmetry to introduce and only shows up as a “feel”
bug, not a compile or logic error. For Compose spring/animation behavior
that’s awkward to unit test in place because the composable is
deeply wired to production DI, extract the animation itself into an
independent composable and test it directly with
runDesktopComposeUiTest’s virtual mainClock (not wall-clock delay) —
see AnimatedScaleTest.kt.