diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..9eb6c0e --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -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." + } +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..46ed807 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -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" + } +} diff --git a/.github/assets/banner.png b/.github/assets/banner.png index eaaf813..690b733 100644 Binary files a/.github/assets/banner.png and b/.github/assets/banner.png differ diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 1567c9f..c824694 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -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 diff --git a/.pubignore b/.pubignore new file mode 100644 index 0000000..0bb3740 --- /dev/null +++ b/.pubignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 814ebeb..7c17d6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 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`. diff --git a/README.md b/README.md index 6b1a7d1..dbc3ea5 100644 --- a/README.md +++ b/README.md @@ -1,807 +1,554 @@ -![Banner](https://raw.githubusercontent.com/hyper-designed/hyper_effects/main/.github/assets/banner.png) +![Hyper Effects](https://raw.githubusercontent.com/hyper-designed/hyper_effects/main/.github/assets/banner.png) # Hyper Effects [![Pub Version](https://img.shields.io/pub/v/hyper_effects?label=Pub)](https://pub.dev/packages/hyper_effects) -[Hyper Effects](https://github.com/hyper-designed/hyper_effects) is a [Flutter](https://flutter.dev) package -inspired from SwiftUI's syntax to create beautiful effects in Flutter. With just a few lines of code after your -widget, you can add any number of effects and transitions with ease. +**Composable effects, spring animations, and keyframe timelines for Flutter.** +Want a favorite button to bounce when tapped? A feed of cards to blur, tilt, and come into focus as you scroll? A card to slide into view? A checkmark to pop, overshoot, and settle? Start with your Flutter widget, add the effects you want after it, and tell them when to move. Hyper Effects takes inspiration from SwiftUI's modifier syntax, while keeping everything in Flutter's widget tree. -Demo: [hyper-effects-demo.web.app](https://hyper-effects-demo.web.app/) +[Live demo](https://hyper-effects-demo.web.app/) · [API reference](https://pub.dev/documentation/hyper_effects/latest/) · [Examples](example/lib/stories) · [Migration notes](CHANGELOG.md) -## Features +## Quick start -* 📏 **Compact Syntax:** Write a single line of code for a whole suite of complex effects. -* 🔁 **Animate Everything:** No animation controllers or tweening needed. Animate any widget with a line of code. -* 🔁 **Scroll Transitions:** Control how your widgets look based on their position in a scroll view. -* 🔁 **Pointer transitions:** No gesture detectors or state management required. Control how your widgets look based on - pointer events. -* 🚀 **Easy Integration:** Missing an effect? The API is designed to be simple and easy to extend. +Add this package to your dependencies in your pubspec.yaml file. -## Examples +```yaml +dependencies: + hyper_effects: +``` + +After publication, use `hyper_effects: ^0.4.0` instead. For reproducible development builds, pin a commit rather than a moving branch. + +Let's start with a favorite button. When you tap it, the heart grows a little; tap it again, and it returns to its normal size. We don't need to manage an animation controller for this. Describe the two sizes and let a spring handle the journey. + +```dart +import 'package:flutter/material.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +class FavoriteButton extends StatefulWidget { + const FavoriteButton({super.key}); + + @override + State createState() => _FavoriteButtonState(); +} + +class _FavoriteButtonState extends State { + bool selected = false; + + @override + Widget build(BuildContext context) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + return IconButton( + tooltip: selected ? 'Remove favorite' : 'Add favorite', + onPressed: () => setState(() => selected = !selected), + icon: Icon(selected ? Icons.favorite : Icons.favorite_border) + .scale(selected ? 1.2 : 1) + .animate( + trigger: selected, + motion: const CupertinoMotion.snappy(), + skipIf: () => reduceMotion, + ), + ); + } +} +``` + +## Guide + +- [Choose a driver](#choose-a-driver) +- [Scroll effects](#scroll-effects) +- [Effects, state, and triggers](#effects-state-and-triggers) +- [Motion with curves and springs](#motion-with-curves-and-springs) +- [Entrances and stagger](#entrances-and-stagger) +- [Timelines](#timelines) +- [Practical recipes](#practical-recipes) +- [Effect catalog](#effect-catalog) +- [Accessibility and lifecycle](#accessibility-and-lifecycle) +- [Agent skill](#agent-skill) + +## Choose a driver + +Most animations start with a simple question. **What should make this move?** A tap, a widget appearing, and a finger scrolling are different signals. You don't need to learn every API before starting. Pick the row that matches what you're building. + +| I want to… | Use | +| --- | --- | +| Apply an effect without animation | `.opacity()`, `.scale()`, `.pad()`, etc. | +| Animate when state changes | `.animate(trigger: state)` | +| Replay an animation for each event | `.animate(trigger: eventCount)` | +| Play an entrance on mount | `.immediate()` | +| Play on mount and respond to later changes | `.animate(trigger: state, startState: AnimationStartState.eager)` | +| Sequence several keyframes | `.step(...).timeline(...)` | +| Reverse, pause, or scrub a sequence | `TimelineController` | +| Respond to hover, press, or scrolling | `.pointerTransition(...)` / `.scrollTransition(...)` | + +## Scroll effects + +Scrolling can do much more than move a list up and down. Imagine cards coming into focus as they enter the screen, drifting sideways as they leave, or tilting around a wheel as you browse. The movement follows your finger rather than a timer. Stop scrolling and it stops. Scroll back and it retraces your movement. - - - - - - - - - - - -
- -
- Scroll Transition -
- -
- Scroll Wheel -
- -
- Scroll Blur -
- -
- Shake -
- -
- Windows Hover Effect -
- -
- Scroll Color Filter -
- -
- Spring Animation -
- -
- Animation Chain -
- - - - - - - - - + + +
- -
- Rolling Text 2 -
- -
- Rolling Text 3 -
- -
- Rolling Text 4 -
Cards changing appearance as they cross the viewport edges
Make an entrance
Scrolling cards blurring near the viewport edges
Bring cards into focus
List items rotating through a three-dimensional wheel
Give the list some depth
-## Getting started +[Try the scroll demos](https://hyper-effects-demo.web.app/) to feel how they respond to your movement. -### Installation +### Start with a list that comes into focus -Add this package to your dependencies in your pubspec.yaml file: +Suppose you're building a feed of cards. You want the card you're reading to look normal, while cards crossing the screen's edges become slightly smaller and softer. Add `.scrollTransition()` to each card inside the list, rather than to the list itself. -```yaml -dependencies: - hyper_effects: -``` +This complete widget can be used as a screen's body. It uses the same Flutter material and Hyper Effects imports as the quick start. -or run following command in your project directory: +```dart +class ScrollingCards extends StatelessWidget { + const ScrollingCards({super.key}); -``` -flutter pub add hyper_effects + @override + Widget build(BuildContext context) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + return ListView.builder( + itemCount: 30, + itemBuilder: (context, index) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SizedBox( + height: 160, + child: Center(child: Text('Card ${index + 1}')), + ), + ).scrollTransition((context, child, event) { + if (reduceMotion) return child; + return child + .opacity(event.phase.isIdentity ? 1 : 0.4) + .blur(event.phase.isIdentity ? 0 : 4) + .scale(event.phase.isIdentity ? 1 : 0.92); + }); + }, + ); + } +} ``` -### Usage +Notice what's missing. There's no animation controller, no trigger, and no `.animate()`. The transition finds the enclosing scrollable and supplies progress as the card crosses its viewport. Your builder gets the original child and an event describing where it is. -For any widget you have, you can append any number of effects and transitions to it using the extensions like so: +The values in that builder describe the appearance to move toward. They aren't an instruction to instantly blur the card the moment it touches an edge. The transition interpolates the effects according to how far the card crosses that edge, so a partially visible card can be partway through its fade, blur, and scale at the same time. -```dart -@override -Widget build(BuildContext context) { - return Container( - color: Colors.blue, - ).opacity(myCondition ? 0 : 1); -} -``` +### Give entering and leaving cards different personalities + +A card can be fully inside the viewport, crossing its top edge, or crossing its bottom edge. Those are the three scroll phases. In a horizontal list, the corresponding edges are left and right. -However, this will not animate the effect. This will only wrap the widget with the specific effect and use the value -provided. - -### List of Effects - -| Effect Name | Description | -|-------------|-------------------------------------------------------| -| Opacity | Changes the opacity of a widget. | -| Blur | Applies a blur effect to a widget. | -| Scale | Scales a widget. | -| TranslateX | Translates a widget along the X-axis. | -| TranslateY | Translates a widget along the Y-axis. | -| TranslateXY | Translates a widget along both the X and Y axes. | -| Rotate | Rotates a widget. | -| RotateX | Rotates a widget around the X-axis. | -| RotateY | Rotates a widget around the Y-axis. | -| RotateZ | Rotates a widget around the Z-axis. | -| SkewX | Skews a widget along the X-axis. | -| SkewY | Skews a widget along the Y-axis. | -| SkewXY | Skews a widget along both the X and Y axes. | -| ColorFilter | Applies a color filter to a widget. | -| Transform | Applies a transformation before painting the widget. | -| ClipRect | Clips its child using a rectangle. | -| ClipRRect | Clips its child using a rounded rectangle. | -| RollingText | Creates a rolling animation from one text to another. | -| Shake | Applies a shake effect to a widget. | -| Align | Changes the alignment of a widget. | -| Roll | Rolls a widget from one state to another. | -| Padding | Adds padding to a widget. | - -For details about each effect, please visit the source code of the effect. -Link: [hyper_effects/lib/src/effects](https://github.com/hyper-designed/hyper_effects/tree/main/lib/src/effects) - -### Methodology - -Hyper Effects is not an animation tweening library. It's a library that provides a convenient way to interpolate -between a large set of values for a widget. It's inspired by SwiftUI's syntax and aims to provide a similar experience -in Flutter. With that knowledge in mind, you can get very far with Hyper Effects when it comes to animation -orchestration, but it's not a replacement for a full-fledged animation library like Rive, Flare, or even a good ol' -TweenSequence from Flutter's animation framework. - -It's important to understand that the way Hyper Effects works is by lerping two values at any given point in time, -therefore, a complex sequenced animation may not have enough context to orchestrate proper state management when -chaining multiple animations. - -To understand exactly how it works and where to draw the line, read the docs and see the examples. - -Points to keep in mind: - -1. Treat our extensions as widget "shortcuts" more than anything else. They simply wrap your widget with a specific - effect and use the value you provide. IE: Instead of wrapping a widget with an `Opacity` widget, you can use the - `opacity` extension. - You can effectively use the extensions as shortcuts without ever animating anything. -2. By doing this, the framework now has a special Opacity-looking widget that has the ability to consume a time value - to interpolate to different values you give that opacity extension. -3. To actually get yourself a time value, you need to use the `animate` method on the widget. This method will take a - trigger value that will trigger the animation when it changes. Alternatively, a transition can be used to provide a - continuous value to the widget. - -These are the core concepts of how the library works. It's important to understand these concepts to get the most out of -Hyper Effects. - -### Animations - -To animate any effect, you need to use the `animate` method on the widget like so: +| Phase | Where the card is | +| --- | --- | +| `ScrollPhase.identity` | Fully inside the viewport | +| `ScrollPhase.topLeading` | Crossing the top or left edge | +| `ScrollPhase.bottomTrailing` | Crossing the bottom or right edge | + +Imagine new cards arriving from the left at the bottom of a vertical feed, then slipping right as they leave the top. Instead of giving both edges the same appearance, choose a different horizontal offset for each phase. Use this on an item inside your scrollable, with `reduceMotion` read from `MediaQuery` as above. ```dart -@override -Widget build(BuildContext context) { - return Container( - color: Colors.blue, - ) - .opacity(myCondition ? 0 : 1) - .animate(trigger: myCondition); -} +return child.scrollTransition((context, child, event) { + if (reduceMotion) return child; + return child + .translateX(switch (event.phase) { + ScrollPhase.identity => 0, + ScrollPhase.topLeading => 48, + ScrollPhase.bottomTrailing => -48, + }) + .scale(event.phase.isIdentity ? 1 : 0.95); +}); ``` -`trigger` is a parameter of type `Object`. It's inspired from SwiftUI's `value` parameter on its `animation` modifier. -Whenever the value of `trigger` changes, the effect will animate to the new value using an internal `AnimatedEffect` -widget with its own `AnimationController`. +The phase describes the card's position, not which way your finger is moving. A card crossing the top edge stays in `topLeading` whether it's leaving the viewport or coming back into it. That's why the movement naturally reverses when you scroll back without needing a second animation. -In this case, `myCondition` is the trigger value. You can use any object as a trigger value, but you will likely want -to use the same object that you use to control the condition of the effect as it is the point in which the effect -should animate. +At the scroll extent's start or end, the implementation suppresses the corresponding edge progress. This keeps boundary items from being forced into an edge effect when you can't scroll any farther to reveal them. -In simpler words, `trigger` takes any object. When the value of the object changes to anything else, the effect will -animate to the new value. The animation will never play if the value of the trigger is the same as the previous value. -The internal `AnimationController` is driven based on changes to the trigger object. +### Turn the list into a wheel -> Note: If you want the animation to trigger immediately when the widget is built and then allow it to be triggered -> again later, you can change the `startState` parameter in the `animate` method to AnimationStartState.playImmediately. -> This will start the animation immediately without waiting for an initial change in the `trigger` object. Subsequent -> changes in the `trigger` object will still trigger the animation as normal. +Edge effects are a good starting point, but sometimes you want an item to keep changing throughout its journey across the viewport. Think of a wheel picker. An item tilts one way near the bottom, faces you in the middle, and tilts the other way near the top. -Hyper Effects takes heavy inspiration from SwiftUI in that it attempts to provide Apple-like default values for -everything, that means that by default, animations are of 350ms duration and use Apple's custom easeInOut curve. The -result is a very smooth and natural feeling animation that is very reminiscent of Apple's style. +`screenOffsetFraction` gives you a continuous position for that. It is zero when the item's center lines up with the viewport's center, positive toward the top or left, and negative toward the bottom or right. An item's center at a viewport edge corresponds to roughly 1 or -1. Cached items outside the viewport can go beyond that range, so clamp it when your effect needs bounded values. -Despite having really nice default values, the API of course allows you to customize the animation to your liking. You -can do so by your own duration and curve. +Here we calculate the transform directly. Add `import 'dart:math' show pi;` alongside the usual imports, and use this in an item's `build` method under a scrollable. ```dart -@override -Widget build(BuildContext context) { - return Container( - color: Colors.blue, - ) - .opacity(myCondition ? 0 : 1) - .animate( - trigger: myCondition, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutQuart, - startImmediately: true, - ); -} +return child.scrollTransition((context, child, event) { + if (reduceMotion) return child; + final position = event.screenOffsetFraction.clamp(-1.0, 1.0).toDouble(); + return TransformEffect( + rotateX: -position * pi / 3, + scaleX: 1 - position.abs() * 0.15, + scaleY: 1 - position.abs() * 0.15, + depth: 0.002, + ).apply(context, child); +}); ``` -You can combine and chain any number of effects like so: +At the center, the item faces you at full size. Toward either edge, it becomes smaller and tilts away. The `depth` adds perspective so the rotation reads as a three-dimensional movement instead of a flat squash. For a horizontal wheel, you can use `rotateY` and experiment with the rotation's sign. -```dart -@override -Widget build(BuildContext context) { - return Container( - color: Colors.blue, - ) - .opacity(myCondition ? 0 : 1) - .blur(myCondition ? 10 : 0) - .scale(myCondition ? 0.5 : 1) - .translateX(myCondition ? 100 : 0) - .animate(trigger: myCondition); -} -``` +Why use `TransformEffect.apply()` here instead of the `.transform()` extension? We've already calculated the exact transform for this scroll position. Applying it directly uses those values as they are. An effect extension inside the transition would interpolate them again using edge progress, which isn't what this wheel needs. + +That distinction is worth remembering. Use the extensions when you're describing a pose for each phase. Use an effect's `.apply(context, child)` when you've calculated the current appearance yourself from continuous event values. + +### Know what the scroll event gives you + +You don't need every field to get started. `phase` handles edge-based effects, and `screenOffsetFraction` handles movement across the whole viewport. The remaining fields let you explore more specific ideas without wiring up your own scroll listener. + +| Field | What you can use it for | +| --- | --- | +| `phase` | Choose the appearance at each viewport edge | +| `phaseOffsetFraction` | Inspect the progress used for phase interpolation; fully visible items report 1, so this isn't a single start-to-finish journey value | +| `screenOffsetFraction` | Calculate continuous tilt, scale, or displacement relative to the viewport center | +| `scrollPixels`, `viewportSize` | Work with the scroll offset and viewport extent; both are nullable before their metrics are available | +| `scrollDelta` | Compare the current scroll offset with the previous update | +| `scrollDirection` | Read the scrollable's configured axis direction, not the user's current gesture direction | +| `pointerPosition`, `distanceFromPointer` | Incorporate the last tracked pointer position and its offset from the item's center | +| `visualIndex`, `reverseVisualIndex` | Explore position-based ordering effects; these are geometry-derived values, not stable item IDs | + +### Keep the content comfortable to browse -Since these are convenient extensions, you can use the effects as Widgets directly using the `apply` method like so: +Start small. A slight scale change or gentle fade often does enough without making a feed harder to read. Blur and perspective look striking in demos, but try them on a real device with the number of cards your screen actually shows. + +Keep the transition on the item under the intended scrollable. It uses the nearest enclosing scrollable, so a card inside a horizontal carousel responds to that carousel rather than an outer vertical feed. Without an enclosing scrollable, it simply displays its child. + +The examples return the unchanged child when reduced motion is requested. Scroll effects aren't timed entrances, so there isn't an `.immediate(skipIf:)` to lean on here. Make that choice in the builder, and keep controls usable while their appearance changes. + +For more ideas, explore the demo's [directional slide](example/lib/stories/scroll_phase_slide.dart), [blur](example/lib/stories/scroll_phase_blur.dart), [3D wheel](example/lib/stories/scroll_wheel_transition.dart), and [color filter](example/lib/stories/color_filter_scroll_transition.dart) stories. + +## Effects, state, and triggers + +Think of effects as instructions about how a widget should look. You might want it a little transparent, a little bigger, or a few pixels lower. By themselves, they don't animate anything. This child is simply half-transparent and shifted down eight logical pixels. ```dart -@override -Widget build(BuildContext context) { - return OpacityEffect( - opacity: myCondition ? 0 : 1, - ).apply( - context, - BlurEffect( - blur: myCondition ? 10 : 0, - ).apply( - context, - ScaleEffect( - scale: myCondition ? 0.5 : 1, - ).apply( - context, - TranslateEffect( - offset: Offset(myCondition ? 100 : 0, 0), - ).apply( - context, - Container( - color: Colors.blue, - ), - ), - ), - ), - ); -} +return child.opacity(0.5).translateY(8); ``` -This is what the Widget tree looks like internally when you use this library. - -#### Properties - -* trigger: The value used to trigger the animation. As long as the value of trigger is the same, the animation will not - be triggered again. -* duration: The duration of the animation. -* curve: The curve of the animation. -* onEnd: A callback that is called when the animation ends. -* repeat: Determines how many times the animation should be repeated. -* reverse: A boolean property that determines whether the animation should be reversed after each repetition. -* delay: A delay before the animation starts. -* playIf: A callback that returns whether the animation should be played or skipped. If the callback returns false, the - animation will be skipped, even when it is explicitly triggered. -* resetValues: Normally, an effect represents the current state of the widget and this - animate effect is only in charge of lerping between states of those effect values. - If this is set to true, instead of treating effects as current states to animate between, it will always animate from - an initial default state towards the current state. -* interruptable: Whether the animation should be reset on subsequent triggers. If this animation is re-triggered, it - will reset the current active animation and re-drive from the beginning. - Setting this to true will force the animation to wait for the last animation in the chain to finish before starting. -* startState: Determines the behavior of the animation as soon as it is added to the widget tree. -* skipIf: A callback that determines whether the animation should be skipped. If the callback returns true, the animation - will be skipped entirely. - -### One Shot Animations - -If you want to trigger an animation immediately, you can use the `oneShot` function which triggers a chain of effects -immediately without a trigger parameter. -This function is useful when you want to start an animation as soon as the widget is built, without waiting for a -specific trigger to change. The animation will never play again after it ends. - -Here's an example of how to use the `oneShot` function: +Now imagine a selectable card. When selected, it should grow slightly and lift off the page. We describe the selected and unselected appearances, then add `.animate()` to move between them. ```dart -@override -Widget build(BuildContext context) { - return Container( - color: Colors.blue, - ).slideInFromBottom() - .oneShot( - // All the normal parameters inside of .animate() but without the trigger and startState parameters. - ); -} +return child + .scale(selected ? 1.08 : 1) + .translateY(selected ? -4 : 0) + .animate(trigger: selected, motion: const CupertinoMotion.snappy()); ``` -### Animate After Animations +Notice that `selected` does two jobs. It chooses the card's destination, and it tells `.animate()` when something has changed. The trigger is a **change signal**, not an on/off switch. Selecting the card triggers a run, but so does deselecting it. Internally, the new trigger is compared with the previous one using equality. -The `animateAfter` function triggers the animation after the last animation in the chain ends. -It's useful when you want to create a simple sequence of animations where one animation starts after the previous one -ends. +An unrelated rebuild won't make the card bounce again if `selected` hasn't changed. That's useful for selection, but what about shaking a field on *every* invalid submission? A boolean that stays `true` won't do that. Use an event counter instead. We'll do exactly that in the [feedback recipe](#replay-feedback-on-every-event). -Here's an example of how to use the `animateAfter` function: +You'll also see `from:` in entrance examples. It gives the effect a deliberate starting point. For example, a card can arrive at zero after starting 24 pixels below. Use it when you want that origin, rather than adding it automatically to every state change. Keep widget keys stable while animating between states, too; changing the key can replace the State instead of updating it. -```dart -@override -Widget build(BuildContext context) { - return Center( - child: GestureDetector( - onTap: () { - setState(() { - trigger = !trigger; - }); - }, - child: Image.asset('assets/pin_ball_256x.png', width: 150, height: 150) - .shake() - .oneShot( - delay: const Duration(seconds: 1), - repeat: -1, - playIf: () => !trigger, - ) - .translateY(300, from: 0) - .animate( - trigger: trigger, - curve: Curves.easeOutQuart, - duration: const Duration(milliseconds: 2000), - playIf: () => trigger, - ) - .slideOut(const Offset(0, -300)) - .animateAfter( - curve: Curves.elasticOut, - duration: const Duration(milliseconds: 450), - onEnd: () => setState(() => trigger = false), - ) - .resetAll(), - ), - ); -} -``` +Finally, these chains are still nested Flutter widgets. An inner `.animate()` has its own effect scope, which is handy when a button needs hover feedback *and* an independent entrance. But adding a second `.animate()` doesn't mean “do this next.” For that, use a [timeline](#timelines). + +### Mount behavior + +Should the card wait until it's selected, or introduce itself as soon as it appears? That's the choice here. Use the default `lazy` behavior to wait for a trigger change, `eager` to play an entrance and keep listening, or `.immediate()` for a mount-only entrance. + +| Configuration | On mount | Later trigger changes | +| --- | --- | --- | +| `lazy` (default) | Holds starting values | Plays | +| `eager` | Plays from starting values | Plays | +| `.immediate()` / `trigger: #immediate` | Plays | The sentinel stays unchanged | -See this example in action in the demo app: [hyper-effects-demo.web.app](https://hyper-effects-demo.web.app/) +One detail is easy to miss. Both `lazy` and `eager` mount at the **starting** values. If you give a fade an explicit `from: 0`, `lazy` leaves it transparent until the trigger changes. It doesn't silently jump to opacity 1. If the widget should already look settled on its first frame, choose its initial endpoints to represent that state. -### Reset Animations +Play-on-mount is once per **State lifetime**, not once per item forever. Imagine a card that slides into view as you scroll down a feed. Scroll far enough away, and Flutter may dispose of it to save resources. When you scroll back, Flutter creates a fresh widget State, and `.immediate()` plays its entrance again. The card effectively has amnesia. It doesn't know you've already seen it. -Using `resetAll` at the end of the chain of animations will reset all the effects in the chain to their original values. +If that's the experience you want, you're done! But if each card should only introduce itself once, keep a set of already-seen card IDs in the feed's parent State, outside the individual cards. Use `skipIf` to skip the entrance for IDs in that set, and record each ID when its first entrance is accepted. Now the feed remembers which cards you've seen, even when the cards themselves don't. That memory lasts as long as the parent State does. -When the animation is triggered again, all the effects will animate from their original values to the new values. In -other words, each individual Hyper Effects AnimationController in the descendent widget tree will be reset to its -initial state. +### Playback options -### Delayed Animations +What happens if someone taps again before the animation finishes? By default, the new trigger interrupts the current run. That is usually what you want for responsive controls. Set `interruptable: false` when runs should wait their turn instead. Here are the other knobs you can reach for when you need them. -The `delay` parameter is used in the `animate`, `animateAfter` and `oneShot` methods. This parameter allows you to -specify a delay -before the animation starts. -The `delay` is specified as a `Duration`. Here's an example of how to use the `delay` parameter: +| Option | Meaning | +| --- | --- | +| `delay` | Holds the run's starting values before playback; repeated legs each wait again | +| `interruptable: true` | A new trigger interrupts the active run | +| `interruptable: false` | Runs wait their turn rather than interrupting | +| `resetValues: true` | Uses initial effect values instead of ordinary state-to-state progression | +| `playIf` returning false | Does not drive the animation to the target | +| `skipIf` returning true | Jumps to the ending values without playback or `onEnd` | +| `onEnd` | Called once at the end of a completed logical animate run, not every repeated leg | + +Think of a pulsing button. It grows, then shrinks back. That's two legs, so use `repeat: 1, reverse: true`. You get one initial leg and one extra leg in the opposite direction. `reverse: true` alone doesn't add a return trip. + +On `.animate()` / `.immediate()`, `repeat` always counts **additional legs**. `0` plays once, `1` plays twice, and `-1` keeps going indefinitely. With `reverse: true`, those legs alternate direction. + +## Motion with curves and springs + +So far we've decided *where* a widget goes and *when* it should move. Motion answers the next question. **How should the trip feel?** + +For a fade that should take exactly a quarter of a second, a duration and curve are a good fit. The duration sets the clock; the curve decides how quickly the fade progresses along the way. + +Choose **either** `motion:` **or** `duration:` / `curve:`. They are two ways to describe timing, not settings to combine; passing both throws. ```dart -@override -Widget build(BuildContext context) { - return Container( - color: Colors.blue, - ) - .opacity(myCondition ? 0 : 1) - .animate( - trigger: myCondition, - delay: const Duration(seconds: 1), // 1 second delay before the animation starts after the trigger changes. - ); -} +return child + .opacity(1, from: 0) + .animate( + trigger: trigger, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); ``` -In this example, the opacity animation will start 1 second after the `myCondition` trigger changes. +The equivalent motion object is `CurvedMotion(duration, curve)`. With no overrides, the curved default is 350 ms with `appleEaseInOut`. + +A spring feels different. Think of a selected tab sliding into place, passing its destination just a little, and settling back. Rather than prescribing every moment of that movement, you choose the spring's character. Start with a preset. + +| Motion | Useful for | +| --- | --- | +| `CupertinoMotion.smooth()` | No-bounce settling | +| `CupertinoMotion.snappy()` | Quick response with a little bounce | +| `CupertinoMotion.bouncy()` | Visible overshoot | +| `CupertinoMotion.interactive()` | Tight interaction feedback | +| `MaterialSpringMotion.standardSpatialDefault()` | Material spatial motion | +| `MaterialSpringMotion.standardEffectsDefault()` | Critically damped visual-property motion | +| `SpringMotion(SpringDescription(...))` | Custom spring physics | -### Repeat Animations +Want a little more personality? Cupertino presets accept `duration:` and `extraBounce:`. Just don't treat that duration as a stopwatch. It describes how the spring feels, **not the exact time until it settles**. If another piece of UI must start precisely 250 ms later, choose curved motion. `Motion.effectiveDuration` gives a spring's computed settling bound when you need it. -The `repeat` parameter is used in the `animate`, `animateAfter`, and `oneShot` functions. -This parameter allows you to specify how many times the animation will be repeated. -The repeat parameter is an `integer`, where 0 (default) means the animation will only play once and not repeat. -Any positive integer specifies the number of times the animation will repeat. -If the `repeat` parameter is set to -1, the animation will repeat indefinitely. +Imagine tapping between tabs before the selection indicator has finished moving. You don't want it to lose all its momentum each time you change your mind. Translation, scale, rotation, opacity, padding, alignment, and size implement `VectorEffect`, so spring-driven `.animate()` can carry that momentum into the new movement. Rendering still has limits. Opacity stays in range, and padding can't become negative. -Here's an example of how to use the `repeat` parameter: +Not every effect speaks that physics language. Blur and clipping, for example, use a normalized interpolation fallback. They can follow the spring's progress, but don't get the same guaranteed overshoot or velocity handoff. You'll see a debug diagnostic once per affected effect type. If that distinction matters for your animation, use curved motion. If you are building your own effect, you can implement `VectorEffect` instead. + +## Entrances and stagger + +Imagine opening a screen with a row of cards. Having every card appear at the same instant is fine, but letting them arrive one after another can make the screen easier to follow. Give each card the same entrance and a slightly larger delay. + +Here, `index` is the card's position and `reduceMotion` comes from `MediaQuery.disableAnimationsOf(context)`. As with the earlier snippets, this goes directly in `build`. ```dart -@override -Widget build(BuildContext context) { - return Container( - color: Colors.blue, - ) - .opacity(myCondition ? 0 : 1) - .animate( - trigger: myCondition, - repeat: 3, // The animation will repeat 3 times - ); -} +return child + .opacity(1, from: 0) + .translateY(0, from: 24) + .immediate( + motion: const CupertinoMotion.smooth(), + delay: Duration(milliseconds: 60 * index), + skipIf: () => reduceMotion, + ); ``` -Note that if the `reverse` parameter is set to true, the repetition counter will count for each animation direction. -For example, if the `repeat` parameter is set to 3 and the `reverse` parameter is set to true, the animation will -play only 3 times, one of which is the reverse animation. +The first card starts right away, the second waits 60 ms, the third waits 120 ms, and so on. Each fades in while moving up from 24 pixels below its destination. During its delay, it stays at its starting values. It doesn't flash into its final position while waiting. -`forwards -> backwards -> forwards -> done.` +These are independent entrances with different delays, not a group controller. If a card should introduce itself *and* react to later state changes, keep a real trigger and use `startState: AnimationStartState.eager` instead of `.immediate()`. -### Rolling Text +## Timelines -The Rolling Text feature in Hyper Effects allows you to create a rolling animation from one text to another. Each -character rolls individually to form the new text. This feature provides a visually appealing way to transition between -different text states in your application. +Sometimes “move from here to there” isn't enough. Picture a success checkmark. It starts invisible, pops up slightly too large, then settles to its normal size. Those are three distinct poses, in a deliberate order. -#### Limitations +A timeline lets you describe those poses as **absolute keyframes**. Put `.step()` between them to say how to reach the next pose, then add `.timeline()` to drive the sequence. -This effect reconstructs each individual symbol in the Text widget into vertical tapes of symbols that are painted -using a custom painter. This means that the Rolling Text feature is not suitable for large text bodies. It is -recommended to use this feature for short text strings. Each individual strip of characters is animated independently -and can be customized to create a unique rolling effect. +```dart +return const Icon(Icons.check, size: 64) + .scale(0) + .opacity(0) + .step(duration: const Duration(milliseconds: 250), curve: Curves.easeOut) + .scale(1.2) + .opacity(1) + .step(duration: const Duration(milliseconds: 180), curve: Curves.easeOut) + .scale(1) + .timeline(trigger: trigger); +``` + +Read that chain as a little storyboard. Start at scale 0 and opacity 0. Take 250 ms to reach scale 1.2 and opacity 1, then take 180 ms to settle at scale 1. We didn't specify opacity again in the final pose, so it stays at 1. -This .roll() effect, when applied to non-Text widgets, will work as expected as it does not need to reconstruct -anything. +The important word is *absolute*. The last `scale(1)` means “normal size,” not “multiply the previous size by 1.” You don't have to undo the overshoot with a reciprocal. Each `.step()` describes travel **to the next keyframe**; give it a delay if you want to hold the current pose first. -#### Usage +A few rules keep the storyboard unambiguous. -To use the Rolling Text feature, you can use the roll extension on any Text widget. Here's an example: +- Each keyframe can contain one effect of each type. To move diagonally, use `.translateXY()` rather than separate X/Y translations, which are both the same effect type. +- A trigger change always restarts **forward**. Setting a boolean to false doesn't tell the checkmark to un-pop; use a controller to reverse it. +- Timeline `repeat: n` means **n additional full cycles**, rather than animate's individual legs. `repeat: -1` loops forever and needs a nonzero-duration sequence. + +For a gentle repeating pulse, grow the widget and bring it back to its original size. Matching the first and last poses avoids a jump when the next cycle starts. ```dart -@override -Widget build(BuildContext context) { - return Text('Hello').roll('World'); -} +return child + .scale(1) + .step(duration: const Duration(milliseconds: 300)) + .scale(1.1) + .step(duration: const Duration(milliseconds: 300)) + .scale(1) + .timeline(trigger: #immediate, repeat: -1); ``` -In this example, the text will roll from 'Hello' to 'World'. Each character in 'Hello' will roll until it changes to the -corresponding character in 'World'. +### Imperative control -For non-text widgets, you can use the roll effect similarly: +Now imagine a toast that slides up when you show it and slides back down when you dismiss it. You already have the entrance. Why describe the same movement backward by hand? Attach a `TimelineController` and play that sequence in either direction. + +Create the controller once in your State, not in `build`, and dispose it when its owner is disposed. You don't need to supply a ticker provider. Pass that controller into the chain. ```dart -@override -Widget build(BuildContext context) { - return Container( - width: 100, - height: 100, - color: Colors.blue, - ).roll( - from: const Offset(0, 0), - to: const Offset(0, 100), - axis: Axis.vertical, - ); -} +return child + .opacity(0) + .translateY(24) + .step(duration: const Duration(milliseconds: 250)) + .opacity(1) + .translateY(0) + .timeline(controller: controller); ``` -#### Customization - -The Rolling Text feature provides several options for customization: - -- padding: Allows you to set the internal padding between the row of symbol tapes and the clipping mask. -- tapeStrategy: Determines the string of characters to create and roll through for each character index - between the old and new text. -- tapeCurve: Determines the curve each roll of symbol tape uses to slide up and down through its characters. -- tapeSlideDirection: Determines the direction each roll of symbol tape slides through its characters. -- staggerTapes: Determines whether the tapes should be staggered or not. -- staggerSoftness: Determines how harsh the stagger effect is. -- reverseStaggerDirection: Determines whether the stagger effect should be reversed or not. -- symbolDistanceMultiplier: Determines the height of each symbol in each tape relative to the font size. If the font - size is 32, the final height of the entire widget is fontSize * lineHeightMultiplier. The default multiplier is 1. -- fixedTapeWidth: Can be optionally used to set a fixed width for each tape. If null, the width of each tape will be - the width of the active character in the tape. If not null, the width of each tape will be the fixed width provided. - Note that this will allow the text's characters to potentially overlap each other. -- widthDuration: Determines the duration of the width animation. If null, the same duration is used as the one - provided to the `animate` function. -- widthCurve: Determines the curve of the width animation of each tape. If null, the same curve is used as the one - provided to the `animate` function. -- characterTapeBuilders: Custom builders for individual character tapes, allowing for fine-grained control over how each - character is displayed and animated. - -For the general roll effect, additional options include: -- axis: The axis along which the rolling animation occurs (horizontal or vertical). -- from: The starting offset or state. -- to: The ending offset or state. -- transformHits: Whether hit testing should be transformed with the roll animation. - -Here's an example of how to use these options: +Call `play()` to show the toast and `reverse()` to send it back. Both start from wherever the timeline currently is, so reversing halfway through doesn't first jump to the end. + +You can also `pause()`, `seek(0.5)` to jump halfway through, or read `progress`. Want a fresh replay rather than continuing from the current position? Call `seek(0)` before `play()`. The widget's `repeat:` setting belongs to trigger-driven runs; an imperative `play()` doesn't start that repeat schedule. + +If the toast might not be mounted, check `isAttached` before driving the controller or reading `progress`. An unattached controller has no timeline to control and throws on those operations. + +See the [self-contained controller recipe](skills/hyper-effects/references/timelines.md) for ownership and migration details. + +## Practical recipes + +### Replay feedback on every event + +Someone submits an empty field. It shakes. They submit it again without changing anything. It should shake again, right? This is where an event counter works better than an `isInvalid` boolean. ```dart -@override -Widget build(BuildContext context) { - return Text('Hello').roll( - 'World', - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - tapeStrategy: TapeStrategy.random, - tapeCurve: Curves.easeInOutQuart, - tapeSlideDirection: TapeSlideDirection.down, - staggerTapes: true, - staggerSoftness: 0.5, - reverseStaggerDirection: true, - ); -} +return child + .shake() + .animate(trigger: attempt, duration: const Duration(milliseconds: 350)); ``` -To trigger the rolling text effect, you need to call the `animate` or `oneShot` functions as normal: +Increment `attempt` on each invalid submission. The field can stay invalid the whole time, but `attempt` changes from 0 to 1 to 2, giving each submission its own trigger. Repeatedly assigning `isInvalid = true` would only give you the first change. + +### Hover and press feedback + +A button can feel more responsive with a small lift when you hover and a little compression when you press. The pointer event tells us which state we're in; we choose a scale and let a tight spring follow it. ```dart -@override -Widget build(BuildContext context) { - return Text('Hello').roll('World').animate(trigger: myTrigger); -} +return button.pointerTransition( + (context, child, event) { + final target = event.isPressed ? 0.96 : (event.isHovering ? 1.03 : 1.0); + return child.scale(target).animate( + trigger: target, + motion: const CupertinoMotion.interactive(), + ); + }, + ); ``` -When myTrigger changes, the text will roll from 'Hello' to 'World' or vice versa. -The animate method also accepts duration and curve parameters that allow you to customize the animation's duration -and easing curve. +This dresses up the button; it doesn't replace it. Keep the real button responsible for clicks, keyboard activation, semantics, and whether it's disabled. A disabled button should use a neutral scale rather than inviting a press it won't accept. This feedback can live in its own animation scope while an outer animation handles the button's entrance or visibility. -Please note that if you specify a curve that goes outside the bounds of 0 and 1 like Curves.elasticIn/Out or -Curves.easeIn/OutBack, the UI will crash with a warning that the width cannot be negative. To resolve this, -simply specify a custom curve for the width. You can do so like this: +### Animate layout, not just pixels + +Imagine a compact chip expanding to show a longer label. Scaling it would stretch the lettering. Its neighbors wouldn't make room either, because Flutter would still lay out the original-sized box. What you want here is a change in width. ```dart -@override -Widget build(BuildContext context) { - return Text('Hello').roll('World').animate( - trigger: myTrigger, - curve: Curves.easeInOutQuart, - duration: const Duration(milliseconds: 500), - widthCurve: Curves.easeInOutQuart, - ); -} +return child + .widthTo(expanded ? 240 : 120) + .animate(trigger: expanded, motion: const CupertinoMotion.bouncy()); ``` -### Transitions +Now the chip's layout width changes from 120 to 240, so its siblings can move out of the way instead of being painted over. Use `.heightTo()` for height, `.sizeTo(Size(...))` for both dimensions, or `.sizeOf(width:, height:)` when you need to control each axis independently. -Transitions are a special type of effect that allows you to control how your widgets look based on some continuous -value. For example, you can control the opacity of a widget based on its position in a scroll view or based on the -pointer position. +The parent still gets a say. These effects respect incoming constraints. Use finite numeric endpoints for animated dimensions. A null axis means “leave this unconstrained,” not “zero pixels,” so switching between null and a number snaps instead of animating an imaginary size in between. -Transitions use a builder pattern to provide you with an `event` property that gives you active information about the -state of a transition for your widget. You can use this information to control how your widget looks, and it will -animate automatically based on the effects you use inside. +### Rolling text and replacement widgets -#### Scroll Transitions - -Scroll transitions are transitions that are based on the position of a widget in a scroll view. You can use the -`scrollTransition` extension on any widget to add a scroll transition to it. +Think of a score counter ticking upward, with the digits rolling into place instead of abruptly changing. Build the `Text` with the current count, then let `.roll()` handle the transition from the previous value. ```dart - @override -Widget build(BuildContext context) { - return ListView.builder( - itemBuilder: (context, index) { - return Container( - width: 350, - height: 100, - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - alignment: Alignment.center, - decoration: BoxDecoration( - color: randomColor(index), - borderRadius: BorderRadius.circular(16), - ), - child: Text( - 'Item $index', - style: const TextStyle( - fontSize: 24, - color: Colors.white, - ), - ), - ).scrollTransition( - (context, widget, event) => - widget - .blur( - switch (event.phase) { - ScrollPhase.identity => 0, - ScrollPhase.topLeading => 10, - ScrollPhase.bottomTrailing => 10, - }, - ) - .scale( - switch (event.phase) { - ScrollPhase.identity => 1, - ScrollPhase.topLeading => 0.9, - ScrollPhase.bottomTrailing => 0.9, - }, - ), - ); - }, - ); -} +return Text('$count') + .roll(widthCurve: Curves.easeOut) + .animate(trigger: count, duration: const Duration(milliseconds: 250)); ``` -In a scroll transition, the `phase` is a `ScrollPhase` enum that determines the view-state of a widget in a scroll view. - -- `ScrollPhase.identity` is when the widget is fully viewable in the scroll view. -- `ScrollPhase.topLeading` is when the widget is partially viewable from the top/left, IE: It's leaving the top or left - of the scroll view. -- `ScrollPhase.bottomTrailing` is when the widget is partially viewable from the bottom/right, IE: It's leaving the - bottom or right of the scroll view. - -Other properties of the scroll transition: - -- phaseOffsetFraction: The current progress an element's phase is going through. If `phase` is identity this value is 1. - If `phase` is topLeading, it goes from 0 towards 1 as it leaves the screen. If `phase` is bottomTrailing, it goes from - 0 towards 1 as it enters the screen. -- phaseOffsetFraction: The current progress an element is going through the scrolling viewport. If the item is near - the center of the scroll view, the value tends towards 0. As the item moves towards the ceiling of the scroll view, - the value tends towards 1. It clamps to 1 when the item is fully out of the scroll view. As the item moves towards the - floor of the scroll view, the value tends towards -1. It clamps to -1 when the item is fully out of the scroll view. - final double screenOffsetFraction; -- scrollPixels: The number of pixels scrolled inside of the parent scroll view. -- viewportSize: The height or width of the parent scroll view. -- scrollDelta: The change in scroll position since the last update. -- scrollDirection: The direction the scroll view is being scrolled to. -- pointerPosition: The position of the pointer device in the global coordinate space. -- distanceFromPointer: The distance from the pointer device to the center of this widget. -- visualIndex: The visual index is the index in which the item is displayed in the scroll view. For example, instead of - the regular index of a list, if you scroll down, the first item that is visible inside the scroll view will have some - arbitrary index, but the visual index would be 0 as it is the index that is perceived by the user. -- reverseVisualIndex: The `visualIndex` calculated in the opposite direction. - -#### Caution - -Be careful when using `screenOffsetFraction` as it can be a bit tricky to use. The way transitions work -internally is that they lerp to and from a specific internal value. That value for scroll transitions is the -ScrollPhase. - -With that in mind, despite the fact that you can use `phase` to animte your widgets easily with the extensions, for -other properties like`screenOffsetFraction`, you will need to use the manual Effects API to animate them. +You can roll whole widgets, too. Here an hourglass gives way to a checkmark. Give each piece of content a distinct key so the effect knows something was replaced. ```dart - @override -Widget build(BuildContext context) { - return ListView.builder( - itemBuilder: (context, index) { - return Container( - width: 350, - height: 100, - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - alignment: Alignment.center, - decoration: BoxDecoration( - color: randomColor(index), - borderRadius: BorderRadius.circular(16), - ), - child: Text( - 'Item $index', - style: const TextStyle( - fontSize: 24, - color: Colors.white, - ), - ), - ).scrollTransition((context, widget, event) => - TransformEffect( - rotateX: -90 * event.screenOffsetFraction * pi / 180, - translateY: (event.screenOffsetFraction * -1) * 200, - translateZ: event.screenOffsetFraction.abs() * 100, - scaleX: 1 - (event.screenOffsetFraction.abs() / 2), - depth: 0.002, - ).apply(context, widget)); - }, - ); -} +return KeyedSubtree( + key: ValueKey(done), + child: Icon(done ? Icons.check : Icons.hourglass_empty), + ).roll(slideInDirection: AxisDirection.up).animate(trigger: done); ``` -When using the manual Effects API directly, you can use any parameter and manipulate their values as much as you like in -the above example, whereas with the convenient extensions API, you may be limited to using the internally -animated value. In scroll transitions, the internally animated value is the `phase` property. +You don't pass the old and new strings to `Text.roll()`. It reads the current `Text` and retains the previous value for you. Call it before other widget extensions, while the receiver is still typed as `Text`. This effect is meant for short, single-line labels and numbers. A paragraph rolling through a slot machine is probably not what you want. Multiline text isn't supported. -#### Pointer Transitions +For more control, text rolling offers `tapeStrategy`, `TextTapeSlideDirection`, integer `staggerSoftness`, and `widthCurve`. That last one controls how character widths change, so it belongs on `.roll()`, not `.animate()`. Whole-widget rolling works differently. It watches child keys, which is why the hourglass and checkmark need different ones. -Pointer transitions are transitions that provide events based on the pointer's position either locally or globally -depending on configuration. This transition has many configurable parameters that allow you to control how the -transition should output its values in its builder. +## Effect catalog -```dart -@override -Widget build(BuildContext context) { - return Container( - decoration: const BoxDecoration( - color: Colors.blue, - ), - ).pointerTransition( - (context, child, event) => - child.translateXY( - event.valueOffset.dx / 2, - event.valueOffset.dy / 2, - fractional: true, - ), - ); -} -``` +Once you know the pattern, changing the effect is the easy part. Here's a compact list to come back to when you know what you're trying to make. -In the above example, the container will move half the distance of the pointer's movement in the x and y axis. The -`fractional` parameter is set to true, which means that the widget will move by multiplying its size by the value -provided. If `fractional` is set to false, the widget will move by the value provided directly in pixels. +| Purpose | Extensions | +| --- | --- | +| Visibility | `.opacity()`, `.fadeIn()`, `.fadeOut()` | +| Transform | `.scale()`, `.translate()`, `.translateX/Y/XY()`, `.rotate()`, `.rotateX/Y/Z()`, `.skewX/Y/XY()`, `.transform()` | +| Layout | `.pad()`, `.padAll()`, `.padOnly()`, `.align()`, `.alignX/Y/XY()`, `.widthTo()`, `.heightTo()`, `.sizeTo()`, `.sizeOf()` | +| Appearance | `.blur()`, `.colorFilter()`, `.clip()` | +| Feedback and content | `.shake()`, `Text.roll()`, `Widget.roll()` | -`value` is a `double` parameter that represents the distance of the pointer device from the `origin`. -A value of zero is when the pointer device is at the `origin`. -A value of 1 or -1 is when the pointer device is at the farthest point from the `origin`. -This value is the average of the `valueOffset`'s x and y values. +Rotation values are radians. Fractional translations multiply offsets by the child's size; non-fractional translations use logical pixels. `.align()` defaults to null width/height factors (fill available constraints); pass factors of `1` explicitly for shrink-wrapping. -`valueOffset` is an `Offset` parameter very similar to `value` but provides more information about the individual x and -y axes. It's The distance of the pointer device from the `origin` as an offset. -A value offset of (0, 0) is when the pointer device is at the `origin`. -A value offset of (1, 1) is when the pointer device is at the farthest point from the `origin`. +You can also use effect classes directly with `.apply(context, child)`, or implement your own `Effect`. See [effect implementations](lib/src/effects) for the actual constructor signatures and vector arithmetic contract. -`isInsideBounds` is a `boolean` parameter that decides whether the pointer is inside the bounds of the widget or not. +## Accessibility and lifecycle -`position` is the raw position of the pointer. It's the position of the pointer device from the top left corner of the -target. The target depends on the configurations below. +### Let people skip the movement, not the content -##### Pointer Transition Properties +If someone has requested reduced motion, a card should still appear. It just doesn't need to make an entrance. Read `MediaQuery.disableAnimationsOf(context)` and use `skipIf` when you want to jump straight to the ending values. `playIf: false` is different. It declines playback rather than moving the widget to its destination, which could leave an entrance hidden. -When using the pointer transition, you can provide multiple parameters to control its behavior, which is reflected back -in the `event` provided to the builder. +Skipping doesn't call `onEnd`, either. If that callback unlocks a control or performs cleanup, make sure your no-motion path handles that work too. Flutter's `animationBehavior` is also configurable, individually or through `HyperEffectsAnimationConfig`. -```dart -Widget pointerTransition(PointerTransitionBuilder builder, { - Alignment origin = Alignment.center, - bool useGlobalPointer = false, - bool usePointerRouter = true, - bool transitionBetweenBounds = true, - bool resetOnExitBounds = true, - Curve curve = appleEaseInOut, - Duration duration = const Duration(milliseconds: 125), - Key? key, -}) { - return PointerTransition( - key: key, - builder: builder, - origin: origin, - useGlobalPointer: useGlobalPointer, - usePointerRouter: usePointerRouter, - transitionBetweenBounds: transitionBetweenBounds, - resetOnExitBounds: resetOnExitBounds, - curve: curve, - duration: duration, - child: this, - ); -} +### A button should answer where you see it + +Imagine translating a button upward but leaving its tappable area behind. That would be a pretty frustrating button! In 0.4.0, translation moves hit testing by default. Use `transformHitTests: false` only when you deliberately want paint-only movement. + +There's still a boundary. Moving a child outside its parent's bounds doesn't expand where the parent accepts hits. Check taps at the button's *painted* position, especially when it overflows. And if you've faded a button out, remember that invisible doesn't mean untappable. Use `IgnorePointer` or another appropriate interaction policy. + +### Give exits time to finish + +A toast can't slide away after you've already removed it from the tree. Keep it mounted and ticking until its exit completes, then remove it or disable its background ticking. If someone shows it again halfway through dismissal, don't let an old completion callback hide the newly visible toast. Guard against stale callbacks and disposal, and handle skipped animations separately. The app owns this lifecycle; Hyper Effects doesn't automatically retain exiting children. + +The same idea matters in tests. A pulse that loops forever will never settle. Advance it with bounded pumps rather than asking `pumpAndSettle` to wait for an ending that isn't coming. + +## Agent skill + +Working with a coding agent? Give it the same API knowledge you're learning here, rather than hoping it guesses the right version. The single **`hyper-effects`** skill covers effects, springs, entrances, interaction, scroll transitions, and timelines. Detailed recipes live inside the skill and load when needed, so there is no separate timeline skill to install. + +### Claude Code + +Install the skill as a Claude Code plugin from this repository. Run these commands inside Claude Code once the plugin files have been pushed to GitHub. + +```text +/plugin marketplace add hyper-designed/hyper_effects +/plugin install hyper-effects@hyper-effects-marketplace +``` + +Choose user scope to make it available across your projects, or project scope to share the installation configuration with a team. In your consuming Flutter project, Claude can select the skill when it matches your request. You can also invoke it explicitly. + +```text +/hyper-effects:hyper-effects ``` -`origin` is an `Alignment` parameter that determines the origin to transform the pointer position around. +For a local checkout, start Claude Code with the repository as a local plugin directory. Replace the path with your checkout's location. -- If the origin is set to `Alignment.center`, as the pointer moves away from the center of the screen, - the `value` will increase. -- If the origin is set to `Alignment.topLeft`, as the pointer moves away from the top left corner of the screen, - the `value` will increase. +```sh +claude --plugin-dir /path/to/hyper_effects +``` -`useGlobalPointer` is a `boolean` parameter that determines whether the pointer position should be calculated globally -or locally. If set to true, the pointer position will be calculated from the top left corner of the screen. If set to -false, the pointer position will be calculated from the top left corner of the widget. +The plugin uses the same `skills/hyper-effects/` files as other agents. There is no second copy to keep in sync. See the [Claude Code plugin documentation](https://code.claude.com/docs/en/plugins) for more about plugin installation. -`transitionBetweenBounds` is a `boolean` parameter that determines whether the transition should animate between the -bounds of the widget or not. If set to true, the transition will trigger the internal animation to reset when the -pointer moves in and out of the bounds of the widget. If set to false, the transition will snap instantly to -the new values when the pointer moves in and out of the bounds of the widget. +### Other compatible agents -`resetOnExitBounds` is a `boolean` parameter that determines whether this transition should reset the position of the -child back to a `value` of zero when the pointer device is outside the bounds of the widget. `transitionBetweenBounds` -decides whether the transition should animate or not. +Install the same portable [Agent Skill](https://agentskills.io/specification) with the [Skills CLI](https://github.com/vercel-labs/skills). -`curve` is a `Curve` parameter that determines the curve of the internal when transitioning between bounds animation. +```sh +npx skills add hyper-designed/hyper_effects --skill hyper-effects +``` -`duration` is a `Duration` parameter that determines the duration of the internal when transitioning between bounds. +For a local checkout, replace `hyper-designed/hyper_effects` with its local path. Alternatively copy `skills/hyper-effects/`, including its references, into your agent's skill directory. Choose the plugin or portable installation route for Claude Code, rather than installing both copies. -#### Caution +## Migrating from 0.3.x -Like the scroll transition, be careful when using `value` and `valueOffset` as it can be a bit tricky to use. The -pointer transition internally uses the `value` property to internally lerp between values. This means that if you use -other properties and expect them to transition perfectly, you may get unexpected results sometimes. +Coming from 0.3.x? Some familiar patterns have changed, especially sequencing and mount behavior. The [0.4.0 changelog](CHANGELOG.md) includes migration guidance; these are the changes to check first. -### Demo: [hyper-effects-demo.web.app](https://hyper-effects-demo.web.app/) +- `.animateAfter()` is replaced by timeline keyframes; `.oneShot()` by `.immediate()`. +- Start states are now `lazy` and `eager`; both mount at starting values. +- `.resetAll()` is deprecated; use timeline repetition or controller seeking. +- Alignment defaults, translated hit testing, and spring-driven padding have changed. +- Experimental group/state-retainer APIs and the `equatable` / `collection` dependencies were removed. ## Contributing diff --git a/analysis_options.yaml b/analysis_options.yaml index 99446d6..2785cfb 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,3 +1,12 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml linter: diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index 67f9f3f..c96d105 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -1,3 +1,12 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml linter: diff --git a/example/lib/main.dart b/example/lib/main.dart index d004e83..693b7ee 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,23 +1,31 @@ import 'dart:math'; import 'package:adaptive_theme/adaptive_theme.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter_box_transform/flutter_box_transform.dart'; import 'package:hyper_effects_demo/stories/color_filter_scroll_transition.dart'; import 'package:hyper_effects_demo/stories/counter_app.dart'; -import 'package:hyper_effects_demo/stories/group_animation.dart'; -import 'package:hyper_effects_demo/stories/one_shot_reset_animation.dart'; +import 'package:hyper_effects_demo/stories/interrupted_delay_hold.dart'; +import 'package:hyper_effects_demo/stories/layout_spring_safety.dart'; +import 'package:hyper_effects_demo/stories/queued_run_isolation.dart'; +import 'package:hyper_effects_demo/stories/spring_lifecycle.dart'; import 'package:hyper_effects_demo/stories/rolling_app_bar_animation.dart'; import 'package:hyper_effects_demo/stories/rolling_pictures_animation.dart'; import 'package:hyper_effects_demo/stories/scroll_phase_blur.dart'; import 'package:hyper_effects_demo/stories/scroll_phase_slide.dart'; import 'package:hyper_effects_demo/stories/scroll_wheel_transition.dart'; import 'package:hyper_effects_demo/stories/shake_and_spring_animation.dart'; +import 'package:hyper_effects_demo/stories/spring_motion.dart'; import 'package:hyper_effects_demo/stories/success_card_animation.dart'; import 'package:hyper_effects_demo/stories/text_animation.dart'; +import 'package:hyper_effects_demo/stories/timeline_journey.dart'; +import 'package:hyper_effects_demo/stories/timeline_notification_bell.dart'; +import 'package:hyper_effects_demo/stories/timeline_pulse_button.dart'; +import 'package:hyper_effects_demo/stories/timeline_rocket_launch.dart'; +import 'package:hyper_effects_demo/stories/timeline_toast.dart'; import 'package:hyper_effects_demo/stories/windows_settings_transition.dart'; +import 'package:material_ui/material_ui.dart'; import 'story.dart'; @@ -85,10 +93,6 @@ class Storyboard extends StatefulWidget { class _StoryboardState extends State with WidgetsBindingObserver { final List animationStories = [ const Story(title: 'Success Card Animation', child: SuccessCardAnimation()), - const Story( - title: 'Group Animation', - child: GroupAnimation(), - ), const Story( title: 'Text Rolling Animations', child: TextAnimation(), @@ -97,10 +101,6 @@ class _StoryboardState extends State with WidgetsBindingObserver { title: 'Rolling Counter App', child: CounterApp(), ), - const Story( - title: 'One-Shot Reset Animation', - child: OneShotResetAnimation(), - ), const Story( title: 'Spring Animation', child: SpringAnimation(), @@ -114,6 +114,32 @@ class _StoryboardState extends State with WidgetsBindingObserver { child: RollingAppBarAnimation(), ), ]; + final List timelineStories = [ + const Story( + title: 'Pulse Button (simple)', + child: TimelinePulseButton(), + ), + const Story( + title: 'Notification Bell (medium)', + child: TimelineNotificationBell(), + ), + const Story( + title: 'Toast Choreography (complex)', + child: TimelineToast(), + ), + const Story( + title: 'Rocket Launch (very complex)', + child: TimelineRocketLaunch(), + ), + const Story( + title: 'Looping Journey (auto-play)', + child: TimelineJourney(), + ), + const Story( + title: 'Spring Motion (physics)', + child: SpringMotionStory(), + ), + ]; final List transitionStories = [ const Story( title: 'Scroll Phase Transition', @@ -137,9 +163,27 @@ class _StoryboardState extends State with WidgetsBindingObserver { ), ]; - int? selectedAnimation; - int? selectedTransition; - int? selectedCategory; + /// Low-priority edge-case exercises; listed after the showcase sections. + final List edgeCaseStories = [ + const Story( + title: 'Interrupted Delay Hold', + child: InterruptedDelayHoldStory(), + ), + const Story( + title: 'Queued Run Isolation', + child: QueuedRunIsolationStory(), + ), + const Story( + title: 'Spring Lifecycle', + child: SpringLifecycleStory(), + ), + const Story( + title: 'Layout Spring Safety', + child: LayoutSpringSafetyStory(), + ), + ]; + + Story? selectedStory; bool openDrawer = false; @@ -175,6 +219,32 @@ class _StoryboardState extends State with WidgetsBindingObserver { openDrawer = screen.width > 800; } + /// One titled drawer section: a header followed by a selectable tile per + /// story. + List _storySection(String title, List stories) => [ + Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16), + child: Text(title, style: Theme.of(context).textTheme.titleLarge), + ), + for (final Story story in stories) + Material( + type: MaterialType.transparency, + borderRadius: const BorderRadius.only( + topRight: Radius.circular(32), + bottomRight: Radius.circular(32), + ), + clipBehavior: Clip.antiAlias, + child: ListTile( + title: Padding( + padding: const EdgeInsets.only(left: 16), + child: Text(story.title), + ), + onTap: () => setState(() => selectedStory = story), + selected: story == selectedStory, + ), + ), + ]; + @override Widget build(BuildContext context) { return Scaffold( @@ -220,75 +290,13 @@ class _StoryboardState extends State with WidgetsBindingObserver { child: ListView( children: [ const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.only( - left: 16, - right: 16, - bottom: 16, - top: 0, - ), - child: Text('Animations', - style: Theme.of(context).textTheme.titleLarge), - ), - for (final Story story in animationStories) - Material( - type: MaterialType.transparency, - borderRadius: const BorderRadius.only( - topRight: Radius.circular(32), - bottomRight: Radius.circular(32), - ), - clipBehavior: Clip.antiAlias, - child: ListTile( - title: Padding( - padding: const EdgeInsets.only(left: 16), - child: Text(story.title), - ), - onTap: () { - setState(() { - selectedAnimation = - animationStories.indexOf(story); - selectedTransition = null; - }); - }, - selected: animationStories.indexOf(story) == - selectedAnimation, - ), - ), + ..._storySection('Animations', animationStories), + const Divider(height: 32, indent: 16, endIndent: 16), + ..._storySection('Timelines', timelineStories), const Divider(height: 32, indent: 16, endIndent: 16), - Padding( - padding: const EdgeInsets.only( - left: 16, - right: 16, - bottom: 16, - top: 0, - ), - child: Text('Transitions', - style: Theme.of(context).textTheme.titleLarge), - ), - for (final Story story in transitionStories) - Material( - type: MaterialType.transparency, - borderRadius: const BorderRadius.only( - topRight: Radius.circular(32), - bottomRight: Radius.circular(32), - ), - clipBehavior: Clip.antiAlias, - child: ListTile( - title: Padding( - padding: const EdgeInsets.only(left: 16), - child: Text(story.title), - ), - onTap: () { - setState(() { - selectedTransition = - transitionStories.indexOf(story); - selectedAnimation = null; - }); - }, - selected: transitionStories.indexOf(story) == - selectedTransition, - ), - ), + ..._storySection('Transitions', transitionStories), + const Divider(height: 32, indent: 16, endIndent: 16), + ..._storySection('Edge Cases', edgeCaseStories), const Divider(height: 16), ], ), @@ -303,13 +311,10 @@ class _StoryboardState extends State with WidgetsBindingObserver { child: ContentView( child: AnimatedSwitcher( duration: const Duration(milliseconds: 300), - child: selectedAnimation != null - ? animationStories[selectedAnimation!].child - : selectedTransition != null - ? transitionStories[selectedTransition!].child - : const Center( - child: Text('Select a story to view.'), - ), + child: selectedStory?.child ?? + const Center( + child: Text('Select a story to view.'), + ), ), ), ), @@ -474,7 +479,7 @@ class _CustomExpansionTileState extends State { color: Theme.of(context) .colorScheme .primaryContainer - .withOpacity(0.05), + .withValues(alpha: 0.05), child: Column( children: widget.children, ), diff --git a/example/lib/stories/color_filter_scroll_transition.dart b/example/lib/stories/color_filter_scroll_transition.dart index 13bf211..cace9b8 100644 --- a/example/lib/stories/color_filter_scroll_transition.dart +++ b/example/lib/stories/color_filter_scroll_transition.dart @@ -1,4 +1,5 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:flutter/rendering.dart'; import 'package:hyper_effects/hyper_effects.dart'; class FashionScrollTransition extends StatefulWidget { @@ -24,7 +25,7 @@ class _FashionScrollTransitionState extends State { Widget build(BuildContext context) { return ListView.builder( itemExtent: 300, - cacheExtent: 300, + scrollCacheExtent: const ScrollCacheExtent.pixels(300), itemBuilder: (context, index) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), diff --git a/example/lib/stories/counter_app.dart b/example/lib/stories/counter_app.dart index 10952b1..a004e62 100644 --- a/example/lib/stories/counter_app.dart +++ b/example/lib/stories/counter_app.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:hyper_effects/hyper_effects.dart'; class CounterApp extends StatefulWidget { diff --git a/example/lib/stories/group_animation.dart b/example/lib/stories/group_animation.dart deleted file mode 100644 index 3dbf348..0000000 --- a/example/lib/stories/group_animation.dart +++ /dev/null @@ -1,138 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hyper_effects/hyper_effects.dart'; - -class GroupAnimation extends StatefulWidget { - const GroupAnimation({super.key}); - - @override - State createState() => _GroupAnimationState(); -} - -class _GroupAnimationState extends State { - int counter = 0; - - final List tags1 = [ - 'family', - 'friends', - 'work', - 'school', - 'sports', - 'hobbies', - ]; - final List tags2 = [ - 'family', - 'work', - 'friends', - 'sports', - ]; - - @override - Widget build(BuildContext context) { - final tags = counter % 2 == 0 ? tags1 : tags2; - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - height: 40, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(20), - ), - clipBehavior: Clip.antiAlias, - child: AnimatedGroup( - triggerAddImmediately: false, - builder: (context, children) => Row(children: children), - children: [ - for (int i = 0; i < tags.length; i++) - Padding( - key: ValueKey(tags[i]), - padding: const EdgeInsets.symmetric(horizontal: 8), - child: TagChip( - tag: tags[i], - selected: false, - ), - ) - ], - ), - ), - ElevatedButton( - onPressed: () { - setState(() { - counter++; - }); - }, - child: const Text('Switch'), - ), - ], - ), - ); - } -} - -class TagChip extends StatefulWidget { - final String tag; - final bool selected; - final VoidCallback? onTap; - final bool compact; - final Widget? suffix; - - const TagChip({ - super.key, - required this.tag, - required this.selected, - this.onTap, - this.compact = false, - this.suffix, - }); - - @override - State createState() => _TagChipState(); -} - -class _TagChipState extends State { - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: widget.selected - ? Theme.of(context).colorScheme.primary.withOpacity(0.1) - : Theme.of(context).colorScheme.onSurface.withOpacity(0.1), - borderRadius: BorderRadius.circular(6), - ), - clipBehavior: Clip.none, - child: InkWell( - borderRadius: BorderRadius.circular(6), - onTap: widget.onTap, - child: Padding( - padding: const EdgeInsets.only(left: 6, right: 8, top: 4, bottom: 4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - widget.selected ? Icons.check_circle : Icons.tag, - size: 16, - color: widget.selected - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurface, - ), - const SizedBox(width: 6), - Text( - widget.tag, - style: TextStyle( - color: widget.selected - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurface, - ), - ), - if (widget.suffix case Widget suffix) ...[ - const SizedBox(width: 4), - suffix, - ], - ], - ), - ), - ), - ); - } -} diff --git a/example/lib/stories/interrupted_delay_hold.dart b/example/lib/stories/interrupted_delay_hold.dart new file mode 100644 index 0000000..a10b49b --- /dev/null +++ b/example/lib/stories/interrupted_delay_hold.dart @@ -0,0 +1,109 @@ +import 'package:hyper_effects/hyper_effects.dart'; +import 'package:material_ui/material_ui.dart'; + +import 'story_scaffold.dart'; + +class InterruptedDelayHoldStory extends StatefulWidget { + const InterruptedDelayHoldStory({super.key}); + + @override + State createState() => + _InterruptedDelayHoldStoryState(); +} + +class _InterruptedDelayHoldStoryState extends State { + static const _delay = Duration(milliseconds: 700); + + int trigger = 0; + bool right = false; + bool holding = false; + int _statusGeneration = 0; + + Future _drive({required bool retarget}) async { + final generation = ++_statusGeneration; + setState(() { + if (retarget) right = !right; + trigger++; + holding = true; + }); + await Future.delayed(_delay); + if (!mounted || generation != _statusGeneration) return; + setState(() => holding = false); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return StoryScaffold( + title: 'Interrupted Delay Hold', + description: 'Retrigger mid-flight. The marker freezes exactly where it ' + 'was interrupted, waits, then continues without a snap.', + maxWidth: 620, + children: [ + const SizedBox(height: 32), + AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: + holding ? colors.tertiaryContainer : colors.secondaryContainer, + borderRadius: BorderRadius.circular(999), + ), + child: Text(holding ? 'HOLDING · 700 MS' : 'READY'), + ), + const SizedBox(height: 28), + Container( + width: 420, + height: 96, + padding: const EdgeInsets.symmetric(horizontal: 36), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(48), + ), + child: Center( + child: Container( + key: const Key('delay-hold-marker'), + width: 54, + height: 54, + decoration: BoxDecoration( + color: colors.primary, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: colors.primary.withValues(alpha: 0.32), + blurRadius: 18, + spreadRadius: 2, + ), + ], + ), + child: Icon(Icons.pause_rounded, color: colors.onPrimary), + ).translateX(right ? 150 : -150).animate( + trigger: trigger, + duration: const Duration(milliseconds: 900), + curve: Curves.easeInOutCubic, + delay: _delay, + ), + ), + ), + const SizedBox(height: 32), + Wrap( + alignment: WrapAlignment.center, + spacing: 12, + runSpacing: 12, + children: [ + FilledButton.icon( + onPressed: () => _drive(retarget: false), + icon: const Icon(Icons.replay_rounded), + label: const Text('Retrigger same target'), + ), + OutlinedButton.icon( + onPressed: () => _drive(retarget: true), + icon: const Icon(Icons.swap_horiz_rounded), + label: const Text('Retarget opposite side'), + ), + ], + ), + ], + ); + } +} diff --git a/example/lib/stories/layout_spring_safety.dart b/example/lib/stories/layout_spring_safety.dart new file mode 100644 index 0000000..1edf02c --- /dev/null +++ b/example/lib/stories/layout_spring_safety.dart @@ -0,0 +1,172 @@ +import 'package:hyper_effects/hyper_effects.dart'; +import 'package:material_ui/material_ui.dart'; + +import 'story_scaffold.dart'; + +class LayoutSpringSafetyStory extends StatefulWidget { + const LayoutSpringSafetyStory({super.key}); + + @override + State createState() => + _LayoutSpringSafetyStoryState(); +} + +class _LayoutSpringSafetyStoryState extends State { + int trigger = 0; + bool expanded = false; + + void _setExpanded(bool value) { + setState(() { + expanded = value; + trigger++; + }); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + const motion = CupertinoMotion.bouncy(extraBounce: 0.12); + + Widget lane(String label, Widget demo, String note) => Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + )), + const SizedBox(height: 12), + SizedBox(height: 92, child: Center(child: demo)), + const SizedBox(height: 10), + Text(note, style: Theme.of(context).textTheme.bodySmall), + ], + ), + ); + + return StoryScaffold( + title: 'Layout Spring Safety', + description: 'Real layout values may overshoot. Positive bounce stays ' + 'visible; unsafe negative dimensions and factors stop at zero.', + maxWidth: 820, + children: [ + const SizedBox(height: 28), + LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 720 ? 3 : 1; + final width = columns == 3 + ? (constraints.maxWidth - 24) / 3 + : constraints.maxWidth; + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + SizedBox( + width: width, + child: lane( + 'SIZE', + Container( + key: const Key('safety-size-box'), + height: 42, + decoration: BoxDecoration( + color: colors.primary, + borderRadius: BorderRadius.circular(12), + ), + ) + .widthTo(expanded ? 150 : 0, from: 0) + .animate(trigger: trigger, motion: motion), + 'Width bounces beyond target, but never below 0.', + ), + ), + SizedBox( + width: width, + child: lane( + 'PADDING', + // The decorated frame hugs the animated Padding, so + // the growing insets paint as a visible halo. Bare + // padding around a centered box would be invisible. + Container( + key: const Key('safety-padding-frame'), + decoration: BoxDecoration( + color: colors.tertiaryContainer, + borderRadius: BorderRadius.circular(14), + ), + child: Container( + key: const Key('safety-padding-box'), + width: 42, + height: 42, + decoration: BoxDecoration( + color: colors.tertiary, + borderRadius: BorderRadius.circular(10), + ), + ) + .padAll(expanded ? 24 : 0) + .animate(trigger: trigger, motion: motion), + ), + 'Insets keep outward overshoot and floor undershoot.', + ), + ), + SizedBox( + width: width, + child: lane( + 'ALIGN FACTOR', + // The decorated frame hugs the animated Align, so + // the width factor paints as a visible strip + // stretching around the circle. + Container( + key: const Key('safety-align-frame'), + decoration: BoxDecoration( + color: colors.secondaryContainer, + borderRadius: BorderRadius.circular(21), + ), + child: Container( + key: const Key('safety-align-box'), + width: 42, + height: 42, + decoration: BoxDecoration( + color: colors.secondary, + shape: BoxShape.circle, + ), + ) + .align( + Alignment.center, + widthFactor: expanded ? 2.2 : 0, + fromWidthFactor: 0, + ) + .animate(trigger: trigger, motion: motion), + ), + 'Width factor can bounce, while layout sees ≥ 0.', + ), + ), + ], + ); + }, + ), + const SizedBox(height: 26), + Wrap( + alignment: WrapAlignment.center, + spacing: 12, + runSpacing: 12, + children: [ + FilledButton.icon( + onPressed: () => _setExpanded(true), + icon: const Icon(Icons.open_in_full_rounded), + label: const Text('Overshoot outward'), + ), + OutlinedButton.icon( + onPressed: () => _setExpanded(false), + icon: const Icon(Icons.close_fullscreen_rounded), + label: const Text('Shrink through floor'), + ), + ], + ), + ], + ); + } +} diff --git a/example/lib/stories/one_shot_reset_animation.dart b/example/lib/stories/one_shot_reset_animation.dart deleted file mode 100644 index af3c5bc..0000000 --- a/example/lib/stories/one_shot_reset_animation.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hyper_effects/hyper_effects.dart'; - -class OneShotResetAnimation extends StatefulWidget { - const OneShotResetAnimation({super.key}); - - @override - State createState() => _OneShotResetAnimationState(); -} - -class _OneShotResetAnimationState extends State { - @override - Widget build(BuildContext context) { - return Center( - child: Image.asset('assets/pin_ball_256x.png', width: 150, height: 150) - // Step 1 - .translateX(100, from: -100) - .oneShot() - // Step 2 - .slideOutToBottom() - .animateAfter() - // Step 3 - .slideOutToLeft(value: -200) - .animateAfter() - // Step 4 - .slideOutToTop() - .animateAfter() - .resetAll(), - ); - } -} diff --git a/example/lib/stories/queued_run_isolation.dart b/example/lib/stories/queued_run_isolation.dart new file mode 100644 index 0000000..280470d --- /dev/null +++ b/example/lib/stories/queued_run_isolation.dart @@ -0,0 +1,142 @@ +import 'package:hyper_effects/hyper_effects.dart'; +import 'package:material_ui/material_ui.dart'; + +import 'story_scaffold.dart'; + +class QueuedRunIsolationStory extends StatefulWidget { + const QueuedRunIsolationStory({super.key}); + + @override + State createState() => + _QueuedRunIsolationStoryState(); +} + +class _QueuedRunIsolationStoryState extends State { + int trigger = 0; + Duration duration = const Duration(milliseconds: 450); + int repeat = 0; + bool reverse = false; + String label = 'Quick'; + final completed = []; + + void _enqueue({ + required String nextLabel, + required Duration nextDuration, + required int nextRepeat, + required bool nextReverse, + }) { + setState(() { + label = nextLabel; + duration = nextDuration; + repeat = nextRepeat; + reverse = nextReverse; + trigger++; + }); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final completionLabel = label; + return StoryScaffold( + title: 'Queued Run Isolation', + description: 'Queue contrasting non-interruptible runs. The active run ' + 'keeps its own timing, repeat, direction, and completion label.', + maxWidth: 680, + children: [ + const SizedBox(height: 32), + Container( + width: 460, + height: 92, + padding: const EdgeInsets.symmetric(horizontal: 42), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + child: Align( + alignment: Alignment.centerLeft, + child: Container( + key: const Key('queue-marker'), + width: 54, + height: 54, + decoration: BoxDecoration( + color: colors.primary, + borderRadius: BorderRadius.circular(16), + ), + child: Icon(Icons.layers_rounded, color: colors.onPrimary), + ).translateX(320, from: 0).animate( + trigger: trigger, + duration: duration, + curve: Curves.easeInOutCubic, + repeat: repeat, + reverse: reverse, + interruptable: false, + onEnd: () => setState(() { + completed.add(completionLabel); + }), + ), + ), + ), + const SizedBox(height: 24), + Wrap( + alignment: WrapAlignment.center, + spacing: 10, + runSpacing: 10, + children: [ + FilledButton( + onPressed: () => _enqueue( + nextLabel: 'Quick', + nextDuration: const Duration(milliseconds: 450), + nextRepeat: 0, + nextReverse: false, + ), + child: const Text('Quick · once'), + ), + OutlinedButton( + onPressed: () => _enqueue( + nextLabel: 'Bounce', + nextDuration: const Duration(milliseconds: 700), + nextRepeat: 1, + nextReverse: true, + ), + child: const Text('Bounce · reverse'), + ), + OutlinedButton( + onPressed: () => _enqueue( + nextLabel: 'Slow', + nextDuration: const Duration(milliseconds: 900), + nextRepeat: 1, + nextReverse: false, + ), + child: const Text('Slow · repeat'), + ), + ], + ), + const SizedBox(height: 28), + Align( + alignment: Alignment.centerLeft, + child: Text('Completion order', + style: Theme.of(context).textTheme.titleMedium), + ), + const SizedBox(height: 10), + if (completed.isEmpty) + const Align( + alignment: Alignment.centerLeft, + child: Text('No runs completed yet'), + ) + else + Align( + alignment: Alignment.centerLeft, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (var i = 0; i < completed.length; i++) + Chip(label: Text('${i + 1} · ${completed[i]}')), + ], + ), + ), + ], + ); + } +} diff --git a/example/lib/stories/rolling_app_bar_animation.dart b/example/lib/stories/rolling_app_bar_animation.dart index 055edc7..a0bed4d 100644 --- a/example/lib/stories/rolling_app_bar_animation.dart +++ b/example/lib/stories/rolling_app_bar_animation.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; import 'package:hyper_effects/hyper_effects.dart'; +import 'package:material_ui/material_ui.dart'; class RollingAppBarAnimation extends StatefulWidget { const RollingAppBarAnimation({super.key}); diff --git a/example/lib/stories/rolling_pictures_animation.dart b/example/lib/stories/rolling_pictures_animation.dart index edf0c42..48fbf23 100644 --- a/example/lib/stories/rolling_pictures_animation.dart +++ b/example/lib/stories/rolling_pictures_animation.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:hyper_effects/hyper_effects.dart'; class RollingWidgetAnimation extends StatefulWidget { diff --git a/example/lib/stories/scroll_phase_blur.dart b/example/lib/stories/scroll_phase_blur.dart index 110d7f0..d1b36a6 100644 --- a/example/lib/stories/scroll_phase_blur.dart +++ b/example/lib/stories/scroll_phase_blur.dart @@ -1,6 +1,6 @@ import 'dart:math'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:hyper_effects/hyper_effects.dart'; class ScrollWheelBlurTransition extends StatefulWidget { diff --git a/example/lib/stories/scroll_phase_slide.dart b/example/lib/stories/scroll_phase_slide.dart index 393bbdf..eb80e0e 100644 --- a/example/lib/stories/scroll_phase_slide.dart +++ b/example/lib/stories/scroll_phase_slide.dart @@ -1,6 +1,6 @@ import 'dart:math'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:hyper_effects/hyper_effects.dart'; class ScrollPhaseTransition extends StatefulWidget { diff --git a/example/lib/stories/scroll_wheel_transition.dart b/example/lib/stories/scroll_wheel_transition.dart index da65f48..3bb8105 100644 --- a/example/lib/stories/scroll_wheel_transition.dart +++ b/example/lib/stories/scroll_wheel_transition.dart @@ -1,6 +1,6 @@ import 'dart:math'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:hyper_effects/hyper_effects.dart'; class ScrollWheelTransition extends StatefulWidget { diff --git a/example/lib/stories/shake_and_spring_animation.dart b/example/lib/stories/shake_and_spring_animation.dart index b6b6f5a..4f84c17 100644 --- a/example/lib/stories/shake_and_spring_animation.dart +++ b/example/lib/stories/shake_and_spring_animation.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:hyper_effects/hyper_effects.dart'; class SpringAnimation extends StatefulWidget { @@ -9,40 +9,53 @@ class SpringAnimation extends StatefulWidget { } class _SpringAnimationState extends State { - bool trigger = false; + /// Whether the drop-and-spring timeline is currently running. + bool dropping = false; + + /// Counts drops; the timeline's identity trigger. + int drops = 0; @override Widget build(BuildContext context) { return Center( child: GestureDetector( onTap: () { + if (dropping) return; setState(() { - trigger = !trigger; + dropping = true; + drops++; }); }, child: Image.asset('assets/pin_ball_256x.png', width: 150, height: 150) + // The idle shake stays on .animate: ShakeEffect reads its + // animation value from EffectQuery, which timelines do not + // provide. .shake() + // #immediate starts the idle shake on mount; each `dropping` + // flip re-drives it, with playIf silencing it during the drop. .animate( - trigger: trigger, - startState: AnimationStartState.playImmediately, + trigger: dropping ? true : #immediate, delay: const Duration(seconds: 1), repeat: -1, - playIf: () => !trigger, + playIf: () => !dropping, ) - .translateY(300, from: 0) - .animate( - trigger: trigger, - curve: Curves.easeOutQuart, + // The drop-and-spring is one timeline: fall to 300, then spring + // back to an absolute 0 — no -300 counter-translation needed. + .translateY(0) + .step( duration: const Duration(milliseconds: 2000), - playIf: () => trigger, + curve: Curves.easeOutQuart, ) - .translateY(-300, from: 0) - .animateAfter( - curve: Curves.elasticOut, + .translateY(300) + .step( duration: const Duration(milliseconds: 450), - onEnd: () => setState(() => trigger = false), + curve: Curves.elasticOut, ) - .resetAll(), + .translateY(0) + .timeline( + trigger: drops, + onEnd: () => setState(() => dropping = false), + ), ), ); } diff --git a/example/lib/stories/spring_lifecycle.dart b/example/lib/stories/spring_lifecycle.dart new file mode 100644 index 0000000..37663fd --- /dev/null +++ b/example/lib/stories/spring_lifecycle.dart @@ -0,0 +1,145 @@ +import 'package:hyper_effects/hyper_effects.dart'; +import 'package:material_ui/material_ui.dart'; + +import 'story_scaffold.dart'; + +class SpringLifecycleStory extends StatefulWidget { + const SpringLifecycleStory({super.key}); + + @override + State createState() => _SpringLifecycleStoryState(); +} + +class _SpringLifecycleStoryState extends State { + final animationKey = GlobalKey(); + + int trigger = 0; + bool right = false; + int repeat = 0; + bool reverse = false; + String status = 'AT START · RESTING'; + int _statusGeneration = 0; + + void _playReverse() { + final generation = ++_statusGeneration; + setState(() { + right = true; + repeat = 1; + reverse = true; + trigger++; + status = 'FORWARD → FRESH REVERSE'; + }); + _settleLabel(generation, 'AT START · RESTING'); + } + + void _retarget() { + final generation = ++_statusGeneration; + setState(() { + right = !right; + repeat = 0; + reverse = false; + trigger++; + status = 'RETARGET · MOMENTUM CARRIED'; + }); + _settleLabel( + generation, right ? 'AT RIGHT · RESTING' : 'AT START · RESTING'); + } + + void _reset() { + _statusGeneration++; + animationKey.currentState?.reset(); + setState(() { + status = 'AT START · RESTING'; + }); + } + + void _replay() { + final generation = ++_statusGeneration; + setState(() { + repeat = 0; + reverse = false; + trigger++; + status = 'REPLAY · ZERO INITIAL VELOCITY'; + }); + _settleLabel( + generation, right ? 'AT RIGHT · RESTING' : 'AT START · RESTING'); + } + + Future _settleLabel(int generation, String label) async { + await Future.delayed(const Duration(milliseconds: 1700)); + if (!mounted || generation != _statusGeneration) return; + setState(() => status = label); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return StoryScaffold( + title: 'Spring Lifecycle', + description: 'Reverse settles forward through physical time. Retargets ' + 'carry live momentum; completed replays and resets start clean.', + children: [ + const SizedBox(height: 24), + Chip( + avatar: const Icon(Icons.monitor_heart_outlined, size: 18), + label: Text(status), + ), + const SizedBox(height: 24), + Container( + width: 460, + height: 112, + padding: const EdgeInsets.symmetric(horizontal: 44), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(28), + ), + child: Center( + child: Container( + key: const Key('spring-lifecycle-marker'), + width: 62, + height: 62, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [colors.primary, colors.tertiary], + ), + shape: BoxShape.circle, + ), + child: Icon(Icons.bolt_rounded, color: colors.onPrimary), + ).translateX(right ? 155 : -155, from: -155).animate( + key: animationKey, + trigger: trigger, + motion: const CupertinoMotion.bouncy(), + repeat: repeat, + reverse: reverse, + ), + ), + ), + const SizedBox(height: 28), + Wrap( + alignment: WrapAlignment.center, + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: _playReverse, + icon: const Icon(Icons.sync_alt_rounded), + label: const Text('Play + reverse'), + ), + OutlinedButton( + onPressed: _retarget, + child: const Text('Retarget now'), + ), + OutlinedButton( + onPressed: _reset, + child: const Text('Reset mid-reverse'), + ), + OutlinedButton( + onPressed: _replay, + child: const Text('Replay from rest'), + ), + ], + ), + ], + ); + } +} diff --git a/example/lib/stories/spring_motion.dart b/example/lib/stories/spring_motion.dart new file mode 100644 index 0000000..d9ab760 --- /dev/null +++ b/example/lib/stories/spring_motion.dart @@ -0,0 +1,83 @@ +import 'package:hyper_effects/hyper_effects.dart'; +import 'package:material_ui/material_ui.dart'; + +/// PHYSICS: real spring motion on both tiers — a `.animate(motion:)` toggle +/// that retargets with a bouncy spring, and a timeline step driven by +/// spring physics instead of a curve. +class SpringMotionStory extends StatefulWidget { + const SpringMotionStory({super.key}); + + @override + State createState() => _SpringMotionStoryState(); +} + +class _SpringMotionStoryState extends State { + bool right = false; + int pops = 0; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Springs are Motions: no duration, no curve — physics.\n' + 'The slider retargets with a bouncy spring; the badge pops\n' + 'on a spring-driven timeline step.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + SizedBox( + width: 320, + height: 72, + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(36), + ), + child: Center( + child: const CircleAvatar(radius: 24, child: Icon(Icons.bolt)) + // .animate(motion:) retargets with spring physics: + // each toggle springs from the captured current + // position toward the new anchor. + .translateX(right ? 120 : -120) + .animate( + trigger: right, + motion: const CupertinoMotion.bouncy(), + ), + ), + ), + ), + const SizedBox(height: 48), + Badge( + isLabelVisible: pops > 0, + label: Text('$pops'), + child: const Icon(Icons.celebration, size: 56), + ) + // A timeline step needs no duration or curve when a spring + // drives it: the segment's length IS the settling time. + .scale(0) + .step(motion: const CupertinoMotion.bouncy()) + .scale(1) + .timeline(trigger: pops), + const SizedBox(height: 48), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + FilledButton( + onPressed: () => setState(() => right = !right), + child: const Text('Toggle'), + ), + const SizedBox(width: 16), + OutlinedButton( + onPressed: () => setState(() => pops++), + child: const Text('Pop'), + ), + ], + ), + ], + ), + ); + } +} diff --git a/example/lib/stories/story_scaffold.dart b/example/lib/stories/story_scaffold.dart new file mode 100644 index 0000000..6142677 --- /dev/null +++ b/example/lib/stories/story_scaffold.dart @@ -0,0 +1,49 @@ +import 'package:material_ui/material_ui.dart'; + +/// The shared page chrome of an edge-case story: a centered, scrollable, +/// width-constrained column with a headline and a one-paragraph description, +/// followed by the story's own content. +class StoryScaffold extends StatelessWidget { + /// The headline shown at the top of the page. + final String title; + + /// One or two sentences describing what the story demonstrates. + final String description; + + /// The maximum content width. + final double maxWidth; + + /// The story's demo and controls, laid out below the description. Each + /// child provides its own leading spacing. + final List children; + + /// Creates a [StoryScaffold]. + const StoryScaffold({ + super.key, + required this.title, + required this.description, + this.maxWidth = 700, + required this.children, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, style: Theme.of(context).textTheme.headlineMedium), + const SizedBox(height: 12), + Text(description, textAlign: TextAlign.center), + ...children, + ], + ), + ), + ), + ); + } +} diff --git a/example/lib/stories/success_card_animation.dart b/example/lib/stories/success_card_animation.dart index 5327ff6..31ab6a0 100644 --- a/example/lib/stories/success_card_animation.dart +++ b/example/lib/stories/success_card_animation.dart @@ -1,7 +1,8 @@ +import 'dart:async'; import 'dart:math'; -import 'package:flutter/material.dart'; import 'package:hyper_effects/hyper_effects.dart'; +import 'package:material_ui/material_ui.dart'; class SuccessCardAnimation extends StatefulWidget { const SuccessCardAnimation({super.key}); @@ -13,6 +14,37 @@ class SuccessCardAnimation extends StatefulWidget { class _SuccessCardAnimationState extends State { bool isCompleted = false; + /// The completion state the first card is animating away from. + /// + /// Before the first tap there is no previous state, so this matches + /// [isCompleted] and every effect's `from` equals its target. The card + /// therefore mounts at rest, hidden, rather than animating on its first + /// frame. After a tap it holds the state just left behind, which is what + /// makes the slide and fade reverse cleanly without snapping. + bool previouslyCompleted = false; + + /// Drives the stamp card. The timeline plays forward on completion and + /// reverses on un-completion; direction is imperative, not trigger-based. + final TimelineController stampController = TimelineController(); + + @override + void dispose() { + stampController.dispose(); + super.dispose(); + } + + void _toggle() { + setState(() { + previouslyCompleted = isCompleted; + isCompleted = !isCompleted; + }); + if (isCompleted) { + unawaited(stampController.play()); + } else { + unawaited(stampController.reverse()); + } + } + @override Widget build(BuildContext context) { return Column( @@ -27,11 +59,7 @@ class _SuccessCardAnimationState extends State { child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( - onTap: () { - setState(() { - isCompleted = !isCompleted; - }); - }, + onTap: _toggle, child: Padding( padding: const EdgeInsets.all(12), child: Container( @@ -62,12 +90,10 @@ class _SuccessCardAnimationState extends State { ) .translateY( isCompleted ? 0 : 100, - from: isCompleted ? 100 : 0, + from: previouslyCompleted ? 0 : 100, ) .animate( trigger: isCompleted, - startState: - AnimationStartState.useCurrentValues, curve: !isCompleted ? Curves.easeInBack : Curves.easeOutBack, @@ -78,12 +104,10 @@ class _SuccessCardAnimationState extends State { ) .opacity( isCompleted ? 1 : 0, - from: isCompleted ? 0 : 1, + from: previouslyCompleted ? 1 : 0, ) .animate( trigger: isCompleted, - startState: - AnimationStartState.useCurrentValues, curve: Curves.easeInOutSine, duration: const Duration( milliseconds: 400, @@ -106,11 +130,7 @@ class _SuccessCardAnimationState extends State { child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( - onTap: () { - setState(() { - isCompleted = !isCompleted; - }); - }, + onTap: _toggle, child: Padding( padding: const EdgeInsets.all(12), child: Container( @@ -135,44 +155,32 @@ class _SuccessCardAnimationState extends State { color: Color(0x3A079455), ), alignment: Alignment.center, + // A "stamp": slam in overshooting at 1.5x + // and tilted, hold, then settle to rest. + // Keyframe values are absolute — the settle + // targets 1.0 directly, no reciprocals. child: const Icon( Icons.check_circle, size: 74, ) - .scale(isCompleted ? 1.5 : 0) - .rotate(isCompleted ? 15 * pi / 180 : 0) - .animate( - trigger: isCompleted, - curve: Curves.easeOutQuart, + .scale(0) + .rotate(0) + .step( duration: const Duration(milliseconds: 350), + curve: Curves.easeOutQuart, ) - .scale( - isCompleted ? 1 / 1.5 : 1, - ) - .rotate( - isCompleted ? -15 * pi / 180 : 0, - ) - .animateAfter( - curve: Curves.easeOutBack, - delay: const Duration(milliseconds: 150), + .scale(1.5) + .rotate(15 * pi / 180) + .step( duration: const Duration(milliseconds: 300), - )) - // .opacity( - // isCompleted ? 1 : 0, - // from: isCompleted ? 0 : 1, - // ) - // .animate( - // trigger: isCompleted, - // startState: - // AnimationStartState.useCurrentValues, - // curve: Curves.easeInOutSine, - // duration: const Duration( - // milliseconds: 400, - // ), - // ), - ), + curve: Curves.easeOutBack, + delay: const Duration(milliseconds: 150), + ) + .scale(1) + .rotate(0) + .timeline(controller: stampController))), ], ), ), diff --git a/example/lib/stories/text_animation.dart b/example/lib/stories/text_animation.dart index b03b76b..fb6cc4c 100644 --- a/example/lib/stories/text_animation.dart +++ b/example/lib/stories/text_animation.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:flutter/scheduler.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:hyper_effects/hyper_effects.dart'; @@ -210,12 +210,12 @@ class _TagLineState extends State { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Colors.white.withOpacity(0), + Colors.white.withValues(alpha: 0), Colors.white, Colors.white, Colors.white, Colors.white, - Colors.white.withOpacity(0), + Colors.white.withValues(alpha: 0), // Colors.white, ], ).createShader(rect), @@ -310,12 +310,12 @@ class _TranslationState extends State { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Colors.white.withOpacity(0), + Colors.white.withValues(alpha: 0), Colors.white, Colors.white, Colors.white, Colors.white, - Colors.white.withOpacity(0), + Colors.white.withValues(alpha: 0), // Colors.white, ], ).createShader(rect), @@ -426,7 +426,7 @@ class _LikeButtonState extends State { color: Theme.of(context) .colorScheme .onPrimaryContainer - .withOpacity(0.5), + .withValues(alpha: 0.5), indent: 4, endIndent: 4, ), @@ -765,7 +765,7 @@ class _ColorPalettePageState extends State { spacing: 6, radius: 12, activeDotColor: Colors.white, - dotColor: Colors.white.withOpacity(0.25), + dotColor: Colors.white.withValues(alpha: 0.25), ), ), ), diff --git a/example/lib/stories/timeline_journey.dart b/example/lib/stories/timeline_journey.dart new file mode 100644 index 0000000..34893ae --- /dev/null +++ b/example/lib/stories/timeline_journey.dart @@ -0,0 +1,28 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +/// The looping journey formerly built from oneShot + animateAfter chains + +/// resetAll: the ball travels a circuit forever, restarting seamlessly. +/// +/// Keyframes are absolute positions, the last keyframe matches the first, +/// and `trigger: #immediate` with `repeat: -1` loops the single controller — no reset snapping. +class TimelineJourney extends StatelessWidget { + const TimelineJourney({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Image.asset('assets/pin_ball_256x.png', width: 150, height: 150) + .translateXY(-100, 0) + .step(duration: const Duration(milliseconds: 350)) + .translateXY(100, 0) + .step(duration: const Duration(milliseconds: 350)) + .translateXY(100, 200) + .step(duration: const Duration(milliseconds: 350)) + .translateXY(-100, 200) + .step(duration: const Duration(milliseconds: 350)) + .translateXY(-100, 0) + .timeline(trigger: #immediate, repeat: -1), + ); + } +} diff --git a/example/lib/stories/timeline_notification_bell.dart b/example/lib/stories/timeline_notification_bell.dart new file mode 100644 index 0000000..388ea33 --- /dev/null +++ b/example/lib/stories/timeline_notification_bell.dart @@ -0,0 +1,71 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +/// MEDIUM: a damped bell swing — many keyframes of one type plus a second +/// track (scale pop), restarted by a counting trigger. +class TimelineNotificationBell extends StatefulWidget { + const TimelineNotificationBell({super.key}); + + @override + State createState() => + _TimelineNotificationBellState(); +} + +class _TimelineNotificationBellState extends State { + int notifications = 0; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'A damped swing: five rotation keyframes and a scale pop,\n' + 'restarted by every new notification.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + // The timeline wraps the Badge (not the other way around): Badge + // reparents its child when the label first appears, which would + // dispose a timeline nested inside it. + Badge( + isLabelVisible: notifications > 0, + label: Text('$notifications'), + child: const Icon(Icons.notifications, size: 64), + ) + .rotate(0, alignment: Alignment.topCenter) + .scale(1) + .step( + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + ) + .rotate(0.35, alignment: Alignment.topCenter) + .scale(1.15) + .step( + duration: const Duration(milliseconds: 140), + curve: Curves.easeInOut, + ) + .rotate(-0.25, alignment: Alignment.topCenter) + .scale(1) + .step( + duration: const Duration(milliseconds: 140), + curve: Curves.easeInOut, + ) + .rotate(0.12, alignment: Alignment.topCenter) + .step( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOutBack, + ) + .rotate(0, alignment: Alignment.topCenter) + .timeline(trigger: notifications), + const SizedBox(height: 48), + OutlinedButton( + onPressed: () => setState(() => notifications++), + child: const Text('Notify'), + ), + ], + ), + ); + } +} diff --git a/example/lib/stories/timeline_pulse_button.dart b/example/lib/stories/timeline_pulse_button.dart new file mode 100644 index 0000000..c6df616 --- /dev/null +++ b/example/lib/stories/timeline_pulse_button.dart @@ -0,0 +1,54 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +/// SIMPLE: one effect type, four keyframes, one controller. +/// +/// Every tap restarts the timeline — a trigger is an identity signal. +class TimelinePulseButton extends StatefulWidget { + const TimelinePulseButton({super.key}); + + @override + State createState() => _TimelinePulseButtonState(); +} + +class _TimelinePulseButtonState extends State { + int taps = 0; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'The simplest timeline: scale keyframes 1 → 0.9 → 1.06 → 1.\n' + 'Every tap restarts it from the beginning.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + FilledButton( + onPressed: () => setState(() => taps++), + child: Text('Saved $taps times'), + ) + .scale(1) + .step( + duration: const Duration(milliseconds: 90), + curve: Curves.easeOut, + ) + .scale(0.9) + .step( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOutBack, + ) + .scale(1.06) + .step( + duration: const Duration(milliseconds: 120), + curve: Curves.easeInOut, + ) + .scale(1) + .timeline(trigger: taps), + ], + ), + ); + } +} diff --git a/example/lib/stories/timeline_rocket_launch.dart b/example/lib/stories/timeline_rocket_launch.dart new file mode 100644 index 0000000..a5ae876 --- /dev/null +++ b/example/lib/stories/timeline_rocket_launch.dart @@ -0,0 +1,137 @@ +import 'dart:async'; + +import 'package:hyper_effects/hyper_effects.dart'; +import 'package:material_ui/material_ui.dart'; + +/// VERY COMPLEX: a five-track launch sequence — pre-ignition shake, crouch, +/// liftoff, and burn-out — with a full external transport: play, pause, +/// reverse, restart, and a scrubber that seeks the timeline by hand. +class TimelineRocketLaunch extends StatefulWidget { + const TimelineRocketLaunch({super.key}); + + @override + State createState() => _TimelineRocketLaunchState(); +} + +class _TimelineRocketLaunchState extends State { + final TimelineController controller = TimelineController(); + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'A launch sequence on one timeline: shake, crouch, liftoff,\n' + 'burn-out. Scrub it, pause it, or play it backwards.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + SizedBox( + height: 320, + child: Align( + alignment: Alignment.bottomCenter, + child: const Icon(Icons.rocket, size: 96) + // Keyframe 0: on the pad. + .translateXY(0, 0) + .scale(1) + .rotate(0) + .opacity(1) + .step(duration: const Duration(milliseconds: 120)) + // Pre-ignition shake: left... + .translateXY(-4, 0) + .step(duration: const Duration(milliseconds: 120)) + // ...right... + .translateXY(4, 0) + .step( + duration: const Duration(milliseconds: 100), + curve: Curves.easeIn, + ) + // ...and a crouch before the jump. + .translateXY(0, 0) + .scale(1.06) + .step( + duration: const Duration(milliseconds: 700), + curve: Curves.easeInQuart, + delay: const Duration(milliseconds: 100), + ) + // Liftoff: accelerate away, shrinking with distance. + .translateXY(0, -240) + .scale(0.72) + .rotate(0.06) + .step( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOut, + ) + // Burn-out: gone. + .translateXY(0, -400) + .scale(0.45) + .opacity(0) + .timeline(controller: controller), + ), + ), + const SizedBox(height: 24), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Play', + icon: const Icon(Icons.play_arrow), + onPressed: () => unawaited(controller.play()), + ), + IconButton( + tooltip: 'Pause', + icon: const Icon(Icons.pause), + onPressed: controller.pause, + ), + IconButton( + tooltip: 'Reverse', + icon: const Icon(Icons.fast_rewind), + onPressed: () => unawaited(controller.reverse()), + ), + IconButton( + tooltip: 'Restart', + icon: const Icon(Icons.replay), + onPressed: () { + controller.seek(0); + unawaited(controller.play()); + }, + ), + ], + ), + ListenableBuilder( + listenable: controller, + builder: (context, _) { + final progress = + controller.isAttached ? controller.progress : 0.0; + return SizedBox( + width: 400, + child: Row( + children: [ + Expanded( + child: Slider( + value: progress, + onChanged: controller.seek, + ), + ), + SizedBox( + width: 48, + child: Text('${(progress * 100).round()}%'), + ), + ], + ), + ); + }, + ), + ], + ), + ); + } +} diff --git a/example/lib/stories/timeline_toast.dart b/example/lib/stories/timeline_toast.dart new file mode 100644 index 0000000..59cde78 --- /dev/null +++ b/example/lib/stories/timeline_toast.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:material_ui/material_ui.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +/// COMPLEX: multi-track entrance choreography (slide + fade + scale with +/// distinct curves), driven imperatively — dismissal is the same timeline +/// played in reverse via a [TimelineController]. +class TimelineToast extends StatefulWidget { + const TimelineToast({super.key}); + + @override + State createState() => _TimelineToastState(); +} + +class _TimelineToastState extends State { + final TimelineController controller = TimelineController(); + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Three tracks — slide, fade, scale — choreographed on one\n' + 'timeline. Dismiss plays the same timeline backwards.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + Material( + elevation: 6, + borderRadius: BorderRadius.circular(12), + color: Theme.of(context).colorScheme.inverseSurface, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + child: Text( + 'Changes saved', + style: TextStyle( + color: Theme.of(context).colorScheme.onInverseSurface, + ), + ), + ), + ) + // Keyframe 0: parked below, invisible, slightly shrunken. + .translateY(80) + .opacity(0) + .scale(0.92) + .step( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutQuart, + ) + // Keyframe 1: overshoots a touch above its seat, fully + // opaque. Opacity is absent from keyframe 2 and carries + // forward automatically. + .translateY(-8) + .opacity(1) + .scale(1.03) + .step( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOutBack, + ) + // Keyframe 2: seated. + .translateY(0) + .scale(1) + .timeline(controller: controller), + const SizedBox(height: 48), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + FilledButton( + onPressed: () => unawaited(controller.play()), + child: const Text('Show'), + ), + const SizedBox(width: 16), + OutlinedButton( + onPressed: () => unawaited(controller.reverse()), + child: const Text('Dismiss'), + ), + ], + ), + ], + ), + ); + } +} diff --git a/example/lib/stories/windows_settings_transition.dart b/example/lib/stories/windows_settings_transition.dart index a2d3d49..13438e2 100644 --- a/example/lib/stories/windows_settings_transition.dart +++ b/example/lib/stories/windows_settings_transition.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:hyper_effects/hyper_effects.dart'; class WindowsSettingsTransition extends StatefulWidget { @@ -35,8 +35,8 @@ class _WindowsSettingsTransitionState extends State { gradient: RadialGradient( focalRadius: 10000, colors: [ - Colors.white.withOpacity(0.5), - Colors.white.withOpacity(0), + Colors.white.withValues(alpha: 0.5), + Colors.white.withValues(alpha: 0), ], ), ), @@ -71,8 +71,8 @@ class _WindowsSettingsTransitionState extends State { decoration: BoxDecoration( gradient: RadialGradient( colors: [ - Colors.white.withOpacity(0.2), - Colors.white.withOpacity(0), + Colors.white.withValues(alpha: 0.2), + Colors.white.withValues(alpha: 0), ], ), ), diff --git a/example/lib/story.dart b/example/lib/story.dart index af1b807..a04157a 100644 --- a/example/lib/story.dart +++ b/example/lib/story.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; class Story { final String title; diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index b8e2b22..724bb2a 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,10 +5,8 @@ import FlutterMacOS import Foundation -import path_provider_foundation import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/example/macos/Podfile b/example/macos/Podfile index c795730..b15254e 100644 --- a/example/macos/Podfile +++ b/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '12.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj index df9b74f..8106982 100644 --- a/example/macos/Runner.xcodeproj/project.pbxproj +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; F2445E0FBADA22DBAECF88BF /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 24E95C664D0B1B1E9C8D1567 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ @@ -83,6 +84,7 @@ 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; 6AF86FC0D986B41DFB12F321 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; A02F36C029429126D3B2C5AA /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; @@ -103,6 +105,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, F2445E0FBADA22DBAECF88BF /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -178,6 +181,7 @@ 33CEB47122A05771004F2AC0 /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, @@ -240,7 +244,6 @@ 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, - DC25671DD086C637864AD21C /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -248,6 +251,9 @@ 33CC11202044C79F0003C045 /* PBXTargetDependency */, ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 33CC10ED2044A3C60003C045 /* hyper_effects_demo.app */; productType = "com.apple.product-type.application"; @@ -292,6 +298,9 @@ Base, ); mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -383,23 +392,6 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; - DC25671DD086C637864AD21C /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; FFDE6EBFA831BB29157AA743 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -557,7 +549,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -639,7 +631,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -689,7 +681,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -796,6 +788,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; } diff --git a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 89451a0..af3ad4d 100644 --- a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + diff --git a/example/pubspec.yaml b/example/pubspec.yaml index b07efbc..c855897 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -10,18 +10,19 @@ environment: dependencies: flutter: sdk: flutter - adaptive_theme: ^3.6.0 - flutter_box_transform: ^0.4.6 + material_ui: ^1.0.0 + adaptive_theme: ^3.8.0 + flutter_box_transform: ^0.4.7 hyper_effects: path: ../ - google_fonts: ^6.2.1 - unicode_emojis: ^0.4.0 - smooth_page_indicator: ^1.2.0+3 + google_fonts: ^8.2.1 + unicode_emojis: ^0.5.1 + smooth_page_indicator: ^2.0.1 dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 flutter: uses-material-design: true diff --git a/example/test/edge_case_stories_test.dart b/example/test/edge_case_stories_test.dart new file mode 100644 index 0000000..ae12fc6 --- /dev/null +++ b/example/test/edge_case_stories_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'storyboard_harness.dart'; + +void main() { + testWidgets('edge-case stories live in their own section, listed last', + (tester) async { + await pumpStoryboard(tester); + + // The showcase sections render first; the Edge Cases section starts + // below the fold of the drawer list. + expect(find.text('Animations'), findsOneWidget); + expect(find.text('Edge Cases'), findsNothing, + reason: 'low-priority edge cases must come after every showcase ' + 'section, below the initial fold'); + + await revealInDrawer(tester, 'Edge Cases'); + for (final story in [ + 'Interrupted Delay Hold', + 'Queued Run Isolation', + 'Spring Lifecycle', + 'Layout Spring Safety', + ]) { + await revealInDrawer(tester, story); + } + }); + + for (final (title, controls) in [ + ( + 'Interrupted Delay Hold', + ['Retrigger same target', 'Retarget opposite side', 'READY'], + ), + ( + 'Queued Run Isolation', + ['Quick · once', 'Bounce · reverse', 'Slow · repeat'], + ), + ( + 'Spring Lifecycle', + ['Play + reverse', 'Retarget now', 'Reset mid-reverse'], + ), + ( + 'Layout Spring Safety', + ['SIZE', 'PADDING', 'ALIGN FACTOR', 'Overshoot outward'], + ), + ]) { + testWidgets('$title story is registered and interactive', (tester) async { + await pumpStoryboard(tester); + await openStory(tester, title); + for (final control in controls) { + expect(find.text(control), findsOneWidget); + } + }); + } +} diff --git a/example/test/legacy_story_migration_test.dart b/example/test/legacy_story_migration_test.dart new file mode 100644 index 0000000..1a5288c --- /dev/null +++ b/example/test/legacy_story_migration_test.dart @@ -0,0 +1,85 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; +import 'package:hyper_effects_demo/stories/shake_and_spring_animation.dart'; +import 'package:hyper_effects_demo/stories/timeline_journey.dart'; + +/// Composite translation of every Transform wrapping [target]. +Offset compositeTranslationOf(WidgetTester tester, Finder target) { + final transforms = tester.widgetList( + find.ancestor(of: target, matching: find.byType(Transform)), + ); + var dx = 0.0; + var dy = 0.0; + for (final t in transforms) { + dx += t.transform.entry(0, 3); + dy += t.transform.entry(1, 3); + } + return Offset(dx, dy); +} + +void main() { + testWidgets('journey story loops on a timeline, no reset machinery', + (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: TimelineJourney())), + ); + + // ignore: deprecated_member_use + expect(find.byType(ResetAllAnimationsEffect), findsNothing, + reason: 'the deprecated reset-loop machinery must be gone'); + expect(find.byType(TimelineEffect), findsOneWidget); + + final image = find.byType(Image); + // Auto-plays: position changes over time. + await tester.pump(const Duration(milliseconds: 175)); + final early = compositeTranslationOf(tester, image); + await tester.pump(const Duration(milliseconds: 350)); + final later = compositeTranslationOf(tester, image); + expect((later - early).distance, greaterThan(10), + reason: 'the journey must be in motion on its own'); + + // Still in motion a full circuit later: it loops. + await tester.pump(const Duration(milliseconds: 1400)); + final loop = compositeTranslationOf(tester, image); + await tester.pump(const Duration(milliseconds: 350)); + final loopLater = compositeTranslationOf(tester, image); + expect((loopLater - loop).distance, greaterThan(10), + reason: 'the journey must keep looping'); + }); + + testWidgets('spring story drops and springs back on a timeline', + (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SpringAnimation())), + ); + + // ignore: deprecated_member_use + expect(find.byType(ResetAllAnimationsEffect), findsNothing, + reason: 'the deprecated reset-loop machinery must be gone'); + expect(find.byType(TimelineEffect), findsOneWidget); + + final image = find.byType(Image); + expect(compositeTranslationOf(tester, image).dy, closeTo(0, 1), + reason: 'at rest the ball sits at its origin'); + + await tester.tap(find.byType(GestureDetector).first, warnIfMissed: false); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1000)); + expect(compositeTranslationOf(tester, image).dy, greaterThan(50), + reason: 'the ball must be falling'); + + // Fall (2000ms) + spring (450ms) + slack, in frame-sized steps because + // the idle shake loop never settles. + for (var i = 0; i < 100; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect(compositeTranslationOf(tester, image).dy.abs(), lessThan(1), + reason: 'the ball must spring back to its origin'); + + // The idle shake's .animate(delay:) schedules a Future.delayed per loop + // cycle; unmount and advance the clock so no timer outlives the test. + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 2)); + }); +} diff --git a/example/test/shake_startup_test.dart b/example/test/shake_startup_test.dart new file mode 100644 index 0000000..73ab9a9 --- /dev/null +++ b/example/test/shake_startup_test.dart @@ -0,0 +1,45 @@ +import 'dart:math'; + +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects_demo/stories/shake_and_spring_animation.dart'; + +/// Largest absolute rotation applied by any Transform wrapping [target]. +double maxRotationOf(WidgetTester tester, Finder target) { + final transforms = tester.widgetList( + find.ancestor(of: target, matching: find.byType(Transform)), + ); + var maxRotation = 0.0; + for (final t in transforms) { + final m = t.transform; + maxRotation = max(maxRotation, atan2(m.entry(1, 0), m.entry(0, 0)).abs()); + } + return maxRotation; +} + +void main() { + testWidgets('the idle shake runs on mount, before any interaction', + (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SpringAnimation())), + ); + + // The shake starts after its 1s delay; sample frames past it and + // record the strongest rotation seen. + await tester.pump(const Duration(milliseconds: 1100)); + var strongest = 0.0; + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 30)); + strongest = max( + strongest, + maxRotationOf(tester, find.byType(Image)), + ); + } + expect(strongest, greaterThan(0.01), + reason: 'the idle shake must be visibly running without any tap'); + + // Drain the shake's per-cycle Future.delayed before the test ends. + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 2)); + }); +} diff --git a/example/test/spring_motion_story_test.dart b/example/test/spring_motion_story_test.dart new file mode 100644 index 0000000..78e3f31 --- /dev/null +++ b/example/test/spring_motion_story_test.dart @@ -0,0 +1,75 @@ +import 'dart:math'; + +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects_demo/stories/spring_motion.dart'; + +double compositeTranslationXOf(WidgetTester tester, Finder target) { + final transforms = tester.widgetList( + find.ancestor(of: target, matching: find.byType(Transform)), + ); + var dx = 0.0; + for (final t in transforms) { + dx += t.transform.entry(0, 3); + } + return dx; +} + +double compositeScaleOf(WidgetTester tester, Finder target) { + final transforms = tester.widgetList( + find.ancestor(of: target, matching: find.byType(Transform)), + ); + var scale = 1.0; + for (final t in transforms) { + final m = t.transform; + scale *= Offset(m.entry(0, 0), m.entry(1, 0)).distance; + } + return scale; +} + +void main() { + testWidgets('the toggle slides with a spring: overshoots, settles exactly', + (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SpringMotionStory())), + ); + final avatar = find.byType(CircleAvatar); + final restLeft = compositeTranslationXOf(tester, avatar); + + await tester.tap(find.text('Toggle')); + await tester.pump(); + var peak = restLeft; + for (var i = 0; i < 80; i++) { + await tester.pump(const Duration(milliseconds: 16)); + peak = max(peak, compositeTranslationXOf(tester, avatar)); + } + await tester.pumpAndSettle(); + final restRight = compositeTranslationXOf(tester, avatar); + + expect(restRight, greaterThan(restLeft + 100), + reason: 'the avatar must travel to the right anchor'); + expect(peak, greaterThan(restRight + 1), + reason: 'a bouncy spring must overshoot its anchor'); + }); + + testWidgets('the badge pops on a spring-driven timeline step', + (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SpringMotionStory())), + ); + final badge = find.byIcon(Icons.celebration); + + await tester.tap(find.text('Pop')); + await tester.pump(); + var peak = 0.0; + for (var i = 0; i < 80; i++) { + await tester.pump(const Duration(milliseconds: 16)); + peak = max(peak, compositeScaleOf(tester, badge)); + } + expect(peak, greaterThan(1.001), + reason: 'the spring pop must overshoot full size'); + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, badge), closeTo(1.0, 1e-9), + reason: 'the pop must rest at exactly full size'); + }); +} diff --git a/example/test/story_behavior_test.dart b/example/test/story_behavior_test.dart new file mode 100644 index 0000000..0e2698f --- /dev/null +++ b/example/test/story_behavior_test.dart @@ -0,0 +1,320 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'storyboard_harness.dart'; + +void main() { + // Note: a 0ms gap (two taps in one frame) is deliberately absent — two + // setStates in the same frame coalesce into a single rebuild and therefore + // a single trigger change; only the last-pressed run exists. That is + // correct Flutter semantics, not a queueing bug. + for (final gapMs in [30, 120, 350, 600, 1000]) { + testWidgets('queued runs pressed ${gapMs}ms apart all complete', + (tester) async { + await pumpStoryboard(tester); + await openStory(tester, 'Queued Run Isolation'); + + await tester.tap(find.text('Quick · once')); + await tester.pump(Duration(milliseconds: gapMs)); + await tester.tap(find.text('Bounce · reverse')); + + for (var i = 0; i < 15; i++) { + await tester.pump(const Duration(milliseconds: 300)); + } + await tester.pump(); + + expect(find.text('1 · Quick'), findsOneWidget, + reason: 'gap=$gapMs: Quick must complete'); + expect(find.text('2 · Bounce'), findsOneWidget, + reason: 'gap=$gapMs: Bounce must complete after Quick'); + + await tester.tap(find.text('Quick · once')); + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 300)); + } + await tester.pump(); + expect(find.text('3 · Quick'), findsOneWidget, + reason: 'gap=$gapMs: the queue must not deadlock'); + }); + } + + testWidgets('queued handoff repaints from its explicit start', + (tester) async { + // Real frames practically never land on a run's exact final value: the + // simulation completes between frames and the next queued run begins in + // the same frame's microtasks. Misaligned pump steps reproduce that. + await pumpStoryboard(tester); + await openStory(tester, 'Queued Run Isolation'); + + final marker = find.byKey(const Key('queue-marker')); + final restingX = tester.getCenter(marker).dx; + double x() => tester.getCenter(marker).dx; + + await tester.tap(find.text('Quick · once')); + await tester.pump(const Duration(milliseconds: 400)); // late in Quick + await tester.tap(find.text('Bounce · reverse')); + + // Sample through Bounce (starts ~450ms, fwd 700ms, rev 700ms) on steps + // that never align with a leg boundary. + var minX = double.infinity; + var maxX = -double.infinity; + for (var i = 0; i < 24; i++) { + await tester.pump(const Duration(milliseconds: 97)); + final v = x(); + if (v < minX) minX = v; + if (v > maxX) maxX = v; + } + + expect(minX, lessThan(restingX + 60), + reason: 'Bounce restarts from its explicit `from: 0`, so the marker ' + 'must return near the left edge (resting=$restingX min=$minX)'); + expect(maxX - minX, greaterThan(200), + reason: 'Bounce must visibly traverse the lane, not hover near the ' + 'end (range=${maxX - minX})'); + + // The next press must also repaint from the start. + await tester.tap(find.text('Quick · once')); + await tester.pump(const Duration(milliseconds: 30)); + var min2 = double.infinity; + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 61)); + if (x() < min2) min2 = x(); + } + expect(min2, lessThan(restingX + 60), + reason: 'later queued runs must keep repainting from the start'); + }); + + testWidgets('queued runs pressed during each Bounce leg all complete', + (tester) async { + await pumpStoryboard(tester); + await openStory(tester, 'Queued Run Isolation'); + + await tester.tap(find.text('Bounce · reverse')); + await tester.pump(const Duration(milliseconds: 300)); // mid forward leg + await tester.tap(find.text('Quick · once')); + await tester.pump(const Duration(milliseconds: 500)); // mid reverse leg + await tester.tap(find.text('Slow · repeat')); + + for (var i = 0; i < 20; i++) { + await tester.pump(const Duration(milliseconds: 300)); + } + await tester.pump(); + + expect(find.text('1 · Bounce'), findsOneWidget); + expect(find.text('2 · Quick'), findsOneWidget); + expect(find.text('3 · Slow'), findsOneWidget); + }); + + testWidgets('spring lifecycle play+reverse rests at start', (tester) async { + await pumpStoryboard(tester); + await openStory(tester, 'Spring Lifecycle'); + + final marker = find.byKey(const Key('spring-lifecycle-marker')); + final restingX = tester.getCenter(marker).dx; + + await tester.tap(find.text('Play + reverse')); + await tester.pump(); + + double x() => tester.getCenter(marker).dx; + + var maxX = restingX; + for (var i = 0; i < 40; i++) { + await tester.pump(const Duration(milliseconds: 50)); + if (x() > maxX) maxX = x(); + } + expect(maxX, greaterThan(restingX + 200), + reason: 'forward leg must travel toward the right'); + + for (var i = 0; i < 40; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + await tester.pump(); + + expect((x() - restingX).abs(), lessThan(1), + reason: 'after play+reverse the marker must rest at the start, ' + 'not jump to the end. resting=$restingX final=${x()}'); + }); + + testWidgets('layout safety lanes are VISIBLY animated and survive re-presses', + (tester) async { + await pumpStoryboard(tester); + await openStory(tester, 'Layout Spring Safety'); + + final sizeBox = find.byKey(const Key('safety-size-box')); + // The lanes must paint their changing geometry: transparent padding + // around a centered box and a bare Align factor are invisible. Each + // lane exposes a keyed, decorated frame whose size IS the animated + // geometry. + final padFrame = find.byKey(const Key('safety-padding-frame')); + final alignFrame = find.byKey(const Key('safety-align-frame')); + + expect(padFrame, findsOneWidget, + reason: 'the padding lane needs a visible frame painting the insets'); + expect(alignFrame, findsOneWidget, + reason: 'the align lane needs a visible frame painting the factor'); + + double sizeW() => tester.getSize(sizeBox).width; + double padW() => tester.getSize(padFrame).width; + double alignW() => tester.getSize(alignFrame).width; + + final padW0 = padW(); + final alignW0 = alignW(); + + await tester.tap(find.text('Overshoot outward')); + await tester.pump(); + + var sizeMoved = false; + var padMoved = false; + var alignMoved = false; + for (var i = 0; i < 60; i++) { + await tester.pump(const Duration(milliseconds: 50)); + final w = sizeW(); + if (w > 5 && w < 149) sizeMoved = true; + if ((padW() - padW0).abs() > 2) padMoved = true; + if ((alignW() - alignW0).abs() > 2) alignMoved = true; + } + + expect(sizeMoved, isTrue, reason: 'SIZE lane must animate visibly'); + expect(padMoved, isTrue, reason: 'PADDING lane must animate visibly'); + expect(alignMoved, isTrue, reason: 'ALIGN lane must animate visibly'); + expect(padW(), moreOrLessEquals(42 + 48, epsilon: 1), + reason: 'padding must settle at 24 per side around the 42px box'); + + // Alternating and repeated presses must keep animating (no deadlock). + await tester.tap(find.text('Shrink through floor')); + for (var i = 0; i < 60; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + await tester.tap(find.text('Overshoot outward')); + await tester.pump(); + var movedAgain = false; + for (var i = 0; i < 60; i++) { + await tester.pump(const Duration(milliseconds: 50)); + final w = sizeW(); + if (w > 5 && w < 149) movedAgain = true; + } + expect(movedAgain, isTrue, + reason: 'SIZE lane must animate on the third press'); + expect(sizeW(), moreOrLessEquals(150, epsilon: 1)); + + // Double-press the same button, then alternate again. + await tester.tap(find.text('Overshoot outward')); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.text('Overshoot outward')); + for (var i = 0; i < 60; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + await tester.tap(find.text('Shrink through floor')); + await tester.pump(); + var shrinkMoved = false; + for (var i = 0; i < 60; i++) { + await tester.pump(const Duration(milliseconds: 50)); + final w = sizeW(); + if (w > 5 && w < 149) shrinkMoved = true; + } + expect(shrinkMoved, isTrue, + reason: 'SIZE lane must animate after double-press of same button'); + expect(sizeW(), lessThan(5), + reason: 'SIZE lane must settle at 0 after shrink'); + }); + + testWidgets('layout safety survives Overshoot spam on misaligned frames', + (tester) async { + await pumpStoryboard(tester); + await openStory(tester, 'Layout Spring Safety'); + + final sizeBox = find.byKey(const Key('safety-size-box')); + final padFrame = find.byKey(const Key('safety-padding-frame')); + double sizeW() => tester.getSize(sizeBox).width; + double padW() => tester.getSize(padFrame).width; + + // Spam the same button with presses landing mid-flight, on pump steps + // that never align with the spring's settling boundary. A zero-delay + // same-target retrigger REPLAYS from its pair start, so every press + // must deterministically restart the lanes — never silently hold. + for (var press = 0; press < 5; press++) { + await tester.tap(find.text('Overshoot outward')); + await tester.pump(const Duration(milliseconds: 16)); + expect(sizeW(), lessThan(40), + reason: 'press ${press + 1} mid-spam must restart the SIZE lane ' + 'from its explicit from: 0 (got ${sizeW()})'); + expect(padW(), lessThan(55), + reason: 'press ${press + 1} mid-spam must restart the PADDING ' + 'lane from its pair start (got ${padW()})'); + await tester.pump(const Duration(milliseconds: 137)); + } + // Settle fully. + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 97)); + } + expect(sizeW(), moreOrLessEquals(150, epsilon: 1)); + expect(padW(), moreOrLessEquals(42 + 48, epsilon: 1)); + + // The implicit-from PADDING lane must also still replay from rest — + // it has no explicit `from` to reseed, so its pair start must survive + // the spam untouched. + await tester.tap(find.text('Overshoot outward')); + var minPadW = double.infinity; + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 16)); + if (padW() < minPadW) minPadW = padW(); + } + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 97)); + if (padW() < minPadW) minPadW = padW(); + } + expect(minPadW, lessThan(55), + reason: 'PADDING must replay from its pair start after spam, not ' + 'stay stuck at the folded end (min=$minPadW)'); + expect(padW(), moreOrLessEquals(42 + 48, epsilon: 1)); + + // A press from rest must still visibly replay across its full range. + // Sample the first frames tightly: a fast spring leaves the origin + // quickly, and a stale folded start would never get near it at all. + await tester.tap(find.text('Overshoot outward')); + var minW = double.infinity; + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 16)); + if (sizeW() < minW) minW = sizeW(); + } + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 97)); + if (sizeW() < minW) minW = sizeW(); + } + expect(minW, lessThan(30), + reason: 'after spam, a fresh press must replay from its explicit ' + 'from: 0, not hover at the folded end (min=$minW)'); + expect(sizeW(), moreOrLessEquals(150, epsilon: 1)); + }); + + testWidgets('spring lifecycle survives Play+reverse spam', (tester) async { + await pumpStoryboard(tester); + await openStory(tester, 'Spring Lifecycle'); + + final marker = find.byKey(const Key('spring-lifecycle-marker')); + final restingX = tester.getCenter(marker).dx; + double x() => tester.getCenter(marker).dx; + + for (var press = 0; press < 4; press++) { + await tester.tap(find.text('Play + reverse')); + await tester.pump(const Duration(milliseconds: 173)); + } + for (var i = 0; i < 40; i++) { + await tester.pump(const Duration(milliseconds: 97)); + } + + // A fresh press from rest must still traverse the lane and rest at start. + await tester.tap(find.text('Play + reverse')); + await tester.pump(const Duration(milliseconds: 30)); + var maxX = -double.infinity; + for (var i = 0; i < 60; i++) { + await tester.pump(const Duration(milliseconds: 97)); + if (x() > maxX) maxX = x(); + } + expect(maxX, greaterThan(restingX + 200), + reason: 'after spam, Play + reverse must still travel right ' + '(resting=$restingX max=$maxX)'); + expect((x() - restingX).abs(), lessThan(1), + reason: 'and rest back at the start (final=${x()})'); + }); +} diff --git a/example/test/storyboard_harness.dart b/example/test/storyboard_harness.dart new file mode 100644 index 0000000..6e259ec --- /dev/null +++ b/example/test/storyboard_harness.dart @@ -0,0 +1,33 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects_demo/main.dart'; + +/// Mounts the storyboard at a fixed 1400x900 desktop viewport. +Future pumpStoryboard(WidgetTester tester) async { + tester.view.physicalSize = const Size(1400, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(const MaterialApp(home: Storyboard())); + await tester.pump(); +} + +/// The drawer's story list — the first scrollable in the storyboard row. +Finder drawerList() => find.byType(Scrollable).first; + +/// Scrolls the drawer until [text] is fully visible and returns its finder. +Future revealInDrawer(WidgetTester tester, String text) async { + final finder = find.text(text); + // scrollUntilVisible builds the lazy list far enough to attach the item; + // ensureVisible then aligns it fully into the viewport so taps land. + await tester.scrollUntilVisible(finder, 200, scrollable: drawerList()); + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + return finder; +} + +/// Opens the story titled [title], scrolling the drawer to it first. +Future openStory(WidgetTester tester, String title) async { + await tester.tap(await revealInDrawer(tester, title)); + await tester.pumpAndSettle(); +} diff --git a/example/test/success_card_test.dart b/example/test/success_card_test.dart new file mode 100644 index 0000000..4fb6f8a --- /dev/null +++ b/example/test/success_card_test.dart @@ -0,0 +1,120 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects_demo/stories/success_card_animation.dart'; + +double compositeScaleOf(WidgetTester tester, Finder target) { + final transforms = tester.widgetList( + find.ancestor(of: target, matching: find.byType(Transform)), + ); + var scale = 1.0; + for (final t in transforms) { + final m = t.transform; + scale *= Offset(m.entry(0, 0), m.entry(1, 0)).distance; + } + return scale; +} + +/// The vertical translation applied to [target] by its Transform ancestors. +double translateYOf(WidgetTester tester, Finder target) { + final transforms = tester.widgetList( + find.ancestor(of: target, matching: find.byType(Transform)), + ); + var dy = 0.0; + for (final t in transforms) { + dy += t.transform.entry(1, 3); + } + return dy; +} + +/// The composite opacity applied to [target] by its Opacity ancestors. +double opacityOf(WidgetTester tester, Finder target) { + final layers = tester.widgetList( + find.ancestor(of: target, matching: find.byType(Opacity)), + ); + var opacity = 1.0; + for (final o in layers) { + opacity *= o.opacity; + } + return opacity; +} + +void main() { + testWidgets('stamp card overshoots on every press, not just the first', + (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SuccessCardAnimation())), + ); + await tester.pump(); + + // The second card's icon is the stamp. + final stampIcon = find.byIcon(Icons.check_circle).last; + final card = find.byType(GestureDetector).last; + + Future press() async { + await tester.tap(card, warnIfMissed: false); + // 175ms into the slam: the stamp must be mid-overshoot. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 175)); + expect( + compositeScaleOf(tester, stampIcon), + greaterThan(1.3), + reason: 'the slam-in must overshoot past 1.3', + ); + // 400ms: inside the hold — the full 1.5 overshoot is on screen. + await tester.pump(const Duration(milliseconds: 225)); + expect(compositeScaleOf(tester, stampIcon), closeTo(1.5, 0.01), + reason: 'the overshoot must hold before settling'); + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, stampIcon), closeTo(1.0, 0.01), + reason: 'the stamp must settle at 1.0'); + } + + Future release() async { + await tester.tap(card, warnIfMissed: false); + await tester.pumpAndSettle(); + } + + await press(); + await release(); + // The second press must behave exactly like the first. + await press(); + }); + + testWidgets('check card mounts hidden and stays put until tapped', + (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SuccessCardAnimation())), + ); + + // The first card's icon is the slide-and-fade check mark. + final checkIcon = find.byIcon(Icons.check_circle).first; + + // Frame one must already be the resting, hidden state. Nothing may + // animate before the user has interacted. + expect(translateYOf(tester, checkIcon), closeTo(100, 0.01), + reason: 'the check mark mounts pushed down out of view'); + expect(opacityOf(tester, checkIcon), closeTo(0, 0.01), + reason: 'the check mark mounts fully transparent'); + + await tester.pump(const Duration(milliseconds: 200)); + expect(translateYOf(tester, checkIcon), closeTo(100, 0.01), + reason: 'an untouched card must not animate on mount'); + expect(opacityOf(tester, checkIcon), closeTo(0, 0.01)); + + // Tapping slides it up and fades it in. + await tester.tap(find.byType(GestureDetector).first, warnIfMissed: false); + await tester.pumpAndSettle(); + expect(translateYOf(tester, checkIcon), closeTo(0, 0.01), + reason: 'completing the card brings the check mark to rest on screen'); + expect(opacityOf(tester, checkIcon), closeTo(1, 0.01)); + + // Tapping again reverses it, with no snap on the way out. + await tester.tap(find.byType(GestureDetector).first, warnIfMissed: false); + await tester.pump(); + expect(translateYOf(tester, checkIcon), closeTo(0, 0.01), + reason: 'the exit starts from where the check mark actually is'); + await tester.pumpAndSettle(); + expect(translateYOf(tester, checkIcon), closeTo(100, 0.01)); + expect(opacityOf(tester, checkIcon), closeTo(0, 0.01)); + }); +} diff --git a/example/test/timeline_stories_test.dart b/example/test/timeline_stories_test.dart new file mode 100644 index 0000000..1af1c18 --- /dev/null +++ b/example/test/timeline_stories_test.dart @@ -0,0 +1,140 @@ +import 'dart:math'; + +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects_demo/stories/timeline_notification_bell.dart'; +import 'package:hyper_effects_demo/stories/timeline_pulse_button.dart'; +import 'package:hyper_effects_demo/stories/timeline_rocket_launch.dart'; +import 'package:hyper_effects_demo/stories/timeline_toast.dart'; + +double compositeScaleOf(WidgetTester tester, Finder target) { + final transforms = tester.widgetList( + find.ancestor(of: target, matching: find.byType(Transform)), + ); + var scale = 1.0; + for (final t in transforms) { + final m = t.transform; + scale *= Offset(m.entry(0, 0), m.entry(1, 0)).distance; + } + return scale; +} + +double compositeRotationOf(WidgetTester tester, Finder target) { + final transforms = tester.widgetList( + find.ancestor(of: target, matching: find.byType(Transform)), + ); + var rotation = 0.0; + for (final t in transforms) { + final m = t.transform; + rotation += atan2(m.entry(1, 0), m.entry(0, 0)); + } + return rotation; +} + +double compositeOpacityOf(WidgetTester tester, Finder target) { + final opacities = tester.widgetList( + find.ancestor(of: target, matching: find.byType(Opacity)), + ); + var opacity = 1.0; + for (final o in opacities) { + opacity *= o.opacity; + } + return opacity; +} + +Widget host(Widget story) => MaterialApp(home: Scaffold(body: story)); + +void main() { + testWidgets('pulse button dips below 1, overshoots, and rests at 1', + (tester) async { + await tester.pumpWidget(host(const TimelinePulseButton())); + final button = find.byType(FilledButton); + + await tester.tap(button); + await tester.pump(); + // Mid first segment: the dip toward 0.9 must be visible. + await tester.pump(const Duration(milliseconds: 45)); + expect(compositeScaleOf(tester, button), lessThan(0.97)); + + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, button), closeTo(1.0, 1e-6)); + + // A second tap restarts and behaves the same. + await tester.tap(button); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 45)); + expect(compositeScaleOf(tester, button), lessThan(0.97)); + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, button), closeTo(1.0, 1e-6)); + }); + + testWidgets('bell swings on notify and settles level', (tester) async { + await tester.pumpWidget(host(const TimelineNotificationBell())); + final bell = find.byIcon(Icons.notifications); + + expect(compositeRotationOf(tester, bell), closeTo(0, 1e-9)); + + await tester.tap(find.text('Notify')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 60)); + expect(compositeRotationOf(tester, bell).abs(), greaterThan(0.05), + reason: 'the bell must visibly swing mid-flight'); + + await tester.pumpAndSettle(); + expect(compositeRotationOf(tester, bell), closeTo(0, 1e-6), + reason: 'the bell must settle level'); + expect(find.text('1'), findsOneWidget, reason: 'badge counts'); + }); + + testWidgets('toast shows on play and dismisses in reverse', (tester) async { + await tester.pumpWidget(host(const TimelineToast())); + final toast = find.text('Changes saved'); + + // Hidden at rest: the first keyframe declares opacity 0. + expect(compositeOpacityOf(tester, toast), closeTo(0, 1e-9)); + + await tester.tap(find.text('Show')); + await tester.pumpAndSettle(); + expect(compositeOpacityOf(tester, toast), closeTo(1, 1e-6)); + + await tester.tap(find.text('Dismiss')); + await tester.pumpAndSettle(); + expect(compositeOpacityOf(tester, toast), closeTo(0, 1e-6), + reason: 'dismiss reverses the same timeline back to hidden'); + }); + + testWidgets('rocket transport: play advances, pause holds, scrub seeks', + (tester) async { + await tester.pumpWidget(host(const TimelineRocketLaunch())); + double sliderValue() => tester.widget(find.byType(Slider)).value; + + expect(sliderValue(), 0); + + await tester.tap(find.byTooltip('Play')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + final advanced = sliderValue(); + expect(advanced, greaterThan(0), reason: 'play must advance progress'); + + await tester.tap(find.byTooltip('Pause')); + await tester.pump(); + final held = sliderValue(); + await tester.pump(const Duration(milliseconds: 300)); + expect(sliderValue(), held, reason: 'pause must hold progress'); + + // Scrub to the end: the rocket has burned out (faded away). + final rocket = find.byIcon(Icons.rocket); + await tester.drag(find.byType(Slider), const Offset(400, 0)); + await tester.pump(); + expect(sliderValue(), closeTo(1, 0.01)); + expect(compositeOpacityOf(tester, rocket), lessThan(0.05), + reason: 'the end of the timeline is a burned-out rocket'); + + // Restart rewinds and plays again from the beginning. + await tester.tap(find.byTooltip('Restart')); + await tester.pump(); + expect(sliderValue(), lessThan(0.05)); + await tester.pumpAndSettle(); + expect(sliderValue(), closeTo(1, 1e-6)); + }); +} diff --git a/lib/hyper_effects.dart b/lib/hyper_effects.dart index 7a322b4..a4ebc84 100644 --- a/lib/hyper_effects.dart +++ b/lib/hyper_effects.dart @@ -1,15 +1,17 @@ library; export 'src/animated_effect.dart'; -export 'src/animated_group_effect.dart'; export 'src/animation_config.dart'; -export 'src/animation_retainer.dart'; export 'src/apple_curves.dart'; export 'src/effect_query.dart'; export 'src/effect_widget.dart'; export 'src/effects/effects.dart'; +export 'src/motion/motion.dart'; export 'src/extensions.dart'; export 'src/pointer_transition.dart'; export 'src/post_frame_widget.dart'; export 'src/scroll_phase.dart'; export 'src/scroll_transition.dart'; +export 'src/timeline/timeline_controller.dart'; +export 'src/timeline/timeline_effect.dart'; +export 'src/timeline/timeline_spec.dart'; diff --git a/lib/src/animated_effect.dart b/lib/src/animated_effect.dart index b5bc655..660e135 100644 --- a/lib/src/animated_effect.dart +++ b/lib/src/animated_effect.dart @@ -1,28 +1,13 @@ import 'package:flutter/widgets.dart'; import '../hyper_effects.dart'; +import 'utils.dart'; /// A callback that returns whether an animation should be allowed /// to follow through with its animation or be skipped completely, /// even when explicitly triggered. typedef BooleanCallback = bool Function(); -/// Represents the different ways that can trigger an animation. -enum AnimationTriggerType { - /// The animation is triggered by a [trigger] parameter. - trigger, - - /// The animation is fired immediately when the widget is built. - oneShot, - - /// The animation stays idle until the last animation in the chain. - /// This does not give this effect autonomy over its animations, rather - /// it stays idle. The last animation that plays will look up the - /// ancestor tree for the next [AnimatedEffect] and trigger it - /// manually if `shouldTriggerAfterLast` is true. - afterLast; -} - /// Provides extension methods for [Widget] to animate it's appearance. extension AnimatedEffectExt on Widget? { /// Animate the effects applied to this widget. @@ -30,6 +15,13 @@ extension AnimatedEffectExt on Widget? { /// The [trigger] parameter is used to trigger the animation. As long as the /// value of [trigger] is the same, the animation will not be triggered again. /// + /// Passing the sentinel `#immediate` as the [trigger] plays the animation + /// as soon as it is mounted, once per State lifetime. Rebuilds do not + /// replay it, since the sentinel's identity never changes. [immediate] is + /// a shorthand for exactly this. + /// + /// The [key] parameter is forwarded to the underlying [AnimatedEffect]. + /// /// The [duration] parameter is used to set the duration of the animation. /// /// The [curve] parameter is used to set the curve of the animation. @@ -44,17 +36,28 @@ extension AnimatedEffectExt on Widget? { /// play backwards after each repetition. /// /// The [delay] parameter is used to set a delay before the animation starts. + /// Throughout the wait the effects are held at the values the run is about + /// to start from — the delay is dead time, not a preview of the target. A + /// re-trigger that lands mid-flight therefore freezes wherever it was + /// interrupted and continues from there once the wait is over. When + /// [repeat] is set, every repetition waits out its own delay, holding at + /// the value that repetition begins at. /// /// The [resetValues] parameter is used to determine whether the animation /// should start from idle values or from the current state of the widget. /// - /// The [interruptable] parameter is used to determine whether the animation - /// should be reset on subsequent triggers. If this animation is re-triggered, - /// it will reset the current active animation and re-drive from the - /// beginning. + /// The [interruptable] parameter is used to determine how a re-trigger is + /// handled while an animation is still in flight. When true (the default), + /// the in-flight animation is interrupted and re-driven from the beginning + /// right away. When false, the new run waits for the in-flight one to + /// finish before it starts. /// /// The [startState] parameter is used to determine the behavior of the /// animation as soon as it is added to the widget tree. + /// [AnimationStartState.lazy], the default, holds the effects at their + /// starting values until [trigger] changes for the first time. + /// [AnimationStartState.eager] plays the animation once on mount and then + /// keeps following [trigger] as usual. /// /// The [playIf] parameter is used to determine whether the animation should /// be played or skipped. If the callback returns false, the animation will @@ -65,24 +68,25 @@ extension AnimatedEffectExt on Widget? { /// animation to the ending values. Widget animate({ required Object? trigger, - Duration duration = const Duration(milliseconds: 350), - Curve curve = appleEaseInOut, + Key? key, + Duration? duration, + Curve? curve, + Motion? motion, int repeat = 0, bool reverse = false, bool resetValues = false, bool interruptable = true, Duration delay = Duration.zero, - AnimationStartState startState = AnimationStartState.idle, + AnimationStartState startState = AnimationStartState.lazy, VoidCallback? onEnd, BooleanCallback? playIf, BooleanCallback? skipIf, AnimationBehavior? animationBehavior, }) { return AnimatedEffect( - triggerType: AnimationTriggerType.trigger, + key: key, trigger: trigger, - duration: duration, - curve: curve, + motion: Utils.resolveMotion(motion, duration, curve), repeat: repeat, reverse: reverse, resetValues: resetValues, @@ -97,85 +101,19 @@ extension AnimatedEffectExt on Widget? { ); } - /// Animate the effects applied to this widget after the last animation - /// in the chain ends. - /// - /// Unlike [animate], this method does not trigger the animation immediately - /// and instead waits for the last animation in the chain to end. - /// This does not give this effect autonomy over its animations, rather - /// it stays idle. The last animation that plays will look up the - /// ancestor tree for the next [AnimatedEffect] and trigger it - /// manually if `shouldTriggerAfterLast` is true. - /// - /// The [duration] parameter is used to set the duration of the animation. - /// - /// The [curve] parameter is used to set the curve of the animation. - /// - /// The [onEnd] parameter is used to set a callback that is called when the - /// animation ends. - /// - /// The [repeat] parameter is used to determine how the animation should be - /// repeated. - /// - /// The [reverse] parameter is used to determine whether the animation should - /// play backwards after each repetition. - /// - /// The [resetValues] parameter is used to determine whether the animation - /// should start from idle values or from the current state of the widget. - /// If set to true, the animation will always animate from the initial - /// default state of an effect towards the current state. - /// When false, the animation will animate from the previous effect state - /// towards the current state. + /// Animate the effects applied to this widget. /// - /// The [interruptable] parameter is used to determine whether the animation - /// should be reset on subsequent triggers. If this animation is re-triggered, - /// it will reset the current active animation and re-drive from the - /// beginning. + /// Unlike [animate], this method takes no [trigger]: it plays the animation + /// as soon as it is mounted, once per [State] lifetime. It is shorthand for + /// `animate(trigger: #immediate)`, and since the sentinel's identity never + /// changes, rebuilds do not replay it. /// - /// The [delay] parameter is used to set a delay before the animation starts. + /// There is no `startState` parameter here: play-on-mount is what this + /// method already expresses. To play on mount AND keep a live trigger for + /// later replays, use `animate(trigger: ..., startState: + /// AnimationStartState.eager)` instead. /// - /// The [playIf] parameter is used to determine whether the animation should - /// be played or skipped. If the callback returns false, the animation will - /// be skipped, even when it is explicitly triggered. - /// - /// The [skipIf] parameter is used to determine whether the animation should - /// be skipped by setting the animation value to 1, effectively skipping the - /// animation to the ending values. - Widget animateAfter({ - Duration duration = const Duration(milliseconds: 350), - Curve curve = appleEaseInOut, - int repeat = 0, - bool reverse = false, - bool resetValues = false, - Duration delay = Duration.zero, - AnimationStartState startState = AnimationStartState.idle, - VoidCallback? onEnd, - BooleanCallback? playIf, - BooleanCallback? skipIf, - AnimationBehavior? animationBehavior, - }) { - return AnimatedEffect( - triggerType: AnimationTriggerType.afterLast, - trigger: false, - duration: duration, - curve: curve, - repeat: repeat, - reverse: reverse, - resetValues: resetValues, - interruptable: true, - delay: delay, - onEnd: onEnd, - playIf: playIf, - skipIf: skipIf, - animationBehavior: animationBehavior, - child: this, - ); - } - - /// Animate the effects applied to this widget. - /// - /// Unlike [animate], this method triggers the animation immediately without - /// a [trigger] parameter. + /// The [key] parameter is forwarded to the underlying [AnimatedEffect]. /// /// The [duration] parameter is used to set the duration of the animation. /// @@ -197,12 +135,17 @@ extension AnimatedEffectExt on Widget? { /// When false, the animation will animate from the previous effect state /// towards the current state. /// - /// The [interruptable] parameter is used to determine whether the animation - /// should be reset on subsequent triggers. If this animation is re-triggered, - /// it will reset the current active animation and re-drive from the - /// beginning. + /// The [interruptable] parameter is used to determine how a re-trigger is + /// handled while an animation is still in flight. When true (the default), + /// the in-flight animation is interrupted and re-driven from the beginning + /// right away. When false, the new run waits for the in-flight one to + /// finish before it starts. /// /// The [delay] parameter is used to set a delay before the animation starts. + /// Throughout the wait the effects are held at the values the run is about + /// to start from — the delay is dead time, not a preview of the target. + /// When [repeat] is set, every repetition waits out its own delay, holding + /// at the value that repetition begins at. /// /// The [playIf] parameter is used to determine whether the animation should /// be played or skipped. If the callback returns false, the animation will @@ -211,10 +154,11 @@ extension AnimatedEffectExt on Widget? { /// The [skipIf] parameter is used to determine whether the animation should /// be skipped by setting the animation value to 1, effectively skipping the /// animation to the ending values. - AnimatedEffect oneShot({ + AnimatedEffect immediate({ Key? key, - Duration duration = const Duration(milliseconds: 350), - Curve curve = appleEaseInOut, + Duration? duration, + Curve? curve, + Motion? motion, int repeat = 0, bool reverse = false, bool resetValues = false, @@ -227,9 +171,8 @@ extension AnimatedEffectExt on Widget? { }) { return AnimatedEffect( key: key, - triggerType: AnimationTriggerType.oneShot, - duration: duration, - curve: curve, + trigger: #immediate, + motion: Utils.resolveMotion(motion, duration, curve), onEnd: onEnd, repeat: repeat, reverse: reverse, @@ -245,27 +188,36 @@ extension AnimatedEffectExt on Widget? { /// Resets all animations in the chain by going down /// the children tree and resetting all animations. + @Deprecated( + 'Auto-resetting chains fire on every completion and cross-talk between ' + 'unrelated animations. Loop with .timeline(repeat:) or rewind with ' + 'TimelineController.seek(0) instead. Will be removed in 0.5.0.', + ) + // ignore: deprecated_member_use_from_same_package Widget resetAll() => ResetAllAnimationsEffect(child: this); } /// Determines the behavior of the [AnimatedEffect] as soon as it is added /// to the widget tree. +/// +/// Both values insert the widget at its STARTING values. They differ only in +/// whether the animation runs on mount without waiting for a trigger change. enum AnimationStartState { - /// As soon as the animation is inserted into the widget tree, it will - /// start playing the animation from the beginning to the end. Before - /// the trigger Object changes. - playImmediately, - - /// The animation will be inserted into the widget tree but will not - /// start playing. It will instead trigger the effects in the chain - /// to their ending values as soon as it is inserted. - /// The "current" values of the effect chain are used - /// immediately as initial values. - useCurrentValues, - - /// As soon as the animation is inserted into the widget tree, none of - /// the effects will be triggered in any state. - idle; + /// The widget is inserted at its STARTING values and immediately plays + /// through to its ending values, once per [State] lifetime. Rebuilds do not + /// replay it. + /// + /// The [AnimatedEffect.trigger] stays live: every subsequent change of it + /// plays the animation again, exactly as it would under [lazy]. This is the + /// difference from `trigger: #immediate`, which pins the trigger to a + /// sentinel whose identity never changes and therefore never replays. + eager, + + /// The widget is inserted inert, with its effects held at their STARTING + /// values: the internal controller starts at 0. + /// + /// Nothing animates until the animation is triggered at least once. + lazy; } /// A widget that animates the effects applied to it's child. @@ -275,20 +227,20 @@ class AnimatedEffect extends StatefulWidget { /// The value used to trigger the animation. As long as the value of [trigger] /// is the same, the animation will not be triggered again. + /// + /// The sentinel `#immediate` plays the animation as soon as it mounts, once + /// per [State] lifetime. final Object? trigger; - /// Defines how the animation is fired. - final AnimationTriggerType triggerType; - /// Determines the behavior of this [AnimatedEffect] as soon as it is added - /// to the widget tree. + /// to the widget tree. [AnimationStartState.eager] plays the animation once + /// on mount; [AnimationStartState.lazy] waits for the first [trigger] + /// change. final AnimationStartState startState; - /// The duration of the animation. - final Duration duration; - - /// The curve of the animation. - final Curve curve; + /// How the animation moves: a [CurvedMotion] built from the + /// duration/curve sugar, or any [Motion] (springs included). + final Motion motion; /// A callback that is called when the animation ends. final VoidCallback? onEnd; @@ -307,14 +259,25 @@ class AnimatedEffect extends StatefulWidget { /// state towards the current state. final bool resetValues; - /// Whether the animation should be reset on subsequent triggers. If this - /// animation is re-triggered, it will reset the current active animation - /// and re-drive from the beginning. - /// Setting this to true will force the animation to wait for the last - /// animation in the chain to finish before starting. + /// How a re-trigger is handled while an animation is still in flight. + /// + /// When true (the default), the in-flight animation is interrupted and + /// re-driven from the beginning right away. When false, the new run waits + /// for the in-flight one to finish before it starts. final bool interruptable; /// A delay before the animation starts. + /// + /// The wait is dead time, not a preview of where the animation is going: + /// the internal controller is parked at the value the upcoming run starts + /// from before the wait begins, so the effects hold at their starting + /// values for the full delay and only then move. A re-trigger that lands + /// mid-flight freezes at the position it interrupted and resumes from + /// there. + /// + /// With [repeat], each repetition waits out this delay in turn, holding at + /// the value that leg begins at — 1 for a [reverse] leg, the start value + /// for a forward one. final Duration delay; /// A callback that returns whether the animation should be played @@ -335,11 +298,10 @@ class AnimatedEffect extends StatefulWidget { const AnimatedEffect({ super.key, required this.child, - required this.duration, - required this.triggerType, - this.startState = AnimationStartState.idle, + this.motion = + const CurvedMotion(Duration(milliseconds: 350), appleEaseInOut), + this.startState = AnimationStartState.lazy, this.trigger, - this.curve = appleEaseInOut, this.onEnd, this.repeat = 0, this.reverse = false, @@ -360,6 +322,29 @@ class AnimatedEffect extends StatefulWidget { context.dependOnInheritedWidgetOfExactType(); } +/// Immutable configuration owned by one logical animation run. +class _RunCycleState { + _RunCycleState({ + required this.id, + required this.repeat, + required this.motion, + required this.delay, + required this.reverse, + required this.play, + required this.skip, + required this.onEnd, + }); + + final int id; + final int repeat; + final Motion motion; + final Duration delay; + final bool reverse; + final bool play; + final bool skip; + final VoidCallback? onEnd; +} + /// The state of [AnimatedEffect]. class AnimatedEffectState extends State with SingleTickerProviderStateMixin { @@ -377,56 +362,51 @@ class AnimatedEffectState extends State /// The animation controller that drives the animation. late final AnimationController controller = AnimationController( vsync: this, - value: - widget.startState == AnimationStartState.useCurrentValues || shouldSkip - ? 1 - : 0, - duration: widget.duration, + value: 0, + duration: widget.motion.effectiveDuration, animationBehavior: widget.animationBehavior ?? HyperEffectsAnimationConfig.maybeOf(context)?.animationBehavior ?? AnimationBehavior.normal, ); - /// The number of times the animation should be repeated. - late int repeatTimes = widget.repeat; - - /// Whether the animation should be reversed after each repetition. - bool shouldReverse = false; - - /// A future that represents a single animation cycle. + /// The future of the currently executing logical run. Queued + /// non-interruptable runs chain onto it to preserve their order. Future? driveFuture; + /// Advances whenever run ownership is revoked — on [reset] and on every + /// fresh interruptable [drive]. [Future.delayed] and ticker futures cannot + /// be cancelled, so an in-flight run body compares its captured epoch + /// against this at every resume point and retires when it no longer owns + /// the animation. + int _cancellationEpoch = 0; + int _runId = 0; + int _nextRunId = 0; + Motion? _activeMotion; + bool _activeReverseLeg = false; + bool _continuesInterrupted = false; + @override void didChangeDependencies() { super.didChangeDependencies(); if (didPlay) return; - if (widget.key case Key key) { - final retainer = AnimatedEffectStateRetainer.maybeOf(context); - final alreadyPlayed = retainer?.didPlay(key) ?? false; - if (alreadyPlayed) { - // If the animation has already played, end it immediately. - controller.value = 1; - return; - } - - retainer?.markAsPlayed(key); - } - - // If the trigger type is one shot or trigger immediately is true, - // drive the animation. - if (widget.triggerType == AnimationTriggerType.oneShot || - widget.startState == AnimationStartState.playImmediately) { + // Both the `#immediate` sentinel and [AnimationStartState.eager] play on + // mount, once per State lifetime. They differ in what happens afterwards: + // `#immediate` pins the trigger to a sentinel whose identity never + // changes, so it can never replay, while `eager` leaves the caller's + // trigger live and plays again on every subsequent change of it. + if (widget.trigger == #immediate || + widget.startState == AnimationStartState.eager) { drive(); - didPlay = false; + didPlay = true; } } @override void didUpdateWidget(covariant AnimatedEffect oldWidget) { super.didUpdateWidget(oldWidget); - controller.duration = widget.duration; + controller.duration = widget.motion.effectiveDuration; // If the trigger value changed, drive the animation. if (widget.trigger != oldWidget.trigger) { @@ -440,103 +420,141 @@ class AnimatedEffectState extends State super.dispose(); } - /// Handles status changes of the animation controller. This is used to - /// determine whether the animation should be repeated or not, and whether - /// the [onEnd] callback should be called. - /// - /// If the animation is repeated, it calls [drive] again. If the animation - /// is not repeated, it calls [onEnd] callback if it is not null. - /// - /// In addition, if the animation is not repeated, it looks up the widget - /// tree for the next [AnimatedEffect] and triggers it manually if the - /// ancestor's [AnimationTriggerType] is [AnimationTriggerType.afterLast]. - Future onAnimationStatusChanged() async { - final status = controller.status; - if (status == AnimationStatus.completed || - status == AnimationStatus.dismissed) { - widget.onEnd?.call(); - - // If repeatTimes is set to -1, repeat the animation indefinitely. - // If repeatTimes is > 0, we keep repeating the animation until - // repeatTimes becomes 0. - if (repeatTimes == -1 || repeatTimes > 0) { - // Only decrement if the animation is not meant to play forever. - if (repeatTimes != -1) { - repeatTimes--; - } - - // The animation must be repeated, call [drive] again. - drive(); - } else if (repeatTimes == 0) { - if (!mounted) return; - - // If the animation is not repeated and just ended, look up the widget - // tree for the next [AnimatedEffect] and trigger it manually if the - // ancestor's [AnimationTriggerType] is - // [AnimationTriggerType.afterLast]. - final AnimatedEffectState? parentState = - context.findAncestorStateOfType(); - final AnimationTriggerType? triggerType = - parentState?.widget.triggerType; - if (parentState != null && - triggerType == AnimationTriggerType.afterLast) { - // Trigger the next animation. - await parentState.drive(); - } - // If instead of an [AnimatedEffect] we find a - // [ResetAllAnimationsEffect], reset all animations in the chain. - else { - final resetState = - context.findAncestorStateOfType(); - resetState?.reset(); - } - } - } - } - /// Resets the animation. Called by [ResetAllAnimationsEffect] if /// it is found in the widget tree. void reset() { - repeatTimes = widget.repeat; + driveFuture = null; + _cancellationEpoch++; + _activeReverseLeg = false; controller.reset(); } /// Drives the animation. - Future drive() async { - if (!widget.interruptable && driveFuture != null) { - await driveFuture; + /// + /// A fresh interruptable invocation starts a new logical run and supersedes + /// every older one via [_cancellationEpoch]. Non-interruptable invocations + /// instead attach themselves to [driveFuture], preserving their serialized + /// order. + Future drive() { + if (!mounted) return Future.value(); + + final play = shouldPlay; + final cycleState = _RunCycleState( + id: ++_nextRunId, + repeat: widget.repeat, + motion: widget.motion, + delay: widget.delay, + reverse: widget.reverse, + play: play, + // The short-circuit is contractual: skipIf must not be evaluated for + // a run that playIf already rejected (one predicate call per run). + skip: play && shouldSkip, + onEnd: widget.onEnd, + ); + if (widget.interruptable) { + controller.stop(); + _cancellationEpoch++; } + final cancellationEpoch = _cancellationEpoch; + final future = widget.interruptable + ? _driveNow(cycleState, cancellationEpoch) + : _driveAfter(driveFuture, cycleState, cancellationEpoch); + driveFuture = future; + return future; + } + + /// Waits for the preceding non-interruptable cycle before starting this one. + Future _driveAfter( + Future? previous, + _RunCycleState cycleState, + int cancellationEpoch, + ) async { + if (previous != null) { + await previous; + if (!mounted || cancellationEpoch != _cancellationEpoch) return; + } + return _driveNow(cycleState, cancellationEpoch); + } + + /// Runs every leg in one logical animation run. + Future _driveNow( + _RunCycleState cycleState, + int cancellationEpoch, + ) async { + bool isCurrentRun() => cancellationEpoch == _cancellationEpoch; + + // Whether this run supersedes a mid-flight predecessor. Sampled before + // this run touches the controller: an interruptable drive() has already + // stopped the controller at the interruption point, while a queued run + // only starts after its predecessor settled at a terminal value. This is + // the authoritative signal — descendants cannot infer interruption from + // their last-built frame, because a completed run's final value may + // never build when its successor begins within the same frame. + final bool interruptedHandoff = + controller.value > 0 && controller.value < 1; + + // Only a DELAYED interrupting run continues from the interrupted render: + // its park would otherwise flash the original start through the delay + // window (spec: same-target delayed retrigger holds the interrupted + // position). A zero-delay retrigger replays from its own start, keeping + // rapid same-target triggers deterministic. + _continuesInterrupted = + interruptedHandoff && cycleState.delay != Duration.zero; + _runId = cycleState.id; + _activeMotion = cycleState.motion; + _activeReverseLeg = false; + + var remainingRepeats = cycleState.repeat; + while (mounted && isCurrentRun()) { + // Re-asserted every leg, not hoisted: didUpdateWidget writes the NEWEST + // widget's duration to the controller, so a queued trigger can retarget + // it mid-run. Each leg of the active run must run at its own snapshot. + controller.duration = cycleState.motion.effectiveDuration; + if (cycleState.delay != Duration.zero) { + // A delay holds the controller at the upcoming leg's starting value. + // Spring reversals solve forward in time from end to start, so they + // begin at zero just like forward legs; curved reversals begin at one. + if (cycleState.play && !cycleState.skip) { + controller.value = + _activeReverseLeg && cycleState.motion is! SpringMotion ? 1 : 0; + } + await Future.delayed(cycleState.delay); + if (!mounted || !isCurrentRun()) return; + } - return driveFuture = ensureDelay(() async { - if (!mounted) return; - if (!shouldPlay) return; - if (shouldSkip) { + if (!cycleState.play) return; + if (cycleState.skip) { controller.value = 1; return; } - if (widget.reverse && shouldReverse) { - shouldReverse = false; - await controller.reverse().catchError((err) { - // ignore - }); - } else { - shouldReverse = widget.reverse; - await controller.forward(from: 0).catchError((err) { - // ignore - }); - } - return onAnimationStatusChanged(); - }); - } + try { + if (_activeReverseLeg && cycleState.motion is! SpringMotion) { + await controller.reverse().orCancel; + } else { + await controller.forward(from: 0).orCancel; + } + } on TickerCanceled { + return; + } + if (!mounted || !isCurrentRun()) return; + final completedLeg = controller.status == AnimationStatus.completed || + controller.status == AnimationStatus.dismissed; + if (!completedLeg) return; + + if (remainingRepeats == -1 || remainingRepeats > 0) { + if (remainingRepeats != -1) { + remainingRepeats--; + } + _activeReverseLeg = cycleState.reverse && !_activeReverseLeg; + continue; + } - /// Ensures that the animation is delayed if [widget.delay] is not - /// [Duration.zero]. - Future ensureDelay(Future Function() fn) async { - if (widget.delay == Duration.zero) { - return fn(); - } else { - return Future.delayed(widget.delay, fn); + cycleState.onEnd?.call(); + final resetState = + context.findAncestorStateOfType(); + resetState?.reset(); + return; } } @@ -544,15 +562,25 @@ class AnimatedEffectState extends State Widget build(BuildContext context) { return AnimatedBuilder( animation: controller, - builder: (context, child) => EffectQuery( - linearValue: controller.value, - curvedValue: widget.curve.transform(controller.value), - isTransition: false, - resetValues: widget.resetValues, - duration: widget.duration, - curve: widget.curve, - child: child!, - ), + builder: (context, child) { + final motion = _activeMotion ?? widget.motion; + return EffectQuery( + linearValue: controller.value, + runId: _runId, + continuesInterrupted: _continuesInterrupted, + reverseLeg: _activeReverseLeg, + curvedValue: motion.transform(controller.value), + motion: motion, + isTransition: false, + resetValues: widget.resetValues, + duration: motion.effectiveDuration, + curve: switch (motion) { + CurvedMotion(:final curve) => curve, + _ => Curves.linear, + }, + child: child ?? const SizedBox.shrink(), + ); + }, child: widget.child, ); } @@ -562,6 +590,11 @@ class AnimatedEffectState extends State /// its child widget tree. /// This is particularly useful when you want to reset a /// series of chained animations to their initial state. +@Deprecated( + 'Auto-resetting chains fire on every completion and cross-talk between ' + 'unrelated animations. Loop with .timeline(repeat:) or rewind with ' + 'TimelineController.seek(0) instead. Will be removed in 0.5.0.', +) class ResetAllAnimationsEffect extends StatefulWidget { /// The widget below this widget in the tree. final Widget? child; @@ -575,6 +608,10 @@ class ResetAllAnimationsEffect extends StatefulWidget { } /// The state of [ResetAllAnimationsEffect]. +@Deprecated( + 'Deprecated along with ResetAllAnimationsEffect. ' + 'Will be removed in 0.5.0.', +) class ResetAllAnimationsEffectState extends State { /// Finds the last possible [AnimatedEffect] state in the tree while /// resetting all the ones on the way down. @@ -603,8 +640,9 @@ class ResetAllAnimationsEffectState extends State { void reset() { final state = findLeafAnimatedEffectState(context); - // Once resetting is complete, re-trigger oneShot animations. - if (state?.widget.triggerType == AnimationTriggerType.oneShot) { + // Once resetting is complete, re-drive `#immediate` animations, which + // would otherwise never play again for the lifetime of their State. + if (state?.widget.trigger == #immediate) { state?.drive(); } } diff --git a/lib/src/animated_group_effect.dart b/lib/src/animated_group_effect.dart deleted file mode 100644 index c39c321..0000000 --- a/lib/src/animated_group_effect.dart +++ /dev/null @@ -1,285 +0,0 @@ -import 'dart:math'; - -import 'package:collection/collection.dart'; -import 'package:flutter/widgets.dart'; -import 'package:meta/meta.dart'; - -import '../hyper_effects.dart'; - -/// A builder that wraps the given [children] list in some -/// [MultiChildRenderObjectWidget] such as a [Stack], [Row], [Column], [Wrap], -/// etc. -/// -/// The point of this builder is to enable the [AnimatedGroup] to re-wrap -/// each child of the [children] list with an [AnimatedChild] widget. -typedef ChildrenBuilder = Widget Function( - BuildContext context, - List children, -); - -/// A builder that wraps the given [child] in a widget that animates the -/// removal of the child out of the widget tree. -/// -/// The reference to the [child] that has been removed is passed to the -/// [RemovedChildBuilder] so that it can be animated out. -typedef RemovedChildBuilder = Widget Function( - BuildContext context, - Widget child, -); - -/// A builder that wraps the given [child] in a widget that animates the -/// addition or insertion of the child into the widget tree. -/// -/// The [skipIf] callback can be used to determine whether the animation -/// should be skipped to the end or not, so as to avoid "initial" animations -/// when the widget is first built. For example, you may want a row of widgets -/// to appear as normal without animating when your screen renders for the first -/// time, but subsequent insertions should animate. -typedef AddedChildBuilder = Widget Function( - BuildContext context, - Widget child, - BooleanCallback skipIf, -); - -/// A builder that wraps the given [child] in a widget that animates the -/// swapping of the child with another child in the widget tree. -typedef SwappedChildBuilder = Widget Function( - BuildContext context, - Widget child, -); - -Widget _defaultRemovedChildBuilder(BuildContext context, Widget child) => child - .fadeOut() - .align( - Alignment.center, - heightFactor: 0, - widthFactor: 0, - fromHeightFactor: 1, - fromWidthFactor: 1, - ) - .oneShot( - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutQuart, - ); - -Widget _defaultAddedChildBuilder( - BuildContext context, Widget child, BooleanCallback skipIf) => - child.slideInFromTop(value: -50).fadeIn().oneShot( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOutQuart, - skipIf: skipIf, - ); - -Widget _defaultSwappedChildBuilder( - BuildContext context, - Widget child, -) => - child.roll(multiplier: 2).clip(0).animate( - trigger: child.key, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOutQuart, - ); - -/// A widget that animates the addition, removal, or swapping of a group of -/// widgets automatically. -@experimental -class AnimatedGroup extends StatefulWidget { - /// The list of children to animate. - final List children; - - /// The builder that wraps the [children] list in a - /// [MultiChildRenderObjectWidget] such as a [Stack], [Row], [Column], [Wrap], - /// etc. - final ChildrenBuilder builder; - - /// The builder that wraps the given [child] in a widget that animates the - /// removal of the child out of the widget tree. - final RemovedChildBuilder removeBuilder; - - /// The builder that wraps the given [child] in a widget that animates the - /// addition or insertion of the child into the widget tree. - final AddedChildBuilder addBuilder; - - /// The builder that wraps the given [child] in a widget that animates the - /// swapping of the child with another child in the widget tree. - final SwappedChildBuilder swapBuilder; - - /// Whether to trigger the addition of the children immediately when the - /// widget is built for the first time, or only for subsequent insertions - /// of children. For example, you may want a row of widgets - /// to appear as normal without animating when your screen renders for the - /// first time, but subsequent insertions should animate. - final bool triggerAddImmediately; - - /// Whether to disable swapping animations for the children when the order - /// of the children changes. - final bool noSwapping; - - /// Creates an [AnimatedGroup] with the given parameters. - const AnimatedGroup({ - super.key, - required this.children, - required this.builder, - this.removeBuilder = _defaultRemovedChildBuilder, - this.addBuilder = _defaultAddedChildBuilder, - this.swapBuilder = _defaultSwappedChildBuilder, - this.triggerAddImmediately = false, - this.noSwapping = false, - }); - - @override - State createState() => _AnimatedGroupState(); -} - -class _AnimatedGroupState extends State { - late int longestChildCount = widget.children.length; - - late bool triggerAdd = widget.triggerAddImmediately; - - @override - void didUpdateWidget(covariant AnimatedGroup oldWidget) { - super.didUpdateWidget(oldWidget); - - triggerAdd = true; - if (const ListEquality().equals( - oldWidget.children.map((child) => child.key).toList(), - widget.children.map((child) => child.key).toList()) == - false) { - final prevChildren = oldWidget.children.toList(); - longestChildCount = max(prevChildren.length, widget.children.length); - } - } - - @override - Widget build(BuildContext context) { - return widget.builder( - context, - List.generate( - longestChildCount, - (index) { - return AnimatedChild( - triggerAdd: triggerAdd, - removeBuilder: widget.removeBuilder, - addBuilder: widget.addBuilder, - swapBuilder: widget.swapBuilder, - noSwapping: widget.noSwapping, - child: - widget.children.length > index ? widget.children[index] : null, - ); - }, - ), - ); - } -} - -/// A widget that animates the addition, removal, or swapping of a single child -/// widget automatically. -@experimental -class AnimatedChild extends StatefulWidget { - /// The child widget to animate. - final Widget? child; - - /// Whether to trigger the addition of the child immediately when the - /// widget is built for the first time, or only for subsequent insertions - /// of children. For example, you may want a row of widgets to appear as - /// normal without animating when your screen renders for the first - /// time, but subsequent insertions should animate. - final bool triggerAdd; - - /// Whether to use snapshots when animating the child. If set to `true`, the - /// child will be wrapped in a [SnapshotWidget] to animate the child. If set - /// to `false`, the child will be animated using the direct reference to the - /// child. - final bool useSnapshots; - - /// Whether to disable swapping animations for the child when the order of - /// the children changes. - final bool noSwapping; - - /// The builder that wraps the given [child] in a widget that animates the - /// removal of the child out of the widget tree. - final RemovedChildBuilder removeBuilder; - - /// The builder that wraps the given [child] in a widget that animates the - /// addition or insertion of the child into the widget tree. - final AddedChildBuilder addBuilder; - - /// The builder that wraps the given [child] in a widget that animates the - /// swapping of the child with another child in the widget tree. - final SwappedChildBuilder swapBuilder; - - /// Creates an [AnimatedChild] with the given parameters. - const AnimatedChild({ - super.key, - required this.child, - this.triggerAdd = true, - this.useSnapshots = true, - this.noSwapping = false, - this.removeBuilder = _defaultRemovedChildBuilder, - this.addBuilder = _defaultAddedChildBuilder, - this.swapBuilder = _defaultSwappedChildBuilder, - }); - - @override - State createState() => _AnimatedChildState(); -} - -class _AnimatedChildState extends State { - final SnapshotController controller = - SnapshotController(allowSnapshotting: true); - Widget? prevChild; - GlobalKey removedKey = GlobalKey(); - - @override - void dispose() { - controller.dispose(); - super.dispose(); - } - - @override - void didUpdateWidget(covariant AnimatedChild oldWidget) { - super.didUpdateWidget(oldWidget); - - if (oldWidget.child != widget.child) { - removedKey = GlobalKey(); - if (widget.useSnapshots) { - prevChild = oldWidget.child == null - ? null - : SnapshotWidget( - mode: SnapshotMode.forced, - controller: controller, - child: oldWidget.child!, - ); - } else { - prevChild = oldWidget.child; - } - } - } - - @override - Widget build(BuildContext context) { - return Stack( - alignment: Alignment.center, - clipBehavior: Clip.none, - children: [ - if (widget.noSwapping || widget.child == null) - if (prevChild case Widget prev) - KeyedSubtree( - key: removedKey, - child: widget.removeBuilder(context, prev), - ), - if (widget.child case Widget child) - widget.addBuilder( - context, - widget.noSwapping - ? child - : widget.swapBuilder( - context, - child, - ), - () => !widget.triggerAdd, - ), - ], - ); - } -} diff --git a/lib/src/animation_config.dart b/lib/src/animation_config.dart index 1936a28..e7e9699 100644 --- a/lib/src/animation_config.dart +++ b/lib/src/animation_config.dart @@ -1,4 +1,4 @@ -import 'package:flutter/cupertino.dart'; +import 'package:flutter/widgets.dart'; /// Provides a way to configure the animation behavior globally for a subtree. class HyperEffectsAnimationConfig extends InheritedWidget { diff --git a/lib/src/animation_retainer.dart b/lib/src/animation_retainer.dart deleted file mode 100644 index 1130589..0000000 --- a/lib/src/animation_retainer.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:meta/meta.dart'; - -import '../hyper_effects.dart'; - -/// A StatefulWidget that retains the state of an [AnimatedEffect] widget. -/// -/// This widget is responsible for managing the state of animations, by -/// not allowing [AnimationTriggerType.oneShot] animations from replaying -/// if their [State] got disposed and recreated. -/// -/// This is useful for animations that are used in -/// [ListView.builder], [GridView.builder] or any other widget that -/// disposes and recreates its children, discouraging the replay of -/// animations that already played. -/// -/// To use it, wrap your dynamic widget builder widget with this widget. -/// -/// Example: -/// ```dart -/// AnimatedEffectStateRetainer( -/// child: ListView.builder( -/// itemBuilder: (context, index) { -/// ... -/// }, -/// ), -/// ``` -@experimental -class AnimatedEffectStateRetainer extends StatefulWidget { - /// A subtree of widgets that contain [AnimatedEffect] widgets. - final Widget child; - - /// Creates an [AnimatedEffectStateRetainer]. - const AnimatedEffectStateRetainer({super.key, required this.child}); - - @override - State createState() => - _AnimatedEffectStateRetainerState(); - - /// Returns the [AnimatedEffectStateRetainerInheritedWidget] from the - /// closest instance of this class that encloses the given context. - /// - /// If no [AnimatedEffectStateRetainerInheritedWidget] is found, returns null. - static AnimatedEffectStateRetainerInheritedWidget? maybeOf( - BuildContext context) { - return context.dependOnInheritedWidgetOfExactType< - AnimatedEffectStateRetainerInheritedWidget>(); - } - - /// Returns the [AnimatedEffectStateRetainerInheritedWidget] from the - /// closest instance of this class that encloses the given context. - /// - /// Throws an exception if no [AnimatedEffectStateRetainerInheritedWidget] is found. - static AnimatedEffectStateRetainerInheritedWidget of(BuildContext context) { - final AnimatedEffectStateRetainerInheritedWidget? result = maybeOf(context); - assert(result != null, - 'No AnimatedEffectStateRetainerInheritedWidget found in context.'); - return result!; - } -} - -class _AnimatedEffectStateRetainerState - extends State { - final Map _animationStates = {}; - - void _markAsPlayed(Key key) => _animationStates[key] = true; - - bool _didPlay(Key key) => _animationStates[key] ?? false; - - @override - Widget build(BuildContext context) { - return AnimatedEffectStateRetainerInheritedWidget( - playedCallback: _markAsPlayed, - didPlayCallback: _didPlay, - child: widget.child, - ); - } -} - -/// A callback that returns whether an [AnimatedEffect] widget with the given -/// [Key] has played. -typedef DidPlayCallback = bool Function(Key key); - -/// An [InheritedWidget] that delegates callbacks to the -/// [AnimatedEffectStateRetainer] parent widget. -@experimental -class AnimatedEffectStateRetainerInheritedWidget extends InheritedWidget { - /// A callback that marks an [AnimatedEffect] widget with the given [Key] - /// as played. - final ValueChanged playedCallback; - - /// A callback that returns whether an [AnimatedEffect] widget with the given - /// [Key] has played. - final DidPlayCallback didPlayCallback; - - /// Creates an [AnimatedEffectStateRetainerInheritedWidget]. - const AnimatedEffectStateRetainerInheritedWidget({ - super.key, - required super.child, - required this.playedCallback, - required this.didPlayCallback, - }); - - /// Marks an [AnimatedEffect] widget with the given [Key] as played. - void markAsPlayed(Key key) => playedCallback(key); - - /// Returns whether an [AnimatedEffect] widget with the given [Key] has played. - bool didPlay(Key key) => didPlayCallback(key); - - @override - bool updateShouldNotify(covariant InheritedWidget oldWidget) => false; -} diff --git a/lib/src/effect_query.dart b/lib/src/effect_query.dart index 171ca97..a6f858c 100644 --- a/lib/src/effect_query.dart +++ b/lib/src/effect_query.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import 'pointer_transition.dart'; +import 'motion/motion.dart'; /// An inherited widget that provides the animation value to it's descendants. /// @@ -11,6 +12,22 @@ class EffectQuery extends InheritedWidget { /// The linear animation value. It's value is between 0 and 1. final double linearValue; + /// Identifies the logical animation run that owns these values. + final int runId; + + /// Whether the active run continues from the render it interrupted rather + /// than replaying from its start. True only for a run that superseded a + /// mid-flight run AND parks through a nonzero delay: a delayed same-target + /// retrigger must hold the interrupted position through its delay window. + /// A zero-delay retrigger is a replay, and a completed predecessor — + /// however recently — is never an interruption. Descendants must use this, + /// never a stale build value, to decide whether to carry the rendered + /// position into the new run. + final bool continuesInterrupted; + + /// Whether the active run is solving its ending-to-starting leg. + final bool reverseLeg; + /// The animation value. It's value is between 0 and 1, interpolated by the /// [Curve] provided. final double curvedValue; @@ -36,6 +53,11 @@ class EffectQuery extends InheritedWidget { /// The duration of the animation. final Duration duration; + /// The [Motion] driving the animation, when driven by an animator that + /// has one. Physics-aware descendants (spring-driven [EffectWidget]s) + /// read this; transitions leave it null. + final Motion? motion; + /// The curve of the animation. final Curve curve; @@ -44,22 +66,31 @@ class EffectQuery extends InheritedWidget { super.key, required super.child, required this.linearValue, + this.runId = 0, + this.continuesInterrupted = false, + this.reverseLeg = false, required this.curvedValue, required this.isTransition, this.lerpValues = true, this.resetValues = false, this.duration = Duration.zero, this.curve = Curves.linear, + this.motion, }); @override bool updateShouldNotify(covariant EffectQuery oldWidget) { - return oldWidget.curvedValue != curvedValue || + return oldWidget.linearValue != linearValue || + oldWidget.runId != runId || + oldWidget.continuesInterrupted != continuesInterrupted || + oldWidget.reverseLeg != reverseLeg || + oldWidget.curvedValue != curvedValue || oldWidget.isTransition != isTransition || oldWidget.lerpValues != lerpValues || oldWidget.resetValues != resetValues || oldWidget.duration != duration || - oldWidget.curve != curve; + oldWidget.curve != curve || + oldWidget.motion != motion; } /// Returns the [EffectQuery] from the given [context]. diff --git a/lib/src/effect_widget.dart b/lib/src/effect_widget.dart index 2a4086c..0f12e7b 100644 --- a/lib/src/effect_widget.dart +++ b/lib/src/effect_widget.dart @@ -3,6 +3,9 @@ import 'package:flutter/widgets.dart'; import 'effect_query.dart'; import 'effects/effect.dart'; +import 'effects/vector_effect.dart'; +import 'motion/motion.dart'; +import 'motion/vector_spring.dart'; /// A widget that applies given [Effect] to a [Widget]. This widget is hardly /// used directly. Instead, use the extension methods provided by the effects @@ -33,6 +36,24 @@ class EffectWidget extends StatefulWidget { required this.child, }); + /// The set of effect runtime types this process has already warned about + /// via the spring-downgrade diagnostic (see [_EffectWidgetState + /// ._warnSpringDowngrade]). Static and process-wide, and lives here on + /// the public [EffectWidget] — rather than on the private state class + /// that actually populates it — purely so [resetSpringDowngradeWarningsForTesting] + /// has a name a test file can reach; privacy in Dart is per-library, and + /// the state class's name is not importable from outside this file. + static final Set _warnedSpringDowngradeTypes = {}; + + /// Clears the set of effect types already warned about by the + /// spring-downgrade diagnostic, so a test that triggers the same + /// downgrade more than once (e.g. once per test case in a single test + /// run) can observe the diagnostic each time instead of only the first. + @visibleForTesting + static void resetSpringDowngradeWarningsForTesting() { + _warnedSpringDowngradeTypes.clear(); + } + @override State createState() => _EffectWidgetState(); } @@ -49,6 +70,170 @@ class _EffectWidgetState extends State { /// between two [Effect]s when the [Effect] changes mid animation. double previousAnimationValue = 0; + /// The previous LINEAR animation value: spring physics runs on real time, + /// not curved progress. + double previousLinearValue = 0; + + int? previousRunId; + Motion? previousMotion; + bool previousReverseLeg = false; + bool _capturedDuringWidgetUpdate = false; + + /// The typed velocity carried across spring retargets — an effect-shaped + /// "units per second", captured analytically at the interruption instant. + Effect? velocity; + + /// Whether the current start/end pair can be driven by spring physics. + bool _isSpringDriven(EffectQuery? query) => + query != null && + !query.isTransition && + query.lerpValues && + query.motion is SpringMotion && + start is VectorEffect && + end is VectorEffect && + start.runtimeType == end.runtimeType; + + /// The mirror image of [_isSpringDriven]: true exactly when a + /// [SpringMotion] WOULD be driving this pair, except that the effect + /// doesn't mix in [VectorEffect] and so is about to fall back to the + /// normalized [Effect.lerp] path instead of real physics. + /// + /// This is deliberately built from the same clauses as [_isSpringDriven] + /// rather than as its plain negation, so it inherits the same guards + /// against false positives: it agrees with [_isSpringDriven] on every + /// condition (a live, non-transition, lerped query actually carrying a + /// spring, with matching start/end runtime types) and disagrees only on + /// the one clause that decides the downgrade. The two predicates are + /// therefore mutually exclusive by construction, which is what lets + /// [build] call this right where [_isSpringDriven] gave up, with no risk + /// of firing on `isTransition` queries, `lerpValues: false` queries, or + /// curve-driven motions — those are different code paths entirely, not + /// silent degradations. + bool _isSilentlyDowngradedFromSpring(EffectQuery? query) => + query != null && + !query.isTransition && + query.lerpValues && + query.motion is SpringMotion && + start.runtimeType == end.runtimeType && + start is! VectorEffect; + + /// Warns, at most once per effect runtime type for the lifetime of the + /// process, that [effectType] is being driven by a [SpringMotion] but + /// doesn't mix in [VectorEffect] — so it has silently fallen back to the + /// normalized `lerp` path instead of real spring physics. + /// + /// The fallback isn't merely an alternate implementation of the same + /// physics. It loses the closed-form spring state in effect space. The + /// scalar [SpringMotion] progress can still exceed 1, so an unclamped + /// [Effect.lerp] MAY render overshoot and settle-back. But that behavior is + /// now implementation-dependent: an effect whose `lerp` clamps progress + /// flattens the overshoot entirely, and even an extrapolating `lerp` follows + /// scalar interpolation rather than the vector spring trajectory. + /// + /// Velocity handoff on retarget is lost unconditionally. [didUpdateWidget] + /// only captures the in-flight spring's momentum inside the + /// [_isSpringDriven] branch, so retargeting a downgraded effect mid-flight + /// restarts it from rest instead of carrying its speed into the new run. + /// + /// This is a debug console warning rather than a [FlutterError]: the + /// downgrade is actionable developer guidance, not a framework failure. + /// Sending it through [FlutterError.reportError] would also feed it into + /// consuming apps' Crashlytics/Sentry handlers and make `flutter_test` + /// promote the warning to a test failure. + /// + /// This method is called from [build], which runs on every frame of every + /// animation driven by this widget. Warning unconditionally would flood the + /// console for the lifetime of a repeatedly-rebuilding, permanently- + /// downgraded effect. Warning is deliberately keyed by runtime [Type] + /// rather than widget identity: "this kind of effect is missing + /// VectorEffect" is a fact about the effect's class, not any one instance. + /// A screen with a dozen affected widgets therefore reports the one-line + /// class fix once instead of printing a dozen identical warnings. + static void _warnSpringDowngrade(Type effectType) { + assert(() { + if (EffectWidget._warnedSpringDowngradeTypes.add(effectType)) { + debugPrint( + 'hyper_effects: SpringMotion downgraded for $effectType\n' + '$effectType does not mix in VectorEffect, so it is using the ' + 'normalized Effect.lerp path instead of closed-form vector spring ' + 'physics. Scalar spring progress may still exceed 1, but overshoot ' + "and settle-back now depend on whether $effectType's lerp " + 'extrapolates or clamps; a clamping lerp flattens them. Mid-flight ' + 'retargets always lose velocity handoff and restart from rest.\n' + 'Remedy: mix VectorEffect<$effectType> into $effectType, or switch ' + 'this animation to CurvedMotion if non-physical interpolation is ' + 'intended.', + ); + } + return true; + }()); + } + + /// The endpoints the active leg runs between: a reverse leg is a fresh + /// forward-time solve from [end] back to the start. This is the single + /// owner of the endpoint rule — resting frames and mid-flight samples + /// both derive from it. + (Effect from, Effect target) _legEndpoints( + bool reverse, + Effect? effectiveStart, + ) { + final Effect resolvedStart = effectiveStart ?? start; + return reverse ? (end, resolvedStart) : (resolvedStart, end); + } + + /// Evaluates the closed-form spring position at [linearValue] of the + /// current run — the per-frame path, which never needs the velocity half + /// of the solution. The coefficients are scalars; all arithmetic happens + /// in effect space via [VectorEffect] operators. + Effect _springPosition( + SpringMotion motion, + double linearValue, { + bool reverse = false, + Effect? effectiveStart, + }) { + final (Effect from, Effect target) = _legEndpoints(reverse, effectiveStart); + // Endpoints are exact: the settling bound leaves a sub-tolerance + // residual which must not leak into resting keyframes. + if (linearValue >= 1) return target; + final double seconds = motion.effectiveDuration.inMicroseconds / + Duration.microsecondsPerSecond; + final SpringCoefficients c = + springCoefficients(motion.description, linearValue * seconds); + final dynamic displacement = (from as dynamic) - target; + dynamic position = (target as dynamic) + displacement * c.a; + final dynamic v0 = reverse ? null : velocity; + if (v0 != null) { + position = position + v0 * c.b; + } + return position as Effect; + } + + /// Evaluates position AND instantaneous velocity. Only needed when a + /// retarget captures the in-flight state — per trigger, not per frame — + /// so re-solving the coefficients for the velocity half is fine. + (Effect, Effect) _springState( + SpringMotion motion, + double linearValue, { + bool reverse = false, + }) { + final Effect position = + _springPosition(motion, linearValue, reverse: reverse); + if (linearValue >= 1) { + return (position, ((position as dynamic) - position) as Effect); + } + final double seconds = motion.effectiveDuration.inMicroseconds / + Duration.microsecondsPerSecond; + final SpringCoefficients c = + springCoefficients(motion.description, linearValue * seconds); + final (Effect from, Effect target) = _legEndpoints(reverse, null); + dynamic speed = ((from as dynamic) - target) * c.da; + final dynamic v0 = reverse ? null : velocity; + if (v0 != null) { + speed = speed + v0 * c.db; + } + return (position, speed as Effect); + } + @override void initState() { super.initState(); @@ -60,31 +245,117 @@ class _EffectWidgetState extends State { void didChangeDependencies() { super.didChangeDependencies(); final effectQuery = EffectQuery.maybeOf(context); - final double animationValue = effectQuery?.curvedValue ?? 0; - previousAnimationValue = animationValue; + final int? runId = effectQuery?.runId; + if (previousRunId != null && runId != previousRunId) { + // Only a run that HOLDS its interrupted predecessor (a delayed + // same-target retrigger) carries the rendered position into its + // start. The last-built frame value cannot decide this: a completed + // run's final frame may never build when its successor starts within + // the same frame, leaving previousLinearValue stranded strictly + // inside (0, 1). Every other new run is a replay: it repaints from + // the author's explicit start when given, and otherwise keeps the + // pair start untouched — the only record of where this pair began. + if (!_capturedDuringWidgetUpdate) { + if (effectQuery != null && !effectQuery.isTransition) { + if (effectQuery.continuesInterrupted && + previousLinearValue > 0 && + previousLinearValue < 1) { + start = start.lerp(end, previousAnimationValue); + } else if (widget.start != null) { + start = widget.start!; + } + } + velocity = null; + } + } + _capturedDuringWidgetUpdate = false; + previousRunId = runId; + previousMotion = effectQuery?.motion; + previousReverseLeg = effectQuery?.reverseLeg ?? false; + previousAnimationValue = effectQuery?.curvedValue ?? 0; + previousLinearValue = effectQuery?.linearValue ?? 0; + } + + /// Set on hot reload ([reassemble] fires only then) and consumed by the + /// next [didUpdateWidget]: a hot reload must behave like a fresh mount, + /// re-seeding from the edited widget configuration. Otherwise the + /// animation-continuation state below masks source edits whenever a + /// driving [EffectQuery] — its own `.animate()` or ANY ancestor one — + /// rests at an animation value of 0. + bool _reassembled = false; + + @override + void reassemble() { + super.reassemble(); + _reassembled = true; + } + + /// Re-adopts the widget configuration as the rendered pair, discarding any + /// continuation state. The single body behind every "this is not a + /// continuable retarget" branch in [didUpdateWidget]. + void _adoptWidgetPair() { + start = widget.start ?? widget.end; + end = widget.end; + velocity = null; } @override void didUpdateWidget(covariant EffectWidget oldWidget) { super.didUpdateWidget(oldWidget); - // TODO: This was introduced in 7th commit of update pack v2 for some reason - // but it breaks the scroll transitions. So we've disabled it for now. So if - // something breaks in the future, this is the first place to look! - // start= widget.start ?? widget.end; - - if (oldWidget.end != widget.end && - oldWidget.end.runtimeType == widget.end.runtimeType && - start.runtimeType == end.runtimeType) { - final effectQuery = EffectQuery.maybeOf(context); - if (effectQuery != null && !effectQuery.isTransition) { - start = start.lerp(end, previousAnimationValue); + // A hot reload, an incompatible effect subtype, or a changed explicit + // start is not a continuable retarget: re-seed from configuration. + if (_reassembled || + oldWidget.end.runtimeType != widget.end.runtimeType || + (oldWidget.end == widget.end && oldWidget.start != widget.start)) { + _reassembled = false; + _adoptWidgetPair(); + return; + } + + final effectQuery = EffectQuery.maybeOf(context); + + // Static usage: with no animation or transition driving this widget, + // the rendered state must track the widget configuration directly, + // otherwise rebuilds (e.g. hot reload with a changed parameter) keep + // showing the values captured in initState. An unconditional version of + // this sync (see git history of update pack v2) broke scroll + // transitions, which is why it is gated on the absence of a query. + if (effectQuery == null) { + _adoptWidgetPair(); + return; + } + + if (oldWidget.end != widget.end && start.runtimeType == end.runtimeType) { + _capturedDuringWidgetUpdate = true; + if (!effectQuery.isTransition) { + if (_isSpringDriven(effectQuery)) { + // Capture BOTH the rendered position and the instantaneous + // velocity of the in-flight spring: the new run starts from the + // captured position with the captured momentum. + final outgoingMotion = previousMotion; + final (Effect position, Effect speed) = _springState( + outgoingMotion is SpringMotion + ? outgoingMotion + : effectQuery.motion! as SpringMotion, + previousLinearValue, + reverse: previousReverseLeg, + ); + start = position; + velocity = speed; + } else { + start = start.lerp(end, previousAnimationValue); + velocity = null; + } } end = widget.end; } } + Effect _effectiveStart(EffectQuery? query) => + widget.start == null && query?.resetValues == true ? end.idle() : start; + @override Widget build(BuildContext context) { final effectQuery = EffectQuery.maybeOf(context); @@ -98,13 +369,23 @@ class _EffectWidgetState extends State { return child ?? const SizedBox.shrink(); } - final double animationValue = effectQuery?.curvedValue ?? 0; - Effect effectiveStart = start; - if (widget.start == null && effectQuery?.resetValues == true) { - effectiveStart = start.idle(); + if (_isSpringDriven(effectQuery)) { + final Effect position = _springPosition( + effectQuery!.motion! as SpringMotion, + effectQuery.linearValue, + reverse: effectQuery.reverseLeg, + effectiveStart: _effectiveStart(effectQuery), + ); + return position.apply(context, child); } - final Effect newEffect = effectiveStart.lerp(end, animationValue); + if (_isSilentlyDowngradedFromSpring(effectQuery)) { + _warnSpringDowngrade(end.runtimeType); + } + + final double animationValue = effectQuery?.curvedValue ?? 0; + final Effect newEffect = + _effectiveStart(effectQuery).lerp(end, animationValue); return newEffect.apply(context, child); } } diff --git a/lib/src/effects/align_effect.dart b/lib/src/effects/align_effect.dart index 7136ed1..57403de 100644 --- a/lib/src/effects/align_effect.dart +++ b/lib/src/effects/align_effect.dart @@ -6,12 +6,21 @@ import '../../hyper_effects.dart'; /// Provides a extension method to apply an [AlignEffect] to a [Widget]. extension AlignEffectExt on Widget { - /// Applies an [AlignEffect] to a [Widget] with the given [alignment]. + /// Applies an [AlignEffect] to a [Widget] with the given [alignment], + /// optionally animating from [from]. + /// + /// [widthFactor] and [heightFactor] mirror [Align]'s parameters: when + /// null (the default), the widget expands to fill its incoming + /// constraints; when set, the widget sizes itself to the child's size + /// multiplied by the factor. [fromWidthFactor] and [fromHeightFactor] + /// provide the starting factors, defaulting to [widthFactor] and + /// [heightFactor]. A null and a non-null factor cannot be interpolated, + /// so such a pair snaps to the target value instead of animating. Widget align( AlignmentGeometry alignment, { AlignmentGeometry? from, - double heightFactor = 1, - double widthFactor = 1, + double? heightFactor, + double? widthFactor, double? fromHeightFactor, double? fromWidthFactor, }) { @@ -33,6 +42,9 @@ extension AlignEffectExt on Widget { } /// Applies an [AlignEffect] to a [Widget] only on the x-axis. + /// + /// The widget expands to fill its incoming constraints, matching the + /// default behavior of [Align]. Widget alignX(double x, {double? from}) { return EffectWidget( start: from == null ? null : AlignEffect(alignment: Alignment(from, 0)), @@ -42,6 +54,9 @@ extension AlignEffectExt on Widget { } /// Applies an [AlignEffect] to a [Widget] only on the y-axis. + /// + /// The widget expands to fill its incoming constraints, matching the + /// default behavior of [Align]. Widget alignY(double y, {double? from}) { return EffectWidget( start: from == null ? null : AlignEffect(alignment: Alignment(0, from)), @@ -52,6 +67,9 @@ extension AlignEffectExt on Widget { /// Applies an [AlignEffect] to a [Widget] with the given [x] and [y] /// values. + /// + /// The widget expands to fill its incoming constraints, matching the + /// default behavior of [Align]. Widget alignXY( double x, double y, { @@ -65,49 +83,202 @@ extension AlignEffectExt on Widget { } } -/// An effect that aligns a [Widget] by a given [alignment]. -class AlignEffect extends Effect { +/// An effect that aligns a [Widget] by a given [alignment], optionally +/// sizing it to a factor of its child. +/// +/// [AlignEffect] is a [VectorEffect], so an alignment animation gets real +/// spring overshoot and carries its momentum across a mid-flight retarget +/// instead of restarting from rest. The alignment itself vectorizes +/// cleanly: [AlignmentGeometry] already defines addition +/// ([AlignmentGeometry.add]), negation and scaling, and defines them ACROSS +/// the [Alignment] / [AlignmentDirectional] divide, so the two endpoints of +/// an animation are never required to agree on a runtime type. +/// +/// ## Why the size factors are the awkward part +/// +/// A null [widthFactor] does not mean "zero" — it means "fill the incoming +/// constraints", which is a different layout mode rather than a smaller +/// number. There is no arithmetic between a mode and a number, so +/// nullability is treated as CONFIGURATION and rides along from the LEFT +/// operand, exactly as [VectorEffect] prescribes for fields that are not +/// animatable: +/// +/// * both operands carry a factor — the numbers combine, and the factor +/// animates like any other scalar; +/// * neither carries one — the result carries none; +/// * only the left carries one — its value passes through unchanged, +/// because there is nothing numeric on the right to combine it with. +/// +/// That asymmetry is deliberate, and it lands on the documented behaviour +/// rather than beside it. The spring solver builds every intermediate state +/// as `target + displacement * coefficient`, with the TARGET as the left +/// operand, so a mixed null / non-null pair resolves to the target's mode +/// on the very first frame — which is precisely the snap-to-target rule +/// [lerp] has always applied, reached by a different route. The two paths +/// agreeing is a behavioural contract, not a coincidence, and +/// `test/align_effect_vector_test.dart` pins it. +/// +/// ## Where the clamping lives +/// +/// [RenderPositionedBox] asserts `widthFactor == null || widthFactor >= 0`, +/// so a factor that overshoots below zero is floored in [apply], where the +/// value becomes layout and the assert applies. [lerp] keeps its own floor +/// as well: unlike a clamp on the interpolation PARAMETER, flooring the +/// RESULT at zero destroys nothing, because a negative factor is not a +/// value the layout can express in the first place. Overshoot above the +/// target is untouched on both paths. +/// +/// [alignment] needs no guard at all. Values outside the -1..1 box are +/// meaningful to [Align] — they simply place the child past the edge — and +/// they are what makes a bouncy `.alignX()` actually bounce. +class AlignEffect extends Effect with VectorEffect { /// The alignment by which the [Widget] is aligned. + /// + /// Mid-flight this may hold values outside the animation's endpoints; + /// that overshoot is intentional and is passed straight through to + /// [Align]. final AlignmentGeometry alignment; /// Sets its width to the child's width multiplied by this factor. - final double widthFactor; + /// If null, the widget expands to fill its incoming width constraints, + /// matching the default behavior of [Align]. + /// + /// Null and non-null are different layout modes, not different numbers: + /// see the class documentation for how a mixed pair resolves. + final double? widthFactor; /// Sets its height to the child's height multiplied by this factor. - final double heightFactor; + /// If null, the widget expands to fill its incoming height constraints, + /// matching the default behavior of [Align]. + /// + /// Null and non-null are different layout modes, not different numbers: + /// see the class documentation for how a mixed pair resolves. + final double? heightFactor; - /// Creates a [AlignEffect] with the given [alignment] and [fractional]. + /// Creates an [AlignEffect] with the given [alignment], [widthFactor], + /// and [heightFactor]. AlignEffect({ this.alignment = AlignmentDirectional.topStart, - this.widthFactor = 1, - this.heightFactor = 1, - }); + this.widthFactor, + this.heightFactor, + }) : assert(widthFactor == null || !widthFactor.isNaN, + 'widthFactor must not be NaN'), + assert(heightFactor == null || !heightFactor.isNaN, + 'heightFactor must not be NaN'); @override AlignEffect lerp(covariant AlignEffect other, double value) { return AlignEffect( alignment: AlignmentGeometry.lerp(alignment, other.alignment, value) ?? AlignmentDirectional.topStart, - widthFactor: (lerpDouble(widthFactor, other.widthFactor, value) ?? 1) - .clampUnderZero, - heightFactor: (lerpDouble(heightFactor, other.heightFactor, value) ?? 1) - .clampUnderZero, + widthFactor: _lerpFactor(widthFactor, other.widthFactor, value), + heightFactor: _lerpFactor(heightFactor, other.heightFactor, value), ); } + /// A null factor means "fill the incoming constraints", which cannot be + /// numerically interpolated with a size factor, so mixed endpoints snap + /// to the target value. + static double? _lerpFactor(double? a, double? b, double value) { + if (a == null || b == null) return b; + return (lerpDouble(a, b, value) ?? 1).clampUnderZero; + } + + /// Builds the [Align], flooring any factor that overshot below zero. + /// + /// [RenderPositionedBox] asserts non-negative factors, so this is a hard + /// requirement rather than a stylistic one — and it is the only place the + /// requirement applies, because it is the only place the value becomes + /// layout. [alignment] is passed through untouched, overshoot included. @override Widget apply(BuildContext context, Widget? child) { return Align( alignment: alignment, - widthFactor: widthFactor, - heightFactor: heightFactor, + widthFactor: _floorFactor(widthFactor), + heightFactor: _floorFactor(heightFactor), child: child, ); } + /// Floors a factor at zero while preserving null, which is a layout mode + /// rather than a number and must never be turned into one. + static double? _floorFactor(double? value) { + if (value == null) return null; + return value < 0 ? 0 : value; + } + @override AlignEffect idle() => AlignEffect(); + /// Field-wise sum. The alignments add through + /// [AlignmentGeometry.add], which spans absolute and directional forms; + /// the factors follow the nullability rule in the class documentation. + @override + AlignEffect operator +(AlignEffect other) => AlignEffect( + alignment: alignment.add(other.alignment), + widthFactor: VectorEffect.addLane(widthFactor, other.widthFactor), + heightFactor: VectorEffect.addLane(heightFactor, other.heightFactor), + ); + + /// Field-wise difference. [AlignmentGeometry] declares no binary `-`, so + /// this is expressed as `a.add(-b)`, which is the same thing and works + /// for every subtype. Routinely produces negative factors: this is the + /// displacement term of the spring solution, not a renderable state. + @override + AlignEffect operator -(AlignEffect other) => AlignEffect( + alignment: alignment.add(-other.alignment), + widthFactor: VectorEffect.subtractLane(widthFactor, other.widthFactor), + heightFactor: + VectorEffect.subtractLane(heightFactor, other.heightFactor), + ); + + /// Scales the alignment and any present factors by [factor]. A null + /// factor stays null: "fill the constraints" has nothing to scale. + @override + AlignEffect operator *(double factor) => AlignEffect( + alignment: alignment * factor, + widthFactor: widthFactor == null ? null : widthFactor! * factor, + heightFactor: heightFactor == null ? null : heightFactor! * factor, + ); + + /// The squared magnitude of the animatable values, used for settle + /// detection. + /// + /// [AlignmentGeometry] keeps its components private, so the alignment is + /// read through [AlignmentGeometry.resolve] with a left-to-right + /// direction; for the purely absolute and purely directional forms the + /// choice is immaterial, and settle detection only needs a scalar that + /// vanishes with the vector. Null factors contribute nothing, matching + /// their "no numeric value" reading everywhere else here. + @override + double get magnitudeSquared { + final Alignment resolved = alignment.resolve(TextDirection.ltr); + final double width = widthFactor ?? 0; + final double height = heightFactor ?? 0; + return resolved.x * resolved.x + + resolved.y * resolved.y + + width * width + + height * height; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is AlignEffect && + other.runtimeType == runtimeType && + other.alignment == alignment && + other.widthFactor == widthFactor && + other.heightFactor == heightFactor; + } + + @override + int get hashCode => Object.hash( + alignment, + widthFactor, + heightFactor, + ); + @override - List get props => [alignment, widthFactor, heightFactor]; + String toString() => + 'AlignEffect(alignment: $alignment, widthFactor: $widthFactor, heightFactor: $heightFactor)'; } diff --git a/lib/src/effects/blur_effect.dart b/lib/src/effects/blur_effect.dart index bbb3fb3..c9610a4 100644 --- a/lib/src/effects/blur_effect.dart +++ b/lib/src/effects/blur_effect.dart @@ -117,5 +117,22 @@ class BlurEffect extends Effect { BlurEffect idle() => BlurEffect(blur: 0); @override - List get props => [blur, blurX, blurY]; + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is BlurEffect && + other.runtimeType == runtimeType && + other.blur == blur && + other.blurX == blurX && + other.blurY == blurY; + } + + @override + int get hashCode => Object.hash( + blur, + blurX, + blurY, + ); + + @override + String toString() => 'BlurEffect(blur: $blur, blurX: $blurX, blurY: $blurY)'; } diff --git a/lib/src/effects/clip_effect.dart b/lib/src/effects/clip_effect.dart index eb7b7a5..595729f 100644 --- a/lib/src/effects/clip_effect.dart +++ b/lib/src/effects/clip_effect.dart @@ -155,5 +155,20 @@ class ClipEffect extends Effect { ClipEffect idle() => ClipEffect(clip: clip, borderRadius: BorderRadius.zero); @override - List get props => [clip, borderRadius]; + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ClipEffect && + other.runtimeType == runtimeType && + other.clip == clip && + other.borderRadius == borderRadius; + } + + @override + int get hashCode => Object.hash( + clip, + borderRadius, + ); + + @override + String toString() => 'ClipEffect(clip: $clip, borderRadius: $borderRadius)'; } diff --git a/lib/src/effects/color_filter_effect.dart b/lib/src/effects/color_filter_effect.dart index 1a0bac0..0064cc5 100644 --- a/lib/src/effects/color_filter_effect.dart +++ b/lib/src/effects/color_filter_effect.dart @@ -1,6 +1,7 @@ import 'dart:ui'; -import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import '../../hyper_effects.dart'; @@ -46,48 +47,6 @@ extension ColorFilterEffectExtension on Widget { child: this, ); } - - /// Applies an [ColorFilterEffect] to a [Widget] with a default - /// flash in animation. - Widget flashIn({ - Color color = Colors.white, - BlendMode mode = BlendMode.overlay, - List matrix = ColorFilterMatrix.identity, - }) { - return EffectWidget( - start: ColorFilterEffect( - color: color, - mode: mode, - matrix: matrix, - ), - end: ColorFilterEffect( - matrix: ColorFilterMatrix.identity, - mode: mode, - ), - child: this, - ); - } - - /// Applies an [ColorFilterEffect] to a [Widget] with a default - /// flash out animation. - Widget flashOut({ - Color color = Colors.white, - BlendMode mode = BlendMode.overlay, - List matrix = ColorFilterMatrix.identity, - }) { - return EffectWidget( - start: ColorFilterEffect( - matrix: ColorFilterMatrix.identity, - mode: mode, - ), - end: ColorFilterEffect( - color: color, - mode: mode, - matrix: matrix, - ), - child: this, - ); - } } /// An [Effect] that applies an opacity to a [Widget]. @@ -113,25 +72,37 @@ class ColorFilterEffect extends Effect { @override ColorFilterEffect lerp(covariant ColorFilterEffect other, double value) { + final double clampedValue = value.clamp(0, 1).toDouble(); + if (clampedValue == 0) return this; + if (clampedValue == 1) return other; + + if (color != null || other.color != null) { + return ColorFilterEffect( + color: Color.lerp( + color ?? const Color(0x00000000), + other.color ?? const Color(0x00000000), + clampedValue, + ), + mode: clampedValue < 0.5 ? mode : other.mode, + ); + } + + final List from = matrix ?? ColorFilterMatrix.identity; + final List to = other.matrix ?? ColorFilterMatrix.identity; + final int maxLength = from.length > to.length ? from.length : to.length; final List lerped = []; - for (final (index, item) in (matrix ?? []).indexed) { + for (int index = 0; index < maxLength; index++) { final double? val = lerpDouble( - item, - other.matrix?[index], - value.clamp(0, 1), + index < from.length ? from[index] : 0, + index < to.length ? to[index] : 0, + clampedValue, ); lerped.add(val ?? 0); } return ColorFilterEffect( - color: color != null - ? Color.lerp( - color, - other.color, - value.clamp(0, 1), - ) - : null, + color: null, matrix: lerped, - mode: mode, + mode: clampedValue < 0.5 ? mode : other.mode, ); } @@ -146,10 +117,29 @@ class ColorFilterEffect extends Effect { } @override - ColorFilterEffect idle() => ColorFilterEffect(mode: mode); + ColorFilterEffect idle() => + ColorFilterEffect(mode: mode, matrix: ColorFilterMatrix.identity); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ColorFilterEffect && + other.runtimeType == runtimeType && + other.color == color && + other.mode == mode && + listEquals(other.matrix, matrix); + } + + @override + int get hashCode => Object.hash( + color, + mode, + matrix == null ? null : Object.hashAll(matrix!), + ); @override - List get props => [color, mode, matrix]; + String toString() => + 'ColorFilterEffect(color: $color, mode: $mode, matrix: $matrix)'; } /// A set of predefined color filter matrices. diff --git a/lib/src/effects/effect.dart b/lib/src/effects/effect.dart index 6791519..7544884 100644 --- a/lib/src/effects/effect.dart +++ b/lib/src/effects/effect.dart @@ -1,4 +1,3 @@ -import 'package:equatable/equatable.dart'; import 'package:flutter/widgets.dart'; /// An [Effect] that can be applied to a [Widget]. This is the base class for @@ -16,7 +15,7 @@ import 'package:flutter/widgets.dart'; /// /// If no parent [AnimatedEffect] or [ScrollTransition] is provided, the /// animation value is 0. -abstract class Effect with EquatableMixin { +abstract class Effect { /// Creates an [Effect]. const Effect(); diff --git a/lib/src/effects/effects.dart b/lib/src/effects/effects.dart index 4cc3c18..bc3373a 100644 --- a/lib/src/effects/effects.dart +++ b/lib/src/effects/effects.dart @@ -5,6 +5,7 @@ export 'blur_effect.dart'; export 'clip_effect.dart'; export 'color_filter_effect.dart'; export 'effect.dart'; +export 'vector_effect.dart'; export 'opacity_effect.dart'; export 'padding_effect.dart'; export 'roll/roll_effect.dart'; @@ -12,6 +13,7 @@ export 'roll/text_extensions.dart'; export 'rotation_effect.dart'; export 'scale_effect.dart'; export 'shake_effect.dart'; +export 'size_effect.dart'; export 'skew_effect.dart'; export 'transform_effect.dart'; export 'translate_effect.dart'; diff --git a/lib/src/effects/opacity_effect.dart b/lib/src/effects/opacity_effect.dart index a79fff5..8eb9231 100644 --- a/lib/src/effects/opacity_effect.dart +++ b/lib/src/effects/opacity_effect.dart @@ -46,7 +46,7 @@ extension OpacityEffectExtension on Widget { } /// An [Effect] that applies an opacity to a [Widget]. -class OpacityEffect extends Effect { +class OpacityEffect extends Effect with VectorEffect { /// The opacity to apply to the [Widget]. Defaults to 1. final double opacity; @@ -62,7 +62,7 @@ class OpacityEffect extends Effect { @override Widget apply(BuildContext context, Widget? child) => - Opacity(opacity: opacity, child: child); + Opacity(opacity: opacity.clamp(0.0, 1.0), child: child); @override OpacityEffect idle() => OpacityEffect( @@ -70,5 +70,31 @@ class OpacityEffect extends Effect { ); @override - List get props => [opacity]; + OpacityEffect operator +(OpacityEffect other) => + OpacityEffect(opacity: opacity + other.opacity); + + @override + OpacityEffect operator -(OpacityEffect other) => + OpacityEffect(opacity: opacity - other.opacity); + + @override + OpacityEffect operator *(double factor) => + OpacityEffect(opacity: opacity * factor); + + @override + double get magnitudeSquared => opacity * opacity; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is OpacityEffect && + other.runtimeType == runtimeType && + other.opacity == opacity; + } + + @override + int get hashCode => opacity.hashCode; + + @override + String toString() => 'OpacityEffect(opacity: $opacity)'; } diff --git a/lib/src/effects/padding_effect.dart b/lib/src/effects/padding_effect.dart index 4d6d35c..054731d 100644 --- a/lib/src/effects/padding_effect.dart +++ b/lib/src/effects/padding_effect.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart'; import '../effect_widget.dart'; import 'effect.dart'; +import 'vector_effect.dart'; /// Provides a extension method to apply an [PaddingEffect] to a [Widget]. extension PaddingEffectExt on Widget { @@ -154,35 +155,147 @@ extension PaddingEffectExt on Widget { } } -/// An effect that aligns a [Widget] by a given [padding]. -class PaddingEffect extends Effect { - /// The alignment by which the [Widget] is aligned. +/// An effect that insets a [Widget] by a given [padding]. +/// +/// [PaddingEffect] is a [VectorEffect]: its four insets add, subtract and +/// scale as a single vector quantity, which is what lets padding take part +/// in the same physics as every other animatable geometry in this library. +/// Two things follow from that, and neither is available to effects that +/// stay on the normalized lerp path: +/// +/// * **Overshoot is real.** A spring does not creep up on its target and +/// stop; it sails past and comes back. Under a bouncy motion `.pad()` +/// now describes the same curve as a sibling `.translateY()` driven by +/// the same motion, instead of quietly arriving early and flat. Before +/// this, mixing the two on one widget meant either accepting that the +/// geometry disagreed with itself or moving everything off padding to +/// keep it consistent. +/// * **Momentum survives a retarget.** Changing the target mid-flight +/// hands the in-flight velocity to the new spring instead of restarting +/// it from rest, so rapid re-triggers whip rather than hitch. +/// +/// The price of real overshoot is that the interpolated value is no longer +/// bounded by its two endpoints: an inset animating down toward zero passes +/// through negative numbers on the way back up. [RenderPadding] asserts +/// `padding.isNonNegative`, so a negative inset can never reach a [Padding] +/// widget. +/// +/// That guard lives in [apply], not in [lerp] — the same division of labour +/// `OpacityEffect` uses. Clamping inside [lerp] protects nothing that +/// [apply] cannot protect, and pays for it by flattening the curve for +/// every consumer, including the ones whose padding never goes anywhere +/// near zero. Clamping inside [apply] floors only the components that +/// actually went negative, at the last possible moment, and leaves the +/// algebra exact. +class PaddingEffect extends Effect with VectorEffect { + /// The insets applied to the [Widget]. + /// + /// Mid-flight this may hold values outside the animation's endpoints, + /// including negative ones — see the class documentation. [apply] is + /// where it is made safe for layout. final EdgeInsets padding; /// Creates a [PaddingEffect] with the given [padding]. PaddingEffect({ this.padding = EdgeInsets.zero, - }); + }) : assert(!padding.left.isNaN, 'padding.left must not be NaN'), + assert(!padding.top.isNaN, 'padding.top must not be NaN'), + assert(!padding.right.isNaN, 'padding.right must not be NaN'), + assert(!padding.bottom.isNaN, 'padding.bottom must not be NaN'); + /// Interpolates toward [other], unclamped in both the parameter and the + /// result. + /// + /// [value] below 0 or above 1 extrapolates rather than saturating: that + /// is exactly how an overshooting curve (or a spring evaluated through + /// this path) expresses its overshoot. Non-negativity is restored in + /// [apply]. @override PaddingEffect lerp(covariant PaddingEffect other, double value) { return PaddingEffect( - padding: EdgeInsets.lerp(padding, other.padding, value.clamp(0, 1)) ?? - EdgeInsets.zero, + padding: + EdgeInsets.lerp(padding, other.padding, value) ?? EdgeInsets.zero, ); } + /// Builds the [Padding], flooring any inset that overshot below zero. + /// + /// Only the negative components are touched; an inset that overshot + /// upward is passed through at full size, because a larger-than-target + /// padding is perfectly representable and is the visible half of the + /// bounce. This is the single point where the [RenderPadding] assert + /// applies, because it is the single point where the value becomes + /// layout. @override Widget apply(BuildContext context, Widget? child) { return Padding( - padding: padding, + padding: padding.isNonNegative + ? padding + : EdgeInsets.only( + left: _floor(padding.left), + top: _floor(padding.top), + right: _floor(padding.right), + bottom: _floor(padding.bottom), + ), child: child, ); } + static double _floor(double value) => value < 0 ? 0 : value; + @override PaddingEffect idle() => PaddingEffect(); + /// Field-wise sum of the four insets. + /// + /// [PaddingEffect] carries no configuration, so there is nothing to ride + /// along from the left operand — every value it holds is animatable. + @override + PaddingEffect operator +(PaddingEffect other) => + PaddingEffect(padding: padding + other.padding); + + /// Field-wise difference of the four insets. Routinely negative: this is + /// the displacement term of the spring solution, not a renderable state. + @override + PaddingEffect operator -(PaddingEffect other) => PaddingEffect( + padding: EdgeInsets.fromLTRB( + _subtractInset(padding.left, other.padding.left), + _subtractInset(padding.top, other.padding.top), + _subtractInset(padding.right, other.padding.right), + _subtractInset(padding.bottom, other.padding.bottom), + ), + ); + + /// Insets are non-nullable, so a lane [VectorEffect.subtractLane] poisons + /// (a non-finite pair with no usable displacement) collapses to zero. + static double _subtractInset(double a, double b) => + VectorEffect.subtractLane(a, b) ?? 0; + + /// Scales all four insets by [factor]. + @override + PaddingEffect operator *(double factor) => + PaddingEffect(padding: padding * factor); + + /// The squared Euclidean length of the inset 4-vector, used for settle + /// detection. + @override + double get magnitudeSquared => + padding.left * padding.left + + padding.top * padding.top + + padding.right * padding.right + + padding.bottom * padding.bottom; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is PaddingEffect && + other.runtimeType == runtimeType && + other.padding == padding; + } + + @override + int get hashCode => padding.hashCode; + @override - List get props => [padding]; + String toString() => 'PaddingEffect(padding: $padding)'; } diff --git a/lib/src/effects/roll/roll_effect.dart b/lib/src/effects/roll/roll_effect.dart index c36d65f..bf1eec7 100644 --- a/lib/src/effects/roll/roll_effect.dart +++ b/lib/src/effects/roll/roll_effect.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import '../../../hyper_effects.dart'; @@ -75,13 +75,29 @@ class RollEffect extends Effect { ); @override - List get props => [ + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is RollEffect && + other.runtimeType == runtimeType && + other.child == child && + other.slideInDirection == slideInDirection && + other.slideOutDirection == slideOutDirection && + other.multiplier == multiplier && + other.useSnapshots == useSnapshots; + } + + @override + int get hashCode => Object.hash( child, slideInDirection, slideOutDirection, multiplier, useSnapshots, - ]; + ); + + @override + String toString() => + 'RollEffect(child: $child, slideInDirection: $slideInDirection, slideOutDirection: $slideOutDirection, multiplier: $multiplier, useSnapshots: $useSnapshots)'; } /// A [StatefulWidget] that applies a roll animation to a [Widget]. @@ -185,7 +201,6 @@ class _RollingEffectWidgetState extends State { late final Widget? oldRoll = oldChild == null ? null : FractionalTranslation( - transformHitTests: false, translation: slideOutOffset * widget.multiplier, child: oldChild, ); @@ -232,7 +247,6 @@ class _RollingEffectWidgetState extends State { ), if (child != null) FractionalTranslation( - transformHitTests: false, translation: slideInOffset * widget.multiplier, child: child, ), diff --git a/lib/src/effects/roll/rolling_text_controller.dart b/lib/src/effects/roll/rolling_text_controller.dart index 4705b99..3a2d220 100644 --- a/lib/src/effects/roll/rolling_text_controller.dart +++ b/lib/src/effects/roll/rolling_text_controller.dart @@ -1,7 +1,7 @@ import 'dart:math'; import 'dart:ui' as ui; -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import '../../../hyper_effects.dart'; diff --git a/lib/src/effects/roll/rolling_text_effect.dart b/lib/src/effects/roll/rolling_text_effect.dart index 7998e5c..1a73109 100644 --- a/lib/src/effects/roll/rolling_text_effect.dart +++ b/lib/src/effects/roll/rolling_text_effect.dart @@ -1,7 +1,7 @@ import 'dart:math'; import 'dart:ui' as ui; -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import '../../../hyper_effects.dart'; import 'rolling_text_controller.dart'; @@ -250,7 +250,40 @@ class RollingTextEffect extends Effect { } @override - List get props => [ + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is RollingTextEffect && + other.runtimeType == runtimeType && + other.text == text && + other.padding == padding && + other.tapeCurve == tapeCurve && + other.tapeSlideDirection == tapeSlideDirection && + other.staggerTapes == staggerTapes && + other.staggerSoftness == staggerSoftness && + other.reverseStaggerDirection == reverseStaggerDirection && + other.tapeStrategy == tapeStrategy && + other.fixedTapeWidth == fixedTapeWidth && + other.widthDuration == widthDuration && + other.widthCurve == widthCurve && + other.clipBehavior == clipBehavior && + other.style == style && + other.strutStyle == strutStyle && + other.textSpan == textSpan && + other.textAlign == textAlign && + other.textDirection == textDirection && + other.locale == locale && + other.softWrap == softWrap && + other.overflow == overflow && + other.textScaler == textScaler && + other.maxLines == maxLines && + other.semanticsLabel == semanticsLabel && + other.textWidthBasis == textWidthBasis && + other.textHeightBehavior == textHeightBehavior && + other.selectionColor == selectionColor; + } + + @override + int get hashCode => Object.hashAll([ text, padding, tapeCurve, @@ -277,7 +310,11 @@ class RollingTextEffect extends Effect { textWidthBasis, textHeightBehavior, selectionColor, - ]; + ]); + + @override + String toString() => + 'RollingTextEffect(text: $text, padding: $padding, tapeCurve: $tapeCurve, tapeSlideDirection: $tapeSlideDirection, staggerTapes: $staggerTapes, staggerSoftness: $staggerSoftness, reverseStaggerDirection: $reverseStaggerDirection, tapeStrategy: $tapeStrategy, fixedTapeWidth: $fixedTapeWidth, widthDuration: $widthDuration, widthCurve: $widthCurve, clipBehavior: $clipBehavior, style: $style, strutStyle: $strutStyle, textSpan: $textSpan, textAlign: $textAlign, textDirection: $textDirection, locale: $locale, softWrap: $softWrap, overflow: $overflow, textScaler: $textScaler, maxLines: $maxLines, semanticsLabel: $semanticsLabel, textWidthBasis: $textWidthBasis, textHeightBehavior: $textHeightBehavior, selectionColor: $selectionColor)'; } /// A StatefulWidget that provides a rolling text effect. diff --git a/lib/src/effects/roll/text_extensions.dart b/lib/src/effects/roll/text_extensions.dart index 839f54c..09f8b86 100644 --- a/lib/src/effects/roll/text_extensions.dart +++ b/lib/src/effects/roll/text_extensions.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import '../../../hyper_effects.dart'; diff --git a/lib/src/effects/rotation_effect.dart b/lib/src/effects/rotation_effect.dart index e2e1c78..7095c67 100644 --- a/lib/src/effects/rotation_effect.dart +++ b/lib/src/effects/rotation_effect.dart @@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart'; import '../effect_widget.dart'; import 'effect.dart'; +import 'vector_effect.dart'; /// Provides a extension method to apply a [RotationEffect] to a [Widget]. extension RotationEffectExt on Widget { @@ -60,7 +61,7 @@ extension RotationEffectExt on Widget { } /// An [Effect] that applies a rotation to a [Widget]. -class RotationEffect extends Effect { +class RotationEffect extends Effect with VectorEffect { /// The angle to rotate the [Widget] in radians. This directly maps to /// angle property in [Transform.rotate] constructor. final double angle; @@ -103,5 +104,47 @@ class RotationEffect extends Effect { RotationEffect idle() => const RotationEffect(); @override - List get props => [angle, origin, alignment]; + RotationEffect operator +(RotationEffect other) => RotationEffect( + angle: angle + other.angle, + origin: origin, + alignment: alignment, + ); + + @override + RotationEffect operator -(RotationEffect other) => RotationEffect( + angle: angle - other.angle, + origin: origin, + alignment: alignment, + ); + + @override + RotationEffect operator *(double factor) => RotationEffect( + angle: angle * factor, + origin: origin, + alignment: alignment, + ); + + @override + double get magnitudeSquared => angle * angle; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is RotationEffect && + other.runtimeType == runtimeType && + other.angle == angle && + other.origin == origin && + other.alignment == alignment; + } + + @override + int get hashCode => Object.hash( + angle, + origin, + alignment, + ); + + @override + String toString() => + 'RotationEffect(angle: $angle, origin: $origin, alignment: $alignment)'; } diff --git a/lib/src/effects/scale_effect.dart b/lib/src/effects/scale_effect.dart index 52f9c59..fd72db5 100644 --- a/lib/src/effects/scale_effect.dart +++ b/lib/src/effects/scale_effect.dart @@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart'; import '../effect_widget.dart'; import 'effect.dart'; +import 'vector_effect.dart'; /// Provides a extension method to apply a [ScaleEffect] to a [Widget]. extension ScaleEffectExt on Widget { @@ -189,7 +190,7 @@ extension ScaleEffectExt on Widget { } /// An [Effect] that applies a scale to a [Widget]. -class ScaleEffect extends Effect { +class ScaleEffect extends Effect with VectorEffect { /// The scale to apply to the [Widget] on both axes. This must be null if /// [scaleX] or [scaleY] is provided. final double? scale; @@ -270,13 +271,65 @@ class ScaleEffect extends Effect { @override ScaleEffect idle() => ScaleEffect(scale: 1); + double get _effectiveX => scale ?? scaleX ?? 1; + + double get _effectiveY => scale ?? scaleY ?? 1; + + @override + ScaleEffect operator +(ScaleEffect other) => ScaleEffect( + scaleX: _effectiveX + other._effectiveX, + scaleY: _effectiveY + other._effectiveY, + alignment: alignment, + origin: origin, + transformHitTests: transformHitTests, + ); + + @override + ScaleEffect operator -(ScaleEffect other) => ScaleEffect( + scaleX: _effectiveX - other._effectiveX, + scaleY: _effectiveY - other._effectiveY, + alignment: alignment, + origin: origin, + transformHitTests: transformHitTests, + ); + @override - List get props => [ + ScaleEffect operator *(double factor) => ScaleEffect( + scaleX: _effectiveX * factor, + scaleY: _effectiveY * factor, + alignment: alignment, + origin: origin, + transformHitTests: transformHitTests, + ); + + @override + double get magnitudeSquared => + _effectiveX * _effectiveX + _effectiveY * _effectiveY; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ScaleEffect && + other.runtimeType == runtimeType && + other.scale == scale && + other.scaleX == scaleX && + other.scaleY == scaleY && + other.alignment == alignment && + other.origin == origin && + other.transformHitTests == transformHitTests; + } + + @override + int get hashCode => Object.hash( scale, scaleX, scaleY, alignment, origin, transformHitTests, - ]; + ); + + @override + String toString() => + 'ScaleEffect(scale: $scale, scaleX: $scaleX, scaleY: $scaleY, alignment: $alignment, origin: $origin, transformHitTests: $transformHitTests)'; } diff --git a/lib/src/effects/shake_effect.dart b/lib/src/effects/shake_effect.dart index 714ba01..6887b0a 100644 --- a/lib/src/effects/shake_effect.dart +++ b/lib/src/effects/shake_effect.dart @@ -1,7 +1,7 @@ import 'dart:math'; import 'dart:ui'; -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import '../../hyper_effects.dart'; @@ -69,7 +69,8 @@ class ShakeEffect extends Effect { return Transform( transform: Matrix4.identity() ..rotateZ(rotation * effectiveValue) - ..translate(offset.dx * effectiveValue, offset.dy * effectiveValue), + ..translateByDouble( + offset.dx * effectiveValue, offset.dy * effectiveValue, 0, 1), alignment: Alignment.center, child: child, ); @@ -79,5 +80,23 @@ class ShakeEffect extends Effect { ShakeEffect idle() => const ShakeEffect(); @override - List get props => [frequency, offset, rotation]; + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ShakeEffect && + other.runtimeType == runtimeType && + other.frequency == frequency && + other.offset == offset && + other.rotation == rotation; + } + + @override + int get hashCode => Object.hash( + frequency, + offset, + rotation, + ); + + @override + String toString() => + 'ShakeEffect(frequency: $frequency, offset: $offset, rotation: $rotation)'; } diff --git a/lib/src/effects/size_effect.dart b/lib/src/effects/size_effect.dart new file mode 100644 index 0000000..2d976be --- /dev/null +++ b/lib/src/effects/size_effect.dart @@ -0,0 +1,289 @@ +import 'dart:ui'; + +import 'package:flutter/widgets.dart'; + +import '../effect_widget.dart'; +import 'effect.dart'; +import 'vector_effect.dart'; + +/// Provides extension methods to apply a [SizeEffect] to a [Widget]. +/// +/// These are deliberately not named `width`, `height`, or `size`: core +/// widgets this extension targets — [SizedBox] and [Image] among them — +/// already declare instance fields with those exact names, and Dart +/// resolves a member access against the receiver's declared instance +/// members before it ever considers an extension. `someSizedBox.width` would +/// silently resolve to [SizedBox.width] (a `double?` field) instead of this +/// extension's method, and calling it — `someSizedBox.width(120)` — fails to +/// compile because a `double?` field is not callable. [PaddingEffectExt] +/// hits the identical hazard against [Padding.padding]/[Container.padding] +/// and sidesteps it the same way, naming its methods `pad`/`padAll` instead +/// of `padding`; the `To` suffix used here follows that precedent while +/// staying close to the requested vocabulary. +extension SizeEffectExt on Widget { + /// Applies a [SizeEffect] that constrains this [Widget]'s width to + /// [width], leaving its height unconstrained, optionally animating from + /// [from]. + /// + /// Unlike [scale], which resizes the widget at paint time without telling + /// its parent, this changes the actual layout size: the parent sees the + /// new width and reflows around it. + Widget widthTo(double width, {double? from}) { + return EffectWidget( + start: from == null ? null : SizeEffect(width: from), + end: SizeEffect(width: width), + child: this, + ); + } + + /// Applies a [SizeEffect] that constrains this [Widget]'s height to + /// [height], leaving its width unconstrained, optionally animating from + /// [from]. + /// + /// Unlike [scale], which resizes the widget at paint time without telling + /// its parent, this changes the actual layout size: the parent sees the + /// new height and reflows around it. + Widget heightTo(double height, {double? from}) { + return EffectWidget( + start: from == null ? null : SizeEffect(height: from), + end: SizeEffect(height: height), + child: this, + ); + } + + /// Applies a [SizeEffect] that constrains this [Widget] to [size], + /// optionally animating from [from]. + /// + /// Both axes are set together. To animate one axis while leaving the + /// other unconstrained — or to give each axis its own starting value — + /// use [sizeOf] instead, since [Size] cannot express "no constraint on + /// this axis". + Widget sizeTo(Size size, {Size? from}) { + return EffectWidget( + start: from == null + ? null + : SizeEffect(width: from.width, height: from.height), + end: SizeEffect(width: size.width, height: size.height), + child: this, + ); + } + + /// Applies a [SizeEffect] with independently nullable [width] and + /// [height], each with its own optional starting value. + /// + /// [sizeTo] takes a [Size], which cannot represent "leave this axis + /// unconstrained" or give the two axes independent `from` values in a + /// single call. This method exists for the cases [sizeTo] cannot cover: + /// setting only one axis while leaving the other to its incoming + /// constraints, or animating both axes from different starting points. + /// A null [width] or [height] here means the same thing it means on + /// [SizeEffect]: don't constrain that axis at all. + Widget sizeOf({ + double? width, + double? height, + double? fromWidth, + double? fromHeight, + }) { + return EffectWidget( + start: fromWidth == null && fromHeight == null + ? null + : SizeEffect( + width: fromWidth ?? width, + height: fromHeight ?? height, + ), + end: SizeEffect(width: width, height: height), + child: this, + ); + } +} + +/// An effect that constrains a [Widget] to an explicit width and/or height +/// via a [SizedBox]. +/// +/// ## This changes layout, not just paint +/// +/// [ScaleEffect] resizes a widget's painted pixels without changing what it +/// reports to its parent during layout — the parent lays it out at its +/// natural size and the scale is applied on top, so surrounding widgets +/// never reflow. [SizeEffect] is the opposite: it wraps the child in a +/// [SizedBox], so the constraint it produces IS what the parent sees during +/// layout. A pill whose width springs to fit a growing label, with +/// siblings sliding out of the way as it grows, needs [SizeEffect] for +/// exactly this reason — [ScaleEffect] would stretch the pixels without +/// ever telling the row that it needs more room. +/// +/// ## What a null axis means +/// +/// [width] and [height] are independently nullable, matching [SizedBox]: +/// null means "do not constrain this axis," not "constrain it to zero." +/// Those are different layout modes, and there is no number partway +/// between "unconstrained" and "300 pixels wide." This is the same +/// situation [AlignEffect] has with `widthFactor`/`heightFactor`, and this +/// effect follows its precedent: nullability is treated as CONFIGURATION +/// and rides along from the LEFT operand in [operator +] and +/// [operator -], and a null paired with a non-null axis in [lerp] snaps +/// straight to the target instead of animating, because there is no +/// arithmetic between a mode and a number. +/// +/// ## The `double.infinity` hazard +/// +/// [SizedBox] accepts `double.infinity` as a legitimate width or height — +/// it means "expand to fill." But the spring solver computes a +/// displacement as `start - end` and later recombines it as +/// `target + displacement * coefficient`; if both endpoints of an axis are +/// infinite, `start - end` is `double.infinity - double.infinity`, which is +/// NaN, and every frame built from that NaN is poisoned for the rest of +/// the animation. [operator -] is the one place this can actually happen — +/// it is the only operator that subtracts two endpoints — so it is the +/// only place a non-finite check is needed: whenever either side of an +/// axis is non-finite there, that axis poisons to `null` in the +/// displacement, exactly as a null axis would, and flows through +/// [operator +] and [operator *] from then on as an absent quantity. Those +/// two operators need no finite check of their own, because by the time +/// they run, an axis that started non-finite has already been nulled out +/// by [operator -] — [operator +] never sees a finite/infinite pair on the +/// same axis to add. [lerp] applies the same rule directly: a non-finite +/// endpoint on either side snaps to the target rather than interpolating, +/// generalizing the null-snap rule above. +/// +/// ## Where the non-negative clamp lives +/// +/// A spring that overshoots past its target can swing an axis below zero, +/// and [SizedBox] asserts non-negative dimensions. Following +/// [OpacityEffect] (which clamps in `apply`, not in `lerp`), the floor +/// lives only in [apply]: [lerp] is also used for the curve-based, +/// non-spring path, where the interpolation `value` itself can exceed +/// `[0, 1]` for overshoot curves like `Curves.easeOutBack`, and clamping +/// the *result* there would quietly discard overshoot the curve was asked +/// to produce. The floor only needs to exist at the one point where a +/// negative value would otherwise become a real constraint and hit +/// [SizedBox]'s assert — which is [apply], after every other combination +/// has already happened. +class SizeEffect extends Effect with VectorEffect { + /// The width this [Widget] is constrained to, or null to leave the width + /// unconstrained. + /// + /// Null and non-null are different layout modes, not different numbers: + /// see the class documentation for how a mixed pair resolves. + final double? width; + + /// The height this [Widget] is constrained to, or null to leave the + /// height unconstrained. + /// + /// Null and non-null are different layout modes, not different numbers: + /// see the class documentation for how a mixed pair resolves. + final double? height; + + /// Creates a [SizeEffect] with the given [width] and [height]. + SizeEffect({this.width, this.height}) + : assert(width == null || !width.isNaN, 'width must not be NaN'), + assert(height == null || !height.isNaN, 'height must not be NaN'); + + @override + SizeEffect lerp(covariant SizeEffect other, double value) { + return SizeEffect( + width: _lerpAxis(width, other.width, value), + height: _lerpAxis(height, other.height, value), + ); + } + + /// Interpolates one axis, snapping straight to [b] whenever either side + /// is null or non-finite — see the class documentation for why neither + /// case can be meaningfully interpolated. + static double? _lerpAxis(double? a, double? b, double value) { + if (a == null || b == null) return b; + if (!a.isFinite || !b.isFinite) return b; + return lerpDouble(a, b, value); + } + + /// Builds the [SizedBox], flooring any axis that overshot below zero. + /// + /// [SizedBox] asserts non-negative dimensions, so this is a hard + /// requirement rather than a stylistic one — and it is the only place + /// the requirement applies, because it is the only place the value + /// becomes a real layout constraint. See the class documentation for why + /// the floor does not also live in [lerp]. + @override + Widget apply(BuildContext context, Widget? child) { + return SizedBox( + width: _floorAxis(width), + height: _floorAxis(height), + child: child, + ); + } + + /// Floors an axis at zero while preserving null, which is a layout mode + /// rather than a number and must never be turned into one. + static double? _floorAxis(double? value) { + if (value == null) return null; + return value < 0 ? 0 : value; + } + + /// The neutral state: both axes unconstrained. + /// + /// [idle] is what an effect looks like when it is not really "on" — + /// [EffectWidget] uses it as the implicit starting point when a widget + /// animates in without an explicit `from`. For [SizeEffect] that neutral + /// state is "no constraint at all," the same as never having applied a + /// [SizeEffect] in the first place, not a copy of whatever width or + /// height happens to be set on this instance. This mirrors + /// `AlignEffect.idle()`, whose factors reset to null for the same reason. + @override + SizeEffect idle() => SizeEffect(); + + /// Field-wise sum, keeping nullability from the LEFT operand: see the + /// class documentation for why this is the right shape for a field that + /// means "unconstrained" rather than "zero" when null. + @override + SizeEffect operator +(SizeEffect other) => SizeEffect( + width: VectorEffect.addLane(width, other.width), + height: VectorEffect.addLane(height, other.height), + ); + + /// Field-wise difference — the displacement term of the spring solution. + /// This is the one operator that can actually produce + /// `double.infinity - double.infinity`, so it is also the one operator + /// that checks for non-finite endpoints: see the class documentation for + /// why poisoning the axis to null here is enough to keep [operator +] + /// and [operator *] safe without their own finite checks. + @override + SizeEffect operator -(SizeEffect other) => SizeEffect( + width: VectorEffect.subtractLane(width, other.width), + height: VectorEffect.subtractLane(height, other.height), + ); + + /// Scales width and height by [factor]. A null axis stays null: there is + /// nothing numeric to scale when the axis means "unconstrained." + @override + SizeEffect operator *(double factor) => SizeEffect( + width: width == null ? null : width! * factor, + height: height == null ? null : height! * factor, + ); + + /// The squared magnitude of the animatable values, used for settle + /// detection. Null axes contribute nothing, matching their "no numeric + /// value" reading everywhere else here. A defensive finite check guards + /// settle-detection arithmetic even though, by construction, a + /// displacement built by [operator -] never carries a non-finite axis. + @override + double get magnitudeSquared { + final double w = width ?? 0; + final double h = height ?? 0; + return (w.isFinite ? w * w : 0) + (h.isFinite ? h * h : 0); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SizeEffect && + other.runtimeType == runtimeType && + other.width == width && + other.height == height; + } + + @override + int get hashCode => Object.hash(width, height); + + @override + String toString() => 'SizeEffect(width: $width, height: $height)'; +} diff --git a/lib/src/effects/skew_effect.dart b/lib/src/effects/skew_effect.dart index 3cea809..3028924 100644 --- a/lib/src/effects/skew_effect.dart +++ b/lib/src/effects/skew_effect.dart @@ -193,8 +193,34 @@ class SkewEffect extends Effect { } @override - SkewEffect idle() => SkewEffect(); + SkewEffect idle() => SkewEffect( + skew: 0, + alignment: alignment, + origin: origin, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SkewEffect && + other.runtimeType == runtimeType && + other.skew == skew && + other.skewX == skewX && + other.skewY == skewY && + other.alignment == alignment && + other.origin == origin; + } + + @override + int get hashCode => Object.hash( + skew, + skewX, + skewY, + alignment, + origin, + ); @override - List get props => [skew, skewX, skewY, alignment, origin]; + String toString() => + 'SkewEffect(skew: $skew, skewX: $skewX, skewY: $skewY, alignment: $alignment, origin: $origin)'; } diff --git a/lib/src/effects/transform_effect.dart b/lib/src/effects/transform_effect.dart index 7fafea3..07f9725 100644 --- a/lib/src/effects/transform_effect.dart +++ b/lib/src/effects/transform_effect.dart @@ -141,8 +141,8 @@ class TransformEffect extends Effect { matrix.rotateX(rotateX); matrix.rotateY(rotateY); matrix.rotateZ(rotateZ); - matrix.translate(translateX, translateY, translateZ); - matrix.scale(scaleX, scaleY, scaleZ); + matrix.translateByDouble(translateX, translateY, translateZ, 1); + matrix.scaleByDouble(scaleX, scaleY, scaleZ, 1); return Transform( transform: matrix, @@ -156,7 +156,26 @@ class TransformEffect extends Effect { TransformEffect idle() => TransformEffect(); @override - List get props => [ + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is TransformEffect && + other.runtimeType == runtimeType && + other.translateX == translateX && + other.translateY == translateY && + other.translateZ == translateZ && + other.rotateX == rotateX && + other.rotateY == rotateY && + other.rotateZ == rotateZ && + other.scaleX == scaleX && + other.scaleY == scaleY && + other.scaleZ == scaleZ && + other.depth == depth && + other.alignment == alignment && + other.origin == origin; + } + + @override + int get hashCode => Object.hash( translateX, translateY, translateZ, @@ -169,5 +188,9 @@ class TransformEffect extends Effect { depth, alignment, origin, - ]; + ); + + @override + String toString() => + 'TransformEffect(translateX: $translateX, translateY: $translateY, translateZ: $translateZ, rotateX: $rotateX, rotateY: $rotateY, rotateZ: $rotateZ, scaleX: $scaleX, scaleY: $scaleY, scaleZ: $scaleZ, depth: $depth, alignment: $alignment, origin: $origin)'; } diff --git a/lib/src/effects/translate_effect.dart b/lib/src/effects/translate_effect.dart index 29c7e03..6a465bf 100644 --- a/lib/src/effects/translate_effect.dart +++ b/lib/src/effects/translate_effect.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart'; import '../effect_widget.dart'; import 'effect.dart'; +import 'vector_effect.dart'; const double _kDefaultSlideOffset = 100.0; @@ -10,10 +11,13 @@ extension TranslateEffectExt on Widget { /// Applies a [TranslateEffect] to a [Widget] with the given [offset]. /// [fractional] determines whether the [offset] moves the [Widget] by using /// its own size as a percentage or by a fixed amount. + /// [transformHitTests] matches Flutter's [Transform.translate] default: + /// hit testing follows the painted widget. Pass false only for deliberately + /// paint-only movement whose interactive region must stay in layout space. Widget translate( Offset offset, { bool fractional = false, - bool transformHitTests = false, + bool transformHitTests = true, Offset? from, }) { return EffectWidget( @@ -39,7 +43,7 @@ extension TranslateEffectExt on Widget { Widget translateX( double x, { bool fractional = false, - bool transformHitTests = false, + bool transformHitTests = true, double? from, }) { return EffectWidget( @@ -65,7 +69,7 @@ extension TranslateEffectExt on Widget { Widget translateY( double y, { bool fractional = false, - bool transformHitTests = false, + bool transformHitTests = true, double? from, }) { return EffectWidget( @@ -93,7 +97,7 @@ extension TranslateEffectExt on Widget { double x, double y, { bool fractional = false, - bool transformHitTests = false, + bool transformHitTests = true, Offset? from, }) { return EffectWidget( @@ -118,7 +122,7 @@ extension TranslateEffectExt on Widget { Widget slideIn( Offset offset, { bool fractional = false, - bool transformHitTests = false, + bool transformHitTests = true, }) { return EffectWidget( start: TranslateEffect( @@ -140,7 +144,7 @@ extension TranslateEffectExt on Widget { Widget slideInVertically( double y, { bool fractional = false, - bool transformHitTests = false, + bool transformHitTests = true, }) { return EffectWidget( start: TranslateEffect( @@ -162,7 +166,7 @@ extension TranslateEffectExt on Widget { Widget slideInHorizontally( double x, { bool fractional = false, - bool transformHitTests = false, + bool transformHitTests = true, }) { return EffectWidget( start: TranslateEffect( @@ -184,7 +188,7 @@ extension TranslateEffectExt on Widget { Widget slideOut( Offset offset, { bool fractional = false, - bool transformHitTests = false, + bool transformHitTests = true, }) { return EffectWidget( start: TranslateEffect( @@ -206,7 +210,7 @@ extension TranslateEffectExt on Widget { Widget slideOutVertically( double y, { bool fractional = false, - bool transformHitTests = false, + bool transformHitTests = true, }) { return EffectWidget( start: TranslateEffect( @@ -228,7 +232,7 @@ extension TranslateEffectExt on Widget { Widget slideOutHorizontally( double x, { bool fractional = false, - bool transformHitTests = false, + bool transformHitTests = true, }) { return EffectWidget( start: TranslateEffect( @@ -250,7 +254,7 @@ extension TranslateEffectExt on Widget { Widget slideInFromLeft({ double? value, bool fractional = false, - bool transformHitTests = false, + bool transformHitTests = true, }) => slideIn( Offset(value ?? (fractional ? -1 : -_kDefaultSlideOffset), 0), @@ -263,7 +267,7 @@ extension TranslateEffectExt on Widget { Widget slideInFromRight({ double? value, bool fractional = false, - bool transformHitTests = false, + bool transformHitTests = true, }) => slideIn( Offset(value ?? (fractional ? 1 : _kDefaultSlideOffset), 0), @@ -276,7 +280,7 @@ extension TranslateEffectExt on Widget { Widget slideInFromTop({ double? value, bool fractional = false, - bool transformHitTests = false, + bool transformHitTests = true, }) => slideIn( Offset(0, value ?? (fractional ? -1 : -_kDefaultSlideOffset)), @@ -289,7 +293,7 @@ extension TranslateEffectExt on Widget { Widget slideInFromBottom({ double? value, bool fractional = false, - bool transformHitTests = false, + bool transformHitTests = true, }) => slideIn( Offset(0, value ?? (fractional ? 1 : _kDefaultSlideOffset)), @@ -302,7 +306,7 @@ extension TranslateEffectExt on Widget { Widget slideOutToLeft({ double? value, bool fractional = false, - bool transformHitTests = false, + bool transformHitTests = true, }) => slideOut( Offset(value ?? (fractional ? -1 : -_kDefaultSlideOffset), 0), @@ -315,7 +319,7 @@ extension TranslateEffectExt on Widget { Widget slideOutToRight({ double? value, bool fractional = false, - bool transformHitTests = false, + bool transformHitTests = true, }) => slideOut( Offset(value ?? (fractional ? 1 : _kDefaultSlideOffset), 0), @@ -328,7 +332,7 @@ extension TranslateEffectExt on Widget { Widget slideOutToTop({ double? value, bool fractional = false, - bool transformHitTests = false, + bool transformHitTests = true, }) => slideOut( Offset(0, value ?? (fractional ? -1 : -_kDefaultSlideOffset)), @@ -341,7 +345,7 @@ extension TranslateEffectExt on Widget { Widget slideOutToBottom({ double? value, bool fractional = false, - bool transformHitTests = false, + bool transformHitTests = true, }) => slideOut( Offset(0, value ?? (fractional ? 1 : _kDefaultSlideOffset)), @@ -351,7 +355,7 @@ extension TranslateEffectExt on Widget { } /// An effect that translates a [Widget] by a given [offset]. -class TranslateEffect extends Effect { +class TranslateEffect extends Effect with VectorEffect { /// The offset by which the [Widget] is translated. final Offset offset; @@ -359,14 +363,20 @@ class TranslateEffect extends Effect { /// of the [Widget]'s size. If false, the [offset] is a fixed amount. final bool fractional; - /// Whether the [Widget] should be hit tested. + /// Whether hit testing follows this effect's painted translation. + /// + /// Defaults to true, matching Flutter's [Transform.translate]. False is an + /// explicit paint-only mode: the widget draws at [offset], but any gesture + /// target inside it continues answering where layout originally placed it. + /// That split is useful for visual-only motion and surprising for controls, + /// so the framework's interactive-safe behavior is the default. final bool transformHitTests; /// Creates a [TranslateEffect] with the given [offset] and [fractional]. TranslateEffect({ this.offset = Offset.zero, this.fractional = false, - this.transformHitTests = false, + this.transformHitTests = true, }); @override @@ -404,5 +414,47 @@ class TranslateEffect extends Effect { ); @override - List get props => [offset, fractional, transformHitTests]; + TranslateEffect operator +(TranslateEffect other) => TranslateEffect( + offset: offset + other.offset, + fractional: fractional, + transformHitTests: transformHitTests, + ); + + @override + TranslateEffect operator -(TranslateEffect other) => TranslateEffect( + offset: offset - other.offset, + fractional: fractional, + transformHitTests: transformHitTests, + ); + + @override + TranslateEffect operator *(double factor) => TranslateEffect( + offset: offset * factor, + fractional: fractional, + transformHitTests: transformHitTests, + ); + + @override + double get magnitudeSquared => offset.distanceSquared; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is TranslateEffect && + other.runtimeType == runtimeType && + other.offset == offset && + other.fractional == fractional && + other.transformHitTests == transformHitTests; + } + + @override + int get hashCode => Object.hash( + offset, + fractional, + transformHitTests, + ); + + @override + String toString() => + 'TranslateEffect(offset: $offset, fractional: $fractional, transformHitTests: $transformHitTests)'; } diff --git a/lib/src/effects/vector_effect.dart b/lib/src/effects/vector_effect.dart new file mode 100644 index 0000000..4b3486c --- /dev/null +++ b/lib/src/effects/vector_effect.dart @@ -0,0 +1,56 @@ +import 'effect.dart'; + +/// Vector arithmetic over an effect's animatable values, in the style of +/// SwiftUI's `VectorArithmetic`. +/// +/// This is exactly the algebraic structure physics-based animation needs — +/// and nothing more. The closed-form spring solution is a combination of +/// scalar coefficients applied to vector quantities: +/// +/// x(t) = target + (x0 - target) * A(t) + v0 * B(t) +/// +/// so an effect that can add, subtract, and scale itself can be driven by +/// real spring physics, carry typed velocities (`(current - previous) * +/// (1 / dt)` is an effect-shaped "units per second"), and hand momentum +/// across retargets — without ever exposing its fields as raw numbers. +/// +/// Only an effect's ANIMATABLE values participate in the algebra. +/// Configuration fields (alignments, origins, flags) ride along unchanged +/// from the LEFT operand. +/// +/// Effects that don't mix this in simply keep the normalized lerp path; +/// physics features degrade gracefully. +mixin VectorEffect on Effect { + /// Adds two optional value lanes, keeping nullability from the LEFT + /// operand: a null left lane has no numeric value at all, and a null + /// right lane contributes nothing. + static double? addLane(double? a, double? b) { + if (a == null) return null; + if (b == null) return a; + return a + b; + } + + /// Subtracts two optional value lanes without ever producing NaN. + /// Nullability follows [addLane]; additionally, any non-finite operand + /// poisons the lane to null — `infinity - infinity` has no usable + /// displacement, and NaN must never enter the spring algebra. + static double? subtractLane(double? a, double? b) { + if (a == null) return null; + if (b == null) return a; + if (!a.isFinite || !b.isFinite) return null; + return a - b; + } + + /// Field-wise sum of animatable values. + T operator +(T other); + + /// Field-wise difference of animatable values. + T operator -(T other); + + /// Scales every animatable value by [factor]. + T operator *(double factor); + + /// The squared Euclidean magnitude of the animatable values, used for + /// settle detection. + double get magnitudeSquared; +} diff --git a/lib/src/motion/motion.dart b/lib/src/motion/motion.dart new file mode 100644 index 0000000..a932b2e --- /dev/null +++ b/lib/src/motion/motion.dart @@ -0,0 +1,926 @@ +import 'dart:math' as math; + +import 'package:flutter/physics.dart'; +import 'package:flutter/widgets.dart'; + +import 'simulation/curve_simulation.dart'; +import 'simulation/no_simulation.dart'; + +/// How an animation moves: the single timing object behind `animate`, +/// `immediate`, and timeline `step`s. +/// +/// Every `duration:` + `curve:` pair in this package is sugar for a +/// [CurvedMotion]; passing a [Motion] directly via `motion:` unlocks the +/// rest of the family — most importantly spring physics, which has no +/// fixed duration and no curve at all. +/// +/// The contract every motion answers: +/// +/// * [effectiveDuration] — how long the animation runs. Real for +/// deterministic motions; a computed physical settling bound for springs. +/// * [transform] — normalized time (0..1 across [effectiveDuration]) to +/// progress. Exact 0 at the start and exact 1 at the end so keyframe +/// handoffs stay precise; free to overshoot past 1 in between. +/// * [createSimulation] — the raw [Simulation], for physics-aware callers +/// that need positions and velocities in value space. +@immutable +abstract class Motion { + /// Creates a motion with the given settle [tolerance]. + const Motion({ + this.tolerance = Tolerance.defaultTolerance, + }); + + /// A deterministic motion: [duration] long, eased by [curve]. + const factory Motion.curved(Duration duration, [Curve curve]) = CurvedMotion; + + /// Creates a linear motion with a fixed duration. + const factory Motion.linear(Duration duration) = LinearMotion; + + /// A motion that holds still; see [NoMotion]. + const factory Motion.none([Duration duration]) = NoMotion; + + /// A spring with custom physics; see [SpringMotion]. + const factory Motion.customSpring(SpringDescription spring) = SpringMotion; + + /// An iOS-feel spring described by perceptual duration and bounce. + const factory Motion.cupertino({ + Duration duration, + double bounce, + bool snapToEnd, + }) = CupertinoMotion; + + /// Shorthand for [CupertinoMotion.bouncy]. + const factory Motion.bouncySpring({ + Duration duration, + double extraBounce, + bool snapToEnd, + }) = CupertinoMotion.bouncy; + + /// Shorthand for [CupertinoMotion.snappy]. + const factory Motion.snappySpring({ + Duration duration, + double extraBounce, + bool snapToEnd, + }) = CupertinoMotion.snappy; + + /// Shorthand for [CupertinoMotion.smooth]. + const factory Motion.smoothSpring({ + Duration duration, + double extraBounce, + bool snapToEnd, + }) = CupertinoMotion.smooth; + + /// Shorthand for [CupertinoMotion.interactive]. + const factory Motion.interactiveSpring({ + Duration duration, + double extraBounce, + bool snapToEnd, + }) = CupertinoMotion.interactive; + + /// The tolerance for this motion. + /// + /// Default is [Tolerance.defaultTolerance]. + final Tolerance tolerance; + + /// Whether this motion needs to settle. + /// + /// If this is true, the motion will continue to animate until the velocity + /// is less than the [tolerance], whenever it is supposed to be stopped. + bool get needsSettle; + + /// Whether this motion will settle without bounds. + /// + /// If this is false, this motion will never terminate without bounds. + bool get unboundedWillSettle; + + /// The effective duration of this motion. + /// + /// For deterministic motions this is the real duration. For physics-based + /// motions it is a computed settling bound: the time after which the + /// motion's distance from its target stays below [tolerance]. + Duration get effectiveDuration; + + /// Maps normalized time [t] — 0 at the start of [effectiveDuration], 1 at + /// its end — to this motion's progress value. + /// + /// The value may exceed 1.0 mid-flight (spring overshoot); effect lerps + /// extrapolate. Motions that reach their target return exactly 0 at t=0 + /// and exactly 1 at t=1 so keyframe handoffs stay precise. + double transform(double t); + + /// Creates a simulation for this motion. + /// + /// This method creates a [Simulation] object that defines how the animation + /// will progress over time based on the motion's characteristics. + /// + /// Parameters: + /// * [start] - The starting value for the simulation, defaults to 0. + /// * [end] - The ending value for the simulation, defaults to 1. + /// * [velocity] - The initial velocity for the simulation, defaults to 0. + /// + /// Returns a [Simulation] that can be used by an [AnimationController]. + Simulation createSimulation({ + double start = 0, + double end = 1, + double velocity = 0, + }); + + @override + bool operator ==(Object other); + + @override + int get hashCode; +} + +/// The classic deterministic motion: a fixed [duration] eased by a +/// [curve]. +/// +/// This is what every `duration:` + `curve:` sugar parameter in the +/// package compiles down to. It always completes on schedule and its +/// [transform] is simply the curve. +@immutable +class CurvedMotion extends Motion { + /// Creates a motion with a fixed duration and curve. + const CurvedMotion( + this.duration, [ + this.curve = Curves.linear, + ]) : super(tolerance: Tolerance.defaultTolerance); + + /// The total duration of the motion. + final Duration duration; + + /// The curve that defines the rate of change of the motion over time. + /// + /// Defaults to [Curves.linear], which represents a constant rate of change. + final Curve curve; + + @override + Duration get effectiveDuration => duration; + + @override + double transform(double t) => curve.transform(t.clamp(0.0, 1.0)); + + /// Whether this motion needs to settle. + /// + /// Always returns false for [CurvedMotion] because it completes in a + /// fixed duration. + @override + bool get needsSettle => false; + + /// Whether this motion will settle without bounds. + /// + /// Always returns true for [CurvedMotion] because it always terminates + /// after the specified duration. + @override + bool get unboundedWillSettle => true; + + /// Creates a new [CurvedMotion] with the given parameters. + CurvedMotion copyWith({ + Duration? duration, + Curve? curve, + }) => + CurvedMotion(duration ?? this.duration, curve ?? this.curve); + + /// Applies [curve] to the current [duration]. + CurvedMotion withCurve(Curve curve) => copyWith(curve: curve); + + @override + Simulation createSimulation({ + double start = 0, + double end = 1, + double velocity = 0, + }) { + return CurveSimulation( + duration: duration, + curve: curve, + start: start, + end: end, + tolerance: tolerance, + ); + } + + @override + bool operator ==(Object other) { + if (other is CurvedMotion) { + return duration == other.duration && curve == other.curve; + } + return false; + } + + /// Returns a hash code for this object. + @override + int get hashCode => Object.hash(duration, curve); + + /// Returns a string representation of this object. + @override + String toString() => 'CurvedMotion($duration, curve: $curve)'; +} + +/// A convenience class for a [CurvedMotion] that uses a linear curve. +class LinearMotion extends CurvedMotion { + /// Creates a linear motion with a fixed duration. + const LinearMotion(Duration duration) : super(duration, Curves.linear); + + @override + String toString() => 'LinearMotion($duration)'; +} + +/// A motion that never moves: it holds the starting value for [duration] +/// and never reaches its target. Useful as an explicit "do not animate" +/// placeholder. +class NoMotion extends Motion { + /// Creates a motion that holds still for [duration] (zero by default). + const NoMotion([this.duration = Duration.zero]); + + /// The duration that this motion holds its value. + final Duration duration; + + @override + String toString() => 'NoMotion($duration)'; + + @override + Duration get effectiveDuration => duration; + + @override + double transform(double t) => 0; + + @override + Simulation createSimulation({ + double start = 0, + double end = 1, + double velocity = 0, + }) { + return NoMotionSimulation( + duration: duration, + value: start, + tolerance: tolerance, + ); + } + + @override + bool get needsSettle => false; + + @override + bool get unboundedWillSettle => true; +} + +/// A motion driven by damped spring physics. +/// +/// Springs have no author-chosen duration: [effectiveDuration] is a +/// settling bound computed from the physics ([description]) and +/// [tolerance] — the time after which the spring's distance from its +/// target stays sub-perceptual. That bound is what lets springs slot into +/// the normalized-time machinery (timelines, seek, repeat) unchanged. +/// +/// [transform] samples the real simulation, so underdamped springs +/// genuinely overshoot past 1 mid-flight while both endpoints stay exact. +/// When the animated effect is a [VectorEffect], the animator additionally +/// runs the closed-form solution in effect space, which is what makes +/// mid-flight retargeting carry velocity. +/// +/// Equality compares the physics (mass, stiffness, damping), not the +/// preset identity that produced them. +@immutable +abstract class SpringMotion extends Motion { + /// Creates a spring from a raw physical [description] + /// (mass, stiffness, damping). + const factory SpringMotion( + SpringDescription description, { + bool snapToEnd, + }) = _DescriptionSpringMotion; + + /// Internal constructor; + const SpringMotion._({ + this.snapToEnd = false, + }); + + /// The physical description of the spring. + /// + /// Contains parameters like mass, stiffness, and damping that define + /// how the spring behaves. + SpringDescription get description; + + /// Whether to snap to the end of the spring. + /// + /// If true, the spring will snap to the end of the motion when the simulation + /// is done. + /// This ensures that the simulation will settle exactly to the target value. + final bool snapToEnd; + + static final Expando _simulations = Expando(); + + /// [effectiveDuration] is derived purely from the immutable [description] + /// and [tolerance], but it is read on every animation frame (directly and + /// via [transform]); memoizing per instance avoids re-solving the settling + /// bound each time. + static final Expando _durations = Expando(); + + @override + Duration get effectiveDuration => _durations[this] ??= _solveDuration(); + + Duration _solveDuration() { + final double omega = math.sqrt(description.stiffness / description.mass); + final double zeta = description.damping / + (2 * math.sqrt(description.stiffness * description.mass)); + if (zeta <= 0 || !zeta.isFinite || omega <= 0 || !omega.isFinite) { + throw StateError( + 'A spring with no damping never settles. Give $description a ' + 'positive damping value.', + ); + } + final double epsilon = tolerance.distance; + final double seconds; + if (zeta < 1) { + // Underdamped: bound by the oscillation envelope. + final double envelope = 1 / math.sqrt(1 - zeta * zeta); + seconds = math.log(envelope / epsilon) / (zeta * omega); + } else { + // Critically/overdamped: bound by the slow exponential pole, with + // headroom for critical damping's polynomial factor. + final double slowPole = + omega * (zeta - math.sqrt(math.max(0, zeta * zeta - 1))); + seconds = 1.25 * math.log(1 / epsilon) / slowPole; + } + return Duration( + microseconds: (seconds * Duration.microsecondsPerSecond).round(), + ); + } + + @override + double transform(double t) { + if (t <= 0) return 0; + if (t >= 1) return 1; + final SpringSimulation simulation = _simulations[this] ??= + SpringSimulation(description, 0, 1, 0, tolerance: tolerance); + final double seconds = + effectiveDuration.inMicroseconds / Duration.microsecondsPerSecond; + return simulation.x(t * seconds); + } + + /// Whether this motion needs to settle. + /// + /// Always returns true for [SpringMotion] because spring physics requires + /// the animation to continue until the spring naturally settles. + @override + bool get needsSettle => true; + + /// Whether this motion will settle without bounds. + /// + /// Returns false for [SpringMotion] because spring physics may not + /// necessarily terminate without bounds in all configurations. + @override + bool get unboundedWillSettle => false; + + /// Creates a simulation for this motion. + /// + /// Returns a [SpringSimulation] that follows the physical behavior + /// defined by the [description] description. + /// + /// Parameters: + /// * [start] - The starting value for the simulation, defaults to 0. + /// * [end] - The ending value (target position) for the simulation, + /// defaults to 1. + /// * [velocity] - The initial velocity of the spring, defaults to 0. + @override + Simulation createSimulation({ + double start = 0, + double end = 1, + double velocity = 0, + }) => + SpringSimulation( + description, + start, + end, + velocity, + tolerance: tolerance, + snapToEnd: snapToEnd, + ); + + /// Equality operator for [SpringMotion]. + /// + /// Two [SpringMotion] instances are considered equal if their [description] + /// descriptions have the same damping, mass, and stiffness values. + @override + bool operator ==(Object other) { + if (other is SpringMotion) { + return description.damping == other.description.damping && + description.mass == other.description.mass && + description.stiffness == other.description.stiffness; + } + return false; + } + + /// Returns a hash code for this object. + @override + int get hashCode => + Object.hash(description.damping, description.mass, description.stiffness); + + /// Returns a string representation of this object. + @override + String toString() => 'Spring(spring: $description)'; + + /// Creates a new [SpringMotion] with the same properties as this one, but + /// with the specified [description] and [snapToEnd]. + SpringMotion copyWith(); +} + +class _DescriptionSpringMotion extends SpringMotion { + /// Creates a new [SpringMotion] with the specified [description]. + const _DescriptionSpringMotion( + this.description, { + super.snapToEnd, + }) : super._(); + + @override + final SpringDescription description; + + @override + String toString() => 'Spring(description: $description)'; + + @override + SpringMotion copyWith({ + SpringDescription? description, + bool? snapToEnd, + }) { + return _DescriptionSpringMotion( + description ?? this.description, + snapToEnd: snapToEnd ?? this.snapToEnd, + ); + } +} + +/// Springs described the way iOS describes them: a perceptual [duration] +/// and a [bounce] amount, rather than raw mass/stiffness/damping. +/// +/// The named presets mirror the feel of the SwiftUI catalog: [bouncy], +/// [snappy], [smooth], and [interactive]. +class CupertinoMotion extends SpringMotion { + /// Creates a spring from a perceptual [duration] and [bounce]. + /// + /// The default is a smooth, no-bounce spring over 550ms. + const CupertinoMotion({ + this.duration = const Duration(milliseconds: 550), + this.bounce = 0, + super.snapToEnd, + }) : super._(); + + /// A visibly bouncy spring: 500ms perceptual duration, 0.3 bounce + /// (plus [extraBounce]). + const CupertinoMotion.bouncy({ + Duration duration = const Duration(milliseconds: 500), + double extraBounce = 0.0, + bool snapToEnd = false, + }) : this( + duration: duration, + bounce: 0.3 + extraBounce, + snapToEnd: snapToEnd, + ); + + /// A quick spring with a small bounce: 500ms perceptual duration, + /// 0.15 bounce (plus [extraBounce]). + const CupertinoMotion.snappy({ + Duration duration = const Duration(milliseconds: 500), + double extraBounce = 0.0, + bool snapToEnd = false, + }) : this( + duration: duration, + bounce: 0.15 + extraBounce, + snapToEnd: snapToEnd, + ); + + /// A no-bounce spring: 500ms perceptual duration, critically smooth + /// unless [extraBounce] is given. + const CupertinoMotion.smooth({ + Duration duration = const Duration(milliseconds: 500), + double extraBounce = 0.0, + bool snapToEnd = false, + }) : this( + duration: duration, + bounce: extraBounce, + snapToEnd: snapToEnd, + ); + + /// A tight, fast spring for gesture-driven animation: 150ms perceptual + /// duration, 0.14 bounce (plus [extraBounce]). + const CupertinoMotion.interactive({ + Duration duration = const Duration(milliseconds: 150), + double extraBounce = 0.0, + bool snapToEnd = false, + }) : this( + duration: duration, + bounce: 0.14 + extraBounce, + snapToEnd: snapToEnd, + ); + + /// The perceptual duration of one spring period. Note this is NOT the + /// settling time — see [effectiveDuration] for that. + final Duration duration; + + /// How much the spring bounces: 0 is smooth, higher values overshoot + /// more before settling. + final double bounce; + + @override + SpringDescription get description => SpringDescription.withDurationAndBounce( + duration: duration, + bounce: bounce, + ); + + /// Creates a new [CupertinoMotion] with the same properties as this one, but + /// with the specified [bounce] and [duration]. + @override + CupertinoMotion copyWith({ + Duration? duration, + double? bounce, + bool? snapToEnd, + }) { + return CupertinoMotion( + duration: duration ?? description.duration, + bounce: bounce ?? description.bounce, + snapToEnd: snapToEnd ?? this.snapToEnd, + ); + } +} + +/// The Material Design 3 expressive spring tokens, as plain physics. +/// +/// Two families — spatial (positions, sizes, layout) and effects +/// (opacity, color, non-spatial properties) — in standard and expressive +/// flavors, each with fast/default/slow variants. Effects tokens are +/// critically damped on purpose: visual properties should not visibly +/// oscillate. +class MaterialSpringMotion extends SpringMotion { + /// Creates a new [MaterialSpringMotion] with the specified damping and + /// stiffness. + const MaterialSpringMotion._({ + required this.damping, + required this.stiffness, + super.snapToEnd, + }) : super._(); + + /// The standard spatial fast token. + const MaterialSpringMotion.standardSpatialFast({ + bool snapToEnd = false, + }) : this._( + damping: 0.9, + stiffness: 1400, + snapToEnd: snapToEnd, + ); + + /// The standard spatial default token. + const MaterialSpringMotion.standardSpatialDefault({ + bool snapToEnd = false, + }) : this._( + damping: 0.9, + stiffness: 700, + snapToEnd: snapToEnd, + ); + + /// The standard spatial slow token. + const MaterialSpringMotion.standardSpatialSlow({ + bool snapToEnd = false, + }) : this._( + damping: 0.9, + stiffness: 300, + snapToEnd: snapToEnd, + ); + + /// The standard effects fast token. + const MaterialSpringMotion.standardEffectsFast({ + bool snapToEnd = false, + }) : this._( + damping: 1, + stiffness: 3800, + snapToEnd: snapToEnd, + ); + + /// The standard effects default token. + const MaterialSpringMotion.standardEffectsDefault({ + bool snapToEnd = false, + }) : this._( + damping: 1, + stiffness: 1600, + snapToEnd: snapToEnd, + ); + + /// The standard effects slow token. + const MaterialSpringMotion.standardEffectsSlow({ + bool snapToEnd = false, + }) : this._( + damping: 1, + stiffness: 800, + snapToEnd: snapToEnd, + ); + + /// The expressive spatial fast token. + const MaterialSpringMotion.expressiveSpatialFast({ + bool snapToEnd = false, + }) : this._( + damping: 0.6, + stiffness: 800, + snapToEnd: snapToEnd, + ); + + /// The expressive spatial default token. + const MaterialSpringMotion.expressiveSpatialDefault({ + bool snapToEnd = false, + }) : this._( + damping: 0.8, + stiffness: 380, + snapToEnd: snapToEnd, + ); + + /// The expressive spatial slow token. + const MaterialSpringMotion.expressiveSpatialSlow({ + bool snapToEnd = false, + }) : this._( + damping: 0.8, + stiffness: 200, + snapToEnd: snapToEnd, + ); + + /// The expressive effects fast token. + const MaterialSpringMotion.expressiveEffectsFast({ + bool snapToEnd = false, + }) : this._( + damping: 1, + stiffness: 3800, + snapToEnd: snapToEnd, + ); + + /// The expressive effects default token. + const MaterialSpringMotion.expressiveEffectsDefault({ + bool snapToEnd = false, + }) : this._( + damping: 1, + stiffness: 1600, + snapToEnd: snapToEnd, + ); + + /// The expressive effects slow token. + const MaterialSpringMotion.expressiveEffectsSlow({ + bool snapToEnd = false, + }) : this._( + damping: 1, + stiffness: 800, + snapToEnd: snapToEnd, + ); + + /// The damping factor of the spring motion. + /// + /// Works exactly like the [SpringDescription.damping] property. + final double damping; + + /// The stiffness factor of the spring motion. + /// + /// Works exactly like the [SpringDescription.stiffness] property. + final double stiffness; + + @override + SpringDescription get description => SpringDescription.withDampingRatio( + ratio: damping, + stiffness: stiffness, + mass: 1, + ); + + @override + MaterialSpringMotion copyWith({ + double? damping, + double? stiffness, + bool? snapToEnd, + }) { + return MaterialSpringMotion._( + damping: damping ?? this.damping, + stiffness: stiffness ?? this.stiffness, + snapToEnd: snapToEnd ?? this.snapToEnd, + ); + } +} + +/// A window onto another motion: plays only the [fromStart]..(1 - [fromEnd]) +/// portion of [parent]'s characteristic curve, renormalized to 0..1. +/// +/// For deterministic parents the window is exact. For springs it is an +/// approximation — the parent simulation is stretched over a wider range +/// and mapped back — close in feel, not physics-exact at every instant. +/// +/// Built via [MotionTrimming.trimmed], [MotionTrimming.sliced], or +/// [MotionTrimming.segment]. +@immutable +class TrimmedMotion extends Motion { + /// Creates a window onto [parent], trimming [fromStart] off the + /// beginning and [fromEnd] off the end. + + const TrimmedMotion({ + required this.parent, + required this.fromStart, + required this.fromEnd, + }) : assert(fromStart >= 0.0, 'fromStart must be non-negative'), + assert(fromEnd >= 0.0, 'fromEnd must be non-negative'), + assert( + fromStart + fromEnd <= 1.0, + 'fromStart + fromEnd must be less than 1.0, ' + 'but received $fromStart + $fromEnd', + ); + + /// The motion to trim. + final Motion parent; + + /// Amount to trim from the start of the motion curve. + final double fromStart; + + /// Amount to trim from the end of the motion curve. + final double fromEnd; + + @override + Duration get effectiveDuration { + final double extent = 1.0 - fromStart - fromEnd; + return Duration( + microseconds: (parent.effectiveDuration.inMicroseconds * extent).round(), + ); + } + + @override + double transform(double t) { + if (t <= 0) return 0; + if (t >= 1) return 1; + final double extent = 1.0 - fromStart - fromEnd; + final double a = parent.transform(fromStart); + final double b = parent.transform(1 - fromEnd); + if (a == b) return 1; + final double raw = parent.transform(fromStart + t * extent); + return (raw - a) / (b - a); + } + + @override + bool get needsSettle => parent.needsSettle; + + @override + bool get unboundedWillSettle => parent.unboundedWillSettle; + + @override + Tolerance get tolerance => parent.tolerance; + + @override + Simulation createSimulation({ + double start = 0, + double end = 1, + double velocity = 0, + }) { + final trimmedExtent = 1.0 - fromStart - fromEnd; + + // We simulate the parent over the extended range + final parentStart = start - trimmedExtent * fromStart; + final parentEnd = end + trimmedExtent * fromEnd; + + final scaledSim = parent.createSimulation( + start: parentStart, + end: parentEnd, + velocity: velocity, + ); + + return _TrimmedSimulation( + parent: scaledSim, + startTrim: fromStart, + endTrim: fromEnd, + trimmedExtent: trimmedExtent, + start: start, + end: end, + ); + } + + @override + bool operator ==(Object other) { + if (other is TrimmedMotion) { + return parent == other.parent && + fromStart == other.fromStart && + fromEnd == other.fromEnd; + } + return false; + } + + @override + int get hashCode => Object.hash(parent, fromStart, fromEnd); + + @override + String toString() => + 'TrimmedMotion(parent: $parent, trim: $fromStart-$fromEnd)'; +} + +class _TrimmedSimulation extends Simulation { + _TrimmedSimulation({ + required this.parent, + required this.startTrim, + required this.endTrim, + required this.trimmedExtent, + required this.start, + required this.end, + }) : _duration = _findParentDuration(parent) * trimmedExtent, + super(tolerance: parent.tolerance); + + final Simulation parent; + final double startTrim; + final double endTrim; + final double trimmedExtent; + final double start; + final double end; + final double _duration; + + static double _findParentDuration(Simulation parent) { + // For most simulations, check when isDone returns true + for (var t = 0.01; t <= 10; t += 0.01) { + if (parent.isDone(t)) { + return t; + } + } + return 1; // fallback + } + + @override + double x(double time) { + if (time <= 0) return start; + if (time >= _duration) return end; + + // Map our time to the parent's time range + final parentDuration = _duration / trimmedExtent; + final progressStart = startTrim; + final progressEnd = 1.0 - endTrim; + + // Scale time to fit within trimmed range + final normalizedTime = time / _duration; + final parentTime = parentDuration * + (progressStart + (progressEnd - progressStart) * normalizedTime); + + // Get parent's values to normalize + final parentStartValue = parent.x(parentDuration * progressStart); + final parentEndValue = parent.x(parentDuration * progressEnd); + final parentCurrentValue = parent.x(parentTime); + + // Normalize and map to our range + if ((parentEndValue - parentStartValue).abs() < 1e-10) { + return start + (end - start) * normalizedTime; + } + + final normalizedProgress = (parentCurrentValue - parentStartValue) / + (parentEndValue - parentStartValue); + return start + (end - start) * normalizedProgress; + } + + @override + double dx(double time) { + if (time < 0 || time > _duration) return 0; + + // Use numerical differentiation + const delta = 0.001; + final x1 = x(time - delta); + final x2 = x(time + delta); + return (x2 - x1) / (2 * delta); + } + + @override + bool isDone(double time) => time >= _duration - tolerance.time; +} + +/// Windowing helpers that carve a [TrimmedMotion] out of any motion. +extension MotionTrimming on Motion { + /// Trims [fromStart] off the beginning and [fromEnd] off the end of this + /// motion's curve; what remains plays as a full 0..1 motion. + TrimmedMotion trimmed({ + double fromStart = 0.0, + double fromEnd = 0.0, + }) { + return TrimmedMotion( + parent: this, + fromStart: fromStart, + fromEnd: fromEnd, + ); + } + + /// The [from]..[to] window of this motion's curve, as a full motion. + TrimmedMotion sliced({ + double from = 0.0, + double to = 1.0, + }) { + assert(from >= 0.0 && from <= 1.0, 'from must be between 0 and 1'); + assert(to >= 0.0 && to <= 1.0, 'to must be between 0 and 1'); + assert(from <= to, 'from cannot be greater than to'); + return TrimmedMotion( + parent: this, + fromStart: from, + fromEnd: 1.0 - to, + ); + } + + /// A window of this motion's curve described by [start] and [length]. + TrimmedMotion segment({ + required double length, + double start = 0.0, + }) { + assert(start + length <= 1.0, 'start + length cannot be larger than 1'); + return TrimmedMotion( + parent: this, + fromStart: start, + fromEnd: 1.0 - (start + length), + ); + } +} diff --git a/lib/src/motion/simulation/curve_simulation.dart b/lib/src/motion/simulation/curve_simulation.dart new file mode 100644 index 0000000..c875a53 --- /dev/null +++ b/lib/src/motion/simulation/curve_simulation.dart @@ -0,0 +1,56 @@ +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +@internal +class CurveSimulation extends Simulation { + CurveSimulation({ + required this.duration, + required this.curve, + required this.start, + required this.end, + required super.tolerance, + }); + + /// The duration of the curve. + final Duration duration; + + /// The curve to use for the simulation. + final Curve curve; + + /// The start value of the curve. + final double start; + + /// The end value of the curve. + final double end; + + @override + double x(double time) { + final relativeTime = time / duration.toSeconds(); + + if (relativeTime > 1) { + return end; + } + + final t = curve.transform(relativeTime.clamp(0, 1)); + + return start + (end - start) * t; + } + + @override + double dx(double time) { + // Calculate the approximate derivative using a small delta + final delta = tolerance.distance; + final x1 = x(time - delta); + final x2 = x(time + delta); + + // Return the rate of change (velocity) via central difference. + return (x2 - x1) / (2 * delta); + } + + @override + bool isDone(double time) => time > duration.toSeconds(); +} + +extension on Duration { + double toSeconds() => inMicroseconds / Duration.microsecondsPerSecond; +} diff --git a/lib/src/motion/simulation/no_simulation.dart b/lib/src/motion/simulation/no_simulation.dart new file mode 100644 index 0000000..161d6d5 --- /dev/null +++ b/lib/src/motion/simulation/no_simulation.dart @@ -0,0 +1,34 @@ +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +@internal +class NoMotionSimulation extends Simulation { + NoMotionSimulation({ + required this.duration, + required this.value, + required super.tolerance, + }); + + /// The duration of the curve. + final Duration duration; + + /// The start value of the curve. + final double value; + + @override + double x(double time) { + return value; + } + + @override + double dx(double time) { + return 0; + } + + @override + bool isDone(double time) => time > duration.toSeconds(); +} + +extension on Duration { + double toSeconds() => inMicroseconds / Duration.microsecondsPerSecond; +} diff --git a/lib/src/motion/vector_spring.dart b/lib/src/motion/vector_spring.dart new file mode 100644 index 0000000..0d9c86d --- /dev/null +++ b/lib/src/motion/vector_spring.dart @@ -0,0 +1,80 @@ +import 'dart:math' as math; + +import 'package:flutter/physics.dart'; + +/// The scalar coefficients of the closed-form spring solution at time [t]: +/// +/// x(t) = target + d0 * a + v0 * b +/// v(t) = d0 * da + v0 * db +/// +/// where `d0 = x0 - target` is the initial displacement and `v0` the +/// initial velocity — both of which may be VECTOR quantities (effects), +/// since the coefficients themselves are plain scalars. +class SpringCoefficients { + /// Weight of the initial displacement in the position. + final double a; + + /// Weight of the initial velocity in the position. + final double b; + + /// Weight of the initial displacement in the velocity. + final double da; + + /// Weight of the initial velocity in the velocity. + final double db; + + /// Creates spring coefficients. + const SpringCoefficients({ + required this.a, + required this.b, + required this.da, + required this.db, + }); +} + +/// Evaluates the closed-form solution of [spring] at time [t] seconds. +SpringCoefficients springCoefficients(SpringDescription spring, double t) { + final double omega = math.sqrt(spring.stiffness / spring.mass); + final double zeta = + spring.damping / (2 * math.sqrt(spring.stiffness * spring.mass)); + + if (zeta < 1 - 1e-9) { + // Underdamped: decaying oscillation. + final double omegaD = omega * math.sqrt(1 - zeta * zeta); + final double envelope = math.exp(-zeta * omega * t); + final double sin = math.sin(omegaD * t); + final double cos = math.cos(omegaD * t); + final double ratio = zeta * omega / omegaD; + return SpringCoefficients( + a: envelope * (cos + ratio * sin), + b: envelope * sin / omegaD, + da: -envelope * (omega * omega / omegaD) * sin, + db: envelope * (cos - ratio * sin), + ); + } + + if (zeta < 1 + 1e-9) { + // Critically damped: fastest non-oscillating return. + final double envelope = math.exp(-omega * t); + return SpringCoefficients( + a: envelope * (1 + omega * t), + b: envelope * t, + da: -envelope * omega * omega * t, + db: envelope * (1 - omega * t), + ); + } + + // Overdamped: two real exponential poles. + final double root = math.sqrt(zeta * zeta - 1); + final double r1 = omega * (-zeta + root); + final double r2 = omega * (-zeta - root); + final double denominator = r1 - r2; + final double e1 = math.exp(r1 * t); + final double e2 = math.exp(r2 * t); + return SpringCoefficients( + a: (r1 * e2 - r2 * e1) / denominator, + b: (e1 - e2) / denominator, + da: r1 * r2 * (e2 - e1) / denominator, + db: (r1 * e1 - r2 * e2) / denominator, + ); +} diff --git a/lib/src/pointer_transition.dart b/lib/src/pointer_transition.dart index aa50ca3..b94c2cd 100644 --- a/lib/src/pointer_transition.dart +++ b/lib/src/pointer_transition.dart @@ -1,3 +1,5 @@ +import 'package:flutter/gestures.dart' + show PointerDeviceKind, PointerRemovedEvent; import 'package:flutter/widgets.dart'; import '../hyper_effects.dart'; @@ -25,13 +27,28 @@ class PointerTransitionEvent { /// Whether the pointer device is inside the bounds of the widget. final bool isInsideBounds; + /// Whether a pointer device is currently pressed down, regardless of + /// whether it is inside the bounds of the widget. A mouse button being + /// held or a touch contact on the screen both count as down. + final bool isDown; + + /// Whether the pointer device is pressed down inside the bounds of the + /// widget. This is the typical condition for a button's pressed state: + /// holding a button down then dragging off of it makes this false again. + bool get isPressed => isDown && isInsideBounds; + + /// Whether the pointer device is inside the bounds of the widget without + /// being pressed down. + bool get isHovering => !isDown && isInsideBounds; + /// Creates a new [PointerTransitionEvent] with the given [position], - /// [value], [valueOffset], and [isInsideBounds]. + /// [value], [valueOffset], [isInsideBounds], and [isDown]. const PointerTransitionEvent({ required this.position, required this.value, required this.valueOffset, required this.isInsideBounds, + this.isDown = false, }); } @@ -195,6 +212,9 @@ class _PointerTransitionState extends State /// Whether the pointer device was inside the bounds of the widget. bool wasInsideBounds = false; + /// Whether a pointer device is currently pressed down. + bool isPointerDown = false; + @override void initState() { super.initState(); @@ -254,9 +274,39 @@ class _PointerTransitionState extends State void updateState(PointerEvent event) { globalPosition = event.position; + + if (event is PointerRemovedEvent) { + // A removed device no longer has a cursor that can hover. In + // particular, iOS represents each indirect-pointer click as a + // short-lived mouse device and removes it immediately after pointer-up. + isPointerDown = false; + resetValue(); + if (mounted) setState(() {}); + return; + } else if (event is PointerDownEvent) { + isPointerDown = true; + } else if (event is PointerUpEvent || event is PointerCancelEvent) { + isPointerDown = false; + + if (event.kind != PointerDeviceKind.mouse) { + // A lifted or cancelled touch/stylus contact ceases to exist, so + // unlike a mouse cursor it cannot remain hovering inside the + // bounds of the widget after release. + resetValue(); + if (mounted) setState(() {}); + return; + } + } + recalculateValue(); } + void handlePointerExit(PointerEvent event) { + globalPosition = event.position; + resetValue(); + if (mounted) setState(() {}); + } + /// Calculates the current value offset of the pointer device based on the /// [origin] and [globalPosition]. void recalculateValue() { @@ -391,16 +441,37 @@ class _PointerTransitionState extends State value: currentValue, valueOffset: currentValueOffset, isInsideBounds: wasInsideBounds, + isDown: isPointerDown, ), ) ?? widget.child; - if (!widget.usePointerRouter) { + if (widget.usePointerRouter && !widget.useGlobalPointer) { + // Global routes only run when the pointer emits an event. A MouseRegion + // also tracks enter/exit changes caused by this widget moving beneath a + // stationary cursor, keeping the bounds and hover state from going + // stale during layout or transform animations. child = MouseRegion( - onHover: (event) => updateState(event), - onExit: (event) => resetValue(), + opaque: false, + onEnter: updateState, + onExit: handlePointerExit, child: child, ); + } else if (!widget.usePointerRouter) { + // MouseRegion only reports hover events, which stop while a button is + // pressed, so down/up/move tracking needs a Listener alongside it. + child = Listener( + onPointerDown: updateState, + onPointerMove: updateState, + onPointerUp: updateState, + onPointerCancel: updateState, + child: MouseRegion( + onEnter: updateState, + onHover: (event) => updateState(event), + onExit: handlePointerExit, + child: child, + ), + ); } return AnimatedBuilder( diff --git a/lib/src/post_frame_widget.dart b/lib/src/post_frame_widget.dart index a173922..dbf3295 100644 --- a/lib/src/post_frame_widget.dart +++ b/lib/src/post_frame_widget.dart @@ -1,7 +1,6 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; -/// A widget that doesn't render first frame. This is useful for widgets that -/// need to be rendered after the first frame +/// A widget that doesn't render first frame. class PostFrame extends StatefulWidget { /// The widget below this widget in the tree. final Widget child; diff --git a/lib/src/timeline/timeline_controller.dart b/lib/src/timeline/timeline_controller.dart new file mode 100644 index 0000000..82d795e --- /dev/null +++ b/lib/src/timeline/timeline_controller.dart @@ -0,0 +1,67 @@ +import 'package:flutter/animation.dart'; +import 'package:flutter/foundation.dart'; + +import 'timeline_effect.dart'; + +/// An external handle for driving a [TimelineEffect] imperatively. +/// +/// A [TimelineController] can be owned anywhere — a State, a cubit, a +/// service — and outlives remounts of the widget it drives. Attach it via +/// the `controller` parameter of `.timeline()`. +/// +/// Listeners are notified whenever the timeline's [progress] changes. +class TimelineController extends ChangeNotifier { + AnimationController? _driven; + + /// Whether this controller is currently attached to a mounted + /// [TimelineEffect]. + bool get isAttached => _driven != null; + + AnimationController get _controller { + final AnimationController? driven = _driven; + if (driven == null) { + throw StateError( + 'This TimelineController is not attached to a TimelineEffect. ' + 'Pass it to `.timeline(controller: ...)` and ensure that widget is ' + 'mounted before driving it.', + ); + } + return driven; + } + + /// The timeline's current position, 0..1. + double get progress => _controller.value; + + /// Plays the timeline forward from its current position. + TickerFuture play() => _controller.forward(); + + /// Plays the timeline backward from its current position. + TickerFuture reverse() => _controller.reverse(); + + /// Freezes the timeline at its current position. + void pause() => _controller.stop(); + + /// Jumps the timeline to [progress] (0..1) without animating. + void seek(double progress) { + _controller.value = progress.clamp(0.0, 1.0); + } + + /// Binds this controller to a [TimelineEffect]'s internal animation + /// controller. Called by [TimelineEffect]; not part of the public API. + @internal + void attach(AnimationController controller) { + _driven?.removeListener(notifyListeners); + _driven = controller; + controller.addListener(notifyListeners); + } + + /// Unbinds this controller if it is currently bound to [controller]. + /// Called by [TimelineEffect]; not part of the public API. + @internal + void detach(AnimationController controller) { + if (_driven == controller) { + controller.removeListener(notifyListeners); + _driven = null; + } + } +} diff --git a/lib/src/timeline/timeline_effect.dart b/lib/src/timeline/timeline_effect.dart new file mode 100644 index 0000000..abb9f3f --- /dev/null +++ b/lib/src/timeline/timeline_effect.dart @@ -0,0 +1,208 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import '../effects/effect.dart'; +import 'timeline_controller.dart'; +import 'timeline_spec.dart'; + +/// Provides the entry point that turns a declarative effect/step chain into +/// a running timeline. +extension TimelineEffectExt on Widget { + /// Compiles the effect/[TimelineChainExt.step] chain this is called on + /// into a single-controller timeline and plays it on [trigger] changes. + /// + /// Unlike `animate`, every effect in the chain is a keyframe VALUE on a + /// shared timeline — values are absolute and hand off between keyframes, + /// they do not stack. + /// + /// A [trigger] is purely an identity signal: any change restarts the + /// timeline from the beginning and plays it forward. It never implies a + /// direction. For directional or positional control — reversing, pausing, + /// seeking, scrubbing — attach a [TimelineController] instead. + /// + /// Passing the sentinel `#immediate` as the [trigger] plays the timeline + /// as soon as it is mounted — once per State lifetime, since rebuilds do + /// not change the sentinel's identity. Combined with [repeat] this + /// expresses auto-playing and looping timelines without any imperative + /// code. + Widget timeline({ + Object? trigger, + TimelineController? controller, + VoidCallback? onEnd, + int repeat = 0, + }) => + TimelineEffect( + trigger: trigger, + controller: controller, + onEnd: onEnd, + repeat: repeat, + chain: this, + ); +} + +/// A widget that drives a compiled [TimelineSpec] with a single +/// [AnimationController]. +class TimelineEffect extends StatefulWidget { + /// The declarative effect/step chain to compile. + final Widget chain; + + /// An identity signal: any change restarts the timeline from the + /// beginning, playing forward. Direction is never inferred from the + /// trigger's value; use a [controller] for directional control. + /// + /// The sentinel `#immediate` plays the timeline as soon as it mounts, + /// once per State lifetime. + final Object? trigger; + + /// An optional external handle for driving this timeline imperatively. + final TimelineController? controller; + + /// Called when the timeline completes in either direction. + final VoidCallback? onEnd; + + /// How many ADDITIONAL times a run plays after its first cycle: 0 plays + /// once, 2 plays three cycles, any negative value loops forever. Applies to every run + /// started by a [trigger] change or the `#immediate` sentinel. + final int repeat; + + /// Creates a [TimelineEffect]. + const TimelineEffect({ + super.key, + required this.chain, + this.trigger, + this.controller, + this.onEnd, + this.repeat = 0, + }); + + @override + State createState() => TimelineEffectState(); +} + +/// The state of a [TimelineEffect]. +class TimelineEffectState extends State + with SingleTickerProviderStateMixin { + /// The compiled timeline. Recompiled on every widget update so keyframe + /// values that depend on outside state stay current. + late TimelineSpec spec = TimelineSpec.compile(widget.chain); + + /// The single controller that drives every track of the timeline. + late final AnimationController controller = AnimationController( + vsync: this, + duration: spec.totalDuration, + )..addStatusListener(_onStatusChanged); + + @override + void initState() { + super.initState(); + widget.controller?.attach(controller); + if (widget.trigger == #immediate) { + _play(); + } + } + + /// Invalidates in-flight cycle loops when a newer run takes over. + int _runGeneration = 0; + + /// Cycles left in the current run AFTER the one in flight. While positive, + /// a completed status is a cycle boundary, not the end of the timeline. + int _pendingCycles = 0; + + /// Starts a run from the beginning, honoring the widget's repeat budget. + void _play() { + _runGeneration++; + _pendingCycles = 0; + if (widget.repeat < 0) { + if (spec.totalDuration == Duration.zero) { + throw FlutterError( + 'A timeline asked to repeat has no duration to repeat over. A ' + 'chain with no step() compiles to a single keyframe, which is a ' + 'static value, not an animation. Separate two keyframes with a ' + 'step(duration: ...) — the timeline loops over the span between ' + 'them — or drop the repeat to apply the keyframe as-is.', + ); + } + // Native repeat is safe here: it never completes, so it cannot rest + // at a wrapped value the way a counted repeat would. + controller.value = 0; + controller.repeat(); + } else { + unawaited(_driveCycles(widget.repeat + 1)); + } + } + + /// Plays [cycles] full forward passes, resting at the final keyframe. + /// + /// Uses `.orCancel` so an interruption (new run, seek, pause, dispose) + /// surfaces as [TickerCanceled] instead of leaving a future that never + /// resolves. + Future _driveCycles(int cycles) async { + _runGeneration++; + final int generation = _runGeneration; + for (int i = 0; i < cycles; i++) { + if (!mounted || generation != _runGeneration) return; + // Rewinding emits a synchronous `dismissed`; keep the count high until + // the cycle is running so that rewind never reads as a terminal end. + _pendingCycles = cycles - i; + final TickerFuture ticket = controller.forward(from: 0); + _pendingCycles = cycles - i - 1; + try { + await ticket.orCancel; + } on TickerCanceled { + // A newer run owns the controller now; only a still-current loop + // may clear its own bookkeeping. + if (generation == _runGeneration) { + _pendingCycles = 0; + } + return; + } + } + } + + void _onStatusChanged(AnimationStatus status) { + if (status == AnimationStatus.completed || + status == AnimationStatus.dismissed) { + if (_pendingCycles > 0) return; + widget.onEnd?.call(); + } + } + + @override + void didUpdateWidget(covariant TimelineEffect oldWidget) { + super.didUpdateWidget(oldWidget); + spec = TimelineSpec.compile(widget.chain); + controller.duration = spec.totalDuration; + + if (widget.controller != oldWidget.controller) { + oldWidget.controller?.detach(controller); + widget.controller?.attach(controller); + } + + if (widget.trigger != oldWidget.trigger) { + _play(); + } + } + + @override + void dispose() { + widget.controller?.detach(controller); + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, child) { + Widget result = child!; + for (final Effect effect in spec.evaluate(controller.value)) { + result = effect.apply(context, result); + } + return result; + }, + child: spec.child, + ); + } +} diff --git a/lib/src/timeline/timeline_spec.dart b/lib/src/timeline/timeline_spec.dart new file mode 100644 index 0000000..0d7cc22 --- /dev/null +++ b/lib/src/timeline/timeline_spec.dart @@ -0,0 +1,254 @@ +import 'package:flutter/widgets.dart'; + +import '../../hyper_effects.dart'; +import '../utils.dart'; + +/// A marker widget that splits a timeline chain into segments. +/// +/// Effects declared before a [TimelineStep] belong to the keyframe on its +/// inner side; effects declared after it belong to the next keyframe. The +/// step itself describes how the timeline animates from the former to the +/// latter: over [duration], along [curve], after waiting [delay]. +/// +/// This widget is consumed by the `.timeline()` extension and never renders +/// anything itself. If left uncompiled in a tree it renders its child +/// unchanged. +class TimelineStep extends StatelessWidget { + /// How the timeline moves to the next keyframe: a [CurvedMotion] built + /// from the duration/curve sugar, or any [Motion] (springs included) + /// passed explicitly. + final Motion motion; + + /// A hold before this segment starts, during which the previous keyframe's + /// values remain applied. + final Duration delay; + + /// The widget below this step in the chain. + final Widget child; + + /// Creates a [TimelineStep]. + TimelineStep({ + super.key, + required this.child, + Duration? duration, + Curve? curve, + Motion? motion, + this.delay = Duration.zero, + }) : motion = Utils.resolveMotion(motion, duration, curve); + + @override + Widget build(BuildContext context) => child; +} + +/// Provides the chain methods that build up a timeline declaration. +extension TimelineChainExt on Widget { + /// Marks the boundary between two keyframes of a timeline chain. + /// + /// Effects declared since the previous [step] (or since the start of the + /// chain) form one keyframe; the effects declared after this call form the + /// next. The transition between them runs for [duration] along [curve], + /// starting after [delay] of holding the previous keyframe. + Widget step({ + Duration? duration, + Curve? curve, + Motion? motion, + Duration delay = Duration.zero, + }) => + TimelineStep( + duration: duration, + curve: curve, + motion: motion, + delay: delay, + child: this, + ); +} + +/// One segment of a compiled timeline: the transition between two adjacent +/// keyframes. +class TimelineSegment { + /// How the transition moves. + final Motion motion; + + /// A hold before the transition starts. + final Duration delay; + + /// Creates a [TimelineSegment]. + const TimelineSegment({ + required this.motion, + required this.delay, + }); + + /// The length of the transition: the motion's effective duration. + Duration get duration => motion.effectiveDuration; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is TimelineSegment && + other.runtimeType == runtimeType && + other.motion == motion && + other.delay == delay; + } + + @override + int get hashCode => Object.hash(motion, delay); + + @override + String toString() => 'TimelineSegment(motion: $motion, delay: $delay)'; +} + +/// The keyframe values of a single effect type across an entire timeline. +class TimelineTrack { + /// The runtime type of the [Effect] this track animates. + final Type effectType; + + /// One value per keyframe; length is `segments.length + 1`. + final List keyframes; + + /// Creates a [TimelineTrack]. + const TimelineTrack({required this.effectType, required this.keyframes}); +} + +/// A compiled timeline: an ordered list of [TimelineSegment]s and the +/// per-effect-type [TimelineTrack]s that animate across them, all driven by +/// a single animation value. +class TimelineSpec { + /// The transitions between adjacent keyframes, in play order. + final List segments; + + /// The per-effect-type keyframe tracks. + final List tracks; + + /// The content widget the effects apply to. + final Widget child; + + /// Creates a [TimelineSpec]. + const TimelineSpec({ + required this.segments, + required this.tracks, + required this.child, + }); + + /// The total duration of the timeline: the sum of every segment's delay + /// and duration. + Duration get totalDuration => segments.fold( + Duration.zero, + (total, segment) => total + segment.delay + segment.duration, + ); + + /// Evaluates every track at linear time [t], where 0 is the start of the + /// timeline and 1 is [totalDuration]. Values outside 0..1 clamp to the + /// first/last keyframe. Returns one [Effect] per track, in track order. + List evaluate(double t) { + final double clamped = t.clamp(0.0, 1.0); + final int totalMicros = totalDuration.inMicroseconds; + if (segments.isEmpty || totalMicros == 0) { + return [for (final track in tracks) track.keyframes.last]; + } + + final double timeMicros = clamped * totalMicros; + double cursor = 0; + int index = segments.length - 1; + double progress = 1; + for (int i = 0; i < segments.length; i++) { + final TimelineSegment segment = segments[i]; + final double delay = segment.delay.inMicroseconds.toDouble(); + final double duration = segment.duration.inMicroseconds.toDouble(); + final double end = cursor + delay + duration; + if (timeMicros <= end || i == segments.length - 1) { + index = i; + if (timeMicros <= cursor + delay) { + // Inside the delay window: hold the previous keyframe. + progress = 0; + } else if (duration == 0) { + progress = 1; + } else { + progress = ((timeMicros - cursor - delay) / duration).clamp(0.0, 1.0); + } + break; + } + cursor = end; + } + + final double curved = segments[index].motion.transform(progress); + return [ + for (final track in tracks) + track.keyframes[index].lerp(track.keyframes[index + 1], curved), + ]; + } + + /// Compiles a declarative timeline chain into a [TimelineSpec]. + /// + /// [root] is the outermost widget of the chain: the widget that + /// `.timeline()` was called on. The chain is walked inward, collecting + /// [EffectWidget]s into keyframes and splitting keyframes at every + /// [TimelineStep], until the first widget that is neither — the content + /// [child]. + static TimelineSpec compile(Widget root) { + // Walk the chain from the outermost widget inward. The walk visits the + // LAST keyframe first, and within a keyframe the LAST-declared effect + // first, so both lists are reversed at the end. + final List> groups = [{}]; + final List segments = []; + Widget? current = root; + while (true) { + if (current is EffectWidget) { + final Effect effect = current.end; + final Map group = groups.last; + if (group.containsKey(effect.runtimeType)) { + throw FlutterError( + 'A timeline keyframe declared two ${effect.runtimeType}s. Each ' + 'effect type may appear at most once per keyframe; separate them ' + 'with a step() if you meant two keyframes.', + ); + } + group[effect.runtimeType] = effect; + current = current.child; + } else if (current is TimelineStep) { + segments.add(TimelineSegment( + motion: current.motion, + delay: current.delay, + )); + groups.add({}); + current = current.child; + } else { + break; + } + } + final Widget child = current ?? const SizedBox.shrink(); + + final List> orderedGroups = groups.reversed.toList(); + final List orderedSegments = segments.reversed.toList(); + + // Track order: first appearance across keyframes, in declaration order + // within a keyframe (the walk reversed it, so reverse back). + final List types = []; + for (final group in orderedGroups) { + for (final type in group.keys.toList().reversed) { + if (!types.contains(type)) types.add(type); + } + } + + final List tracks = []; + for (final type in types) { + final List raw = []; + Effect? last; + for (final group in orderedGroups) { + last = group[type] ?? last; + raw.add(last); + } + // Keyframes before the type's first appearance idle at no-op values. + final Effect idle = raw.firstWhere((effect) => effect != null)!.idle(); + tracks.add(TimelineTrack( + effectType: type, + keyframes: [for (final effect in raw) effect ?? idle], + )); + } + + return TimelineSpec( + segments: orderedSegments, + tracks: tracks, + child: child, + ); + } +} diff --git a/lib/src/utils.dart b/lib/src/utils.dart new file mode 100644 index 0000000..3ae66e0 --- /dev/null +++ b/lib/src/utils.dart @@ -0,0 +1,30 @@ +import 'package:flutter/animation.dart'; +import 'package:flutter/foundation.dart'; + +import '../hyper_effects.dart'; + +/// A utility class for internal use. +abstract class Utils { + /// Resolves the duration/curve sugar and the explicit [motion] channel into + /// one [Motion], refusing ambiguous calls loudly. + static Motion resolveMotion( + Motion? motion, + Duration? duration, + Curve? curve, + ) { + if (motion != null) { + if (duration != null || curve != null) { + throw FlutterError( + 'Provide either a motion OR duration/curve, not both. The ' + 'duration/curve parameters are sugar for ' + 'CurvedMotion(duration, curve).', + ); + } + return motion; + } + return CurvedMotion( + duration ?? const Duration(milliseconds: 350), + curve ?? appleEaseInOut, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 6879f15..67699df 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: hyper_effects description: Create beautiful effects and animations with just a few lines of code. -version: 0.3.0+1 +version: 0.4.0 homepage: https://hyperdesigned.dev/ repository: https://github.com/hyper-designed/hyper_effects @@ -18,13 +18,12 @@ environment: dependencies: flutter: sdk: flutter - equatable: ">=2.0.5 <3.0.0" - collection: ">=1.15.0 <2.0.0" meta: ">=1.11.0 <2.0.0" dev_dependencies: flutter_test: sdk: flutter + material_ui: ^1.0.0 flutter_lints: ^6.0.0 flutter: diff --git a/skills/hyper-effects/SKILL.md b/skills/hyper-effects/SKILL.md new file mode 100644 index 0000000..9ed0be6 --- /dev/null +++ b/skills/hyper-effects/SKILL.md @@ -0,0 +1,44 @@ +--- +name: hyper-effects +description: Use when building or troubleshooting Flutter UI with package:hyper_effects, including state-driven animation, mount entrances, springs, rolling text, layout size, hover/press feedback, scroll transitions, timelines, TimelineController, keyframe sequencing, loops, or migration to version 0.4.0. +--- + +# Hyper Effects + +For Hyper Effects **0.4.0**. Check the consuming app's resolved version before applying this reference; older releases have different APIs. + +## Choose the driver + +| Intent | API | +| --- | --- | +| Static appearance | Effect extensions without a driver | +| React to state or replay an event | `.animate(trigger: value)` | +| Entrance once per mounted State | `.immediate()` | +| Entrance and later trigger changes | `.animate(trigger: value, startState: AnimationStartState.eager)` | +| Sequential absolute keyframes | `.step(...).timeline(...)` | +| Pointer or scroll response | `.pointerTransition(...)` / `.scrollTransition(...)` | + +Effects wrap widgets. An animation drives its descendant effect scope; nested drivers create independent scopes, not a sequence. Keep widget identity stable across state changes. + +Use widget classes for reusable UI, not widget-returning helper functions. The short example below belongs inside `build`, with Flutter material and Hyper Effects imported. + +```dart +return Text('$count') + .roll(widthCurve: Curves.easeOut) + .animate(trigger: count, duration: const Duration(milliseconds: 250)); +``` + +`Text.roll()` reads the receiver's current text and retains its previous value. It takes **named options only**, not a replacement string. Call it before other extensions erase the receiver's `Text` type. For arbitrary widgets, use `Widget.roll()` with distinct child keys; its options are `slideInDirection`, `slideOutDirection`, `multiplier`, and `useSnapshots`. + +## Contracts to preserve + +- `trigger` changes use equality comparison. Use an incrementing integer for repeated events; repeatedly assigning `true` is not a new event. +- Default `lazy` mounts at starting values and waits. `eager` plays on mount and keeps the trigger live. `#immediate` occupies the trigger slot; ordinary rebuilds do not replay it, but a new State does. +- Use explicit `from:` for deliberate entrances/replays. For ordinary retargeting, preserve effect identity and derive the destination and trigger from state; do not blindly add a fixed origin. +- Choose `motion:` **or** `duration:`/`curve:`, never both. Cupertino spring duration is perceptual, not exact settling time. +- True vector springs support translation, scale, rotation, opacity, padding, alignment and size. Blur, clipping and other non-vector effects use a normalized interpolation fallback: no guaranteed physical overshoot or velocity handoff. Prefer curved motion when that distinction matters. +- `playIf: false` does not drive to the destination. `skipIf: true` jumps there without playback **or `onEnd`**. Reduced-motion entrances generally need the latter; completion-dependent UI needs an explicit skipped path. +- Layout: `.widthTo()`, `.heightTo()`, `.sizeTo()`, `.sizeOf()` change layout; `.scale()` changes paint. Null size axes/factors represent layout modes, not zero-sized endpoints. +- Translation hit tests move by default, but ancestor bounds still constrain hit testing. Hidden controls need an interaction policy such as `IgnorePointer`. + +Read [recipes and pitfalls](references/recipes.md) for effects and interaction. For sequencing, loops, scrubbing, or migration from animateAfter, read [timelines](references/timelines.md). Verify snippets with `flutter analyze` and widget tests in the consuming package. Test mount, retrigger, interruption, reduced motion and actual tap positions where relevant. Use finite pumps for infinite loops. diff --git a/skills/hyper-effects/references/recipes.md b/skills/hyper-effects/references/recipes.md new file mode 100644 index 0000000..6c818c5 --- /dev/null +++ b/skills/hyper-effects/references/recipes.md @@ -0,0 +1,122 @@ +# Recipes and pitfalls for Hyper Effects 0.4.0 + +Import `package:flutter/material.dart` and `package:hyper_effects/hyper_effects.dart`. Place the short snippets directly inside `build`. Their inputs are widget fields or local state. Extract reusable UI into widget classes, not widget-returning helper functions. + +## State versus event + +```dart +return child + .scale(selected ? 1.08 : 1) + .animate(trigger: selected, motion: const CupertinoMotion.snappy()); +``` + +For an event (invalid submission, notification), increment a counter each time: + +```dart +return child + .shake() + .animate(trigger: attempt, duration: const Duration(milliseconds: 350)); +``` + +Do not replace a stable widget key on each event: that recreates State rather than triggering the existing animation. + +## Entrance and delayed stagger + +```dart +return child + .opacity(1, from: 0) + .translateY(0, from: 24) + .immediate( + delay: Duration(milliseconds: 60 * index), + motion: const CupertinoMotion.smooth(), + skipIf: () => reduceMotion, + ); +``` + +This is several independent entrances, not a coordinated timeline. Remounting replays; retain a set of stable item IDs outside item State if entrances should happen once per item across remounts. + +To play on mount and keep responding later: + +```dart +return child + .scale(expanded ? 1.15 : 1, from: 0.8) + .animate( + trigger: expanded, + startState: AnimationStartState.eager, + motion: const CupertinoMotion.snappy(), + ); +``` + +Here the fixed `from:` deliberately specifies the starting scale. Omit it when a fixed replay origin is not wanted. For state-mirroring first-frame behavior, choose initial values deliberately; do not assume `lazy` means “render the target.” + +A delay holds the run's starting values until playback begins. An `Interval` inside a `CurvedMotion` instead allocates a quiet portion of an already progressing controller. They are not interchangeable. + +## Pointer feedback + +```dart +return button.pointerTransition( + (context, child, event) { + final target = event.isPressed ? 0.96 : (event.isHovering ? 1.03 : 1.0); + return child.scale(target).animate( + trigger: target, + motion: const CupertinoMotion.interactive(), + ); + }, + ); +``` + +Keep the actual button's onPressed/disabled/keyboard/semantics behavior. For disabled buttons return a neutral target. This is a separate animation scope from an outer entrance or visibility animation. + +## Rolling text and widgets + +```dart +return Text('$count') + .roll( + tapeSlideDirection: TextTapeSlideDirection.up, + staggerSoftness: 10, + widthCurve: Curves.easeOut, + ) + .animate(trigger: count, duration: const Duration(milliseconds: 250)); + +return KeyedSubtree( + key: ValueKey(done), + child: Icon(done ? Icons.check : Icons.hourglass_empty), + ).roll(slideInDirection: AxisDirection.up).animate(trigger: done); +``` + +Text options belong to `Text.roll()`, including `widthCurve` and `widthDuration`; they are not animate parameters. `staggerSoftness` is a positive integer. Use short single-line plain text, not multiline text or rich text. Preserve a semantics label for important numbers. Generic widget rolling detects child **key changes**, not just new widget instances. + +## Layout-driven size + +```dart +return child + .widthTo(expanded ? 240 : 120) + .animate(trigger: expanded, motion: const CupertinoMotion.bouncy()); +``` + +Incoming constraints still apply. Prefer finite numeric endpoints. Null means unconstrained and a null/numeric pair snaps rather than numerically interpolating. `.align()` now defaults width/height factors to null; pass `1` explicitly to shrink-wrap. Padding floors negative insets at rendering time but can overshoot upward. + +## Completion and accessibility + +Read reduced-motion preferences with `MediaQuery.disableAnimationsOf(context)` when an explicit no-motion policy is required. `skipIf` jumps to the final state and does not call `onEnd`; it is not interchangeable with `playIf` or zero duration. + +An exit needs its child mounted and ticking until completion. If the app retains the child afterward, gate ticking only after the exit finishes. Bind cleanup to the visibility animation, reject stale callbacks after visibility changes, and handle disposal and skipped animation separately. This is application lifecycle policy, not a built-in exit-retention feature. + +Transforms do not expand ancestor hit-test bounds. Check taps at the painted position, not only the original box. An opacity of zero alone does not disable interaction. + +## Version migration + +- `.oneShot()` → `.immediate()`. +- `AnimationStartState.playImmediately` → `.eager`; `.idle` → `.lazy`. +- `useCurrentValues` has no direct enum replacement: express the desired initial state through effect endpoints. +- `.animateAfter()` → absolute keyframes separated by `.step()`, driven by `.timeline()`. +- `.resetAll()` is deprecated: prefer timeline repetition or `TimelineController.seek(0)`. +- `AnimatedGroup` / `AnimatedChild` and state-retainer APIs were removed; do not invent replacements with those names. + +## References + +- [Package source](https://github.com/hyper-designed/hyper_effects/tree/main/lib/src) +- [Release and migration notes](https://github.com/hyper-designed/hyper_effects/blob/main/CHANGELOG.md) +- [Agent Skills format](https://agentskills.io/specification) + +The installed package source is authoritative when the main branch and the consuming app resolve different versions. diff --git a/skills/hyper-effects/references/timelines.md b/skills/hyper-effects/references/timelines.md new file mode 100644 index 0000000..703d8da --- /dev/null +++ b/skills/hyper-effects/references/timelines.md @@ -0,0 +1,159 @@ +# Hyper Effects Timelines + +For **0.4.0**. Verify the resolved package version. A timeline compiles absolute effect keyframes onto one controller; nested `.animate()` calls are independent scopes, not a sequence. + +```dart +// Inside build, with Flutter material and Hyper Effects imported. +return const Icon(Icons.check) + .scale(0) + .step(duration: const Duration(milliseconds: 250)) + .scale(1.2) + .step(duration: const Duration(milliseconds: 180)) + .scale(1) + .timeline(trigger: trigger); +``` + +## Keyframe rules + +- Effects before the first `.step()` define the initial keyframe. Each step specifies travel to the next keyframe. Its `delay` holds the preceding frame. +- Values are absolute; do not use reciprocal scales to undo prior keyframes. +- Omitted effect types carry forward. Each type may appear once per keyframe. `.translateX()` and `.translateY()` are both TranslateEffect: combine them with `.translateXY()`. Likewise combine size axes with `.sizeTo()` or `.sizeOf()`. +- Use step `motion:` OR `duration:`/`curve:`, not both. Spring steps have computed settling duration; do not promise animate-style velocity handoff between timeline segments. +- A changed trigger restarts forward regardless of its value. `false` does not mean reverse. `#immediate` starts once per mounted State. + +## Playback reference + +| Intent | Operation | +| --- | --- | +| Three total trigger-driven cycles | `.timeline(trigger: value, repeat: 2)` | +| Infinite auto-play | `.timeline(trigger: #immediate, repeat: -1)` | +| Forward from current position | `controller.play()` | +| Restart imperatively | `controller.seek(0); controller.play();` | +| Reverse from current position | `controller.reverse()` | +| Freeze / scrub | `controller.pause()` / `controller.seek(0.5)` | + +`repeat` counts additional **full forward cycles** on trigger-driven runs, not imperative `play()`. Finite cycles rest at the final frame; `onEnd` fires at the true end, not each cycle. A repeating sequence needs nonzero duration. Match first/last keyframes for a seamless loop. + +## Ownership and mistakes + +Create the controller once in its owner, pass it to `.timeline(controller:)`, and dispose it with the owner. No caller ticker provider is needed. `isAttached` is safe before mount; `progress` and driving methods require attachment and throw StateError otherwise. Treat one handle as controlling one mounted timeline. + +For directional state changes use explicit play/reverse calls after attachment, not a boolean trigger expecting automatic reversal. Preserve widget identity. Infinite loops cannot use `pumpAndSettle`; advance finite durations in tests. + +The controller and migration recipes below provide a complete example. Validate keyframe endpoints, delay holds, retriggers and reversal with widget tests rather than assuming a compiling chain is correct. + +## Recipes + +## Owned controller + +```dart +import 'package:flutter/material.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +class RevealDemo extends StatefulWidget { + const RevealDemo({super.key}); + + @override + State createState() => _RevealDemoState(); +} + +class _RevealDemoState extends State { + final controller = TimelineController(); + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Saved') + .opacity(0) + .translateY(24) + .step(duration: const Duration(milliseconds: 250)) + .opacity(1) + .translateY(0) + .timeline(controller: controller), + FilledButton( + onPressed: () { + if (!controller.isAttached) return; + controller.play(); + }, + child: const Text('Show'), + ), + OutlinedButton( + onPressed: () { + if (!controller.isAttached) return; + controller.reverse(); + }, + child: const Text('Hide'), + ), + ], + ); +} +``` + +Play resumes forward; if already at the end it is not a restart. Call `seek(0)` first when restart is intended. Keep the controller outside `build`, and do not share one controller between simultaneously mounted timelines. + +This snippet illustrates manual playback, not a reduced-motion policy. A timeline does not accept animate's `skipIf` or `playIf`. For an explicit no-motion path, render the appropriate final/static state instead of constructing an auto-looping timeline, or seek an attached controller to the desired endpoint. Preserve required semantic actions independently of animation callbacks. + +## Multi-track keyframes + +```dart +return child + .translateXY(0, 40) + .opacity(0) + .step( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + delay: const Duration(milliseconds: 100), + ) + .translateXY(0, 0) + .opacity(1) + .step(duration: const Duration(milliseconds: 150)) + .translateXY(8, 0) + .timeline(trigger: trigger); +``` + +The initial frame holds for 100 ms, then translation and opacity travel together. Opacity remains 1 in the last frame because it was omitted there. `.translateX().translateY()` in one frame is a duplicate type, not two tracks. + +## Loop + +```dart +return child + .scale(1) + .step(duration: const Duration(milliseconds: 300)) + .scale(1.1) + .step(duration: const Duration(milliseconds: 300)) + .scale(1) + .timeline(trigger: #immediate, repeat: -1); +``` + +`repeat: 2` would run three complete cycles and stop at scale 1. Do not await settlement in tests of the infinite form. Verify progress with bounded pumps, then unmount to stop it. + +## Migration + +Replace old `.animateAfter()` chains with explicit starting/ending keyframes separated by `.step()`. Replace reciprocal values such as scale `1 / 1.5` with the intended absolute value `1`. Replace `.resetAll()` looping with `repeat:`; use `seek(0)` for an explicit rewind. + +Do not translate a chain of independent hover/visibility `.animate()` groups into a timeline merely because it has more than one driver. A timeline is for ordered steps; independent reactions should remain independent. + +## Verification checklist + +- Initial values appear before the trigger changes. +- A changed trigger replays forward, including true-to-false changes. +- Delay holds the preceding keyframe. +- Missing tracks carry forward and duplicate types are rejected. +- Finite repeat finishes at the last frame; imperative play starts at current progress. +- Reverse is driven explicitly, and detached access is guarded. +- Infinite animation is bypassed under the app's explicit reduced-motion policy. + +## References + +- [Timeline source](https://github.com/hyper-designed/hyper_effects/tree/main/lib/src/timeline) +- [Release migration notes](https://github.com/hyper-designed/hyper_effects/blob/main/CHANGELOG.md) +- [Agent Skills specification](https://agentskills.io/specification) + +Verify against the installed package when the repository's main branch differs from the consumer's resolved version. diff --git a/test/align_effect_layout_test.dart b/test/align_effect_layout_test.dart new file mode 100644 index 0000000..6cfccad --- /dev/null +++ b/test/align_effect_layout_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +void main() { + group('AlignEffect layout semantics', () { + testWidgets('align() expands to fill loose constraints like a plain Align', + (tester) async { + const childKey = Key('child'); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200, maxHeight: 200), + child: const SizedBox(key: childKey, width: 50, height: 50) + .align(Alignment.bottomRight), + ), + ), + ), + ); + + // A plain Align with null factors fills the loose 200x200 constraints. + expect(tester.getSize(find.byType(Align)), const Size(200, 200)); + + // The 200x200 box is centered in the 800x600 test surface at (300, 200); + // a bottom-right aligned 50x50 child sits at (450, 350). + expect(tester.getTopLeft(find.byKey(childKey)), const Offset(450, 350)); + }); + + testWidgets('alignX() expands to fill loose constraints', (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200, maxHeight: 200), + child: const SizedBox(width: 50, height: 50).alignX(1), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(Align)), const Size(200, 200)); + }); + + testWidgets('align() passes explicit factors through to Align', + (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: const SizedBox(width: 50, height: 50).align( + Alignment.center, + widthFactor: 2, + heightFactor: 3, + ), + ), + ), + ); + + final Align align = tester.widget(find.byType(Align)); + expect(align.widthFactor, 2); + expect(align.heightFactor, 3); + expect(tester.getSize(find.byType(Align)), const Size(100, 150)); + }); + }); + + group('AlignEffect factor lerp', () { + test('both factors null stays null', () { + final result = AlignEffect(alignment: Alignment.topLeft) + .lerp(AlignEffect(alignment: Alignment.bottomRight), 0.5); + expect(result.widthFactor, isNull); + expect(result.heightFactor, isNull); + }); + + test('both factors non-null interpolate', () { + final result = AlignEffect(widthFactor: 1, heightFactor: 1) + .lerp(AlignEffect(widthFactor: 3, heightFactor: 5), 0.5); + expect(result.widthFactor, 2); + expect(result.heightFactor, 3); + }); + + test('mixed null and non-null snaps to the target value', () { + final nullToValue = AlignEffect().lerp(AlignEffect(widthFactor: 2), 0.5); + expect(nullToValue.widthFactor, 2); + + final valueToNull = AlignEffect(widthFactor: 2).lerp(AlignEffect(), 0.5); + expect(valueToNull.widthFactor, isNull); + }); + + test('interpolated factors are clamped at zero', () { + final result = + AlignEffect(widthFactor: 0).lerp(AlignEffect(widthFactor: -4), 0.5); + expect(result.widthFactor, 0); + }); + + test('idle() has null factors', () { + final idle = AlignEffect(widthFactor: 2, heightFactor: 2).idle(); + expect(idle.widthFactor, isNull); + expect(idle.heightFactor, isNull); + }); + }); +} diff --git a/test/align_effect_vector_test.dart b/test/align_effect_vector_test.dart new file mode 100644 index 0000000..05cb4b8 --- /dev/null +++ b/test/align_effect_vector_test.dart @@ -0,0 +1,321 @@ +import 'dart:math'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +/// [AlignEffect] is a [VectorEffect]. The alignment vectorizes cleanly; +/// the nullable size factors do not, and this file pins the exact rule +/// that lets them ride along anyway without changing what anyone sees: +/// nullability comes from the LEFT operand, which — because the spring +/// solver always puts the TARGET on the left — reproduces the +/// snap-to-target behaviour `lerp` has always had. +void main() { + const Key boxKey = Key('box'); + + group('vector arithmetic on alignment', () { + test('adds, subtracts and scales field-wise', () { + final a = AlignEffect(alignment: const Alignment(0.5, -0.25)); + final b = AlignEffect(alignment: const Alignment(0.25, 0.5)); + + expect((a + b).alignment, const Alignment(0.75, 0.25)); + expect((a - b).alignment, const Alignment(0.25, -0.75)); + expect((a * 2).alignment, const Alignment(1, -0.5)); + expect(a.magnitudeSquared, closeTo(0.25 + 0.0625, 1e-12)); + }); + + test('spans the absolute / directional divide', () { + final absolute = AlignEffect(alignment: const Alignment(1, 0)); + final directional = + AlignEffect(alignment: const AlignmentDirectional(1, 0)); + + // AlignmentGeometry.add mixes the two forms; equality is on the + // resolved components, so the sum reads back as a plain sum. + final sum = absolute + directional; + expect(sum.alignment.resolve(TextDirection.ltr), const Alignment(2, 0)); + expect(sum.alignment.resolve(TextDirection.rtl), Alignment.center); + expect((absolute - absolute).alignment, Alignment.center); + }); + + test('lerp is derivable from the algebra when factors agree', () { + final a = AlignEffect(alignment: Alignment.topLeft, widthFactor: 1); + final b = AlignEffect(alignment: Alignment.bottomRight, widthFactor: 3); + + final derived = a + (b - a) * 0.25; + final lerped = a.lerp(b, 0.25); + expect(derived.alignment, lerped.alignment); + expect(derived.widthFactor, lerped.widthFactor); + }); + }); + + group('factor nullability rides from the left operand', () { + test('both present: the numbers combine', () { + final sum = AlignEffect(widthFactor: 2, heightFactor: 5) + + AlignEffect(widthFactor: 3, heightFactor: 1); + expect(sum.widthFactor, 5); + expect(sum.heightFactor, 6); + + final difference = + AlignEffect(widthFactor: 2) - AlignEffect(widthFactor: 3); + expect(difference.widthFactor, -1, + reason: 'the displacement term is routinely negative'); + }); + + test('neither present: the result carries none', () { + expect((AlignEffect() + AlignEffect()).widthFactor, isNull); + expect((AlignEffect() - AlignEffect()).heightFactor, isNull); + expect((AlignEffect() * 3).widthFactor, isNull); + }); + + test('only the left present: its value passes through unchanged', () { + expect((AlignEffect(widthFactor: 2) + AlignEffect()).widthFactor, 2); + expect((AlignEffect(widthFactor: 2) - AlignEffect()).widthFactor, 2); + }); + + test('only the right present: the result carries none', () { + expect((AlignEffect() + AlignEffect(widthFactor: 2)).widthFactor, isNull); + expect((AlignEffect() - AlignEffect(widthFactor: 2)).widthFactor, isNull); + }); + + /// The load-bearing consequence. This is the shape the spring solver + /// evaluates every frame; if it is ever reformulated so the target is + /// no longer the left operand, mixed factor pairs would silently stop + /// snapping and this test is the alarm. + test('the solver form reproduces lerp\'s snap-to-target rule', () { + const coefficients = [1, 0.7, 0.0, -0.3, 1.4]; + + AlignEffect solve(AlignEffect start, AlignEffect end, double a) => + end + (start - end) * a; + + final nullToValue = [ + AlignEffect(alignment: Alignment.topLeft), + AlignEffect(alignment: Alignment.bottomRight, widthFactor: 2), + ]; + for (final a in coefficients) { + expect(solve(nullToValue[0], nullToValue[1], a).widthFactor, 2, + reason: 'null -> 2 must read 2 from the very first frame, ' + 'exactly as lerp(_, _, any) does'); + } + expect(nullToValue[0].lerp(nullToValue[1], 0.5).widthFactor, 2); + + final valueToNull = [ + AlignEffect(widthFactor: 2), + AlignEffect(), + ]; + for (final a in coefficients) { + expect(solve(valueToNull[0], valueToNull[1], a).widthFactor, isNull, + reason: '2 -> null must read null from the very first frame'); + } + expect(valueToNull[0].lerp(valueToNull[1], 0.5).widthFactor, isNull); + }); + }); + + group('apply floors factors, never alignment', () { + testWidgets('a negative factor is floored at zero and null is preserved', + (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: AlignEffect( + alignment: const Alignment(1.4, -1.9), + widthFactor: -3, + ).apply( + tester.binding.rootElement! as BuildContext, + const SizedBox.square(dimension: 10), + ), + ), + ); + + expect(tester.takeException(), isNull); + final Align align = tester.widget(find.byType(Align)); + expect(align.widthFactor, 0); + expect(align.heightFactor, isNull, + reason: 'null is a layout mode, not a number to floor'); + expect(align.alignment, const Alignment(1.4, -1.9), + reason: 'alignment overshoot is meaningful and must be untouched'); + }); + }); + + group('spring behaviour', () { + Widget alignHost(bool on) => Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 200, + height: 200, + child: const SizedBox.square(dimension: 50, key: boxKey) + .alignX(on ? 1 : -1) + .animate( + trigger: on, + motion: const CupertinoMotion.bouncy(), + ), + ), + ), + ); + + double xOf(WidgetTester tester) => + tester.getTopLeft(find.byKey(boxKey)).dx - + tester.getTopLeft(find.byType(Align)).dx; + + testWidgets('alignment overshoots its target', (tester) async { + await tester.pumpWidget(alignHost(false)); + await tester.pump(); + await tester.pumpWidget(alignHost(true)); + await tester.pump(); + + var peak = 0.0; + for (var i = 0; i < 60; i++) { + await tester.pump(const Duration(milliseconds: 16)); + peak = max(peak, xOf(tester)); + } + + // 200 wide box, 50 wide child: alignment x == 1 lands at dx == 150. + expect(peak, greaterThan(150)); + await tester.pumpAndSettle(); + expect(xOf(tester), closeTo(150, 1e-6)); + }); + + testWidgets('velocity carries across a mid-flight alignment retarget', + (tester) async { + await tester.pumpWidget(alignHost(false)); + await tester.pump(); + await tester.pumpWidget(alignHost(true)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 96)); + + final p1 = xOf(tester); + await tester.pump(const Duration(milliseconds: 16)); + final vBefore = (xOf(tester) - p1) / 0.016; + expect(vBefore.abs(), greaterThan(100), + reason: 'precondition: must be moving fast mid-flight'); + + await tester.pumpWidget(alignHost(false)); + await tester.pump(); + + final p3 = xOf(tester); + await tester.pump(const Duration(milliseconds: 16)); + final vAfter = (xOf(tester) - p3) / 0.016; + + expect(vAfter.sign, vBefore.sign, + reason: 'momentum must initially continue in the same direction'); + expect(vAfter.abs(), greaterThan(vBefore.abs() * 0.5)); + }); + + Widget factorHost(bool on) => Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: const SizedBox.square(dimension: 50, key: boxKey) + .align( + on ? Alignment.bottomRight : Alignment.topLeft, + widthFactor: on ? 2 : null, + ) + .animate( + trigger: on, + motion: const CupertinoMotion.bouncy(), + ), + ), + ); + + testWidgets('a mixed null / non-null factor still snaps to the target', + (tester) async { + await tester.pumpWidget(factorHost(false)); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(Align)).widthFactor, isNull); + + // null -> 2 under a spring: the factor must read 2 immediately, the + // way it does on the lerp path, and stay there for the whole flight. + await tester.pumpWidget(factorHost(true)); + await tester.pump(); + for (var i = 0; i < 40; i++) { + expect(tester.widget(find.byType(Align)).widthFactor, 2, + reason: 'frame $i must already be at the target factor'); + await tester.pump(const Duration(milliseconds: 16)); + } + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(Align)).widthFactor, 2); + + // 2 -> null must snap the other way, just as promptly. + await tester.pumpWidget(factorHost(false)); + await tester.pump(); + for (var i = 0; i < 40; i++) { + expect(tester.widget(find.byType(Align)).widthFactor, isNull, + reason: 'frame $i must already be back to "fill constraints"'); + await tester.pump(const Duration(milliseconds: 16)); + } + }); + + Widget shrinkHost(bool on) => Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: const SizedBox.square(dimension: 50, key: boxKey) + .align( + Alignment.center, + widthFactor: on ? 0 : 2, + heightFactor: 1, + ) + .animate( + trigger: on, + motion: const CupertinoMotion.bouncy(), + ), + ), + ); + + testWidgets('a factor that undershoots below zero never reaches layout', + (tester) async { + await tester.pumpWidget(shrinkHost(false)); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(Align)).widthFactor, 2); + + // 2 -> 0 with a bouncy spring wants to pass below zero. + await tester.pumpWidget(shrinkHost(true)); + await tester.pump(); + + var sawFloor = false; + for (var i = 0; i < 60; i++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(tester.takeException(), isNull); + final factor = tester.widget(find.byType(Align)).widthFactor!; + expect(factor, greaterThanOrEqualTo(0), + reason: 'RenderPositionedBox asserts a non-negative factor'); + if (factor == 0) sawFloor = true; + } + + expect(sawFloor, isTrue, + reason: 'precondition: the animation must actually hit the floor'); + }); + }); + + test('AlignEffect rejects NaN widthFactor', () { + expect( + () => AlignEffect(widthFactor: double.nan), + throwsA(isA().having( + (error) => error.message, + 'message', + contains('widthFactor'), + )), + ); + }); + + test('AlignEffect rejects NaN heightFactor', () { + expect( + () => AlignEffect(heightFactor: double.nan), + throwsA(isA().having( + (error) => error.message, + 'message', + contains('heightFactor'), + )), + ); + }); + + test('infinite factors never manufacture NaN', () { + final start = AlignEffect(widthFactor: double.infinity); + final target = AlignEffect(widthFactor: double.infinity); + + for (final coefficient in [1, 0.5, 0]) { + final solved = target + (start - target) * coefficient; + expect(solved.widthFactor?.isNaN, isFalse, + reason: 'coefficient $coefficient'); + expect(solved.widthFactor, double.infinity); + } + }); +} diff --git a/test/animate_immediate_test.dart b/test/animate_immediate_test.dart new file mode 100644 index 0000000..cae32b5 --- /dev/null +++ b/test/animate_immediate_test.dart @@ -0,0 +1,54 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +double compositeScaleOf(WidgetTester tester, Key key) { + final transforms = tester.widgetList( + find.ancestor(of: find.byKey(key), matching: find.byType(Transform)), + ); + var scale = 1.0; + for (final t in transforms) { + final m = t.transform; + scale *= Offset(m.entry(0, 0), m.entry(1, 0)).distance; + } + return scale; +} + +const key = Key('box'); + +Widget host() => MaterialApp( + home: Center( + child: const SizedBox.square(dimension: 50, key: key) + .scale(2, from: 1) + .animate( + trigger: #immediate, + duration: const Duration(milliseconds: 300), + curve: Curves.linear, + ), + ), + ); + +void main() { + testWidgets('trigger: #immediate plays an animate() on mount', + (tester) async { + await tester.pumpWidget(host()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + expect(compositeScaleOf(tester, key), closeTo(1.5, 1e-6), + reason: '#immediate must play without any trigger change'); + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, key), closeTo(2.0, 1e-9)); + }); + + testWidgets('rebuilding with #immediate does not replay the animate()', + (tester) async { + await tester.pumpWidget(host()); + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, key), closeTo(2.0, 1e-9)); + + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 150)); + expect(compositeScaleOf(tester, key), closeTo(2.0, 1e-9), + reason: 'a rebuild is not a trigger change'); + }); +} diff --git a/test/animated_effect_delay_test.dart b/test/animated_effect_delay_test.dart new file mode 100644 index 0000000..7053609 --- /dev/null +++ b/test/animated_effect_delay_test.dart @@ -0,0 +1,370 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; +import 'package:material_ui/material_ui.dart'; + +const key = Key('box'); +const delay = Duration(milliseconds: 130); +const duration = Duration(milliseconds: 300); +const frame = Duration(milliseconds: 16); + +/// The opacity actually painted above the keyed box. Multiplying every +/// [Opacity] ancestor keeps the reading correct if the host ever wraps the +/// subject in a second, incidental one. +double opacityOf(WidgetTester tester) { + final layers = tester.widgetList( + find.ancestor(of: find.byKey(key), matching: find.byType(Opacity)), + ); + return layers.fold(1, (product, layer) => product * layer.opacity); +} + +/// The horizontal translation actually painted above the keyed box. +double translationXOf(WidgetTester tester) { + final transforms = tester.widgetList( + find.ancestor(of: find.byKey(key), matching: find.byType(Transform)), + ); + var dx = 0.0; + for (final transform in transforms) { + dx += transform.transform.entry(0, 3); + } + return dx; +} + +Widget fadeHost(bool active, {Duration hostDelay = delay}) => MaterialApp( + home: Center( + child: const SizedBox.square(dimension: 50, key: key) + .fade(active ? 1 : 0) + .animate( + trigger: active, + duration: duration, + curve: Curves.linear, + delay: hostDelay, + ), + ), + ); + +Widget slideHost(bool right) => MaterialApp( + home: Center( + child: const SizedBox.square(dimension: 50, key: key) + .translateX(right ? 120 : -120) + .animate( + trigger: right, + duration: duration, + curve: Curves.linear, + delay: delay, + ), + ), + ); + +/// Runs a delayed animation all the way out. [WidgetTester.pumpAndSettle] +/// alone would stop short: nothing is scheduled while the delay timer is +/// pending, so the clock has to be walked past it first. +Future runOut(WidgetTester tester) async { + await tester.pump(delay + frame); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('a delayed re-trigger holds the STARTING value through the delay', + (tester) async { + // A round trip first. The very first run of any effect looks correct even + // when broken, because a controller that has never run already rests at 0. + // The bug only surfaces from the second run onward, when the controller + // still carries the value the previous run left it at. + await tester.pumpWidget(fadeHost(false)); + await tester.pump(); + expect(opacityOf(tester), closeTo(0, 1e-6)); + + await tester.pumpWidget(fadeHost(true)); + await runOut(tester); + expect(opacityOf(tester), closeTo(1, 1e-6)); + + await tester.pumpWidget(fadeHost(false)); + await runOut(tester); + expect(opacityOf(tester), closeTo(0, 1e-6), + reason: 'precondition: the round trip must land back at 0'); + + // Third trigger: 0 -> 1, sampled per frame across the delay window. + await tester.pumpWidget(fadeHost(true)); + final samples = [opacityOf(tester)]; + for (var elapsed = Duration.zero; elapsed < delay; elapsed += frame) { + await tester.pump(frame); + samples.add(opacityOf(tester)); + } + + expect( + samples.reduce((a, b) => a > b ? a : b), + closeTo(0, 1e-6), + reason: 'the delay must hold the START value, never flash the target. ' + 'samples: $samples', + ); + + // ...and once the delay is over, it really does animate. + await tester.pump(frame); + await tester.pump(const Duration(milliseconds: 120)); + final midway = opacityOf(tester); + expect(midway, greaterThan(0.05)); + expect(midway, lessThan(0.95)); + + await tester.pumpAndSettle(); + expect(opacityOf(tester), closeTo(1, 1e-6)); + }); + + testWidgets('only the newest interruptable delay may start the controller', + (tester) async { + var onEndCalls = 0; + + Widget host(int trigger) => MaterialApp( + home: Center( + child: const SizedBox.square(dimension: 50, key: key) + .fade(1, from: 0) + .animate( + trigger: trigger, + duration: duration, + curve: Curves.linear, + delay: delay, + onEnd: () => onEndCalls++, + ), + ), + ); + + await tester.pumpWidget(host(0)); + await tester.pump(); + final state = tester.state( + find.byType(AnimatedEffect), + ); + + // Start A, then supersede it twice while each run is still delayed. The + // timers cannot be cancelled, so all three callbacks will eventually wake; + // only C owns the controller by then. + await tester.pumpWidget(host(1)); // A: due at 130ms. + await tester.pump(const Duration(milliseconds: 40)); + await tester.pumpWidget(host(2)); // B: due at 170ms. + await tester.pump(const Duration(milliseconds: 40)); + await tester.pumpWidget(host(3)); // C: due at 210ms. + + // Cross A's and B's old deadlines. Neither stale callback may drive. + await tester.pump(const Duration(milliseconds: 66)); + await tester.pump(frame); + expect(state.controller.value, 0, + reason: 'A must retire when its obsolete delay wakes'); + await tester.pump(const Duration(milliseconds: 24)); + await tester.pump(frame); + expect(state.controller.value, 0, + reason: 'B must retire when its obsolete delay wakes'); + + // C reaches its own deadline and is the only run allowed to move. + await tester.pump(const Duration(milliseconds: 24)); + await tester.pump(const Duration(milliseconds: 120)); + expect(state.controller.value, greaterThan(0)); + expect(state.controller.value, lessThan(1)); + + await tester.pumpAndSettle(); + expect(state.controller.value, 1); + expect(onEndCalls, 1, + reason: 'superseded delayed runs must not complete or call onEnd'); + }); + + testWidgets('non-interruptable delayed triggers stay serialized', + (tester) async { + var onEndCalls = 0; + + Widget host(int trigger) => MaterialApp( + home: Center( + child: const SizedBox.square(dimension: 50, key: key) + .fade(1, from: 0) + .animate( + trigger: trigger, + duration: duration, + curve: Curves.linear, + delay: delay, + interruptable: false, + onEnd: () => onEndCalls++, + ), + ), + ); + + await tester.pumpWidget(host(0)); + await tester.pump(); + final state = tester.state( + find.byType(AnimatedEffect), + ); + + await tester.pumpWidget(host(1)); // A starts waiting. + await tester.pump(const Duration(milliseconds: 40)); + await tester.pumpWidget(host(2)); // B queues behind all of A. + + await tester.pump(const Duration(milliseconds: 110)); + await tester.pump(frame); + expect(state.controller.value, greaterThan(0), + reason: 'A must be the run in flight'); + + // Finish A. B must only now begin its own delay, parked at its start. + await tester.pump(const Duration(milliseconds: 300)); + expect(onEndCalls, 1); + expect(state.controller.value, 0, + reason: 'B must wait through a fresh delay after A completes'); + + await tester.pump(delay + frame); + await tester.pump(const Duration(milliseconds: 150)); + expect(state.controller.value, greaterThan(0)); + expect(state.controller.value, lessThan(1)); + + await tester.pumpAndSettle(); + expect(state.controller.value, 1); + expect(onEndCalls, 2); + }); + + testWidgets('a delayed reverse leg holds at the END value, not the start', + (tester) async { + // `repeat`/`reverse` drives a second leg from `onAnimationStatusChanged`, + // and that leg starts at 1, not 0. The parked value must branch the same + // way the controller call below it does, otherwise the pulse snaps back + // to its start before the reverse leg even begins. + Widget pulse(int trigger) => MaterialApp( + home: Center( + child: const SizedBox.square(dimension: 50, key: key) + .fade(1, from: 0) + .animate( + trigger: trigger, + duration: duration, + curve: Curves.linear, + delay: delay, + repeat: 1, + reverse: true, + ), + ), + ); + + await tester.pumpWidget(pulse(0)); + await tester.pump(); + + await tester.pumpWidget(pulse(1)); + // Walk past the delay and through the whole forward leg. + await tester.pump(delay + frame); + await tester.pump(duration + frame); + expect(opacityOf(tester), closeTo(1, 1e-6), + reason: 'precondition: the forward leg must land at 1'); + + // The reverse leg is now waiting out its own delay. It must hold at 1. + final samples = [opacityOf(tester)]; + for (var elapsed = Duration.zero; elapsed < delay; elapsed += frame) { + await tester.pump(frame); + samples.add(opacityOf(tester)); + } + expect( + samples.reduce((a, b) => a < b ? a : b), + closeTo(1, 1e-6), + reason: 'the inter-leg delay must hold at 1. samples: $samples', + ); + + await tester.pump(frame); + await tester.pumpAndSettle(); + expect(opacityOf(tester), closeTo(0, 1e-6), + reason: 'the reverse leg must still run all the way back'); + }); + + testWidgets('a delayed retarget holds at the INTERRUPTED position', + (tester) async { + // Guards the retarget/momentum capture: `EffectWidget` folds the in-flight + // run into its `start` using a one-frame-stale `previousAnimationValue` + // snapshot that is only refreshed in `didChangeDependencies`. Parking the + // controller synchronously in the ancestor's `didUpdateWidget` must not + // disturb that, so the render has to freeze exactly where it was + // interrupted and then continue from there. + await tester.pumpWidget(slideHost(false)); + await tester.pump(); + + await tester.pumpWidget(slideHost(true)); + await tester.pump(delay + frame); + await tester.pump(const Duration(milliseconds: 150)); + + final interrupted = translationXOf(tester); + expect(interrupted, greaterThan(-100)); + expect(interrupted, lessThan(100), + reason: 'precondition: the box must be caught mid-flight'); + + await tester.pumpWidget(slideHost(false)); + var maxDrift = (translationXOf(tester) - interrupted).abs(); + for (var elapsed = Duration.zero; elapsed < delay; elapsed += frame) { + await tester.pump(frame); + final drift = (translationXOf(tester) - interrupted).abs(); + if (drift > maxDrift) maxDrift = drift; + } + expect(maxDrift, lessThan(1), + reason: 'the delay must freeze the render at the interrupted ' + 'position, not jump'); + + await tester.pump(frame); + await tester.pumpAndSettle(); + expect(translationXOf(tester), closeTo(-120, 1e-6)); + }); + + testWidgets('zero delay is unchanged: the run starts on the same frame', + (tester) async { + const none = Duration.zero; + + await tester.pumpWidget(fadeHost(false, hostDelay: none)); + await tester.pump(); + + await tester.pumpWidget(fadeHost(true, hostDelay: none)); + await tester.pumpAndSettle(); + expect(opacityOf(tester), closeTo(1, 1e-6)); + + await tester.pumpWidget(fadeHost(false, hostDelay: none)); + await tester.pumpAndSettle(); + expect(opacityOf(tester), closeTo(0, 1e-6)); + + // Third trigger, with no delay to wait out: the value must leave the + // start immediately rather than sitting still for a frame. + await tester.pumpWidget(fadeHost(true, hostDelay: none)); + expect(opacityOf(tester), closeTo(0, 1e-6)); + + await tester.pump(const Duration(milliseconds: 150)); + expect(opacityOf(tester), closeTo(0.5, 0.05)); + + await tester.pump(const Duration(milliseconds: 160)); + expect(opacityOf(tester), closeTo(1, 1e-6), + reason: 'no delay means the run finishes within its own duration'); + }); + + testWidgets('same-target delayed retrigger freezes at interrupted position', + (tester) async { + var trigger = 0; + + Widget host() => MaterialApp( + home: Center( + child: const SizedBox.square(dimension: 50, key: key) + .translateX(120, from: -120) + .animate( + trigger: trigger, + duration: duration, + curve: Curves.linear, + delay: delay, + ), + ), + ); + + await tester.pumpWidget(host()); + trigger = 1; + await tester.pumpWidget(host()); + await tester.pump(delay + frame); + await tester.pump(const Duration(milliseconds: 120)); + + final interrupted = translationXOf(tester); + expect(interrupted, greaterThan(-100)); + expect(interrupted, lessThan(100)); + + trigger = 2; + await tester.pumpWidget(host()); + var maxDrift = (translationXOf(tester) - interrupted).abs(); + for (var elapsed = Duration.zero; elapsed < delay; elapsed += frame) { + await tester.pump(frame); + final drift = (translationXOf(tester) - interrupted).abs(); + if (drift > maxDrift) maxDrift = drift; + } + + expect(maxDrift, lessThan(1), + reason: 'a trigger-only retrigger must hold the interrupted render, ' + 'not snap to the original from value'); + }); +} diff --git a/test/animated_effect_on_end_test.dart b/test/animated_effect_on_end_test.dart new file mode 100644 index 0000000..557601c --- /dev/null +++ b/test/animated_effect_on_end_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +void main() { + testWidgets('onEnd fires once after a repeated reverse animation finishes', + (tester) async { + var onEndCalls = 0; + var trigger = 0; + + Widget buildSubject() => Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.shrink().scale(0.5).animate( + trigger: trigger, + duration: const Duration(milliseconds: 10), + repeat: 1, + reverse: true, + onEnd: () => onEndCalls++, + ), + ); + + await tester.pumpWidget(buildSubject()); + + trigger = 1; + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + expect(onEndCalls, 1); + }); +} diff --git a/test/animated_effect_repeat_trigger_test.dart b/test/animated_effect_repeat_trigger_test.dart new file mode 100644 index 0000000..1d9287b --- /dev/null +++ b/test/animated_effect_repeat_trigger_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +void main() { + testWidgets('reverses every repeated trigger animation back to its start', + (tester) async { + final key = GlobalKey<_TriggerHarnessState>(); + + await tester.pumpWidget(_TriggerHarness(key: key)); + + key.currentState!.trigger(); + await tester.pump(); + await tester.pumpAndSettle(); + expect(_scaleOf(tester), 1); + + key.currentState!.trigger(); + await tester.pump(); + await tester.pumpAndSettle(); + expect(_scaleOf(tester), 1); + }); +} + +double _scaleOf(WidgetTester tester) { + final transform = tester.widget(find.byType(Transform)); + return transform.transform.storage[0]; +} + +class _TriggerHarness extends StatefulWidget { + const _TriggerHarness({super.key}); + + @override + State<_TriggerHarness> createState() => _TriggerHarnessState(); +} + +class _TriggerHarnessState extends State<_TriggerHarness> { + int triggerValue = 0; + + void trigger() { + setState(() => triggerValue++); + } + + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox().scale(1.3, from: 1).animate( + trigger: triggerValue, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + repeat: 1, + reverse: true, + ), + ); + } +} diff --git a/test/animated_effect_run_ownership_test.dart b/test/animated_effect_run_ownership_test.dart new file mode 100644 index 0000000..cfd99f7 --- /dev/null +++ b/test/animated_effect_run_ownership_test.dart @@ -0,0 +1,570 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +void main() { + testWidgets('interrupted drive future settles', (tester) async { + final animationKey = GlobalKey(); + var onEndCalls = 0; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.shrink().fade(1, from: 0).animate( + key: animationKey, + trigger: 0, + duration: const Duration(milliseconds: 300), + curve: Curves.linear, + onEnd: () => onEndCalls++, + ), + ), + ); + + var firstSettled = false; + final first = animationKey.currentState!.drive(); + first.then((_) => firstSettled = true); + await tester.pump(const Duration(milliseconds: 50)); + + var secondSettled = false; + animationKey.currentState!.drive().then((_) => secondSettled = true); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pumpAndSettle(); + + expect(firstSettled, isTrue, + reason: 'a canceled ticker must settle its public drive future'); + expect(secondSettled, isTrue, + reason: 'the replacement drive must also settle after its ticker'); + expect(onEndCalls, 1); + }); + + testWidgets('queued runs keep independent repeat budgets', (tester) async { + final animationKey = GlobalKey(); + var trigger = 0; + var repeat = 1; + var onEndCalls = 0; + + Widget host() => Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.shrink().fade(1, from: 0).animate( + key: animationKey, + trigger: trigger, + duration: const Duration(milliseconds: 100), + curve: Curves.linear, + repeat: repeat, + interruptable: false, + onEnd: () => onEndCalls++, + ), + ); + + await tester.pumpWidget(host()); + trigger = 1; + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 40)); + + repeat = 0; + trigger = 2; + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 65)); + await tester.pump(); + + expect(onEndCalls, 0, + reason: 'A must begin its own repeated leg before any logical run ' + "completes; queuing B cannot replace A's repeat budget"); + }); + + testWidgets('queued run cannot reset active reverse phase', (tester) async { + final animationKey = GlobalKey(); + var trigger = 0; + + Widget host() => Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.shrink().fade(1, from: 0).animate( + key: animationKey, + trigger: trigger, + duration: const Duration(milliseconds: 100), + curve: Curves.linear, + repeat: 1, + reverse: true, + interruptable: false, + ), + ); + + await tester.pumpWidget(host()); + trigger = 1; + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 40)); + + trigger = 2; + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 65)); + await tester.pump(); + + expect(animationKey.currentState!.controller.value, greaterThan(0.8), + reason: 'A must begin its reverse leg at the completed endpoint; ' + 'queuing B cannot reset A to a new forward leg'); + }); + + testWidgets('queued runs retain their own onEnd callback', (tester) async { + final animationKey = GlobalKey(); + var trigger = 0; + var callbackLabel = 'A'; + final callbacks = []; + + Widget host() { + final label = callbackLabel; + return Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.shrink().fade(1, from: 0).animate( + key: animationKey, + trigger: trigger, + duration: const Duration(milliseconds: 100), + curve: Curves.linear, + interruptable: false, + onEnd: () => callbacks.add(label), + ), + ); + } + + await tester.pumpWidget(host()); + trigger = 1; + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 40)); + + callbackLabel = 'B'; + trigger = 2; + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 65)); + await tester.pump(); + + expect(callbacks, ['A'], + reason: 'completion belongs to the run that started, not the latest ' + 'widget configuration'); + }); + + testWidgets('queued run retains its own motion duration', (tester) async { + final animationKey = GlobalKey(); + var trigger = 0; + var duration = const Duration(milliseconds: 100); + var callbackLabel = 'A'; + final callbacks = []; + + Widget host() { + final label = callbackLabel; + return Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.shrink().fade(1, from: 0).animate( + key: animationKey, + trigger: trigger, + duration: duration, + curve: Curves.linear, + repeat: 1, + interruptable: false, + onEnd: () => callbacks.add(label), + ), + ); + } + + await tester.pumpWidget(host()); + trigger = 1; + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 40)); + + callbackLabel = 'B'; + duration = const Duration(milliseconds: 300); + trigger = 2; + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 65)); + await tester.pump(); + expect(callbacks, isEmpty, + reason: 'A must begin its repeated leg before completing'); + + await tester.pump(const Duration(milliseconds: 105)); + await tester.pump(); + + expect(callbacks, ['A'], + reason: 'A must finish on its captured 100ms motion even though B ' + 'was queued with a 300ms motion'); + }); + + testWidgets('reset cancels delayed non-interruptible run', (tester) async { + final animationKey = GlobalKey(); + var onEndCalls = 0; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.shrink().fade(1, from: 0).animate( + key: animationKey, + trigger: 0, + duration: const Duration(milliseconds: 100), + curve: Curves.linear, + delay: const Duration(milliseconds: 100), + interruptable: false, + onEnd: () => onEndCalls++, + ), + ), + ); + + var settled = false; + animationKey.currentState!.drive().then((_) => settled = true); + await tester.pump(const Duration(milliseconds: 50)); + animationKey.currentState!.reset(); + await tester.pump(); + + await tester.pump(const Duration(milliseconds: 250)); + await tester.pumpAndSettle(); + + expect(animationKey.currentState!.controller.value, 0, + reason: 'reset must prevent the delayed run from restarting'); + expect(onEndCalls, 0); + expect(settled, isTrue, + reason: 'canceling the delayed run must settle its public future'); + }); + + testWidgets('interruptible trigger supersedes old non-interruptible delay', + (tester) async { + final animationKey = GlobalKey(); + var trigger = 0; + var interruptable = false; + var callbackLabel = 'A'; + final callbacks = []; + + Widget host() { + final label = callbackLabel; + return Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.shrink().fade(1, from: 0).animate( + key: animationKey, + trigger: trigger, + duration: const Duration(milliseconds: 100), + curve: Curves.linear, + delay: const Duration(milliseconds: 100), + interruptable: interruptable, + onEnd: () => callbacks.add(label), + ), + ); + } + + await tester.pumpWidget(host()); + trigger = 1; + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 40)); + + callbackLabel = 'B'; + interruptable = true; + trigger = 2; + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 70)); + await tester.pump(const Duration(milliseconds: 16)); + expect(animationKey.currentState!.controller.value, 0, + reason: 'A must remain canceled after its old delay expires and ' + 'before B reaches its own deadline'); + + await tester.pump(const Duration(milliseconds: 150)); + await tester.pumpAndSettle(); + + expect(callbacks, ['B'], + reason: 'the interruptible run supersedes delayed non-interruptible ' + 'work; A must never resume or complete'); + + await tester.pump(const Duration(milliseconds: 200)); + expect(callbacks, ['B'], + reason: 'A must remain canceled past its deadline'); + }); + + testWidgets('delayed run evaluates predicates once', (tester) async { + var trigger = 0; + var playCalls = 0; + var skipCalls = 0; + + Widget host() => Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.shrink().fade(1, from: 0).animate( + trigger: trigger, + duration: const Duration(milliseconds: 100), + curve: Curves.linear, + delay: const Duration(milliseconds: 100), + playIf: () { + playCalls++; + return true; + }, + skipIf: () { + skipCalls++; + return false; + }, + ), + ); + + await tester.pumpWidget(host()); + playCalls = 0; + skipCalls = 0; + + trigger = 1; + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 220)); + await tester.pumpAndSettle(); + + expect(playCalls, 1, + reason: 'playIf is a decision for one logical run, not each phase'); + expect(skipCalls, 1, + reason: 'skipIf is a decision for one logical run, not each phase'); + }); + + testWidgets('drive after disposal is a settled no-op', (tester) async { + final animationKey = GlobalKey(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.shrink().fade(1, from: 0).animate( + key: animationKey, + trigger: 0, + duration: const Duration(milliseconds: 100), + delay: const Duration(milliseconds: 100), + ), + ), + ); + final state = animationKey.currentState!; + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + + var settled = false; + state.drive().then((_) => settled = true); + await tester.pump(); + + expect(settled, isTrue); + expect(tester.takeException(), isNull); + }); + + testWidgets('queued run is not published before it starts', (tester) async { + var trigger = 0; + const childKey = Key('queued-publication-child'); + + Widget host() => Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.shrink(key: childKey).fade(1, from: 0).animate( + trigger: trigger, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + interruptable: false, + ), + ); + + double opacity() => tester + .widgetList(find.ancestor( + of: find.byKey(childKey), + matching: find.byType(Opacity), + )) + .fold(1, (value, widget) => value * widget.opacity); + + await tester.pumpWidget(host()); + trigger = 1; + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 80)); + final beforeEnqueue = opacity(); + + trigger = 2; + await tester.pumpWidget(host()); + + expect(opacity(), closeTo(beforeEnqueue, 1e-6), + reason: 'enqueueing B must be visually inert while A owns the ' + 'controller'); + }); + + testWidgets('rejected interrupting run stops superseded ticker', + (tester) async { + var trigger = 0; + var allowPlay = true; + const childKey = Key('rejected-interrupt-child'); + + Widget host() => Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.shrink(key: childKey).fade(1, from: 0).animate( + trigger: trigger, + duration: const Duration(milliseconds: 300), + curve: Curves.linear, + playIf: () => allowPlay, + ), + ); + + double opacity() => tester + .widgetList(find.ancestor( + of: find.byKey(childKey), + matching: find.byType(Opacity), + )) + .fold(1, (value, widget) => value * widget.opacity); + + await tester.pumpWidget(host()); + trigger = 1; + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 100)); + + allowPlay = false; + trigger = 2; + await tester.pumpWidget(host()); + final rejectedAt = opacity(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(opacity(), closeTo(rejectedAt, 1e-6), + reason: 'superseded motion must stop even when the replacement run ' + 'is rejected by playIf'); + }); + + testWidgets('lazy mount does not evaluate run predicates', (tester) async { + var playCalls = 0; + var skipCalls = 0; + const childKey = Key('lazy-predicate-child'); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.shrink(key: childKey).fade(1, from: 0).animate( + trigger: 0, + duration: const Duration(milliseconds: 100), + playIf: () { + playCalls++; + return false; + }, + skipIf: () { + skipCalls++; + return true; + }, + ), + ), + ); + + final opacity = tester + .widgetList(find.ancestor( + of: find.byKey(childKey), + matching: find.byType(Opacity), + )) + .fold(1, (value, widget) => value * widget.opacity); + expect(opacity, 0); + expect(playCalls, 0); + expect(skipCalls, 0); + }); + + testWidgets('null widget receiver renders an empty child', (tester) async { + const Widget? child = null; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: child.animate( + trigger: 0, + duration: const Duration(milliseconds: 100), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(SizedBox), findsOneWidget); + }); + + testWidgets('playIf false leaves a nonzero controller untouched', + (tester) async { + final key = GlobalKey(); + var allowPlay = true; + var onEndCalls = 0; + + Widget host(int trigger) => Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.shrink().fade(1, from: 0).animate( + key: key, + trigger: trigger, + duration: const Duration(milliseconds: 100), + playIf: () => allowPlay, + onEnd: () => onEndCalls++, + ), + ); + + await tester.pumpWidget(host(0)); + await tester.pumpWidget(host(1)); + await tester.pumpAndSettle(); + expect(key.currentState!.controller.value, 1); + expect(onEndCalls, 1); + + allowPlay = false; + await tester.pumpWidget(host(2)); + await tester.pump(); + + expect(key.currentState!.controller.value, 1); + expect(onEndCalls, 1); + }); + + testWidgets('skipIf true jumps to end without repeat or onEnd', + (tester) async { + final key = GlobalKey(); + var onEndCalls = 0; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.shrink().fade(1, from: 0).animate( + key: key, + trigger: 0, + duration: const Duration(milliseconds: 100), + repeat: 2, + skipIf: () => true, + onEnd: () => onEndCalls++, + ), + ), + ); + key.currentState!.drive(); + await tester.pump(); + + expect(key.currentState!.controller.value, 1); + expect(onEndCalls, 0); + }); + + testWidgets('infinite repeat remains active until reset', (tester) async { + final key = GlobalKey(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.shrink().fade(1, from: 0).animate( + key: key, + trigger: 0, + duration: const Duration(milliseconds: 20), + repeat: -1, + ), + ), + ); + + var settled = false; + key.currentState!.drive().then((_) => settled = true); + for (var i = 0; i < 4; i++) { + await tester.pump(const Duration(milliseconds: 25)); + await tester.pump(); + } + expect(settled, isFalse); + + key.currentState!.reset(); + await tester.pump(); + expect(settled, isTrue); + }); + + testWidgets('zero-delay drive settles after its controller leg', + (tester) async { + final key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.shrink().fade(1, from: 0).animate( + key: key, + trigger: 0, + duration: const Duration(milliseconds: 20), + ), + ), + ); + + var settled = false; + key.currentState!.drive().then((_) => settled = true); + expect(settled, isFalse); + await tester.pump(const Duration(milliseconds: 25)); + await tester.pumpAndSettle(); + expect(settled, isTrue); + }); +} diff --git a/test/animated_effect_trigger_test.dart b/test/animated_effect_trigger_test.dart new file mode 100644 index 0000000..dcb2f9d --- /dev/null +++ b/test/animated_effect_trigger_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +void main() { + testWidgets('a mid-flight trigger starts and completes a fresh pulse', + (tester) async { + Widget pulse(int trigger) => Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox().scale(1.3, from: 1).animate( + trigger: trigger, + duration: const Duration(milliseconds: 200), + repeat: 1, + reverse: true, + ), + ); + + await tester.pumpWidget(pulse(0)); + await tester.pumpWidget(pulse(1)); + await tester.pump(const Duration(milliseconds: 100)); + + final state = tester.state( + find.byType(AnimatedEffect), + ); + expect(state.controller.value, closeTo(0.5, 0.01)); + + await tester.pumpWidget(pulse(2)); + expect(state.controller.value, 0); + + await tester.pump(const Duration(milliseconds: 100)); + expect(state.controller.value, closeTo(0.5, 0.01)); + + await tester.pumpAndSettle(); + expect(state.controller.value, 0); + }); +} diff --git a/test/animation_start_state_test.dart b/test/animation_start_state_test.dart new file mode 100644 index 0000000..311a6f4 --- /dev/null +++ b/test/animation_start_state_test.dart @@ -0,0 +1,119 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +/// Pins the contract of [AnimationStartState]: `eager` plays the animation +/// once on mount and then keeps following the trigger; `lazy` (the default) +/// inserts at start values and stays inert until the first trigger change. +/// Both insert the widget at its starting values. +double compositeScaleOf(WidgetTester tester, Key key) { + final transforms = tester.widgetList( + find.ancestor(of: find.byKey(key), matching: find.byType(Transform)), + ); + var scale = 1.0; + for (final t in transforms) { + final m = t.transform; + scale *= Offset(m.entry(0, 0), m.entry(1, 0)).distance; + } + return scale; +} + +const key = Key('box'); + +Widget host( + AnimationStartState startState, { + int trigger = 0, + AnimationBehavior? behavior, +}) => + HyperEffectsAnimationConfig( + animationBehavior: behavior, + child: MaterialApp( + home: Center( + child: const SizedBox.square(dimension: 50, key: key) + .scale(2, from: 1) + .animate( + trigger: trigger, + startState: startState, + duration: const Duration(milliseconds: 300), + curve: Curves.linear, + ), + ), + ), + ); + +void main() { + testWidgets('eager plays on mount, starting from the start values', + (tester) async { + await tester.pumpWidget(host(AnimationStartState.eager)); + expect(compositeScaleOf(tester, key), closeTo(1.0, 1e-9), + reason: 'eager renders the START values on the very first frame'); + + await tester.pump(const Duration(milliseconds: 150)); + expect(compositeScaleOf(tester, key), closeTo(1.5, 1e-6), + reason: 'eager is mid-flight halfway through the duration'); + + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, key), closeTo(2.0, 1e-9), + reason: 'eager rests at the end values'); + }); + + testWidgets('eager plays only once on mount, not on every rebuild', + (tester) async { + await tester.pumpWidget(host(AnimationStartState.eager)); + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, key), closeTo(2.0, 1e-9)); + + // A plain rebuild with an unchanged trigger must not replay. + await tester.pumpWidget(host(AnimationStartState.eager)); + await tester.pump(const Duration(milliseconds: 150)); + expect(compositeScaleOf(tester, key), closeTo(2.0, 1e-9), + reason: 'a rebuild with the same trigger must not replay eager'); + + // Neither must a dependency change: HyperEffectsAnimationConfig is an + // inherited dependency of the effect, so changing it re-runs + // didChangeDependencies. + await tester.pumpWidget( + host(AnimationStartState.eager, behavior: AnimationBehavior.preserve), + ); + await tester.pump(const Duration(milliseconds: 150)); + expect(compositeScaleOf(tester, key), closeTo(2.0, 1e-9), + reason: 'a dependency change must not replay eager'); + }); + + testWidgets('eager keeps its trigger live after the mount play', + (tester) async { + await tester.pumpWidget(host(AnimationStartState.eager)); + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, key), closeTo(2.0, 1e-9)); + + // Unlike `trigger: #immediate`, a trigger change still replays it. + await tester.pumpWidget(host(AnimationStartState.eager, trigger: 1)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + expect(compositeScaleOf(tester, key), closeTo(1.5, 1e-6), + reason: 'a trigger change replays eager from the start values'); + + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, key), closeTo(2.0, 1e-9)); + }); + + testWidgets('lazy inserts at start values and stays inert until triggered', + (tester) async { + await tester.pumpWidget(host(AnimationStartState.lazy)); + await tester.pump(); + expect(compositeScaleOf(tester, key), closeTo(1.0, 1e-9), + reason: 'lazy renders the start values on the very first frame'); + await tester.pump(const Duration(milliseconds: 150)); + expect(compositeScaleOf(tester, key), closeTo(1.0, 1e-9), + reason: 'lazy stays inert without a trigger change'); + + // First trigger change plays normally. + await tester.pumpWidget(host(AnimationStartState.lazy, trigger: 1)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + expect(compositeScaleOf(tester, key), closeTo(1.5, 1e-6), + reason: 'a trigger change animates from start to end'); + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, key), closeTo(2.0, 1e-9)); + }); +} diff --git a/test/documentation_examples_test.dart b/test/documentation_examples_test.dart new file mode 100644 index 0000000..3d0a894 --- /dev/null +++ b/test/documentation_examples_test.dart @@ -0,0 +1,151 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +class FavoriteButton extends StatefulWidget { + const FavoriteButton({super.key}); + + @override + State createState() => _FavoriteButtonState(); +} + +class _FavoriteButtonState extends State { + bool selected = false; + + @override + Widget build(BuildContext context) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + return IconButton( + tooltip: selected ? 'Remove favorite' : 'Add favorite', + onPressed: () => setState(() => selected = !selected), + icon: Icon(selected ? Icons.favorite : Icons.favorite_border) + .scale(selected ? 1.2 : 1) + .animate( + trigger: selected, + motion: const CupertinoMotion.snappy(), + skipIf: () => reduceMotion, + ), + ); + } +} + +Widget staticEffect(Widget child) => child.opacity(0.5).translateY(8); + +Widget selectedItem(Widget child, bool selected) => child + .scale(selected ? 1.08 : 1) + .translateY(selected ? -4 : 0) + .animate(trigger: selected, motion: const CupertinoMotion.snappy()); + +Widget curved(Widget child, Object trigger) => + child.opacity(1, from: 0).animate( + trigger: trigger, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + +Widget entrance(Widget child, int index, bool reduceMotion) => + child.opacity(1, from: 0).translateY(0, from: 24).immediate( + motion: const CupertinoMotion.smooth(), + delay: Duration(milliseconds: 60 * index), + skipIf: () => reduceMotion, + ); + +Widget celebration(Object trigger) => const Icon(Icons.check, size: 64) + .scale(0) + .opacity(0) + .step(duration: const Duration(milliseconds: 250), curve: Curves.easeOut) + .scale(1.2) + .opacity(1) + .step(duration: const Duration(milliseconds: 180), curve: Curves.easeOut) + .scale(1) + .timeline(trigger: trigger); + +Widget pulse(Widget child) => child + .scale(1) + .step(duration: const Duration(milliseconds: 300)) + .scale(1.1) + .step(duration: const Duration(milliseconds: 300)) + .scale(1) + .timeline(trigger: #immediate, repeat: -1); + +Widget reveal(Widget child, TimelineController controller) => child + .opacity(0) + .translateY(24) + .step(duration: const Duration(milliseconds: 250)) + .opacity(1) + .translateY(0) + .timeline(controller: controller); + +Widget invalidInput(Widget child, int attempt) => child + .shake() + .animate(trigger: attempt, duration: const Duration(milliseconds: 350)); + +Widget pointerFeedback(Widget button) => button.pointerTransition( + (context, child, event) { + final target = event.isPressed ? 0.96 : (event.isHovering ? 1.03 : 1.0); + return child.scale(target).animate( + trigger: target, + motion: const CupertinoMotion.interactive(), + ); + }, + ); + +Widget expanding(Widget child, bool expanded) => child + .widthTo(expanded ? 240 : 120) + .animate(trigger: expanded, motion: const CupertinoMotion.bouncy()); + +Widget counter(int count) => Text('$count') + .roll(widthCurve: Curves.easeOut) + .animate(trigger: count, duration: const Duration(milliseconds: 250)); + +Widget status(bool done) => KeyedSubtree( + key: ValueKey(done), + child: Icon(done ? Icons.check : Icons.hourglass_empty), + ).roll(slideInDirection: AxisDirection.up).animate(trigger: done); + +Widget scrollItem(Widget child) => child.scrollTransition( + (context, widget, event) => widget + .opacity(event.phase == ScrollPhase.identity ? 1 : 0.3) + .scale(event.phase == ScrollPhase.identity ? 1 : 0.9), + ); + +void main() { + testWidgets('README favorite responds to selection', (tester) async { + await tester + .pumpWidget(const MaterialApp(home: Scaffold(body: FavoriteButton()))); + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.favorite), findsOneWidget); + final transform = tester.widget(find + .descendant( + of: find.byType(IconButton), matching: find.byType(Transform)) + .first); + expect(transform.transform.storage[0], closeTo(1.2, 0.001)); + }); + testWidgets('README layout recipe changes actual width', (tester) async { + Widget host(bool expanded) => MaterialApp( + home: Center(child: expanding(const SizedBox(height: 40), expanded))); + await tester.pumpWidget(host(false)); + await tester.pumpWidget(host(true)); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(EffectWidget).first).width, + closeTo(240, 0.01)); + }); + testWidgets('README timeline ends at absolute scale one', (tester) async { + await tester.pumpWidget(MaterialApp(home: celebration(0))); + await tester.pumpWidget(MaterialApp(home: celebration(1))); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(Transform)).transform.storage[0], + closeTo(1, 0.001)); + }); + testWidgets('README rolling recipes survive content changes', (tester) async { + Widget host(int count) => MaterialApp( + home: Column(children: [counter(count), status(count > 0)])); + await tester.pumpWidget(host(0)); + await tester.pumpWidget(host(1)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.check), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/effect_equality_test.dart b/test/effect_equality_test.dart new file mode 100644 index 0000000..46f5581 --- /dev/null +++ b/test/effect_equality_test.dart @@ -0,0 +1,134 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +/// Contract for the hand-written ==/hashCode/toString overrides that +/// replaced Equatable: value equality over every declared field, hashCode +/// consistency, and a toString that names the type. +void expectValueSemantics(T a, T sameAsA, T different) { + expect(a, equals(sameAsA), reason: '$T: equal fields must compare equal'); + expect(a.hashCode, sameAsA.hashCode, + reason: '$T: equal instances must share a hashCode'); + expect(a, isNot(equals(different)), + reason: '$T: a differing field must break equality'); + expect(a.toString(), contains('$T'), + reason: '$T: toString should name the type'); +} + +void main() { + test('ScaleEffect', () { + expectValueSemantics( + ScaleEffect(scale: 1.5, alignment: Alignment.topLeft), + ScaleEffect(scale: 1.5, alignment: Alignment.topLeft), + ScaleEffect(scale: 2.0, alignment: Alignment.topLeft), + ); + // Axis-split vs uniform scale must not compare equal. + expect(ScaleEffect(scaleX: 1, scaleY: 1), isNot(ScaleEffect(scale: 1))); + }); + + test('RotationEffect', () { + expectValueSemantics( + const RotationEffect(angle: 0.5), + const RotationEffect(angle: 0.5), + const RotationEffect(angle: 0.6), + ); + }); + + test('OpacityEffect', () { + expectValueSemantics( + OpacityEffect(opacity: 0.5), + OpacityEffect(opacity: 0.5), + OpacityEffect(opacity: 1), + ); + }); + + test('TranslateEffect', () { + expectValueSemantics( + TranslateEffect(offset: const Offset(1, 2)), + TranslateEffect(offset: const Offset(1, 2)), + TranslateEffect(offset: const Offset(1, 2), fractional: true), + ); + }); + + test('AlignEffect', () { + expectValueSemantics( + AlignEffect(alignment: Alignment.center, widthFactor: 1), + AlignEffect(alignment: Alignment.center, widthFactor: 1), + AlignEffect(alignment: Alignment.topRight, widthFactor: 1), + ); + }); + + test('BlurEffect', () { + expectValueSemantics( + BlurEffect(blur: 4), + BlurEffect(blur: 4), + BlurEffect(blur: 8), + ); + }); + + test('ClipEffect', () { + expectValueSemantics( + ClipEffect(clip: Clip.antiAlias, borderRadius: BorderRadius.circular(4)), + ClipEffect(clip: Clip.antiAlias, borderRadius: BorderRadius.circular(4)), + ClipEffect(clip: Clip.antiAlias, borderRadius: BorderRadius.circular(8)), + ); + }); + + test('PaddingEffect', () { + expectValueSemantics( + PaddingEffect(padding: const EdgeInsets.all(8)), + PaddingEffect(padding: const EdgeInsets.all(8)), + PaddingEffect(padding: const EdgeInsets.all(16)), + ); + }); + + test('SkewEffect', () { + expectValueSemantics( + const SkewEffect(skew: 0.2), + const SkewEffect(skew: 0.2), + const SkewEffect(skew: 0.4), + ); + }); + + test('ShakeEffect', () { + expectValueSemantics( + const ShakeEffect(frequency: 4), + const ShakeEffect(frequency: 4), + const ShakeEffect(frequency: 8), + ); + }); + + test('TransformEffect', () { + expectValueSemantics( + TransformEffect(translateX: 10, rotateZ: 0.3, scaleX: 2), + TransformEffect(translateX: 10, rotateZ: 0.3, scaleX: 2), + TransformEffect(translateX: 10, rotateZ: 0.3, scaleX: 3), + ); + }); + + test('ColorFilterEffect list field compares by contents', () { + expectValueSemantics( + ColorFilterEffect(matrix: const [1, 0, 0, 0, 0], mode: BlendMode.color), + // A DIFFERENT list instance with the same contents must be equal. + ColorFilterEffect(matrix: const [1.0, 0, 0, 0, 0], mode: BlendMode.color), + ColorFilterEffect(matrix: const [0, 1, 0, 0, 0], mode: BlendMode.color), + ); + }); + + test('TimelineSegment', () { + expectValueSemantics( + const TimelineSegment( + motion: CurvedMotion(Duration(milliseconds: 300), Curves.easeOut), + delay: Duration.zero, + ), + const TimelineSegment( + motion: CurvedMotion(Duration(milliseconds: 300), Curves.easeOut), + delay: Duration.zero, + ), + const TimelineSegment( + motion: CurvedMotion(Duration(milliseconds: 400), Curves.easeOut), + delay: Duration.zero, + ), + ); + }); +} diff --git a/test/effect_matrix_characterization_test.dart b/test/effect_matrix_characterization_test.dart new file mode 100644 index 0000000..2b53ec8 --- /dev/null +++ b/test/effect_matrix_characterization_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +/// Pins the exact transformation matrices produced by [ShakeEffect] and +/// [TransformEffect] so that the deprecated Matrix4 call sites can be +/// migrated to their replacements without any behavioral change. +void main() { + void expectMatrix(Matrix4 actual, Matrix4 expected) { + for (var i = 0; i < 16; i++) { + expect(actual.storage[i], closeTo(expected.storage[i], 1e-9), + reason: 'matrix storage[$i]'); + } + } + + testWidgets('ShakeEffect composes rotation then translation', (tester) async { + // curvedValue 1/16 with duration 500ms and frequency 8 makes + // sin(value * count * 2pi) == sin(pi / 2) == 1.0, so the matrix is + // exactly rotateZ(rotation) * translate(offset). + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: EffectQuery( + linearValue: 1 / 16, + curvedValue: 1 / 16, + isTransition: false, + duration: const Duration(milliseconds: 500), + child: const SizedBox.square(dimension: 10) + .shake(frequency: 8, offset: const Offset(10, 0), rotation: 0.2), + ), + ), + ); + + final transform = tester.widget(find.byType(Transform)); + final expected = + Matrix4.rotationZ(0.2).multiplied(Matrix4.translationValues(10, 0, 0)); + expectMatrix(transform.transform, expected); + }); + + testWidgets('TransformEffect composes rotations, translation, then scale', + (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.square(dimension: 10).transform( + rotateX: 0.1, + rotateY: 0.2, + rotateZ: 0.3, + translateX: 10, + translateY: 20, + translateZ: 5, + scaleX: 2, + scaleY: 3, + scaleZ: 4, + ), + ), + ); + + final transform = tester.widget(find.byType(Transform)); + final expected = Matrix4.identity() + ..rotateX(0.1) + ..rotateY(0.2) + ..rotateZ(0.3); + expected.multiply(Matrix4.translationValues(10, 20, 5)); + expected.multiply(Matrix4.diagonal3Values(2, 3, 4)); + expectMatrix(transform.transform, expected); + }); +} diff --git a/test/effect_query_notification_test.dart b/test/effect_query_notification_test.dart new file mode 100644 index 0000000..6664e93 --- /dev/null +++ b/test/effect_query_notification_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +class _LinearValueText extends StatelessWidget { + const _LinearValueText(); + + @override + Widget build(BuildContext context) => Text( + EffectQuery.of(context).linearValue.toStringAsFixed(1), + ); +} + +void main() { + testWidgets('linear progress notifies when curved progress is equal', + (tester) async { + var linearValue = 0.2; + + Widget host() => Directionality( + textDirection: TextDirection.ltr, + child: EffectQuery( + linearValue: linearValue, + curvedValue: 0.5, + isTransition: false, + child: const _LinearValueText(), + ), + ); + + await tester.pumpWidget(host()); + expect(find.text('0.2'), findsOneWidget); + + linearValue = 0.4; + await tester.pumpWidget(host()); + + expect(find.text('0.4'), findsOneWidget); + }); +} diff --git a/test/effect_widget_update_test.dart b/test/effect_widget_update_test.dart new file mode 100644 index 0000000..dc1730c --- /dev/null +++ b/test/effect_widget_update_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +const _childKey = Key('child'); +const _effectKey = Key('effect'); + +double _opacity(WidgetTester tester) => tester + .widgetList( + find.ancestor(of: find.byKey(_childKey), matching: find.byType(Opacity)), + ) + .fold(1, (value, widget) => value * widget.opacity); + +void main() { + testWidgets('equal target adopts changed explicit start', (tester) async { + var startOpacity = 0.0; + + Widget host() => Directionality( + textDirection: TextDirection.ltr, + child: EffectQuery( + linearValue: 0, + curvedValue: 0, + isTransition: false, + child: EffectWidget( + key: _effectKey, + start: OpacityEffect(opacity: startOpacity), + end: OpacityEffect(opacity: 1), + child: const SizedBox.shrink(key: _childKey), + ), + ), + ); + + await tester.pumpWidget(host()); + expect(_opacity(tester), 0); + + startOpacity = 0.5; + await tester.pumpWidget(host()); + + expect(_opacity(tester), 0.5, + reason: 'an explicit start update is configuration even when the ' + 'target remains equal'); + }); + + testWidgets('incompatible effect replacement reseeds state', (tester) async { + Effect end = OpacityEffect(opacity: 0.5); + + Widget host() => Directionality( + textDirection: TextDirection.ltr, + child: EffectQuery( + linearValue: 1, + curvedValue: 1, + isTransition: false, + child: EffectWidget( + key: _effectKey, + end: end, + child: const SizedBox.shrink(key: _childKey), + ), + ), + ); + + await tester.pumpWidget(host()); + expect(find.byType(Opacity), findsOneWidget); + + end = PaddingEffect(padding: const EdgeInsets.all(12)); + await tester.pumpWidget(host()); + + expect(find.byType(Padding), findsOneWidget); + expect(find.byType(Opacity), findsNothing); + }); +} diff --git a/test/keyed_animate_dependency_test.dart b/test/keyed_animate_dependency_test.dart new file mode 100644 index 0000000..a33ccdd --- /dev/null +++ b/test/keyed_animate_dependency_test.dart @@ -0,0 +1,52 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +/// Regression pin for the retainer's worst defect: a KEYED, never-played +/// `.animate(trigger:)` must not be slammed to its end values when an +/// unrelated inherited dependency changes. +double compositeScaleOf(WidgetTester tester, Key key) { + final transforms = tester.widgetList( + find.ancestor(of: find.byKey(key), matching: find.byType(Transform)), + ); + var scale = 1.0; + for (final t in transforms) { + final m = t.transform; + scale *= Offset(m.entry(0, 0), m.entry(1, 0)).distance; + } + return scale; +} + +const boxKey = Key('box'); + +Widget host(AnimationBehavior behavior) => MaterialApp( + home: HyperEffectsAnimationConfig( + animationBehavior: behavior, + child: Center( + child: const SizedBox.square(dimension: 50, key: boxKey) + .scale(2, from: 1) + .animate( + key: const Key('effect'), + trigger: 0, + duration: const Duration(milliseconds: 300), + ), + ), + ), + ); + +void main() { + testWidgets( + 'a keyed, untriggered animate() stays at start values when an ' + 'inherited dependency changes', (tester) async { + await tester.pumpWidget(host(AnimationBehavior.normal)); + await tester.pump(); + expect(compositeScaleOf(tester, boxKey), closeTo(1.0, 1e-9)); + + // An unrelated config change must not fabricate an end state. + await tester.pumpWidget(host(AnimationBehavior.preserve)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + expect(compositeScaleOf(tester, boxKey), closeTo(1.0, 1e-9), + reason: 'never-played animations must remain at their start values'); + }); +} diff --git a/test/motion_test.dart b/test/motion_test.dart new file mode 100644 index 0000000..961a1b9 --- /dev/null +++ b/test/motion_test.dart @@ -0,0 +1,102 @@ +import 'dart:math'; + +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +void main() { + group('CurvedMotion', () { + test('effectiveDuration is the declared duration', () { + const motion = CurvedMotion(Duration(milliseconds: 350), Curves.easeOut); + expect(motion.effectiveDuration, const Duration(milliseconds: 350)); + }); + + test('transform is the curve', () { + const motion = CurvedMotion(Duration(milliseconds: 350), Curves.easeOut); + for (final t in [0.0, 0.25, 0.5, 0.75, 1.0]) { + expect(motion.transform(t), Curves.easeOut.transform(t)); + } + }); + }); + + group('SpringMotion', () { + const bouncy = CupertinoMotion.bouncy(); + const smooth = CupertinoMotion.smooth(); + + test('endpoints are exact', () { + expect(bouncy.transform(0), 0); + expect(bouncy.transform(1), 1); + expect(smooth.transform(0), 0); + expect(smooth.transform(1), 1); + }); + + test('effectiveDuration is a finite, positive settling bound', () { + expect(bouncy.effectiveDuration.inMicroseconds, greaterThan(0)); + expect(bouncy.effectiveDuration.inSeconds, lessThan(30), + reason: 'a bouncy preset must settle in sane time'); + // A bouncier spring takes at least as long to settle as a smooth one + // of the same perceptual duration. + expect( + bouncy.effectiveDuration >= smooth.effectiveDuration, + isTrue, + ); + }); + + test('the spring has actually settled at its bound', () { + // Just before t=1, the raw simulation value must be within an order + // of magnitude of tolerance from the target. + final value = bouncy.transform(0.999); + expect((value - 1).abs(), lessThan(0.01)); + }); + + test('an underdamped spring overshoots past 1.0 mid-flight', () { + var peak = 0.0; + for (var i = 1; i < 200; i++) { + peak = max(peak, bouncy.transform(i / 200)); + } + expect(peak, greaterThan(1.001), + reason: 'bouncy springs must visibly overshoot'); + }); + + test('an undamped spring throws loudly instead of never settling', () { + const undamped = SpringMotion( + SpringDescription(mass: 1, stiffness: 100, damping: 0), + ); + expect(() => undamped.effectiveDuration, throwsStateError); + }); + }); + + group('NoMotion', () { + test('holds at 0 and reports its duration', () { + const none = NoMotion(Duration(seconds: 1)); + expect(none.effectiveDuration, const Duration(seconds: 1)); + expect(none.transform(0.5), 0); + }); + }); + + group('TrimmedMotion', () { + test('spans 0..1 over the trimmed window', () { + const trimmed = TrimmedMotion( + parent: CurvedMotion(Duration(milliseconds: 400)), + fromStart: 0.25, + fromEnd: 0.25, + ); + expect(trimmed.transform(0), 0); + expect(trimmed.transform(1), 1); + // Linear parent trimmed symmetrically stays linear. + expect(trimmed.transform(0.5), closeTo(0.5, 1e-9)); + expect(trimmed.effectiveDuration, const Duration(milliseconds: 200)); + }); + }); + + group('CurveSimulation', () { + test('dx approximates the true derivative', () { + // Linear 0..1 over 1 second: velocity is exactly 1.0 mid-flight. + final simulation = const CurvedMotion( + Duration(seconds: 1), + Curves.linear, + ).createSimulation(); + expect(simulation.dx(0.5), closeTo(1.0, 0.05)); + }); + }); +} diff --git a/test/one_shot_dependency_change_test.dart b/test/one_shot_dependency_change_test.dart new file mode 100644 index 0000000..625f7a0 --- /dev/null +++ b/test/one_shot_dependency_change_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +void main() { + testWidgets( + 'completed oneShot does not restart when animation config changes', + (tester) async { + final hostKey = GlobalKey<_AnimationConfigHostState>(); + + await tester.pumpWidget(_AnimationConfigHost(key: hostKey)); + await tester.pumpAndSettle(); + + expect(_opacity(tester), 1); + + hostKey.currentState!.changeAnimationBehavior(); + await tester.pump(); + + expect(_opacity(tester), 1); + }, + ); +} + +double _opacity(WidgetTester tester) => + tester.widget(find.byType(Opacity)).opacity; + +class _AnimationConfigHost extends StatefulWidget { + const _AnimationConfigHost({super.key}); + + @override + State<_AnimationConfigHost> createState() => _AnimationConfigHostState(); +} + +class _AnimationConfigHostState extends State<_AnimationConfigHost> { + AnimationBehavior _animationBehavior = AnimationBehavior.normal; + + void changeAnimationBehavior() { + setState(() { + _animationBehavior = AnimationBehavior.preserve; + }); + } + + @override + Widget build(BuildContext context) { + return HyperEffectsAnimationConfig( + animationBehavior: _animationBehavior, + child: const SizedBox().fadeIn().immediate(), + ); + } +} diff --git a/test/padding_effect_vector_test.dart b/test/padding_effect_vector_test.dart new file mode 100644 index 0000000..5bf694e --- /dev/null +++ b/test/padding_effect_vector_test.dart @@ -0,0 +1,282 @@ +import 'dart:math'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +/// [PaddingEffect] is a [VectorEffect], which buys it two things the +/// normalized lerp path cannot give: overshoot that is actually rendered, +/// and velocity that survives a mid-flight retarget. Non-negativity — the +/// price of real overshoot, since [RenderPadding] asserts it — is enforced +/// in `apply`, at the moment the value becomes layout, and nowhere earlier. +void main() { + const Key boxKey = Key('box'); + + /// A single [Padding] in the whole tree, pinned to the top left so the + /// child's origin IS the rendered inset. Measuring geometry rather than + /// the effect object proves the value reached the render tree. + Widget host(bool expanded) => Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: const SizedBox.square(dimension: 50, key: boxKey) + .padAll(expanded ? 100 : 0) + .animate( + trigger: expanded, + motion: const CupertinoMotion.bouncy(), + ), + ), + ); + + double insetOf(WidgetTester tester) => + tester.getTopLeft(find.byKey(boxKey)).dx; + + EdgeInsetsGeometry renderedPadding(WidgetTester tester) => tester + .widget( + find.ancestor(of: find.byKey(boxKey), matching: find.byType(Padding)), + ) + .padding; + + group('vector arithmetic', () { + test('is field-wise over the four insets', () { + final a = PaddingEffect( + padding: const EdgeInsets.fromLTRB(1, 2, 3, 4), + ); + final b = PaddingEffect( + padding: const EdgeInsets.fromLTRB(10, 20, 30, 40), + ); + + expect((a + b).padding, const EdgeInsets.fromLTRB(11, 22, 33, 44)); + expect((a - b).padding, const EdgeInsets.fromLTRB(-9, -18, -27, -36)); + expect((a * 2).padding, const EdgeInsets.fromLTRB(2, 4, 6, 8)); + expect(a.magnitudeSquared, 1 + 4 + 9 + 16); + }); + + test('lerp is derivable from the algebra, and extrapolates', () { + final a = PaddingEffect(padding: const EdgeInsets.all(0)); + final b = PaddingEffect(padding: const EdgeInsets.all(100)); + + expect(a + (b - a) * 0.25, a.lerp(b, 0.25)); + // The parameter is not clamped: this is how a spring or an + // overshooting curve expresses overshoot. + expect(a.lerp(b, 1.2).padding, const EdgeInsets.all(120)); + expect(a.lerp(b, -0.2).padding, const EdgeInsets.all(-20)); + }); + }); + + group('apply is the only clamp', () { + testWidgets('floors only the components that went negative', + (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: PaddingEffect( + padding: const EdgeInsets.fromLTRB(-5, 10, -0.5, 2), + ).apply( + tester.binding.rootElement! as BuildContext, + const SizedBox.shrink(), + ), + ), + ); + + expect( + tester.widget(find.byType(Padding)).padding, + const EdgeInsets.fromLTRB(0, 10, 0, 2), + reason: 'an inset that overshot upward must survive intact', + ); + }); + + testWidgets('an all-negative padding renders as zero and does not throw', + (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: PaddingEffect(padding: const EdgeInsets.all(-8)).apply( + tester.binding.rootElement! as BuildContext, + const SizedBox.shrink(), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect( + tester.widget(find.byType(Padding)).padding, + EdgeInsets.zero, + ); + }); + }); + + testWidgets('padding overshoots its target under a spring motion', + (tester) async { + await tester.pumpWidget(host(false)); + await tester.pump(); + + await tester.pumpWidget(host(true)); + await tester.pump(); + + var peak = 0.0; + for (var i = 0; i < 60; i++) { + await tester.pump(const Duration(milliseconds: 16)); + peak = max(peak, insetOf(tester)); + } + + expect( + peak, + greaterThan(100), + reason: 'a bouncy spring must sail past its target, not ease into it', + ); + + await tester.pumpAndSettle(); + expect( + insetOf(tester), + closeTo(100, 1e-6), + reason: 'and must still come to rest exactly on the target', + ); + }); + + testWidgets('an undershooting spring never renders a negative inset', + (tester) async { + await tester.pumpWidget(host(true)); + await tester.pumpAndSettle(); + expect(insetOf(tester), closeTo(100, 1e-6)); + + // 100 -> 0 with a bouncy spring wants to pass below zero on the way. + await tester.pumpWidget(host(false)); + await tester.pump(); + + var sawRest = false; + for (var i = 0; i < 60; i++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(tester.takeException(), isNull); + expect( + renderedPadding(tester).isNonNegative, + isTrue, + reason: 'RenderPadding asserts non-negative insets', + ); + expect(insetOf(tester), greaterThanOrEqualTo(0)); + if (insetOf(tester) == 0) sawRest = true; + } + + expect( + sawRest, + isTrue, + reason: 'precondition: the animation must actually reach the floor, ' + 'otherwise this test never exercises the clamp', + ); + }); + + testWidgets('velocity carries across a mid-flight padding retarget', + (tester) async { + await tester.pumpWidget(host(false)); + await tester.pump(); + + // Launch toward 100 and let it build up speed. + await tester.pumpWidget(host(true)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 96)); + + final p1 = insetOf(tester); + await tester.pump(const Duration(milliseconds: 16)); + final p2 = insetOf(tester); + final vBefore = (p2 - p1) / 0.016; + expect(vBefore.abs(), greaterThan(100), + reason: 'precondition: the padding must be growing fast mid-flight'); + + // Retarget back to 0 mid-flight... + await tester.pumpWidget(host(false)); + await tester.pump(); + + // ...momentum must carry, not reset to rest. + final p3 = insetOf(tester); + await tester.pump(const Duration(milliseconds: 16)); + final p4 = insetOf(tester); + final vAfter = (p4 - p3) / 0.016; + + expect(vAfter.sign, vBefore.sign, + reason: 'momentum must initially continue in the same direction'); + expect(vAfter.abs(), greaterThan(vBefore.abs() * 0.5), + reason: 'the handed-off speed must be of the same order, not ~zero'); + }); + + testWidgets('a retargeted padding spring never teleports between frames', + (tester) async { + await tester.pumpWidget(host(false)); + await tester.pump(); + + await tester.pumpWidget(host(true)); + await tester.pump(); + + var previous = insetOf(tester); + var maxJump = 0.0; + for (var i = 0; i < 40; i++) { + if (i == 8) await tester.pumpWidget(host(false)); + await tester.pump(const Duration(milliseconds: 16)); + final current = insetOf(tester); + maxJump = max(maxJump, (current - previous).abs()); + previous = current; + } + + expect(maxJump, lessThan(20)); + }); + + test('PaddingEffect rejects NaN components', () { + final cases = { + 'left': const EdgeInsets.only(left: double.nan), + 'top': const EdgeInsets.only(top: double.nan), + 'right': const EdgeInsets.only(right: double.nan), + 'bottom': const EdgeInsets.only(bottom: double.nan), + }; + + for (final entry in cases.entries) { + expect( + () => PaddingEffect(padding: entry.value), + throwsA(isA().having( + (error) => error.message, + 'message', + contains(entry.key), + )), + reason: entry.key, + ); + } + }); + + testWidgets('infinite padding remains a valid render endpoint', + (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 100, + height: 100, + child: PaddingEffect( + padding: const EdgeInsets.all(double.infinity), + ).apply( + tester.binding.rootElement! as BuildContext, + const SizedBox.square(dimension: 10), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + test('infinite insets never manufacture NaN', () { + final start = PaddingEffect( + padding: const EdgeInsets.all(double.infinity), + ); + final target = PaddingEffect( + padding: const EdgeInsets.all(double.infinity), + ); + + for (final coefficient in [1, 0.5, 0]) { + final solved = target + (start - target) * coefficient; + final insets = solved.padding; + expect(insets.left.isNaN, isFalse, reason: 'coefficient $coefficient'); + expect(insets.top.isNaN, isFalse); + expect(insets.right.isNaN, isFalse); + expect(insets.bottom.isNaN, isFalse); + expect(insets, const EdgeInsets.all(double.infinity)); + } + }); +} diff --git a/test/pointer_transition_press_test.dart b/test/pointer_transition_press_test.dart new file mode 100644 index 0000000..52cfde2 --- /dev/null +++ b/test/pointer_transition_press_test.dart @@ -0,0 +1,369 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +double _scaleOf(WidgetTester tester, Key key) { + final transforms = tester.widgetList( + find.ancestor(of: find.byKey(key), matching: find.byType(Transform)), + ); + var scale = 1.0; + for (final transform in transforms) { + final matrix = transform.transform; + scale *= Offset(matrix.entry(0, 0), matrix.entry(1, 0)).distance; + } + return scale; +} + +void main() { + testWidgets('isDown reflects pointer press and release in router mode', + (tester) async { + PointerTransitionEvent? lastEvent; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: const SizedBox( + key: ValueKey('target'), + width: 100, + height: 100, + ).pointerTransition( + (context, child, event) { + lastEvent = event; + return child; + }, + ), + ), + ), + ); + + final center = tester.getCenter(find.byKey(const ValueKey('target'))); + final gesture = await tester.startGesture(center); + await tester.pump(); + expect(lastEvent!.isDown, isTrue); + + await gesture.up(); + await tester.pump(); + expect(lastEvent!.isDown, isFalse); + }); + + testWidgets('isPressed and isHovering combine down state with widget bounds', + (tester) async { + PointerTransitionEvent? lastEvent; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: const SizedBox( + key: ValueKey('target'), + width: 100, + height: 100, + ).pointerTransition( + (context, child, event) { + lastEvent = event; + return child; + }, + ), + ), + ), + ); + + final center = tester.getCenter(find.byKey(const ValueKey('target'))); + final gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(location: Offset.zero); + await gesture.down(center); + await tester.pump(); + expect(lastEvent!.isPressed, isTrue); + expect(lastEvent!.isHovering, isFalse); + + // Drag off the widget while still holding down: no longer pressed, + // but the pointer is still down. + await gesture.moveTo(center + const Offset(200, 0)); + await tester.pump(); + expect(lastEvent!.isPressed, isFalse); + expect(lastEvent!.isDown, isTrue); + + // Drag back inside and release: a mouse keeps hovering after release. + await gesture.moveTo(center); + await gesture.up(); + await tester.pump(); + expect(lastEvent!.isPressed, isFalse); + expect(lastEvent!.isHovering, isTrue); + }); + + testWidgets('press and release are tracked in MouseRegion mode', + (tester) async { + PointerTransitionEvent? lastEvent; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: const SizedBox( + key: ValueKey('target'), + width: 100, + height: 100, + ).pointerTransition( + (context, child, event) { + lastEvent = event; + return child; + }, + usePointerRouter: false, + ), + ), + ), + ); + + final center = tester.getCenter(find.byKey(const ValueKey('target'))); + final gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(location: Offset.zero); + await gesture.moveTo(center); + await tester.pump(); + expect(lastEvent!.isHovering, isTrue); + expect(lastEvent!.isDown, isFalse); + + await gesture.down(center); + await tester.pump(); + expect(lastEvent!.isPressed, isTrue); + + await gesture.up(); + await tester.pump(); + expect(lastEvent!.isDown, isFalse); + expect(lastEvent!.isHovering, isTrue); + }); + + testWidgets('a cancelled touch is no longer down or hovering', + (tester) async { + PointerTransitionEvent? lastEvent; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: const SizedBox( + key: ValueKey('target'), + width: 100, + height: 100, + ).pointerTransition( + (context, child, event) { + lastEvent = event; + return child; + }, + ), + ), + ), + ); + + final center = tester.getCenter(find.byKey(const ValueKey('target'))); + final gesture = await tester.startGesture(center); + await tester.pump(); + expect(lastEvent!.isPressed, isTrue); + + await gesture.cancel(); + await tester.pump(); + expect(lastEvent!.isDown, isFalse); + // A cancelled touch contact no longer exists, so it cannot linger + // inside the bounds of the widget the way a mouse cursor does. + expect(lastEvent!.isHovering, isFalse); + }); + + testWidgets('a lifted touch is no longer hovering', (tester) async { + PointerTransitionEvent? lastEvent; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: const SizedBox( + key: ValueKey('target'), + width: 100, + height: 100, + ).pointerTransition( + (context, child, event) { + lastEvent = event; + return child; + }, + ), + ), + ), + ); + + final center = tester.getCenter(find.byKey(const ValueKey('target'))); + final gesture = await tester.startGesture(center); + await tester.pump(); + expect(lastEvent!.isPressed, isTrue); + + await gesture.up(); + await tester.pump(); + expect(lastEvent!.isDown, isFalse); + // A lifted touch contact no longer exists; only hover-capable devices + // like mice remain hovering after release. + expect(lastEvent!.isHovering, isFalse); + }); + + testWidgets('a removed indirect mouse pointer is no longer hovering', + (tester) async { + const targetKey = ValueKey('indirect-pointer-target'); + PointerTransitionEvent? lastEvent; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: const SizedBox.square( + key: targetKey, + dimension: 100, + ).pointerTransition((context, child, event) { + lastEvent = event; + final target = event.isPressed + ? 0.9 + : event.isHovering + ? 1.1 + : 1.0; + return child + .scale(target) + .animate(trigger: target, curve: Curves.easeOutQuart); + }), + ), + ), + ); + + final center = tester.getCenter(find.byKey(targetKey)); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(center); + await mouse.down(center); + await tester.pumpAndSettle(); + expect(lastEvent!.isPressed, isTrue); + expect(_scaleOf(tester, targetKey), closeTo(0.9, 1e-9)); + + await mouse.up(); + await tester.pumpAndSettle(); + expect(lastEvent!.isHovering, isTrue); + expect(_scaleOf(tester, targetKey), closeTo(1.1, 1e-9)); + + // iOS sends PointerRemovedEvent immediately after PointerUpEvent for an + // indirect pointer click, including clicks made in the Simulator. + await mouse.removePointer(); + await tester.pumpAndSettle(); + expect(lastEvent!.isDown, isFalse); + expect(lastEvent!.isHovering, isFalse); + expect(_scaleOf(tester, targetKey), closeTo(1.0, 1e-9)); + }); + + testWidgets('hover resets when the target moves away from a still mouse', + (tester) async { + const targetKey = ValueKey('moving-target'); + const transitionKey = ValueKey('moving-transition'); + PointerTransitionEvent? lastEvent; + + Widget host(Alignment alignment) => Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 400, + height: 200, + child: Align( + alignment: alignment, + child: const SizedBox.square( + key: targetKey, + dimension: 100, + ).pointerTransition( + key: transitionKey, + (context, child, event) { + lastEvent = event; + final target = event.isPressed + ? 0.9 + : event.isHovering + ? 1.1 + : 1.0; + return child + .scale(target) + .animate(trigger: target, curve: Curves.easeOutQuart); + }, + ), + ), + ), + ); + + await tester.pumpWidget(host(Alignment.centerRight)); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(find.byKey(targetKey))); + await tester.pumpAndSettle(); + expect(lastEvent!.isHovering, isTrue); + expect(_scaleOf(tester, targetKey), closeTo(1.1, 1e-9)); + + await mouse.down(tester.getCenter(find.byKey(targetKey))); + await tester.pumpAndSettle(); + expect(lastEvent!.isPressed, isTrue); + expect(_scaleOf(tester, targetKey), closeTo(0.9, 1e-9)); + + await mouse.up(); + await tester.pumpAndSettle(); + expect(lastEvent!.isHovering, isTrue); + expect(_scaleOf(tester, targetKey), closeTo(1.1, 1e-9)); + + await tester.pumpWidget(host(Alignment.centerLeft)); + await tester.pumpAndSettle(); + expect(lastEvent!.isHovering, isFalse); + expect(_scaleOf(tester, targetKey), closeTo(1.0, 1e-9)); + + await tester.pumpWidget(host(Alignment.centerRight)); + await tester.pumpAndSettle(); + expect(lastEvent!.isHovering, isTrue); + expect(_scaleOf(tester, targetKey), closeTo(1.1, 1e-9)); + }); + + testWidgets('global pointer bounds do not exit when the local target moves', + (tester) async { + tester.view.physicalSize = const Size(400, 200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + const targetKey = ValueKey('global-target'); + const transitionKey = ValueKey('global-transition'); + PointerTransitionEvent? lastEvent; + + Widget host(Alignment alignment) => MediaQuery( + data: const MediaQueryData(size: Size(400, 200)), + child: Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 400, + height: 200, + child: Align( + alignment: alignment, + child: const SizedBox.square( + key: targetKey, + dimension: 100, + ).pointerTransition( + key: transitionKey, + useGlobalPointer: true, + (context, child, event) { + lastEvent = event; + return child; + }, + ), + ), + ), + ), + ); + + await tester.pumpWidget(host(Alignment.centerRight)); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(find.byKey(targetKey))); + await tester.pump(); + expect(lastEvent!.isInsideBounds, isTrue); + + await tester.pumpWidget(host(Alignment.centerLeft)); + await tester.pump(); + expect(lastEvent!.isInsideBounds, isTrue); + }); +} diff --git a/test/reset_all_non_interruptable_animation_test.dart b/test/reset_all_non_interruptable_animation_test.dart new file mode 100644 index 0000000..83fa5a0 --- /dev/null +++ b/test/reset_all_non_interruptable_animation_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +void main() { + testWidgets( + 'a non-interruptable animation plays after resetAll cancels it mid-flight', + (tester) async { + final resetKey = GlobalKey(); + late StateSetter rebuild; + var trigger = 0; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return ResetAllAnimationsEffect( + key: resetKey, + child: const Text('effect').fadeIn().animate( + trigger: trigger, + duration: const Duration(seconds: 1), + interruptable: false, + ), + ); + }, + ), + ), + ); + + rebuild(() => trigger = 1); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + resetKey.currentState!.reset(); + await tester.pump(); + + rebuild(() => trigger = 2); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final state = tester.state( + find.byType(AnimatedEffect), + ); + expect(state.controller.value, greaterThan(0)); + }, + ); +} diff --git a/test/roll_effect_hit_test_default_test.dart b/test/roll_effect_hit_test_default_test.dart new file mode 100644 index 0000000..d71270b --- /dev/null +++ b/test/roll_effect_hit_test_default_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; +import 'package:material_ui/material_ui.dart'; + +void main() { + testWidgets( + 'roll translations preserve FractionalTranslation hit-test defaults', + (tester) async { + Widget host({required Key childKey, required int trigger}) => MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 100, + height: 100, + child: SizedBox.square(dimension: 100, key: childKey) + .roll( + slideInDirection: AxisDirection.right, + slideOutDirection: AxisDirection.left, + ) + .animate( + trigger: trigger, + duration: const Duration(milliseconds: 300), + curve: Curves.linear, + ), + ), + ), + ); + + await tester.pumpWidget(host(childKey: const Key('old'), trigger: 0)); + await tester.pump(); + await tester.pumpWidget(host(childKey: const Key('new'), trigger: 1)); + await tester.pump(const Duration(milliseconds: 150)); + + final translations = tester + .widgetList( + find.descendant( + of: find.byType(RollingEffectWidget), + matching: find.byType(FractionalTranslation), + ), + ) + .toList(); + expect(translations, hasLength(2)); + expect( + translations.every((translation) => translation.transformHitTests), + isTrue, + reason: 'RollEffect must not override Flutter’s true default', + ); + }, + ); +} diff --git a/test/run_start_reseed_test.dart b/test/run_start_reseed_test.dart new file mode 100644 index 0000000..5a5fb9c --- /dev/null +++ b/test/run_start_reseed_test.dart @@ -0,0 +1,156 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +import 'support/geometry.dart'; + +const _boxKey = Key('box'); + +void main() { + testWidgets('queued handoff paints the new run from its explicit start', + (tester) async { + var trigger = 0; + + Widget host() => Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.square(dimension: 20, key: _boxKey) + .translateX(320, from: 0) + .animate( + trigger: trigger, + duration: const Duration(milliseconds: 450), + curve: Curves.linear, + interruptable: false, + ), + ); + + await tester.pumpWidget(host()); + + trigger = 1; + await tester.pumpWidget(host()); + // Build a frame late in run 1 so its rendered value is close to the end. + await tester.pump(const Duration(milliseconds: 400)); + expect(translationXOf(tester, _boxKey), closeTo(320 * 400 / 450, 1)); + + // Queue run 2 while run 1 is mid-flight. + trigger = 2; + await tester.pumpWidget(host()); + + // This frame lands past run 1's completion: the completed run's final + // value never builds, run 2 starts within the same frame's microtasks. + // Run 1 COMPLETED — it was not interrupted — so run 2 must repaint from + // its explicit `from: 0`, not from run 1's last-built frame. + await tester.pump(const Duration(milliseconds: 100)); + expect(translationXOf(tester, _boxKey), closeTo(0, 1), + reason: 'a queued run follows a COMPLETED run; it must start ' + 'painting at its explicit start'); + + // And it must traverse the full distance. + await tester.pump(const Duration(milliseconds: 225)); + expect(translationXOf(tester, _boxKey), closeTo(320 * 225 / 450, 1), + reason: 'the queued run must animate across its full range'); + + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 1)); + expect(translationXOf(tester, _boxKey), closeTo(320, 1e-6)); + }); + + testWidgets('a run from rest reseeds its explicit start after an interrupt', + (tester) async { + var trigger = 0; + + Widget host() => Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.square(dimension: 20, key: _boxKey) + .translateX(320, from: 0) + .animate( + trigger: trigger, + duration: const Duration(milliseconds: 450), + curve: Curves.linear, + ), + ); + + await tester.pumpWidget(host()); + + trigger = 1; + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 200)); + + // A zero-delay same-target retrigger is a REPLAY: deterministic under + // spam. Only a DELAYED retrigger holds the interrupted position + // (spec regression 8). + trigger = 2; + await tester.pumpWidget(host()); + await tester.pump(); + expect(translationXOf(tester, _boxKey), closeTo(0, 1), + reason: 'a zero-delay retrigger replays from its explicit start'); + + // Let the interrupting run finish completely. + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 1)); + expect(translationXOf(tester, _boxKey), closeTo(320, 1e-6)); + + // A fresh run from REST also repaints from the author's explicit start. + trigger = 3; + await tester.pumpWidget(host()); + await tester.pump(); + expect(translationXOf(tester, _boxKey), closeTo(0, 1), + reason: 'a fresh run from rest must reseed its explicit start'); + + await tester.pump(const Duration(milliseconds: 225)); + expect(translationXOf(tester, _boxKey), closeTo(320 * 225 / 450, 1), + reason: 'the fresh run must traverse its full range'); + }); + + testWidgets( + 'zero-delay same-target retriggers replay an implicit-start effect ' + 'from its pair start', (tester) async { + var trigger = 0; + var end = 0.0; + + // No `from:` — the pair start is captured when the end retargets, and + // it must SURVIVE same-target retriggers: it is the only record of + // where this pair began. + Widget host() => Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.square(dimension: 20, key: _boxKey) + .translateX(end) + .animate( + trigger: trigger, + duration: const Duration(milliseconds: 450), + curve: Curves.linear, + ), + ); + + await tester.pumpWidget(host()); + + end = 320; + trigger = 1; + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 200)); + expect(translationXOf(tester, _boxKey), closeTo(320 * 200 / 450, 1)); + + // Mid-flight same-target retrigger: replay from the pair start (0). + trigger = 2; + await tester.pumpWidget(host()); + await tester.pump(); + expect(translationXOf(tester, _boxKey), closeTo(0, 1), + reason: 'the retrigger must replay from the pair start, not ' + 'continue-and-corrupt it'); + + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 1)); + expect(translationXOf(tester, _boxKey), closeTo(320, 1e-6)); + + // From rest, the pair start must still be intact: replay covers the + // full range instead of hovering at a folded leftover. + trigger = 3; + await tester.pumpWidget(host()); + await tester.pump(); + expect(translationXOf(tester, _boxKey), closeTo(0, 1), + reason: 'the pair start must survive spam; an implicit-start ' + 'effect has no explicit from to recover with'); + + await tester.pump(const Duration(milliseconds: 225)); + expect(translationXOf(tester, _boxKey), closeTo(320 * 225 / 450, 1)); + }); +} diff --git a/test/size_effect_test.dart b/test/size_effect_test.dart new file mode 100644 index 0000000..a38c3aa --- /dev/null +++ b/test/size_effect_test.dart @@ -0,0 +1,379 @@ +import 'dart:math'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +/// [SizeEffect] is a [VectorEffect] that constrains layout, not paint. This +/// file checks that the constraint actually reaches [RenderBox.size] (not +/// just the widget's own parameters), that it overshoots and hands off +/// velocity like every other [VectorEffect], that overshoot below zero +/// never reaches [SizedBox] as a negative dimension, and pins the +/// null-axis / non-finite rules documented on the class. +void main() { + const Key boxKey = Key('box'); + + group('SizeEffect layout semantics', () { + testWidgets('widthTo() actually changes the rendered layout size', + (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: const SizedBox(key: boxKey, height: 40).widthTo(120), + ), + ), + ); + + // Reading the rendered size, not the widget's constructor argument, + // is the point: layout is what SizeEffect promises to change. + expect(tester.getSize(find.byKey(boxKey)), const Size(120, 40)); + }); + + testWidgets('heightTo() actually changes the rendered layout size', + (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: const SizedBox(key: boxKey, width: 40).heightTo(90), + ), + ), + ); + + expect(tester.getSize(find.byKey(boxKey)), const Size(40, 90)); + }); + + testWidgets('sizeTo() constrains both axes', (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: const SizedBox(key: boxKey).sizeTo(const Size(80, 60)), + ), + ), + ); + + expect(tester.getSize(find.byKey(boxKey)), const Size(80, 60)); + }); + + testWidgets('sizeOf() constrains one axis, leaving the other to flow', + (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: const SizedBox(key: boxKey, height: 30).sizeOf(width: 70), + ), + ), + ); + + expect(tester.getSize(find.byKey(boxKey)), const Size(70, 30)); + }); + + testWidgets( + 'widthTo() reflows a sibling, unlike scale() which paints on top', + (tester) async { + const Key siblingKey = Key('sibling'); + Widget host(double w) => Directionality( + textDirection: TextDirection.ltr, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(key: boxKey, height: 20).widthTo(w), + const SizedBox(key: siblingKey, width: 10, height: 20), + ], + ), + ); + + await tester.pumpWidget(host(20)); + final double x1 = tester.getTopLeft(find.byKey(siblingKey)).dx; + await tester.pumpWidget(host(80)); + final double x2 = tester.getTopLeft(find.byKey(siblingKey)).dx; + + expect(x2 - x1, closeTo(60, 1e-6), + reason: 'the sibling must actually move when widthTo() grows, ' + 'because SizeEffect changes layout rather than just paint'); + }); + }); + + group('vector arithmetic on width/height', () { + test('adds, subtracts and scales field-wise', () { + final a = SizeEffect(width: 10, height: 20); + final b = SizeEffect(width: 4, height: 5); + + expect((a + b).width, 14); + expect((a + b).height, 25); + expect((a - b).width, 6); + expect((a - b).height, 15); + expect((a * 2).width, 20); + expect((a * 2).height, 40); + expect(a.magnitudeSquared, closeTo(100 + 400, 1e-9)); + }); + + test('lerp is derivable from the algebra when both axes agree', () { + final a = SizeEffect(width: 10, height: 40); + final b = SizeEffect(width: 50, height: 0); + + final derived = a + (b - a) * 0.25; + final lerped = a.lerp(b, 0.25); + expect(derived.width, lerped.width); + expect(derived.height, lerped.height); + }); + }); + + group('null-axis rule', () { + test('both null: the result stays unconstrained', () { + expect((SizeEffect() + SizeEffect()).width, isNull); + expect((SizeEffect() - SizeEffect()).height, isNull); + expect((SizeEffect() * 3).width, isNull); + expect(SizeEffect().lerp(SizeEffect(), 0.5).width, isNull); + }); + + test('only the left present: its value passes through unchanged', () { + expect((SizeEffect(width: 10) + SizeEffect()).width, 10); + expect((SizeEffect(width: 10) - SizeEffect()).width, 10); + }); + + test('only the right present: the result carries none', () { + expect((SizeEffect() + SizeEffect(width: 10)).width, isNull); + expect((SizeEffect() - SizeEffect(width: 10)).width, isNull); + }); + + test('lerp snaps to the target on a mixed null / non-null pair', () { + final nullToValue = SizeEffect().lerp(SizeEffect(width: 200), 0.5); + expect(nullToValue.width, 200); + + final valueToNull = SizeEffect(width: 200).lerp(SizeEffect(), 0.5); + expect(valueToNull.width, isNull); + }); + + /// The load-bearing consequence: this is the exact shape the spring + /// solver evaluates every frame (target on the left of `+`), so a + /// mixed pair must resolve to the target's mode from the very first + /// frame, matching what lerp has always done. + test('the solver form reproduces lerp\'s snap-to-target rule', () { + const coefficients = [1, 0.7, 0.0, -0.3, 1.4]; + SizeEffect solve(SizeEffect start, SizeEffect end, double a) => + end + (start - end) * a; + + final start = SizeEffect(width: 200); + final end = SizeEffect(); + for (final a in coefficients) { + expect(solve(start, end, a).width, isNull, + reason: '200 -> null must read null from the very first frame'); + } + expect(start.lerp(end, 0.5).width, isNull); + }); + + test('idle() resets both axes to unconstrained, not a value copy', () { + // idle() is the implicit starting point for a widget that animates in + // without an explicit `from` — it must read as "no SizeEffect was ever + // applied," matching AlignEffect.idle()'s null factors, not preserve + // whatever width/height this particular instance happens to hold. + final idle = SizeEffect(width: 40, height: 50).idle(); + expect(idle.width, isNull); + expect(idle.height, isNull); + }); + }); + + group('non-finite hazard', () { + test('operator - poisons an infinite axis to null instead of NaN', () { + final difference = SizeEffect(width: double.infinity) - + SizeEffect(width: double.infinity); + expect(difference.width, isNull, + reason: 'infinity - infinity is NaN; this must never surface'); + }); + + test('operator - poisons when only one side is infinite', () { + final difference = + SizeEffect(width: double.infinity) - SizeEffect(width: 10); + expect(difference.width, isNull); + }); + + test('operator + still resolves a genuinely infinite target', () { + // By construction the engine only ever pairs an infinite endpoint + // with a displacement that operator - has already nulled on that + // axis, so + only ever needs its ordinary null-check shape. + final result = + SizeEffect(width: double.infinity) + SizeEffect(width: null); + expect(result.width, double.infinity); + }); + + test('lerp snaps to the target on a non-finite endpoint', () { + final growing = + SizeEffect(width: 10).lerp(SizeEffect(width: double.infinity), 0.5); + expect(growing.width, double.infinity); + + final shrinking = + SizeEffect(width: double.infinity).lerp(SizeEffect(width: 10), 0.5); + expect(shrinking.width, 10); + }); + + test('magnitudeSquared never turns into NaN or infinity', () { + final effect = SizeEffect(width: double.infinity, height: 5); + expect(effect.magnitudeSquared.isFinite, isTrue); + }); + }); + + group('apply floors overshoot at zero', () { + testWidgets('a negative width never reaches SizedBox', (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizeEffect(width: -10, height: 20).apply( + tester.binding.rootElement! as BuildContext, + null, + ), + ), + ); + + expect(tester.takeException(), isNull); + final SizedBox sizedBox = tester.widget(find.byType(SizedBox)); + expect(sizedBox.width, 0); + expect(sizedBox.height, 20); + }); + + testWidgets('a null axis is preserved through apply, not floored', + (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizeEffect(width: -10).apply( + tester.binding.rootElement! as BuildContext, + null, + ), + ), + ); + + final SizedBox sizedBox = tester.widget(find.byType(SizedBox)); + expect(sizedBox.height, isNull, + reason: 'null is a layout mode, not a number to floor'); + }); + }); + + group('spring behaviour', () { + Widget widthHost(bool grown) => Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: const SizedBox(key: boxKey, height: 20) + .widthTo(grown ? 200 : 20) + .animate( + trigger: grown, + motion: const CupertinoMotion.bouncy(), + ), + ), + ); + + testWidgets('width overshoots its target', (tester) async { + await tester.pumpWidget(widthHost(false)); + await tester.pump(); + await tester.pumpWidget(widthHost(true)); + await tester.pump(); + + var peak = 0.0; + for (var i = 0; i < 60; i++) { + await tester.pump(const Duration(milliseconds: 16)); + peak = max(peak, tester.getSize(find.byKey(boxKey)).width); + } + + expect(peak, greaterThan(200), + reason: 'a bouncy spring must overshoot the 200 target width'); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byKey(boxKey)).width, closeTo(200, 1e-6)); + }); + + testWidgets('velocity carries across a mid-flight width retarget', + (tester) async { + await tester.pumpWidget(widthHost(false)); + await tester.pump(); + await tester.pumpWidget(widthHost(true)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 96)); + + final double p1 = tester.getSize(find.byKey(boxKey)).width; + await tester.pump(const Duration(milliseconds: 16)); + final double p2 = tester.getSize(find.byKey(boxKey)).width; + final double vBefore = (p2 - p1) / 0.016; + expect(vBefore.abs(), greaterThan(50), + reason: 'precondition: width must be changing fast mid-flight'); + + await tester.pumpWidget(widthHost(false)); + await tester.pump(); + + final double p3 = tester.getSize(find.byKey(boxKey)).width; + await tester.pump(const Duration(milliseconds: 16)); + final double p4 = tester.getSize(find.byKey(boxKey)).width; + final double vAfter = (p4 - p3) / 0.016; + + expect(vAfter.sign, vBefore.sign, + reason: 'momentum must initially continue in the same direction'); + expect(vAfter.abs(), greaterThan(vBefore.abs() * 0.3), + reason: 'the handed-off speed must be of the same order, not ~zero'); + }); + + Widget shrinkHost(bool on) => Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: const SizedBox(key: boxKey, height: 20) + .widthTo(on ? 0 : 40) + .animate( + trigger: on, + motion: const CupertinoMotion.bouncy(), + ), + ), + ); + + testWidgets('an undershooting width never renders negative or throws', + (tester) async { + await tester.pumpWidget(shrinkHost(false)); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byKey(boxKey)).width, 40); + + // 40 -> 0 with a bouncy spring wants to swing below zero. + await tester.pumpWidget(shrinkHost(true)); + await tester.pump(); + + var sawFloor = false; + for (var i = 0; i < 60; i++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(tester.takeException(), isNull); + final double width = tester.getSize(find.byKey(boxKey)).width; + expect(width, greaterThanOrEqualTo(0), + reason: 'SizedBox asserts a non-negative width'); + if (width == 0) sawFloor = true; + } + + expect(sawFloor, isTrue, + reason: 'precondition: the animation must actually hit the floor'); + }); + }); + + test('SizeEffect rejects NaN width', () { + expect( + () => SizeEffect(width: double.nan), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('width'), + ), + ), + ); + }); + + test('SizeEffect rejects NaN height', () { + expect( + () => SizeEffect(height: double.nan), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('height'), + ), + ), + ); + }); +} diff --git a/test/spring_downgrade_warning_test.dart b/test/spring_downgrade_warning_test.dart new file mode 100644 index 0000000..0180a0b --- /dev/null +++ b/test/spring_downgrade_warning_test.dart @@ -0,0 +1,207 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; +import 'package:material_ui/material_ui.dart'; + +class _UnclampedEffect extends Effect { + const _UnclampedEffect(this.value); + + final double value; + + static double largestInterpolationValue = double.negativeInfinity; + + static void resetObservations() { + largestInterpolationValue = double.negativeInfinity; + } + + @override + _UnclampedEffect lerp( + covariant _UnclampedEffect other, + double interpolationValue, + ) { + if (interpolationValue > largestInterpolationValue) { + largestInterpolationValue = interpolationValue; + } + return _UnclampedEffect( + value + (other.value - value) * interpolationValue, + ); + } + + @override + Widget apply(BuildContext context, Widget? child) => + child ?? const SizedBox.shrink(); +} + +class _DebugPrintCapture { + static Future> run( + Future Function(List) body) async { + final DebugPrintCallback originalDebugPrint = debugPrint; + final List messages = []; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) messages.add(message); + }; + try { + await body(messages); + return messages; + } finally { + debugPrint = originalDebugPrint; + } + } +} + +/// Covers the debug-only diagnostic in `effect_widget.dart` that fires when +/// a [SpringMotion] drives an effect that doesn't mix in [VectorEffect] and +/// has therefore silently fallen back to a normalized lerp instead of real +/// spring physics. See `_EffectWidgetState._warnSpringDowngrade` for the +/// full rationale. +/// +/// [BlurEffect] is the ordinary non-vector fixture. The first test instead +/// uses [_UnclampedEffect], whose `lerp` records and extrapolates its input, to +/// prove that the fallback MAY preserve scalar overshoot. What it cannot +/// preserve is closed-form vector spring state or velocity handoff. +/// [TranslateEffect] (via `.translateX`) is the vector control. +/// +/// The diagnostic uses [debugPrint] instead of [FlutterError.reportError] so +/// developer guidance never enters application crash-reporting pipelines and +/// never becomes a framework exception. Each test captures the global callback +/// inside a `try`/`finally` scope and restores it before Flutter verifies its +/// test invariants. +void main() { + setUp(() { + EffectWidget.resetSpringDowngradeWarningsForTesting(); + _UnclampedEffect.resetObservations(); + }); + tearDown(EffectWidget.resetSpringDowngradeWarningsForTesting); + + Widget unclampedNonVectorHost(bool trigger) => MaterialApp( + home: Center( + child: const EffectWidget( + start: _UnclampedEffect(0), + end: _UnclampedEffect(1), + child: SizedBox.square(dimension: 50), + ).animate( + trigger: trigger, + motion: const CupertinoMotion.bouncy(), + ), + ), + ); + + Widget nonVectorHost(bool trigger) => MaterialApp( + home: Center( + child: const SizedBox.square(dimension: 50) + .blur(trigger ? 10 : 0) + .animate( + trigger: trigger, + motion: const CupertinoMotion.bouncy(), + ), + ), + ); + + Widget vectorHost(bool trigger) => MaterialApp( + home: Center( + child: const SizedBox.square(dimension: 50) + .translateX(trigger ? 120 : -120) + .animate( + trigger: trigger, + motion: const CupertinoMotion.bouncy(), + ), + ), + ); + + Widget nonVectorCurvedHost(bool trigger) => MaterialApp( + home: Center( + child: const SizedBox.square(dimension: 50) + .blur(trigger ? 10 : 0) + .animate( + trigger: trigger, + motion: const CurvedMotion(Duration(milliseconds: 200)), + ), + ), + ); + + testWidgets( + 'an unclamped non-VectorEffect warns while preserving scalar overshoot', + (tester) async { + final List printedMessages = await _DebugPrintCapture.run( + (printedMessages) async { + await tester.pumpWidget(unclampedNonVectorHost(false)); + await tester.pump(); + + await tester.pumpWidget(unclampedNonVectorHost(true)); + for (var i = 0; i < 80; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + }, + ); + + expect(printedMessages, hasLength(1)); + final String message = printedMessages.single; + expect(message, contains('_UnclampedEffect')); + expect(message, contains('VectorEffect')); + expect(message, contains('may still exceed 1')); + expect(message, contains('clamping lerp flattens')); + expect(message, contains('always lose velocity handoff')); + expect(message, contains('CurvedMotion')); + expect(_UnclampedEffect.largestInterpolationValue, greaterThan(1)); + }); + + testWidgets( + 'the warning fires once total, not once per frame, across many pumps', + (tester) async { + final List printedMessages = await _DebugPrintCapture.run( + (printedMessages) async { + await tester.pumpWidget(nonVectorHost(false)); + await tester.pump(); + + await tester.pumpWidget(nonVectorHost(true)); + await tester.pump(); + + // The first frame discovers the downgrade exactly once. + expect(printedMessages, hasLength(1)); + + // Later frames re-evaluate the pair without printing again. + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + }, + ); + + expect(printedMessages, hasLength(1)); + }); + + testWidgets( + 'a VectorEffect (TranslateEffect via translateX) driven by a ' + 'SpringMotion emits no warning', (tester) async { + final List printedMessages = await _DebugPrintCapture.run( + (_) async { + await tester.pumpWidget(vectorHost(false)); + await tester.pump(); + + await tester.pumpWidget(vectorHost(true)); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + }, + ); + + expect(printedMessages, isEmpty); + }); + + testWidgets( + 'a non-VectorEffect driven by a CurvedMotion (not a spring) emits no ' + 'warning', (tester) async { + final List printedMessages = await _DebugPrintCapture.run( + (_) async { + await tester.pumpWidget(nonVectorCurvedHost(false)); + await tester.pump(); + + await tester.pumpWidget(nonVectorCurvedHost(true)); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + }, + ); + + expect(printedMessages, isEmpty); + }); +} diff --git a/test/static_effect_rebuild_test.dart b/test/static_effect_rebuild_test.dart new file mode 100644 index 0000000..1a29aed --- /dev/null +++ b/test/static_effect_rebuild_test.dart @@ -0,0 +1,202 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; +import 'package:material_ui/material_ui.dart'; + +/// Static effects — an effect chain with NO `.animate()`, scroll transition, +/// or pointer transition above it — must track their widget configuration +/// across rebuilds. This is what makes hot reload of an effect parameter +/// (e.g. changing the angle of a bare `.rotate()`) take effect without a +/// hot restart. +/// +/// The guard tests at the bottom pin the behaviors that must NOT change: +/// transition-driven and animation-driven [EffectWidget]s keep their +/// interrupted-animation continuation semantics. The TODO in +/// `effect_widget.dart` records that a previous fix for this bug broke +/// scroll transitions; those guards exist so this fix cannot repeat that. +const Key boxKey = Key('box'); + +const Widget box = SizedBox.square(dimension: 50, key: boxKey); + +Widget host(Widget child) => MaterialApp(home: Center(child: child)); + +/// The nearest [Transform] ancestor of the keyed box. +Transform nearestTransform(WidgetTester tester) => tester.widget( + find + .ancestor(of: find.byKey(boxKey), matching: find.byType(Transform)) + .first, + ); + +double rotationAngle(WidgetTester tester) { + final m = nearestTransform(tester).transform; + return atan2(m.entry(1, 0), m.entry(0, 0)); +} + +double scaleFactor(WidgetTester tester) { + final m = nearestTransform(tester).transform; + return Offset(m.entry(0, 0), m.entry(1, 0)).distance; +} + +Offset translationOffset(WidgetTester tester) { + final m = nearestTransform(tester).transform; + return Offset(m.entry(0, 3), m.entry(1, 3)); +} + +double opacityValue(WidgetTester tester) => tester + .widget( + find + .ancestor(of: find.byKey(boxKey), matching: find.byType(Opacity)) + .first, + ) + .opacity; + +void main() { + group('static effects track widget configuration across rebuilds', () { + testWidgets('rotate picks up a new angle', (tester) async { + await tester.pumpWidget(host(box.rotate(0.5))); + expect(rotationAngle(tester), closeTo(0.5, 1e-9)); + + await tester.pumpWidget(host(box.rotate(1.2))); + expect(rotationAngle(tester), closeTo(1.2, 1e-9), + reason: 'a rebuilt static rotate must render the new angle'); + }); + + testWidgets('scale picks up a new scale', (tester) async { + await tester.pumpWidget(host(box.scale(2))); + expect(scaleFactor(tester), closeTo(2, 1e-9)); + + await tester.pumpWidget(host(box.scale(3))); + expect(scaleFactor(tester), closeTo(3, 1e-9), + reason: 'a rebuilt static scale must render the new scale'); + }); + + testWidgets('opacity picks up a new opacity', (tester) async { + await tester.pumpWidget(host(box.opacity(0.5))); + expect(opacityValue(tester), closeTo(0.5, 1e-9)); + + await tester.pumpWidget(host(box.opacity(0.25))); + expect(opacityValue(tester), closeTo(0.25, 1e-9), + reason: 'a rebuilt static opacity must render the new opacity'); + }); + + testWidgets('translate picks up a new offset', (tester) async { + await tester.pumpWidget(host(box.translate(const Offset(10, 20)))); + expect(translationOffset(tester), const Offset(10, 20)); + + await tester.pumpWidget(host(box.translate(const Offset(-4, 8)))); + expect(translationOffset(tester), const Offset(-4, 8), + reason: 'a rebuilt static translate must render the new offset'); + }); + + testWidgets('rotate with from: picks up a new from value', (tester) async { + // A static effect with an explicit start renders that start value. + await tester.pumpWidget(host(box.rotate(2, from: 1))); + expect(rotationAngle(tester), closeTo(1, 1e-9)); + + await tester.pumpWidget(host(box.rotate(2, from: 0.4))); + expect(rotationAngle(tester), closeTo(0.4, 1e-9), + reason: 'a rebuilt static rotate must render the new from value'); + }); + + testWidgets('swapping the effect type applies the new effect', + (tester) async { + await tester.pumpWidget(host(box.rotate(1))); + expect(rotationAngle(tester), closeTo(1, 1e-9)); + + // Same element slot, different Effect type — the hot reload case of + // replacing `.rotate(...)` with `.scale(...)` in source. + await tester.pumpWidget(host(box.scale(3))); + expect(scaleFactor(tester), closeTo(3, 1e-9), + reason: 'a rebuilt static effect of a new type must render it'); + expect(rotationAngle(tester), closeTo(0, 1e-9), + reason: 'the old rotation must be gone after the type swap'); + }); + }); + + group('guards: driven effects keep their existing semantics', () { + testWidgets( + 'transition query: rebuilding with a new end must NOT reset start', + (tester) async { + // Pins the scroll/pointer transition contract that the reverted fix + // (see TODO in effect_widget.dart didUpdateWidget) broke: under a + // transition, `start` stays where initState put it while `end` + // follows the widget. + Widget transitionHost(double endAngle) => host( + EffectQuery( + linearValue: 0.5, + curvedValue: 0.5, + isTransition: true, + child: EffectWidget( + end: RotationEffect(angle: endAngle), + child: box, + ), + ), + ); + + await tester.pumpWidget(transitionHost(1)); + // start == end == 1, lerped at 0.5 -> 1. + expect(rotationAngle(tester), closeTo(1, 1e-9)); + + await tester.pumpWidget(transitionHost(2)); + // start must remain 1 (from initState); end follows to 2. + // lerp(1, 2, 0.5) -> 1.5. If start were reset to the new widget + // values, this would render 2. + expect(rotationAngle(tester), closeTo(1.5, 1e-9), + reason: 'transitions lerp from the original start, never reset'); + }); + + testWidgets('lerpValues: false query renders end directly and follows it', + (tester) async { + // Pins the pointer transition contract (isTransition + lerpValues + // false): the end effect is applied as-is and rebuilds update it. + Widget pointerHost(double endAngle) => host( + EffectQuery( + linearValue: 0.3, + curvedValue: 0.3, + isTransition: true, + lerpValues: false, + child: EffectWidget( + end: RotationEffect(angle: endAngle), + child: box, + ), + ), + ); + + await tester.pumpWidget(pointerHost(1)); + expect(rotationAngle(tester), closeTo(1, 1e-9)); + + await tester.pumpWidget(pointerHost(2)); + expect(rotationAngle(tester), closeTo(2, 1e-9)); + }); + + testWidgets( + 'animation query: rebuilding with a new end continues from the ' + 'in-flight value', (tester) async { + // Pins the interrupted-animation continuation: under a non-transition + // query mid-animation, a new end re-bases start onto the currently + // rendered value. + Widget animationHost(double endAngle) => host( + EffectQuery( + linearValue: 0.5, + curvedValue: 0.5, + isTransition: false, + child: EffectWidget( + start: const RotationEffect(angle: 0), + end: RotationEffect(angle: endAngle), + child: box, + ), + ), + ); + + await tester.pumpWidget(animationHost(2)); + // lerp(0, 2, 0.5) -> 1. + expect(rotationAngle(tester), closeTo(1, 1e-9)); + + await tester.pumpWidget(animationHost(4)); + // start re-based to the in-flight value 1; lerp(1, 4, 0.5) -> 2.5. + expect(rotationAngle(tester), closeTo(2.5, 1e-9), + reason: 'retargeting continues from the in-flight value'); + }); + }); +} diff --git a/test/support/geometry.dart b/test/support/geometry.dart new file mode 100644 index 0000000..fdb3eb7 --- /dev/null +++ b/test/support/geometry.dart @@ -0,0 +1,14 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Sums the x-translation of every [Transform] ancestor of [key] — the +/// composite translation the effect chain applies to that widget. +double translationXOf(WidgetTester tester, Key key) { + final transforms = tester.widgetList( + find.ancestor(of: find.byKey(key), matching: find.byType(Transform)), + ); + return transforms.fold( + 0, + (sum, transform) => sum + transform.transform.entry(0, 3), + ); +} diff --git a/test/timeline_compile_test.dart b/test/timeline_compile_test.dart new file mode 100644 index 0000000..e0608e8 --- /dev/null +++ b/test/timeline_compile_test.dart @@ -0,0 +1,132 @@ +import 'dart:math'; + +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +void main() { + test('compiles the stamp chain into typed keyframe tracks', () { + const content = SizedBox.square(dimension: 74); + final chain = content + .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); + + final spec = TimelineSpec.compile(chain); + + expect(spec.segments, [ + const TimelineSegment( + motion: CurvedMotion( + Duration(milliseconds: 350), + Curves.easeOutQuart, + ), + delay: Duration.zero, + ), + const TimelineSegment( + motion: CurvedMotion( + Duration(milliseconds: 300), + Curves.easeOutBack, + ), + delay: Duration(milliseconds: 150), + ), + ]); + expect(spec.totalDuration, const Duration(milliseconds: 800)); + + expect(spec.tracks, hasLength(2)); + final scaleTrack = + spec.tracks.singleWhere((t) => t.effectType == ScaleEffect); + expect(scaleTrack.keyframes, [ + ScaleEffect(scale: 0), + ScaleEffect(scale: 1.5), + ScaleEffect(scale: 1), + ]); + final rotationTrack = + spec.tracks.singleWhere((t) => t.effectType == RotationEffect); + expect(rotationTrack.keyframes, [ + const RotationEffect(angle: 0), + const RotationEffect(angle: 15 * pi / 180), + const RotationEffect(angle: 0), + ]); + + expect(spec.child, same(content)); + }); + + test('a type missing from a keyframe carries the previous value forward', () { + const content = SizedBox.square(dimension: 74); + final chain = content + .scale(0.5) + .step(duration: const Duration(milliseconds: 100)) + .fadeOut() + .step(duration: const Duration(milliseconds: 100)) + .scale(1); + + final spec = TimelineSpec.compile(chain); + + final scaleTrack = + spec.tracks.singleWhere((t) => t.effectType == ScaleEffect); + expect(scaleTrack.keyframes, [ + ScaleEffect(scale: 0.5), + ScaleEffect(scale: 0.5), // carried forward through keyframe 1 + ScaleEffect(scale: 1), + ]); + + // Opacity first appears at keyframe 1: earlier keyframes idle. + final opacityTrack = + spec.tracks.singleWhere((t) => t.effectType == OpacityEffect); + expect(opacityTrack.keyframes[1], OpacityEffect(opacity: 0)); + expect(opacityTrack.keyframes[0], OpacityEffect(opacity: 0).idle()); + // And carries forward to the final keyframe. + expect(opacityTrack.keyframes[2], OpacityEffect(opacity: 0)); + }); + + test('late color and skew tracks start from valid idle effects', () { + const content = SizedBox.square(dimension: 74); + final chain = content + .step(duration: const Duration(milliseconds: 100)) + .color(color: const Color(0xFFFF0000), mode: BlendMode.overlay) + .skewX(0.3); + + final spec = TimelineSpec.compile(chain); + final colorTrack = + spec.tracks.singleWhere((t) => t.effectType == ColorFilterEffect); + final skewTrack = + spec.tracks.singleWhere((t) => t.effectType == SkewEffect); + + expect( + colorTrack.keyframes.first, + ColorFilterEffect( + mode: BlendMode.overlay, + matrix: ColorFilterMatrix.identity, + ), + ); + expect(skewTrack.keyframes.first, const SkewEffect(skewX: 0)); + + final startEffects = spec.evaluate(0); + final ColorFilterEffect startColor = startEffects + .singleWhere((effect) => effect is ColorFilterEffect) + as ColorFilterEffect; + final SkewEffect startSkew = + startEffects.singleWhere((effect) => effect is SkewEffect) as SkewEffect; + + expect(startColor.matrix, ColorFilterMatrix.identity); + expect(startSkew, const SkewEffect(skewX: 0)); + }); + + test('duplicate effect type within one keyframe throws', () { + const content = SizedBox.square(dimension: 74); + final chain = content.scale(0.5).scale(2); + expect(() => TimelineSpec.compile(chain), throwsFlutterError); + }); +} diff --git a/test/timeline_controller_test.dart b/test/timeline_controller_test.dart new file mode 100644 index 0000000..85200b2 --- /dev/null +++ b/test/timeline_controller_test.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +double compositeScaleOf(WidgetTester tester, Key key) { + final transforms = tester.widgetList( + find.ancestor(of: find.byKey(key), matching: find.byType(Transform)), + ); + var scale = 1.0; + for (final t in transforms) { + final m = t.transform; + scale *= Offset(m.entry(0, 0), m.entry(1, 0)).distance; + } + return scale; +} + +const key = Key('box'); + +Widget host(TimelineController controller) => MaterialApp( + home: Center( + child: const SizedBox.square(dimension: 50, key: key) + .scale(0) + .step(duration: const Duration(milliseconds: 200)) + .scale(2) + .step( + duration: const Duration(milliseconds: 200), + delay: const Duration(milliseconds: 100), + ) + .scale(1) + .timeline(controller: controller), + ), + ); + +void main() { + testWidgets('seek renders the scrubbed position without animating', + (tester) async { + final controller = TimelineController(); + await tester.pumpWidget(host(controller)); + + // 250ms of 500ms total = inside segment 2's delay: holds scale 2. + controller.seek(0.5); + await tester.pump(); + expect(compositeScaleOf(tester, key), closeTo(2, 1e-9)); + expect(controller.progress, 0.5); + + controller.seek(0); + await tester.pump(); + expect(compositeScaleOf(tester, key), closeTo(0, 1e-9)); + }); + + testWidgets('play, pause mid-flight, resume to completion', (tester) async { + final controller = TimelineController(); + await tester.pumpWidget(host(controller)); + + expect(controller.isAttached, isTrue); + unawaited(controller.play()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + controller.pause(); + final held = controller.progress; + expect(held, closeTo(0.2, 1e-6)); // 100ms of 500ms + await tester.pump(const Duration(milliseconds: 300)); + expect(controller.progress, held, reason: 'paused timeline must hold'); + + unawaited(controller.play()); + await tester.pumpAndSettle(); + expect(controller.progress, 1); + expect(compositeScaleOf(tester, key), closeTo(1, 1e-9)); + + unawaited(controller.reverse()); + await tester.pumpAndSettle(); + expect(controller.progress, 0); + }); + + testWidgets('notifies listeners as progress changes', (tester) async { + final controller = TimelineController(); + await tester.pumpWidget(host(controller)); + + var notifications = 0; + controller.addListener(() => notifications++); + unawaited(controller.play()); + await tester.pumpAndSettle(); + expect(notifications, greaterThan(5)); + }); + + testWidgets('driving a detached controller throws loudly', (tester) async { + final controller = TimelineController(); + await tester.pumpWidget(host(controller)); + expect(controller.isAttached, isTrue); + + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + expect(controller.isAttached, isFalse); + expect(() => controller.play(), throwsStateError); + expect(() => controller.seek(0.5), throwsStateError); + }); +} diff --git a/test/timeline_effect_test.dart b/test/timeline_effect_test.dart new file mode 100644 index 0000000..e6a082d --- /dev/null +++ b/test/timeline_effect_test.dart @@ -0,0 +1,117 @@ +import 'dart:math'; + +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +double compositeScaleOf(WidgetTester tester, Key key) { + final transforms = tester.widgetList( + find.ancestor(of: find.byKey(key), matching: find.byType(Transform)), + ); + var scale = 1.0; + for (final t in transforms) { + final m = t.transform; + scale *= Offset(m.entry(0, 0), m.entry(1, 0)).distance; + } + return scale; +} + +const key = Key('icon'); + +Widget stamp(bool isCompleted) => MaterialApp( + home: Center( + child: const SizedBox.square(dimension: 74, key: key) + .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), + ), + ); + +void main() { + testWidgets('at rest the timeline renders its first keyframe', + (tester) async { + await tester.pumpWidget(stamp(false)); + await tester.pump(); + expect(compositeScaleOf(tester, key), closeTo(0, 1e-9)); + }); + + testWidgets('triggering plays the stamp with correct values throughout', + (tester) async { + await tester.pumpWidget(stamp(false)); + await tester.pump(); + + await tester.pumpWidget(stamp(true)); + // 175ms: segment 1 local progress 0.5 under easeOutQuart. + await tester.pump(const Duration(milliseconds: 175)); + expect( + compositeScaleOf(tester, key), + closeTo(1.5 * Curves.easeOutQuart.transform(0.5), 1e-6), + ); + // 400ms: inside the 150ms delay window — holds the slam keyframe. + await tester.pump(const Duration(milliseconds: 225)); + expect(compositeScaleOf(tester, key), closeTo(1.5, 1e-6)); + // 650ms: segment 2 local progress 0.5 under easeOutBack. + await tester.pump(const Duration(milliseconds: 250)); + expect( + compositeScaleOf(tester, key), + closeTo(1.5 + (1 - 1.5) * Curves.easeOutBack.transform(0.5), 1e-6), + ); + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, key), closeTo(1.0, 1e-9)); + }); + + testWidgets( + 'any trigger change restarts forward — direction is never ' + 'inferred from the trigger value', (tester) async { + await tester.pumpWidget(stamp(false)); + await tester.pump(); + + await tester.pumpWidget(stamp(true)); + await tester.pump(const Duration(milliseconds: 175)); + + // Mid-flight the trigger changes to FALSE. A trigger is an identity + // signal, not a direction: the timeline must restart from the beginning + // and play forward to its final keyframe — not reverse toward 0. + await tester.pumpWidget(stamp(false)); + await tester.pump(); + expect(compositeScaleOf(tester, key), closeTo(0, 1e-9), + reason: 'restart must rewind to the first keyframe'); + await tester.pump(const Duration(milliseconds: 175)); + expect( + compositeScaleOf(tester, key), + closeTo(1.5 * Curves.easeOutQuart.transform(0.5), 1e-6), + reason: 'the restarted run must play forward', + ); + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, key), closeTo(1.0, 1e-9), + reason: 'the restarted run must complete at the final keyframe'); + }); + + testWidgets('onEnd fires exactly once per completed run', (tester) async { + var calls = 0; + Widget build(bool t) => MaterialApp( + home: const SizedBox.square(dimension: 74, key: key) + .scale(0) + .step(duration: const Duration(milliseconds: 100)) + .scale(1) + .timeline(trigger: t, onEnd: () => calls++), + ); + await tester.pumpWidget(build(false)); + await tester.pumpWidget(build(true)); + await tester.pumpAndSettle(); + expect(calls, 1); + }); +} diff --git a/test/timeline_evaluate_test.dart b/test/timeline_evaluate_test.dart new file mode 100644 index 0000000..4b60ead --- /dev/null +++ b/test/timeline_evaluate_test.dart @@ -0,0 +1,93 @@ +import 'dart:math'; + +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +void main() { + // The stamp: 350ms easeOutQuart to (1.5, 15deg), 150ms hold, then 300ms + // easeOutBack to (1.0, 0deg). Total 800ms. + late TimelineSpec spec; + + setUp(() { + spec = TimelineSpec.compile( + const SizedBox.square(dimension: 74) + .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), + ); + }); + + double scaleAt(double t) => + (spec.evaluate(t).singleWhere((e) => e is ScaleEffect) as ScaleEffect) + .scale!; + + double angleAt(double t) => + (spec.evaluate(t).singleWhere((e) => e is RotationEffect) + as RotationEffect) + .angle; + + test('t=0 renders the first keyframe', () { + expect(scaleAt(0), 0); + expect(angleAt(0), 0); + }); + + test('mid segment 1 lerps along the segment curve', () { + // 175ms of 800ms total; segment-local progress 0.5. + final curved = Curves.easeOutQuart.transform(0.5); + expect(scaleAt(175 / 800), closeTo(1.5 * curved, 1e-9)); + expect(angleAt(175 / 800), closeTo(15 * pi / 180 * curved, 1e-9)); + }); + + test('during a delay the previous keyframe holds', () { + // 350ms..500ms is segment 2's delay window. + expect(scaleAt(400 / 800), 1.5); + expect(scaleAt(499 / 800), 1.5); + }); + + test('mid segment 2 lerps from held value along its own curve', () { + // 650ms: segment-2-local progress (650-500)/300 = 0.5. + final curved = Curves.easeOutBack.transform(0.5); + expect(scaleAt(650 / 800), closeTo(1.5 + (1 - 1.5) * curved, 1e-9)); + }); + + test('t=1 renders the final keyframe', () { + expect(scaleAt(1), 1); + expect(angleAt(1), 0); + }); + + test('out-of-range times clamp', () { + expect(scaleAt(-0.5), 0); + expect(scaleAt(1.5), 1); + }); + + test('zero-duration segment snaps to its end keyframe', () { + final snap = TimelineSpec.compile( + const SizedBox() + .scale(0) + .step(duration: Duration.zero) + .scale(2) + .step(duration: const Duration(milliseconds: 100)) + .scale(3), + ); + final scale = + (snap.evaluate(0).single as ScaleEffect).scale; // t=0: after snap seg? + // At exactly t=0 the timeline is at its start: first keyframe. + expect(scale, 0); + // Any t past the zero-duration segment's position renders its end. + final justAfter = (snap.evaluate(0.001).single as ScaleEffect).scale; + expect(justAfter, greaterThanOrEqualTo(2)); + }); +} diff --git a/test/timeline_immediate_test.dart b/test/timeline_immediate_test.dart new file mode 100644 index 0000000..f83ebfc --- /dev/null +++ b/test/timeline_immediate_test.dart @@ -0,0 +1,66 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +double compositeScaleOf(WidgetTester tester, Key key) { + final transforms = tester.widgetList( + find.ancestor(of: find.byKey(key), matching: find.byType(Transform)), + ); + var scale = 1.0; + for (final t in transforms) { + final m = t.transform; + scale *= Offset(m.entry(0, 0), m.entry(1, 0)).distance; + } + return scale; +} + +const key = Key('box'); + +Widget host({int repeat = 0}) => MaterialApp( + home: Center( + child: const SizedBox.square(dimension: 50, key: key) + .scale(0) + .step( + duration: const Duration(milliseconds: 300), + curve: Curves.linear, + ) + .scale(1) + .timeline(trigger: #immediate, repeat: repeat), + ), + ); + +void main() { + testWidgets('trigger: #immediate plays the timeline on mount', + (tester) async { + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 150)); + expect(compositeScaleOf(tester, key), closeTo(0.5, 1e-6), + reason: '#immediate must play without any trigger change'); + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, key), closeTo(1.0, 1e-9)); + }); + + testWidgets('rebuilding with #immediate does not replay', (tester) async { + await tester.pumpWidget(host()); + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, key), closeTo(1.0, 1e-9)); + + // Rebuild: the sentinel is unchanged, so nothing restarts. + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 150)); + expect(compositeScaleOf(tester, key), closeTo(1.0, 1e-9), + reason: 'a rebuild is not a trigger change'); + }); + + testWidgets('#immediate honors the repeat budget', (tester) async { + await tester.pumpWidget(host(repeat: 1)); + // Cross the cycle-1 boundary, then sample cycle 2 mid-flight. + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(const Duration(milliseconds: 150)); + expect(compositeScaleOf(tester, key), closeTo(0.5, 1e-6), + reason: 'cycle 2 must play'); + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, key), closeTo(1.0, 1e-9)); + }); +} diff --git a/test/timeline_loop_test.dart b/test/timeline_loop_test.dart new file mode 100644 index 0000000..eb0c620 --- /dev/null +++ b/test/timeline_loop_test.dart @@ -0,0 +1,110 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +double compositeScaleOf(WidgetTester tester, Key key) { + final transforms = tester.widgetList( + find.ancestor(of: find.byKey(key), matching: find.byType(Transform)), + ); + var scale = 1.0; + for (final t in transforms) { + final m = t.transform; + scale *= Offset(m.entry(0, 0), m.entry(1, 0)).distance; + } + return scale; +} + +const key = Key('box'); + +Widget host({ + Object? trigger = #immediate, + int repeat = 0, + VoidCallback? onEnd, +}) => + MaterialApp( + home: Center( + child: const SizedBox.square(dimension: 50, key: key) + .scale(0) + .step( + duration: const Duration(milliseconds: 300), + curve: Curves.linear, + ) + .scale(1) + .timeline( + trigger: trigger, + repeat: repeat, + onEnd: onEnd, + ), + ), + ); + +void main() { + testWidgets('#immediate plays on mount without any trigger change', + (tester) async { + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 150)); + expect(compositeScaleOf(tester, key), closeTo(0.5, 1e-6), + reason: 'the timeline must be mid-flight on its own'); + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, key), closeTo(1.0, 1e-9)); + }); + + testWidgets('negative repeat loops forever', (tester) async { + await tester.pumpWidget(host(repeat: -2)); + await tester.pump(const Duration(milliseconds: 150)); + expect(compositeScaleOf(tester, key), closeTo(0.5, 1e-6)); + // One full cycle later: mid-flight again, not resting. + await tester.pump(const Duration(milliseconds: 300)); + expect(compositeScaleOf(tester, key), closeTo(0.5, 1e-6), + reason: 'cycle 2 must replay, not rest'); + await tester.pump(const Duration(milliseconds: 300)); + expect(compositeScaleOf(tester, key), closeTo(0.5, 1e-6), + reason: 'cycle 3 must replay, not rest'); + }); + + testWidgets('finite repeat plays N+1 cycles then rests; onEnd fires once', + (tester) async { + var ends = 0; + await tester.pumpWidget(host( + repeat: 2, + onEnd: () { + ends++; + })); + // A cycle completes on the first tick strictly past its duration, and + // the next cycle's clock starts at that boundary-crossing frame. + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(const Duration(milliseconds: 150)); + expect(compositeScaleOf(tester, key), closeTo(0.5, 1e-6), + reason: 'cycle 2 must be playing'); + await tester.pump(const Duration(milliseconds: 150)); + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(const Duration(milliseconds: 150)); + expect(compositeScaleOf(tester, key), closeTo(0.5, 1e-6), + reason: 'cycle 3 must be playing'); + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, key), closeTo(1.0, 1e-9), + reason: 'after the final cycle the timeline rests at its end'); + expect(ends, 1, reason: 'onEnd fires once, at the true end'); + }); + + testWidgets('a trigger change restores the full repeat budget', + (tester) async { + await tester.pumpWidget(host(trigger: 0, repeat: 1)); + await tester.pump(); + + await tester.pumpWidget(host(trigger: 1, repeat: 1)); + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, key), closeTo(1.0, 1e-9)); + + // Re-trigger: both cycles must play again. + await tester.pumpWidget(host(trigger: 2, repeat: 1)); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(const Duration(milliseconds: 150)); + expect(compositeScaleOf(tester, key), closeTo(0.5, 1e-6), + reason: 'cycle 2 of the re-triggered run must be playing'); + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, key), closeTo(1.0, 1e-9)); + }); +} diff --git a/test/timeline_motion_test.dart b/test/timeline_motion_test.dart new file mode 100644 index 0000000..e941f8c --- /dev/null +++ b/test/timeline_motion_test.dart @@ -0,0 +1,135 @@ +import 'dart:math'; + +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +double compositeScaleOf(WidgetTester tester, Key key) { + final transforms = tester.widgetList( + find.ancestor(of: find.byKey(key), matching: find.byType(Transform)), + ); + var scale = 1.0; + for (final t in transforms) { + final m = t.transform; + scale *= Offset(m.entry(0, 0), m.entry(1, 0)).distance; + } + return scale; +} + +const key = Key('box'); + +void main() { + test('duration+curve sugar and an explicit CurvedMotion compile identically', + () { + final sugar = TimelineSpec.compile( + const SizedBox() + .scale(0) + .step( + duration: const Duration(milliseconds: 350), + curve: Curves.easeOut, + ) + .scale(1), + ); + final explicit = TimelineSpec.compile( + const SizedBox() + .scale(0) + .step( + motion: const CurvedMotion( + Duration(milliseconds: 350), + Curves.easeOut, + ), + ) + .scale(1), + ); + expect(sugar.segments, explicit.segments); + expect(sugar.totalDuration, explicit.totalDuration); + }); + + test('a spring segment contributes its settling bound to totalDuration', () { + const bouncy = CupertinoMotion.bouncy(); + final spec = TimelineSpec.compile( + const SizedBox().scale(0).step(motion: bouncy).scale(1), + ); + expect(spec.totalDuration, bouncy.effectiveDuration); + }); + + test('evaluate overshoots through a spring segment and ends exactly', () { + final spec = TimelineSpec.compile( + const SizedBox() + .scale(0) + .step(motion: const CupertinoMotion.bouncy()) + .scale(1), + ); + var peak = 0.0; + for (var i = 1; i < 200; i++) { + final scale = (spec.evaluate(i / 200).single as ScaleEffect).scale!; + peak = max(peak, scale); + } + expect(peak, greaterThan(1.001), reason: 'the spring must overshoot'); + expect((spec.evaluate(1).single as ScaleEffect).scale, 1, + reason: 'the segment must end exactly at its keyframe'); + }); + + test('declaring both motion and duration/curve throws', () { + expect( + () => const SizedBox().step( + duration: const Duration(milliseconds: 100), + motion: const CupertinoMotion.bouncy(), + ), + throwsFlutterError, + ); + }); + + testWidgets('a spring step renders overshoot and rests exactly', + (tester) async { + Widget host(int trigger) => MaterialApp( + home: Center( + child: const SizedBox.square(dimension: 50, key: key) + .scale(0) + .step(motion: const CupertinoMotion.bouncy()) + .scale(1) + .timeline(trigger: trigger), + ), + ); + await tester.pumpWidget(host(0)); + await tester.pump(); + + await tester.pumpWidget(host(1)); + var peak = 0.0; + for (var i = 0; i < 60; i++) { + await tester.pump(const Duration(milliseconds: 16)); + peak = max(peak, compositeScaleOf(tester, key)); + } + expect(peak, greaterThan(1.001), + reason: 'the rendered spring must visibly overshoot'); + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, key), closeTo(1.0, 1e-9), + reason: 'the spring must rest exactly at the keyframe'); + }); + + testWidgets('.animate(motion:) springs and rests exactly', (tester) async { + Widget host(int trigger) => MaterialApp( + home: Center( + child: const SizedBox.square(dimension: 50, key: key) + .scale(2, from: 1) + .animate( + trigger: trigger, + motion: const CupertinoMotion.bouncy(), + ), + ), + ); + await tester.pumpWidget(host(0)); + await tester.pump(); + + await tester.pumpWidget(host(1)); + var peak = 0.0; + for (var i = 0; i < 60; i++) { + await tester.pump(const Duration(milliseconds: 16)); + peak = max(peak, compositeScaleOf(tester, key)); + } + expect(peak, greaterThan(2.001), + reason: 'the spring must overshoot past the target scale'); + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, key), closeTo(2.0, 1e-9)); + }); +} diff --git a/test/timeline_replay_test.dart b/test/timeline_replay_test.dart new file mode 100644 index 0000000..9091538 --- /dev/null +++ b/test/timeline_replay_test.dart @@ -0,0 +1,140 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +double compositeScaleOf(WidgetTester tester, Key key) { + final transforms = tester.widgetList( + find.ancestor(of: find.byKey(key), matching: find.byType(Transform)), + ); + var scale = 1.0; + for (final t in transforms) { + final m = t.transform; + scale *= Offset(m.entry(0, 0), m.entry(1, 0)).distance; + } + return scale; +} + +const key = Key('icon'); + +Widget stampChain() => const SizedBox.square(dimension: 74, key: key) + .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); + +void main() { + testWidgets('replays via trigger are frame-for-frame identical', + (tester) async { + Widget host(int trigger) => MaterialApp( + home: Center(child: stampChain().timeline(trigger: trigger)), + ); + + Future> trace(int trigger) async { + await tester.pumpWidget(host(trigger)); + final samples = []; + for (var t = 50; t <= 850; t += 50) { + await tester.pump(const Duration(milliseconds: 50)); + samples.add(compositeScaleOf(tester, key)); + } + await tester.pumpAndSettle(); + samples.add(compositeScaleOf(tester, key)); + return samples; + } + + await tester.pumpWidget(host(0)); + await tester.pump(); + + final play1 = await trace(1); + final play2 = await trace(2); + + expect(play2, play1, reason: 'every run must be identical'); + expect(play1.reduce(max), closeTo(1.5, 1e-6), + reason: 'the stamp must overshoot on every run'); + }); + + testWidgets( + 'controller-driven press/release: press 2 identical to press 1, ' + 'reverse returns to true rest', (tester) async { + final controller = TimelineController(); + await tester.pumpWidget( + MaterialApp( + home: Center(child: stampChain().timeline(controller: controller)), + ), + ); + await tester.pump(); + + Future> trace(Future Function() drive) async { + unawaited(drive()); + final samples = []; + for (var t = 50; t <= 850; t += 50) { + await tester.pump(const Duration(milliseconds: 50)); + samples.add(compositeScaleOf(tester, key)); + } + await tester.pumpAndSettle(); + samples.add(compositeScaleOf(tester, key)); + return samples; + } + + final press1 = await trace(controller.play); + final release = await trace(controller.reverse); + final press2 = await trace(controller.play); + + expect(release.last, closeTo(0, 1e-9), + reason: 'reverse must return to the true resting state'); + expect(press2, press1, reason: 'press 2 must trace identically'); + expect(press1.reduce(max), closeTo(1.5, 1e-6)); + expect(press2.reduce(max), closeTo(1.5, 1e-6)); + }); + + testWidgets('controller interrupts mid-flight never snap', (tester) async { + final controller = TimelineController(); + await tester.pumpWidget( + MaterialApp( + home: Center(child: stampChain().timeline(controller: controller)), + ), + ); + await tester.pump(); + + unawaited(controller.play()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + // Reverse mid-settle, then play again mid-reverse. + unawaited(controller.reverse()); + final samples = [compositeScaleOf(tester, key)]; + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 16)); + samples.add(compositeScaleOf(tester, key)); + } + unawaited(controller.play()); + for (var i = 0; i < 20; i++) { + await tester.pump(const Duration(milliseconds: 16)); + samples.add(compositeScaleOf(tester, key)); + } + + var maxJump = 0.0; + for (var i = 1; i < samples.length; i++) { + maxJump = max(maxJump, (samples[i] - samples[i - 1]).abs()); + } + // At 16ms frames the steepest legitimate slope of this timeline moves + // well under 0.2 per frame; a controller snap moves 0.5+ in one frame. + expect(maxJump, lessThan(0.2)); + + await tester.pumpAndSettle(); + expect(compositeScaleOf(tester, key), closeTo(1.0, 1e-9)); + }); +} diff --git a/test/timeline_stepless_repeat_test.dart b/test/timeline_stepless_repeat_test.dart new file mode 100644 index 0000000..019a627 --- /dev/null +++ b/test/timeline_stepless_repeat_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +void main() { + testWidgets('looping a chain with no step() explains what is missing', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: const SizedBox(width: 10, height: 10) + .rotate(1.0) + .timeline(trigger: #immediate, repeat: -1), + ), + ); + + final Object? error = tester.takeException(); + expect(error, isFlutterError); + expect( + (error as FlutterError).message, + allOf( + contains('step()'), + contains('repeat'), + isNot(contains('_periodInSeconds')), + ), + ); + }); + + testWidgets('any negative repeat on a chain with no step() explains what is missing', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: const SizedBox(width: 10, height: 10) + .rotate(1.0) + .timeline(trigger: #immediate, repeat: -2), + ), + ); + + final Object? error = tester.takeException(); + expect(error, isFlutterError); + expect( + (error as FlutterError).message, + allOf( + contains('step()'), + contains('repeat'), + isNot(contains('_periodInSeconds')), + ), + ); + }); +} diff --git a/test/translate_effect_hit_test_test.dart b/test/translate_effect_hit_test_test.dart new file mode 100644 index 0000000..c29949b --- /dev/null +++ b/test/translate_effect_hit_test_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; +import 'package:material_ui/material_ui.dart'; + +void main() { + testWidgets( + 'translate() moves hit testing with paint by default, matching Flutter', + (tester) async { + var taps = 0; + const key = Key('button'); + + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: GestureDetector( + key: key, + behavior: HitTestBehavior.opaque, + onTap: () => taps++, + child: const SizedBox.square(dimension: 48), + ).translateX(160), + ), + ), + ); + + final paintedCentre = tester.getCenter(find.byKey(key)); + expect(paintedCentre.dx, closeTo(184, 0.01)); + + await tester.tapAt(paintedCentre); + await tester.pump(); + + expect( + taps, + 1, + reason: 'Transform.translate defaults transformHitTests to true; ' + 'the effect wrapper must preserve that contract', + ); + }, + ); + + testWidgets('translate() still allows paint-only movement explicitly', ( + tester, + ) async { + var taps = 0; + const key = Key('paint-only-button'); + + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: GestureDetector( + key: key, + behavior: HitTestBehavior.opaque, + onTap: () => taps++, + child: const SizedBox.square(dimension: 48), + ).translateX(160, transformHitTests: false), + ), + ), + ); + + await tester.tapAt(tester.getCenter(find.byKey(key))); + await tester.pump(); + expect(taps, 0); + + await tester.tapAt(const Offset(24, 24)); + await tester.pump(); + expect(taps, 1); + }); +} diff --git a/test/vector_effect_test.dart b/test/vector_effect_test.dart new file mode 100644 index 0000000..1fc0b5e --- /dev/null +++ b/test/vector_effect_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; +import 'package:material_ui/material_ui.dart'; + +/// Contract for [VectorEffect]: typed vector arithmetic on the effect +/// itself — the structure spring physics needs, with components never +/// exposed as raw numbers. +void main() { + group('TranslateEffect', () { + final a = TranslateEffect(offset: const Offset(10, 20)); + final b = TranslateEffect(offset: const Offset(1, 2)); + + test('arithmetic is field-wise', () { + expect((a + b).offset, const Offset(11, 22)); + expect((a - b).offset, const Offset(9, 18)); + expect((a * 2).offset, const Offset(20, 40)); + expect(a.magnitudeSquared, 10 * 10 + 20 * 20); + }); + + test('non-vector fields ride along from the left operand', () { + final fractional = TranslateEffect( + offset: const Offset(1, 0), + fractional: true, + ); + expect((fractional + b).fractional, isTrue); + expect((fractional * 2).fractional, isTrue); + }); + + test('lerp is derivable from the algebra', () { + final derived = a + (b - a) * 0.25; + expect(derived, a.lerp(b, 0.25)); + }); + }); + + group('ScaleEffect', () { + test('arithmetic works across uniform and split forms', () { + final uniform = ScaleEffect(scale: 2); + final split = ScaleEffect(scaleX: 1, scaleY: 3); + final sum = uniform + split; + // Effective per-axis values combine; the result renders identically + // regardless of declared form. + expect(sum.scaleX ?? sum.scale, 3); + expect(sum.scaleY ?? sum.scale, 5); + }); + + test('scaling and magnitude', () { + final s = ScaleEffect(scaleX: 3, scaleY: 4); + final doubled = s * 2; + expect(doubled.scaleX, 6); + expect(doubled.scaleY, 8); + expect(s.magnitudeSquared, 25); + }); + }); + + group('RotationEffect', () { + const a = RotationEffect(angle: 1.0, alignment: Alignment.topCenter); + const b = RotationEffect(angle: 0.25); + + test('arithmetic on angle; alignment rides along', () { + expect((a + b).angle, 1.25); + expect((a - b).angle, 0.75); + expect((a * 2).angle, 2.0); + expect((a + b).alignment, Alignment.topCenter); + expect(a.magnitudeSquared, 1.0); + }); + }); + + group('OpacityEffect', () { + final a = OpacityEffect(opacity: 0.5); + final b = OpacityEffect(opacity: 0.25); + + test('arithmetic on opacity, unclamped for physics', () { + expect((a + b).opacity, 0.75); + expect((a - b).opacity, 0.25); + expect((a * 3).opacity, 1.5, reason: 'velocity math needs >1 values'); + expect(b.magnitudeSquared, 0.0625); + }); + + testWidgets('apply clamps overshoot so the Opacity widget never throws', + (tester) async { + await tester.pumpWidget( + OpacityEffect(opacity: 1.2).apply(context_(tester), const SizedBox()), + ); + expect(tester.widget(find.byType(Opacity)).opacity, 1.0); + await tester.pumpWidget( + OpacityEffect(opacity: -0.2).apply(context_(tester), const SizedBox()), + ); + expect(tester.widget(find.byType(Opacity)).opacity, 0.0); + }); + }); +} + +BuildContext context_(WidgetTester tester) => + tester.binding.rootElement! as BuildContext; diff --git a/test/vector_spring_lifecycle_test.dart b/test/vector_spring_lifecycle_test.dart new file mode 100644 index 0000000..7392e35 --- /dev/null +++ b/test/vector_spring_lifecycle_test.dart @@ -0,0 +1,305 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; +import 'package:hyper_effects/src/motion/vector_spring.dart'; + +import 'support/geometry.dart'; + +const _boxKey = Key('box'); + +void main() { + testWidgets('reverse spring is a fresh forward-time solve', (tester) async { + final animationKey = GlobalKey(); + const motion = SpringMotion( + SpringDescription(mass: 1, stiffness: 100, damping: 10), + ); + var trigger = 0; + + Widget host() => Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.square(dimension: 20, key: _boxKey) + .translateX(120, from: -120) + .animate( + key: animationKey, + trigger: trigger, + motion: motion, + repeat: 1, + reverse: true, + ), + ); + + await tester.pumpWidget(host()); + trigger = 1; + await tester.pumpWidget(host()); + await tester.pump(motion.effectiveDuration); + await tester.pump(); + + expect(translationXOf(tester, _boxKey), closeTo(120, 1e-6)); + + await tester.pump(const Duration(milliseconds: 1)); + expect( + animationKey.currentState!.controller.status, AnimationStatus.forward, + reason: 'a reverse spring advances fresh physical time forward'); + await tester.pump(Duration( + microseconds: motion.effectiveDuration.inMicroseconds ~/ 4, + )); + final controllerValue = animationKey.currentState!.controller.value; + final rendered = translationXOf(tester, _boxKey); + final reverseTime = controllerValue; + final seconds = motion.effectiveDuration.inMicroseconds / + Duration.microsecondsPerSecond; + final fresh = springCoefficients( + motion.description, + reverseTime * seconds, + ); + final backward = springCoefficients( + motion.description, + (1 - controllerValue) * seconds, + ); + final expectedFresh = -120 + (120 - -120) * fresh.a; + final expectedBackward = 120 + (-120 - 120) * backward.a; + + expect((expectedFresh - expectedBackward).abs(), greaterThan(10), + reason: 'the sample must discriminate the two equations'); + expect(rendered, closeTo(expectedFresh, 1e-3)); + }); + + testWidgets('completed reverse spring rests exactly at its start value', + (tester) async { + final animationKey = GlobalKey(); + const motion = SpringMotion( + SpringDescription(mass: 1, stiffness: 100, damping: 10), + ); + var trigger = 0; + + Widget host() => Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.square(dimension: 20, key: _boxKey) + .translateX(120, from: -120) + .animate( + key: animationKey, + trigger: trigger, + motion: motion, + repeat: 1, + reverse: true, + ), + ); + + await tester.pumpWidget(host()); + trigger = 1; + await tester.pumpWidget(host()); + + // Forward leg completes at the end value. The simulation's isDone is + // strictly greater-than its duration, so one extra millisecond is + // needed to finish each leg and hand off to the next. + await tester.pump(motion.effectiveDuration); + await tester.pump(const Duration(milliseconds: 1)); + expect(translationXOf(tester, _boxKey), closeTo(120, 1e-6)); + + // Reverse leg completes: the run rests at its START, with no snap + // to the end value at the final frame. + await tester.pump(motion.effectiveDuration); + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(); + expect(translationXOf(tester, _boxKey), closeTo(-120, 1e-6), + reason: 'the reverse leg solves end-to-start, so its resting ' + 'endpoint is the start value'); + + // The resting frame must be stable across later idle pumps. + await tester.pump(const Duration(seconds: 1)); + expect(translationXOf(tester, _boxKey), closeTo(-120, 1e-6)); + }); + + testWidgets('vector springs honor implicit idle start', (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.square(dimension: 20, key: _boxKey) + .padAll(120) + .animate( + trigger: #immediate, + motion: const SpringMotion( + SpringDescription(mass: 1, stiffness: 100, damping: 10), + ), + resetValues: true, + ), + ), + ); + + double padding() => tester + .widget(find.byType(Padding)) + .padding + .resolve(TextDirection.ltr) + .left; + + expect(padding(), 0, + reason: 'implicit PaddingEffect start is its numeric zero idle'); + await tester.pump(const Duration(milliseconds: 100)); + expect(padding(), greaterThan(0)); + await tester.pumpAndSettle(); + expect(padding(), closeTo(120, 1e-6)); + }); + + testWidgets('completed spring replay starts from rest', (tester) async { + final animationKey = GlobalKey(); + const motion = SpringMotion( + SpringDescription(mass: 1, stiffness: 100, damping: 10), + ); + var trigger = 0; + var target = -120.0; + + Widget host() => Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.square(dimension: 20, key: _boxKey) + .translateX(target, from: -120) + .animate( + key: animationKey, + trigger: trigger, + motion: motion, + ), + ); + + await tester.pumpWidget(host()); + target = 120; + trigger = 1; + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 100)); + + target = -120; + trigger = 2; + await tester.pumpWidget(host()); + await tester.pumpAndSettle(); + expect(translationXOf(tester, _boxKey), closeTo(-120, 1e-6)); + + trigger = 3; + await tester.pumpWidget(host()); + final replayStart = translationXOf(tester, _boxKey); + await tester.pump(const Duration(milliseconds: 32)); + + final t = animationKey.currentState!.controller.value; + final seconds = motion.effectiveDuration.inMicroseconds / + Duration.microsecondsPerSecond; + final c = springCoefficients(motion.description, t * seconds); + final expectedFromRest = -120 + (replayStart - -120) * c.a; + + expect(translationXOf(tester, _boxKey), closeTo(expectedFromRest, 1e-3), + reason: 'a completed prior run must not inject retarget velocity into ' + 'a later trigger-only replay'); + }); + + testWidgets('retarget after completion captures exact endpoint', + (tester) async { + const motion = SpringMotion( + SpringDescription(mass: 1, stiffness: 100, damping: 10), + ); + var trigger = 0; + var target = -1000000000.0; + + Widget host() => Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.square(dimension: 20, key: _boxKey) + .translateX(target, from: -1000000000) + .animate(trigger: trigger, motion: motion), + ); + + await tester.pumpWidget(host()); + target = 1000000000; + trigger = 1; + await tester.pumpWidget(host()); + await tester.pumpAndSettle(); + final completed = translationXOf(tester, _boxKey); + expect(completed, 1000000000); + + target = -1000000000; + trigger = 2; + await tester.pumpWidget(host()); + + expect(translationXOf(tester, _boxKey), completed, + reason: 'retarget capture must start at the exact endpoint painted ' + 'by the completed frame, not the finite-bound spring residual'); + }); + + testWidgets('simultaneous target and motion update samples outgoing motion', + (tester) async { + const motionA = SpringMotion( + SpringDescription(mass: 1, stiffness: 80, damping: 8), + ); + const motionB = SpringMotion( + SpringDescription(mass: 1, stiffness: 800, damping: 55), + ); + var trigger = 0; + var target = -120.0; + SpringMotion motion = motionA; + + Widget host() => Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.square(dimension: 20, key: _boxKey) + .translateX(target, from: -120) + .animate(trigger: trigger, motion: motion), + ); + + await tester.pumpWidget(host()); + target = 120; + trigger = 1; + await tester.pumpWidget(host()); + await tester.pump(const Duration(milliseconds: 120)); + + final outgoingRendered = translationXOf(tester, _boxKey); + final state = + tester.state(find.byType(AnimatedEffect)); + final oldT = state.controller.value; + final bSeconds = motionB.effectiveDuration.inMicroseconds / + Duration.microsecondsPerSecond; + final b = springCoefficients(motionB.description, oldT * bSeconds); + final incomingMotionPrediction = 120 + (-120 - 120) * b.a; + expect((incomingMotionPrediction - outgoingRendered).abs(), greaterThan(10), + reason: 'the two motions must predict visibly different captures'); + + target = -120; + motion = motionB; + trigger = 2; + await tester.pumpWidget(host()); + + expect(translationXOf(tester, _boxKey), closeTo(outgoingRendered, 1e-3), + reason: 'retarget capture must use the outgoing motion that painted ' + 'the prior frame'); + }); + + testWidgets('reset during reverse spring renders original start', + (tester) async { + final animationKey = GlobalKey(); + const motion = SpringMotion( + SpringDescription(mass: 1, stiffness: 100, damping: 10), + ); + var trigger = 0; + + Widget host() => Directionality( + textDirection: TextDirection.ltr, + child: const SizedBox.square(dimension: 20, key: _boxKey) + .translateX(120, from: -120) + .animate( + key: animationKey, + trigger: trigger, + motion: motion, + repeat: 1, + reverse: true, + ), + ); + + await tester.pumpWidget(host()); + trigger = 1; + await tester.pumpWidget(host()); + await tester.pump(motion.effectiveDuration); + await tester.pump(const Duration(milliseconds: 1)); + expect( + animationKey.currentState!.controller.status, AnimationStatus.forward, + reason: 'spring reverse uses fresh forward physical time'); + + animationKey.currentState!.reset(); + await tester.pump(); + + expect(animationKey.currentState!.controller.value, 0); + expect(translationXOf(tester, _boxKey), -120, + reason: 'reset clears reverse-leg publication and renders start'); + }); +} diff --git a/test/vector_spring_test.dart b/test/vector_spring_test.dart new file mode 100644 index 0000000..f8ba396 --- /dev/null +++ b/test/vector_spring_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter/physics.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/src/motion/vector_spring.dart'; + +/// Pins the closed-form coefficient solver against Flutter's own +/// [SpringSimulation] across all three damping regimes, positions and +/// velocities alike. +void main() { + const springs = { + 'underdamped': SpringDescription(mass: 1, stiffness: 100, damping: 10), + 'critical': SpringDescription(mass: 1, stiffness: 100, damping: 20), + 'overdamped': SpringDescription(mass: 1, stiffness: 100, damping: 25), + }; + + for (final MapEntry(key: name, value: spring) in springs.entries) { + group(name, () { + test('t=0 is exact', () { + final c = springCoefficients(spring, 0); + expect(c.a, closeTo(1, 1e-12)); + expect(c.b, closeTo(0, 1e-12)); + expect(c.da, closeTo(0, 1e-12)); + expect(c.db, closeTo(1, 1e-12)); + }); + + test('matches SpringSimulation.x and .dx', () { + for (final v0 in [0.0, 3.0, -2.0]) { + final reference = SpringSimulation(spring, 0, 1, v0); + for (var i = 1; i <= 20; i++) { + final t = i * 0.05; + final c = springCoefficients(spring, t); + // x0 = 0, target = 1 => d0 = -1. + final x = 1 + -1 * c.a + v0 * c.b; + final dx = -1 * c.da + v0 * c.db; + expect(x, closeTo(reference.x(t), 1e-6), + reason: '$name x(t=$t, v0=$v0)'); + expect(dx, closeTo(reference.dx(t), 1e-6), + reason: '$name dx(t=$t, v0=$v0)'); + } + } + }); + }); + } +} diff --git a/test/velocity_handoff_test.dart b/test/velocity_handoff_test.dart new file mode 100644 index 0000000..4951603 --- /dev/null +++ b/test/velocity_handoff_test.dart @@ -0,0 +1,107 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_effects/hyper_effects.dart'; +import 'package:material_ui/material_ui.dart'; + +double translationXOf(WidgetTester tester, Key key) { + final transforms = tester.widgetList( + find.ancestor(of: find.byKey(key), matching: find.byType(Transform)), + ); + var dx = 0.0; + for (final t in transforms) { + dx += t.transform.entry(0, 3); + } + return dx; +} + +const key = Key('box'); + +Widget host(bool right) => MaterialApp( + home: Center( + child: const SizedBox.square(dimension: 50, key: key) + .translateX(right ? 120 : -120) + .animate( + trigger: right, + motion: const CupertinoMotion.bouncy(), + ), + ), + ); + +void main() { + testWidgets('velocity carries across a mid-flight retarget', (tester) async { + await tester.pumpWidget(host(false)); + await tester.pump(); + + // Launch toward +120 and let it build up speed. + await tester.pumpWidget(host(true)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 96)); + + // Measure velocity right before the retarget... + final p1 = translationXOf(tester, key); + await tester.pump(const Duration(milliseconds: 16)); + final p2 = translationXOf(tester, key); + final vBefore = (p2 - p1) / 0.016; + expect(vBefore.abs(), greaterThan(100), + reason: 'precondition: the box must be moving fast mid-flight'); + + // ...retarget mid-flight... + await tester.pumpWidget(host(false)); + await tester.pump(); + + // ...and right after: momentum must carry, not reset to rest. + final p3 = translationXOf(tester, key); + await tester.pump(const Duration(milliseconds: 16)); + final p4 = translationXOf(tester, key); + final vAfter = (p4 - p3) / 0.016; + + expect(vAfter.sign, vBefore.sign, + reason: 'momentum must initially continue in the same direction'); + expect(vAfter.abs(), greaterThan(vBefore.abs() * 0.5), + reason: 'the handed-off speed must be of the same order, not ~zero'); + }); + + testWidgets('spamming retargets still settles exactly at the final anchor', + (tester) async { + await tester.pumpWidget(host(false)); + await tester.pump(); + + var right = false; + for (var i = 0; i < 6; i++) { + right = !right; + await tester.pumpWidget(host(right)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 80)); + } + await tester.pumpAndSettle(); + expect( + translationXOf(tester, key), + closeTo(right ? 120 : -120, 1e-6), + reason: 'physics must still converge exactly on the last target', + ); + }); + + testWidgets('a retargeted spring never teleports between frames', + (tester) async { + await tester.pumpWidget(host(false)); + await tester.pump(); + + await tester.pumpWidget(host(true)); + await tester.pump(); + var previous = translationXOf(tester, key); + var maxJump = 0.0; + for (var i = 0; i < 40; i++) { + if (i == 8) { + await tester.pumpWidget(host(false)); + } + await tester.pump(const Duration(milliseconds: 16)); + final current = translationXOf(tester, key); + maxJump = max(maxJump, (current - previous).abs()); + previous = current; + } + // 240px of travel with a bouncy spring peaks well under 40px/frame; + // a position snap would jump 100+. + expect(maxJump, lessThan(40)); + }); +}