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
118 changes: 118 additions & 0 deletions PhotonUI/Animation/AnimationHandle.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using PhotonUI.Events.Framework;

namespace PhotonUI.Animations
{
public enum AnimationState
{
Ready,
Running,
Paused,
Stopped,
Completed,
Canceled,
Invalid
}

public sealed class AnimationHandle(AnimationBase inner)
{
public event EventHandler<AnimationEventArgs>? StateChanged;

private readonly AnimationBase inner = inner;
private AnimationState state = AnimationState.Ready;
private bool isValid = true;

public AnimationState State => this.state;
public bool IsValid => this.isValid;
public bool IsComplete
{
get
{
this.EnsureValid();

return this.state == AnimationState.Completed;
}
}

public void Invalidate()
=> this.isValid = false;
public void EnsureValid()
{
if (!this.isValid)
throw new InvalidOperationException("Animation handle is no longer valid.");
}

public void Start()
{
this.EnsureValid();

if (this.state == AnimationState.Ready || this.state == AnimationState.Paused)
{
this.inner.Start();
this.state = AnimationState.Running;

this.RaiseStateChanged();
}
}
public void Update()
{
this.EnsureValid();

if (this.state == AnimationState.Running)
{
this.inner.Update();

if (this.inner.IsComplete)
{
this.state = AnimationState.Completed;

this.RaiseStateChanged();
}
}
}

public void Pause()
{
this.EnsureValid();

if (this.state == AnimationState.Running)
{
this.state = AnimationState.Paused;

this.RaiseStateChanged();
}
}
public void Resume()
{
this.EnsureValid();

if (this.state == AnimationState.Paused)
{
this.state = AnimationState.Running;

this.RaiseStateChanged();
}
}
public void Stop()
{
this.EnsureValid();

if (this.state == AnimationState.Running || this.state == AnimationState.Paused)
{
this.state = AnimationState.Stopped;

this.RaiseStateChanged();
}
}

public void Cancel()
{
this.EnsureValid();
this.state = AnimationState.Canceled;
this.Invalidate();
this.RaiseStateChanged();
}

private void RaiseStateChanged()
=> StateChanged?.Invoke(this, new AnimationEventArgs(this, this.state));
}
}
32 changes: 32 additions & 0 deletions PhotonUI/Animation/AnimationNode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using CommunityToolkit.Mvvm.ComponentModel;
using PhotonUI.Animations.AnimationNodes;
using PhotonUI.Controls;

namespace PhotonUI.Animations
{
public abstract partial class AnimationBase : ObservableObject
{
public abstract void Start();
public abstract void Update();
public abstract bool IsComplete { get; }

public AnimationBase Then(AnimationBase next)
=> new SequenceAnimation(this, next);
public AnimationBase Group(AnimationBase other)
=> new GroupAnimation(this, other);
public AnimationBase Wait(TimeSpan delay)
=> new SequenceAnimation(this, new WaitAnimation(delay));
public AnimationBase OnComplete(Action callback)
=> new SequenceAnimation(this, new CallbackAnimation(callback));

public AnimationHandle Enqueue(Window window)
{
AnimationHandle handle = window.AnimationEnqueue(this);

if (handle.State == AnimationState.Ready)
handle.Start();

return handle;
}
}
}
20 changes: 20 additions & 0 deletions PhotonUI/Animation/Nodes/CallbackAnimation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace PhotonUI.Animations.AnimationNodes
{
public partial class CallbackAnimation(Action callback) : AnimationBase
{
private readonly Action callback = callback;
private bool fired;

public override void Start()
{
if (!this.fired)
{
this.fired = true;
this.callback();
}
}

public override void Update() { }
public override bool IsComplete => this.fired;
}
}
11 changes: 11 additions & 0 deletions PhotonUI/Animation/Nodes/GroupAnimation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace PhotonUI.Animations.AnimationNodes
{
public partial class GroupAnimation(params AnimationBase[] animations) : AnimationBase
{
private readonly List<AnimationBase> animations = [.. animations];

public override void Start() { foreach (AnimationBase a in this.animations) a.Start(); }
public override void Update() { foreach (AnimationBase a in this.animations) a.Update(); }
public override bool IsComplete => this.animations.All(a => a.IsComplete);
}
}
63 changes: 63 additions & 0 deletions PhotonUI/Animation/Nodes/PropertyAnimation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using CommunityToolkit.Mvvm.ComponentModel;
using PhotonUI.Controls;
using PhotonUI.Interfaces.Services;
using PhotonUI.Services;

