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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 14.1.0
- **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.

Expand Down
76 changes: 69 additions & 7 deletions lib/core/models/layers/layer_animation.dart
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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
Expand All @@ -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({
Expand All @@ -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].
Expand All @@ -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<String, dynamic>.from(map['slideFrom'] as Map))
: null,
scaleFrom: (map['scaleFrom'] as num?)?.toDouble(),
);
}
Expand Down Expand Up @@ -176,9 +201,38 @@ 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.
///
/// 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;

/// The starting scale factor for [LayerAnimationType.scale] animations.
///
/// Defaults to `0.0` (invisible) when not set. A value of `0.5` means the
Expand All @@ -193,6 +247,9 @@ class LayerAnimation {
'durationUs': duration.inMicroseconds,
'curve': curve.name,
'slideDirection': slideDirection?.name,
'slideFrom': slideFrom != null
? {'dx': slideFrom!.dx, 'dy': slideFrom!.dy}
: null,
'scaleFrom': scaleFrom,
};
}
Expand All @@ -204,6 +261,7 @@ class LayerAnimation {
Duration? duration,
AnimationCurve? curve,
SlideDirection? slideDirection,
Offset? slideFrom,
double? scaleFrom,
}) {
return LayerAnimation(
Expand All @@ -212,6 +270,7 @@ class LayerAnimation {
duration: duration ?? this.duration,
curve: curve ?? this.curve,
slideDirection: slideDirection ?? this.slideDirection,
slideFrom: slideFrom ?? this.slideFrom,
scaleFrom: scaleFrom ?? this.scaleFrom,
);
}
Expand All @@ -221,6 +280,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' : ''})';
}

Expand All @@ -232,6 +292,7 @@ class LayerAnimation {
other.duration == duration &&
other.curve == curve &&
other.slideDirection == slideDirection &&
other.slideFrom == slideFrom &&
other.scaleFrom == scaleFrom;
}

Expand All @@ -242,6 +303,7 @@ class LayerAnimation {
duration.hashCode ^
curve.hashCode ^
slideDirection.hashCode ^
slideFrom.hashCode ^
scaleFrom.hashCode;
}
}
1 change: 1 addition & 0 deletions lib/pro_image_editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
39 changes: 39 additions & 0 deletions lib/shared/utils/parser/offset_parser.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic>? 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),
);
}
2 changes: 1 addition & 1 deletion lib/shared/utils/parser/size_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic>? map, {Size fallback = Size.zero}) {
if (map == null) return fallback;
Expand Down
50 changes: 44 additions & 6 deletions lib/shared/widgets/layer/layer_timeline_visibility.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -86,10 +88,27 @@ class LayerTimelineVisibility extends StatefulWidget {
class _LayerTimelineVisibilityState extends State<LayerTimelineVisibility> {
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);
}

Expand All @@ -107,9 +126,10 @@ class _LayerTimelineVisibilityState extends State<LayerTimelineVisibility> {
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);
}
}

Expand All @@ -120,12 +140,20 @@ class _LayerTimelineVisibilityState extends State<LayerTimelineVisibility> {
}

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(
Expand Down Expand Up @@ -209,9 +237,19 @@ class _LayerTimelineVisibilityState extends State<LayerTimelineVisibility> {
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
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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/
Expand Down
Loading