diff --git a/MapWizard.Desktop/MapWizard.Desktop.csproj b/MapWizard.Desktop/MapWizard.Desktop.csproj index 433af60..aef2a45 100644 --- a/MapWizard.Desktop/MapWizard.Desktop.csproj +++ b/MapWizard.Desktop/MapWizard.Desktop.csproj @@ -20,21 +20,21 @@ - + - + - - - - + + + + @@ -42,7 +42,7 @@ - + diff --git a/MapWizard.Desktop/Program.cs b/MapWizard.Desktop/Program.cs index 42e628a..1c0d6c7 100644 --- a/MapWizard.Desktop/Program.cs +++ b/MapWizard.Desktop/Program.cs @@ -55,6 +55,7 @@ private static AppBuilder BuildAvaloniaApp(bool forceSoftwareRendering) ] }); } + return app; } diff --git a/MapWizard.Desktop/Transitions/BlurSlideFadeTransition.cs b/MapWizard.Desktop/Transitions/BlurSlideFadeTransition.cs index 453f6a0..c7424f9 100644 --- a/MapWizard.Desktop/Transitions/BlurSlideFadeTransition.cs +++ b/MapWizard.Desktop/Transitions/BlurSlideFadeTransition.cs @@ -1,20 +1,27 @@ using System; +using System.Numerics; using System.Threading; using System.Threading.Tasks; using Avalonia; using Avalonia.Animation; +using Avalonia.Animation.Easings; using Avalonia.Controls; using Avalonia.Media; +using Avalonia.Rendering.Composition; +using Avalonia.Styling; namespace MapWizard.Desktop.Transitions; /// /// Page transition that blurs and slides the old page out while fading, -/// then unblurs and settles the new page in. Driven by stepped interpolation -/// so it cannot stall. +/// then unblurs and settles the new page in. All animated properties are +/// render-only; the blur is interpolated as an Avalonia effect and rendered +/// by the Skia backend. /// public sealed class BlurSlideFadeTransition : IPageTransition { + private readonly SemaphoreSlim _transitionGate = new(1, 1); + public TimeSpan OutDuration { get; set; } = TimeSpan.FromMilliseconds(160); public TimeSpan InDuration { get; set; } = TimeSpan.FromMilliseconds(300); @@ -25,19 +32,23 @@ public sealed class BlurSlideFadeTransition : IPageTransition public async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) { - var direction = forward ? 1d : -1d; + // TransitioningContentControl can start the replacement transition before + // the canceled transition has unwound. Let the old transition restore its + // compositor state first so it cannot leave (or later reapply) an offset. + await _transitionGate.WaitAsync(cancellationToken); - // TransitioningContentControl adds the incoming presenter before calling Start. - // Hide it synchronously so it cannot paint a frame over the outgoing page. - if (to is not null) - { - to.IsVisible = false; - to.Opacity = 0d; - to.RenderTransform = new TranslateTransform(direction * SlideDistance, 0d); - } + var direction = forward ? 1d : -1d; try { + // TransitioningContentControl adds the incoming presenter before calling Start. + // Hide it synchronously so it cannot paint a frame over the outgoing page. + if (to is not null) + { + ResetVisualState(to); + to.IsVisible = false; + } + if (from is not null) { await AnimateOutAsync(from, direction, cancellationToken); @@ -54,62 +65,247 @@ public async Task Start(Visual? from, Visual? to, bool forward, CancellationToke { if (from is not null) { - from.Effect = null; - from.Opacity = 1d; - from.RenderTransform = new TranslateTransform(0d, 0d); + ResetVisualState(from); } if (to is not null) { - to.Effect = null; - to.Opacity = 1d; + ResetVisualState(to); to.IsVisible = true; - to.RenderTransform = new TranslateTransform(0d, 0d); } + + _transitionGate.Release(); } } - private async Task AnimateOutAsync(Visual from, double direction, CancellationToken cancellationToken) + private static void ResetVisualState(Visual visual) { - var blur = new BlurEffect { Radius = 0d }; - var translation = new TranslateTransform(); - from.Effect = blur; - from.RenderTransform = translation; + visual.Effect = null; + visual.Opacity = 1d; + visual.RenderTransform = new TranslateTransform(0d, 0d); - const int steps = 10; - for (var i = 1; i <= steps; i++) + if (ElementComposition.GetElementVisual(visual) is { } compositionVisual) { - var t = SmoothStep((double)i / steps); - from.Opacity = 1d - t; - translation.X = -direction * SlideDistance * t; - blur.Radius = MaxBlurRadius * t; - await Task.Delay(OutDuration / steps, cancellationToken); + compositionVisual.StopAnimation("Opacity"); + compositionVisual.StopAnimation("Translation"); + compositionVisual.Opacity = 1f; + compositionVisual.Translation = default; } } + private async Task AnimateOutAsync(Visual from, double direction, CancellationToken cancellationToken) + { + await AnimateAsync( + from, + OutDuration, + new CubicEaseIn(), + startOpacity: 1d, + endOpacity: 0d, + startOffset: 0d, + endOffset: -direction * SlideDistance, + startBlurRadius: 0d, + endBlurRadius: MaxBlurRadius, + revealBeforeAnimation: false, + cancellationToken); + } + private async Task AnimateInAsync(Visual to, double direction, CancellationToken cancellationToken) { - var blur = new BlurEffect { Radius = MaxBlurRadius }; - var translation = new TranslateTransform(direction * SlideDistance, 0d); - to.Effect = blur; - to.Opacity = 0d; - to.RenderTransform = translation; - to.IsVisible = true; - - const int steps = 14; - for (var i = 1; i <= steps; i++) + await AnimateAsync( + to, + InDuration, + new CubicEaseOut(), + startOpacity: 0d, + endOpacity: 1d, + startOffset: direction * SlideDistance, + endOffset: 0d, + startBlurRadius: MaxBlurRadius, + endBlurRadius: 0d, + revealBeforeAnimation: true, + cancellationToken); + } + + private static async Task AnimateAsync( + Visual target, + TimeSpan duration, + Easing easing, + double startOpacity, + double endOpacity, + double startOffset, + double endOffset, + double startBlurRadius, + double endBlurRadius, + bool revealBeforeAnimation, + CancellationToken cancellationToken) + { + var compositionVisual = ElementComposition.GetElementVisual(target); + if (compositionVisual is null) { - var t = SmoothStep((double)i / steps); - to.Opacity = t; - translation.X = direction * SlideDistance * (1d - t); - blur.Radius = MaxBlurRadius * (1d - t); - await Task.Delay(InDuration / steps, cancellationToken); + await AnimateFallbackAsync( + target, + duration, + easing, + startOpacity, + endOpacity, + startOffset, + endOffset, + startBlurRadius, + endBlurRadius, + revealBeforeAnimation, + cancellationToken); + return; + } + + var previousOpacity = compositionVisual.Opacity; + var previousTranslation = compositionVisual.Translation; + var startTranslation = new Vector3( + (float)(previousTranslation.X + startOffset), + (float)previousTranslation.Y, + (float)previousTranslation.Z); + var endTranslation = new Vector3( + (float)(previousTranslation.X + endOffset), + (float)previousTranslation.Y, + (float)previousTranslation.Z); + + compositionVisual.StopAnimation("Opacity"); + compositionVisual.StopAnimation("Translation"); + compositionVisual.Opacity = (float)startOpacity; + compositionVisual.Translation = startTranslation; + + // The effect animation is the Skia-rendered path. Opacity and translation + // remain compositor animations because they do not redraw or invalidate layout. + target.Effect = new BlurEffect { Radius = endBlurRadius }; + var blurAnimation = CreateBlurAnimation(startBlurRadius, endBlurRadius, duration, easing) + .RunAsync(target, cancellationToken); + + if (revealBeforeAnimation) + { + target.IsVisible = true; + } + + StartCompositionAnimation( + compositionVisual, + duration, + easing, + (float)startOpacity, + (float)endOpacity, + startTranslation, + endTranslation); + + try + { + await Task.WhenAll( + blurAnimation, + Task.Delay(duration, cancellationToken)); + target.Effect = new BlurEffect { Radius = endBlurRadius }; + } + finally + { + compositionVisual.StopAnimation("Opacity"); + compositionVisual.StopAnimation("Translation"); + compositionVisual.Opacity = previousOpacity; + compositionVisual.Translation = previousTranslation; } } - private static double SmoothStep(double t) + private static void StartCompositionAnimation( + CompositionVisual visual, + TimeSpan duration, + Easing easing, + float startOpacity, + float endOpacity, + Vector3 startTranslation, + Vector3 endTranslation) { - t = Math.Clamp(t, 0d, 1d); - return t * t * (3d - 2d * t); + var opacity = visual.Compositor.CreateScalarKeyFrameAnimation(); + opacity.Target = "Opacity"; + opacity.Duration = duration; + opacity.InsertKeyFrame(0f, startOpacity); + opacity.InsertKeyFrame(1f, endOpacity, easing); + visual.StartAnimation("Opacity", opacity); + + var translation = visual.Compositor.CreateVector3KeyFrameAnimation(); + translation.Target = "Translation"; + translation.Duration = duration; + translation.InsertKeyFrame(0f, startTranslation); + translation.InsertKeyFrame(1f, endTranslation, easing); + visual.StartAnimation("Translation", translation); + } + + private static async Task AnimateFallbackAsync( + Visual target, + TimeSpan duration, + Easing easing, + double startOpacity, + double endOpacity, + double startOffset, + double endOffset, + double startBlurRadius, + double endBlurRadius, + bool revealBeforeAnimation, + CancellationToken cancellationToken) + { + var translation = new TranslateTransform(startOffset, 0d); + target.Opacity = startOpacity; + target.RenderTransform = translation; + target.Effect = new BlurEffect { Radius = endBlurRadius }; + + var blurAnimation = CreateBlurAnimation(startBlurRadius, endBlurRadius, duration, easing) + .RunAsync(target, cancellationToken); + + if (revealBeforeAnimation) + { + target.IsVisible = true; + } + + var steps = Math.Max(1, (int)Math.Ceiling(duration.TotalMilliseconds / 16d)); + var stepDuration = TimeSpan.FromTicks(duration.Ticks / steps); + + for (var step = 1; step <= steps; step++) + { + var progress = easing.Ease((double)step / steps); + target.Opacity = startOpacity + ((endOpacity - startOpacity) * progress); + translation.X = startOffset + ((endOffset - startOffset) * progress); + await Task.Delay(stepDuration, cancellationToken); + } + + await blurAnimation; + } + + private static Animation CreateBlurAnimation( + double startRadius, + double endRadius, + TimeSpan duration, + Easing easing) + { + return new Animation + { + Duration = duration, + Easing = easing, + FillMode = FillMode.Forward, + Children = + { + new KeyFrame + { + Cue = new Cue(0d), + Setters = + { + new Setter( + Visual.EffectProperty, + new BlurEffect { Radius = startRadius }), + }, + }, + new KeyFrame + { + Cue = new Cue(1d), + Setters = + { + new Setter( + Visual.EffectProperty, + new BlurEffect { Radius = endRadius }), + }, + }, + }, + }; } } diff --git a/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml b/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml index 13d4f0a..97d5414 100644 --- a/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml +++ b/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml @@ -226,16 +226,6 @@ - -