core_animation/
animation_builder.rs

1//! GPU-accelerated animations using `CABasicAnimation`.
2//!
3//! Animations run on the compositor thread, not the main thread.
4//! No manual loop required - just configure and show.
5//!
6//! # Quick Start
7//!
8//! ```ignore
9//! let layer = CAShapeLayerBuilder::new()
10//!     .path(circle_path)
11//!     .fill_color(Color::RED)
12//!     .animate("pulse", KeyPath::TransformScale, |a| {
13//!         a.values(0.85, 1.15)
14//!             .duration(800.millis())
15//!             .easing(Easing::InOut)
16//!             .autoreverses()
17//!             .repeat(Repeat::Forever)
18//!     })
19//!     .build();
20//!
21//! window.container().add_sublayer(&layer);
22//! window.show_for(10.seconds());  // No animation loop needed
23//! ```
24//!
25//! # Builder API
26//!
27//! ## `CABasicAnimationBuilder`
28//!
29//! Configures a from→to animation on a single property.
30//!
31//! | Method | Description |
32//! |--------|-------------|
33//! | `.values(from, to)` | Numeric from/to values (f64) |
34//! | `.values_point(from, to)` | CGPoint from/to |
35//! | `.values_color(from, to)` | Color from/to |
36//! | `.duration(Duration)` | Animation cycle duration |
37//! | `.easing(Easing)` | Timing curve (default: `InOut`) |
38//! | `.autoreverses()` | Ping-pong animation |
39//! | `.repeat(Repeat)` | Repeat behavior (default: `Once`) |
40//! | `.phase_offset(f64)` | Start at fraction of cycle (0.0-1.0) |
41//!
42//! ## `KeyPath`
43//!
44//! Property to animate. Common paths:
45//!
46//! | Constant | Animates |
47//! |----------|----------|
48//! | `TransformScale` | Uniform scale (0.0 = invisible, 1.0 = normal) |
49//! | `TransformScaleX` | Horizontal scale |
50//! | `TransformScaleY` | Vertical scale |
51//! | `TransformRotation` | Z-axis rotation (radians) |
52//! | `Opacity` | Alpha (0.0 = transparent, 1.0 = opaque) |
53//! | `Position` | Layer center point (CGPoint) |
54//! | `PositionX` | Horizontal position |
55//! | `PositionY` | Vertical position |
56//! | `BackgroundColor` | Fill color (CGColor) |
57//! | `CornerRadius` | Corner rounding |
58//! | `BorderWidth` | Border thickness |
59//!
60//! ## `Easing`
61//!
62//! Timing curves for animation interpolation:
63//!
64//! | Variant | Behavior |
65//! |---------|----------|
66//! | `Linear` | Constant speed |
67//! | `In` | Slow start, fast end |
68//! | `Out` | Fast start, slow end |
69//! | `InOut` | Slow start and end (default) |
70//!
71//! ## `Repeat`
72//!
73//! How many times the animation plays:
74//!
75//! | Variant | Behavior |
76//! |---------|----------|
77//! | `Once` | Play once, hold final value (default) |
78//! | `Times(n)` | Play n times, hold final value |
79//! | `Forever` | Loop indefinitely |
80//!
81//! # Default Behaviors
82//!
83//! **Values persist after animation ends.** Unlike raw `CABasicAnimation`,
84//! this builder defaults to `fillMode = forwards` and `removedOnCompletion = false`.
85//! The layer stays at the final animated value.
86//!
87//! To opt-in to snap-back behavior:
88//!
89//! ```ignore
90//! a.values(0.0, 1.0)
91//!     .remove_on_completion()  // Snap back to original value
92//! ```
93//!
94//! # Integration with Layer Builders
95//!
96//! The `.animate()` method is available on:
97//! - `CALayerBuilder`
98//! - `CAShapeLayerBuilder`
99//!
100//! Multiple animations can be added to a single layer:
101//!
102//! ```ignore
103//! CAShapeLayerBuilder::new()
104//!     .fill_color(Color::RED)
105//!     .animate("pulse", KeyPath::TransformScale, |a| {
106//!         a.values(0.9, 1.1).duration(500.millis()).repeat(Repeat::Forever)
107//!     })
108//!     .animate("fade", KeyPath::Opacity, |a| {
109//!         a.values(1.0, 0.7).duration(1.seconds()).repeat(Repeat::Forever)
110//!     })
111//!     .build()
112//! ```
113//!
114//! # Standalone Usage
115//!
116//! Animations can also be created and added manually:
117//!
118//! ```ignore
119//! let anim = CABasicAnimationBuilder::new(KeyPath::TransformScale)
120//!     .values(0.85, 1.15)
121//!     .duration(800.millis())
122//!     .autoreverses()
123//!     .repeat(Repeat::Forever)
124//!     .build();
125//!
126//! layer.add_animation(&anim, "pulse");
127//! ```
128//!
129//! # Future: Other Animation Types
130//!
131//! The closure pattern allows different builders for different animation types:
132//!
133//! ```ignore
134//! // Keyframe animation (multiple values)
135//! .animate_keyframes("bounce", KeyPath::PositionY, |a| {
136//!     a.values([100.0, 50.0, 80.0, 60.0, 70.0])
137//!         .key_times([0.0, 0.3, 0.5, 0.7, 1.0])
138//! })
139//!
140//! // Spring animation (physics-based)
141//! .animate_spring("snap", KeyPath::Position, |a| {
142//!     a.to_point(200.0, 200.0)
143//!         .damping(10.0)
144//!         .stiffness(100.0)
145//! })
146//! ```
147//!
148//! The user-facing API pattern remains consistent; only the builder inside
149//! the closure changes.
150
151use std::time::Duration;
152
153use objc2::rc::Retained;
154use objc2_foundation::{NSNumber, NSString};
155use objc2_quartz_core::{
156    kCAFillModeForwards, kCAMediaTimingFunctionEaseIn, kCAMediaTimingFunctionEaseInEaseOut,
157    kCAMediaTimingFunctionEaseOut, kCAMediaTimingFunctionLinear, CABasicAnimation, CAMediaTiming,
158    CAMediaTimingFunction,
159};
160
161/// Property key path for animation targets.
162///
163/// Each variant maps to a Core Animation key path string that identifies
164/// which property of a `CALayer` to animate.
165///
166/// # Examples
167///
168/// ```ignore
169/// // Animate scale
170/// let anim = CABasicAnimationBuilder::new(KeyPath::TransformScale)
171///     .values(0.5, 1.0)
172///     .build();
173///
174/// // Animate position
175/// let anim = CABasicAnimationBuilder::new(KeyPath::PositionX)
176///     .values(0.0, 100.0)
177///     .build();
178/// ```
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
180pub enum KeyPath {
181    /// Uniform scale transform (`transform.scale`).
182    /// Value range: 0.0 = invisible, 1.0 = normal size.
183    TransformScale,
184    /// Horizontal scale transform (`transform.scale.x`).
185    TransformScaleX,
186    /// Vertical scale transform (`transform.scale.y`).
187    TransformScaleY,
188    /// Z-axis rotation transform (`transform.rotation.z`).
189    /// Value is in radians.
190    TransformRotation,
191    /// Layer opacity (`opacity`).
192    /// Value range: 0.0 = transparent, 1.0 = opaque.
193    Opacity,
194    /// Layer center position (`position`).
195    /// Value type: CGPoint.
196    Position,
197    /// Horizontal position (`position.x`).
198    PositionX,
199    /// Vertical position (`position.y`).
200    PositionY,
201    /// Layer background color (`backgroundColor`).
202    /// Value type: CGColor.
203    BackgroundColor,
204    /// Corner radius (`cornerRadius`).
205    CornerRadius,
206    /// Border width (`borderWidth`).
207    BorderWidth,
208    /// Border color (`borderColor`).
209    /// Value type: CGColor.
210    BorderColor,
211    /// Shadow opacity (`shadowOpacity`).
212    ShadowOpacity,
213    /// Shadow radius (`shadowRadius`).
214    ShadowRadius,
215    /// Shadow offset (`shadowOffset`).
216    /// Value type: CGSize.
217    ShadowOffset,
218    /// Bounds rectangle (`bounds`).
219    /// Value type: CGRect.
220    Bounds,
221    /// Custom key path string.
222    Custom(&'static str),
223}
224
225impl KeyPath {
226    /// Returns the Core Animation key path string for this property.
227    ///
228    /// # Examples
229    ///
230    /// ```ignore
231    /// assert_eq!(KeyPath::TransformScale.as_str(), "transform.scale");
232    /// assert_eq!(KeyPath::Opacity.as_str(), "opacity");
233    /// ```
234    #[must_use]
235    pub const fn as_str(&self) -> &'static str {
236        match self {
237            KeyPath::TransformScale => "transform.scale",
238            KeyPath::TransformScaleX => "transform.scale.x",
239            KeyPath::TransformScaleY => "transform.scale.y",
240            KeyPath::TransformRotation => "transform.rotation.z",
241            KeyPath::Opacity => "opacity",
242            KeyPath::Position => "position",
243            KeyPath::PositionX => "position.x",
244            KeyPath::PositionY => "position.y",
245            KeyPath::BackgroundColor => "backgroundColor",
246            KeyPath::CornerRadius => "cornerRadius",
247            KeyPath::BorderWidth => "borderWidth",
248            KeyPath::BorderColor => "borderColor",
249            KeyPath::ShadowOpacity => "shadowOpacity",
250            KeyPath::ShadowRadius => "shadowRadius",
251            KeyPath::ShadowOffset => "shadowOffset",
252            KeyPath::Bounds => "bounds",
253            KeyPath::Custom(s) => s,
254        }
255    }
256
257    /// Creates an `NSString` for this key path.
258    ///
259    /// This is used internally when constructing `CABasicAnimation`.
260    fn to_nsstring(self) -> Retained<NSString> {
261        NSString::from_str(self.as_str())
262    }
263}
264
265/// Timing curve for animation interpolation.
266///
267/// Controls how the animation progresses over time. The default is `InOut`
268/// which provides smooth acceleration and deceleration.
269///
270/// # Examples
271///
272/// ```ignore
273/// // Linear motion (constant speed)
274/// builder.easing(Easing::Linear)
275///
276/// // Smooth start and end (default)
277/// builder.easing(Easing::InOut)
278/// ```
279#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
280pub enum Easing {
281    /// Constant speed throughout the animation.
282    Linear,
283    /// Slow start, accelerating to full speed.
284    In,
285    /// Fast start, decelerating to a stop.
286    Out,
287    /// Slow start and end with acceleration in the middle (default).
288    #[default]
289    InOut,
290}
291
292impl Easing {
293    /// Creates the corresponding `CAMediaTimingFunction` for this easing curve.
294    fn to_timing_function(self) -> Retained<CAMediaTimingFunction> {
295        // SAFETY: The timing function name constants are valid extern statics
296        // that are always available on macOS.
297        let name = unsafe {
298            match self {
299                Easing::Linear => kCAMediaTimingFunctionLinear,
300                Easing::In => kCAMediaTimingFunctionEaseIn,
301                Easing::Out => kCAMediaTimingFunctionEaseOut,
302                Easing::InOut => kCAMediaTimingFunctionEaseInEaseOut,
303            }
304        };
305        CAMediaTimingFunction::functionWithName(name)
306    }
307}
308
309/// Repeat behavior for animations.
310///
311/// Controls how many times the animation plays before stopping.
312///
313/// # Examples
314///
315/// ```ignore
316/// // Play once and hold (default)
317/// builder.repeat(Repeat::Once)
318///
319/// // Play 3 times
320/// builder.repeat(Repeat::Times(3))
321///
322/// // Loop forever
323/// builder.repeat(Repeat::Forever)
324/// ```
325#[derive(Debug, Clone, Copy, PartialEq, Default)]
326pub enum Repeat {
327    /// Play the animation once and hold the final value (default).
328    #[default]
329    Once,
330    /// Play the animation a specific number of times.
331    Times(u32),
332    /// Loop the animation indefinitely.
333    Forever,
334}
335
336impl Repeat {
337    /// Returns the `repeatCount` value for Core Animation.
338    ///
339    /// - `Once` returns 1.0 (play once)
340    /// - `Times(n)` returns n as f32
341    /// - `Forever` returns `f32::INFINITY`
342    fn to_repeat_count(self) -> f32 {
343        match self {
344            Repeat::Once => 1.0,
345            Repeat::Times(n) => n as f32,
346            Repeat::Forever => f32::INFINITY,
347        }
348    }
349}
350
351/// Builder for configuring `CABasicAnimation` instances.
352///
353/// Creates from→to animations on a single property. The builder uses
354/// sensible defaults:
355/// - `fillMode = forwards` (values persist after animation)
356/// - `removedOnCompletion = false` (animation stays attached)
357/// - `easing = InOut` (smooth acceleration/deceleration)
358/// - `repeat = Once` (play once)
359///
360/// # Examples
361///
362/// ```ignore
363/// // Simple pulse animation
364/// let anim = CABasicAnimationBuilder::new(KeyPath::TransformScale)
365///     .values(0.85, 1.15)
366///     .duration(800.millis())
367///     .autoreverses()
368///     .repeat(Repeat::Forever)
369///     .build();
370///
371/// layer.addAnimation_forKey(&anim, Some(ns_string!("pulse")));
372/// ```
373///
374/// # Default Behaviors
375///
376/// By default, the animation's final value persists on the layer after
377/// completion. This differs from raw `CABasicAnimation` which snaps back
378/// to the original value. Call `.remove_on_completion()` to opt-in to
379/// snap-back behavior.
380pub struct CABasicAnimationBuilder {
381    key_path: KeyPath,
382    from_value: Option<f64>,
383    to_value: Option<f64>,
384    duration: Duration,
385    easing: Easing,
386    autoreverses: bool,
387    repeat: Repeat,
388    phase_offset: f64,
389    remove_on_completion: bool,
390}
391
392impl CABasicAnimationBuilder {
393    /// Creates a new animation builder for the specified property.
394    ///
395    /// # Arguments
396    ///
397    /// * `key_path` - The property to animate (e.g., `KeyPath::TransformScale`)
398    ///
399    /// # Examples
400    ///
401    /// ```ignore
402    /// let builder = CABasicAnimationBuilder::new(KeyPath::Opacity);
403    /// ```
404    #[must_use]
405    pub fn new(key_path: KeyPath) -> Self {
406        Self {
407            key_path,
408            from_value: None,
409            to_value: None,
410            duration: Duration::from_millis(250),
411            easing: Easing::default(),
412            autoreverses: false,
413            repeat: Repeat::default(),
414            phase_offset: 0.0,
415            remove_on_completion: false,
416        }
417    }
418
419    /// Sets the from and to values for the animation.
420    ///
421    /// The values are interpreted based on the key path:
422    /// - Scale properties: 0.0 = invisible, 1.0 = normal
423    /// - Opacity: 0.0 = transparent, 1.0 = opaque
424    /// - Rotation: radians
425    /// - Position: points
426    ///
427    /// # Arguments
428    ///
429    /// * `from` - Starting value
430    /// * `to` - Ending value
431    ///
432    /// # Examples
433    ///
434    /// ```ignore
435    /// // Scale from 85% to 115%
436    /// builder.values(0.85, 1.15)
437    ///
438    /// // Fade from fully opaque to 70% opacity
439    /// builder.values(1.0, 0.7)
440    /// ```
441    #[must_use]
442    pub fn values(mut self, from: f64, to: f64) -> Self {
443        self.from_value = Some(from);
444        self.to_value = Some(to);
445        self
446    }
447
448    /// Sets the duration of one animation cycle.
449    ///
450    /// # Arguments
451    ///
452    /// * `duration` - The time for one complete animation cycle
453    ///
454    /// # Examples
455    ///
456    /// ```ignore
457    /// use std::time::Duration;
458    ///
459    /// builder.duration(Duration::from_millis(800))
460    ///
461    /// // Or with DurationExt:
462    /// builder.duration(800.millis())
463    /// builder.duration(1.5.seconds())
464    /// ```
465    #[must_use]
466    pub fn duration(mut self, duration: Duration) -> Self {
467        self.duration = duration;
468        self
469    }
470
471    /// Sets the easing curve for the animation.
472    ///
473    /// The default is `Easing::InOut` which provides smooth acceleration
474    /// at the start and deceleration at the end.
475    ///
476    /// # Arguments
477    ///
478    /// * `easing` - The timing curve to use
479    ///
480    /// # Examples
481    ///
482    /// ```ignore
483    /// // Constant speed
484    /// builder.easing(Easing::Linear)
485    ///
486    /// // Quick start, slow end
487    /// builder.easing(Easing::Out)
488    /// ```
489    #[must_use]
490    pub fn easing(mut self, easing: Easing) -> Self {
491        self.easing = easing;
492        self
493    }
494
495    /// Enables ping-pong animation (play forward then backward).
496    ///
497    /// When combined with `repeat(Repeat::Forever)`, creates a smooth
498    /// oscillating animation.
499    ///
500    /// # Examples
501    ///
502    /// ```ignore
503    /// // Pulse that smoothly grows and shrinks
504    /// builder
505    ///     .values(0.9, 1.1)
506    ///     .autoreverses()
507    ///     .repeat(Repeat::Forever)
508    /// ```
509    #[must_use]
510    pub fn autoreverses(mut self) -> Self {
511        self.autoreverses = true;
512        self
513    }
514
515    /// Sets the repeat behavior for the animation.
516    ///
517    /// # Arguments
518    ///
519    /// * `repeat` - How many times to play the animation
520    ///
521    /// # Examples
522    ///
523    /// ```ignore
524    /// // Play 3 times
525    /// builder.repeat(Repeat::Times(3))
526    ///
527    /// // Loop forever
528    /// builder.repeat(Repeat::Forever)
529    /// ```
530    #[must_use]
531    pub fn repeat(mut self, repeat: Repeat) -> Self {
532        self.repeat = repeat;
533        self
534    }
535
536    /// Sets the phase offset (starting point within the animation cycle).
537    ///
538    /// This allows multiple animations to be out of phase with each other,
539    /// creating interesting visual effects.
540    ///
541    /// # Arguments
542    ///
543    /// * `offset` - Fraction of the cycle to skip (0.0 to 1.0).
544    ///   - 0.0 = start at beginning (default)
545    ///   - 0.5 = start at midpoint
546    ///   - 1.0 = start at end (same as beginning for looping animations)
547    ///
548    /// # Examples
549    ///
550    /// ```ignore
551    /// // Two circles pulsing out of phase
552    /// let anim1 = builder.clone().phase_offset(0.0).build();
553    /// let anim2 = builder.clone().phase_offset(0.5).build();
554    /// ```
555    #[must_use]
556    pub fn phase_offset(mut self, offset: f64) -> Self {
557        self.phase_offset = offset;
558        self
559    }
560
561    /// Opts in to snap-back behavior (remove animation on completion).
562    ///
563    /// By default, the animation's final value persists on the layer.
564    /// Calling this method causes the layer to snap back to its original
565    /// value when the animation completes.
566    ///
567    /// # Examples
568    ///
569    /// ```ignore
570    /// // Animation snaps back to original value when done
571    /// builder
572    ///     .values(0.0, 1.0)
573    ///     .repeat(Repeat::Times(3))
574    ///     .remove_on_completion()
575    /// ```
576    #[must_use]
577    pub fn remove_on_completion(mut self) -> Self {
578        self.remove_on_completion = true;
579        self
580    }
581
582    /// Builds and returns the configured `CABasicAnimation`.
583    ///
584    /// # Returns
585    ///
586    /// A retained `CABasicAnimation` ready to be added to a layer.
587    ///
588    /// # Examples
589    ///
590    /// ```ignore
591    /// let anim = CABasicAnimationBuilder::new(KeyPath::TransformScale)
592    ///     .values(0.85, 1.15)
593    ///     .duration(800.millis())
594    ///     .autoreverses()
595    ///     .repeat(Repeat::Forever)
596    ///     .build();
597    ///
598    /// layer.addAnimation_forKey(&anim, Some(ns_string!("pulse")));
599    /// ```
600    #[must_use]
601    pub fn build(self) -> Retained<CABasicAnimation> {
602        let key_path_str = self.key_path.to_nsstring();
603        let anim = CABasicAnimation::animationWithKeyPath(Some(&key_path_str));
604
605        // Set from/to values if provided
606        if let Some(from) = self.from_value {
607            let from_number = NSNumber::new_f64(from);
608            // SAFETY: NSNumber is a valid object type for fromValue
609            unsafe {
610                anim.setFromValue(Some(&from_number));
611            }
612        }
613        if let Some(to) = self.to_value {
614            let to_number = NSNumber::new_f64(to);
615            // SAFETY: NSNumber is a valid object type for toValue
616            unsafe {
617                anim.setToValue(Some(&to_number));
618            }
619        }
620
621        // Set timing properties (from CAMediaTiming trait)
622        let duration_secs = self.duration.as_secs_f64();
623        anim.setDuration(duration_secs);
624        anim.setAutoreverses(self.autoreverses);
625        anim.setRepeatCount(self.repeat.to_repeat_count());
626
627        // Set phase offset as timeOffset
628        // For autoreverses, multiply by 2 because the full cycle is forward + backward
629        if self.phase_offset > 0.0 {
630            let cycle_duration = if self.autoreverses {
631                duration_secs * 2.0
632            } else {
633                duration_secs
634            };
635            anim.setTimeOffset(self.phase_offset * cycle_duration);
636        }
637
638        // Set timing function (easing)
639        let timing_function = self.easing.to_timing_function();
640        anim.setTimingFunction(Some(&timing_function));
641
642        // Set fill mode and removedOnCompletion for value persistence
643        if self.remove_on_completion {
644            anim.setRemovedOnCompletion(true);
645            // Default fill mode is fine for removal
646        } else {
647            anim.setRemovedOnCompletion(false);
648            // SAFETY: kCAFillModeForwards is a valid extern static
649            anim.setFillMode(unsafe { kCAFillModeForwards });
650        }
651
652        anim
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659
660    #[test]
661    fn test_key_path_as_str() {
662        assert_eq!(KeyPath::TransformScale.as_str(), "transform.scale");
663        assert_eq!(KeyPath::TransformScaleX.as_str(), "transform.scale.x");
664        assert_eq!(KeyPath::TransformScaleY.as_str(), "transform.scale.y");
665        assert_eq!(KeyPath::TransformRotation.as_str(), "transform.rotation.z");
666        assert_eq!(KeyPath::Opacity.as_str(), "opacity");
667        assert_eq!(KeyPath::Position.as_str(), "position");
668        assert_eq!(KeyPath::PositionX.as_str(), "position.x");
669        assert_eq!(KeyPath::PositionY.as_str(), "position.y");
670        assert_eq!(KeyPath::BackgroundColor.as_str(), "backgroundColor");
671        assert_eq!(KeyPath::CornerRadius.as_str(), "cornerRadius");
672        assert_eq!(KeyPath::BorderWidth.as_str(), "borderWidth");
673        assert_eq!(KeyPath::BorderColor.as_str(), "borderColor");
674        assert_eq!(KeyPath::ShadowOpacity.as_str(), "shadowOpacity");
675        assert_eq!(KeyPath::ShadowRadius.as_str(), "shadowRadius");
676        assert_eq!(KeyPath::ShadowOffset.as_str(), "shadowOffset");
677        assert_eq!(KeyPath::Bounds.as_str(), "bounds");
678        assert_eq!(KeyPath::Custom("custom.path").as_str(), "custom.path");
679    }
680
681    #[test]
682    fn test_easing_default() {
683        assert_eq!(Easing::default(), Easing::InOut);
684    }
685
686    #[test]
687    fn test_repeat_default() {
688        assert_eq!(Repeat::default(), Repeat::Once);
689    }
690
691    #[test]
692    fn test_repeat_to_count() {
693        assert_eq!(Repeat::Once.to_repeat_count(), 1.0);
694        assert_eq!(Repeat::Times(5).to_repeat_count(), 5.0);
695        assert!(Repeat::Forever.to_repeat_count().is_infinite());
696    }
697
698    #[test]
699    fn test_builder_defaults() {
700        let builder = CABasicAnimationBuilder::new(KeyPath::Opacity);
701        assert_eq!(builder.key_path, KeyPath::Opacity);
702        assert_eq!(builder.from_value, None);
703        assert_eq!(builder.to_value, None);
704        assert_eq!(builder.duration, Duration::from_millis(250));
705        assert_eq!(builder.easing, Easing::InOut);
706        assert!(!builder.autoreverses);
707        assert_eq!(builder.repeat, Repeat::Once);
708        assert_eq!(builder.phase_offset, 0.0);
709        assert!(!builder.remove_on_completion);
710    }
711
712    #[test]
713    fn test_builder_chaining() {
714        let builder = CABasicAnimationBuilder::new(KeyPath::TransformScale)
715            .values(0.5, 1.5)
716            .duration(Duration::from_secs(1))
717            .easing(Easing::Linear)
718            .autoreverses()
719            .repeat(Repeat::Forever)
720            .phase_offset(0.25)
721            .remove_on_completion();
722
723        assert_eq!(builder.key_path, KeyPath::TransformScale);
724        assert_eq!(builder.from_value, Some(0.5));
725        assert_eq!(builder.to_value, Some(1.5));
726        assert_eq!(builder.duration, Duration::from_secs(1));
727        assert_eq!(builder.easing, Easing::Linear);
728        assert!(builder.autoreverses);
729        assert_eq!(builder.repeat, Repeat::Forever);
730        assert_eq!(builder.phase_offset, 0.25);
731        assert!(builder.remove_on_completion);
732    }
733}