diff --git a/CHANGELOG.md b/CHANGELOG.md index 99a38ed..7bfe4ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,37 +1,108 @@ -## 0.2.3 -- Fix dart analysis issues. - -## 0.2.2 -- Clamp `OpacityEffect`, `ClipEffect`, and `ColorFilterEffect` values to 0.0 - 1.0 to prevent exceptions with -curves that go outside of this range. -- Add new `startImmediately` boolean to .animate() to allow for animations to start immediately without waiting for an -initial change in the `trigger` object. -- Improve documentation of `AnimatedEffect`. - -## 0.2.1 -- Fix exceptions being thrown when animation controller state is changed before completion. - -## 0.2.0 -- [BREAKING] Renamed `toggle` to `trigger` in .animate() to better reflect its purpose. -- [BREAKING] Renamed `AnimatedEffect` to `EffectWidget` to better reflect its purpose. -- [BREAKING] Renamed `EffectAnimationValue` to `EffectQuery` to better reflect its purpose. -- [BREAKING] Replace `value` in `EffectQuery` with `linearValue` and `curvedValue` to allow more refined control over animations. -- [BREAKING] Renamed `PostFrameWidget` to `PostFrame`. -- Add new Rolling Text effect. -- Add new shake effect. -- Add new align effect. -- Update all effect extension functions to add more functionality of the `from` state. -- Add new extension functions that have default from states like slideIn/Out() and fadeIn/Out(). -- Add new `oneShot`, `animateAfter`, `resetAll` functions to allow for more control over animations. -- Add new `repeat` parameter to animation functions to allow for repeating animations. -- Add new `delay` parameter to animation functions to allow for delaying animations. -- Add new `playIf` parameter to animation functions to allow for conditional animations. - -## 0.1.1 - -- Minor doc updates. -- Add example GIFs in readme. - -## 0.1.0 - -- Initial Release. +# Changelog + +All notable changes to the Hyper Effects package are documented in this file. + +## [0.3.0] - Dec 15, 2024 + +### Added +- **New Effects** + - Padding effect for dynamic padding adjustments. + - Global roll effect for universal rolling animations. + - Width & height factor support in align effect. +- **Scroll Transition Enhancements** + - Additional event variables for finer control. + - Improved transition state management. +- **Pointer Transition Features** + - `usePointerRouter` option for flexible pointer event handling. + - Enhanced pointer position tracking. +- **New AnimatedEffect Properties** + - `resetValues` - Controls value reset behavior. + - `interruptable` - Manages animation interruption. + - `skipIf` - Conditional animation execution. + - `startState` - Initial animation state control. + - `transformHits` property for translate effect. + - `rotateIn` and `rotateOut` methods for rotate effect. +- **Added New Examples** + - group_animation.dart + - rolling_app_bar_animation.dart + - rolling_pictures_animation.dart + - scroll_phase_slide.dart + - scroll_phase_blur.dart + - success_card_animation.dart + +### Changed +- **Breaking Changes** + - Effect apply function's child parameter is now nullable. + - Text rolling API redesigned for consistency with other effects. + - New unified interface matching other animation effects. + - Previous text rolling methods have been deprecated. + - `startImmediately` replaced with more flexible `startState`. + - Removed unnecessary PostFrame callbacks from pointer transition logic. +- **Improvements** + - Default blur effect state now starts un-blurred. + - Added `characterTapeBuilders` to `SymbolTapeStrategy` for customization. + - Fixed issues with scroll transitions to provide smoother and more consistent user experience. + +## [0.2.3] - Feb 2, 2024 + +### Fixed +- Resolved Dart analysis issues for better code quality + +## [0.2.2] - Feb 2, 2024 + +### Added +- New `startImmediately` boolean in .animate() +- Improved documentation for `AnimatedEffect` + +### Fixed +- Value clamping for: + - `OpacityEffect` (0.0 - 1.0) + - `ClipEffect` (0.0 - 1.0) + - `ColorFilterEffect` (0.0 - 1.0) +- Prevents exceptions with out-of-range curves + +## [0.2.1] - Dec 28, 2023 + +### Fixed +- Animation controller state change exception handling + +## [0.2.0] - Dec 24, 2023 + +### Added +- **New Effects** + - Rolling Text effect for text animations + - Shake effect for vibration animations + - Align effect for alignment control +- **Animation Control** + - `oneShot` function for immediate animations + - `animateAfter` for sequential animations + - `resetAll` for animation state reset + - Repeat parameter for cyclic animations + - Delay parameter for timed starts + - `playIf` for conditional execution + +### Changed +- **Breaking Changes** + - Renamed: + - `toggle` β†’ `trigger` in .animate() + - `AnimatedEffect` β†’ `EffectWidget` + - `EffectAnimationValue` β†’ `EffectQuery` + - `PostFrameWidget` β†’ `PostFrame` + - Enhanced `EffectQuery` with `linearValue` and `curvedValue` +- **Improvements** + - Updated effect extensions with `from` state support + - Added convenience methods (slideIn/Out, fadeIn/Out) + +## [0.1.1] - Oct 26, 2023 + +### Changed +- Documentation improvements +- Added example GIFs in README + +## [0.1.0] - Oct 25, 2023 + +### Added +- Initial release of Hyper Effects +- Core animation and effect system +- Basic effect implementations +- Documentation and examples diff --git a/README.md b/README.md index 8a94047..6b1a7d1 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@ Demo: [hyper-effects-demo.web.app](https://hyper-effects-demo.web.app/) * πŸ” **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. + pointer events. * πŸš€ **Easy Integration:** Missing an effect? The API is designed to be simple and easy to extend. -## Examples +## Examples @@ -148,13 +148,44 @@ provided. | 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 the effects, you need to call the `animate` method on the widget like so: +To animate any effect, you need to use the `animate` method on the widget like so: ```dart @override @@ -168,19 +199,23 @@ Widget build(BuildContext context) { ``` `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. 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. +Whenever the value of `trigger` changes, the effect will animate to the new value using an internal `AnimatedEffect` +widget with its own `AnimationController`. + +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. 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. > 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 use the `startImmediately` parameter in the `animate` method. 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. +> 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. -HyperEffects takes heavy inspiration from SwiftUI in that it attempts to provide Apple-like default values for +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. @@ -195,11 +230,11 @@ Widget build(BuildContext context) { ) .opacity(myCondition ? 0 : 1) .animate( - trigger: myCondition, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutQuart, - startImmediately: true, - ); + trigger: myCondition, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutQuart, + startImmediately: true, + ); } ``` @@ -219,7 +254,7 @@ Widget build(BuildContext context) { } ``` -Since these are convenient extensions, you can use the effects as Widgets using the `apply` method like so: +Since these are convenient extensions, you can use the effects as Widgets directly using the `apply` method like so: ```dart @override @@ -250,6 +285,8 @@ Widget build(BuildContext context) { } ``` +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 @@ -262,8 +299,16 @@ Widget build(BuildContext context) { * 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. -* startImmediately: A boolean property that determines whether the animation should start immediately without waiting for - an initial change in the trigger object. +* 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 @@ -281,7 +326,7 @@ Widget build(BuildContext context) { color: Colors.blue, ).slideInFromBottom() .oneShot( - // All the normal parameters inside of .animate() but without the trigger parameter. + // All the normal parameters inside of .animate() but without the trigger and startState parameters. ); } ``` @@ -289,12 +334,13 @@ Widget build(BuildContext context) { ### Animate After Animations The `animateAfter` function triggers the animation after the last animation in the chain ends. -It's useful when you want to create a sequence of animations where one animation starts after the previous one ends. +It's useful when you want to create a simple sequence of animations where one animation starts after the previous one +ends. Here's an example of how to use the `animateAfter` function: ```dart - @override +@override Widget build(BuildContext context) { return Center( child: GestureDetector( @@ -335,7 +381,9 @@ See this example in action in the demo app: [hyper-effects-demo.web.app](https:/ Using `resetAll` at the end of the chain of animations will reset all the effects in the chain to their original values. -When the animation is triggered again, all the effects will animate from their original values to the new values. +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. ### Delayed Animations @@ -350,8 +398,8 @@ Widget build(BuildContext context) { return Container( color: Colors.blue, ) - .opacity(myCondition ? 0 : 1) - .animate( + .opacity(myCondition ? 0 : 1) + .animate( trigger: myCondition, delay: const Duration(seconds: 1), // 1 second delay before the animation starts after the trigger changes. ); @@ -376,8 +424,8 @@ Widget build(BuildContext context) { return Container( color: Colors.blue, ) - .opacity(myCondition ? 0 : 1) - .animate( + .opacity(myCondition ? 0 : 1) + .animate( trigger: myCondition, repeat: 3, // The animation will repeat 3 times ); @@ -396,6 +444,16 @@ The Rolling Text feature in Hyper Effects allows you to create a rolling animati character rolls individually to form the new text. This feature provides a visually appealing way to transition between different text states in your application. +#### Limitations + +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. + +This .roll() effect, when applied to non-Text widgets, will work as expected as it does not need to reconstruct +anything. + #### Usage To use the Rolling Text feature, you can use the roll extension on any Text widget. Here's an example: @@ -410,18 +468,52 @@ Widget build(BuildContext context) { 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'. +For non-text widgets, you can use the roll effect similarly: + +```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, + ); +} +``` + #### Customization The Rolling Text feature provides several options for customization: -- padding: This option allows you to set the internal padding between the row of symbol tapes and the clipping mask. -- tapeStrategy: This option determines the string of characters to create and roll through for each character index +- 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: This option determines the curve each roll of symbol tape uses to slide up and down through its characters. -- tapeSlideDirection: This option determines the direction each roll of symbol tape slides through its characters. -- staggerTapes: This option determines whether the tapes should be staggered or not. -- staggerSoftness: This option determines how harsh the stagger effect is. -- reverseStaggerDirection: This option determines whether the stagger effect should be reversed or not. +- 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: @@ -487,110 +579,128 @@ Scroll transitions are transitions that are based on the position of a widget in ```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), +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, ), - 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, + }, ), - ), - ).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, - }, - ), - ); - }, - ); - } + ); + }, + ); +} ``` 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. - -In addition to the ScrollPhase, the `event` also provides `screenOffsetFraction` which is a double value between -1, 0 -and 1 that represents the progress the scroll view is moving away from the center of the scroll view. -- If the widget is near the center of the scroll view, the value tends towards 0. -- As the widget 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. +- `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 +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 +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. ```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, - ), +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)); - }, - ); - } + ), + ).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)); + }, + ); +} ``` -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 +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. #### Pointer Transitions 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 +depending on configuration. This transition has many configurable parameters that allow you to control how the transition should output its values in its builder. ```dart @@ -601,11 +711,12 @@ Widget build(BuildContext context) { color: Colors.blue, ), ).pointerTransition( - (context, child, event) => child.translateXY( - event.valueOffset.dx / 2, - event.valueOffset.dy / 2, - fractional: true, - ), + (context, child, event) => + child.translateXY( + event.valueOffset.dx / 2, + event.valueOffset.dy / 2, + fractional: true, + ), ); } ``` @@ -620,7 +731,7 @@ A value of 1 or -1 is when the pointer device is at the farthest point from the This value is the average of the `valueOffset`'s x and y values. `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. +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`. @@ -635,33 +746,37 @@ When using the pointer transition, you can provide multiple parameters to contro in the `event` provided to the builder. ```dart - Widget pointerTransition( - PointerTransitionBuilder builder, { - Alignment origin = Alignment.center, - bool useGlobalPointer = false, - bool transitionBetweenBounds = true, - bool resetOnExitBounds = true, - Curve curve = appleEaseInOut, - Duration duration = const Duration(milliseconds: 125), - }) { - return PointerTransition( - builder: builder, - origin: origin, - useGlobalPointer: useGlobalPointer, - transitionBetweenBounds: transitionBetweenBounds, - resetOnExitBounds: resetOnExitBounds, - curve: curve, - duration: duration, - child: this, - ); - } +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, + ); +} ``` `origin` is an `Alignment` parameter that determines the origin to transform the pointer position around. -- 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. + +- 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. `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 @@ -669,7 +784,7 @@ false, the pointer position will be calculated from the top left corner of the w `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 +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. `resetOnExitBounds` is a `boolean` parameter that determines whether this transition should reset the position of the @@ -690,7 +805,8 @@ other properties and expect them to transition perfectly, you may get unexpected ## Contributing -You are welcome to contribute on this package. See [CONTRIBUTING.md](https://github.com/hyper-designed/hyper_effects/blob/main/CONTRIBUTING.md) for details. +You are welcome to contribute on this package. +See [CONTRIBUTING.md](https://github.com/hyper-designed/hyper_effects/blob/main/CONTRIBUTING.md) for details. ## Authors diff --git a/example/.gitignore b/example/.gitignore index 24476c5..6c31954 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -5,9 +5,11 @@ *.swp .DS_Store .atom/ +.build/ .buildlog/ .history .svn/ +.swiftpm/ migrate_working_dir/ # IntelliJ related diff --git a/example/.metadata b/example/.metadata index d363751..9eeac39 100644 --- a/example/.metadata +++ b/example/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "ead455963c12b453cdb2358cad34969c76daf180" + revision: "8495dee1fd4aacbe9de707e7581203232f591b2f" channel: "stable" project_type: app @@ -13,14 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: ead455963c12b453cdb2358cad34969c76daf180 - base_revision: ead455963c12b453cdb2358cad34969c76daf180 + create_revision: 8495dee1fd4aacbe9de707e7581203232f591b2f + base_revision: 8495dee1fd4aacbe9de707e7581203232f591b2f - platform: macos - create_revision: ead455963c12b453cdb2358cad34969c76daf180 - base_revision: ead455963c12b453cdb2358cad34969c76daf180 - - platform: web - create_revision: ead455963c12b453cdb2358cad34969c76daf180 - base_revision: ead455963c12b453cdb2358cad34969c76daf180 + create_revision: 8495dee1fd4aacbe9de707e7581203232f591b2f + base_revision: 8495dee1fd4aacbe9de707e7581203232f591b2f # User provided section diff --git a/example/assets/fashion/fashion_0.jpg b/example/assets/fashion/fashion_0.jpg index 619128a..0816428 100644 Binary files a/example/assets/fashion/fashion_0.jpg and b/example/assets/fashion/fashion_0.jpg differ diff --git a/example/assets/fashion/fashion_1.jpg b/example/assets/fashion/fashion_1.jpg index 68f6284..cb11359 100644 Binary files a/example/assets/fashion/fashion_1.jpg and b/example/assets/fashion/fashion_1.jpg differ diff --git a/example/assets/fashion/fashion_2.jpg b/example/assets/fashion/fashion_2.jpg index 3f4140f..8788d46 100644 Binary files a/example/assets/fashion/fashion_2.jpg and b/example/assets/fashion/fashion_2.jpg differ diff --git a/example/assets/fashion/fashion_3.jpg b/example/assets/fashion/fashion_3.jpg index e8545bb..9157b28 100644 Binary files a/example/assets/fashion/fashion_3.jpg and b/example/assets/fashion/fashion_3.jpg differ diff --git a/example/assets/fashion/fashion_4.jpg b/example/assets/fashion/fashion_4.jpg index 48909f1..d36a7f7 100644 Binary files a/example/assets/fashion/fashion_4.jpg and b/example/assets/fashion/fashion_4.jpg differ diff --git a/example/assets/fashion/fashion_5.jpg b/example/assets/fashion/fashion_5.jpg index 74e5566..c3da6b0 100644 Binary files a/example/assets/fashion/fashion_5.jpg and b/example/assets/fashion/fashion_5.jpg differ diff --git a/example/assets/fashion/fashion_6.jpg b/example/assets/fashion/fashion_6.jpg index 02e2a21..a7f13c0 100644 Binary files a/example/assets/fashion/fashion_6.jpg and b/example/assets/fashion/fashion_6.jpg differ diff --git a/example/assets/fashion/fashion_7.jpg b/example/assets/fashion/fashion_7.jpg index 9f21d16..dbe22f7 100644 Binary files a/example/assets/fashion/fashion_7.jpg and b/example/assets/fashion/fashion_7.jpg differ diff --git a/example/assets/fashion/fashion_8.jpg b/example/assets/fashion/fashion_8.jpg index 02e3f8d..d911850 100644 Binary files a/example/assets/fashion/fashion_8.jpg and b/example/assets/fashion/fashion_8.jpg differ diff --git a/example/lib/main.dart b/example/lib/main.dart index 1638dcb..d004e83 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -7,11 +7,15 @@ 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/scroll_phase_transition.dart'; -import 'package:hyper_effects_demo/stories/scroll_wheel_blur.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/success_card_animation.dart'; import 'package:hyper_effects_demo/stories/text_animation.dart'; import 'package:hyper_effects_demo/stories/windows_settings_transition.dart'; @@ -32,7 +36,7 @@ class MyApp extends StatelessWidget { colorScheme: ColorScheme.fromSeed( brightness: Brightness.light, seedColor: Colors.blue, - background: const Color(0xFFF0F0F0), + surface: const Color(0xFFF0F0F0), ), inputDecorationTheme: InputDecorationTheme( isDense: true, @@ -48,7 +52,7 @@ class MyApp extends StatelessWidget { colorScheme: ColorScheme.fromSeed( brightness: Brightness.dark, seedColor: Colors.blue, - background: const Color(0xFF0F0F0F), + surface: const Color(0xFF0F0F0F), ), inputDecorationTheme: InputDecorationTheme( isDense: true, @@ -80,6 +84,11 @@ 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(), @@ -96,6 +105,14 @@ class _StoryboardState extends State with WidgetsBindingObserver { title: 'Spring Animation', child: SpringAnimation(), ), + const Story( + title: 'Rolling Pictures Animation', + child: RollingWidgetAnimation(), + ), + const Story( + title: 'Rolling App Bar Animation', + child: RollingAppBarAnimation(), + ), ]; final List transitionStories = [ const Story( diff --git a/example/lib/stories/color_filter_scroll_transition.dart b/example/lib/stories/color_filter_scroll_transition.dart index ce904ee..13bf211 100644 --- a/example/lib/stories/color_filter_scroll_transition.dart +++ b/example/lib/stories/color_filter_scroll_transition.dart @@ -26,17 +26,18 @@ class _FashionScrollTransitionState extends State { itemExtent: 300, cacheExtent: 300, itemBuilder: (context, index) { - return Padding( + return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + alignment: Alignment.center, child: Container( - width: 350, + width: 500, height: 300, alignment: Alignment.center, clipBehavior: Clip.antiAlias, decoration: BoxDecoration( image: DecorationImage( image: AssetImage('assets/fashion/fashion_${index % 8}.jpg'), - fit: BoxFit.cover, + fit: BoxFit.fitWidth, ), borderRadius: BorderRadius.circular(16), ), diff --git a/example/lib/stories/counter_app.dart b/example/lib/stories/counter_app.dart index de56228..10952b1 100644 --- a/example/lib/stories/counter_app.dart +++ b/example/lib/stories/counter_app.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; import 'package:hyper_effects/hyper_effects.dart'; @@ -34,9 +32,9 @@ class _CounterAppState extends State { 'You have pushed the button this many times:', ), Text( - '${max(0, _counter - 1)}', + '$_counter', style: Theme.of(context).textTheme.headlineMedium, - ).roll('$_counter').animate(trigger: _counter), + ).roll().animate(trigger: _counter), ], ), ), diff --git a/example/lib/stories/group_animation.dart b/example/lib/stories/group_animation.dart new file mode 100644 index 0000000..3dbf348 --- /dev/null +++ b/example/lib/stories/group_animation.dart @@ -0,0 +1,138 @@ +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/rolling_app_bar_animation.dart b/example/lib/stories/rolling_app_bar_animation.dart new file mode 100644 index 0000000..055edc7 --- /dev/null +++ b/example/lib/stories/rolling_app_bar_animation.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +class RollingAppBarAnimation extends StatefulWidget { + const RollingAppBarAnimation({super.key}); + + @override + State createState() => _RollingAppBarAnimationState(); +} + +class _RollingAppBarAnimationState extends State { + final TextEditingController searchController = TextEditingController(); + bool showSearchView = false; + + @override + void dispose() { + searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + padding: const EdgeInsets.all(8), + margin: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + ), + clipBehavior: Clip.hardEdge, + child: Row( + children: [ + Expanded( + child: TextField( + controller: searchController, + decoration: const InputDecoration(hintText: 'Search'), + onTap: () { + showSearchView = true; + setState(() {}); + }, + onTapOutside: (event) { + showSearchView = false; + setState(() {}); + }, + ), + ), + KeyedSubtree( + key: ValueKey(showSearchView), + child: showSearchView + ? TextButton( + child: const Text('Cancel'), + onPressed: () { + showSearchView = false; + setState(() {}); + }, + ) + : const HomeButtonTools(), + ) + .roll( + multiplier: 1.5, + slideInDirection: + showSearchView ? AxisDirection.right : AxisDirection.left, + slideOutDirection: + showSearchView ? AxisDirection.right : AxisDirection.left, + ) + .clip(0) + .animate( + trigger: showSearchView, + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOutQuart, + ) + ], + ), + ), + ); + } +} + +class HomeButtonTools extends StatefulWidget { + const HomeButtonTools({super.key}); + + @override + State createState() => _HomeButtonToolsState(); +} + +class _HomeButtonToolsState extends State { + @override + Widget build(BuildContext context) { + return Row( + children: [ + IconButton(onPressed: () {}, icon: const Icon(Icons.filter_alt)), + IconButton(onPressed: () {}, icon: const Icon(Icons.compare_arrows)), + IconButton(onPressed: () {}, icon: const Icon(Icons.add)), + IconButton(onPressed: () {}, icon: const Icon(Icons.settings)), + ], + ); + } +} diff --git a/example/lib/stories/rolling_pictures_animation.dart b/example/lib/stories/rolling_pictures_animation.dart new file mode 100644 index 0000000..edf0c42 --- /dev/null +++ b/example/lib/stories/rolling_pictures_animation.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +class RollingWidgetAnimation extends StatefulWidget { + const RollingWidgetAnimation({super.key}); + + @override + State createState() => _RollingWidgetAnimationState(); +} + +class _RollingWidgetAnimationState extends State { + int counter = 0; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + children: [ + (counter % 9 == 8 + ? null + : Image.asset( + 'assets/fashion/fashion_${counter % 9}.jpg', + key: ValueKey(counter), + height: 500 + (counter % 2 == 0 ? 0 : 100), + cacheHeight: 500 + (counter % 2 == 0 ? 0 : 100), + )) + .roll( + slideInDirection: AxisDirection.up, + slideOutDirection: AxisDirection.left, + ) + .clip(0) + .animate( + trigger: counter, + curve: Curves.easeInOutQuart, + duration: const Duration(milliseconds: 500), + ), + Align( + alignment: Alignment.topCenter, + child: ElevatedButton( + onPressed: () { + setState(() { + counter++; + }); + }, + child: const Text('Roll'), + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/stories/scroll_wheel_blur.dart b/example/lib/stories/scroll_phase_blur.dart similarity index 100% rename from example/lib/stories/scroll_wheel_blur.dart rename to example/lib/stories/scroll_phase_blur.dart diff --git a/example/lib/stories/scroll_phase_transition.dart b/example/lib/stories/scroll_phase_slide.dart similarity index 100% rename from example/lib/stories/scroll_phase_transition.dart rename to example/lib/stories/scroll_phase_slide.dart diff --git a/example/lib/stories/shake_and_spring_animation.dart b/example/lib/stories/shake_and_spring_animation.dart index 0e9ad45..b6b6f5a 100644 --- a/example/lib/stories/shake_and_spring_animation.dart +++ b/example/lib/stories/shake_and_spring_animation.dart @@ -24,7 +24,7 @@ class _SpringAnimationState extends State { .shake() .animate( trigger: trigger, - startImmediately: true, + startState: AnimationStartState.playImmediately, delay: const Duration(seconds: 1), repeat: -1, playIf: () => !trigger, diff --git a/example/lib/stories/success_card_animation.dart b/example/lib/stories/success_card_animation.dart new file mode 100644 index 0000000..5327ff6 --- /dev/null +++ b/example/lib/stories/success_card_animation.dart @@ -0,0 +1,187 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:hyper_effects/hyper_effects.dart'; + +class SuccessCardAnimation extends StatefulWidget { + const SuccessCardAnimation({super.key}); + + @override + State createState() => _SuccessCardAnimationState(); +} + +class _SuccessCardAnimationState extends State { + bool isCompleted = false; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 350, + maxHeight: 200, + ), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + setState(() { + isCompleted = !isCompleted; + }); + }, + child: Padding( + padding: const EdgeInsets.all(12), + child: Container( + constraints: const BoxConstraints(minHeight: 148), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Stack( + alignment: Alignment.center, + fit: StackFit.expand, + children: [ + Image.asset( + 'assets/fashion/fashion_0.jpg', + fit: BoxFit.cover, + ), + Positioned.fill( + child: Container( + decoration: const BoxDecoration( + color: Color(0x3A079455), + ), + alignment: Alignment.center, + child: const Icon( + Icons.check_circle, + size: 74, + ) + .translateY( + isCompleted ? 0 : 100, + from: isCompleted ? 100 : 0, + ) + .animate( + trigger: isCompleted, + startState: + AnimationStartState.useCurrentValues, + curve: !isCompleted + ? Curves.easeInBack + : Curves.easeOutBack, + duration: const Duration( + milliseconds: 400, + ), + ), + ) + .opacity( + isCompleted ? 1 : 0, + from: isCompleted ? 0 : 1, + ) + .animate( + trigger: isCompleted, + startState: + AnimationStartState.useCurrentValues, + curve: Curves.easeInOutSine, + duration: const Duration( + milliseconds: 400, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 350, + maxHeight: 200, + ), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + setState(() { + isCompleted = !isCompleted; + }); + }, + child: Padding( + padding: const EdgeInsets.all(12), + child: Container( + constraints: const BoxConstraints(minHeight: 148), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Stack( + alignment: Alignment.center, + fit: StackFit.expand, + children: [ + Image.asset( + 'assets/fashion/fashion_0.jpg', + fit: BoxFit.cover, + ), + Positioned.fill( + child: Container( + decoration: const BoxDecoration( + color: Color(0x3A079455), + ), + alignment: Alignment.center, + 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, + duration: + const Duration(milliseconds: 350), + ) + .scale( + isCompleted ? 1 / 1.5 : 1, + ) + .rotate( + isCompleted ? -15 * pi / 180 : 0, + ) + .animateAfter( + curve: Curves.easeOutBack, + delay: const Duration(milliseconds: 150), + 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, + // ), + // ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/example/lib/stories/text_animation.dart b/example/lib/stories/text_animation.dart index eec4b7c..b03b76b 100644 --- a/example/lib/stories/text_animation.dart +++ b/example/lib/stories/text_animation.dart @@ -5,6 +5,7 @@ import 'package:flutter/scheduler.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:hyper_effects/hyper_effects.dart'; import 'package:smooth_page_indicator/smooth_page_indicator.dart'; +import 'package:unicode_emojis/unicode_emojis.dart'; class TextAnimation extends StatefulWidget { const TextAnimation({super.key}); @@ -78,6 +79,18 @@ class IPhone extends StatelessWidget { } } +final String _allEmojis = + UnicodeEmojis.allEmojis.map((emoji) => emoji.emoji).join(''); + +class EmojiTapeBuilder extends CharacterTapeBuilder { + @override + String get characters => _allEmojis; + + @override + bool compare(String a, String b) => + _allEmojis.contains(a) && _allEmojis.contains(b); +} + class EmojiLine extends StatefulWidget { const EmojiLine({super.key}); @@ -103,24 +116,29 @@ class _EmojiLineState extends State { borderRadius: BorderRadius.circular(32), ), child: Text( - 'Hello πŸ˜€πŸ˜ƒπŸ˜„πŸ˜πŸ˜†πŸ˜…πŸ˜‚πŸ€£πŸ₯²πŸ₯ΉοΈπŸ˜ŠπŸ˜‡πŸ™‚πŸ™ƒπŸ˜‰πŸ˜Œ Sexy', + trigger + ? 'World πŸ§³πŸŒ‚β˜‚οΈπŸ§΅πŸͺ‘πŸͺ’πŸͺ­πŸ§ΆπŸ‘“πŸ•ΆπŸ₯½πŸ₯ΌπŸ¦ΊπŸ‘”πŸ‘–πŸ§£ Effect' + : 'Hello πŸ˜€πŸ˜ƒπŸ˜„πŸ˜πŸ˜†πŸ˜…πŸ˜‚πŸ€£πŸ₯²πŸ₯ΉοΈπŸ˜ŠπŸ˜‡πŸ™‚πŸ™ƒπŸ˜‰πŸ˜Œ Sexy', style: TextStyle( color: Theme.of(context).colorScheme.onPrimaryContainer, ), ) .roll( - 'World πŸ§³πŸŒ‚β˜‚οΈπŸ§΅πŸͺ‘πŸͺ’πŸͺ­πŸ§ΆπŸ‘“πŸ•ΆπŸ₯½πŸ₯ΌπŸ¦ΊπŸ‘”πŸ‘–πŸ§£ Effect', - tapeStrategy: const ConsistentSymbolTapeStrategy(4), - tapeSlideDirection: TapeSlideDirection.alternating, - staggerTapes: false, + tapeStrategy: + ConsistentSymbolTapeStrategy(4, characterTapeBuilders: { + EmojiTapeBuilder(), + }), + tapeSlideDirection: TextTapeSlideDirection.alternating, + staggerTapes: true, tapeCurve: Curves.easeInOutBack, widthCurve: Curves.easeOutQuart, symbolDistanceMultiplier: 2, + staggerSoftness: 30, + // clipBehavior: Clip.none, ) .animate( trigger: trigger, - reverse: true, - duration: const Duration(milliseconds: 1500), + duration: const Duration(milliseconds: 2000), ), ), ); @@ -148,7 +166,6 @@ class _TagLineState extends State { 'Build', 'Code', ]; - int lastTagLine = 0; int tagLine = 0; late Timer timer; @@ -159,7 +176,6 @@ class _TagLineState extends State { timer = Timer.periodic( Duration(milliseconds: (1800 * timeDilation).toInt()), (timer) { setState(() { - lastTagLine = tagLine; tagLine = (tagLine + 1) % tagLines.length; }); }); @@ -179,7 +195,7 @@ class _TagLineState extends State { Text( 'We help you', style: GoogleFonts.robotoMono().copyWith( - color: Theme.of(context).colorScheme.onBackground, + color: Theme.of(context).colorScheme.onSurface, fontSize: 48, ), strutStyle: const StrutStyle( @@ -212,7 +228,7 @@ class _TagLineState extends State { ], ).createShader(rect), child: Text( - tagLines[lastTagLine], + tagLines[tagLine], style: GoogleFonts.gloriaHallelujah().copyWith( color: Colors.white, fontWeight: FontWeight.bold, @@ -220,9 +236,8 @@ class _TagLineState extends State { ), ) .roll( - tagLines[tagLine], symbolDistanceMultiplier: 2, - tapeSlideDirection: TapeSlideDirection.down, + tapeSlideDirection: TextTapeSlideDirection.down, tapeCurve: Curves.easeInOutCubic, widthCurve: Curves.easeOutCubic, widthDuration: const Duration(milliseconds: 1000), @@ -263,7 +278,6 @@ class _TranslationState extends State { 'Namaste', 'Salaam', ]; - int lastTranslation = 0; int translation = 0; late Timer timer; @@ -275,7 +289,6 @@ class _TranslationState extends State { timer = Timer.periodic( Duration(milliseconds: (2000 * timeDilation).toInt()), (timer) { setState(() { - lastTranslation = translation; translation = (translation + 1) % translations.length; }); }); @@ -315,7 +328,7 @@ class _TranslationState extends State { ], ).createShader(rect), child: Text( - translations[lastTranslation], + translations[translation], style: GoogleFonts.sacramento().copyWith( color: Colors.white, fontWeight: FontWeight.bold, @@ -323,7 +336,6 @@ class _TranslationState extends State { ), ) .roll( - translations[translation], symbolDistanceMultiplier: 2, tapeCurve: Curves.easeInOutBack, widthCurve: Curves.easeInOutQuart, @@ -338,7 +350,7 @@ class _TranslationState extends State { Text( ', Stranger', style: GoogleFonts.sacramento().copyWith( - color: Theme.of(context).colorScheme.onBackground, + color: Theme.of(context).colorScheme.onSurface, fontSize: 56, ), strutStyle: const StrutStyle( @@ -361,7 +373,6 @@ class LikeButton extends StatefulWidget { } class _LikeButtonState extends State { - int lastCounter = 19; int counter = 19; bool triggerShare = false; int downloadIteration = 1; @@ -378,7 +389,6 @@ class _LikeButtonState extends State { child: InkWell( onTap: () { setState(() { - lastCounter = counter; counter++; }); }, @@ -395,14 +405,15 @@ class _LikeButtonState extends State { ), const SizedBox(width: 8), Text( - '${lastCounter}K', + '${counter}K', style: GoogleFonts.robotoTextTheme() .bodyMedium! .copyWith(color: Colors.white, fontSize: 16), ) .roll( - '${counter}K', - tapeStrategy: const AllSymbolsTapeStrategy(false), + tapeStrategy: const AllSymbolsTapeStrategy( + repeatCharacters: false, + ), symbolDistanceMultiplier: 2, clipBehavior: Clip.none, tapeCurve: Curves.easeOutQuart, @@ -453,15 +464,14 @@ class _LikeButtonState extends State { ), const SizedBox(width: 8), Text( - 'Share', + triggerShare ? 'Thanks!' : 'Share', style: GoogleFonts.robotoTextTheme() .bodyMedium! .copyWith(color: Colors.white, fontSize: 16), ) .roll( - 'Thanks!', - tapeStrategy: - const ConsistentSymbolTapeStrategy(0, true), + tapeStrategy: const ConsistentSymbolTapeStrategy(0, + repeatCharacters: true), symbolDistanceMultiplier: 2, clipBehavior: Clip.none, tapeCurve: @@ -513,13 +523,8 @@ class _LikeButtonState extends State { .copyWith(color: Colors.white, fontSize: 16), ) .roll( - switch (downloadIteration) { - 1 => 'Downloading', - 2 => 'Downloaded', - _ => 'Download', - }, - tapeStrategy: - const ConsistentSymbolTapeStrategy(0, false), + tapeStrategy: const ConsistentSymbolTapeStrategy(0, + repeatCharacters: false), symbolDistanceMultiplier: 2, clipBehavior: Clip.none, tapeCurve: Curves.easeOutBack, @@ -642,7 +647,6 @@ class _ColorPalettePageState extends State { ] }; - int lastPage = 0; int currentPage = 0; final PageController _pageController = PageController(); @@ -694,15 +698,14 @@ class _ColorPalettePageState extends State { colors: palettes.values.elementAt(currentPage), ).createShader(rect), child: Text( - palettes.keys.elementAt(lastPage).toUpperCase(), + palettes.keys.elementAt(currentPage).toUpperCase(), style: const TextStyle( fontWeight: FontWeight.w700, color: Colors.white), ) .roll( - palettes.keys.elementAt(currentPage).toUpperCase(), staggerSoftness: 6, reverseStaggerDirection: false, - tapeSlideDirection: TapeSlideDirection.down, + tapeSlideDirection: TextTapeSlideDirection.down, tapeCurve: Curves.easeOutBack, widthCurve: Curves.easeOutQuart, widthDuration: const Duration(milliseconds: 500), @@ -731,7 +734,6 @@ class _ColorPalettePageState extends State { itemCount: palettes.keys.length, onPageChanged: (int page) { setState(() { - lastPage = currentPage; currentPage = page; }); }, diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj index e1c62d4..df9b74f 100644 --- a/example/macos/Runner.xcodeproj/project.pbxproj +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -21,14 +21,14 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 233C6FD519D5A479DBC4A7B3 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1B2C434BED57CE763B8D88B4 /* Pods_RunnerTests.framework */; }; 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; 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 */; }; - 9749B080EE3903CE36CD9B9F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DF66A7423E27B1E1C2B7DF2E /* Pods_Runner.framework */; }; - BEEE65E5C6D9898EDDD2C219 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A215E138480AD76222C3A2F /* Pods_RunnerTests.framework */; }; + F2445E0FBADA22DBAECF88BF /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 24E95C664D0B1B1E9C8D1567 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -62,14 +62,15 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 02C1D53FE00F265778E04051 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - 0A9CAE6517C9B0AEB712B7E8 /* 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 = ""; }; - 1A215E138480AD76222C3A2F /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 05D7AD55499634497DDAAEE2 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 0AEA63D43EC920AC96E17696 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 1B2C434BED57CE763B8D88B4 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 24E95C664D0B1B1E9C8D1567 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* hyper_effects_demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = hyper_effects_demo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -81,13 +82,12 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 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 = ""; }; - 713C55CFD54062B2ACD29F8E /* 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 = ""; }; + 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 = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 8026674D67C5F4C67964DC92 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - 81981A169A98233BCEDE34B9 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; - A01D35E74C0CF549EBD91FDB /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - DF66A7423E27B1E1C2B7DF2E /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 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 = ""; }; + A66162B928096445514EFC91 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + FF533E0B3F902C87057D3406 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -95,7 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - BEEE65E5C6D9898EDDD2C219 /* Pods_RunnerTests.framework in Frameworks */, + 233C6FD519D5A479DBC4A7B3 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -103,13 +103,27 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9749B080EE3903CE36CD9B9F /* Pods_Runner.framework in Frameworks */, + F2445E0FBADA22DBAECF88BF /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 263529EFD77750E6A2E03F88 /* Pods */ = { + isa = PBXGroup; + children = ( + A66162B928096445514EFC91 /* Pods-Runner.debug.xcconfig */, + 6AF86FC0D986B41DFB12F321 /* Pods-Runner.release.xcconfig */, + FF533E0B3F902C87057D3406 /* Pods-Runner.profile.xcconfig */, + 05D7AD55499634497DDAAEE2 /* Pods-RunnerTests.debug.xcconfig */, + A02F36C029429126D3B2C5AA /* Pods-RunnerTests.release.xcconfig */, + 0AEA63D43EC920AC96E17696 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; 331C80D6294CF71000263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -137,14 +151,14 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, - 4259EDDF2DD4F19F2F705E7E /* Pods */, + 263529EFD77750E6A2E03F88 /* Pods */, ); sourceTree = ""; }; 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( - 33CC10ED2044A3C60003C045 /* example.app */, + 33CC10ED2044A3C60003C045 /* hyper_effects_demo.app */, 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, ); name = Products; @@ -185,25 +199,11 @@ path = Runner; sourceTree = ""; }; - 4259EDDF2DD4F19F2F705E7E /* Pods */ = { - isa = PBXGroup; - children = ( - 02C1D53FE00F265778E04051 /* Pods-Runner.debug.xcconfig */, - 0A9CAE6517C9B0AEB712B7E8 /* Pods-Runner.release.xcconfig */, - 8026674D67C5F4C67964DC92 /* Pods-Runner.profile.xcconfig */, - A01D35E74C0CF549EBD91FDB /* Pods-RunnerTests.debug.xcconfig */, - 713C55CFD54062B2ACD29F8E /* Pods-RunnerTests.release.xcconfig */, - 81981A169A98233BCEDE34B9 /* Pods-RunnerTests.profile.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( - DF66A7423E27B1E1C2B7DF2E /* Pods_Runner.framework */, - 1A215E138480AD76222C3A2F /* Pods_RunnerTests.framework */, + 24E95C664D0B1B1E9C8D1567 /* Pods_Runner.framework */, + 1B2C434BED57CE763B8D88B4 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -215,7 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - F4B967E491B126A3E7A6DAC9 /* [CP] Check Pods Manifest.lock */, + 00AD03243451643F0598D10C /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -234,13 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 08FBC521EC148D3FE9645785 /* [CP] Check Pods Manifest.lock */, + FFDE6EBFA831BB29157AA743 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, - B99A704086D4B9C28A5DB143 /* [CP] Embed Pods Frameworks */, + DC25671DD086C637864AD21C /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -249,7 +249,7 @@ ); name = Runner; productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* example.app */; + productReference = 33CC10ED2044A3C60003C045 /* hyper_effects_demo.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -258,8 +258,9 @@ 33CC10E52044A3C60003C045 /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 331C80D4294CF70F00263BE5 = { @@ -322,7 +323,7 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 08FBC521EC148D3FE9645785 /* [CP] Check Pods Manifest.lock */ = { + 00AD03243451643F0598D10C /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -337,7 +338,7 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -382,7 +383,7 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; - B99A704086D4B9C28A5DB143 /* [CP] Embed Pods Frameworks */ = { + DC25671DD086C637864AD21C /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -399,7 +400,7 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - F4B967E491B126A3E7A6DAC9 /* [CP] Check Pods Manifest.lock */ = { + FFDE6EBFA831BB29157AA743 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -414,7 +415,7 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -472,46 +473,46 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = A01D35E74C0CF549EBD91FDB /* Pods-RunnerTests.debug.xcconfig */; + baseConfigurationReference = 05D7AD55499634497DDAAEE2 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.example.hyperEffectsDemo.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hyper_effects_demo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hyper_effects_demo"; }; name = Debug; }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 713C55CFD54062B2ACD29F8E /* Pods-RunnerTests.release.xcconfig */; + baseConfigurationReference = A02F36C029429126D3B2C5AA /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.example.hyperEffectsDemo.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hyper_effects_demo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hyper_effects_demo"; }; name = Release; }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 81981A169A98233BCEDE34B9 /* Pods-RunnerTests.profile.xcconfig */; + baseConfigurationReference = 0AEA63D43EC920AC96E17696 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.example.hyperEffectsDemo.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hyper_effects_demo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hyper_effects_demo"; }; name = Profile; }; @@ -520,6 +521,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -543,9 +545,11 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CODE_SIGN_IDENTITY = "-"; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -593,6 +597,7 @@ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -616,9 +621,11 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CODE_SIGN_IDENTITY = "-"; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -646,6 +653,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -669,9 +677,11 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CODE_SIGN_IDENTITY = "-"; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; diff --git a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 397f3d3..89451a0 100644 --- a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ @@ -31,7 +31,7 @@ @@ -65,7 +65,7 @@ @@ -82,7 +82,7 @@ diff --git a/example/macos/Runner/AppDelegate.swift b/example/macos/Runner/AppDelegate.swift index d53ef64..b3c1761 100644 --- a/example/macos/Runner/AppDelegate.swift +++ b/example/macos/Runner/AppDelegate.swift @@ -1,9 +1,13 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } diff --git a/example/macos/Runner/Configs/AppInfo.xcconfig b/example/macos/Runner/Configs/AppInfo.xcconfig index dda192b..d9e0e80 100644 --- a/example/macos/Runner/Configs/AppInfo.xcconfig +++ b/example/macos/Runner/Configs/AppInfo.xcconfig @@ -5,10 +5,10 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = example +PRODUCT_NAME = hyper_effects_demo // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.example.example +PRODUCT_BUNDLE_IDENTIFIER = com.example.hyperEffectsDemo // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright Β© 2023 com.example. All rights reserved. +PRODUCT_COPYRIGHT = Copyright Β© 2024 com.example. All rights reserved. diff --git a/example/macos/RunnerTests/RunnerTests.swift b/example/macos/RunnerTests/RunnerTests.swift index 5418c9f..61f3bd1 100644 --- a/example/macos/RunnerTests/RunnerTests.swift +++ b/example/macos/RunnerTests/RunnerTests.swift @@ -1,5 +1,5 @@ -import FlutterMacOS import Cocoa +import FlutterMacOS import XCTest class RunnerTests: XCTestCase { diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 5ef917a..b07efbc 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -10,17 +10,18 @@ environment: dependencies: flutter: sdk: flutter - adaptive_theme: ^3.4.1 - flutter_box_transform: ^0.4.2 + adaptive_theme: ^3.6.0 + flutter_box_transform: ^0.4.6 hyper_effects: path: ../ - google_fonts: ^6.1.0 - smooth_page_indicator: ^1.1.0 + google_fonts: ^6.2.1 + unicode_emojis: ^0.4.0 + smooth_page_indicator: ^1.2.0+3 dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^3.0.1 + flutter_lints: ^5.0.0 flutter: uses-material-design: true diff --git a/example/web/favicon.png b/example/web/favicon.png index 8aaa46a..fe2faa5 100644 Binary files a/example/web/favicon.png and b/example/web/favicon.png differ diff --git a/example/web/flutter_bootstrap.js b/example/web/flutter_bootstrap.js new file mode 100644 index 0000000..775f975 --- /dev/null +++ b/example/web/flutter_bootstrap.js @@ -0,0 +1,14 @@ +{{flutter_js}} +{{flutter_build_config}} + +// Customize the app initialization process +_flutter.loader.load({ + onEntrypointLoaded: async function(engineInitializer) { + const appRunner = await engineInitializer.initializeEngine({ + useColorEmoji: true + }); + + await appRunner.runApp(); + } + +}); diff --git a/example/web/icons/Icon-192.png b/example/web/icons/Icon-192.png index b749bfe..0590b43 100644 Binary files a/example/web/icons/Icon-192.png and b/example/web/icons/Icon-192.png differ diff --git a/example/web/icons/Icon-512.png b/example/web/icons/Icon-512.png index 88cfd48..c714458 100644 Binary files a/example/web/icons/Icon-512.png and b/example/web/icons/Icon-512.png differ diff --git a/example/web/icons/Icon-maskable-192.png b/example/web/icons/Icon-maskable-192.png index eb9b4d7..88c3124 100644 Binary files a/example/web/icons/Icon-maskable-192.png and b/example/web/icons/Icon-maskable-192.png differ diff --git a/example/web/icons/Icon-maskable-512.png b/example/web/icons/Icon-maskable-512.png index d69c566..2d7ea8e 100644 Binary files a/example/web/icons/Icon-maskable-512.png and b/example/web/icons/Icon-maskable-512.png differ diff --git a/example/web/index.html b/example/web/index.html index 2e043ca..c7c2880 100644 --- a/example/web/index.html +++ b/example/web/index.html @@ -12,39 +12,16 @@ - + - + - - example - - - - - + diff --git a/example/web/manifest.json b/example/web/manifest.json index 096edf8..c4cb0b7 100644 --- a/example/web/manifest.json +++ b/example/web/manifest.json @@ -1,11 +1,11 @@ { - "name": "example", - "short_name": "example", + "name": "Hyper Effects Demo", + "short_name": "Hyper Effects Demo", "start_url": ".", "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", + "background_color": "#121212", + "theme_color": "#121212", + "description": "A demo of the Hyper Effects library", "orientation": "portrait-primary", "prefer_related_applications": false, "icons": [ diff --git a/lib/hyper_effects.dart b/lib/hyper_effects.dart index 3b13a31..7a322b4 100644 --- a/lib/hyper_effects.dart +++ b/lib/hyper_effects.dart @@ -1,12 +1,15 @@ -library hyper_effects; +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/extensions.dart'; -export 'src/scroll_phase.dart'; -export 'src/scroll_transition.dart'; -export 'src/effect_query.dart'; export 'src/pointer_transition.dart'; -export 'src/apple_curves.dart'; export 'src/post_frame_widget.dart'; +export 'src/scroll_phase.dart'; +export 'src/scroll_transition.dart'; diff --git a/lib/src/animated_effect.dart b/lib/src/animated_effect.dart index 0d2732d..b5bc655 100644 --- a/lib/src/animated_effect.dart +++ b/lib/src/animated_effect.dart @@ -24,7 +24,7 @@ enum AnimationTriggerType { } /// Provides extension methods for [Widget] to animate it's appearance. -extension AnimatedEffectExt on Widget { +extension AnimatedEffectExt on Widget? { /// Animate the effects applied to this widget. /// /// The [trigger] parameter is used to trigger the animation. As long as the @@ -45,23 +45,38 @@ extension AnimatedEffectExt on Widget { /// /// The [delay] parameter is used to set a delay before the animation starts. /// + /// 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 [startState] parameter is used to determine the behavior of the + /// animation as soon as it is added to the widget tree. + /// /// 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 [startImmediately] parameter is used to determine whether the - /// animation should be triggered immediately when the widget is built, - /// ignoring the value of [trigger] initially. + /// 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 animate({ required Object? trigger, Duration duration = const Duration(milliseconds: 350), Curve curve = appleEaseInOut, int repeat = 0, bool reverse = false, - bool startImmediately = false, + bool resetValues = false, + bool interruptable = true, Duration delay = Duration.zero, + AnimationStartState startState = AnimationStartState.idle, VoidCallback? onEnd, BooleanCallback? playIf, + BooleanCallback? skipIf, + AnimationBehavior? animationBehavior, }) { return AnimatedEffect( triggerType: AnimationTriggerType.trigger, @@ -70,10 +85,14 @@ extension AnimatedEffectExt on Widget { curve: curve, repeat: repeat, reverse: reverse, - startImmediately: startImmediately, + resetValues: resetValues, + interruptable: interruptable, delay: delay, + startState: startState, onEnd: onEnd, playIf: playIf, + skipIf: skipIf, + animationBehavior: animationBehavior, child: this, ); } @@ -101,19 +120,39 @@ extension AnimatedEffectExt on Widget { /// 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. + /// + /// 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 [delay] parameter is used to set a delay before the animation starts. /// /// 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, @@ -122,9 +161,13 @@ extension AnimatedEffectExt on Widget { curve: curve, repeat: repeat, reverse: reverse, + resetValues: resetValues, + interruptable: true, delay: delay, onEnd: onEnd, playIf: playIf, + skipIf: skipIf, + animationBehavior: animationBehavior, child: this, ); } @@ -147,30 +190,55 @@ extension AnimatedEffectExt on Widget { /// 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. + /// + /// 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 [delay] parameter is used to set a delay before the animation starts. /// /// 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. AnimatedEffect oneShot({ + Key? key, Duration duration = const Duration(milliseconds: 350), Curve curve = appleEaseInOut, int repeat = 0, bool reverse = false, + bool resetValues = false, + bool interruptable = true, Duration delay = Duration.zero, VoidCallback? onEnd, BooleanCallback? playIf, + BooleanCallback? skipIf, + AnimationBehavior? animationBehavior, }) { return AnimatedEffect( + key: key, triggerType: AnimationTriggerType.oneShot, duration: duration, curve: curve, onEnd: onEnd, repeat: repeat, reverse: reverse, + resetValues: resetValues, + interruptable: interruptable, delay: delay, playIf: playIf, + skipIf: skipIf, + animationBehavior: animationBehavior, child: this, ); } @@ -180,10 +248,30 @@ extension AnimatedEffectExt on Widget { Widget resetAll() => ResetAllAnimationsEffect(child: this); } +/// Determines the behavior of the [AnimatedEffect] as soon as it is added +/// to the widget tree. +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; +} + /// A widget that animates the effects applied to it's child. class AnimatedEffect extends StatefulWidget { /// The widget below this widget in the tree. - final Widget child; + final Widget? child; /// The value used to trigger the animation. As long as the value of [trigger] /// is the same, the animation will not be triggered again. @@ -192,6 +280,10 @@ class AnimatedEffect extends StatefulWidget { /// 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. + final AnimationStartState startState; + /// The duration of the animation. final Duration duration; @@ -207,9 +299,20 @@ class AnimatedEffect extends StatefulWidget { /// Whether the animation should be reversed after each repetition. final bool reverse; - /// Whether the animation should be triggered immediately when the widget is - /// built, ignoring the value of [trigger] initially. - final bool startImmediately; + /// 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. + 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. + final bool interruptable; /// A delay before the animation starts. final Duration delay; @@ -219,24 +322,37 @@ class AnimatedEffect extends StatefulWidget { /// be skipped, even when it is explicitly triggered. final BooleanCallback? playIf; + /// A callback that determines whether the animation should be skipped by + /// setting the animation value to 1, effectively skipping the animation to + /// the ending values. + final BooleanCallback? skipIf; + + /// The behavior of the controller when + /// [AccessibilityFeatures.disableAnimations] is true. + final AnimationBehavior? animationBehavior; + /// Creates [AnimatedEffect] widget. const AnimatedEffect({ super.key, required this.child, required this.duration, required this.triggerType, + this.startState = AnimationStartState.idle, this.trigger, this.curve = appleEaseInOut, this.onEnd, this.repeat = 0, this.reverse = false, - this.startImmediately = false, + this.resetValues = false, + this.interruptable = true, this.delay = Duration.zero, this.playIf, + this.skipIf, + this.animationBehavior, }); @override - State createState() => _AnimatedEffectState(); + State createState() => AnimatedEffectState(); /// Returns the animation value of the nearest [EffectQuery] ancestor. /// If there is no ancestor, it returns null. @@ -244,17 +360,31 @@ class AnimatedEffect extends StatefulWidget { context.dependOnInheritedWidgetOfExactType(); } -class _AnimatedEffectState extends State +/// The state of [AnimatedEffect]. +class AnimatedEffectState extends State with SingleTickerProviderStateMixin { + /// Tracks whether the animation has played or not. + bool didPlay = false; + /// Returns whether the animation should be played or skipped based /// on the [playIf] callback. bool get shouldPlay => widget.playIf?.call() ?? true; + /// Returns whether the animation should be skipped based on the [skipIf] + /// callback. + bool get shouldSkip => widget.skipIf?.call() ?? false; + /// The animation controller that drives the animation. late final AnimationController controller = AnimationController( vsync: this, - value: 0, + value: + widget.startState == AnimationStartState.useCurrentValues || shouldSkip + ? 1 + : 0, duration: widget.duration, + animationBehavior: widget.animationBehavior ?? + HyperEffectsAnimationConfig.maybeOf(context)?.animationBehavior ?? + AnimationBehavior.normal, ); /// The number of times the animation should be repeated. @@ -263,15 +393,33 @@ class _AnimatedEffectState extends State /// Whether the animation should be reversed after each repetition. bool shouldReverse = false; + /// A future that represents a single animation cycle. + Future? driveFuture; + @override - void initState() { - super.initState(); + 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.startImmediately) { + widget.startState == AnimationStartState.playImmediately) { drive(); + didPlay = false; } } @@ -320,24 +468,26 @@ class _AnimatedEffectState extends State // 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 parentState = - context.findAncestorStateOfType<_AnimatedEffectState>(); - if (parentState != null) { - final triggerType = parentState.widget.triggerType; - if (triggerType == AnimationTriggerType.afterLast) { - // Trigger the next animation. - await parentState.drive(); - } + 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<_ResetAllAnimationsEffectState>(); + context.findAncestorStateOfType(); resetState?.reset(); } } @@ -353,9 +503,17 @@ class _AnimatedEffectState extends State /// Drives the animation. Future drive() async { - return ensureDelay(() async { + if (!widget.interruptable && driveFuture != null) { + await driveFuture; + } + + return driveFuture = ensureDelay(() async { if (!mounted) return; if (!shouldPlay) return; + if (shouldSkip) { + controller.value = 1; + return; + } if (widget.reverse && shouldReverse) { shouldReverse = false; await controller.reverse().catchError((err) { @@ -390,6 +548,7 @@ class _AnimatedEffectState extends State linearValue: controller.value, curvedValue: widget.curve.transform(controller.value), isTransition: false, + resetValues: widget.resetValues, duration: widget.duration, curve: widget.curve, child: child!, @@ -405,28 +564,29 @@ class _AnimatedEffectState extends State /// series of chained animations to their initial state. class ResetAllAnimationsEffect extends StatefulWidget { /// The widget below this widget in the tree. - final Widget child; + final Widget? child; /// Creates [ResetAllAnimationsEffect] widget. const ResetAllAnimationsEffect({super.key, required this.child}); @override State createState() => - _ResetAllAnimationsEffectState(); + ResetAllAnimationsEffectState(); } -class _ResetAllAnimationsEffectState extends State { +/// The state of [ResetAllAnimationsEffect]. +class ResetAllAnimationsEffectState extends State { /// Finds the last possible [AnimatedEffect] state in the tree while /// resetting all the ones on the way down. - _AnimatedEffectState? findLeafAnimatedEffectState(BuildContext context) { - _AnimatedEffectState? result; + AnimatedEffectState? findLeafAnimatedEffectState(BuildContext context) { + AnimatedEffectState? result; void visitor(Element element) { final Widget widget = element.widget; if (widget is AnimatedEffect) { - final StatefulElement editableTextElement = element as StatefulElement; + final StatefulElement animatedEffectEl = element as StatefulElement; - result = editableTextElement.state as _AnimatedEffectState; + result = animatedEffectEl.state as AnimatedEffectState; // Reset ALL animations in the chain. result?.reset(); @@ -442,11 +602,13 @@ class _ResetAllAnimationsEffectState extends State { /// the children tree and resetting all animations. void reset() { final state = findLeafAnimatedEffectState(context); + + // Once resetting is complete, re-trigger oneShot animations. if (state?.widget.triggerType == AnimationTriggerType.oneShot) { state?.drive(); } } @override - Widget build(BuildContext context) => widget.child; + Widget build(BuildContext context) => widget.child ?? const SizedBox.shrink(); } diff --git a/lib/src/animated_group_effect.dart b/lib/src/animated_group_effect.dart new file mode 100644 index 0000000..c39c321 --- /dev/null +++ b/lib/src/animated_group_effect.dart @@ -0,0 +1,285 @@ +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 new file mode 100644 index 0000000..1936a28 --- /dev/null +++ b/lib/src/animation_config.dart @@ -0,0 +1,37 @@ +import 'package:flutter/cupertino.dart'; + +/// Provides a way to configure the animation behavior globally for a subtree. +class HyperEffectsAnimationConfig extends InheritedWidget { + /// The animation behavior to use for the subtree. + final AnimationBehavior? animationBehavior; + + /// Returns the [HyperEffectsAnimationConfig] from the closest instance of + /// this class that encloses the given context. + static HyperEffectsAnimationConfig of(BuildContext context) { + final result = maybeOf(context); + assert( + result != null, + 'No HyperEffectsAnimationConfig found in provided context.', + ); + return result!; + } + + /// Returns the [HyperEffectsAnimationConfig] from the closest instance of + /// this class that encloses the given context. + static HyperEffectsAnimationConfig? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType(); + } + + /// Creates a new [HyperEffectsAnimationConfig] widget. + const HyperEffectsAnimationConfig({ + super.key, + required super.child, + this.animationBehavior, + }); + + @override + bool updateShouldNotify(covariant HyperEffectsAnimationConfig oldWidget) { + return animationBehavior != oldWidget.animationBehavior; + } +} diff --git a/lib/src/animation_retainer.dart b/lib/src/animation_retainer.dart new file mode 100644 index 0000000..1130589 --- /dev/null +++ b/lib/src/animation_retainer.dart @@ -0,0 +1,112 @@ +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 7999ec0..171ca97 100644 --- a/lib/src/effect_query.dart +++ b/lib/src/effect_query.dart @@ -25,6 +25,14 @@ class EffectQuery extends InheritedWidget { /// interpolated between 0 and 1. final bool lerpValues; + /// 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. + final bool resetValues; + /// The duration of the animation. final Duration duration; @@ -39,6 +47,7 @@ class EffectQuery extends InheritedWidget { required this.curvedValue, required this.isTransition, this.lerpValues = true, + this.resetValues = false, this.duration = Duration.zero, this.curve = Curves.linear, }); @@ -47,7 +56,10 @@ class EffectQuery extends InheritedWidget { bool updateShouldNotify(covariant EffectQuery oldWidget) { return oldWidget.curvedValue != curvedValue || oldWidget.isTransition != isTransition || - oldWidget.lerpValues != lerpValues; + oldWidget.lerpValues != lerpValues || + oldWidget.resetValues != resetValues || + oldWidget.duration != duration || + oldWidget.curve != curve; } /// Returns the [EffectQuery] from the given [context]. diff --git a/lib/src/effect_widget.dart b/lib/src/effect_widget.dart index 6d1acc2..2a4086c 100644 --- a/lib/src/effect_widget.dart +++ b/lib/src/effect_widget.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'effect_query.dart'; @@ -22,7 +23,7 @@ class EffectWidget extends StatefulWidget { final Effect? start; /// The [Widget] to apply the [end] to. - final Widget child; + final Widget? child; /// Creates an [EffectWidget]. const EffectWidget({ @@ -38,34 +39,45 @@ class EffectWidget extends StatefulWidget { class _EffectWidgetState extends State { /// The [Effect] to interpolate to. - late Effect end = widget.end; + late Effect end; /// The [Effect] to interpolate from. - late Effect start = widget.start ?? widget.end; + late Effect start; /// caches the previous animation value to use in didUpdateWidget /// to calculate the begin value. This is used to create a smooth transition /// between two [Effect]s when the [Effect] changes mid animation. - late double previousAnimationValue = 0; + double previousAnimationValue = 0; - /// Pulls the parent [EffectQuery] inherited widget. - EffectQuery? get effectAnimationValue => EffectQuery.maybeOf(context); - - /// Pulls the animation value from the parent [EffectQuery] widget. - double get animationValue => effectAnimationValue?.curvedValue ?? 0; + @override + void initState() { + super.initState(); + end = widget.end; + start = widget.start ?? widget.end; + } @override void didChangeDependencies() { super.didChangeDependencies(); + final effectQuery = EffectQuery.maybeOf(context); + final double animationValue = effectQuery?.curvedValue ?? 0; previousAnimationValue = animationValue; } @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) { - if (effectAnimationValue != null && !effectAnimationValue!.isTransition) { + 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); } @@ -75,13 +87,32 @@ class _EffectWidgetState extends State { @override Widget build(BuildContext context) { - if (effectAnimationValue?.lerpValues == false) { - return end.apply(context, widget.child); + final effectQuery = EffectQuery.maybeOf(context); + + final child = widget.child; + + if (effectQuery?.lerpValues == false) { + return end.apply(context, child); } else { - if (start.runtimeType != end.runtimeType) return widget.child; + if (start.runtimeType != end.runtimeType) { + return child ?? const SizedBox.shrink(); + } + + final double animationValue = effectQuery?.curvedValue ?? 0; + Effect effectiveStart = start; + if (widget.start == null && effectQuery?.resetValues == true) { + effectiveStart = start.idle(); + } - final Effect newEffect = start.lerp(end, animationValue); - return newEffect.apply(context, widget.child); + final Effect newEffect = effectiveStart.lerp(end, animationValue); + return newEffect.apply(context, child); } } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('start', start)); + properties.add(DiagnosticsProperty('end', end)); + } } diff --git a/lib/src/effects/align_effect.dart b/lib/src/effects/align_effect.dart index 896a5d1..7136ed1 100644 --- a/lib/src/effects/align_effect.dart +++ b/lib/src/effects/align_effect.dart @@ -1,7 +1,8 @@ +import 'dart:ui'; + import 'package:flutter/widgets.dart'; -import '../effect_widget.dart'; -import 'effect.dart'; +import '../../hyper_effects.dart'; /// Provides a extension method to apply an [AlignEffect] to a [Widget]. extension AlignEffectExt on Widget { @@ -9,10 +10,24 @@ extension AlignEffectExt on Widget { Widget align( AlignmentGeometry alignment, { AlignmentGeometry? from, + double heightFactor = 1, + double widthFactor = 1, + double? fromHeightFactor, + double? fromWidthFactor, }) { return EffectWidget( - start: from == null ? null : AlignEffect(alignment: from), - end: AlignEffect(alignment: alignment), + start: from == null && fromHeightFactor == null && fromWidthFactor == null + ? null + : AlignEffect( + alignment: from ?? alignment, + heightFactor: fromHeightFactor ?? heightFactor, + widthFactor: fromWidthFactor ?? widthFactor, + ), + end: AlignEffect( + alignment: alignment, + heightFactor: heightFactor, + widthFactor: widthFactor, + ), child: this, ); } @@ -55,9 +70,17 @@ class AlignEffect extends Effect { /// The alignment by which the [Widget] is aligned. final AlignmentGeometry alignment; + /// Sets its width to the child's width multiplied by this factor. + final double widthFactor; + + /// Sets its height to the child's height multiplied by this factor. + final double heightFactor; + /// Creates a [AlignEffect] with the given [alignment] and [fractional]. AlignEffect({ this.alignment = AlignmentDirectional.topStart, + this.widthFactor = 1, + this.heightFactor = 1, }); @override @@ -65,17 +88,26 @@ class AlignEffect extends Effect { 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, ); } @override - Widget apply(BuildContext context, Widget child) { + Widget apply(BuildContext context, Widget? child) { return Align( alignment: alignment, + widthFactor: widthFactor, + heightFactor: heightFactor, child: child, ); } @override - List get props => [alignment]; + AlignEffect idle() => AlignEffect(); + + @override + List get props => [alignment, widthFactor, heightFactor]; } diff --git a/lib/src/effects/blur_effect.dart b/lib/src/effects/blur_effect.dart index 9fe52fd..bbb3fb3 100644 --- a/lib/src/effects/blur_effect.dart +++ b/lib/src/effects/blur_effect.dart @@ -94,15 +94,15 @@ class BlurEffect extends Effect { @override BlurEffect lerp(covariant BlurEffect other, double value) { final effect = BlurEffect( - blur: blur != null ? (lerpDouble(blur, other.blur, value) ?? 1) : null, - blurX: blur == null ? (lerpDouble(blurX, other.blurX, value) ?? 1) : null, - blurY: blur == null ? (lerpDouble(blurY, other.blurY, value) ?? 1) : null, + blur: blur != null ? (lerpDouble(blur, other.blur, value) ?? 0) : null, + blurX: blur == null ? (lerpDouble(blurX, other.blurX, value) ?? 0) : null, + blurY: blur == null ? (lerpDouble(blurY, other.blurY, value) ?? 0) : null, ); return effect; } @override - Widget apply(BuildContext context, Widget child) { + Widget apply(BuildContext context, Widget? child) { return ImageFiltered( imageFilter: ImageFilter.blur( sigmaX: blurX ?? blur ?? 0, @@ -113,6 +113,9 @@ class BlurEffect extends Effect { ); } + @override + BlurEffect idle() => BlurEffect(blur: 0); + @override List get props => [blur, blurX, blurY]; } diff --git a/lib/src/effects/clip_effect.dart b/lib/src/effects/clip_effect.dart index 707ebf8..eb7b7a5 100644 --- a/lib/src/effects/clip_effect.dart +++ b/lib/src/effects/clip_effect.dart @@ -136,8 +136,8 @@ class ClipEffect extends Effect { } @override - Widget apply(BuildContext context, Widget child) { - if (clip == Clip.none) return child; + Widget apply(BuildContext context, Widget? child) { + if (clip == Clip.none) return child ?? const SizedBox.shrink(); if (borderRadius == BorderRadius.zero) { return ClipRect( clipBehavior: clip, @@ -151,6 +151,9 @@ class ClipEffect extends Effect { ); } + @override + ClipEffect idle() => ClipEffect(clip: clip, borderRadius: BorderRadius.zero); + @override List get props => [clip, borderRadius]; } diff --git a/lib/src/effects/color_filter_effect.dart b/lib/src/effects/color_filter_effect.dart index 1854d9e..1a0bac0 100644 --- a/lib/src/effects/color_filter_effect.dart +++ b/lib/src/effects/color_filter_effect.dart @@ -136,7 +136,7 @@ class ColorFilterEffect extends Effect { } @override - Widget apply(BuildContext context, Widget child) { + Widget apply(BuildContext context, Widget? child) { return ColorFiltered( colorFilter: color != null ? ColorFilter.mode(color!, mode) @@ -145,6 +145,9 @@ class ColorFilterEffect extends Effect { ); } + @override + ColorFilterEffect idle() => ColorFilterEffect(mode: mode); + @override List get props => [color, mode, matrix]; } diff --git a/lib/src/effects/effect.dart b/lib/src/effects/effect.dart index 2509c1e..6791519 100644 --- a/lib/src/effects/effect.dart +++ b/lib/src/effects/effect.dart @@ -20,6 +20,10 @@ abstract class Effect with EquatableMixin { /// Creates an [Effect]. const Effect(); + /// Returns a copy of this [Effect] but with default values that produce + /// no visual change to the widget. + Effect idle() => this; + /// Linearly interpolates between two [Effect]s. This is used to animate /// between two [Effect]s. The [value] argument is a fraction that /// determines the position of this effect between [this] and [other]. @@ -55,5 +59,5 @@ abstract class Effect with EquatableMixin { /// /// Check out [ScaleEffect.apply] for an example of how to implement this /// method. - Widget apply(BuildContext context, Widget child); + Widget apply(BuildContext context, Widget? child); } diff --git a/lib/src/effects/effects.dart b/lib/src/effects/effects.dart index b2ffbb9..4cc3c18 100644 --- a/lib/src/effects/effects.dart +++ b/lib/src/effects/effects.dart @@ -1,4 +1,4 @@ -library effects; +library; export 'align_effect.dart'; export 'blur_effect.dart'; @@ -6,10 +6,12 @@ export 'clip_effect.dart'; export 'color_filter_effect.dart'; export 'effect.dart'; export 'opacity_effect.dart'; +export 'padding_effect.dart'; +export 'roll/roll_effect.dart'; +export 'roll/text_extensions.dart'; export 'rotation_effect.dart'; export 'scale_effect.dart'; export 'shake_effect.dart'; export 'skew_effect.dart'; -export 'text/text_extensions.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 83f2e06..a79fff5 100644 --- a/lib/src/effects/opacity_effect.dart +++ b/lib/src/effects/opacity_effect.dart @@ -1,6 +1,7 @@ import 'dart:ui'; import 'package:flutter/widgets.dart'; + import '../../hyper_effects.dart'; /// Provides a extension method to apply an [OpacityEffect] to a [Widget]. @@ -60,9 +61,14 @@ class OpacityEffect extends Effect { } @override - Widget apply(BuildContext context, Widget child) => + Widget apply(BuildContext context, Widget? child) => Opacity(opacity: opacity, child: child); + @override + OpacityEffect idle() => OpacityEffect( + opacity: 1, + ); + @override List get props => [opacity]; } diff --git a/lib/src/effects/padding_effect.dart b/lib/src/effects/padding_effect.dart new file mode 100644 index 0000000..4d6d35c --- /dev/null +++ b/lib/src/effects/padding_effect.dart @@ -0,0 +1,188 @@ +import 'package:flutter/widgets.dart'; + +import '../effect_widget.dart'; +import 'effect.dart'; + +/// Provides a extension method to apply an [PaddingEffect] to a [Widget]. +extension PaddingEffectExt on Widget { + /// Applies an [PaddingEffect] to a [Widget] with the given [padding]. + Widget pad( + EdgeInsets padding, { + EdgeInsets? from, + }) { + return EffectWidget( + start: from == null ? null : PaddingEffect(padding: from), + end: PaddingEffect(padding: padding), + child: this, + ); + } + + /// Applies an [PaddingEffect] to a [Widget] with the given [padding]. + Widget padAll( + double padding, { + double? from, + }) { + return EffectWidget( + start: from == null ? null : PaddingEffect(padding: EdgeInsets.all(from)), + end: PaddingEffect(padding: EdgeInsets.all(padding)), + child: this, + ); + } + + /// Applies an [PaddingEffect] to a [Widget] with the given [vertical] + /// and [horizontal] padding. + Widget padSymmetric( + {double vertical = 0, double horizontal = 0, double? from}) { + return EffectWidget( + start: from == null + ? null + : PaddingEffect( + padding: EdgeInsets.symmetric(vertical: from, horizontal: from)), + end: PaddingEffect( + padding: + EdgeInsets.symmetric(vertical: vertical, horizontal: horizontal)), + child: this, + ); + } + + /// Applies an [PaddingEffect] to a [Widget] with the given [horizontal] + /// padding. + Widget padHorizontal( + double padding, { + double? from, + }) { + return EffectWidget( + start: from == null + ? null + : PaddingEffect(padding: EdgeInsets.symmetric(horizontal: from)), + end: PaddingEffect(padding: EdgeInsets.symmetric(horizontal: padding)), + child: this, + ); + } + + /// Applies an [PaddingEffect] to a [Widget] with the given [vertical] + /// padding. + Widget padVertical( + double padding, { + double? from, + }) { + return EffectWidget( + start: from == null + ? null + : PaddingEffect(padding: EdgeInsets.symmetric(vertical: from)), + end: PaddingEffect(padding: EdgeInsets.symmetric(vertical: padding)), + child: this, + ); + } + + /// Applies an [PaddingEffect] to a [Widget] with the given [left], [right], + /// [top], and [bottom] padding. + Widget padOnly({ + double left = 0, + double right = 0, + double top = 0, + double bottom = 0, + double? from, + }) { + return EffectWidget( + start: from == null + ? null + : PaddingEffect( + padding: EdgeInsets.only( + left: from, right: from, top: from, bottom: from)), + end: PaddingEffect( + padding: EdgeInsets.only( + left: left, right: right, top: top, bottom: bottom)), + child: this, + ); + } + + /// Applies an [PaddingEffect] to a [Widget] with the given [left] padding. + Widget padLeft( + double padding, { + double? from, + }) { + return EffectWidget( + start: from == null + ? null + : PaddingEffect(padding: EdgeInsets.only(left: from)), + end: PaddingEffect(padding: EdgeInsets.only(left: padding)), + child: this, + ); + } + + /// Applies an [PaddingEffect] to a [Widget] with the given [right] padding. + Widget padRight( + double padding, { + double? from, + }) { + return EffectWidget( + start: from == null + ? null + : PaddingEffect(padding: EdgeInsets.only(right: from)), + end: PaddingEffect(padding: EdgeInsets.only(right: padding)), + child: this, + ); + } + + /// Applies an [PaddingEffect] to a [Widget] with the given [top] padding. + Widget padTop( + double padding, { + double? from, + }) { + return EffectWidget( + start: from == null + ? null + : PaddingEffect(padding: EdgeInsets.only(top: from)), + end: PaddingEffect(padding: EdgeInsets.only(top: padding)), + child: this, + ); + } + + /// Applies an [PaddingEffect] to a [Widget] with the given [bottom] padding. + Widget padBottom( + double padding, { + double? from, + }) { + return EffectWidget( + start: from == null + ? null + : PaddingEffect(padding: EdgeInsets.only(bottom: from)), + end: PaddingEffect(padding: EdgeInsets.only(bottom: padding)), + child: this, + ); + } +} + +/// An effect that aligns a [Widget] by a given [padding]. +class PaddingEffect extends Effect { + /// The alignment by which the [Widget] is aligned. + final EdgeInsets padding; + + /// Creates a [PaddingEffect] with the given [padding]. + PaddingEffect({ + this.padding = EdgeInsets.zero, + }); + + @override + PaddingEffect lerp(covariant PaddingEffect other, double value) { + return PaddingEffect( + padding: EdgeInsets.lerp(padding, other.padding, value.clamp(0, 1)) ?? + EdgeInsets.zero, + ); + } + + @override + Widget apply(BuildContext context, Widget? child) { + return Padding( + padding: padding, + child: child, + ); + } + + @override + PaddingEffect idle() => PaddingEffect(); + + @override + List get props => [padding]; +} diff --git a/lib/src/effects/roll/roll_effect.dart b/lib/src/effects/roll/roll_effect.dart new file mode 100644 index 0000000..c36d65f --- /dev/null +++ b/lib/src/effects/roll/roll_effect.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; + +import '../../../hyper_effects.dart'; + +/// A builder that takes a [BuildContext] and a [Widget] and returns a [Widget]. +typedef RollBuilder = Widget Function( + BuildContext context, + Widget child, +); + +/// Provides a extension method to apply an [RollEffect] to a [Widget]. +extension RollEffectExtension on Widget? { + /// Applies an [RollEffect] to a [Widget] with the given [slideInDirection], + /// [slideOutDirection] and [multiplier]. + EffectWidget roll({ + AxisDirection slideInDirection = AxisDirection.up, + AxisDirection slideOutDirection = AxisDirection.up, + double multiplier = 1, + bool useSnapshots = false, + }) => + EffectWidget( + end: RollEffect( + child: this, + slideInDirection: slideInDirection, + slideOutDirection: slideOutDirection, + multiplier: multiplier, + useSnapshots: useSnapshots, + ), + child: this, + ); +} + +/// An [Effect] that applies a roll animation to a [Widget] with the +/// given [slideInDirection], [slideOutDirection] and [multiplier]. +class RollEffect extends Effect { + /// The [Widget] to apply the effect to. + final Widget? child; + + /// The direction to slide in the [Widget]. + final AxisDirection slideInDirection; + + /// The direction to slide out the [Widget]. + final AxisDirection slideOutDirection; + + /// The multiplier to apply to the slide out direction. + final double multiplier; + + /// Determines whether the old [Widget]s that are being rolled away from + /// should be rendered via a [SnapshotWidget] or just using the original + /// widget directly. + /// + /// This may be needed in cases where state management of the old [Widget]s + /// may be sensitive. + final bool useSnapshots; + + /// Creates a [RollEffect] with the given parameters. + const RollEffect({ + required this.child, + this.slideInDirection = AxisDirection.up, + this.slideOutDirection = AxisDirection.up, + this.multiplier = 1, + this.useSnapshots = false, + }); + + @override + Effect lerp(covariant Effect other, double value) => other; + + @override + Widget apply(BuildContext context, Widget? child) => RollingEffectWidget( + slideInDirection: slideInDirection, + slideOutDirection: slideOutDirection, + multiplier: multiplier, + useSnapshots: useSnapshots, + child: child, + ); + + @override + List get props => [ + child, + slideInDirection, + slideOutDirection, + multiplier, + useSnapshots, + ]; +} + +/// A [StatefulWidget] that applies a roll animation to a [Widget]. +class RollingEffectWidget extends StatefulWidget { + /// The [Widget] to apply the effect to. + final Widget? child; + + /// The direction to slide in the [Widget]. + final AxisDirection slideInDirection; + + /// The direction to slide out the [Widget]. + final AxisDirection slideOutDirection; + + /// The multiplier to apply to the slide out direction. + final double multiplier; + + /// Determines whether the old [Widget]s that are being rolled away from + /// should be rendered via a [SnapshotWidget] or just using the original + /// widget directly. + /// + /// This may be needed in cases where state management of the old [Widget]s + /// may be sensitive. + final bool useSnapshots; + + /// Creates a [RollingEffectWidget] with the given parameters. + const RollingEffectWidget({ + super.key, + required this.child, + this.slideInDirection = AxisDirection.up, + this.slideOutDirection = AxisDirection.up, + this.multiplier = 1, + this.useSnapshots = false, + }); + + @override + State createState() => _RollingEffectWidgetState(); +} + +class _RollingEffectWidgetState extends State { + final SnapshotController snapshotController = + SnapshotController(allowSnapshotting: true); + + int retainedWidgets = 0; + Widget? oldChild; + + bool canRoll = false; + + @override + void didUpdateWidget(covariant RollingEffectWidget oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.child?.key != widget.child?.key) { + canRoll = true; + if (widget.useSnapshots) { + oldChild = SnapshotWidget( + key: ValueKey(retainedWidgets++), + mode: SnapshotMode.forced, + controller: snapshotController, + child: oldWidget.child, + ); + } else { + oldChild = oldWidget.child; + } + } + } + + @override + void dispose() { + snapshotController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final query = EffectQuery.maybeOf(context); + + final timeValue = (query?.curvedValue ?? 1); + + final slideInTime = switch (widget.slideInDirection) { + AxisDirection.up || AxisDirection.left => (canRoll ? 1 : 0) - timeValue, + AxisDirection.down || + AxisDirection.right => + (canRoll ? -1 : 0) + timeValue, + }; + final slideInOffset = switch (widget.slideInDirection) { + AxisDirection.up || AxisDirection.down => Offset(0, slideInTime), + AxisDirection.left || AxisDirection.right => Offset(slideInTime, 0), + }; + + final slideOutTime = switch (widget.slideOutDirection) { + AxisDirection.up || AxisDirection.left => -timeValue, + AxisDirection.down || AxisDirection.right => timeValue, + }; + final slideOutOffset = switch (widget.slideOutDirection) { + AxisDirection.up || AxisDirection.down => Offset(0, slideOutTime), + AxisDirection.left || AxisDirection.right => Offset(slideOutTime, 0), + }; + + final Widget? child = widget.child; + + late final Widget? oldRoll = oldChild == null + ? null + : FractionalTranslation( + transformHitTests: false, + translation: slideOutOffset * widget.multiplier, + child: oldChild, + ); + + final double clampedTime = timeValue.clamp(0, 1); + final double reverseClampedTime = 1 - clampedTime; + + return AnimatedSize( + duration: query?.duration ?? Duration.zero, + curve: query?.curve ?? Curves.linear, + clipBehavior: Clip.none, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + if (oldRoll != null) + // If the main child is null, then we can't use a Positioned.fill + // widget to fill the entire space with the old roll because a stack + // must have at least one non-positioned child. Use an Align widget + // instead. The stack's size will be determined by the size of the + // old rolling widget. + if (child == null) + Align( + heightFactor: reverseClampedTime, + widthFactor: reverseClampedTime, + child: oldRoll, + ) + else + // If the main child is not null, then we can use a + // Positioned.fill widget to fill the entire space with the old + // roll. The stack's size will be determined by the size of the + // main child, while this old roll is de-transitioned. + // The FittedBox ensures that the oldRoll is laid out as if it + // were the main child, unaffected by the main child's size. + Positioned.fill( + child: Align( + heightFactor: reverseClampedTime, + widthFactor: reverseClampedTime, + child: FittedBox( + fit: BoxFit.none, + child: oldRoll, + ), + ), + ), + if (child != null) + FractionalTranslation( + transformHitTests: false, + translation: slideInOffset * widget.multiplier, + child: child, + ), + ], + ), + ); + } +} diff --git a/lib/src/effects/text/rolling_text_controller.dart b/lib/src/effects/roll/rolling_text_controller.dart similarity index 92% rename from lib/src/effects/text/rolling_text_controller.dart rename to lib/src/effects/roll/rolling_text_controller.dart index db60d6f..4705b99 100644 --- a/lib/src/effects/text/rolling_text_controller.dart +++ b/lib/src/effects/roll/rolling_text_controller.dart @@ -22,7 +22,7 @@ class RollingTextController with ChangeNotifier { /// Determines the direction in which each tape of characters will /// slide. - final TapeSlideDirection tapeSlideDirection; + final TextTapeSlideDirection tapeSlideDirection; /// If non-null, the style to use for this text. /// @@ -99,7 +99,7 @@ class RollingTextController with ChangeNotifier { required this.oldText, required this.newText, required this.tapeStrategy, - this.tapeSlideDirection = TapeSlideDirection.up, + this.tapeSlideDirection = TextTapeSlideDirection.up, this.style, this.strutStyle, this.textAlign, @@ -116,14 +116,14 @@ class RollingTextController with ChangeNotifier { /// A list containing strings that represent a tape of characters /// to roll through for each character index between the old and /// new text. - late final List tapes = []; + final List tapes = []; /// A list containing painters that represent each tape of characters /// from [tapes]. - late final List tapePainters = []; + final List tapePainters = []; /// A cached map of tape heights for each tape painter. - late final Map tapeHeights = {}; + final Map tapeHeights = {}; /// Returns the height of a tape at the given index. double getTapeHeight(int tapeIndex) => tapeHeights[tapeIndex] ?? 0; @@ -187,10 +187,10 @@ class RollingTextController with ChangeNotifier { }) { final painter = tapePainters[tapeIndex]; directionReversed ??= switch (tapeSlideDirection) { - TapeSlideDirection.up => false, - TapeSlideDirection.down => true, - TapeSlideDirection.alternating => tapeIndex % 2 == 0, - TapeSlideDirection.random => Random('$tapeIndex'.hashCode).nextBool(), + TextTapeSlideDirection.up => false, + TextTapeSlideDirection.down => true, + TextTapeSlideDirection.alternating => tapeIndex % 2 == 0, + TextTapeSlideDirection.random => Random('$tapeIndex'.hashCode).nextBool(), }; final selection = selectionAtTapeIndexNearValue( tapeIndex, @@ -236,11 +236,12 @@ class RollingTextController with ChangeNotifier { ); tapes.add(switch (tapeSlideDirection) { - TapeSlideDirection.up => tape, - TapeSlideDirection.down => tape.characters.toList().reversed.join(''), - TapeSlideDirection.alternating => - i % 2 == 0 ? tape : tape.characters.toList().reversed.join(''), - TapeSlideDirection.random => Random('$i'.hashCode).nextBool() + TextTapeSlideDirection.up => tape, + TextTapeSlideDirection.down => + tape.characters.toList().reversed.join(''), + TextTapeSlideDirection.alternating => + i % 2 == 1 ? tape : tape.characters.toList().reversed.join(''), + TextTapeSlideDirection.random => Random('$i'.hashCode).nextBool() ? tape : tape.characters.toList().reversed.join(''), }); diff --git a/lib/src/effects/text/rolling_text_effect.dart b/lib/src/effects/roll/rolling_text_effect.dart similarity index 94% rename from lib/src/effects/text/rolling_text_effect.dart rename to lib/src/effects/roll/rolling_text_effect.dart index 75ab6f6..7998e5c 100644 --- a/lib/src/effects/text/rolling_text_effect.dart +++ b/lib/src/effects/roll/rolling_text_effect.dart @@ -2,21 +2,18 @@ import 'dart:math'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; -import '../../../hyper_effects.dart'; +import '../../../hyper_effects.dart'; import 'rolling_text_controller.dart'; +export 'slide_direction.dart'; export 'symbol_tape_strategy.dart'; -export 'tape_slide_direction.dart'; /// Rolls each character with a tape of characters individually /// to form the [newText] from the [oldText]. class RollingTextEffect extends Effect { - /// The text to display interpolating away from. - final String oldText; - /// The text to display interpolating to. - final String newText; + final String text; /// Internal padding to apply between the row of symbol tapes and /// the clipping mask. @@ -34,7 +31,7 @@ class RollingTextEffect extends Effect { /// Determines the direction in which each tape of characters will /// slide. - final TapeSlideDirection tapeSlideDirection; + final TextTapeSlideDirection tapeSlideDirection; /// Determines how the text should be clipped. The rendered text is /// going to be a fixed-height box based on the font size. @@ -190,13 +187,12 @@ class RollingTextEffect extends Effect { /// Creates a [RollingTextEffect]. const RollingTextEffect({ - required this.oldText, - required this.newText, + required this.text, this.padding = EdgeInsets.zero, this.tapeStrategy = const ConsistentSymbolTapeStrategy(0), this.clipBehavior = Clip.hardEdge, this.tapeCurve, - this.tapeSlideDirection = TapeSlideDirection.up, + this.tapeSlideDirection = TextTapeSlideDirection.up, this.staggerTapes = true, this.staggerSoftness = 1, this.reverseStaggerDirection = false, @@ -223,10 +219,9 @@ class RollingTextEffect extends Effect { other; @override - Widget apply(BuildContext context, Widget child) { + Widget apply(BuildContext context, Widget? child) { return RollingText( - newText: newText, - oldText: oldText, + text: text, padding: padding, tapeStrategy: tapeStrategy, tapeCurve: tapeCurve, @@ -256,8 +251,7 @@ class RollingTextEffect extends Effect { @override List get props => [ - oldText, - newText, + text, padding, tapeCurve, tapeSlideDirection, @@ -296,11 +290,8 @@ class RollingTextEffect extends Effect { /// The text style, alignment, directionality, and other text properties can /// also be customized. class RollingText extends StatefulWidget { - /// The text to display interpolating away from. - final String oldText; - /// The text to display interpolating to. - final String newText; + final String text; /// Internal padding to apply between the row of symbol tapes and /// the clipping mask. @@ -318,7 +309,7 @@ class RollingText extends StatefulWidget { /// Determines the direction in which each tape of characters will /// slide. - final TapeSlideDirection tapeSlideDirection; + final TextTapeSlideDirection tapeSlideDirection; /// Determines how the text should be clipped. The rendered text is /// going to be a fixed-height box based on the font size. @@ -461,12 +452,11 @@ class RollingText extends StatefulWidget { /// Creates a new [RollingText] with the given parameters. const RollingText({ super.key, - required this.oldText, - required this.newText, + required this.text, this.padding = EdgeInsets.zero, this.tapeStrategy = const ConsistentSymbolTapeStrategy(0), this.tapeCurve, - this.tapeSlideDirection = TapeSlideDirection.up, + this.tapeSlideDirection = TextTapeSlideDirection.up, this.clipBehavior = Clip.hardEdge, this.staggerTapes = true, this.staggerSoftness = 1, @@ -494,15 +484,20 @@ class RollingText extends StatefulWidget { } class _RollingTextState extends State { - late RollingTextController rollingTextPainter = createRollingTextPainter(); + late RollingTextController rollingTextPainter = createRollingTextController(); + + late String oldText = widget.text; @override void didUpdateWidget(covariant RollingText oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.text != widget.text) { + oldText = oldWidget.text; + } + // Account for all parameters except for value. - if (oldWidget.oldText == widget.oldText && - oldWidget.newText == widget.newText && + if (oldWidget.text == widget.text && oldWidget.padding == widget.padding && oldWidget.tapeStrategy == widget.tapeStrategy && oldWidget.tapeCurve == widget.tapeCurve && @@ -526,15 +521,17 @@ class _RollingTextState extends State { oldWidget.textHeightBehavior == widget.textHeightBehavior && oldWidget.maxLines == widget.maxLines && oldWidget.semanticsLabel == widget.semanticsLabel && - oldWidget.selectionColor == widget.selectionColor) return; + oldWidget.selectionColor == widget.selectionColor) { + return; + } rollingTextPainter.dispose(); - rollingTextPainter = createRollingTextPainter(); + rollingTextPainter = createRollingTextController(); } - RollingTextController createRollingTextPainter() => RollingTextController( - oldText: widget.oldText, - newText: widget.newText, + RollingTextController createRollingTextController() => RollingTextController( + oldText: oldText, + newText: widget.text, tapeStrategy: widget.tapeStrategy, tapeSlideDirection: widget.tapeSlideDirection, style: widget.style, @@ -559,7 +556,7 @@ class _RollingTextState extends State { @override Widget build(BuildContext context) { final int longest = - max(widget.oldText.characters.length, widget.newText.characters.length); + max(oldText.characters.length, widget.text.characters.length); final effectAnimationValue = EffectQuery.maybeOf(context); final timeValue = effectAnimationValue?.linearValue ?? 1; @@ -605,10 +602,10 @@ class _RollingTextState extends State { final tapeHeight = rollingTextPainter.getTapeHeight(charIndex); final bool directionReversed = switch (widget.tapeSlideDirection) { - TapeSlideDirection.up => false, - TapeSlideDirection.down => true, - TapeSlideDirection.alternating => charIndex % 2 == 0, - TapeSlideDirection.random => + TextTapeSlideDirection.up => false, + TextTapeSlideDirection.down => true, + TextTapeSlideDirection.alternating => charIndex % 2 == 0, + TextTapeSlideDirection.random => Random('$charIndex'.hashCode).nextBool(), }; final transformedValue = diff --git a/lib/src/effects/text/tape_slide_direction.dart b/lib/src/effects/roll/slide_direction.dart similarity index 94% rename from lib/src/effects/text/tape_slide_direction.dart rename to lib/src/effects/roll/slide_direction.dart index fe41591..d4f272c 100644 --- a/lib/src/effects/text/tape_slide_direction.dart +++ b/lib/src/effects/roll/slide_direction.dart @@ -2,7 +2,7 @@ import '../../../hyper_effects.dart'; /// Determines the direction in which each tape of characters will /// slide in the [RollingTextEffect]. -enum TapeSlideDirection { +enum TextTapeSlideDirection { /// The tape of characters will slide from bottom to top. up, diff --git a/lib/src/effects/text/symbol_tape_strategy.dart b/lib/src/effects/roll/symbol_tape_strategy.dart similarity index 83% rename from lib/src/effects/text/symbol_tape_strategy.dart rename to lib/src/effects/roll/symbol_tape_strategy.dart index 1a4720a..76729d3 100644 --- a/lib/src/effects/text/symbol_tape_strategy.dart +++ b/lib/src/effects/roll/symbol_tape_strategy.dart @@ -1,7 +1,6 @@ import 'dart:math'; import 'package:flutter/widgets.dart'; -import 'package:unicode_emojis/unicode_emojis.dart'; const String _kLowerAlphabet = 'abcdefghijklmnopqrstuvwxyz '; const String _kUpperAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; @@ -9,8 +8,6 @@ const String _kNumbers = '0123456789'; const String _kSymbols = '`~!@#\$%^&*()-_=+[{]}\\|;:\'",<.>/?'; const String _kSpace = ' '; const String _kZeroWidth = '​'; -final String _allEmojis = - UnicodeEmojis.allEmojis.map((emoji) => emoji.emoji).join(''); extension _StringHelper on String { bool isSymbol() => _kSymbols.contains(this); @@ -24,8 +21,18 @@ extension _StringHelper on String { bool isLowerAlphabet() => _kLowerAlphabet.contains(this); bool isUpperAlphabet() => _kUpperAlphabet.contains(this); +} + +/// A builder that constructs a tape of characters tailored for a specific +/// condition of characters such as emojis. +abstract class CharacterTapeBuilder { + /// The characters that are used to build the tape of characters. For example, + /// an entire emoji set. + String get characters; - bool isEmoji() => _allEmojis.contains(this); + /// Compares two strings to determine if the builder should be used to + /// insert the [characters] into the main tape. + bool compare(String a, String b); } /// Defines the strategy to create the tape of character symbols @@ -38,9 +45,17 @@ sealed class SymbolTapeStrategy { /// be rendered and animated to roll twice instead of just once. final bool repeatCharacters; + /// A set of [CharacterTapeBuilder]s that can be used to build the tape + /// of characters to interpolate to and from for a specific set of characters, + /// like emojis. + final Set characterTapeBuilders; + /// Creates a new [SymbolTapeStrategy] with the given [repeatCharacters] /// property. - const SymbolTapeStrategy([this.repeatCharacters = true]); + const SymbolTapeStrategy({ + this.repeatCharacters = true, + this.characterTapeBuilders = const {}, + }); /// Builds the tape of characters to interpolate to and from. String build(String a, String b) => @@ -68,8 +83,10 @@ sealed class SymbolTapeStrategy { if (a.isNumber() || b.isNumber()) { charKitBuffer.write(_kNumbers); } - if (a.isEmoji() || b.isEmoji()) { - charKitBuffer.write(_allEmojis); + for (final CharacterTapeBuilder builder in characterTapeBuilders) { + if (builder.compare(a, b)) { + charKitBuffer.write(builder.characters); + } } } @@ -107,8 +124,10 @@ sealed class SymbolTapeStrategy { if (a.isNumber() || b.isNumber()) { charKitBuffer.write(_kNumbers); } - if (a.isEmoji() || b.isEmoji()) { - charKitBuffer.write(_allEmojis); + for (final CharacterTapeBuilder builder in characterTapeBuilders) { + if (builder.compare(a, b)) { + charKitBuffer.write(builder.characters); + } } } @@ -137,7 +156,7 @@ sealed class SymbolTapeStrategy { class AllSymbolsTapeStrategy extends SymbolTapeStrategy { /// Creates a new [AllSymbolsTapeStrategy] with the given [repeatCharacters] /// property. - const AllSymbolsTapeStrategy([super.repeatCharacters = true]); + const AllSymbolsTapeStrategy({super.repeatCharacters = true}); } /// Constructs symbol tapes that contain all the characters @@ -167,9 +186,11 @@ class ConsistentSymbolTapeStrategy extends SymbolTapeStrategy { /// Creates a new [ConsistentSymbolTapeStrategy] with the given [distance] /// and [repeatCharacters] properties. - const ConsistentSymbolTapeStrategy(this.distance, - [super.repeatCharacters = true]) - : assert( + const ConsistentSymbolTapeStrategy( + this.distance, { + super.repeatCharacters = true, + Set characterTapeBuilders = const {}, + }) : assert( distance >= 0, 'Distance must be >= 0.', ); diff --git a/lib/src/effects/text/text_extensions.dart b/lib/src/effects/roll/text_extensions.dart similarity index 91% rename from lib/src/effects/text/text_extensions.dart rename to lib/src/effects/roll/text_extensions.dart index f91b65e..839f54c 100644 --- a/lib/src/effects/text/text_extensions.dart +++ b/lib/src/effects/roll/text_extensions.dart @@ -56,11 +56,6 @@ extension TextEffectExt on Text { /// the final height of the entire widget is fontSize * lineHeightMultiplier. /// The default multiplier is 1. /// - /// The [interpolateWidthPerSymbol] parameter is used to determine whether - /// the width of each tape should be interpolated between the width of the - /// old and new text as the symbols roll or if the width should interpolate - /// directly between the starting and ending texts. - /// /// The [fixedTapeWidth] parameter 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 @@ -78,14 +73,14 @@ extension TextEffectExt on Text { /// width animation of each tape. /// If null, the same curve is used as the one provided to the [animate] /// function. - Widget roll( - String newText, { + Widget roll({ EdgeInsets padding = EdgeInsets.zero, SymbolTapeStrategy tapeStrategy = const ConsistentSymbolTapeStrategy(0), Curve? tapeCurve, - TapeSlideDirection tapeSlideDirection = TapeSlideDirection.up, - bool staggerTapes = true, + TextTapeSlideDirection tapeSlideDirection = TextTapeSlideDirection.up, int staggerSoftness = 10, + double strutHeight = 1, + bool staggerTapes = true, bool reverseStaggerDirection = false, Clip clipBehavior = Clip.hardEdge, double symbolDistanceMultiplier = 1, @@ -102,7 +97,7 @@ extension TextEffectExt on Text { 'staggerSoftness must be greater than 0.', ); assert( - !(data ?? '').contains('\n') && !newText.contains('\n'), + !(data ?? '').contains('\n'), 'Rolling text effect does not support multiline text.', ); @@ -114,8 +109,7 @@ extension TextEffectExt on Text { return EffectWidget( end: RollingTextEffect( - oldText: data ?? '', - newText: newText, + text: data ?? '', padding: padding, tapeStrategy: tapeStrategy, tapeCurve: tapeCurve, @@ -130,7 +124,7 @@ extension TextEffectExt on Text { widthCurve: widthCurve, strutStyle: StrutStyle( fontSize: effectiveStyle.fontSize, - height: 1, + height: strutHeight, forceStrutHeight: true, leading: symbolDistanceMultiplier - 1, ), diff --git a/lib/src/effects/rotation_effect.dart b/lib/src/effects/rotation_effect.dart index 1fbd424..e2e1c78 100644 --- a/lib/src/effects/rotation_effect.dart +++ b/lib/src/effects/rotation_effect.dart @@ -1,3 +1,4 @@ +import 'dart:math'; import 'dart:ui'; import 'package:flutter/widgets.dart'; @@ -30,6 +31,32 @@ extension RotationEffectExt on Widget { child: this, ); } + + /// Applies a [RotationEffect] to a [Widget] to rotate it in. + Widget rotateIn({ + double? from, + Offset origin = Offset.zero, + AlignmentGeometry alignment = Alignment.center, + }) { + return EffectWidget( + start: RotationEffect(angle: from ?? -pi / 2), + end: RotationEffect(angle: 0, origin: origin, alignment: alignment), + child: this, + ); + } + + /// Applies a [RotationEffect] to a [Widget] to rotate it out. + Widget rotateOut({ + double? to, + Offset origin = Offset.zero, + AlignmentGeometry alignment = Alignment.center, + }) { + return EffectWidget( + start: RotationEffect(angle: 0, origin: origin, alignment: alignment), + end: RotationEffect(angle: to ?? pi / 2), + child: this, + ); + } } /// An [Effect] that applies a rotation to a [Widget]. @@ -63,7 +90,7 @@ class RotationEffect extends Effect { } @override - Widget apply(BuildContext context, Widget child) { + Widget apply(BuildContext context, Widget? child) { return Transform.rotate( angle: angle, alignment: alignment, @@ -72,6 +99,9 @@ class RotationEffect extends Effect { ); } + @override + RotationEffect idle() => const RotationEffect(); + @override List get props => [angle, origin, alignment]; } diff --git a/lib/src/effects/scale_effect.dart b/lib/src/effects/scale_effect.dart index 7296908..52f9c59 100644 --- a/lib/src/effects/scale_effect.dart +++ b/lib/src/effects/scale_effect.dart @@ -19,13 +19,20 @@ extension ScaleEffectExt on Widget { AlignmentGeometry alignment = Alignment.center, Offset origin = Offset.zero, double? from, + bool transformHitTests = true, }) { return EffectWidget( - start: from == null ? null : ScaleEffect(scale: from), + start: from == null + ? null + : ScaleEffect( + scale: from, + transformHitTests: transformHitTests, + ), end: ScaleEffect( scale: scale, alignment: alignment, origin: origin, + transformHitTests: transformHitTests, ), child: this, ); @@ -43,13 +50,20 @@ extension ScaleEffectExt on Widget { AlignmentGeometry alignment = Alignment.center, Offset origin = Offset.zero, double? from, + bool transformHitTests = true, }) { return EffectWidget( - start: from == null ? null : ScaleEffect(scaleX: from), + start: from == null + ? null + : ScaleEffect( + scaleX: from, + transformHitTests: transformHitTests, + ), end: ScaleEffect( scaleX: scaleX, alignment: alignment, origin: origin, + transformHitTests: transformHitTests, ), child: this, ); @@ -67,13 +81,20 @@ extension ScaleEffectExt on Widget { AlignmentGeometry alignment = Alignment.center, Offset origin = Offset.zero, double? from, + bool transformHitTests = true, }) { return EffectWidget( - start: from == null ? null : ScaleEffect(scaleY: from), + start: from == null + ? null + : ScaleEffect( + scaleY: from, + transformHitTests: transformHitTests, + ), end: ScaleEffect( scaleY: scaleY, alignment: alignment, origin: origin, + transformHitTests: transformHitTests, ), child: this, ); @@ -93,15 +114,22 @@ extension ScaleEffectExt on Widget { AlignmentGeometry alignment = Alignment.center, Offset origin = Offset.zero, Offset? from, + bool transformHitTests = true, }) { return EffectWidget( - start: - from == null ? null : ScaleEffect(scaleX: from.dx, scaleY: from.dy), + start: from == null + ? null + : ScaleEffect( + scaleX: from.dx, + scaleY: from.dy, + transformHitTests: transformHitTests, + ), end: ScaleEffect( scaleX: scaleX, scaleY: scaleY, alignment: alignment, origin: origin, + transformHitTests: transformHitTests, ), child: this, ); @@ -114,11 +142,21 @@ extension ScaleEffectExt on Widget { double? end, AlignmentGeometry alignment = Alignment.center, Offset origin = Offset.zero, + bool transformHitTests = true, }) { return EffectWidget( - start: - ScaleEffect(scale: start ?? 0, alignment: alignment, origin: origin), - end: ScaleEffect(scale: end ?? 1, alignment: alignment, origin: origin), + start: ScaleEffect( + scale: start ?? 0, + alignment: alignment, + origin: origin, + transformHitTests: transformHitTests, + ), + end: ScaleEffect( + scale: end ?? 1, + alignment: alignment, + origin: origin, + transformHitTests: transformHitTests, + ), child: this, ); } @@ -130,11 +168,21 @@ extension ScaleEffectExt on Widget { double? end, AlignmentGeometry alignment = Alignment.center, Offset origin = Offset.zero, + bool transformHitTests = true, }) { return EffectWidget( - start: - ScaleEffect(scale: start ?? 1, alignment: alignment, origin: origin), - end: ScaleEffect(scale: end ?? 0, alignment: alignment, origin: origin), + start: ScaleEffect( + scale: start ?? 1, + alignment: alignment, + origin: origin, + transformHitTests: transformHitTests, + ), + end: ScaleEffect( + scale: end ?? 0, + alignment: alignment, + origin: origin, + transformHitTests: transformHitTests, + ), child: this, ); } @@ -163,6 +211,9 @@ class ScaleEffect extends Effect { /// (relative to the upper left corner of this render object) final Offset origin; + /// Whether to apply the transformation when performing hit tests. + final bool transformHitTests; + /// Creates a [ScaleEffect]. ScaleEffect({ this.scale, @@ -170,6 +221,7 @@ class ScaleEffect extends Effect { this.scaleY, this.alignment = Alignment.center, this.origin = Offset.zero, + this.transformHitTests = true, }) : assert(scale != null || scaleX != null || scaleY != null, 'At least one of scale, scaleX, or scaleY must be non-null'); @@ -187,6 +239,8 @@ class ScaleEffect extends Effect { scale: effectiveScale, alignment: effectiveAlignment, origin: effectiveOrigin, + transformHitTests: + value < 0.5 ? transformHitTests : other.transformHitTests, ); } @@ -197,19 +251,25 @@ class ScaleEffect extends Effect { scaleY: effectiveScaleY, alignment: effectiveAlignment, origin: effectiveOrigin, + transformHitTests: + value < 0.5 ? transformHitTests : other.transformHitTests, ); } @override - Widget apply(BuildContext context, Widget child) => Transform.scale( + Widget apply(BuildContext context, Widget? child) => Transform.scale( scale: scale, scaleX: scaleX, scaleY: scaleY, alignment: alignment, origin: origin, + transformHitTests: transformHitTests, child: child, ); + @override + ScaleEffect idle() => ScaleEffect(scale: 1); + @override List get props => [ scale, @@ -217,5 +277,6 @@ class ScaleEffect extends Effect { scaleY, alignment, origin, + transformHitTests, ]; } diff --git a/lib/src/effects/shake_effect.dart b/lib/src/effects/shake_effect.dart index 098aaac..714ba01 100644 --- a/lib/src/effects/shake_effect.dart +++ b/lib/src/effects/shake_effect.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'dart:ui'; import 'package:flutter/material.dart'; + import '../../hyper_effects.dart'; /// Provides a extension method to apply a [ShakeEffect] to a [Widget]. @@ -57,7 +58,7 @@ class ShakeEffect extends Effect { } @override - Widget apply(BuildContext context, Widget child) { + Widget apply(BuildContext context, Widget? child) { final effectQuery = EffectQuery.maybeOf(context); final value = effectQuery?.curvedValue ?? 1; final duration = effectQuery?.duration ?? Duration.zero; @@ -74,6 +75,9 @@ class ShakeEffect extends Effect { ); } + @override + ShakeEffect idle() => const ShakeEffect(); + @override List get props => [frequency, offset, rotation]; } diff --git a/lib/src/effects/skew_effect.dart b/lib/src/effects/skew_effect.dart index e3d9f59..3cea809 100644 --- a/lib/src/effects/skew_effect.dart +++ b/lib/src/effects/skew_effect.dart @@ -155,7 +155,7 @@ class SkewEffect extends Effect { final Offset origin; /// Creates a [SkewEffect]. - SkewEffect({ + const SkewEffect({ this.skew, this.skewX, this.skewY, @@ -180,7 +180,7 @@ class SkewEffect extends Effect { ); @override - Widget apply(BuildContext context, Widget child) { + Widget apply(BuildContext context, Widget? child) { return Transform( transform: Matrix4.skew( skewX ?? skew ?? 0, @@ -192,6 +192,9 @@ class SkewEffect extends Effect { ); } + @override + SkewEffect idle() => SkewEffect(); + @override List get props => [skew, skewX, skewY, alignment, origin]; } diff --git a/lib/src/effects/transform_effect.dart b/lib/src/effects/transform_effect.dart index 1a0313d..7fafea3 100644 --- a/lib/src/effects/transform_effect.dart +++ b/lib/src/effects/transform_effect.dart @@ -133,7 +133,7 @@ class TransformEffect extends Effect { } @override - Widget apply(BuildContext context, Widget child) { + Widget apply(BuildContext context, Widget? child) { final matrix = Matrix4.identity(); if (depth > 0) { matrix.setEntry(3, 2, depth); @@ -152,6 +152,9 @@ class TransformEffect extends Effect { ); } + @override + TransformEffect idle() => TransformEffect(); + @override List get props => [ translateX, diff --git a/lib/src/effects/translate_effect.dart b/lib/src/effects/translate_effect.dart index 79f2168..29c7e03 100644 --- a/lib/src/effects/translate_effect.dart +++ b/lib/src/effects/translate_effect.dart @@ -13,6 +13,7 @@ extension TranslateEffectExt on Widget { Widget translate( Offset offset, { bool fractional = false, + bool transformHitTests = false, Offset? from, }) { return EffectWidget( @@ -21,10 +22,12 @@ extension TranslateEffectExt on Widget { : TranslateEffect( offset: from, fractional: fractional, + transformHitTests: transformHitTests, ), end: TranslateEffect( offset: offset, fractional: fractional, + transformHitTests: transformHitTests, ), child: this, ); @@ -36,6 +39,7 @@ extension TranslateEffectExt on Widget { Widget translateX( double x, { bool fractional = false, + bool transformHitTests = false, double? from, }) { return EffectWidget( @@ -44,10 +48,12 @@ extension TranslateEffectExt on Widget { : TranslateEffect( offset: Offset(from, 0), fractional: fractional, + transformHitTests: transformHitTests, ), end: TranslateEffect( offset: Offset(x, 0), fractional: fractional, + transformHitTests: transformHitTests, ), child: this, ); @@ -59,6 +65,7 @@ extension TranslateEffectExt on Widget { Widget translateY( double y, { bool fractional = false, + bool transformHitTests = false, double? from, }) { return EffectWidget( @@ -67,10 +74,12 @@ extension TranslateEffectExt on Widget { : TranslateEffect( offset: Offset(0, from), fractional: fractional, + transformHitTests: transformHitTests, ), end: TranslateEffect( offset: Offset(0, y), fractional: fractional, + transformHitTests: transformHitTests, ), child: this, ); @@ -84,6 +93,7 @@ extension TranslateEffectExt on Widget { double x, double y, { bool fractional = false, + bool transformHitTests = false, Offset? from, }) { return EffectWidget( @@ -92,10 +102,12 @@ extension TranslateEffectExt on Widget { : TranslateEffect( offset: from, fractional: fractional, + transformHitTests: transformHitTests, ), end: TranslateEffect( offset: Offset(x, y), fractional: fractional, + transformHitTests: transformHitTests, ), child: this, ); @@ -103,15 +115,65 @@ extension TranslateEffectExt on Widget { /// Applies a [TranslateEffect] to a [Widget] with a default animation /// to slide this widget in. - Widget slideIn(Offset offset, {bool fractional = false}) { + Widget slideIn( + Offset offset, { + bool fractional = false, + bool transformHitTests = false, + }) { return EffectWidget( start: TranslateEffect( offset: offset, fractional: fractional, + transformHitTests: transformHitTests, + ), + end: TranslateEffect( + offset: Offset.zero, + fractional: fractional, + transformHitTests: transformHitTests, + ), + child: this, + ); + } + + /// Applies a [TranslateEffect] to a [Widget] with a default animation + /// to slide this widget in. + Widget slideInVertically( + double y, { + bool fractional = false, + bool transformHitTests = false, + }) { + return EffectWidget( + start: TranslateEffect( + offset: Offset(0, y), + fractional: fractional, + transformHitTests: transformHitTests, ), end: TranslateEffect( offset: Offset.zero, fractional: fractional, + transformHitTests: transformHitTests, + ), + child: this, + ); + } + + /// Applies a [TranslateEffect] to a [Widget] with a default animation + /// to slide this widget in. + Widget slideInHorizontally( + double x, { + bool fractional = false, + bool transformHitTests = false, + }) { + return EffectWidget( + start: TranslateEffect( + offset: Offset(x, 0), + fractional: fractional, + transformHitTests: transformHitTests, + ), + end: TranslateEffect( + offset: Offset.zero, + fractional: fractional, + transformHitTests: transformHitTests, ), child: this, ); @@ -119,15 +181,65 @@ extension TranslateEffectExt on Widget { /// Applies a [TranslateEffect] to a [Widget] with a default animation /// to slide this widget out. - Widget slideOut(Offset offset, {bool fractional = false}) { + Widget slideOut( + Offset offset, { + bool fractional = false, + bool transformHitTests = false, + }) { return EffectWidget( start: TranslateEffect( offset: Offset.zero, fractional: fractional, + transformHitTests: transformHitTests, ), end: TranslateEffect( offset: offset, fractional: fractional, + transformHitTests: transformHitTests, + ), + child: this, + ); + } + + /// Applies a [TranslateEffect] to a [Widget] with a default animation + /// to slide this widget out. + Widget slideOutVertically( + double y, { + bool fractional = false, + bool transformHitTests = false, + }) { + return EffectWidget( + start: TranslateEffect( + offset: Offset.zero, + fractional: fractional, + transformHitTests: transformHitTests, + ), + end: TranslateEffect( + offset: Offset(0, y), + fractional: fractional, + transformHitTests: transformHitTests, + ), + child: this, + ); + } + + /// Applies a [TranslateEffect] to a [Widget] with a default animation + /// to slide this widget out. + Widget slideOutHorizontally( + double x, { + bool fractional = false, + bool transformHitTests = false, + }) { + return EffectWidget( + start: TranslateEffect( + offset: Offset.zero, + fractional: fractional, + transformHitTests: transformHitTests, + ), + end: TranslateEffect( + offset: Offset(x, 0), + fractional: fractional, + transformHitTests: transformHitTests, ), child: this, ); @@ -138,10 +250,12 @@ extension TranslateEffectExt on Widget { Widget slideInFromLeft({ double? value, bool fractional = false, + bool transformHitTests = false, }) => slideIn( Offset(value ?? (fractional ? -1 : -_kDefaultSlideOffset), 0), fractional: fractional, + transformHitTests: transformHitTests, ); /// Applies a [TranslateEffect] to a [Widget] with a default animation @@ -149,10 +263,12 @@ extension TranslateEffectExt on Widget { Widget slideInFromRight({ double? value, bool fractional = false, + bool transformHitTests = false, }) => slideIn( Offset(value ?? (fractional ? 1 : _kDefaultSlideOffset), 0), fractional: fractional, + transformHitTests: transformHitTests, ); /// Applies a [TranslateEffect] to a [Widget] with a default animation @@ -160,10 +276,12 @@ extension TranslateEffectExt on Widget { Widget slideInFromTop({ double? value, bool fractional = false, + bool transformHitTests = false, }) => slideIn( Offset(0, value ?? (fractional ? -1 : -_kDefaultSlideOffset)), fractional: fractional, + transformHitTests: transformHitTests, ); /// Applies a [TranslateEffect] to a [Widget] with a default animation @@ -171,10 +289,12 @@ extension TranslateEffectExt on Widget { Widget slideInFromBottom({ double? value, bool fractional = false, + bool transformHitTests = false, }) => slideIn( Offset(0, value ?? (fractional ? 1 : _kDefaultSlideOffset)), fractional: fractional, + transformHitTests: transformHitTests, ); /// Applies a [TranslateEffect] to a [Widget] with a default animation @@ -182,10 +302,12 @@ extension TranslateEffectExt on Widget { Widget slideOutToLeft({ double? value, bool fractional = false, + bool transformHitTests = false, }) => slideOut( Offset(value ?? (fractional ? -1 : -_kDefaultSlideOffset), 0), fractional: fractional, + transformHitTests: transformHitTests, ); /// Applies a [TranslateEffect] to a [Widget] with a default animation @@ -193,10 +315,12 @@ extension TranslateEffectExt on Widget { Widget slideOutToRight({ double? value, bool fractional = false, + bool transformHitTests = false, }) => slideOut( Offset(value ?? (fractional ? 1 : _kDefaultSlideOffset), 0), fractional: fractional, + transformHitTests: transformHitTests, ); /// Applies a [TranslateEffect] to a [Widget] with a default animation @@ -204,10 +328,12 @@ extension TranslateEffectExt on Widget { Widget slideOutToTop({ double? value, bool fractional = false, + bool transformHitTests = false, }) => slideOut( Offset(0, value ?? (fractional ? -1 : -_kDefaultSlideOffset)), fractional: fractional, + transformHitTests: transformHitTests, ); /// Applies a [TranslateEffect] to a [Widget] with a default animation @@ -215,10 +341,12 @@ extension TranslateEffectExt on Widget { Widget slideOutToBottom({ double? value, bool fractional = false, + bool transformHitTests = false, }) => slideOut( Offset(0, value ?? (fractional ? 1 : _kDefaultSlideOffset)), fractional: fractional, + transformHitTests: transformHitTests, ); } @@ -231,34 +359,50 @@ 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. + final bool transformHitTests; + /// Creates a [TranslateEffect] with the given [offset] and [fractional]. TranslateEffect({ this.offset = Offset.zero, this.fractional = false, + this.transformHitTests = false, }); @override TranslateEffect lerp(covariant TranslateEffect other, double value) { return TranslateEffect( + fractional: other.fractional, + transformHitTests: + value < 0.5 ? transformHitTests : other.transformHitTests, offset: Offset.lerp(offset, other.offset, value) ?? Offset.zero, ); } @override - Widget apply(BuildContext context, Widget child) { + Widget apply(BuildContext context, Widget? child) { if (fractional) { return FractionalTranslation( translation: offset, + transformHitTests: transformHitTests, child: child, ); } else { return Transform.translate( offset: offset, + transformHitTests: transformHitTests, child: child, ); } } @override - List get props => [offset, fractional]; + TranslateEffect idle() => TranslateEffect( + offset: Offset.zero, + fractional: fractional, + transformHitTests: transformHitTests, + ); + + @override + List get props => [offset, fractional, transformHitTests]; } diff --git a/lib/src/extensions.dart b/lib/src/extensions.dart index 1c061f5..25aa9e7 100644 --- a/lib/src/extensions.dart +++ b/lib/src/extensions.dart @@ -24,3 +24,9 @@ extension ScrollPositionExt on ScrollPosition { /// Returns true if the scroll position is at the end of the scroll view. bool get atEnd => pixels == maxScrollExtent; } + +/// Extension methods for [double]. +extension DoubleHelper on double? { + /// Clamps the value to be at least 0. + double get clampUnderZero => (this ?? 0) < 0 ? 0 : this!; +} diff --git a/lib/src/pointer_transition.dart b/lib/src/pointer_transition.dart index 1690a01..aa50ca3 100644 --- a/lib/src/pointer_transition.dart +++ b/lib/src/pointer_transition.dart @@ -1,8 +1,6 @@ import 'package:flutter/widgets.dart'; -import 'post_frame_widget.dart'; -import 'apple_curves.dart'; -import 'effect_query.dart'; +import '../hyper_effects.dart'; /// Represents the pointer event for [PointerTransition]. class PointerTransitionEvent { @@ -52,19 +50,25 @@ extension PointerTransitionExt on Widget { 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, + AnimationBehavior? animationBehavior, }) { return PointerTransition( + key: key, builder: builder, origin: origin, useGlobalPointer: useGlobalPointer, + usePointerRouter: usePointerRouter, transitionBetweenBounds: transitionBetweenBounds, resetOnExitBounds: resetOnExitBounds, curve: curve, duration: duration, + animationBehavior: animationBehavior, child: this, ); } @@ -87,7 +91,7 @@ class PointerTransition extends StatefulWidget { /// Decides whether this transition calculates the value based on the global /// position of the pointer device or the local position of the pointer - /// device. + /// device relative to the widget's box. final bool useGlobalPointer; /// Decides whether this transition should transition between when the pointer @@ -99,6 +103,17 @@ class PointerTransition extends StatefulWidget { /// the widget. final bool resetOnExitBounds; + /// Whether this transition should rely on + /// [WidgetsBinding.instance.pointerRouter.addGlobalRoute] to read + /// the pointer device's position, which is useful for reading cursor + /// events that may not be read properly via [MouseRegion]s like if the + /// cursor is outside the bounds of the physical window or applies some + /// unusual gesture that may not be normally detected. + /// + /// If set to false, a traditional [MouseRegion] is used to read + /// the pointer device's position. + final bool usePointerRouter; + /// The child widget to apply the effects to. final Widget child; @@ -110,6 +125,10 @@ class PointerTransition extends StatefulWidget { /// about to reset. final Duration duration; + /// The behavior of the controller when + /// [AccessibilityFeatures.disableAnimations] is true. + final AnimationBehavior? animationBehavior; + /// Creates a new [PointerTransition] with the given [builder], [origin], /// [useGlobalPointer], [child], [curve], and [duration]. const PointerTransition({ @@ -119,9 +138,11 @@ class PointerTransition extends StatefulWidget { this.useGlobalPointer = false, this.transitionBetweenBounds = true, this.resetOnExitBounds = true, + this.usePointerRouter = true, this.builder, this.curve = appleEaseInOut, this.duration = const Duration(milliseconds: 125), + this.animationBehavior, }); @override @@ -133,9 +154,12 @@ class _PointerTransitionState extends State late final AnimationController _controller = AnimationController( vsync: this, duration: widget.duration, + animationBehavior: widget.animationBehavior ?? + HyperEffectsAnimationConfig.maybeOf(context)?.animationBehavior ?? + AnimationBehavior.normal, ); - late Animation _animation = CurvedAnimation( + late CurvedAnimation _animation = CurvedAnimation( parent: _controller, curve: widget.curve, ); @@ -174,7 +198,10 @@ class _PointerTransitionState extends State @override void initState() { super.initState(); - WidgetsBinding.instance.pointerRouter.addGlobalRoute(updateState); + if (widget.usePointerRouter) { + WidgetsBinding.instance.pointerRouter.addGlobalRoute(updateState); + } + _controller.addListener(animationListener); } @@ -187,11 +214,20 @@ class _PointerTransitionState extends State } if (widget.curve != oldWidget.curve) { + _animation.dispose(); _animation = CurvedAnimation( parent: _controller, curve: widget.curve, ); } + + if (oldWidget.usePointerRouter != widget.usePointerRouter) { + if (widget.usePointerRouter) { + WidgetsBinding.instance.pointerRouter.addGlobalRoute(updateState); + } else { + WidgetsBinding.instance.pointerRouter.removeGlobalRoute(updateState); + } + } } void animationListener() { @@ -206,9 +242,13 @@ class _PointerTransitionState extends State @override void dispose() { + _animation.dispose(); _controller.removeListener(animationListener); _controller.dispose(); - WidgetsBinding.instance.pointerRouter.removeGlobalRoute(updateState); + + if (widget.usePointerRouter) { + WidgetsBinding.instance.pointerRouter.removeGlobalRoute(updateState); + } super.dispose(); } @@ -343,7 +383,7 @@ class _PointerTransitionState extends State @override Widget build(BuildContext context) { - final Widget child = widget.builder?.call( + Widget child = widget.builder?.call( context, widget.child, PointerTransitionEvent( @@ -355,21 +395,27 @@ class _PointerTransitionState extends State ) ?? widget.child; - return PostFrame( - child: AnimatedBuilder( - animation: _animation, - builder: (context, child) => EffectQuery( - curvedValue: currentValue, - linearValue: currentValue, - isTransition: true, - lerpValues: false, - child: KeyedSubtree( - key: _key, - child: child!, - ), - ), + if (!widget.usePointerRouter) { + child = MouseRegion( + onHover: (event) => updateState(event), + onExit: (event) => resetValue(), child: child, + ); + } + + return AnimatedBuilder( + animation: _animation, + builder: (context, child) => EffectQuery( + curvedValue: currentValue, + linearValue: currentValue, + isTransition: true, + lerpValues: false, + child: KeyedSubtree( + key: _key, + child: child!, + ), ), + child: child, ); } } diff --git a/lib/src/scroll_transition.dart b/lib/src/scroll_transition.dart index 50e31f7..7112e75 100644 --- a/lib/src/scroll_transition.dart +++ b/lib/src/scroll_transition.dart @@ -27,11 +27,47 @@ class ScrollTransitionEvent { /// towards -1. It clamps to -1 when the item is fully out of the scroll view. final double screenOffsetFraction; + /// The number of pixels scrolled inside of the parent scroll view. + final double? scrollPixels; + + /// The height or width of the parent scroll view. + final double? viewportSize; + + /// The change in scroll position since the last update. + final double scrollDelta; + + /// The direction the scroll view is being scrolled to. + final AxisDirection scrollDirection; + + /// The position of the pointer device in the global coordinate space. + final Offset pointerPosition; + + /// The distance from the pointer device to the center of this widget. + final Offset distanceFromPointer; + + /// 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. + final int visualIndex; + + /// The [visualIndex] calculated in the opposite direction. + final int reverseVisualIndex; + /// Creates a [ScrollTransitionEvent]. ScrollTransitionEvent({ required this.phase, required this.phaseOffsetFraction, required this.screenOffsetFraction, + required this.scrollPixels, + required this.viewportSize, + required this.scrollDelta, + required this.scrollDirection, + required this.pointerPosition, + required this.distanceFromPointer, + required this.visualIndex, + required this.reverseVisualIndex, }); } @@ -64,11 +100,23 @@ class ScrollTransition extends StatefulWidget { /// The child widget to apply the effects to. final Widget child; + /// Whether this transition should rely on + /// [WidgetsBinding.instance.pointerRouter.addGlobalRoute] to read + /// the pointer device's position, which is useful for reading cursor + /// events that may not be read properly via [MouseRegion]s like if the + /// cursor is outside the bounds of the physical window or applies some + /// unusual gesture that may not be normally detected. + /// + /// If set to false, a traditional [MouseRegion] is used to read + /// the pointer device's position. + final bool usePointerRouter; + /// Creates a new [ScrollTransition] with the given [builder] and [child]. const ScrollTransition({ super.key, required this.child, this.builder, + this.usePointerRouter = true, }); @override @@ -93,6 +141,38 @@ class _ScrollTransitionState extends State { /// animation through the parent viewport. double screenOffsetFraction = 0; + /// Keeps track of the last scroll position in pixels. + /// This is used to calculate the [_scrollDelta]. + double? _lastScrollPixels; + + /// The change in scroll position since the last update. + double _scrollDelta = 0; + + /// The position of the pointer device in the global coordinate space. + Offset _pointerPosition = Offset.zero; + + /// The distance from the pointer device that was used to trigger the + /// scroll transition effect to the center of this widget. + Offset _distanceFromPointer = Offset.zero; + + /// 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. + int _visualIndex = 0; + + /// The visual index calculated in the opposite direction. + int _reverseVisualIndex = 0; + + @override + void initState() { + super.initState(); + if (widget.usePointerRouter) { + WidgetsBinding.instance.pointerRouter.addGlobalRoute(updateState); + } + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -104,14 +184,36 @@ class _ScrollTransitionState extends State { } } + @override + void didUpdateWidget(covariant ScrollTransition oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.usePointerRouter != widget.usePointerRouter) { + if (widget.usePointerRouter) { + WidgetsBinding.instance.pointerRouter.addGlobalRoute(updateState); + } else { + WidgetsBinding.instance.pointerRouter.removeGlobalRoute(updateState); + } + } + } + @override void dispose() { scrollPosition?.removeListener(onScrollChanged); scrollPosition = null; scrollable = null; + + if (widget.usePointerRouter) { + WidgetsBinding.instance.pointerRouter.removeGlobalRoute(updateState); + } + super.dispose(); } + void updateState(PointerEvent event) { + _pointerPosition = event.position; + } + /// Calculates the current phase of the scroll animation. /// Returns a record of the current phase and it's associated progress /// through the viewport as a value between 0 and 1. @@ -192,11 +294,34 @@ class _ScrollTransitionState extends State { /// If the [onChanged] callback is provided, it is called if the phase or /// value has changed. void updateCurrentState() { + if (!mounted) return; + final (:phase, :phaseOffsetFraction, :screenOffsetFraction) = calculatePhase(); this.phase = phase; this.phaseOffsetFraction = phaseOffsetFraction; this.screenOffsetFraction = screenOffsetFraction; + final double currentPixels = + scrollPosition?.hasPixels == true ? scrollPosition!.pixels : 0.0; + _scrollDelta = + _lastScrollPixels != null ? currentPixels - _lastScrollPixels! : 0; + _lastScrollPixels = currentPixels; + _distanceFromPointer = _pointerPosition - context.globalPaintBounds!.center; + + final distanceToTopOfView = context.globalPaintBounds!.top - + scrollable!.context.globalPaintBounds!.top; + final distanceToBottomOfView = context.globalPaintBounds!.bottom - + scrollable!.context.globalPaintBounds!.bottom; + final visualIndex = + (distanceToTopOfView / context.globalPaintBounds!.height).ceil(); + final reverseVisualIndex = + (distanceToBottomOfView / context.globalPaintBounds!.height).ceil(); + if (visualIndex != _visualIndex) { + _visualIndex = visualIndex; + } + if (reverseVisualIndex != _reverseVisualIndex) { + _reverseVisualIndex = reverseVisualIndex; + } if (mounted) setState(() {}); } @@ -220,6 +345,19 @@ class _ScrollTransitionState extends State { phase: phase, phaseOffsetFraction: phaseOffsetFraction, screenOffsetFraction: screenOffsetFraction, + scrollPixels: scrollPosition?.hasPixels == true + ? scrollPosition!.pixels + : null, + viewportSize: scrollPosition?.hasViewportDimension == true + ? scrollPosition!.viewportDimension + : null, + scrollDelta: _scrollDelta, + scrollDirection: + scrollPosition?.axisDirection ?? AxisDirection.down, + pointerPosition: _pointerPosition, + distanceFromPointer: _distanceFromPointer, + visualIndex: _visualIndex, + reverseVisualIndex: _reverseVisualIndex, )) ?? widget.child; } else { diff --git a/pubspec.yaml b/pubspec.yaml index fa7c226..45084ab 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,13 +18,14 @@ environment: dependencies: flutter: sdk: flutter - equatable: ^2.0.5 - unicode_emojis: ^0.3.0 + equatable: ^2.0.7 + collection: ">=1.15.0 <2.0.0" + meta: ">=1.11.0 <2.0.0" dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^3.0.1 + flutter_lints: ^5.0.0 flutter: