use std::time::Duration;
use objc2::rc::Retained;
use objc2_foundation::{NSNumber, NSString};
use objc2_quartz_core::{
kCAFillModeForwards, kCAMediaTimingFunctionEaseIn, kCAMediaTimingFunctionEaseInEaseOut,
kCAMediaTimingFunctionEaseOut, kCAMediaTimingFunctionLinear, CABasicAnimation, CAMediaTiming,
CAMediaTimingFunction,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum KeyPath {
TransformScale,
TransformScaleX,
TransformScaleY,
TransformRotation,
Opacity,
Position,
PositionX,
PositionY,
BackgroundColor,
CornerRadius,
BorderWidth,
BorderColor,
ShadowOpacity,
ShadowRadius,
ShadowOffset,
Bounds,
Custom(&'static str),
}
impl KeyPath {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
KeyPath::TransformScale => "transform.scale",
KeyPath::TransformScaleX => "transform.scale.x",
KeyPath::TransformScaleY => "transform.scale.y",
KeyPath::TransformRotation => "transform.rotation.z",
KeyPath::Opacity => "opacity",
KeyPath::Position => "position",
KeyPath::PositionX => "position.x",
KeyPath::PositionY => "position.y",
KeyPath::BackgroundColor => "backgroundColor",
KeyPath::CornerRadius => "cornerRadius",
KeyPath::BorderWidth => "borderWidth",
KeyPath::BorderColor => "borderColor",
KeyPath::ShadowOpacity => "shadowOpacity",
KeyPath::ShadowRadius => "shadowRadius",
KeyPath::ShadowOffset => "shadowOffset",
KeyPath::Bounds => "bounds",
KeyPath::Custom(s) => s,
}
}
fn to_nsstring(self) -> Retained<NSString> {
NSString::from_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Easing {
Linear,
In,
Out,
#[default]
InOut,
}
impl Easing {
fn to_timing_function(self) -> Retained<CAMediaTimingFunction> {
let name = unsafe {
match self {
Easing::Linear => kCAMediaTimingFunctionLinear,
Easing::In => kCAMediaTimingFunctionEaseIn,
Easing::Out => kCAMediaTimingFunctionEaseOut,
Easing::InOut => kCAMediaTimingFunctionEaseInEaseOut,
}
};
CAMediaTimingFunction::functionWithName(name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum Repeat {
#[default]
Once,
Times(u32),
Forever,
}
impl Repeat {
fn to_repeat_count(self) -> f32 {
match self {
Repeat::Once => 1.0,
Repeat::Times(n) => n as f32,
Repeat::Forever => f32::INFINITY,
}
}
}
pub struct CABasicAnimationBuilder {
key_path: KeyPath,
from_value: Option<f64>,
to_value: Option<f64>,
duration: Duration,
easing: Easing,
autoreverses: bool,
repeat: Repeat,
phase_offset: f64,
remove_on_completion: bool,
}
impl CABasicAnimationBuilder {
#[must_use]
pub fn new(key_path: KeyPath) -> Self {
Self {
key_path,
from_value: None,
to_value: None,
duration: Duration::from_millis(250),
easing: Easing::default(),
autoreverses: false,
repeat: Repeat::default(),
phase_offset: 0.0,
remove_on_completion: false,
}
}
#[must_use]
pub fn values(mut self, from: f64, to: f64) -> Self {
self.from_value = Some(from);
self.to_value = Some(to);
self
}
#[must_use]
pub fn duration(mut self, duration: Duration) -> Self {
self.duration = duration;
self
}
#[must_use]
pub fn easing(mut self, easing: Easing) -> Self {
self.easing = easing;
self
}
#[must_use]
pub fn autoreverses(mut self) -> Self {
self.autoreverses = true;
self
}
#[must_use]
pub fn repeat(mut self, repeat: Repeat) -> Self {
self.repeat = repeat;
self
}
#[must_use]
pub fn phase_offset(mut self, offset: f64) -> Self {
self.phase_offset = offset;
self
}
#[must_use]
pub fn remove_on_completion(mut self) -> Self {
self.remove_on_completion = true;
self
}
#[must_use]
pub fn build(self) -> Retained<CABasicAnimation> {
let key_path_str = self.key_path.to_nsstring();
let anim = CABasicAnimation::animationWithKeyPath(Some(&key_path_str));
if let Some(from) = self.from_value {
let from_number = NSNumber::new_f64(from);
unsafe {
anim.setFromValue(Some(&from_number));
}
}
if let Some(to) = self.to_value {
let to_number = NSNumber::new_f64(to);
unsafe {
anim.setToValue(Some(&to_number));
}
}
let duration_secs = self.duration.as_secs_f64();
anim.setDuration(duration_secs);
anim.setAutoreverses(self.autoreverses);
anim.setRepeatCount(self.repeat.to_repeat_count());
if self.phase_offset > 0.0 {
let cycle_duration = if self.autoreverses {
duration_secs * 2.0
} else {
duration_secs
};
anim.setTimeOffset(self.phase_offset * cycle_duration);
}
let timing_function = self.easing.to_timing_function();
anim.setTimingFunction(Some(&timing_function));
if self.remove_on_completion {
anim.setRemovedOnCompletion(true);
} else {
anim.setRemovedOnCompletion(false);
anim.setFillMode(unsafe { kCAFillModeForwards });
}
anim
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_key_path_as_str() {
assert_eq!(KeyPath::TransformScale.as_str(), "transform.scale");
assert_eq!(KeyPath::TransformScaleX.as_str(), "transform.scale.x");
assert_eq!(KeyPath::TransformScaleY.as_str(), "transform.scale.y");
assert_eq!(KeyPath::TransformRotation.as_str(), "transform.rotation.z");
assert_eq!(KeyPath::Opacity.as_str(), "opacity");
assert_eq!(KeyPath::Position.as_str(), "position");
assert_eq!(KeyPath::PositionX.as_str(), "position.x");
assert_eq!(KeyPath::PositionY.as_str(), "position.y");
assert_eq!(KeyPath::BackgroundColor.as_str(), "backgroundColor");
assert_eq!(KeyPath::CornerRadius.as_str(), "cornerRadius");
assert_eq!(KeyPath::BorderWidth.as_str(), "borderWidth");
assert_eq!(KeyPath::BorderColor.as_str(), "borderColor");
assert_eq!(KeyPath::ShadowOpacity.as_str(), "shadowOpacity");
assert_eq!(KeyPath::ShadowRadius.as_str(), "shadowRadius");
assert_eq!(KeyPath::ShadowOffset.as_str(), "shadowOffset");
assert_eq!(KeyPath::Bounds.as_str(), "bounds");
assert_eq!(KeyPath::Custom("custom.path").as_str(), "custom.path");
}
#[test]
fn test_easing_default() {
assert_eq!(Easing::default(), Easing::InOut);
}
#[test]
fn test_repeat_default() {
assert_eq!(Repeat::default(), Repeat::Once);
}
#[test]
fn test_repeat_to_count() {
assert_eq!(Repeat::Once.to_repeat_count(), 1.0);
assert_eq!(Repeat::Times(5).to_repeat_count(), 5.0);
assert!(Repeat::Forever.to_repeat_count().is_infinite());
}
#[test]
fn test_builder_defaults() {
let builder = CABasicAnimationBuilder::new(KeyPath::Opacity);
assert_eq!(builder.key_path, KeyPath::Opacity);
assert_eq!(builder.from_value, None);
assert_eq!(builder.to_value, None);
assert_eq!(builder.duration, Duration::from_millis(250));
assert_eq!(builder.easing, Easing::InOut);
assert!(!builder.autoreverses);
assert_eq!(builder.repeat, Repeat::Once);
assert_eq!(builder.phase_offset, 0.0);
assert!(!builder.remove_on_completion);
}
#[test]
fn test_builder_chaining() {
let builder = CABasicAnimationBuilder::new(KeyPath::TransformScale)
.values(0.5, 1.5)
.duration(Duration::from_secs(1))
.easing(Easing::Linear)
.autoreverses()
.repeat(Repeat::Forever)
.phase_offset(0.25)
.remove_on_completion();
assert_eq!(builder.key_path, KeyPath::TransformScale);
assert_eq!(builder.from_value, Some(0.5));
assert_eq!(builder.to_value, Some(1.5));
assert_eq!(builder.duration, Duration::from_secs(1));
assert_eq!(builder.easing, Easing::Linear);
assert!(builder.autoreverses);
assert_eq!(builder.repeat, Repeat::Forever);
assert_eq!(builder.phase_offset, 0.25);
assert!(builder.remove_on_completion);
}
}