Browse Source
- animation/layout/render cycle is now managed from a central location - animations are now throttled if animation/layout/render pass takes longer than a frame which previously caused a soft-freeze with input not being processed - the public API is trimmed to make sure that we can make other planned changes during the 11.x support cycle "Changelog": - IClock is hidden and is planned to be replaced later - Animator classes are hidden and are planned to be refactored later - IAnimation members are hidden, it's supposed to be a marker interface for Style.Animations collection now, to start animations manually use Animation.RunAsync - Sealed several classes in Avalonia.Animation namespace - Spring class is removed from the public API (it wasn't possible to use it directly in a meaningful way anyway) - Sealed brushes, transforms, effects and drawings - Removed separate dispatcher priorities for Layout and Composition, everything now happens from a central place with Render priority (same as WPF) - - some private "hook" priorities are added for now, those will be removed later - IRenderLoop is hidden and removed from locator - IRenderer is hidden (the plan is to remove that concept later) - - Renderer.Start/Stop exposed as StartRendering/StopRendering on the toplevel (will be on a CompositionTarget/PresentationSource-like type later) - - Renderer.Diagnistics exposed as RendererDiagnostics (same) - - Renderer is no longer created by the platform code and is created by TopLevel itself - - From the user-code hit-testing should be done by VisualExtensions.GetVisual(s)At, which has the same features - - For unit tests a separate IHitTester interface is added which can be changed for a particular toplevel - ILayoutManager is hidden - - LayoutManager.ExecuteLayoutPass() exposed as TopLevel.UpdateLayout() - Custom animators now have a separate base class that only deals with interpolation Minor improvements: - Compositor has a mode that doesn't use DispatcherTimers, useful for unit tests - Introduced ScopedTestBase that auto-resets the locator when test is finishedpull/11556/head
201 changed files with 1254 additions and 821 deletions
@ -0,0 +1,30 @@ |
|||||
|
using System; |
||||
|
using Avalonia.Animation.Animators; |
||||
|
|
||||
|
namespace Avalonia.Animation; |
||||
|
|
||||
|
public abstract class CustomAnimatorBase |
||||
|
{ |
||||
|
internal abstract IAnimator CreateWrapper(); |
||||
|
internal abstract Type WrapperType { get; } |
||||
|
} |
||||
|
|
||||
|
public abstract class CustomAnimatorBase<T> : CustomAnimatorBase |
||||
|
{ |
||||
|
public abstract T Interpolate(double progress, T oldValue, T newValue); |
||||
|
|
||||
|
internal override Type WrapperType => typeof(AnimatorWrapper); |
||||
|
internal override IAnimator CreateWrapper() => new AnimatorWrapper(this); |
||||
|
|
||||
|
internal class AnimatorWrapper : Animator<T> |
||||
|
{ |
||||
|
private readonly CustomAnimatorBase<T> _parent; |
||||
|
|
||||
|
public AnimatorWrapper(CustomAnimatorBase<T> parent) |
||||
|
{ |
||||
|
_parent = parent; |
||||
|
} |
||||
|
|
||||
|
public override T Interpolate(double progress, T oldValue, T newValue) => _parent.Interpolate(progress, oldValue, newValue); |
||||
|
} |
||||
|
} |
||||
@ -1,26 +0,0 @@ |
|||||
using System; |
|
||||
using System.Collections.Generic; |
|
||||
using System.Text; |
|
||||
using Avalonia.Rendering; |
|
||||
|
|
||||
namespace Avalonia.Animation |
|
||||
{ |
|
||||
public class RenderLoopClock : ClockBase, IRenderLoopTask, IGlobalClock |
|
||||
{ |
|
||||
protected override void Stop() |
|
||||
{ |
|
||||
AvaloniaLocator.Current.GetRequiredService<IRenderLoop>().Remove(this); |
|
||||
} |
|
||||
|
|
||||
bool IRenderLoopTask.NeedsUpdate => HasSubscriptions; |
|
||||
|
|
||||
void IRenderLoopTask.Render() |
|
||||
{ |
|
||||
} |
|
||||
|
|
||||
void IRenderLoopTask.Update(TimeSpan time) |
|
||||
{ |
|
||||
Pulse(time); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,53 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Diagnostics; |
||||
|
using Avalonia.Animation; |
||||
|
using Avalonia.Reactive; |
||||
|
using Avalonia.Threading; |
||||
|
|
||||
|
namespace Avalonia.Media; |
||||
|
|
||||
|
internal partial class MediaContext |
||||
|
{ |
||||
|
private readonly MediaContextClock _clock; |
||||
|
public IGlobalClock Clock => _clock; |
||||
|
private readonly Stopwatch _time = Stopwatch.StartNew(); |
||||
|
|
||||
|
class MediaContextClock : IGlobalClock |
||||
|
{ |
||||
|
private readonly MediaContext _parent; |
||||
|
private List<IObserver<TimeSpan>> _observers = new(); |
||||
|
public bool HasNewSubscriptions { get; set; } |
||||
|
public bool HasSubscriptions => _observers.Count > 0; |
||||
|
|
||||
|
public MediaContextClock(MediaContext parent) |
||||
|
{ |
||||
|
_parent = parent; |
||||
|
} |
||||
|
|
||||
|
public IDisposable Subscribe(IObserver<TimeSpan> observer) |
||||
|
{ |
||||
|
_parent.ScheduleRender(false); |
||||
|
Dispatcher.UIThread.VerifyAccess(); |
||||
|
HasNewSubscriptions = true; |
||||
|
_observers.Add(observer); |
||||
|
return Disposable.Create(() => |
||||
|
{ |
||||
|
Dispatcher.UIThread.VerifyAccess(); |
||||
|
_observers.Remove(observer); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public void Pulse(TimeSpan now) |
||||
|
{ |
||||
|
foreach (var observer in _observers.ToArray()) |
||||
|
observer.OnNext(now); |
||||
|
} |
||||
|
|
||||
|
public PlayState PlayState |
||||
|
{ |
||||
|
get => PlayState.Run; |
||||
|
set => throw new InvalidOperationException(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,135 @@ |
|||||
|
using System; |
||||
|
using System.Threading.Tasks; |
||||
|
using Avalonia.Platform; |
||||
|
using Avalonia.Rendering.Composition; |
||||
|
using Avalonia.Rendering.Composition.Transport; |
||||
|
using Avalonia.Threading; |
||||
|
|
||||
|
namespace Avalonia.Media; |
||||
|
|
||||
|
partial class MediaContext |
||||
|
{ |
||||
|
private bool _scheduleCommitOnLastCompositionBatchCompletion; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Actually sends the current batch to the compositor and does the required housekeeping
|
||||
|
/// This is the only place that should be allowed to call Commit
|
||||
|
/// </summary>
|
||||
|
private Batch CommitCompositor(Compositor compositor) |
||||
|
{ |
||||
|
var commit = compositor.Commit(); |
||||
|
_requestedCommits.Remove(compositor); |
||||
|
_pendingCompositionBatches[compositor] = commit; |
||||
|
commit.Processed.ContinueWith(_ => |
||||
|
Dispatcher.UIThread.Post(() => CompositionBatchFinished(compositor, commit), DispatcherPriority.Send)); |
||||
|
return commit; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Handles batch completion, required to re-schedule a render pass if one was skipped due to compositor throttling
|
||||
|
/// </summary>
|
||||
|
private void CompositionBatchFinished(Compositor compositor, Batch batch) |
||||
|
{ |
||||
|
// Check if it was the last commited batch, since sometimes we are forced to send a new
|
||||
|
// one without waiting for the previous one to complete
|
||||
|
if (_pendingCompositionBatches.TryGetValue(compositor, out var waitingForBatch) && waitingForBatch == batch) |
||||
|
_pendingCompositionBatches.Remove(compositor); |
||||
|
|
||||
|
if (_pendingCompositionBatches.Count == 0) |
||||
|
{ |
||||
|
_animationsAreWaitingForComposition = false; |
||||
|
|
||||
|
// Check if we have uncommited changes
|
||||
|
if (_scheduleCommitOnLastCompositionBatchCompletion && _pendingCompositionBatches.Count > 0) |
||||
|
{ |
||||
|
CommitCompositorsWithThrottling(); |
||||
|
_scheduleCommitOnLastCompositionBatchCompletion = false; |
||||
|
} |
||||
|
// Check if there are active animations and schedule the next render
|
||||
|
else if(_clock.HasSubscriptions) |
||||
|
ScheduleRender(false); |
||||
|
} |
||||
|
|
||||
|
} |
||||
|
|
||||
|
private bool CommitCompositorsWithThrottling() |
||||
|
{ |
||||
|
// Check if we are still waiting for previous composition batches
|
||||
|
if (_pendingCompositionBatches.Count > 0) |
||||
|
{ |
||||
|
_scheduleCommitOnLastCompositionBatchCompletion = true; |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
if (_requestedCommits.Count == 0) |
||||
|
return false; |
||||
|
|
||||
|
foreach (var c in _requestedCommits) |
||||
|
CommitCompositor(c); |
||||
|
|
||||
|
_requestedCommits.Clear(); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Executes a synchronous commit when we need to wait for composition jobs to be done
|
||||
|
/// Is used in resize and TopLevel destruction scenarios
|
||||
|
/// </summary>
|
||||
|
private void SyncCommit(Compositor compositor, bool waitFullRender) |
||||
|
{ |
||||
|
// Unit tests are assuming that they can call any API without setting up platforms
|
||||
|
if (AvaloniaLocator.Current.GetService<IPlatformRenderInterface>() == null) |
||||
|
return; |
||||
|
|
||||
|
if (compositor is |
||||
|
{ |
||||
|
UseUiThreadForSynchronousCommits: false, |
||||
|
Loop.RunsInBackground: true |
||||
|
}) |
||||
|
{ |
||||
|
var batch = CommitCompositor(compositor); |
||||
|
(waitFullRender ? batch.Rendered : batch.Processed).Wait(); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
CommitCompositor(compositor); |
||||
|
compositor.Server.Render(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// This method handles synchronous rendering of a surface when requested by the OS (typically during the resize)
|
||||
|
/// </summary>
|
||||
|
// TODO: do we need to execute a render pass here too?
|
||||
|
// We've previously tried that and it made the resize experience worse
|
||||
|
public void ImmediateRenderRequested(CompositionTarget target) |
||||
|
{ |
||||
|
SyncCommit(target.Compositor, true); |
||||
|
} |
||||
|
|
||||
|
|
||||
|
/// <summary>
|
||||
|
/// This method handles synchronous destruction of the composition target, so we are guaranteed
|
||||
|
/// to release all resources when a TopLevel is being destroyed
|
||||
|
/// </summary>
|
||||
|
public void SyncDisposeCompositionTarget(CompositionTarget compositionTarget) |
||||
|
{ |
||||
|
compositionTarget.Dispose(); |
||||
|
|
||||
|
// TODO: introduce a way to skip any actual rendering for other targets and only do a dispose?
|
||||
|
SyncCommit(compositionTarget.Compositor, false); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// This method schedules a render when something has called RequestCommitAsync
|
||||
|
/// This can be triggered by user code outside of our normal layout and rendering
|
||||
|
/// </summary>
|
||||
|
void ICompositorScheduler.CommitRequested(Compositor compositor) |
||||
|
{ |
||||
|
if(!_requestedCommits.Add(compositor)) |
||||
|
return; |
||||
|
|
||||
|
// TODO: maybe skip the full render here?
|
||||
|
ScheduleRender(true); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,171 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Threading.Tasks; |
||||
|
using Avalonia.Animation; |
||||
|
using Avalonia.Layout; |
||||
|
using Avalonia.Rendering; |
||||
|
using Avalonia.Rendering.Composition; |
||||
|
using Avalonia.Rendering.Composition.Transport; |
||||
|
using Avalonia.Threading; |
||||
|
|
||||
|
namespace Avalonia.Media; |
||||
|
|
||||
|
internal partial class MediaContext : ICompositorScheduler |
||||
|
{ |
||||
|
private DispatcherOperation? _nextRenderOp; |
||||
|
private DispatcherOperation? _inputMarkerOp; |
||||
|
private TimeSpan _inputMarkerAddedAt; |
||||
|
private bool _animationsAreWaitingForComposition; |
||||
|
private const double MaxSecondsWithoutInput = 1; |
||||
|
private readonly Action _render; |
||||
|
private readonly Action _inputMarkerHandler; |
||||
|
private readonly HashSet<Compositor> _requestedCommits = new(); |
||||
|
private readonly Dictionary<Compositor, Batch> _pendingCompositionBatches = new(); |
||||
|
private record TopLevelInfo(Compositor Compositor, CompositingRenderer Renderer, ILayoutManager LayoutManager); |
||||
|
private readonly HashSet<LayoutManager> _queuedLayoutManagers = new(); |
||||
|
public static bool InputMarkerEnabled = true; |
||||
|
|
||||
|
private Dictionary<object, TopLevelInfo> _topLevels = new(); |
||||
|
|
||||
|
private MediaContext() |
||||
|
{ |
||||
|
_render = Render; |
||||
|
_inputMarkerHandler = InputMarkerHandler; |
||||
|
_clock = new(this); |
||||
|
} |
||||
|
|
||||
|
public static MediaContext Instance |
||||
|
{ |
||||
|
get |
||||
|
{ |
||||
|
// Technically it's supposed to be a thread-static singleton, but we don't have multiple threads
|
||||
|
// and need to do a full reset for unit tests
|
||||
|
var context = AvaloniaLocator.Current.GetService<MediaContext>(); |
||||
|
if (context == null) |
||||
|
AvaloniaLocator.CurrentMutable.Bind<MediaContext>().ToConstant(context = new()); |
||||
|
return context; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Schedules the next render operation, handles render throttling for input processing
|
||||
|
/// </summary>
|
||||
|
private void ScheduleRender(bool now) |
||||
|
{ |
||||
|
// Already scheduled, nothing to do
|
||||
|
if (_nextRenderOp != null) |
||||
|
{ |
||||
|
if (now) |
||||
|
_nextRenderOp.Priority = DispatcherPriority.Render; |
||||
|
return; |
||||
|
} |
||||
|
// Sometimes our animation, layout and render passes might be taking more than a frame to complete
|
||||
|
// which can cause a "freeze"-like state when UI is being updated, but input is never being processed
|
||||
|
// So here we inject an operation with Input priority to check if Input wasn't being processed
|
||||
|
// for a long time. If that's the case the next rendering operation will be scheduled to happen after all pending input
|
||||
|
|
||||
|
var priority = DispatcherPriority.Render; |
||||
|
|
||||
|
if (InputMarkerEnabled) |
||||
|
{ |
||||
|
if (_inputMarkerOp == null) |
||||
|
{ |
||||
|
_inputMarkerOp = Dispatcher.UIThread.InvokeAsync(_inputMarkerHandler, DispatcherPriority.Input); |
||||
|
_inputMarkerAddedAt = _time.Elapsed; |
||||
|
} |
||||
|
else if (!now && (_time.Elapsed - _inputMarkerAddedAt).TotalSeconds > MaxSecondsWithoutInput) |
||||
|
{ |
||||
|
priority = DispatcherPriority.Input; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
_nextRenderOp = Dispatcher.UIThread.InvokeAsync(_render, priority); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// This handles the _inputMarkerOp message. We're using
|
||||
|
/// _inputMarkerOp to determine if input priority dispatcher ops
|
||||
|
/// have been processes.
|
||||
|
/// </summary>
|
||||
|
private void InputMarkerHandler() |
||||
|
{ |
||||
|
//set the marker to null so we know that input priority has been processed
|
||||
|
_inputMarkerOp = null; |
||||
|
} |
||||
|
|
||||
|
private void Render() |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
RenderCore(); |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
_nextRenderOp = null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void RenderCore() |
||||
|
{ |
||||
|
var now = _time.Elapsed; |
||||
|
if (!_animationsAreWaitingForComposition) |
||||
|
_clock.Pulse(now); |
||||
|
|
||||
|
// Since new animations could be started during the layout and can affect layout/render
|
||||
|
// We are doing several iterations when it happens
|
||||
|
for (var c = 0; c < 10; c++) |
||||
|
{ |
||||
|
_clock.HasNewSubscriptions = false; |
||||
|
//TODO: Integrate LayoutManager's attempt limit here
|
||||
|
foreach (var layout in _queuedLayoutManagers.ToArray()) |
||||
|
layout.ExecuteQueuedLayoutPass(); |
||||
|
_queuedLayoutManagers.Clear(); |
||||
|
|
||||
|
if (_clock.HasNewSubscriptions) |
||||
|
{ |
||||
|
_clock.Pulse(now); |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
// We are currently using compositor commit callbacks to drive animations
|
||||
|
// Later we should use WPF's approach that asks the animation system for the next tick time
|
||||
|
// and use some timer if the next animation frame is not needed to be sent to the compositor immediately
|
||||
|
if (_requestedCommits.Count > 0 || _clock.HasSubscriptions) |
||||
|
{ |
||||
|
_animationsAreWaitingForComposition = true; |
||||
|
CommitCompositorsWithThrottling(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Used for unit tests
|
||||
|
public bool IsTopLevelActive(object key) => _topLevels.ContainsKey(key); |
||||
|
|
||||
|
public void AddTopLevel(object key, ILayoutManager layoutManager, IRenderer renderer) |
||||
|
{ |
||||
|
if(_topLevels.ContainsKey(key)) |
||||
|
return; |
||||
|
var render = (CompositingRenderer)renderer; |
||||
|
_topLevels.Add(key, new TopLevelInfo(render.Compositor, render, layoutManager)); |
||||
|
render.Start(); |
||||
|
ScheduleRender(true); |
||||
|
} |
||||
|
|
||||
|
public void RemoveTopLevel(object key) |
||||
|
{ |
||||
|
if (_topLevels.TryGetValue(key, out var info)) |
||||
|
{ |
||||
|
_topLevels.Remove(key); |
||||
|
info.Renderer.Stop(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public void QueueLayoutPass(LayoutManager layoutManager) |
||||
|
{ |
||||
|
_queuedLayoutManagers.Add(layoutManager); |
||||
|
ScheduleRender(true); |
||||
|
} |
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue