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
14 changes: 7 additions & 7 deletions MapWizard.Desktop/MapWizard.Desktop.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,29 +20,29 @@
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="ManagedBass" Version="4.0.2" />
<PackageReference Include="MapWizard.BeatmapParser" Version="1.0.6" />
<PackageReference Include="Markdown.Avalonia.Tight" Version="12.0.0-a3" />
<PackageReference Include="Markdown.Avalonia.Tight" Version="12.0.0-a3" />
<PackageReference Include="Lucide.Avalonia" Version="0.2.22" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
<PackageReference Include="OsuMemoryDataProvider" Version="0.12.2" />
<PackageReference Include="OsuWineMemReader" Version="1.0.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="4.152.0" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="4.152.1" />
<PackageReference Include="System.Reactive" Version="7.0.0" />
<PackageReference Include="Velopack" Version="1.2.0" />
<PackageReference Include="Xaml.Behaviors" Version="12.0.7" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\MapWizard.Tools\MapWizard.Tools.csproj" />
<ProjectReference Include="..\MapWizard.Theme\MapWizard.Theme.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MapWizard.Tools\MapWizard.Tools.csproj" />
<ProjectReference Include="..\MapWizard.Theme\MapWizard.Theme.csproj" />
</ItemGroup>

<ItemGroup>
<None Include="Assets\Skins\Legacy\**\*" CopyToOutputDirectory="PreserveNewest" />
<AvaloniaResource Include="Assets\app-icon.ico" />
<AvaloniaResource Include="Assets\app-icon.png" />
<AvaloniaResource Include="Assets\app-icon-64.png" />
<AvaloniaResource Include="Assets\app-icon-256.png" />
<AvaloniaResource Include="Assets\mapwizard.svg" />
<AvaloniaResource Include="Assets\mapwizard.svg" />
</ItemGroup>

<ItemGroup>
Expand Down
1 change: 1 addition & 0 deletions MapWizard.Desktop/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ private static AppBuilder BuildAvaloniaApp(bool forceSoftwareRendering)
]
});
}

return app;
}

Expand Down
288 changes: 242 additions & 46 deletions MapWizard.Desktop/Transitions/BlurSlideFadeTransition.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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);
Expand All @@ -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);
Expand All @@ -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 }),
},
},
},
};
}
}
Loading
Loading