diff --git a/src/Controls/src/Core/AnimationExtensions.cs b/src/Controls/src/Core/AnimationExtensions.cs
index 83d3f1a5d295..9ccb0628fcc7 100644
--- a/src/Controls/src/Core/AnimationExtensions.cs
+++ b/src/Controls/src/Core/AnimationExtensions.cs
@@ -26,6 +26,7 @@
// THE SOFTWARE.
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using Microsoft.Maui.Animations;
using Microsoft.Maui.Controls.Internals;
@@ -36,7 +37,14 @@ namespace Microsoft.Maui.Controls
///
public static class AnimationExtensions
{
- static readonly Dictionary s_tweeners;
+ // We use a ConcurrentDictionary because Tweener relies on being able to remove
+ // animations from the AnimationManager within its finalizer (via the Remove extension
+ // method below). Since finalization occurs on a different thread, it risks crashes when
+ // the finalizer is running at the same time another animation is finsihing and removing
+ // itself from this dictionary. So until we can change that design, this dictionary must
+ // be thread-safe.
+ static readonly ConcurrentDictionary s_tweeners;
+
static readonly Dictionary s_animations;
static readonly Dictionary s_kinetics;
static int s_currentTweener = 1;
@@ -45,7 +53,7 @@ static AnimationExtensions()
{
s_animations = new Dictionary();
s_kinetics = new Dictionary();
- s_tweeners = new Dictionary();
+ s_tweeners = new ConcurrentDictionary();
}
public static int Add(this IAnimationManager animationManager, Action step)
@@ -61,6 +69,7 @@ public static int Add(this IAnimationManager animationManager, Action st
animation.Commit(animationManager);
return id;
}
+
public static int Insert(this IAnimationManager animationManager, Func step)
{
var id = s_currentTweener++;
@@ -74,11 +83,13 @@ public static int Insert(this IAnimationManager animationManager, Func
@@ -213,14 +224,13 @@ static void AbortKinetic(AnimatableKey key)
{
if (s_kinetics.TryGetValue(key, out var ticker))
{
- var animation = s_tweeners[ticker];
- animation.AnimationManager?.Remove(ticker);
- s_kinetics.Remove(key);
- }
- if (!s_kinetics.ContainsKey(key))
- {
- return;
+ if (s_tweeners.TryGetValue(ticker, out Animation animation))
+ {
+ animation.AnimationManager?.Remove(ticker);
+ }
}
+
+ s_kinetics.Remove(key);
}
static void AnimateInternal(IAnimatable self, IAnimationManager animationManager, string name, Func transform, Action callback,
@@ -279,8 +289,11 @@ static void AnimateKineticInternal(IAnimatable self, IAnimationManager animation
if (!result)
{
finished?.Invoke();
+ if (s_kinetics.TryGetValue(key, out var ticker))
+ {
+ animationManager.Remove(ticker);
+ }
s_kinetics.Remove(key);
- animationManager.Remove(tick);
}
return result;
});
@@ -292,13 +305,15 @@ static void AnimateKineticInternal(IAnimatable self, IAnimationManager animation
static void HandleTweenerFinished(object o, EventArgs args)
{
var tweener = o as Tweener;
- Info info;
- if (tweener != null && s_animations.TryGetValue(tweener.Handle, out info))
+
+ if (tweener != null && s_animations.TryGetValue(tweener.Handle, out Info info))
{
- IAnimatable owner;
- if (info.Owner.TryGetTarget(out owner))
- owner.BatchBegin();
- info.Callback(tweener.Value);
+ var tweenerValue = tweener.Value;
+ info.Owner.TryGetTarget(out IAnimatable owner);
+
+ owner?.BatchBegin();
+
+ info.Callback(tweenerValue);
var repeat = false;
@@ -306,7 +321,9 @@ static void HandleTweenerFinished(object o, EventArgs args)
var animationsEnabled = info.AnimationManager.Ticker.SystemEnabled;
if (info.Repeat != null && animationsEnabled)
+ {
repeat = info.Repeat();
+ }
if (!repeat)
{
@@ -316,10 +333,9 @@ static void HandleTweenerFinished(object o, EventArgs args)
tweener.Stop();
}
- info.Finished?.Invoke(tweener.Value, !animationsEnabled);
+ info.Finished?.Invoke(tweenerValue, !animationsEnabled);
- if (info.Owner.TryGetTarget(out owner))
- owner.BatchCommit();
+ owner?.BatchCommit();
if (repeat)
{
@@ -330,11 +346,7 @@ static void HandleTweenerFinished(object o, EventArgs args)
static void HandleTweenerUpdated(object o, EventArgs args)
{
- var tweener = o as Tweener;
- Info info;
- IAnimatable owner;
-
- if (tweener != null && s_animations.TryGetValue(tweener.Handle, out info) && info.Owner.TryGetTarget(out owner))
+ if (o is Tweener tweener && s_animations.TryGetValue(tweener.Handle, out Info info) && info.Owner.TryGetTarget(out IAnimatable owner))
{
owner.BatchBegin();
info.Callback(info.Easing.Ease(tweener.Value));
diff --git a/src/Controls/src/Core/Tweener.cs b/src/Controls/src/Core/Tweener.cs
index 702203ad2444..50c5c9d9f18d 100644
--- a/src/Controls/src/Core/Tweener.cs
+++ b/src/Controls/src/Core/Tweener.cs
@@ -27,7 +27,6 @@
using System;
using Microsoft.Maui.Animations;
-using Microsoft.Maui.Controls.Internals;
namespace Microsoft.Maui.Controls
{
@@ -39,27 +38,40 @@ public TweenerAnimation(Func step)
{
_step = step;
}
+
protected override void OnTick(double millisecondsSinceLastUpdate)
{
var running = _step.Invoke((long)millisecondsSinceLastUpdate);
HasFinished = !running;
}
+ internal override void ForceFinish()
+ {
+ if (HasFinished)
+ {
+ return;
+ }
+
+ HasFinished = true;
+
+ // The tweeners use long.MaxValue for in-band signaling that they should
+ // jump to the end of the animation
+ _ = _step.Invoke(long.MaxValue);
+ }
}
internal class Tweener
{
- IAnimationManager animationManager;
+ readonly IAnimationManager _animationManager;
long _lastMilliseconds;
-
- int _timer;
+ int _animationManagerKey;
long _frames;
public Tweener(uint length, IAnimationManager animationManager)
{
Value = 0.0f;
Length = length;
- this.animationManager = animationManager;
+ _animationManager = animationManager;
Rate = 1;
Loop = false;
}
@@ -69,7 +81,7 @@ public Tweener(uint length, uint rate, IAnimationManager animationManager)
Value = 0.0f;
Length = length;
Rate = rate;
- this.animationManager = animationManager;
+ _animationManager = animationManager;
Loop = false;
}
@@ -77,23 +89,67 @@ public Tweener(uint length, uint rate, IAnimationManager animationManager)
public uint Length { get; }
- public uint Rate { get; }
+ public uint Rate { get; } = 1;
public bool Loop { get; set; }
- public double Value { get; private set; }
+ public double Value { get; set; }
public event EventHandler Finished;
+ public event EventHandler ValueUpdated;
public void Pause()
{
- if (_timer != 0)
+ if (_animationManagerKey != 0)
{
- animationManager.Remove(_timer);
- _timer = 0;
+ _animationManager.Remove(_animationManagerKey);
+ _animationManagerKey = 0;
}
}
+ bool Step(long step)
+ {
+ if (step == long.MaxValue)
+ {
+ // Signal that the Tweener is being force to move to the finished state,
+ // usually because the underlying Ticker has been disabled by the system
+ FinishImmediately();
+ return false;
+ }
+ else
+ {
+ long ms = step + _lastMilliseconds;
+ Value = Math.Min(1.0f, ms / (double)Length);
+ _lastMilliseconds = ms;
+ }
+
+ long wantedFrames = (_lastMilliseconds / Rate) + 1;
+ if (wantedFrames > _frames || Value >= 1.0f)
+ {
+ ValueUpdated?.Invoke(this, EventArgs.Empty);
+ }
+
+ _frames = wantedFrames;
+
+ if (Value >= 1.0f)
+ {
+ if (Loop)
+ {
+ _lastMilliseconds = 0;
+ Value = 0.0f;
+ return true;
+ }
+
+ Finished?.Invoke(this, EventArgs.Empty);
+
+ Value = 0.0f;
+ _animationManagerKey = 0;
+ return false;
+ }
+
+ return true;
+ }
+
public void Start()
{
Pause();
@@ -101,87 +157,51 @@ public void Start()
_lastMilliseconds = 0;
_frames = 0;
- if (!animationManager.Ticker.SystemEnabled)
+ if (!_animationManager.Ticker.SystemEnabled)
{
+ // The Ticker's disabled, probably because the system has animations disabled
+ // The Tweener should move immediately to the finished state and shut down
FinishImmediately();
return;
}
- _timer = animationManager.Insert(step =>
- {
- if (step == long.MaxValue)
- {
- // We're being forced to finish
- Value = 1.0;
- }
- else
- {
- long ms = step + _lastMilliseconds;
-
- Value = Math.Min(1.0f, ms / (double)Length);
-
- _lastMilliseconds = ms;
- }
+ _animationManagerKey = _animationManager.Insert(Step);
- long wantedFrames = (_lastMilliseconds / Rate) + 1;
- if (wantedFrames > _frames || Value >= 1.0f)
- {
- ValueUpdated?.Invoke(this, EventArgs.Empty);
- }
- _frames = wantedFrames;
-
- if (Value >= 1.0f)
- {
- if (Loop)
- {
- _lastMilliseconds = 0;
- Value = 0.0f;
- return true;
- }
-
- Finished?.Invoke(this, EventArgs.Empty);
- Value = 0.0f;
- _timer = 0;
- return false;
- }
- return true;
- });
- if (!animationManager.Ticker.IsRunning)
- animationManager.Ticker.Start();
+ if (!_animationManager.Ticker.IsRunning)
+ _animationManager.Ticker.Start();
}
void FinishImmediately()
{
Value = 1.0f;
+
ValueUpdated?.Invoke(this, EventArgs.Empty);
Finished?.Invoke(this, EventArgs.Empty);
+
Value = 0.0f;
- _timer = 0;
+ _animationManagerKey = 0;
}
public void Stop()
{
Pause();
- Value = 1.0f;
Finished?.Invoke(this, EventArgs.Empty);
Value = 0.0f;
}
- public event EventHandler ValueUpdated;
-
~Tweener()
{
- if (_timer != 0)
+ if (_animationManagerKey != 0)
{
try
{
- animationManager.Remove(_timer);
+ _animationManager.Remove(_animationManagerKey);
}
catch (InvalidOperationException)
{
}
}
- _timer = 0;
+ _animationManagerKey = 0;
}
}
}
\ No newline at end of file
diff --git a/src/Controls/tests/Core.UnitTests/SingleThreadSynchronizationContext.cs b/src/Controls/tests/Core.UnitTests/SingleThreadSynchronizationContext.cs
new file mode 100644
index 000000000000..cd5634208eb9
--- /dev/null
+++ b/src/Controls/tests/Core.UnitTests/SingleThreadSynchronizationContext.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Maui.Controls.Core.UnitTests
+{
+ ///
+ /// Synchronization context suitable for testing animation tasks
+ ///
+ ///
+ /// Animations in an app run on the UI thread; all of the async operations synchronize back to the UI thread's context.
+ /// But async operations in unit tests don't have a single-threaded sychronization context by default, so they fall back
+ /// to the default behavior of scheduling their continuations on the thread pool. To accurately test animation operations
+ /// asynchronously (they way they behave when animating properties in an app), we need them to use a single-threaded
+ /// context. So we provide this one for testing. It queues operations up and executes them in order on the current thread,
+ /// just like the UI thread does.
+ ///
+ sealed class SingleThreadSynchronizationContext : SynchronizationContext
+ {
+ readonly BlockingCollection> _queue = new();
+
+ public override void Post(SendOrPostCallback d, object state)
+ {
+ ArgumentNullException.ThrowIfNull(d);
+ _queue.Add(new KeyValuePair(d, state));
+ }
+
+ void RunOnCurrentThread()
+ {
+ foreach (var workItem in _queue.GetConsumingEnumerable())
+ {
+ workItem.Key(workItem.Value);
+ }
+ }
+
+ void Complete()
+ {
+ _queue.CompleteAdding();
+ }
+
+ public static T Run(Func> asyncMethod)
+ {
+ ArgumentNullException.ThrowIfNull(asyncMethod);
+
+ var previousContext = Current;
+ try
+ {
+ var context = new SingleThreadSynchronizationContext();
+ SetSynchronizationContext(context);
+
+ // Invoke the function and alert the context when it's complete
+ var task = asyncMethod() ?? throw new InvalidOperationException("No task provided.");
+ task.ContinueWith(delegate { context.Complete(); }, TaskScheduler.Default);
+
+ // Start working through the queue
+ context.RunOnCurrentThread();
+ return task.GetAwaiter().GetResult();
+ }
+ finally
+ {
+ SetSynchronizationContext(previousContext);
+ }
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/src/Controls/tests/Core.UnitTests/TestClasses/AnimationReadyHandler.cs b/src/Controls/tests/Core.UnitTests/TestClasses/AnimationReadyHandler.cs
index 494547214958..63dc66c4d087 100644
--- a/src/Controls/tests/Core.UnitTests/TestClasses/AnimationReadyHandler.cs
+++ b/src/Controls/tests/Core.UnitTests/TestClasses/AnimationReadyHandler.cs
@@ -1,6 +1,7 @@
using System;
using System.Threading.Tasks;
using Microsoft.Maui.Animations;
+using Microsoft.Maui.Dispatching;
using Microsoft.Maui.Handlers;
namespace Microsoft.Maui.Controls.Core.UnitTests
@@ -33,10 +34,15 @@ public AnimationReadyHandler(IAnimationManager animationManager)
public static AnimationReadyHandler Prepare(params T[] views)
where T : View
{
- var handler = new AnimationReadyHandler(new TestAnimationManager(new TTicker()));
+ AnimationReadyHandler handler = null;
+
+ var ticker = new TestAnimationManager(new TTicker());
foreach (var view in views)
+ {
+ handler = new AnimationReadyHandler(ticker);
view.Handler = handler;
+ }
return handler;
}
@@ -83,7 +89,12 @@ public object GetService(Type serviceType)
if (serviceType == typeof(IAnimationManager))
return _animationManager;
- throw new NotSupportedException();
+ if (serviceType == typeof(IDispatcherProvider))
+ {
+ return DispatcherProvider.Current;
+ }
+
+ throw new NotSupportedException($"Attempting to get service type {serviceType}");
}
}
}
diff --git a/src/Controls/tests/Core.UnitTests/TestClasses/AsyncTicker.cs b/src/Controls/tests/Core.UnitTests/TestClasses/AsyncTicker.cs
index 50c6fad50679..b4a7abe06659 100644
--- a/src/Controls/tests/Core.UnitTests/TestClasses/AsyncTicker.cs
+++ b/src/Controls/tests/Core.UnitTests/TestClasses/AsyncTicker.cs
@@ -5,22 +5,28 @@ namespace Microsoft.Maui.Controls.Core.UnitTests
{
class AsyncTicker : Ticker
{
- bool _enabled;
- bool _systemEnabled = true;
+ bool _running;
- public override bool SystemEnabled => _systemEnabled;
+ public override bool IsRunning => _running;
public void SetEnabled(bool enabled)
{
- _systemEnabled = enabled;
- _enabled = enabled;
+ SystemEnabled = enabled;
+
+ if (!enabled)
+ {
+ Stop();
+ }
}
public override async void Start()
{
- _enabled = true;
+ if (SystemEnabled)
+ {
+ _running = true;
+ }
- while (_enabled)
+ while (IsRunning && SystemEnabled)
{
Fire?.Invoke();
await Task.Delay(16);
@@ -29,7 +35,7 @@ public override async void Start()
public override void Stop()
{
- _enabled = false;
+ _running = false;
}
}
}
\ No newline at end of file
diff --git a/src/Controls/tests/Core.UnitTests/TestClasses/BlockingTicker.cs b/src/Controls/tests/Core.UnitTests/TestClasses/BlockingTicker.cs
index f0c57d7e2005..efb30649c9d0 100644
--- a/src/Controls/tests/Core.UnitTests/TestClasses/BlockingTicker.cs
+++ b/src/Controls/tests/Core.UnitTests/TestClasses/BlockingTicker.cs
@@ -7,6 +7,8 @@ class BlockingTicker : Ticker
{
bool _enabled;
+ public override bool IsRunning => _enabled;
+
public override void Start()
{
_enabled = true;
diff --git a/src/Controls/tests/Core.UnitTests/TestClasses/TestAnimationManager.cs b/src/Controls/tests/Core.UnitTests/TestClasses/TestAnimationManager.cs
index af8369d3e41f..34380f34d554 100644
--- a/src/Controls/tests/Core.UnitTests/TestClasses/TestAnimationManager.cs
+++ b/src/Controls/tests/Core.UnitTests/TestClasses/TestAnimationManager.cs
@@ -1,63 +1,18 @@
-using System.Collections.Generic;
-using System.Linq;
-using Microsoft.Maui.Animations;
+using Microsoft.Maui.Animations;
namespace Microsoft.Maui.Controls.Core.UnitTests
{
- public class TestAnimationManager : IAnimationManager
+ public class TestAnimationManager : AnimationManager
{
- readonly List _animations = new();
-
- public TestAnimationManager(ITicker ticker = null)
- {
- Ticker = ticker ?? new BlockingTicker();
- Ticker.Fire = OnFire;
- }
-
- public double SpeedModifier { get; set; } = 1;
-
- public bool AutoStartTicker { get; set; } = false;
-
- public ITicker Ticker { get; }
-
- public void Add(Animations.Animation animation)
+ public TestAnimationManager(ITicker ticker = null)
+ : base(ticker ?? new BlockingTicker())
{
- _animations.Add(animation);
- if (AutoStartTicker && !Ticker.IsRunning)
- Ticker.Start();
}
- public void Remove(Animations.Animation animation)
+ internal override double AdjustSpeed(double elapsedMilliseconds)
{
- _animations.Remove(animation);
- if (!_animations.Any())
- Ticker.Stop();
- }
-
- void OnFire()
- {
- var animations = _animations.ToList();
- animations.ForEach(animationTick);
-
- if (!_animations.Any())
- Ticker.Stop();
-
- void animationTick(Animations.Animation animation)
- {
- if (animation.HasFinished)
- {
- _animations.Remove(animation);
- animation.RemoveFromParent();
- return;
- }
-
- animation.Tick(16);
- if (animation.HasFinished)
- {
- _animations.Remove(animation);
- animation.RemoveFromParent();
- }
- }
+ // Regulate the speed so the tests are predictable
+ return 16;
}
}
}
\ No newline at end of file
diff --git a/src/Controls/tests/Core.UnitTests/TickerSystemEnabledTests.cs b/src/Controls/tests/Core.UnitTests/TickerSystemEnabledTests.cs
index 8c0602c4e0a1..936cf708c28b 100644
--- a/src/Controls/tests/Core.UnitTests/TickerSystemEnabledTests.cs
+++ b/src/Controls/tests/Core.UnitTests/TickerSystemEnabledTests.cs
@@ -3,13 +3,13 @@
using Microsoft.Maui.Dispatching;
using Microsoft.Maui.UnitTests;
using Xunit;
+using Xunit.Abstractions;
namespace Microsoft.Maui.Controls.Core.UnitTests
{
-
public class TickerSystemEnabledTests : IDisposable
{
- public TickerSystemEnabledTests()
+ public TickerSystemEnabledTests(ITestOutputHelper testOutput)
{
DispatcherProvider.SetCurrent(new DispatcherProviderStub());
}
@@ -20,33 +20,44 @@ public void Dispose()
DispatcherProvider.SetCurrent(null);
}
- async Task SwapFadeViews(View view1, View view2)
+ static async Task SwapFadeViews(View view1, View view2)
{
- await view1.FadeTo(0, 1000);
- await view2.FadeTo(1, 1000);
+ await view1.FadeTo(0, 15000);
+ await view2.FadeTo(1, 15000);
}
- [Fact(Skip = "https://github.com/dotnet/maui/pull/1511", Timeout = 3000)]
+ [Fact(Timeout = 3000)]
public async Task DisablingTickerFinishesAnimationInProgress()
{
- var view = AnimationReadyHandlerAsync.Prepare(new View { Opacity = 1 }, out var handler);
+ SingleThreadSynchronizationContext.Run(async () =>
+ {
+ var view = AnimationReadyHandlerAsync.Prepare(new View { Opacity = 1 }, out var handler);
+
+ await Task.WhenAll(view.FadeTo(0, 2000), handler.DisableTicker());
- await Task.WhenAll(view.FadeTo(0, 2000), handler.DisableTicker());
+ Assert.Equal(0, view.Opacity);
- Assert.Equal(0, view.Opacity);
+ return true;
+ });
}
- [Fact(Timeout = 3000, Skip = "https://github.com/dotnet/maui/pull/1511")]
+ [Fact(Timeout = 3000)]
public async Task DisablingTickerFinishesAllAnimationsInChain()
{
- var view1 = new View { Opacity = 1 };
- var view2 = new View { Opacity = 0 };
+ SingleThreadSynchronizationContext.Run(async () =>
+ {
+ var view1 = new View { Opacity = 1 };
+ var view2 = new View { Opacity = 0 };
+
+ var handler = AnimationReadyHandlerAsync.Prepare(view1, view2);
- var handler = AnimationReadyHandlerAsync.Prepare(view1, view2);
+ await Task.WhenAll(SwapFadeViews(view1, view2), handler.DisableTicker());
- await Task.WhenAll(SwapFadeViews(view1, view2), handler.DisableTicker());
+ Assert.Equal(0, view1.Opacity);
+ Assert.Equal(1, view2.Opacity);
- Assert.Equal(0, view1.Opacity);
+ return true;
+ });
}
static Task RepeatFade(View view)
@@ -55,25 +66,37 @@ static Task RepeatFade(View view)
var fadeIn = new Animation(d => { view.Opacity = d; }, 0, 1);
var i = 0;
- fadeIn.Commit(view, "fadeIn", length: 2000, repeat: () => ++i < 2, finished: (d, b) =>
+ fadeIn.Commit(view, "fadeIn", length: 1000, repeat: () => ++i < 5, finished: (d, b) =>
{
- tcs.SetResult(b);
+ if (!tcs.Task.IsCompleted)
+ {
+ tcs.SetResult(b);
+ }
});
return tcs.Task;
}
- [Fact(Timeout = 3000, Skip = "https://github.com/dotnet/maui/pull/1511")]
+
+ [Fact(Timeout = 2000)]
public async Task DisablingTickerPreventsAnimationFromRepeating()
{
- var view = AnimationReadyHandlerAsync.Prepare(new View { Opacity = 0 }, out var handler);
+ SingleThreadSynchronizationContext.Run(async () => {
- await Task.WhenAll(RepeatFade(view), handler.DisableTicker());
+ var view = AnimationReadyHandlerAsync.Prepare(new View { Opacity = 0 }, out var handler);
- Assert.Equal(1, view.Opacity);
+ // RepeatFade is set to repeat a 1-second animation 5 times; if it runs all the way through,
+ // this test will timeout before it's finished. But disabling the ticker should cause it to
+ // finish immediately, so it'll be done before the test times out.
+ await Task.WhenAll(RepeatFade(view), handler.DisableTicker());
+
+ Assert.Equal(1, view.Opacity);
+
+ return true;
+ });
}
- [Fact]
+ [Fact(Timeout = 2000)]
public async Task NewAnimationsFinishImmediatelyWhenTickerDisabled()
{
var view = AnimationReadyHandlerAsync.Prepare(new View(), out var handler);
@@ -85,32 +108,42 @@ public async Task NewAnimationsFinishImmediatelyWhenTickerDisabled()
Assert.Equal(200, view.RotationY);
}
- [Fact]
+ [Fact(Timeout = 2000)]
public async Task AnimationExtensionsReturnTrueIfAnimationsDisabled()
{
- var label = AnimationReadyHandlerAsync.Prepare(new Label { Text = "Foo" }, out var handler);
+ SingleThreadSynchronizationContext.Run(async () =>
+ {
+ var label = AnimationReadyHandlerAsync.Prepare(new Label { Text = "Foo" }, out var handler);
- await handler.DisableTicker();
+ await handler.DisableTicker();
+
+ var result = await label.ScaleTo(2, 500);
- var result = await label.ScaleTo(2, 500);
+ Assert.True(result);
- Assert.True(result);
+ return true;
+ });
}
[Fact(Timeout = 2000)]
public async Task CanExitAnimationLoopIfAnimationsDisabled()
{
- var label = AnimationReadyHandlerAsync.Prepare(new Label { Text = "Foo" }, out var handler);
+ SingleThreadSynchronizationContext.Run(async () =>
+ {
+ var label = AnimationReadyHandlerAsync.Prepare(new Label { Text = "Foo" }, out var handler);
- await handler.DisableTicker();
+ await handler.DisableTicker();
- var run = true;
+ var run = true;
- while (run)
- {
- await label.ScaleTo(2, 500);
- run = !(await label.ScaleTo(0.5, 500));
- }
+ while (run)
+ {
+ await label.ScaleTo(2, 500);
+ run = !(await label.ScaleTo(0.5, 500));
+ }
+
+ return true;
+ });
}
}
}
\ No newline at end of file
diff --git a/src/Core/src/Animations/Animation.cs b/src/Core/src/Animations/Animation.cs
index c164bc751e60..33e06b31bd7f 100644
--- a/src/Core/src/Animations/Animation.cs
+++ b/src/Core/src/Animations/Animation.cs
@@ -374,5 +374,13 @@ public void Dispose()
// Do not change this code. Put cleanup code in Dispose(bool disposing) above.
Dispose(true);
}
+
+ internal virtual void ForceFinish()
+ {
+ if (Progress < 1.0)
+ {
+ Update(1.0);
+ }
+ }
}
}
\ No newline at end of file
diff --git a/src/Core/src/Animations/AnimationManager.cs b/src/Core/src/Animations/AnimationManager.cs
index 3f72c551821c..41f0ef80ee88 100644
--- a/src/Core/src/Animations/AnimationManager.cs
+++ b/src/Core/src/Animations/AnimationManager.cs
@@ -36,7 +36,9 @@ public void Add(Animation animation)
{
// If animations are disabled, don't do anything
if (!Ticker.SystemEnabled)
+ {
return;
+ }
if (!_animations.Contains(animation))
_animations.Add(animation);
@@ -62,11 +64,22 @@ void Start()
void End() =>
Ticker?.Stop();
- long GetCurrentTick() =>
+ static long GetCurrentTick() =>
Environment.TickCount & int.MaxValue;
void OnFire()
{
+ if (!Ticker.SystemEnabled)
+ {
+ // This is a hack - if we're here, the ticker has detected that animations are no longer enabled,
+ // and it's invoked the Fire event one last time because that's the only communication mechanism
+ // it currently has available with the AnimationManager. We need to force all the running animations
+ // to move to their finished state and stop running.
+
+ ForceFinishAnimations();
+ return;
+ }
+
var now = GetCurrentTick();
var milliseconds = TimeSpan.FromMilliseconds(now - _lastUpdate).TotalMilliseconds;
_lastUpdate = now;
@@ -86,7 +99,7 @@ void OnAnimationTick(Animation animation)
return;
}
- animation.Tick(milliseconds * SpeedModifier);
+ animation.Tick(AdjustSpeed(milliseconds));
if (animation.HasFinished)
{
@@ -113,5 +126,24 @@ public void Dispose()
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
+
+ void ForceFinishAnimations()
+ {
+ var animations = new List(_animations);
+ animations.ForEach(ForceFinish);
+ End();
+
+ void ForceFinish(Animation animation)
+ {
+ animation.ForceFinish();
+ _animations.TryRemove(animation);
+ animation.RemoveFromParent();
+ }
+ }
+
+ internal virtual double AdjustSpeed(double elapsedMilliseconds)
+ {
+ return elapsedMilliseconds * SpeedModifier;
+ }
}
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/src/Core/src/Animations/PlatformTicker.Android.cs b/src/Core/src/Animations/PlatformTicker.Android.cs
index 10eca9110a61..a1c74963cd77 100644
--- a/src/Core/src/Animations/PlatformTicker.Android.cs
+++ b/src/Core/src/Animations/PlatformTicker.Android.cs
@@ -1,5 +1,6 @@
using System;
using Android.Animation;
+using Java.Interop;
namespace Microsoft.Maui.Animations
{
@@ -8,9 +9,8 @@ public class PlatformTicker : Ticker, IDisposable, IEnergySaverListener
{
readonly IEnergySaverListenerManager _manager;
readonly ValueAnimator _val;
-
- bool _systemEnabled;
bool _disposedValue;
+ readonly DurationScaleListener? _durationScaleListener;
///
/// Creates a new Android object.
@@ -24,15 +24,20 @@ public PlatformTicker(IEnergySaverListenerManager manager)
_val.RepeatCount = ValueAnimator.Infinite;
_val.Update += (s, e) => Fire?.Invoke();
+ CheckAnimationEnabledStatus();
+
_manager.Add(this);
+
+ if (OperatingSystem.IsAndroidVersionAtLeast(33))
+ {
+ _durationScaleListener = new DurationScaleListener(CheckAnimationEnabledStatus);
+ ValueAnimator.RegisterDurationScaleChangeListener(_durationScaleListener);
+ }
}
///
public override bool IsRunning => _val.IsStarted;
- ///
- public override bool SystemEnabled => _systemEnabled;
-
///
public override void Start() => _val.Start();
@@ -45,8 +50,15 @@ protected virtual void Dispose(bool disposing)
if (!_disposedValue)
{
if (disposing)
+ {
_manager.Remove(this);
+ if (OperatingSystem.IsAndroidVersionAtLeast(33) && _durationScaleListener != null)
+ {
+ ValueAnimator.UnregisterDurationScaleChangeListener(_durationScaleListener);
+ }
+ }
+
_disposedValue = true;
}
}
@@ -58,7 +70,56 @@ public void Dispose()
GC.SuppressFinalize(this);
}
- void IEnergySaverListener.OnStatusUpdated(bool energySaverEnabled) =>
- _systemEnabled = !energySaverEnabled;
+ void IEnergySaverListener.OnStatusUpdated(bool energySaverEnabled)
+ {
+ // Moving in and out of power saving mode may enable/disable animations, depending on
+ // some other settings; we should check
+ SystemEnabled = AreAnimationsEnabled();
+ }
+
+ internal void CheckAnimationEnabledStatus()
+ {
+ SystemEnabled = AreAnimationsEnabled();
+ }
+
+ static bool AreAnimationsEnabled()
+ {
+ if (OperatingSystem.IsAndroidVersionAtLeast(26))
+ {
+ // For more recent API levels, we can just check this method and be done with it
+ // https://developer.android.com/reference/android/animation/ValueAnimator#areAnimatorsEnabled()
+ return ValueAnimator.AreAnimatorsEnabled();
+ }
+
+ if (OperatingSystem.IsAndroidVersionAtLeast(21))
+ {
+ // For API levels which support power saving but not AreAnimatorsEnabled, we can check the
+ // power save mode; for these API levels, power saving == ON will mean that animations are disabled
+
+ return Devices.Battery.EnergySaverStatus switch
+ {
+ Devices.EnergySaverStatus.On => false,
+ _ => true,
+ };
+ }
+
+ // We don't support anything below 21
+ return false;
+ }
+
+ class DurationScaleListener : Java.Lang.Object, ValueAnimator.IDurationScaleChangeListener
+ {
+ readonly Action _check;
+
+ public DurationScaleListener(Action check)
+ {
+ _check = check;
+ }
+
+ public void OnChanged(float scale)
+ {
+ _check.Invoke();
+ }
+ }
}
}
\ No newline at end of file
diff --git a/src/Core/src/Animations/Ticker.cs b/src/Core/src/Animations/Ticker.cs
index f405228e1b5d..075a443a6cf9 100644
--- a/src/Core/src/Animations/Ticker.cs
+++ b/src/Core/src/Animations/Ticker.cs
@@ -7,6 +7,7 @@ namespace Microsoft.Maui.Animations
public class Ticker : ITicker
{
Timer? _timer;
+ bool _systemEnabled = true;
///
public virtual int MaxFps { get; set; } = 60;
@@ -18,7 +19,21 @@ public class Ticker : ITicker
public virtual bool IsRunning => _timer?.Enabled ?? false;
///
- public virtual bool SystemEnabled => true;
+ public virtual bool SystemEnabled
+ {
+ get
+ {
+ return _systemEnabled;
+ }
+ protected set
+ {
+ if (_systemEnabled != value)
+ {
+ _systemEnabled = value;
+ OnSystemEnabledChanged();
+ }
+ }
+ }
///
public virtual void Start()
@@ -52,5 +67,17 @@ public virtual void Stop()
void OnTimerElapsed(object? sender, ElapsedEventArgs e) =>
Fire?.Invoke();
+
+ protected virtual void OnSystemEnabledChanged()
+ {
+ if (IsRunning && !_systemEnabled)
+ {
+ // Animations are disabled for some reason; we need to
+ // force the AnimationManager to process them again now
+ // that the ticker is disabled. This will give it a chance
+ // to force-finish any animations in progress.
+ Fire?.Invoke();
+ }
+ }
}
}
\ No newline at end of file
diff --git a/src/Core/src/PublicAPI/net-android/PublicAPI.Shipped.txt b/src/Core/src/PublicAPI/net-android/PublicAPI.Shipped.txt
index 9b54b3a78b95..43d433ddb6a5 100644
--- a/src/Core/src/PublicAPI/net-android/PublicAPI.Shipped.txt
+++ b/src/Core/src/PublicAPI/net-android/PublicAPI.Shipped.txt
@@ -2080,7 +2080,7 @@ override Microsoft.Maui.Animations.LerpingAnimation.Update(double percent) -> vo
override Microsoft.Maui.Animations.PlatformTicker.IsRunning.get -> bool
override Microsoft.Maui.Animations.PlatformTicker.Start() -> void
override Microsoft.Maui.Animations.PlatformTicker.Stop() -> void
-override Microsoft.Maui.Animations.PlatformTicker.SystemEnabled.get -> bool
+
override Microsoft.Maui.Converters.CornerRadiusTypeConverter.CanConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Type! sourceType) -> bool
override Microsoft.Maui.Converters.CornerRadiusTypeConverter.CanConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Type? destinationType) -> bool
override Microsoft.Maui.Converters.CornerRadiusTypeConverter.ConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object? value) -> object!
diff --git a/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
index acf58b4646f5..3f0cb13925a4 100644
--- a/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
@@ -129,6 +129,8 @@ static Microsoft.Maui.SoftInputExtensions.ShowSoftInputAsync(this Microsoft.Maui
*REMOVED*Microsoft.Maui.Handlers.ImageButtonHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
*REMOVED*Microsoft.Maui.Handlers.ImageHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
*REMOVED*Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
+virtual Microsoft.Maui.Animations.Ticker.OnSystemEnabledChanged() -> void
+virtual Microsoft.Maui.Animations.Ticker.SystemEnabled.set -> void
virtual Microsoft.Maui.Handlers.ButtonHandler.ImageSourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
virtual Microsoft.Maui.Handlers.ImageButtonHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
virtual Microsoft.Maui.Handlers.ImageHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
diff --git a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
index a3d8aa09d958..7e2017867d3f 100644
--- a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
@@ -138,6 +138,8 @@ static Microsoft.Maui.SoftInputExtensions.ShowSoftInputAsync(this Microsoft.Maui
*REMOVED*Microsoft.Maui.Handlers.ImageButtonHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
*REMOVED*Microsoft.Maui.Handlers.ImageHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
*REMOVED*Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
+virtual Microsoft.Maui.Animations.Ticker.OnSystemEnabledChanged() -> void
+virtual Microsoft.Maui.Animations.Ticker.SystemEnabled.set -> void
virtual Microsoft.Maui.Handlers.ButtonHandler.ImageSourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
virtual Microsoft.Maui.Handlers.ImageButtonHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
virtual Microsoft.Maui.Handlers.ImageHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
diff --git a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
index 382bd5ddc9b1..4baaf01d90b2 100644
--- a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
@@ -142,6 +142,8 @@ static Microsoft.Maui.SoftInputExtensions.ShowSoftInputAsync(this Microsoft.Maui
*REMOVED*Microsoft.Maui.Handlers.ImageButtonHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
*REMOVED*Microsoft.Maui.Handlers.ImageHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
*REMOVED*Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
+virtual Microsoft.Maui.Animations.Ticker.OnSystemEnabledChanged() -> void
+virtual Microsoft.Maui.Animations.Ticker.SystemEnabled.set -> void
virtual Microsoft.Maui.Handlers.ButtonHandler.ImageSourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
virtual Microsoft.Maui.Handlers.ImageButtonHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
virtual Microsoft.Maui.Handlers.ImageHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
diff --git a/src/Core/src/PublicAPI/net-tizen/PublicAPI.Shipped.txt b/src/Core/src/PublicAPI/net-tizen/PublicAPI.Shipped.txt
index ea474f4ccd96..800774879be8 100644
--- a/src/Core/src/PublicAPI/net-tizen/PublicAPI.Shipped.txt
+++ b/src/Core/src/PublicAPI/net-tizen/PublicAPI.Shipped.txt
@@ -2918,4 +2918,4 @@ virtual Microsoft.Maui.WindowOverlay.Deinitialize() -> bool
virtual Microsoft.Maui.WindowOverlay.HandleUIChange() -> void
virtual Microsoft.Maui.WindowOverlay.Initialize() -> bool
virtual Microsoft.Maui.WindowOverlay.RemoveWindowElement(Microsoft.Maui.IWindowOverlayElement! drawable) -> bool
-virtual Microsoft.Maui.WindowOverlay.RemoveWindowElements() -> void
+virtual Microsoft.Maui.WindowOverlay.RemoveWindowElements() -> void
\ No newline at end of file
diff --git a/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
index 864814f9914f..516ccdcc342b 100644
--- a/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
@@ -80,3 +80,5 @@ virtual Microsoft.Maui.Handlers.ButtonHandler.ImageSourceLoader.get -> Microsoft
virtual Microsoft.Maui.Handlers.ImageButtonHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
virtual Microsoft.Maui.Handlers.ImageHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
virtual Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
+virtual Microsoft.Maui.Animations.Ticker.OnSystemEnabledChanged() -> void
+virtual Microsoft.Maui.Animations.Ticker.SystemEnabled.set -> void
\ No newline at end of file
diff --git a/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt
index bc36259e880e..cf283eb95a8b 100644
--- a/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt
@@ -107,6 +107,8 @@ static Microsoft.Maui.SoftInputExtensions.ShowSoftInputAsync(this Microsoft.Maui
*REMOVED*Microsoft.Maui.Handlers.ButtonHandler.ImageSourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
*REMOVED*Microsoft.Maui.Handlers.ImageButtonHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
*REMOVED*Microsoft.Maui.Handlers.ImageHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
+virtual Microsoft.Maui.Animations.Ticker.OnSystemEnabledChanged() -> void
+virtual Microsoft.Maui.Animations.Ticker.SystemEnabled.set -> void
virtual Microsoft.Maui.Handlers.ButtonHandler.ImageSourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
virtual Microsoft.Maui.Handlers.ImageButtonHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
virtual Microsoft.Maui.Handlers.ImageHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
diff --git a/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt
index 36a5c45f645d..d7bb997de6e3 100644
--- a/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt
@@ -76,6 +76,8 @@ static Microsoft.Maui.SoftInputExtensions.ShowSoftInputAsync(this Microsoft.Maui
*REMOVED*Microsoft.Maui.Handlers.ImageButtonHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
*REMOVED*Microsoft.Maui.Handlers.ImageHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
*REMOVED*Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
+virtual Microsoft.Maui.Animations.Ticker.OnSystemEnabledChanged() -> void
+virtual Microsoft.Maui.Animations.Ticker.SystemEnabled.set -> void
virtual Microsoft.Maui.Handlers.ButtonHandler.ImageSourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
virtual Microsoft.Maui.Handlers.ImageButtonHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
virtual Microsoft.Maui.Handlers.ImageHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
diff --git a/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt
index bcd94699a92d..7785582f1314 100644
--- a/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt
@@ -75,6 +75,8 @@ static Microsoft.Maui.SoftInputExtensions.ShowSoftInputAsync(this Microsoft.Maui
*REMOVED*Microsoft.Maui.Handlers.ButtonHandler.ImageSourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
*REMOVED*Microsoft.Maui.Handlers.ImageButtonHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
*REMOVED*Microsoft.Maui.Handlers.ImageHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
+virtual Microsoft.Maui.Animations.Ticker.OnSystemEnabledChanged() -> void
+virtual Microsoft.Maui.Animations.Ticker.SystemEnabled.set -> void
virtual Microsoft.Maui.Handlers.ButtonHandler.ImageSourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
virtual Microsoft.Maui.Handlers.ImageButtonHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
virtual Microsoft.Maui.Handlers.ImageHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
diff --git a/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
index a2876d5282db..d46dad7603ea 100644
--- a/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
@@ -75,6 +75,8 @@ static Microsoft.Maui.SoftInputExtensions.ShowSoftInputAsync(this Microsoft.Maui
*REMOVED*Microsoft.Maui.Handlers.ButtonHandler.ImageSourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
*REMOVED*Microsoft.Maui.Handlers.ImageButtonHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
*REMOVED*Microsoft.Maui.Handlers.ImageHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
+virtual Microsoft.Maui.Animations.Ticker.OnSystemEnabledChanged() -> void
+virtual Microsoft.Maui.Animations.Ticker.SystemEnabled.set -> void
virtual Microsoft.Maui.Handlers.ButtonHandler.ImageSourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
virtual Microsoft.Maui.Handlers.ImageButtonHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!
virtual Microsoft.Maui.Handlers.ImageHandler.SourceLoader.get -> Microsoft.Maui.Platform.ImageSourcePartLoader!