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