namespace PhotonUI.Animations
{
public partial class PropertyAnimation<TTarget, TProp>(IInterpolatorService lerpService) : AnimationBase
where TTarget : Control
{
private readonly IInterpolatorService lerpService = lerpService;
private readonly IInterpolator<TProp> interpolator = lerpService.Get<TProp>();

private TProp? start;
private TProp? end;
private TimeSpan duration;
private Func<float, float> easing = t => t;
private DateTime startTime;

private bool loop;
private Func<TProp, TProp>? nextTargetFactory;
private TimeSpan epsilon = TimeSpan.FromMilliseconds(1);

[ObservableProperty]
private TProp? value;

public override bool IsComplete => !this.loop && (DateTime.UtcNow - this.startTime) >= this.duration;

public PropertyAnimation<TTarget, TProp> From(TProp start) { this.start = start; return this; }
public PropertyAnimation<TTarget, TProp> To(TProp end) { this.end = end; return this; }
public PropertyAnimation<TTarget, TProp> Over(TimeSpan duration) { this.duration = duration; return this; }
public PropertyAnimation<TTarget, TProp> WithEasing(Func<float, float> easing) { this.easing = easing; return this; }
public PropertyAnimation<TTarget, TProp> Loop(Func<TProp, TProp> nextFactory) { this.loop = true; this.nextTargetFactory = nextFactory; return this; }
public PropertyAnimation<TTarget, TProp> WithLoopOverlap(TimeSpan overlap) { this.epsilon = overlap; return this; }

public override void Start() => this.startTime = DateTime.UtcNow;
public override void Update()
{
if (this.duration == TimeSpan.Zero || this.start is null || this.end is null) return;

float elapsedMs = (float)(DateTime.UtcNow - this.startTime).TotalMilliseconds;
float durationMs = (float)this.duration.TotalMilliseconds;

if (this.loop && elapsedMs >= durationMs - (float)this.epsilon.TotalMilliseconds)
{
this.Value = this.end;
TProp current = this.end!;
TProp? next = this.nextTargetFactory!(current);

this.start = current;
this.end = next;
this.startTime = DateTime.UtcNow;

elapsedMs = 0f;
}

float progress = Math.Clamp(elapsedMs / durationMs, 0f, 1f);
float eased = this.easing(progress);

this.Value = this.interpolator.Lerp(this.start!, this.end!, eased);
}
}
}
22 changes: 22 additions & 0 deletions PhotonUI/Animation/Nodes/SequenceAnimation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace PhotonUI.Animations.AnimationNodes
{
public partial class SequenceAnimation(params AnimationBase[] animations) : AnimationBase
{
private readonly Queue<AnimationBase> queue = new(animations);
private AnimationBase? current;

public override void Start() => this.Advance();
public override void Update()
{
this.current?.Update();
if (this.current?.IsComplete == true) this.Advance();
}
public override bool IsComplete => this.current == null;

private void Advance()
{
this.current = this.queue.Count > 0 ? this.queue.Dequeue() : null;
this.current?.Start();
}
}
}
12 changes: 12 additions & 0 deletions PhotonUI/Animation/Nodes/WaitAnimation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace PhotonUI.Animations.AnimationNodes
{
public partial class WaitAnimation(TimeSpan duration) : AnimationBase
{
private readonly TimeSpan duration = duration;
private DateTime start;

public override void Start() => this.start = DateTime.UtcNow;
public override void Update() { }
public override bool IsComplete => (DateTime.UtcNow - this.start) >= this.duration;
}
}
27 changes: 27 additions & 0 deletions PhotonUI/Components/AnimationBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using PhotonUI.Animations;
using PhotonUI.Controls;
using PhotonUI.Interfaces.Services;
using System.Linq.Expressions;
using System.Reflection;

namespace PhotonUI.Components
{
public class AnimationBuilder(IInterpolatorService lerpService, IBindingService bindingService) : IAnimationBuilder
{
private readonly IInterpolatorService lerpService = lerpService;
private readonly IBindingService bindingService = bindingService;

public PropertyAnimation<TTarget, TProp> BuildPropertyAnimation<TTarget, TProp>(TTarget target, Expression<Func<TTarget, TProp>> selector)
where TTarget : Control
{
if (selector.Body is not MemberExpression member || member.Member is not PropertyInfo propInfo)
throw new InvalidOperationException("Selector must be a property expression");

PropertyAnimation<TTarget, TProp> anim = new(this.lerpService);

this.bindingService.Bind(target, propInfo.Name, anim, nameof(anim.Value), twoWay: false);

return anim;
}
}
}
4 changes: 2 additions & 2 deletions PhotonUI/Controls/Composites/UserWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace PhotonUI.Controls.Composites
{
public partial class UserWindow(IServiceProvider serviceProvider, IBindingService bindingService, IKeyBindingService keyBindingService)
: Window(serviceProvider, bindingService, keyBindingService)
public partial class UserWindow(IServiceProvider serviceProvider, IBindingService bindingService, IKeyBindingService keyBindingService, IAnimationBuilder animationBulder)
: Window(serviceProvider, bindingService, keyBindingService, animationBulder)
{ }
}
Loading