Files
maspotlighttour/MarketAlly.MASpotlightTour/Animations/AnimationHelper.cs

660 lines
24 KiB
C#

namespace MarketAlly.SpotlightTour.Maui.Animations;
/// <summary>
/// Helper class for executing tour animations on visual elements.
/// </summary>
public static class AnimationHelper
{
private static readonly Random _random = new();
/// <summary>
/// Converts AnimationEasing enum to MAUI Easing.
/// </summary>
public static Easing ToMauiEasing(AnimationEasing easing)
{
return easing switch
{
AnimationEasing.Linear => Easing.Linear,
AnimationEasing.CubicIn => Easing.CubicIn,
AnimationEasing.CubicOut => Easing.CubicOut,
AnimationEasing.CubicInOut => Easing.CubicInOut,
AnimationEasing.SpringOut => Easing.SpringOut,
AnimationEasing.BounceOut => Easing.BounceOut,
AnimationEasing.SinIn => Easing.SinIn,
AnimationEasing.SinOut => Easing.SinOut,
AnimationEasing.Elastic => CreateElasticEasing(),
_ => Easing.CubicInOut
};
}
/// <summary>
/// Creates a custom elastic easing function.
/// </summary>
private static Easing CreateElasticEasing()
{
return new Easing(t =>
{
if (t == 0 || t == 1) return t;
var p = 0.3;
var s = p / 4;
return Math.Pow(2, -10 * t) * Math.Sin((t - s) * (2 * Math.PI) / p) + 1;
});
}
#region Entrance Animations
/// <summary>
/// Plays an entrance animation on an element.
/// </summary>
public static async Task PlayEntranceAsync(
VisualElement element,
EntranceAnimation animation,
AnimationConfiguration config,
CancellationToken cancellationToken = default)
{
// Ensure we're on the main thread for UI operations
if (!MainThread.IsMainThread)
{
await MainThread.InvokeOnMainThreadAsync(() =>
PlayEntranceAsync(element, animation, config, cancellationToken));
return;
}
// Capture the target position BEFORE any modifications
// This preserves positions set by corner positioning, etc.
var targetX = element.TranslationX;
var targetY = element.TranslationY;
if (animation == EntranceAnimation.None || config.EntranceDurationMs == 0)
{
element.Opacity = 1;
element.Scale = 1;
// Preserve the target position instead of resetting to 0
element.TranslationX = targetX;
element.TranslationY = targetY;
element.IsVisible = true;
return;
}
var duration = config.EntranceDurationMs;
var easing = ToMauiEasing(config.EntranceEasing);
// Set initial state (with offset from target position)
SetEntranceInitialState(element, animation, config, targetX, targetY);
element.IsVisible = true;
// Apply delay if configured
if (config.EntranceDelayMs > 0)
{
await Task.Delay((int)config.EntranceDelayMs, cancellationToken);
}
cancellationToken.ThrowIfCancellationRequested();
// Animate to final state (the target position)
await AnimateEntranceAsync(element, animation, duration, easing, config, targetX, targetY);
}
private static void SetEntranceInitialState(VisualElement element, EntranceAnimation animation, AnimationConfiguration config, double targetX, double targetY)
{
element.Opacity = 0;
element.Scale = 1;
// Start at the target position (preserves corner positioning, etc.)
element.TranslationX = targetX;
element.TranslationY = targetY;
element.RotationY = 0;
switch (animation)
{
case EntranceAnimation.FadeIn:
// Just opacity change
break;
case EntranceAnimation.SlideFromLeft:
element.TranslationX = targetX - config.SlideDistance;
break;
case EntranceAnimation.SlideFromRight:
element.TranslationX = targetX + config.SlideDistance;
break;
case EntranceAnimation.SlideFromTop:
element.TranslationY = targetY - config.SlideDistance;
break;
case EntranceAnimation.SlideFromBottom:
case EntranceAnimation.SlideUpFade:
element.TranslationY = targetY + config.SlideDistance;
break;
case EntranceAnimation.SlideDownFade:
element.TranslationY = targetY - config.SlideDistance;
break;
case EntranceAnimation.ScaleUp:
case EntranceAnimation.PopIn:
element.Scale = config.ScaleDownFactor;
break;
case EntranceAnimation.ScaleDown:
element.Scale = config.ScaleUpFactor;
break;
case EntranceAnimation.BounceIn:
case EntranceAnimation.ZoomIn:
element.Scale = 0;
break;
case EntranceAnimation.FlipIn:
element.RotationY = -90;
break;
}
}
private static async Task AnimateEntranceAsync(
VisualElement element,
EntranceAnimation animation,
uint duration,
Easing easing,
AnimationConfiguration config,
double targetX,
double targetY)
{
var tasks = new List<Task>();
switch (animation)
{
case EntranceAnimation.FadeIn:
tasks.Add(element.FadeTo(1, duration, easing));
break;
case EntranceAnimation.SlideFromLeft:
case EntranceAnimation.SlideFromRight:
tasks.Add(element.FadeTo(1, duration, easing));
tasks.Add(element.TranslateTo(targetX, targetY, duration, easing));
break;
case EntranceAnimation.SlideFromTop:
case EntranceAnimation.SlideFromBottom:
tasks.Add(element.FadeTo(1, duration, easing));
tasks.Add(element.TranslateTo(targetX, targetY, duration, easing));
break;
case EntranceAnimation.SlideUpFade:
case EntranceAnimation.SlideDownFade:
tasks.Add(element.FadeTo(1, duration, easing));
tasks.Add(element.TranslateTo(targetX, targetY, duration, easing));
break;
case EntranceAnimation.ScaleUp:
case EntranceAnimation.ScaleDown:
tasks.Add(element.FadeTo(1, duration, easing));
tasks.Add(element.ScaleTo(1, duration, easing));
break;
case EntranceAnimation.BounceIn:
tasks.Add(element.FadeTo(1, duration / 2, easing));
tasks.Add(AnimateBounceInAsync(element, duration));
break;
case EntranceAnimation.ZoomIn:
tasks.Add(element.FadeTo(1, duration, easing));
tasks.Add(AnimateZoomInAsync(element, duration, config));
break;
case EntranceAnimation.PopIn:
tasks.Add(element.FadeTo(1, duration, easing));
tasks.Add(AnimatePopInAsync(element, duration, config));
break;
case EntranceAnimation.FlipIn:
tasks.Add(element.FadeTo(1, duration / 2, easing));
tasks.Add(element.RotateYTo(0, duration, easing));
break;
}
await Task.WhenAll(tasks).ConfigureAwait(false);
}
private static async Task AnimateBounceInAsync(VisualElement element, uint duration)
{
var animation = new Animation();
animation.Add(0, 0.6, new Animation(v => element.Scale = v, 0, 1.15, Easing.CubicOut));
animation.Add(0.6, 0.8, new Animation(v => element.Scale = v, 1.15, 0.9, Easing.CubicInOut));
animation.Add(0.8, 1.0, new Animation(v => element.Scale = v, 0.9, 1.0, Easing.CubicOut));
var tcs = new TaskCompletionSource<bool>();
animation.Commit(element, "BounceIn", length: duration, finished: (_, _) => tcs.SetResult(true));
await tcs.Task.ConfigureAwait(false);
}
private static async Task AnimateZoomInAsync(VisualElement element, uint duration, AnimationConfiguration config)
{
var animation = new Animation();
animation.Add(0, 0.7, new Animation(v => element.Scale = v, 0, config.ScaleUpFactor, Easing.CubicOut));
animation.Add(0.7, 1.0, new Animation(v => element.Scale = v, config.ScaleUpFactor, 1.0, Easing.CubicInOut));
var tcs = new TaskCompletionSource<bool>();
animation.Commit(element, "ZoomIn", length: duration, finished: (_, _) => tcs.SetResult(true));
await tcs.Task.ConfigureAwait(false);
}
private static async Task AnimatePopInAsync(VisualElement element, uint duration, AnimationConfiguration config)
{
var animation = new Animation();
animation.Add(0, 0.5, new Animation(v => element.Scale = v, config.ScaleDownFactor, 1.05, Easing.CubicOut));
animation.Add(0.5, 1.0, new Animation(v => element.Scale = v, 1.05, 1.0, Easing.CubicInOut));
var tcs = new TaskCompletionSource<bool>();
animation.Commit(element, "PopIn", length: duration, finished: (_, _) => tcs.SetResult(true));
await tcs.Task.ConfigureAwait(false);
}
#endregion
#region Exit Animations
/// <summary>
/// Plays an exit animation on an element.
/// </summary>
public static async Task PlayExitAsync(
VisualElement element,
ExitAnimation animation,
AnimationConfiguration config,
CancellationToken cancellationToken = default)
{
// Ensure we're on the main thread for UI operations
if (!MainThread.IsMainThread)
{
await MainThread.InvokeOnMainThreadAsync(() =>
PlayExitAsync(element, animation, config, cancellationToken));
return;
}
if (animation == ExitAnimation.None || config.ExitDurationMs == 0)
{
element.IsVisible = false;
return;
}
var duration = config.ExitDurationMs;
var easing = ToMauiEasing(config.ExitEasing);
cancellationToken.ThrowIfCancellationRequested();
await AnimateExitAsync(element, animation, duration, easing, config);
element.IsVisible = false;
// Reset properties for next use
element.Opacity = 1;
element.Scale = 1;
element.TranslationX = 0;
element.TranslationY = 0;
}
private static async Task AnimateExitAsync(
VisualElement element,
ExitAnimation animation,
uint duration,
Easing easing,
AnimationConfiguration config)
{
var tasks = new List<Task>();
switch (animation)
{
case ExitAnimation.FadeOut:
tasks.Add(element.FadeTo(0, duration, easing));
break;
case ExitAnimation.SlideToLeft:
tasks.Add(element.FadeTo(0, duration, easing));
tasks.Add(element.TranslateTo(-config.SlideDistance, element.TranslationY, duration, easing));
break;
case ExitAnimation.SlideToRight:
tasks.Add(element.FadeTo(0, duration, easing));
tasks.Add(element.TranslateTo(config.SlideDistance, element.TranslationY, duration, easing));
break;
case ExitAnimation.SlideToTop:
case ExitAnimation.SlideUpFade:
tasks.Add(element.FadeTo(0, duration, easing));
tasks.Add(element.TranslateTo(element.TranslationX, -config.SlideDistance, duration, easing));
break;
case ExitAnimation.SlideToBottom:
case ExitAnimation.SlideDownFade:
tasks.Add(element.FadeTo(0, duration, easing));
tasks.Add(element.TranslateTo(element.TranslationX, config.SlideDistance, duration, easing));
break;
case ExitAnimation.ScaleDown:
case ExitAnimation.PopOut:
tasks.Add(element.FadeTo(0, duration, easing));
tasks.Add(element.ScaleTo(config.ScaleDownFactor, duration, easing));
break;
case ExitAnimation.ScaleUp:
tasks.Add(element.FadeTo(0, duration, easing));
tasks.Add(element.ScaleTo(config.ScaleUpFactor, duration, easing));
break;
case ExitAnimation.ZoomOut:
tasks.Add(element.FadeTo(0, duration, easing));
tasks.Add(element.ScaleTo(0, duration, easing));
break;
}
await Task.WhenAll(tasks).ConfigureAwait(false);
}
#endregion
#region Spotlight Effects
/// <summary>
/// Starts a spotlight effect animation.
/// </summary>
/// <param name="element">The spotlight overlay element.</param>
/// <param name="effect">The effect to apply.</param>
/// <param name="config">Animation configuration.</param>
/// <param name="cancellationToken">Cancellation token to stop the effect.</param>
public static async Task PlaySpotlightEffectAsync(
VisualElement element,
SpotlightEffect effect,
AnimationConfiguration config,
CancellationToken cancellationToken = default)
{
if (effect == SpotlightEffect.None)
return;
var repeatCount = config.SpotlightEffectRepeat;
var iterations = 0;
while (!cancellationToken.IsCancellationRequested && (repeatCount == 0 || iterations < repeatCount))
{
iterations++;
try
{
switch (effect)
{
case SpotlightEffect.Pulse:
await PlayPulseEffectAsync(element, config, cancellationToken).ConfigureAwait(false);
break;
case SpotlightEffect.Breathe:
await PlayBreatheEffectAsync(element, config, cancellationToken).ConfigureAwait(false);
break;
case SpotlightEffect.Glow:
await PlayGlowEffectAsync(element, config, cancellationToken).ConfigureAwait(false);
break;
case SpotlightEffect.Shimmer:
await PlayShimmerEffectAsync(element, config, cancellationToken).ConfigureAwait(false);
break;
case SpotlightEffect.Ripple:
await PlayRippleEffectAsync(element, config, cancellationToken).ConfigureAwait(false);
break;
}
}
catch (OperationCanceledException)
{
break;
}
}
}
private static async Task PlayPulseEffectAsync(VisualElement element, AnimationConfiguration config, CancellationToken ct)
{
var duration = config.PulseEffectDurationMs / 2;
var intensity = config.SpotlightEffectIntensity;
// Scale up
await element.ScaleTo(1 + intensity * 0.05, duration, Easing.SinInOut).ConfigureAwait(false);
ct.ThrowIfCancellationRequested();
// Scale back
await element.ScaleTo(1, duration, Easing.SinInOut).ConfigureAwait(false);
}
private static async Task PlayBreatheEffectAsync(VisualElement element, AnimationConfiguration config, CancellationToken ct)
{
var duration = config.PulseEffectDurationMs;
var intensity = config.SpotlightEffectIntensity;
// Slow breathe in
await element.ScaleTo(1 + intensity * 0.03, duration / 2, Easing.SinInOut).ConfigureAwait(false);
ct.ThrowIfCancellationRequested();
// Hold briefly
await Task.Delay(100, ct).ConfigureAwait(false);
// Slow breathe out
await element.ScaleTo(1, duration / 2, Easing.SinInOut).ConfigureAwait(false);
}
private static async Task PlayGlowEffectAsync(VisualElement element, AnimationConfiguration config, CancellationToken ct)
{
var duration = config.PulseEffectDurationMs / 2;
var baseOpacity = element.Opacity;
var glowAmount = config.SpotlightEffectIntensity * 0.2;
// Brighten
await element.FadeTo(Math.Min(1, baseOpacity + glowAmount), duration, Easing.SinInOut).ConfigureAwait(false);
ct.ThrowIfCancellationRequested();
// Return to normal
await element.FadeTo(baseOpacity, duration, Easing.SinInOut).ConfigureAwait(false);
}
private static async Task PlayShimmerEffectAsync(VisualElement element, AnimationConfiguration config, CancellationToken ct)
{
var duration = config.PulseEffectDurationMs / 4;
// Quick opacity fluctuation
for (int i = 0; i < 3 && !ct.IsCancellationRequested; i++)
{
var targetOpacity = 0.85 + (_random.NextDouble() * 0.15);
await element.FadeTo(targetOpacity, duration, Easing.Linear).ConfigureAwait(false);
}
await element.FadeTo(1, duration, Easing.Linear).ConfigureAwait(false);
}
private static async Task PlayRippleEffectAsync(VisualElement element, AnimationConfiguration config, CancellationToken ct)
{
var duration = config.PulseEffectDurationMs;
var intensity = config.SpotlightEffectIntensity;
// Create ripple effect using scale animation
var animation = new Animation();
animation.Add(0, 0.5, new Animation(v => element.Scale = v, 1, 1 + intensity * 0.08, Easing.CubicOut));
animation.Add(0.5, 1.0, new Animation(v => element.Scale = v, 1 + intensity * 0.08, 1, Easing.CubicIn));
var tcs = new TaskCompletionSource<bool>();
animation.Commit(element, "Ripple", length: duration, finished: (_, _) => tcs.SetResult(true));
using var registration = ct.Register(() => tcs.TrySetCanceled());
await tcs.Task.ConfigureAwait(false);
}
#endregion
#region Step Transitions
/// <summary>
/// Animates transition between steps for a callout element.
/// </summary>
public static async Task AnimateStepTransitionAsync(
VisualElement element,
StepTransition transition,
bool isForward,
AnimationConfiguration config,
CancellationToken cancellationToken = default)
{
// Ensure we're on the main thread for UI operations
if (!MainThread.IsMainThread)
{
await MainThread.InvokeOnMainThreadAsync(() =>
AnimateStepTransitionAsync(element, transition, isForward, config, cancellationToken));
return;
}
// No animation for None transition - just return immediately
if (transition == StepTransition.None)
return;
var duration = config.StepTransitionDurationMs;
var easing = ToMauiEasing(config.SpotlightEasing);
switch (transition)
{
case StepTransition.Crossfade:
await AnimateCrossfadeAsync(element, duration, easing);
break;
case StepTransition.Slide:
case StepTransition.Push:
await AnimateSlideTransitionAsync(element, isForward, duration, easing, config);
break;
case StepTransition.Flip:
await AnimateFlipTransitionAsync(element, isForward, duration, easing);
break;
default:
await AnimateCrossfadeAsync(element, duration, easing);
break;
}
}
private static async Task AnimateCrossfadeAsync(VisualElement element, uint duration, Easing easing)
{
// Quick fade out and in
await element.FadeTo(0.3, duration / 2, easing).ConfigureAwait(false);
await element.FadeTo(1, duration / 2, easing).ConfigureAwait(false);
}
private static async Task AnimateSlideTransitionAsync(
VisualElement element,
bool isForward,
uint duration,
Easing easing,
AnimationConfiguration config)
{
var direction = isForward ? 1 : -1;
var distance = config.SlideDistance * direction;
// Save original position to restore after animation
var originalX = element.TranslationX;
var originalY = element.TranslationY;
// Slide out (relative to current position)
await Task.WhenAll(
element.FadeTo(0, duration / 2, easing),
element.TranslateTo(originalX + distance, originalY, duration / 2, easing)
);
// Reset position to opposite side (relative to original)
element.TranslationX = originalX - distance;
// Slide in back to original position
await Task.WhenAll(
element.FadeTo(1, duration / 2, easing),
element.TranslateTo(originalX, originalY, duration / 2, easing)
);
}
private static async Task AnimateFlipTransitionAsync(
VisualElement element,
bool isForward,
uint duration,
Easing easing)
{
var direction = isForward ? 1 : -1;
// Flip out
await element.RotateYTo(90 * direction, duration / 2, easing).ConfigureAwait(false);
// Flip in from opposite side
element.RotationY = -90 * direction;
await element.RotateYTo(0, duration / 2, easing).ConfigureAwait(false);
}
#endregion
#region Utility Methods
/// <summary>
/// Cancels all animations on an element.
/// </summary>
public static void CancelAnimations(VisualElement element)
{
element.CancelAnimations();
}
/// <summary>
/// Resets element to default visual state.
/// </summary>
public static void ResetElement(VisualElement element)
{
// Ensure we're on the main thread for UI operations
if (!MainThread.IsMainThread)
{
MainThread.BeginInvokeOnMainThread(() => ResetElement(element));
return;
}
element.CancelAnimations();
element.Opacity = 1;
element.Scale = 1;
element.TranslationX = 0;
element.TranslationY = 0;
element.RotationY = 0;
element.Rotation = 0;
}
/// <summary>
/// Creates a shake animation for error feedback.
/// </summary>
public static async Task ShakeAsync(VisualElement element, uint duration = 400)
{
var animation = new Animation();
animation.Add(0, 0.125, new Animation(v => element.TranslationX = v, 0, -10, Easing.Linear));
animation.Add(0.125, 0.25, new Animation(v => element.TranslationX = v, -10, 10, Easing.Linear));
animation.Add(0.25, 0.375, new Animation(v => element.TranslationX = v, 10, -10, Easing.Linear));
animation.Add(0.375, 0.5, new Animation(v => element.TranslationX = v, -10, 10, Easing.Linear));
animation.Add(0.5, 0.625, new Animation(v => element.TranslationX = v, 10, -5, Easing.Linear));
animation.Add(0.625, 0.75, new Animation(v => element.TranslationX = v, -5, 5, Easing.Linear));
animation.Add(0.75, 0.875, new Animation(v => element.TranslationX = v, 5, -2, Easing.Linear));
animation.Add(0.875, 1.0, new Animation(v => element.TranslationX = v, -2, 0, Easing.Linear));
var tcs = new TaskCompletionSource<bool>();
animation.Commit(element, "Shake", length: duration, finished: (_, _) => tcs.SetResult(true));
await tcs.Task.ConfigureAwait(false);
}
/// <summary>
/// Creates a heartbeat/attention animation.
/// </summary>
public static async Task HeartbeatAsync(VisualElement element, uint duration = 800)
{
var animation = new Animation();
animation.Add(0, 0.14, new Animation(v => element.Scale = v, 1, 1.1, Easing.CubicOut));
animation.Add(0.14, 0.28, new Animation(v => element.Scale = v, 1.1, 1, Easing.CubicIn));
animation.Add(0.42, 0.56, new Animation(v => element.Scale = v, 1, 1.1, Easing.CubicOut));
animation.Add(0.56, 0.70, new Animation(v => element.Scale = v, 1.1, 1, Easing.CubicIn));
var tcs = new TaskCompletionSource<bool>();
animation.Commit(element, "Heartbeat", length: duration, finished: (_, _) => tcs.SetResult(true));
await tcs.Task.ConfigureAwait(false);
}
#endregion
}