Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "hyper-effects-marketplace",
"owner": {
"name": "Hyperdesigned"
},
"plugins": [
{
"name": "hyper-effects",
"description": "A Flutter hyper_effects skill covering effects, motion, scroll transitions, and timelines.",
"source": "."
}
],
"metadata": {
"description": "Consumer guidance for building Flutter animations with Hyper Effects."
}
}
8 changes: 8 additions & 0 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "hyper-effects",
"description": "Consumer guidance for Flutter hyper_effects, including effects, springs, scroll transitions, and timelines.",
"version": "0.4.0",
"author": {
"name": "Hyperdesigned"
}
}
Binary file modified .github/assets/banner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 8 additions & 1 deletion .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,14 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Format code
run: dart format --set-exit-if-changed .
run: dart format --output=none --set-exit-if-changed .
- name: Analyze
run: flutter analyze
- name: Test package
run: flutter test
- name: Test example
working-directory: example
run: flutter test
- name: Dry Run
run: flutter pub publish --dry-run
- name: Publish
Expand Down
38 changes: 38 additions & 0 deletions .pubignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/

# IntelliJ related
*.iml
*.ipr
*.iws
.idea/

# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/

# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.packages
build/

**/*.lock

# Local tooling and demo deployment state
.claude/
.firebase/
.firebaserc
firebase.json
262 changes: 262 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,268 @@

All notable changes to the Hyper Effects package are documented in this file.

## [0.4.0] - Sep 27, 2026

### Breaking
- **Translated widgets now move their hit-test region by default** — every
`.translate*()` extension and `TranslateEffect` now defaults
`transformHitTests` to `true`, matching Flutter's `Transform.translate`
and `FractionalTranslation` defaults as well as `ScaleEffect`. RollEffect's
internal `FractionalTranslation`s now inherit that framework default too.
Interactive children therefore answer where they are painted instead of
leaving an invisible hit target at their pre-transform layout position.
Pass `transformHitTests: false` explicitly on translate effects for
deliberate paint-only movement.

- **`AlignEffect` factors now default to null** — `widthFactor` and
`heightFactor` on `AlignEffect` and `.align()` are now `double?`
defaulting to null, matching Flutter's `Align`: null expands to fill the
incoming constraints instead of shrink-wrapping to the child. Previously
the hardcoded default of `1` forced every `.align()`, `.alignX()`,
`.alignY()`, and `.alignXY()` to shrink-wrap, which made the alignment a
visual no-op under loose constraints. Pass `widthFactor: 1` /
`heightFactor: 1` explicitly to restore the old shrink-wrap behavior.
Mixed null/non-null factor endpoints snap to the target value rather
than interpolating (there is no numeric interpolation between "fill
constraints" and a size factor).

### Added
- **Layout-driven size effects** — `SizeEffect` and the `.widthTo()`,
`.heightTo()`, `.sizeTo()`, and `.sizeOf()` widget extensions animate the
actual width and height reported during layout, so surrounding widgets
reflow instead of merely seeing paint-time scaling. Each axis can remain
unconstrained independently, bouncy springs preserve overshoot and carry
velocity across mid-flight retargets, and negative overshoot is floored
only when the effect builds its `SizedBox`.

- **Spring-downgrade diagnostic** — in debug builds, driving an effect that
doesn't mix in `VectorEffect` (e.g. `.blur()`, `.clip()`, `.colorFilter()`)
with a `SpringMotion` now prints a debug warning the first time it
happens for that effect type, naming the offending effect, spelling out
what the silent fallback actually costs — overshoot and settle-back
become dependent on whether that effect's `lerp` extrapolates or clamps,
rather than guaranteed vector spring physics, and a mid-flight retarget
can no longer hand off velocity, restarting from rest instead — and
recommending either
mixing in `VectorEffect<T>` on that effect or switching the `.animate()`
to a `CurvedMotion`. The check sits at the exact point `build()` decides
to fall back to the normalized lerp, so it cannot fire for `isTransition`
queries, `lerpValues: false` queries, or curve-driven motions, and it
warns once per effect TYPE rather than once per widget instance, since
the fix is a one-line change to the effect's class, not to any one call
site. It deliberately bypasses `FlutterError.onError`, so developer guidance
isn't forwarded to crash reporting or promoted to a test failure.
Assert-gated, so it costs nothing in release or profile builds.

- **Velocity handoff for springs** — retargeting a spring-driven
`.animate()` mid-flight (changing the effect's target while it moves)
now carries momentum: the new spring starts from the captured position
AND the captured velocity, so rapid re-targets whip naturally instead of
deadening. Built on the new `VectorEffect` mixin — SwiftUI-style vector
arithmetic (`+`, `-`, `*`, `magnitudeSquared`) implemented by
`TranslateEffect`, `ScaleEffect`, `RotationEffect`, and `OpacityEffect` —
and a closed-form spring solver verified against Flutter's own
`SpringSimulation`. Effects without the mixin (and all curved motions)
keep the existing behavior.
- **Motion API** — timing is now a first-class object. Every `duration:` +
`curve:` pair on `.animate()`, `.immediate()`, and `.step()` is sugar for
`motion: CurvedMotion(duration, curve)`, and the new `motion:` parameter
accepts spring physics: `CupertinoMotion.bouncy()` / `.smooth()` /
`.snappy()` / `.interactive()`, the Material 3 `MaterialSpringMotion`
tokens, or a custom `SpringMotion(SpringDescription)`. Springs have no
fixed duration — the segment's length is a computed physical settling
bound (`Motion.effectiveDuration`), overshoot renders truly past the
target, and keyframe handoffs stay exact. Passing both `motion:` and
`duration:`/`curve:` throws.
- **Timeline API** — a single-controller orchestration tier. Declare absolute
keyframes with `.step()` boundaries and drive them with `.timeline()`:
```dart
icon
.scale(0).rotate(0)
.step(duration: const Duration(milliseconds: 350), curve: Curves.easeOutQuart)
.scale(1.5).rotate(15 * pi / 180)
.step(duration: const Duration(milliseconds: 300),
curve: Curves.easeOutBack, delay: const Duration(milliseconds: 150))
.scale(1).rotate(0)
.timeline(trigger: isCompleted);
```
Keyframe values are absolute and hand off between steps — they never stack.
A `trigger` is a pure identity signal: any change restarts the timeline
forward. Effect types absent from a keyframe carry their previous value
forward; declaring the same effect type twice in one keyframe throws.
- `TimelineController` — imperative `play` / `reverse` / `pause` / `seek` /
`progress`, attachable via `.timeline(controller:)`, ownable outside the
widget tree (a State, cubit, or service), with listener notifications.
- The `#immediate` trigger sentinel — pass `trigger: #immediate` to
`.timeline()` OR `.animate()` to play on mount, once per State lifetime.
Rebuilds do not replay it. One channel now answers "when does this play"
for both tiers.
- `.immediate()` on the animate extension — a shorthand that constructs
`trigger: #immediate`, taking every `animate` parameter except `trigger`
and `startState`.
- `repeat:` on `.timeline()` — loop with `repeat: -1` (forever) or
`repeat: n` (n extra cycles, resting at the final keyframe; `onEnd` fires
once at the true end).
- `key:` on `.animate()`, forwarded to the underlying `AnimatedEffect` so an
animation can be identified across `State` disposal and recreation.
- `AnimationStartState` overhauled to `{eager, lazy}`. Both values insert the
widget at its STARTING values; they differ only in whether the animation
runs on mount:
- `eager` plays the animation through to its ending values immediately on
mount, once per `State` lifetime. Rebuilds do not replay it, and
`trigger` stays live: every later change of it plays the animation again.
- `lazy` (the default) inserts the widget inert, until it is triggered at
least once.

`eager` and `trigger: #immediate` both play on mount and differ in what
follows. `#immediate` spends the trigger slot to say "now" and so can never
replay; `eager` leaves the caller's trigger free. Play-on-mount combined
with a live trigger had no expression before this release.
- Rewritten getting-started guide and portable consumer agent skills for effects, motion, and timelines.

### Changed
- **`PaddingEffect` and `AlignEffect` are now `VectorEffect`s** — `.pad()`,
`.padAll()`, `.padOnly()` and friends, along with `.align()`, `.alignX()`,
`.alignY()` and `.alignXY()`, are driven by real spring physics instead of
falling back to a normalized lerp. Two visible consequences:
- **Padding overshoots under spring motions.** `PaddingEffect.lerp`
previously clamped its progress to 0..1, so `.pad()` under
`CupertinoMotion.bouncy()` eased flatly into its target while a sibling
`.translateY()` on the same motion bounced — the same declared motion
produced two different curves on one widget. Padding now describes the
same curve as everything else. Insets are floored at zero in `apply`
(where `RenderPadding` requires non-negativity) rather than in `lerp`,
so only the components that actually went negative are touched and the
upward half of the bounce survives intact. Interpolation is also
unclamped for overshooting CURVES now, not just springs: a `.pad()` on
`Curves.easeOutBack` will pass its target and come back. If a padding
animation must not overshoot, use a monotone curve or a non-bouncy
spring such as `CupertinoMotion.smooth()`.
- **Momentum carries across a mid-flight retarget.** Re-triggering a
padding or alignment animation while it is still moving now starts the
new spring from the captured velocity instead of from rest, so rapid
re-triggers whip rather than hitch.

`AlignEffect`'s nullable `widthFactor` / `heightFactor` keep their existing
semantics exactly: a null factor is the "fill the incoming constraints"
layout mode, not a number, so nullability rides along from the left operand
of the vector algebra and a mixed null / non-null pair still snaps to the
target value rather than interpolating.

### Fixed
- `.animate(delay:)` no longer flashes the animation's target value while it
waits — a delayed re-trigger now holds the effects at the values the run is
about to start from, then animates. Previously the controller kept
reporting the value the PREVIOUS run left it at for the whole delay, so
every run after the first painted its destination instantly, held it, and
snapped back before easing in. Only the very first run looked correct.
Interruptible delayed re-triggers also retire superseded timers now, so an
older wait cannot wake after a newer trigger, restart the controller, or
call `onEnd`; non-interruptible delayed triggers remain serialized in
arrival order.

- `PointerTransition` no longer keeps stale hover/bounds state when its target
moves beneath a stationary mouse while using the default global pointer
router.
- `PointerTransition` now clears hover state when a pointer device is removed,
fixing indirect-pointer taps in the iOS Simulator that otherwise remained
at their hover value after release.
- `.animate()` re-triggers now restore the `repeat`/`reverse` budget — pulse
animations no longer play once and rest stuck at their peak value.
- Fresh triggers reset the reverse-leg state machine — re-triggering
mid-flight plays a complete new run.
- `onEnd` fires exactly once per logical run instead of once per
repeat/reverse leg.
- Resetting an `interruptable: false` animation mid-flight no longer bricks
it permanently (stale canceled ticker futures are cleared).
- Completed play-on-mount (`#immediate`) animations no longer replay when an
inherited dependency (e.g. `HyperEffectsAnimationConfig`) changes.
- Migrated deprecated `EquatableMixin` and `Matrix4.translate`/`scale`
usages to their replacements.

### Removed (BREAKING)
These were removed outright, with no deprecation window. The timeline API
replaces all of them.

- The `equatable` dependency. Effects and `TimelineSegment` now implement
`==`, `hashCode`, and `toString` directly, with identical semantics
(including deep list comparison for `ColorFilterEffect.matrix`). Only
affects consumers who relied on hyper_effects to transitively provide
`equatable`.
- The unused `collection` dependency (its only consumer was the removed
`AnimatedGroup`).

- `AnimatedGroup` / `AnimatedChild` (experimental) — removed after review
found removed children were never disposed (subtrees kept ticking
indefinitely), removal re-ran `initState` on exiting children, slots only
ever grew, and reordering was not actually animated. Use
`AnimatedSwitcher`/`AnimatedSize` with `.roll()` for the same effects.
- `AnimatedEffectStateRetainer` (experimental) — removed after review found
it recorded "was mounted" as "has played", fabricating end states for
never-played keyed animations on any dependency change, freezing looping
animations on remount, and never evicting recycled keys. Track played
items yourself with `skipIf: () => !played.add(index)`.

- `.animateAfter()` and the entire `AnimationTriggerType` enum (including
`AnimationTriggerType.afterLast`) — chained segments composed as stacked
transforms, could not rest at their declared values, replayed
inconsistently, and severed on `skipIf`/`playIf`.

**Migration:** declare the sequence as timeline keyframes. Before:
```dart
icon
.scale(1.5).animate(trigger: done, duration: const Duration(milliseconds: 350))
.scale(1 / 1.5) // reciprocal needed to undo the first segment
.animateAfter(duration: const Duration(milliseconds: 300))
.resetAll();
```
After:
```dart
icon
.scale(0)
.step(duration: const Duration(milliseconds: 350))
.scale(1.5)
.step(duration: const Duration(milliseconds: 300))
.scale(1) // absolute — no reciprocals
.timeline(trigger: done);
```
Replace `resetAll` looping with `.timeline(trigger: #immediate, repeat: -1)`,
and reverse-on-dismiss patterns with `TimelineController.reverse()`.

- `.oneShot()` — replaced by `.immediate()`, which has the same
play-once-on-mount semantics.

**Migration:** `.oneShot(...)` → `.immediate(...)`, or
`.animate(trigger: #immediate, ...)`.

- `AnimationStartState.playImmediately`, `.useCurrentValues` and `.idle` —
the enum is now `{eager, lazy}`. Mounting a widget at its ENDING values is
no longer offered: both values mount at the starting values, and the enum
now only controls whether the animation runs on mount.

**Migration:**
- `startState: AnimationStartState.playImmediately` →
`startState: AnimationStartState.eager`, which keeps a live trigger. Use
`trigger: #immediate` (or `.immediate()`) instead if the animation should
never replay.
- `startState: AnimationStartState.idle` →
`startState: AnimationStartState.lazy` (also the new default, so it can
simply be dropped).
- `startState: AnimationStartState.useCurrentValues` has no direct
replacement. It existed so that a state-mirroring animation would render
its current state on the first frame instead of its `from` values. Express
that in the effects themselves: track the state the widget is animating
away from and feed it to `from`, so that on the very first build `from`
equals the target and the widget mounts at rest. See
`example/lib/stories/success_card_animation.dart`.

### Deprecated
- `.resetAll()`, `ResetAllAnimationsEffect` and
`ResetAllAnimationsEffectState` — auto-resetting chains fire on every
completion and cross-talk between unrelated animations. Loop with
`.timeline(repeat: -1)` or rewind with `TimelineController.seek(0)`
instead. They will be removed in 0.5.0.

## [0.3.0+1] - Aug 15, 2025

- Loosen dependency constraints for equatable to `>=2.0.5 <3.0.0`.
Expand Down
Loading
Loading