using System; using System.Threading; using System.Threading.Tasks; using Avalonia; using Avalonia.Animation; using Avalonia.Animation.Easings; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Reactive; using Avalonia.Styling; namespace Nitrox.Launcher.Models.Behaviors; public abstract class SmoothScrollBehavior { private static CancellationTokenSource animationTokenSource; private static readonly Easing smoothScrollEasing = new ExponentialEaseOut(); private static readonly Animation animation = new() { Duration = TimeSpan.FromMilliseconds(250), Easing = smoothScrollEasing, Children = { new KeyFrame { Cue = new Cue(0), Setters = { new Setter(ScrollViewer.OffsetProperty, 0) } }, new KeyFrame { Cue = new Cue(1), Setters = { new Setter(ScrollViewer.OffsetProperty, 0) } } } }; public static readonly AttachedProperty SmoothScrollProperty = AvaloniaProperty.RegisterAttached("SmoothScroll"); /// /// Gets or sets the target offset which was last used as smooth scrolling target. /// /// /// lastOffsetProperty is needed here since the ScrollViewer.Offset property is already set to the target offset when /// the PointerWheelChanged event is raised /// private static readonly AttachedProperty lastOffsetProperty = AvaloniaProperty.RegisterAttached("LastOffset", new Vector(0, 0)); static SmoothScrollBehavior() { SmoothScrollProperty.Changed.Subscribe(new AnonymousObserver>(OnEnableSmoothScrollingChanged)); ScrollViewer.OffsetProperty.Changed.Subscribe(new AnonymousObserver>(OnScrollOffsetChanged)); } private static void OnScrollOffsetChanged(AvaloniaPropertyChangedEventArgs args) { if (args.Sender is not ScrollViewer scrollViewer) { return; } // This keeps LastOffset in sync with programmatic changes to offset so there won't be any huge and ugly scroll jumps. if (animationTokenSource is null or { IsCancellationRequested: true }) { SetLastOffset(scrollViewer, args.OldValue.Value); } } public static bool GetSmoothScroll(ScrollViewer element) => element.GetValue(SmoothScrollProperty); public static void SetSmoothScroll(ScrollViewer element, bool value) => element.SetValue(SmoothScrollProperty, value); private static Vector GetLastOffset(ScrollViewer element) => element.GetValue(lastOffsetProperty); private static void SetLastOffset(ScrollViewer element, Vector value) => element.SetValue(lastOffsetProperty, value); private static void OnEnableSmoothScrollingChanged(AvaloniaPropertyChangedEventArgs args) { if (args.Sender is not ScrollViewer scrollViewer) { return; } if (args.NewValue.GetValueOrDefault()) { scrollViewer.AddHandler(InputElement.PointerWheelChangedEvent, OnPointerWheelChanged, handledEventsToo: true); } else { scrollViewer.RemoveHandler(InputElement.PointerWheelChangedEvent, OnPointerWheelChanged); } } private static void OnPointerWheelChanged(object sender, PointerWheelEventArgs e) { if (sender is not ScrollViewer scrollViewer) { return; } // Cancel ongoing animations if (animationTokenSource is { IsCancellationRequested: false }) { animationTokenSource.Cancel(); animationTokenSource.Dispose(); } // Get new offset (already set on each ScrollViewer as attached property) Vector lastOffset = GetLastOffset(scrollViewer); Vector newOffset = scrollViewer.Offset; if (lastOffset != newOffset) { animationTokenSource = new CancellationTokenSource(); SetLastOffset(scrollViewer, newOffset); AnimateScrollToTargetAsync(scrollViewer, lastOffset, newOffset, animationTokenSource.Token).ContinueWithHandleError(); } } private static async Task AnimateScrollToTargetAsync(ScrollViewer scrollViewer, Vector previousOffset, Vector targetOffset, CancellationToken cancellationToken = default) { try { ((Setter)animation.Children[0].Setters[0]).Value = previousOffset; ((Setter)animation.Children[1].Setters[0]).Value = targetOffset; await animation.RunAsync(scrollViewer, cancellationToken); } catch (OperationCanceledException) { // ignored } } }