From e1dc162f65f8cb01861ada804c9a797285c83915 Mon Sep 17 00:00:00 2001 From: hm21 Date: Thu, 10 Sep 2026 12:48:29 +0200 Subject: [PATCH 1/3] feat(layers): let a layer slide animation start from a point of its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A slide could only travel to and from a canvas edge, named by `slideDirection`. `LayerAnimation.slideFrom` now takes a start point instead: the layer's position in the same coordinates as `Layer.offset`, measured from the center of the editor canvas. It may sit outside the canvas, and it overrides `slideDirection` when both are set, so nothing existing changes. The start point and the layer's resting place are both anchor points, so their difference is the distance travelled — the layer's own size cancels out, and with it the fractional component the edge-aware slide needs to clear the border. The preview therefore composes the point slide as a plain pixel translation. This mirrors `pro_video_editor`'s feature of the same name, so a layer timeline built here keeps matching the exported video. The one difference is the origin: `pro_video_editor` measures its `slideFrom` from the video frame's top-left corner because that is where its own layer offsets start, while this one follows `Layer.offset` and measures from the canvas center. `slideFrom` is serialized as `{dx, dy}` and carried through `copyWith`, equality and `toString`. Parsing it reuses a new `safeParseOffset`, the sibling of the existing `safeParseSize`. --- CHANGELOG.md | 3 + lib/core/models/layers/layer_animation.dart | 70 ++++++++++-- lib/shared/utils/parser/offset_parser.dart | 39 +++++++ .../layer/layer_timeline_visibility.dart | 16 ++- pubspec.yaml | 2 +- .../models/layers/layer_animation_test.dart | 103 ++++++++++++++++++ .../utils/parser/offset_parser_test.dart | 74 +++++++++++++ .../layer_timeline_visibility_test.dart | 94 ++++++++++++++++ 8 files changed, 391 insertions(+), 10 deletions(-) create mode 100644 lib/shared/utils/parser/offset_parser.dart create mode 100644 test/shared/utils/parser/offset_parser_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e1d99773e..b823e94de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 14.1.0 +- **FEAT**(layers): `LayerAnimation.slideFrom` gives a slide animation its own start point instead of a canvas edge. It takes the layer's position in the same coordinates as `Layer.offset`, may sit outside the canvas, and overrides `slideDirection` when both are set. Mirrors the `pro_video_editor` feature of the same name. + ## 14.0.0 - **BREAKING**(ui): Migrate from `package:flutter/material.dart` and `package:flutter/cupertino.dart` to the standalone [`material_ui`](https://pub.dev/packages/material_ui) and [`cupertino_ui`](https://pub.dev/packages/cupertino_ui) packages, which Flutter is decoupling from the SDK. Apps must migrate their own imports as well (`dart fix --apply --code=migrate_design_widgets`), because types such as `ThemeData` are no longer interchangeable with the SDK ones. diff --git a/lib/core/models/layers/layer_animation.dart b/lib/core/models/layers/layer_animation.dart index ad976526c..a32ce183f 100644 --- a/lib/core/models/layers/layer_animation.dart +++ b/lib/core/models/layers/layer_animation.dart @@ -1,3 +1,8 @@ +import 'dart:ui' show Offset; + +import '/shared/utils/parser/offset_parser.dart'; +import 'layer.dart'; + /// The type of animation to apply to a [Layer]. enum LayerAnimationType { /// Fade opacity from 0 to 1 (in) or 1 to 0 (out). @@ -82,10 +87,11 @@ enum AnimationPhase { /// A single animation applied to a [Layer] on the video timeline. /// -/// Multiple animations can be combined on one layer, e.g. a [fade] in together -/// with a [slide] in from the left. This model mirrors the `LayerAnimation` -/// model in the sister package `pro_video_editor`, so the in-editor video -/// timeline preview matches the exported result. +/// Multiple animations can be combined on one layer, e.g. a +/// [LayerAnimationType.fade] in together with a [LayerAnimationType.slide] in +/// from the left. This model mirrors the `LayerAnimation` model in the sister +/// package `pro_video_editor`, so the in-editor video timeline preview matches +/// the exported result. /// /// Example: /// ```dart @@ -109,6 +115,19 @@ enum AnimationPhase { /// ], /// ) /// ``` +/// +/// A slide can start from a point of your own instead of a canvas edge — the +/// layer below comes in diagonally from beyond the top-left corner: +/// +/// ```dart +/// LayerAnimation( +/// type: LayerAnimationType.slide, +/// phase: AnimationPhase.animateIn, +/// duration: Duration(milliseconds: 600), +/// slideFrom: Offset(-400, -400), +/// curve: AnimationCurve.easeOutCubic, +/// ) +/// ``` class LayerAnimation { /// Creates a [LayerAnimation]. const LayerAnimation({ @@ -117,10 +136,13 @@ class LayerAnimation { required this.duration, this.curve = AnimationCurve.linear, this.slideDirection, + this.slideFrom, this.scaleFrom, }) : assert( - type != LayerAnimationType.slide || slideDirection != null, - 'slideDirection is required for slide animations', + type != LayerAnimationType.slide || + slideDirection != null || + slideFrom != null, + 'slide animations need either a slideDirection or a slideFrom point', ); /// Creates a [LayerAnimation] from a serialized [map]. @@ -145,6 +167,9 @@ class LayerAnimation { _enumByName(AnimationCurve.values, map['curve']) ?? AnimationCurve.linear, slideDirection: _enumByName(SlideDirection.values, map['slideDirection']), + slideFrom: map['slideFrom'] is Map + ? safeParseOffset(Map.from(map['slideFrom'] as Map)) + : null, scaleFrom: (map['scaleFrom'] as num?)?.toDouble(), ); } @@ -176,9 +201,32 @@ class LayerAnimation { /// The direction for [LayerAnimationType.slide] animations. /// - /// Required when [type] is [LayerAnimationType.slide]. + /// The layer travels between its resting place and the canvas edge in this + /// direction, far enough to sit completely outside the canvas. + /// + /// Required when [type] is [LayerAnimationType.slide], unless [slideFrom] + /// names a start point instead. final SlideDirection? slideDirection; + /// A custom start point for [LayerAnimationType.slide] animations, in + /// canvas pixels. + /// + /// Uses the same coordinate system as [Layer.offset]: the layer's anchor + /// point measured from the center of the editor canvas. The layer starts + /// here and slides to its resting [Layer.offset] + /// ([AnimationPhase.animateIn]), or leaves its resting place for this point + /// ([AnimationPhase.animateOut]). With [AnimationPhase.animateInOut] the + /// point is both: the layer enters from it and leaves back towards it. + /// + /// Values may sit outside the canvas — on a 400×800 canvas + /// `Offset(-400, 0)` starts the layer 200px past the left edge. + /// + /// Note that the `pro_video_editor` counterpart measures its `slideFrom` + /// from the video frame's top-left corner, matching its own layer offsets. + /// + /// Overrides [slideDirection] when both are set. + final Offset? slideFrom; + /// The starting scale factor for [LayerAnimationType.scale] animations. /// /// Defaults to `0.0` (invisible) when not set. A value of `0.5` means the @@ -193,6 +241,9 @@ class LayerAnimation { 'durationUs': duration.inMicroseconds, 'curve': curve.name, 'slideDirection': slideDirection?.name, + 'slideFrom': slideFrom != null + ? {'dx': slideFrom!.dx, 'dy': slideFrom!.dy} + : null, 'scaleFrom': scaleFrom, }; } @@ -204,6 +255,7 @@ class LayerAnimation { Duration? duration, AnimationCurve? curve, SlideDirection? slideDirection, + Offset? slideFrom, double? scaleFrom, }) { return LayerAnimation( @@ -212,6 +264,7 @@ class LayerAnimation { duration: duration ?? this.duration, curve: curve ?? this.curve, slideDirection: slideDirection ?? this.slideDirection, + slideFrom: slideFrom ?? this.slideFrom, scaleFrom: scaleFrom ?? this.scaleFrom, ); } @@ -221,6 +274,7 @@ class LayerAnimation { return 'LayerAnimation(type: $type, phase: $phase, ' 'duration: $duration, curve: $curve' '${slideDirection != null ? ', slideDirection: $slideDirection' : ''}' + '${slideFrom != null ? ', slideFrom: $slideFrom' : ''}' '${scaleFrom != null ? ', scaleFrom: $scaleFrom' : ''})'; } @@ -232,6 +286,7 @@ class LayerAnimation { other.duration == duration && other.curve == curve && other.slideDirection == slideDirection && + other.slideFrom == slideFrom && other.scaleFrom == scaleFrom; } @@ -242,6 +297,7 @@ class LayerAnimation { duration.hashCode ^ curve.hashCode ^ slideDirection.hashCode ^ + slideFrom.hashCode ^ scaleFrom.hashCode; } } diff --git a/lib/shared/utils/parser/offset_parser.dart b/lib/shared/utils/parser/offset_parser.dart new file mode 100644 index 000000000..4b998232f --- /dev/null +++ b/lib/shared/utils/parser/offset_parser.dart @@ -0,0 +1,39 @@ +import 'package:flutter/widgets.dart'; +import 'double_parser.dart'; + +/// Safely parses a [Map] representation of a point to an [Offset] object. +/// +/// This function attempts to convert the provided [map] to an [Offset] object. +/// If the [map] is `null`, missing required keys (`dx` and `dy`), or contains +/// invalid values, a [fallback] offset is returned instead. +/// +/// - Parameters: +/// - [map]: A [Map] that is expected to contain `dx` and `dy` keys (or the +/// short forms `x` and `y`), where their values can be converted to +/// [double]. +/// - [fallback]: An [Offset] value to return if parsing fails or if [map] is +/// `null`. +/// Defaults to [Offset.zero] if not provided. +/// +/// - Returns: +/// An [Offset] object constructed from the [map] if parsing succeeds, or the +/// [fallback] offset if it fails. +/// +/// - Example: +/// ```dart +/// safeParseOffset({'dx': 200, 'dy': 100}); // returns Offset(200.0, 100.0) +/// safeParseOffset(null); // returns Offset.zero (fallback) +/// safeParseOffset({'dx': 'abc', 'dy': 50}, fallback: Offset(10, 10)); +/// // returns Offset(10.0, 50.0) +/// ``` +Offset safeParseOffset( + Map? map, { + Offset fallback = Offset.zero, +}) { + if (map == null) return fallback; + + return Offset( + safeParseDouble(map['dx'] ?? map['x'], fallback: fallback.dx), + safeParseDouble(map['dy'] ?? map['y'], fallback: fallback.dy), + ); +} diff --git a/lib/shared/widgets/layer/layer_timeline_visibility.dart b/lib/shared/widgets/layer/layer_timeline_visibility.dart index c1ea3b33e..f5f57fa18 100644 --- a/lib/shared/widgets/layer/layer_timeline_visibility.dart +++ b/lib/shared/widgets/layer/layer_timeline_visibility.dart @@ -25,7 +25,9 @@ import '/shared/utils/timeline_progress.dart'; /// express. The slide effect is edge-aware: using [canvasSize] and /// [layerCenter] it pushes the layer just past the nearest canvas edge (rather /// than by its own size), so even an off-center layer leaves the visible area -/// completely. The scale effect is anchored on the layer's visual center (via +/// completely. A [LayerAnimation.slideFrom] point replaces that edge with a +/// start position of the caller's own, measured like [Layer.offset]. The scale +/// effect is anchored on the layer's visual center (via /// [layerFractionalOffset]) so that a combined slide + scale enters straight /// instead of drifting diagonally. When [Layer.animations] is empty, the /// legacy fade convenience @@ -209,9 +211,19 @@ class _LayerTimelineVisibilityState extends State { case LayerAnimationType.fade: opacity *= progress; case LayerAnimationType.slide: + final invP = 1.0 - progress; + final from = anim.slideFrom; + if (from != null) { + // A start point of the caller's own wins over the edge the + // direction would otherwise pick. Both the point and the layer's + // resting place are measured like [Layer.offset], so their + // difference is the distance travelled — the layer's own size + // cancels out and no fractional part is needed. + slideAbsolute += (from - layer.offset) * invP; + break; + } final direction = anim.slideDirection; if (direction == null) break; - final invP = 1.0 - progress; final center = widget.layerCenter; final canvas = widget.canvasSize; // Edge-aware displacement D = invP × (absolute + fractional), where diff --git a/pubspec.yaml b/pubspec.yaml index a0888bd3e..8ddea2e8c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: pro_image_editor description: "A Flutter image editor: Seamlessly enhance your images with user-friendly editing features." -version: 14.0.0 +version: 14.1.0 homepage: https://github.com/hm21/pro_image_editor/ repository: https://github.com/hm21/pro_image_editor/ documentation: https://github.com/hm21/pro_image_editor/ diff --git a/test/core/models/layers/layer_animation_test.dart b/test/core/models/layers/layer_animation_test.dart index 30b5e097f..b3d05591a 100644 --- a/test/core/models/layers/layer_animation_test.dart +++ b/test/core/models/layers/layer_animation_test.dart @@ -33,6 +33,18 @@ void main() { expect(anim.curve, AnimationCurve.easeOut); }); + test('creates slide animation with a custom start point only', () { + const anim = LayerAnimation( + type: LayerAnimationType.slide, + phase: AnimationPhase.animateIn, + duration: Duration(milliseconds: 600), + slideFrom: Offset(-400, -400), + ); + + expect(anim.slideFrom, const Offset(-400, -400)); + expect(anim.slideDirection, isNull); + }); + test('creates scale animation with scaleFrom', () { const anim = LayerAnimation( type: LayerAnimationType.scale, @@ -81,6 +93,19 @@ void main() { expect(map['slideDirection'], 'bottom'); }); + test('serializes slideFrom as dx/dy', () { + const anim = LayerAnimation( + type: LayerAnimationType.slide, + phase: AnimationPhase.animateIn, + duration: Duration(milliseconds: 600), + slideFrom: Offset(-120.5, 40), + ); + final map = anim.toMap(); + + expect(map['slideFrom'], {'dx': -120.5, 'dy': 40.0}); + expect(map['slideDirection'], isNull); + }); + test('serializes scale animation with scaleFrom', () { const anim = LayerAnimation( type: LayerAnimationType.scale, @@ -130,6 +155,32 @@ void main() { expect(anim.curve, AnimationCurve.easeOut); }); + test('deserializes slideFrom', () { + final map = { + 'type': 'slide', + 'phase': 'animateIn', + 'durationUs': 600000, + 'slideFrom': {'dx': -400, 'dy': -400}, + }; + final anim = LayerAnimation.fromMap(map); + + expect(anim.slideFrom, const Offset(-400, -400)); + }); + + test('ignores a slideFrom that is not a map', () { + // Hand-edited JSON or data from a newer version must degrade + // gracefully instead of throwing. + final map = { + 'type': 'fade', + 'phase': 'animateIn', + 'durationUs': 100000, + 'slideFrom': 'top-left', + }; + final anim = LayerAnimation.fromMap(map); + + expect(anim.slideFrom, isNull); + }); + test('defaults curve to linear when missing', () { final map = { 'type': 'fade', @@ -205,6 +256,18 @@ void main() { expect(restored, original); }); + test('slideFrom roundtrip preserves data', () { + const original = LayerAnimation( + type: LayerAnimationType.slide, + phase: AnimationPhase.animateInOut, + duration: Duration(milliseconds: 600), + curve: AnimationCurve.easeOutCubic, + slideFrom: Offset(-400.5, 250), + ); + final restored = LayerAnimation.fromMap(original.toMap()); + expect(restored, original); + }); + test('scale roundtrip preserves data', () { const original = LayerAnimation( type: LayerAnimationType.scale, @@ -230,6 +293,20 @@ void main() { expect(original.copyWith(), original); }); + test('adds a slideFrom point to an edge slide', () { + const original = LayerAnimation( + type: LayerAnimationType.slide, + phase: AnimationPhase.animateIn, + duration: Duration(milliseconds: 400), + slideDirection: SlideDirection.left, + ); + + final updated = original.copyWith(slideFrom: const Offset(-300, -80)); + + expect(updated.slideFrom, const Offset(-300, -80)); + expect(updated.slideDirection, SlideDirection.left); + }); + test('overrides only the provided fields', () { const original = LayerAnimation( type: LayerAnimationType.fade, @@ -296,6 +373,22 @@ void main() { expect(a, isNot(b)); }); + test('different slideFrom makes unequal', () { + const a = LayerAnimation( + type: LayerAnimationType.slide, + phase: AnimationPhase.animateIn, + duration: Duration(milliseconds: 500), + slideFrom: Offset(-100, 0), + ); + const b = LayerAnimation( + type: LayerAnimationType.slide, + phase: AnimationPhase.animateIn, + duration: Duration(milliseconds: 500), + slideFrom: Offset(100, 0), + ); + expect(a, isNot(b)); + }); + test('different scaleFrom makes unequal', () { const a = LayerAnimation( type: LayerAnimationType.scale, @@ -337,6 +430,16 @@ void main() { expect(anim.toString(), contains('slideDirection')); }); + test('includes slideFrom when present', () { + const anim = LayerAnimation( + type: LayerAnimationType.slide, + phase: AnimationPhase.animateIn, + duration: Duration(milliseconds: 500), + slideFrom: Offset(-400, 0), + ); + expect(anim.toString(), contains('slideFrom')); + }); + test('excludes scaleFrom when null', () { const anim = LayerAnimation( type: LayerAnimationType.fade, diff --git a/test/shared/utils/parser/offset_parser_test.dart b/test/shared/utils/parser/offset_parser_test.dart new file mode 100644 index 000000000..5bbe3a318 --- /dev/null +++ b/test/shared/utils/parser/offset_parser_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pro_image_editor/shared/utils/parser/offset_parser.dart'; + +void main() { + group('safeParseOffset', () { + test('parses valid dx and dy', () { + final map = {'dx': 200, 'dy': 100}; + final result = safeParseOffset(map); + expect(result, const Offset(200.0, 100.0)); + }); + + test('returns fallback for null map', () { + const fallback = Offset(10, 20); + final result = safeParseOffset(null, fallback: fallback); + expect(result, fallback); + }); + + test('returns Offset.zero for null map if no fallback', () { + final result = safeParseOffset(null); + expect(result, Offset.zero); + }); + + test('parses dx and dy as strings', () { + final map = {'dx': '50.5', 'dy': '-25.2'}; + final result = safeParseOffset(map); + expect(result, const Offset(50.5, -25.2)); + }); + + test('uses fallback if dx is invalid', () { + const fallback = Offset(1, 2); + final map = {'dx': 'abc', 'dy': 10}; + final result = safeParseOffset(map, fallback: fallback); + expect(result.dx, fallback.dx); + expect(result.dy, 10); + }); + + test('uses fallback if dy is invalid', () { + const fallback = Offset(3, 4); + final map = {'dx': 10, 'dy': 'xyz'}; + final result = safeParseOffset(map, fallback: fallback); + expect(result.dx, 10); + expect(result.dy, fallback.dy); + }); + + test('uses fallback if both dx and dy are missing', () { + const fallback = Offset(5, 6); + Map? map = {}; + final result = safeParseOffset(map, fallback: fallback); + expect(result, fallback); + }); + + test('parses using "x" and "y" keys', () { + final map = {'x': 12, 'y': 34}; + final result = safeParseOffset(map); + expect(result, const Offset(12.0, 34.0)); + }); + + test('uses fallback dx if dx is missing', () { + const fallback = Offset(7, 8); + final map = {'dy': 20}; + final result = safeParseOffset(map, fallback: fallback); + expect(result.dx, fallback.dx); + expect(result.dy, 20.0); + }); + + test('uses fallback dy if dy is missing', () { + const fallback = Offset(9, 10); + final map = {'dx': 30}; + final result = safeParseOffset(map, fallback: fallback); + expect(result.dx, 30.0); + expect(result.dy, fallback.dy); + }); + }); +} diff --git a/test/shared/widgets/layer_timeline_visibility_test.dart b/test/shared/widgets/layer_timeline_visibility_test.dart index ea84d1db8..a19b2974e 100644 --- a/test/shared/widgets/layer_timeline_visibility_test.dart +++ b/test/shared/widgets/layer_timeline_visibility_test.dart @@ -214,6 +214,100 @@ void main() { }); }); + group('LayerTimelineVisibility slide from a custom point', () { + // A layer resting 10px right and 20px below the canvas center, entering + // from a point 150px left and 60px above that center. Both are measured + // like [Layer.offset], so the layer travels (-160, -80). + const restingOffset = Offset(10, 20); + const startPoint = Offset(-150, -60); + const travel = Offset(-160, -80); + + Layer slideFromLayer({SlideDirection? direction}) => Layer( + offset: restingOffset, + startTime: const Duration(seconds: 1), + endTime: const Duration(seconds: 10), + animations: [ + LayerAnimation( + type: LayerAnimationType.slide, + phase: AnimationPhase.animateIn, + duration: const Duration(milliseconds: 400), + slideFrom: startPoint, + slideDirection: direction, + curve: AnimationCurve.linear, + ), + ], + ); + + testWidgets('starts on the point at the enter window start', ( + tester, + ) async { + final notifier = await pumpVisibility( + tester, + slideFromLayer(), + center: const Offset(60, 70), + ); + await seek(tester, notifier, const Duration(seconds: 1)); + + // invP = 1: the layer sits exactly on the start point. The distance is + // measured between two anchor points, so the layer's own size plays no + // part and there is no fractional component. + expect(slideAbsolute(tester), travel); + expect(find.byType(FractionalTranslation), findsNothing); + }); + + testWidgets('travels a linear fraction partway through the window', ( + tester, + ) async { + final notifier = await pumpVisibility(tester, slideFromLayer()); + // 100ms into a 400ms linear window: invP = 0.75. + await seek(tester, notifier, const Duration(milliseconds: 1100)); + + expect(slideAbsolute(tester), travel * 0.75); + }); + + testWidgets('settles on the resting place once entered', (tester) async { + final notifier = await pumpVisibility(tester, slideFromLayer()); + await seek(tester, notifier, const Duration(seconds: 5)); + + expect(find.byType(Transform), findsNothing); + expect(find.byType(FractionalTranslation), findsNothing); + }); + + testWidgets('overrides slideDirection when both are set', (tester) async { + final notifier = await pumpVisibility( + tester, + slideFromLayer(direction: SlideDirection.right), + ); + await seek(tester, notifier, const Duration(seconds: 1)); + + // The right edge would push the layer the other way and add a + // fractional half-width; the point wins outright. + expect(slideAbsolute(tester), travel); + expect(find.byType(FractionalTranslation), findsNothing); + }); + + testWidgets('leaves back towards the point on the way out', (tester) async { + final layer = Layer( + offset: restingOffset, + startTime: Duration.zero, + endTime: const Duration(seconds: 10), + animations: const [ + LayerAnimation( + type: LayerAnimationType.slide, + phase: AnimationPhase.animateOut, + duration: Duration(milliseconds: 400), + slideFrom: startPoint, + curve: AnimationCurve.linear, + ), + ], + ); + final notifier = await pumpVisibility(tester, layer); + await seek(tester, notifier, const Duration(seconds: 10)); + + expect(slideAbsolute(tester), travel); + }); + }); + group('LayerTimelineVisibility scale animation', () { final layer = Layer( startTime: Duration.zero, From aa4224fd069089cbfbcf6a0d02b8d15378b09c2e Mon Sep 17 00:00:00 2001 From: hm21 Date: Thu, 10 Sep 2026 12:53:15 +0200 Subject: [PATCH 2/3] docs(layers): say how slideFrom must be converted when bridging to pro_video_editor The two packages agree on the maths - only the distance between the start point and the layer's resting anchor is ever used - but disagree on the origin, because each follows its own layer offsets. An exporter that converts `Layer.offset` and forgets `slideFrom` therefore parks the layer correctly and moves it the wrong distance, silently. --- lib/core/models/layers/layer_animation.dart | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/core/models/layers/layer_animation.dart b/lib/core/models/layers/layer_animation.dart index a32ce183f..3a2aa3832 100644 --- a/lib/core/models/layers/layer_animation.dart +++ b/lib/core/models/layers/layer_animation.dart @@ -221,8 +221,14 @@ class LayerAnimation { /// Values may sit outside the canvas — on a 400×800 canvas /// `Offset(-400, 0)` starts the layer 200px past the left edge. /// - /// Note that the `pro_video_editor` counterpart measures its `slideFrom` - /// from the video frame's top-left corner, matching its own layer offsets. + /// Only the distance between this point and [Layer.offset] is used, so the + /// layer's own size, rotation and scale play no part — exactly how the + /// native renderer in `pro_video_editor` computes it. That package measures + /// its own `slideFrom` from the video frame's top-left corner, matching its + /// layer offsets, so an exporter bridging to it must convert this point + /// through the very same transform it applies to [Layer.offset] (canvas + /// origin *and* export scale). Converting one but not the other leaves the + /// layer resting correctly while travelling the wrong distance. /// /// Overrides [slideDirection] when both are set. final Offset? slideFrom; From ecf661e25ef9be1f990499e1d187ee611d2f2431 Mon Sep 17 00:00:00 2001 From: hm21 Date: Thu, 10 Sep 2026 13:04:07 +0200 Subject: [PATCH 3/3] fix(layers): recompute a layer's slide when its geometry moves The timeline preview cached the composed frame and only rebuilt it when the video position or an animation setting changed. The slide effect also reads the layer's offset, its center and the canvas size, so dragging a layer or resizing the canvas left the layer travelling the distance measured for the old geometry until the next time tick. `Layer` is mutable and is mutated in place while it is dragged, so the old and new widget hold the same instance and comparing them cannot see the move. Record the geometry each frame was computed from and compare against that instead. Also export `safeParseOffset` from the package barrel next to its `safeParseSize` sibling, which was left out when it was added. --- CHANGELOG.md | 3 +- lib/pro_image_editor.dart | 1 + lib/shared/utils/parser/size_parser.dart | 2 +- .../layer/layer_timeline_visibility.dart | 34 +++++++- .../layer_timeline_visibility_test.dart | 80 ++++++++++++++++++- 5 files changed, 111 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b823e94de..2857c797a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ # Changelog ## 14.1.0 -- **FEAT**(layers): `LayerAnimation.slideFrom` gives a slide animation its own start point instead of a canvas edge. It takes the layer's position in the same coordinates as `Layer.offset`, may sit outside the canvas, and overrides `slideDirection` when both are set. Mirrors the `pro_video_editor` feature of the same name. +- **FEAT**(layers): Add `LayerAnimation.slideFrom` to start a slide from a point of your own instead of a canvas edge. It uses the same coordinates as `Layer.offset`, may sit outside the canvas, and overrides `slideDirection`. +- **FIX**(layers): The timeline preview recomputes a layer's slide when the layer moves or the canvas resizes, instead of keeping the displacement it measured for the old geometry. ## 14.0.0 - **BREAKING**(ui): Migrate from `package:flutter/material.dart` and `package:flutter/cupertino.dart` to the standalone [`material_ui`](https://pub.dev/packages/material_ui) and [`cupertino_ui`](https://pub.dev/packages/cupertino_ui) packages, which Flutter is decoupling from the SDK. Apps must migrate their own imports as well (`dart fix --apply --code=migrate_design_widgets`), because types such as `ThemeData` are no longer interchangeable with the SDK ones. diff --git a/lib/pro_image_editor.dart b/lib/pro_image_editor.dart index c6dafaa74..eb8afb0a5 100644 --- a/lib/pro_image_editor.dart +++ b/lib/pro_image_editor.dart @@ -71,6 +71,7 @@ export 'core/constants/editor_style_constants.dart'; export 'core/utils/image_converter.dart'; export '/shared/utils/parser/int_parser.dart'; export '/shared/utils/parser/double_parser.dart'; +export '/shared/utils/parser/offset_parser.dart'; export '/shared/utils/parser/size_parser.dart'; export '/core/models/editor_configs/utils/editor_safe_area.dart'; diff --git a/lib/shared/utils/parser/size_parser.dart b/lib/shared/utils/parser/size_parser.dart index a52530de0..d578d7ec9 100644 --- a/lib/shared/utils/parser/size_parser.dart +++ b/lib/shared/utils/parser/size_parser.dart @@ -23,7 +23,7 @@ import 'double_parser.dart'; /// safeParseSize({'width': 200, 'height': 100}); // returns Size(200.0, 100.0) /// safeParseSize(null); // returns Size.zero (fallback) /// safeParseSize({'width': 'abc', 'height': 50}, fallback: Size(10, 10)); -/// // returns Size(10.0, 10.0) (fallback) +/// // returns Size(10.0, 50.0) /// ``` Size safeParseSize(Map? map, {Size fallback = Size.zero}) { if (map == null) return fallback; diff --git a/lib/shared/widgets/layer/layer_timeline_visibility.dart b/lib/shared/widgets/layer/layer_timeline_visibility.dart index f5f57fa18..f213b7a7f 100644 --- a/lib/shared/widgets/layer/layer_timeline_visibility.dart +++ b/lib/shared/widgets/layer/layer_timeline_visibility.dart @@ -88,10 +88,27 @@ class LayerTimelineVisibility extends StatefulWidget { class _LayerTimelineVisibilityState extends State { late _TimelineFrame _frame; + /// The geometry [_frame] was computed from, so [_geometryChanged] can tell + /// when the cached frame went stale. + late Offset _framedLayerOffset; + late Offset _framedLayerCenter; + late Size _framedCanvasSize; + + /// Whether the geometry the slide animation reads has moved since [_frame] + /// was computed. + /// + /// [Layer] is mutable and is mutated in place while it is dragged, so + /// `oldWidget.layer.offset != widget.layer.offset` never fires — both + /// widgets hold the same instance. The recorded values are compared instead. + bool get _geometryChanged => + _framedLayerOffset != widget.layer.offset || + _framedLayerCenter != widget.layerCenter || + _framedCanvasSize != widget.canvasSize; + @override void initState() { super.initState(); - _frame = _computeFrame(widget.playTimeNotifier.value); + _frame = _frameFor(widget.playTimeNotifier.value); widget.playTimeNotifier.addListener(_onTimeChanged); } @@ -109,9 +126,10 @@ class _LayerTimelineVisibilityState extends State { oldWidget.layer.exitDuration != widget.layer.exitDuration || oldWidget.layer.enterCurve != widget.layer.enterCurve || oldWidget.layer.exitCurve != widget.layer.exitCurve || - !listEquals(oldWidget.layer.animations, widget.layer.animations); + !listEquals(oldWidget.layer.animations, widget.layer.animations) || + _geometryChanged; if (changed) { - _frame = _computeFrame(widget.playTimeNotifier.value); + _frame = _frameFor(widget.playTimeNotifier.value); } } @@ -122,12 +140,20 @@ class _LayerTimelineVisibilityState extends State { } void _onTimeChanged() { - final next = _computeFrame(widget.playTimeNotifier.value); + final next = _frameFor(widget.playTimeNotifier.value); if (next != _frame) { setState(() => _frame = next); } } + /// Computes the frame for [currentTime] and records the geometry it used. + _TimelineFrame _frameFor(Duration currentTime) { + _framedLayerOffset = widget.layer.offset; + _framedLayerCenter = widget.layerCenter; + _framedCanvasSize = widget.canvasSize; + return _computeFrame(currentTime); + } + /// Computes a curved progress value (0.0 – 1.0) for the legacy fade path. double _computeLegacyProgress(Duration currentTime) { return computeTimelineProgress( diff --git a/test/shared/widgets/layer_timeline_visibility_test.dart b/test/shared/widgets/layer_timeline_visibility_test.dart index a19b2974e..1219a48e7 100644 --- a/test/shared/widgets/layer_timeline_visibility_test.dart +++ b/test/shared/widgets/layer_timeline_visibility_test.dart @@ -15,11 +15,14 @@ void main() { Size canvas = canvasSize, Offset center = layerCenter, Offset fractionalOffset = const Offset(-0.5, -0.5), + ValueNotifier? reuse, }) async { // Start before any layer's time range so the first seek registers as a - // real change on the [ValueNotifier]. - final notifier = ValueNotifier(const Duration(milliseconds: -1)); - addTearDown(notifier.dispose); + // real change on the [ValueNotifier]. Pass [reuse] to re-pump with a + // changed geometry while the video position stays put. + final notifier = + reuse ?? ValueNotifier(const Duration(milliseconds: -1)); + if (reuse == null) addTearDown(notifier.dispose); await tester.pumpWidget( Directionality( @@ -308,6 +311,77 @@ void main() { }); }); + group('LayerTimelineVisibility geometry updates', () { + // [Layer] is mutable and is mutated in place while it is dragged, so the + // cached frame has to be invalidated by the geometry it read, not by + // comparing the old and new widget's layer. + testWidgets('recomputes a point slide when the layer moves', ( + tester, + ) async { + final layer = Layer( + offset: const Offset(10, 20), + startTime: const Duration(seconds: 1), + endTime: const Duration(seconds: 10), + animations: const [ + LayerAnimation( + type: LayerAnimationType.slide, + phase: AnimationPhase.animateIn, + duration: Duration(milliseconds: 400), + slideFrom: Offset(-150, -60), + curve: AnimationCurve.linear, + ), + ], + ); + final notifier = await pumpVisibility(tester, layer); + await seek(tester, notifier, const Duration(seconds: 1)); + + expect(slideAbsolute(tester), const Offset(-160, -80)); + + // Drag the layer without touching the video position. + layer.offset = const Offset(50, 50); + await pumpVisibility( + tester, + layer, + center: const Offset(90, 100), + reuse: notifier, + ); + + expect(slideAbsolute(tester), const Offset(-200, -110)); + }); + + testWidgets('recomputes an edge slide when the canvas resizes', ( + tester, + ) async { + final layer = Layer( + startTime: const Duration(seconds: 1), + endTime: const Duration(seconds: 10), + animations: const [ + LayerAnimation( + type: LayerAnimationType.slide, + phase: AnimationPhase.animateIn, + duration: Duration(milliseconds: 400), + slideDirection: SlideDirection.right, + curve: AnimationCurve.linear, + ), + ], + ); + final notifier = await pumpVisibility(tester, layer); + await seek(tester, notifier, const Duration(seconds: 1)); + + // invP = 1: pushed to the right edge, (canvas.width - center.dx). + expect(slideAbsolute(tester), const Offset(50, 0)); + + await pumpVisibility( + tester, + layer, + canvas: const Size(300, 100), + reuse: notifier, + ); + + expect(slideAbsolute(tester), const Offset(250, 0)); + }); + }); + group('LayerTimelineVisibility scale animation', () { final layer = Layer( startTime: Duration.zero,