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!