using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; using Avalonia.Animation; using Avalonia.Animation.Easings; using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Input.GestureRecognizers; using Avalonia.Media; using Avalonia.Styling; using Avalonia.Utilities; namespace Avalonia.Controls { /// /// A panel used by to display the current item. /// public class VirtualizingCarouselPanel : VirtualizingPanel, ILogicalScrollable { private sealed class ViewportRealizedItem { public ViewportRealizedItem(int itemIndex, Control control) { ItemIndex = itemIndex; Control = control; } public int ItemIndex { get; } public Control Control { get; } } private static readonly AttachedProperty RecycleKeyProperty = AvaloniaProperty.RegisterAttached("RecycleKey"); private static readonly object s_itemIsItsOwnContainer = new object(); private Size _extent; private Vector _offset; private Size _viewport; private Dictionary>? _recyclePool; private readonly Dictionary _viewportRealized = new(); private Control? _realized; private int _realizedIndex = -1; private Control? _transitionFrom; private int _transitionFromIndex = -1; private CancellationTokenSource? _transition; private Task? _transitionTask; private EventHandler? _scrollInvalidated; private bool _canHorizontallyScroll; private bool _canVerticallyScroll; private SwipeGestureRecognizer? _swipeGestureRecognizer; private int _swipeGestureId; private bool _isDragging; private double _totalDelta; private bool _isForward; private Control? _swipeTarget; private int _swipeTargetIndex = -1; private PageSlide.SlideAxis? _swipeAxis; private PageSlide.SlideAxis _lockedAxis; private const double SwipeCommitThreshold = 0.25; private const double VelocityCommitThreshold = 800; private const double MinSwipeDistanceForVelocityCommit = 0.05; private const double RubberBandFactor = 0.3; private const double RubberBandReturnDuration = 0.16; private const double MaxCompletionDuration = 0.35; private const double MinCompletionDuration = 0.12; private static readonly StyledProperty CompletionProgressProperty = AvaloniaProperty.Register("CompletionProgress"); private static readonly StyledProperty OffsetAnimationProgressProperty = AvaloniaProperty.Register("OffsetAnimationProgress"); private CancellationTokenSource? _completionCts; private CancellationTokenSource? _offsetAnimationCts; private double _completionEndProgress; private bool _isRubberBanding; private double _dragStartOffset; private double _progressStartOffset; private double _offsetAnimationStart; private double _offsetAnimationTarget; private double _activeViewportTargetOffset; private int _progressFromIndex = -1; private int _progressToIndex = -1; internal bool IsManagingInteractionOffset => UsesViewportFractionLayout() && (_isDragging || _offsetAnimationCts is { IsCancellationRequested: false }); bool ILogicalScrollable.CanHorizontallyScroll { get => _canHorizontallyScroll; set => _canHorizontallyScroll = value; } bool ILogicalScrollable.CanVerticallyScroll { get => _canVerticallyScroll; set => _canVerticallyScroll = value; } bool IScrollable.CanHorizontallyScroll => _canHorizontallyScroll; bool IScrollable.CanVerticallyScroll => _canVerticallyScroll; bool ILogicalScrollable.IsLogicalScrollEnabled => true; Size ILogicalScrollable.ScrollSize => new(1, 1); Size ILogicalScrollable.PageScrollSize => new(1, 1); Size IScrollable.Extent => Extent; Size IScrollable.Viewport => Viewport; Vector IScrollable.Offset { get => _offset; set => SetOffset(value); } private Size Extent { get => _extent; set { if (_extent != value) { _extent = value; _scrollInvalidated?.Invoke(this, EventArgs.Empty); } } } private Size Viewport { get => _viewport; set { if (_viewport != value) { _viewport = value; _scrollInvalidated?.Invoke(this, EventArgs.Empty); } } } event EventHandler? ILogicalScrollable.ScrollInvalidated { add => _scrollInvalidated += value; remove => _scrollInvalidated -= value; } bool ILogicalScrollable.BringIntoView(Control target, Rect targetRect) => false; Control? ILogicalScrollable.GetControlInDirection(NavigationDirection direction, Control? from) => null; void ILogicalScrollable.RaiseScrollInvalidated(EventArgs e) => _scrollInvalidated?.Invoke(this, e); private bool UsesViewportFractionLayout() { return ItemsControl is Carousel carousel && !MathUtilities.AreClose(carousel.ViewportFraction, 1d); } private PageSlide.SlideAxis GetLayoutAxis() { return (ItemsControl as Carousel)?.GetLayoutAxis() ?? PageSlide.SlideAxis.Horizontal; } private double GetViewportFraction() { return (ItemsControl as Carousel)?.ViewportFraction ?? 1d; } private double GetViewportUnits() { return 1d / GetViewportFraction(); } private double GetPrimaryOffset(Vector offset) { return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? offset.Y : offset.X; } private double GetPrimarySize(Size size) { return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? size.Height : size.Width; } private double GetCrossSize(Size size) { return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? size.Width : size.Height; } private Size CreateLogicalSize(double primary) { return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? new Size(1, primary) : new Size(primary, 1); } private Size CreateItemSize(double primary, double cross) { return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? new Size(cross, primary) : new Size(primary, cross); } private Rect CreateItemRect(double primaryOffset, double primarySize, double crossSize) { return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? new Rect(0, primaryOffset, crossSize, primarySize) : new Rect(primaryOffset, 0, primarySize, crossSize); } private Vector WithPrimaryOffset(Vector offset, double primary) { return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? new Vector(offset.X, primary) : new Vector(primary, offset.Y); } private Size ResolveLayoutSize(Size availableSize) { var owner = ItemsControl as Control; double ResolveDimension(double available, double bounds, double ownerBounds, double ownerExplicit) { if (!double.IsInfinity(available) && available > 0) return available; if (bounds > 0) return bounds; if (ownerBounds > 0) return ownerBounds; return double.IsNaN(ownerExplicit) ? 0 : ownerExplicit; } var width = ResolveDimension(availableSize.Width, Bounds.Width, owner?.Bounds.Width ?? 0, owner?.Width ?? double.NaN); var height = ResolveDimension(availableSize.Height, Bounds.Height, owner?.Bounds.Height ?? 0, owner?.Height ?? double.NaN); return new Size(width, height); } private double GetViewportItemExtent(Size size) { var viewportUnits = GetViewportUnits(); return viewportUnits <= 0 ? 0 : GetPrimarySize(size) / viewportUnits; } private bool UsesViewportWrapLayout() { return UsesViewportFractionLayout() && ItemsControl is Carousel { WrapSelection: true } && Items.Count > 1; } private static int NormalizeIndex(int index, int count) { return ((index % count) + count) % count; } private double GetNearestLogicalOffset(int itemIndex, double referenceOffset) { if (!UsesViewportWrapLayout() || Items.Count == 0) return Math.Clamp(itemIndex, 0, Math.Max(0, Items.Count - 1)); var wrapSpan = Items.Count; var wrapMultiplier = Math.Round((referenceOffset - itemIndex) / wrapSpan); return itemIndex + (wrapMultiplier * wrapSpan); } private bool IsPreferredViewportSlot(int candidateLogicalIndex, int existingLogicalIndex, double primaryOffset) { var candidateDistance = Math.Abs(candidateLogicalIndex - primaryOffset); var existingDistance = Math.Abs(existingLogicalIndex - primaryOffset); if (!MathUtilities.AreClose(candidateDistance, existingDistance)) return candidateDistance < existingDistance; var candidateInRange = candidateLogicalIndex >= 0 && candidateLogicalIndex < Items.Count; var existingInRange = existingLogicalIndex >= 0 && existingLogicalIndex < Items.Count; if (candidateInRange != existingInRange) return candidateInRange; if (_isDragging) return _isForward ? candidateLogicalIndex > existingLogicalIndex : candidateLogicalIndex < existingLogicalIndex; return candidateLogicalIndex < existingLogicalIndex; } private IReadOnlyList<(int LogicalIndex, int ItemIndex)> GetRequiredViewportSlots(double primaryOffset) { if (Items.Count == 0) return Array.Empty<(int LogicalIndex, int ItemIndex)>(); var viewportUnits = GetViewportUnits(); var edgeInset = (viewportUnits - 1) / 2; var start = (int)Math.Floor(primaryOffset - edgeInset); var end = (int)Math.Ceiling(primaryOffset + viewportUnits - edgeInset) - 1; if (!UsesViewportWrapLayout()) { start = Math.Max(0, start); end = Math.Min(Items.Count - 1, end); if (start > end) return Array.Empty<(int LogicalIndex, int ItemIndex)>(); var result = new (int LogicalIndex, int ItemIndex)[end - start + 1]; for (var i = 0; i < result.Length; ++i) { var index = start + i; result[i] = (index, index); } return result; } var bestSlots = new Dictionary(); for (var logicalIndex = start; logicalIndex <= end; ++logicalIndex) { var itemIndex = NormalizeIndex(logicalIndex, Items.Count); if (!bestSlots.TryGetValue(itemIndex, out var existingLogicalIndex) || IsPreferredViewportSlot(logicalIndex, existingLogicalIndex, primaryOffset)) { bestSlots[itemIndex] = logicalIndex; } } return bestSlots .Select(x => (LogicalIndex: x.Value, ItemIndex: x.Key)) .OrderBy(x => x.LogicalIndex) .ToArray(); } private bool ViewportSlotsChanged(double oldPrimaryOffset, double newPrimaryOffset) { var oldSlots = GetRequiredViewportSlots(oldPrimaryOffset); var newSlots = GetRequiredViewportSlots(newPrimaryOffset); if (oldSlots.Count != newSlots.Count) return true; for (var i = 0; i < oldSlots.Count; ++i) { if (oldSlots[i].LogicalIndex != newSlots[i].LogicalIndex || oldSlots[i].ItemIndex != newSlots[i].ItemIndex) { return true; } } return false; } private void SetOffset(Vector value) { if (UsesViewportFractionLayout()) { var oldPrimaryOffset = GetPrimaryOffset(_offset); var newPrimaryOffset = GetPrimaryOffset(value); if (MathUtilities.AreClose(oldPrimaryOffset, newPrimaryOffset)) { _offset = value; return; } _offset = value; var rangeChanged = ViewportSlotsChanged(oldPrimaryOffset, newPrimaryOffset); if (rangeChanged) InvalidateMeasure(); else InvalidateArrange(); _scrollInvalidated?.Invoke(this, EventArgs.Empty); return; } if ((int)_offset.X != value.X) InvalidateMeasure(); _offset = value; } private void ClearViewportRealized() { if (_viewportRealized.Count == 0) return; foreach (var element in _viewportRealized.Values.Select(x => x.Control).ToArray()) RecycleElement(element); _viewportRealized.Clear(); } private void ResetSinglePageState() { _transition?.Cancel(); _transition = null; _transitionTask = null; if (_transitionFrom is not null) RecycleElement(_transitionFrom); if (_swipeTarget is not null) RecycleElement(_swipeTarget); if (_realized is not null) RecycleElement(_realized); _transitionFrom = null; _transitionFromIndex = -1; _swipeTarget = null; _swipeTargetIndex = -1; _realized = null; _realizedIndex = -1; } private void CancelOffsetAnimation() { _offsetAnimationCts?.Cancel(); _offsetAnimationCts = null; } protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); RefreshGestureRecognizer(); } protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { base.OnDetachedFromVisualTree(e); TeardownGestureRecognizer(); } protected override void OnItemsControlChanged(ItemsControl? oldValue) { base.OnItemsControlChanged(oldValue); RefreshGestureRecognizer(); } protected override Size MeasureOverride(Size availableSize) { if (UsesViewportFractionLayout()) return MeasureViewportFractionOverride(availableSize); ClearViewportRealized(); CancelOffsetAnimation(); return MeasureSinglePageOverride(availableSize); } private Size MeasureSinglePageOverride(Size availableSize) { var items = Items; var index = (int)_offset.X; CompleteFinishedTransitionIfNeeded(); if (index != _realizedIndex) { if (_realized is not null) { // Cancel any already running transition, and recycle the element we're transitioning from. if (_transition is not null) { _transition.Cancel(); _transition = null; _transitionTask = null; if (_transitionFrom is not null) RecycleElement(_transitionFrom); _transitionFrom = null; _transitionFromIndex = -1; ResetTransitionState(_realized); } if (GetTransition() is null) { RecycleElement(_realized); } else { // Record the current element as the element we're transitioning // from and we'll start the transition in the arrange pass. _transitionFrom = _realized; _transitionFromIndex = _realizedIndex; } _realized = null; _realizedIndex = -1; } // Get or create an element for the new item. if (index >= 0 && index < items.Count) { _realized = GetOrCreateElement(items, index); _realizedIndex = index; } } if (_realized is null) { Extent = Viewport = new(0, 0); _transitionFrom = null; _transitionFromIndex = -1; return default; } _realized.Measure(availableSize); Extent = new(items.Count, 1); Viewport = new(1, 1); return _realized.DesiredSize; } protected override Size ArrangeOverride(Size finalSize) { if (UsesViewportFractionLayout()) return ArrangeViewportFractionOverride(finalSize); return ArrangeSinglePageOverride(finalSize); } private Size ArrangeSinglePageOverride(Size finalSize) { var result = base.ArrangeOverride(finalSize); if (_transition is null && _transitionFrom is not null && _realized is { } to && GetTransition() is { } transition) { _transition = new CancellationTokenSource(); var forward = (_realizedIndex > _transitionFromIndex); if (Items.Count > 2) { forward = forward || (_transitionFromIndex == Items.Count - 1 && _realizedIndex == 0); forward = forward && !(_transitionFromIndex == 0 && _realizedIndex == Items.Count - 1); } _transitionTask = RunTransitionAsync(_transition, _transitionFrom, to, forward, transition); } return result; } private Size MeasureViewportFractionOverride(Size availableSize) { ResetSinglePageState(); if (Items.Count == 0) { ClearViewportRealized(); Extent = Viewport = new(0, 0); return default; } var layoutSize = ResolveLayoutSize(availableSize); var primarySize = GetPrimarySize(layoutSize); var crossSize = GetCrossSize(layoutSize); var viewportUnits = GetViewportUnits(); if (primarySize <= 0 || viewportUnits <= 0) { ClearViewportRealized(); Extent = Viewport = new(0, 0); return default; } var itemPrimarySize = primarySize / viewportUnits; var itemSize = CreateItemSize(itemPrimarySize, crossSize); var requiredSlots = GetRequiredViewportSlots(GetPrimaryOffset(_offset)); var requiredMap = requiredSlots.ToDictionary(x => x.LogicalIndex, x => x.ItemIndex); foreach (var entry in _viewportRealized.ToArray()) { if (!requiredMap.TryGetValue(entry.Key, out var itemIndex) || entry.Value.ItemIndex != itemIndex) { RecycleElement(entry.Value.Control); _viewportRealized.Remove(entry.Key); } } foreach (var slot in requiredSlots) { if (!_viewportRealized.ContainsKey(slot.LogicalIndex)) { _viewportRealized[slot.LogicalIndex] = new ViewportRealizedItem( slot.ItemIndex, GetOrCreateElement(Items, slot.ItemIndex)); } } var maxCrossDesiredSize = 0d; foreach (var element in _viewportRealized.Values.Select(x => x.Control)) { element.Measure(itemSize); maxCrossDesiredSize = Math.Max(maxCrossDesiredSize, GetCrossSize(element.DesiredSize)); } Viewport = CreateLogicalSize(viewportUnits); Extent = CreateLogicalSize(Math.Max(0, Items.Count + viewportUnits - 1)); var desiredPrimary = double.IsInfinity(primarySize) ? itemPrimarySize * viewportUnits : primarySize; var desiredCross = double.IsInfinity(crossSize) ? maxCrossDesiredSize : crossSize; return CreateItemSize(desiredPrimary, desiredCross); } private Size ArrangeViewportFractionOverride(Size finalSize) { var primarySize = GetPrimarySize(finalSize); var crossSize = GetCrossSize(finalSize); var viewportUnits = GetViewportUnits(); if (primarySize <= 0 || viewportUnits <= 0) return finalSize; if (_viewportRealized.Count == 0 && Items.Count > 0) { InvalidateMeasure(); return finalSize; } var itemPrimarySize = primarySize / viewportUnits; var edgeInset = (viewportUnits - 1) / 2; var primaryOffset = GetPrimaryOffset(_offset); foreach (var entry in _viewportRealized.OrderBy(x => x.Key)) { var itemOffset = (edgeInset + entry.Key - primaryOffset) * itemPrimarySize; var rect = CreateItemRect(itemOffset, itemPrimarySize, crossSize); entry.Value.Control.IsVisible = true; entry.Value.Control.Arrange(rect); } return finalSize; } protected override IInputElement? GetControl(NavigationDirection direction, IInputElement? from, bool wrap) => null; protected internal override Control? ContainerFromIndex(int index) { if (index < 0 || index >= Items.Count) return null; var viewportRealized = _viewportRealized.Values.FirstOrDefault(x => x.ItemIndex == index); if (viewportRealized is not null) return viewportRealized.Control; if (index == _realizedIndex) return _realized; if (Items[index] is Control c && c.GetValue(RecycleKeyProperty) == s_itemIsItsOwnContainer) return c; return null; } protected internal override IEnumerable? GetRealizedContainers() { if (_viewportRealized.Count > 0) return _viewportRealized.OrderBy(x => x.Key).Select(x => x.Value.Control); return _realized is not null ? new[] { _realized } : null; } protected internal override int IndexFromContainer(Control container) { foreach (var entry in _viewportRealized) { if (ReferenceEquals(entry.Value.Control, container)) return entry.Value.ItemIndex; } return container == _realized ? _realizedIndex : -1; } protected internal override Control? ScrollIntoView(int index) { return null; } protected override void OnItemsChanged(IReadOnlyList items, NotifyCollectionChangedEventArgs e) { base.OnItemsChanged(items, e); if (UsesViewportFractionLayout() || _viewportRealized.Count > 0) { ClearViewportRealized(); InvalidateMeasure(); return; } void Add(int index, int count) { if (_realized is null) { InvalidateMeasure(); return; } if (index <= _realizedIndex) _realizedIndex += count; } void Remove(int index, int count) { var end = index + (count - 1); if (_realized is not null && index <= _realizedIndex && end >= _realizedIndex) { RecycleElement(_realized); _realized = null; _realizedIndex = -1; } else if (index < _realizedIndex) { _realizedIndex -= count; } } switch (e.Action) { case NotifyCollectionChangedAction.Add: Add(e.NewStartingIndex, e.NewItems!.Count); break; case NotifyCollectionChangedAction.Remove: Remove(e.OldStartingIndex, e.OldItems!.Count); break; case NotifyCollectionChangedAction.Replace: if (e.OldStartingIndex < 0) { goto case NotifyCollectionChangedAction.Reset; } Remove(e.OldStartingIndex, e.OldItems!.Count); Add(e.NewStartingIndex, e.NewItems!.Count); break; case NotifyCollectionChangedAction.Move: if (e.OldStartingIndex < 0) { goto case NotifyCollectionChangedAction.Reset; } Remove(e.OldStartingIndex, e.OldItems!.Count); var insertIndex = e.NewStartingIndex; if (e.NewStartingIndex > e.OldStartingIndex) { insertIndex -= e.OldItems.Count - 1; } Add(insertIndex, e.NewItems!.Count); break; case NotifyCollectionChangedAction.Reset: if (_realized is not null) { RecycleElement(_realized); _realized = null; _realizedIndex = -1; } break; } InvalidateMeasure(); } private Control GetOrCreateElement(IReadOnlyList items, int index) { Debug.Assert(ItemContainerGenerator is not null); var e = GetRealizedElement(index); if (e is null) { var item = items[index]; var generator = ItemContainerGenerator!; if (generator.NeedsContainer(item, index, out var recycleKey)) { e = GetRecycledElement(item, index, recycleKey) ?? CreateElement(item, index, recycleKey); } else { e = GetItemAsOwnContainer(item, index); } } return e; } private Control? GetRealizedElement(int index) { var viewportRealized = _viewportRealized.Values.FirstOrDefault(x => x.ItemIndex == index); if (viewportRealized is not null) return viewportRealized.Control; return _realizedIndex == index ? _realized : null; } private Control GetItemAsOwnContainer(object? item, int index) { Debug.Assert(ItemContainerGenerator is not null); var controlItem = (Control)item!; var generator = ItemContainerGenerator!; if (!controlItem.IsSet(RecycleKeyProperty)) { generator.PrepareItemContainer(controlItem, controlItem, index); AddInternalChild(controlItem); controlItem.SetValue(RecycleKeyProperty, s_itemIsItsOwnContainer); generator.ItemContainerPrepared(controlItem, item, index); } controlItem.IsVisible = true; return controlItem; } private Control? GetRecycledElement(object? item, int index, object? recycleKey) { Debug.Assert(ItemContainerGenerator is not null); if (recycleKey is null) return null; var generator = ItemContainerGenerator!; if (_recyclePool?.TryGetValue(recycleKey, out var recyclePool) == true && recyclePool.Count > 0) { var recycled = recyclePool.Pop(); recycled.IsVisible = true; generator.PrepareItemContainer(recycled, item, index); generator.ItemContainerPrepared(recycled, item, index); return recycled; } return null; } private Control CreateElement(object? item, int index, object? recycleKey) { Debug.Assert(ItemContainerGenerator is not null); var generator = ItemContainerGenerator!; var container = generator.CreateContainer(item, index, recycleKey); container.SetValue(RecycleKeyProperty, recycleKey); generator.PrepareItemContainer(container, item, index); AddInternalChild(container); generator.ItemContainerPrepared(container, item, index); return container; } private void RecycleElement(Control element) { Debug.Assert(ItemContainerGenerator is not null); var recycleKey = element.GetValue(RecycleKeyProperty); Debug.Assert(recycleKey is not null); // Hide first so cleanup doesn't visibly snap transforms/opacity for a frame. element.IsVisible = false; ResetTransitionState(element); if (recycleKey == s_itemIsItsOwnContainer) { return; } else { ItemContainerGenerator.ClearItemContainer(element); _recyclePool ??= new(); if (!_recyclePool.TryGetValue(recycleKey, out var pool)) { pool = new(); _recyclePool.Add(recycleKey, pool); } pool.Push(element); } } private IPageTransition? GetTransition() => (ItemsControl as Carousel)?.PageTransition; private void CompleteFinishedTransitionIfNeeded() { if (_transition is not null && _transitionTask?.IsCompleted == true) { if (_transitionFrom is not null) RecycleElement(_transitionFrom); _transition = null; _transitionTask = null; _transitionFrom = null; _transitionFromIndex = -1; } } private async Task RunTransitionAsync( CancellationTokenSource transitionCts, Control transitionFrom, Control transitionTo, bool forward, IPageTransition transition) { try { await transition.Start(transitionFrom, transitionTo, forward, transitionCts.Token); } catch (OperationCanceledException) { // Expected when a transition is interrupted by a newer navigation action. } catch (Exception e) { _ = e; } if (transitionCts.IsCancellationRequested || !ReferenceEquals(_transition, transitionCts)) return; if (_transitionFrom is not null) RecycleElement(_transitionFrom); _transition = null; _transitionTask = null; _transitionFrom = null; _transitionFromIndex = -1; } internal void SyncSelectionOffset(int selectedIndex) { if (!UsesViewportFractionLayout()) { SetOffset(WithPrimaryOffset(_offset, selectedIndex)); return; } var currentOffset = GetPrimaryOffset(_offset); var targetOffset = GetNearestLogicalOffset(selectedIndex, currentOffset); if (MathUtilities.AreClose(currentOffset, targetOffset)) { SetOffset(WithPrimaryOffset(_offset, targetOffset)); return; } if (_isDragging) return; var transition = GetTransition(); var canAnimate = transition is not null && Math.Abs(targetOffset - currentOffset) <= 1.001; if (!canAnimate) { ResetViewportTransitionState(); ClearFractionalProgressContext(); SetOffset(WithPrimaryOffset(_offset, targetOffset)); return; } var fromIndex = Items.Count > 0 ? NormalizeIndex((int)Math.Round(currentOffset), Items.Count) : -1; var forward = targetOffset > currentOffset; ResetViewportTransitionState(); SetFractionalProgressContext(fromIndex, selectedIndex, forward, currentOffset, targetOffset); _ = AnimateViewportOffsetAsync( currentOffset, targetOffset, TimeSpan.FromSeconds(MaxCompletionDuration), new QuadraticEaseOut(), () => { ResetViewportTransitionState(); ClearFractionalProgressContext(); // SyncScrollOffset is blocked during animation and the post-animation layout // still sees a live CTS, so re-sync explicitly in case SelectedIndex changed. if (ItemsControl is Carousel carousel) SyncSelectionOffset(carousel.SelectedIndex); }); } /// /// Refreshes the gesture recognizer based on the carousel's IsSwipeEnabled and PageTransition settings. /// internal void RefreshGestureRecognizer() { TeardownGestureRecognizer(); if (ItemsControl is not Carousel carousel || !carousel.IsSwipeEnabled) return; _swipeAxis = UsesViewportFractionLayout() ? carousel.GetLayoutAxis() : carousel.GetTransitionAxis(); _swipeGestureRecognizer = new SwipeGestureRecognizer { CanHorizontallySwipe = _swipeAxis != PageSlide.SlideAxis.Vertical, CanVerticallySwipe = _swipeAxis != PageSlide.SlideAxis.Horizontal, IsMouseEnabled = true, }; GestureRecognizers.Add(_swipeGestureRecognizer); AddHandler(InputElement.SwipeGestureEvent, OnSwipeGesture); AddHandler(InputElement.SwipeGestureEndedEvent, OnSwipeGestureEnded); } private void TeardownGestureRecognizer() { _completionCts?.Cancel(); _completionCts = null; CancelOffsetAnimation(); if (_swipeGestureRecognizer is not null) { GestureRecognizers.Remove(_swipeGestureRecognizer); _swipeGestureRecognizer = null; } RemoveHandler(InputElement.SwipeGestureEvent, OnSwipeGesture); RemoveHandler(InputElement.SwipeGestureEndedEvent, OnSwipeGestureEnded); ResetSwipeState(); } private Control? FindViewportControl(int itemIndex) { return _viewportRealized.Values.FirstOrDefault(x => x.ItemIndex == itemIndex)?.Control; } private void SetFractionalProgressContext(int fromIndex, int toIndex, bool forward, double startOffset, double targetOffset) { _progressFromIndex = fromIndex; _progressToIndex = toIndex; _isForward = forward; _progressStartOffset = startOffset; _activeViewportTargetOffset = targetOffset; } private void ClearFractionalProgressContext() { _progressFromIndex = -1; _progressToIndex = -1; _progressStartOffset = 0; _activeViewportTargetOffset = 0; } private double GetFractionalTransitionProgress(double currentOffset) { var totalDistance = Math.Abs(_activeViewportTargetOffset - _progressStartOffset); if (totalDistance <= 0) return 0; return Math.Clamp(Math.Abs(currentOffset - _progressStartOffset) / totalDistance, 0, 1); } private void ResetViewportTransitionState() { foreach (var element in _viewportRealized.Values.Select(x => x.Control)) ResetTransitionState(element); } private void OnSwipeGesture(object? sender, SwipeGestureEventArgs e) { if (ItemsControl is not Carousel carousel || !carousel.IsSwipeEnabled) return; if (UsesViewportFractionLayout()) { OnViewportFractionSwipeGesture(carousel, e); return; } if (_realizedIndex < 0 || Items.Count == 0) return; if (_completionCts is { IsCancellationRequested: false }) { _completionCts.Cancel(); _completionCts = null; var wasCommit = _completionEndProgress > 0.5; if (wasCommit && _swipeTarget is not null) { if (_realized != null) RecycleElement(_realized); _realized = _swipeTarget; _realizedIndex = _swipeTargetIndex; carousel.SelectedIndex = _swipeTargetIndex; } else { ResetSwipeState(); } _swipeTarget = null; _swipeTargetIndex = -1; _totalDelta = 0; } if (_isDragging && e.Id != _swipeGestureId) return; if (!_isDragging) { // Lock the axis on gesture start to keep diagonal drags stable. _lockedAxis = _swipeAxis ?? (Math.Abs(e.Delta.X) >= Math.Abs(e.Delta.Y) ? PageSlide.SlideAxis.Horizontal : PageSlide.SlideAxis.Vertical); } var delta = _lockedAxis == PageSlide.SlideAxis.Horizontal ? e.Delta.X : e.Delta.Y; if (!_isDragging) { _isForward = delta > 0; _isRubberBanding = false; var currentIndex = _realizedIndex; var targetIndex = _isForward ? currentIndex + 1 : currentIndex - 1; if (targetIndex >= Items.Count) { if (carousel.WrapSelection) targetIndex = 0; else _isRubberBanding = true; } else if (targetIndex < 0) { if (carousel.WrapSelection) targetIndex = Items.Count - 1; else _isRubberBanding = true; } if (!_isRubberBanding && (targetIndex == currentIndex || targetIndex < 0 || targetIndex >= Items.Count)) return; _isDragging = true; _swipeGestureId = e.Id; _totalDelta = 0; _swipeTargetIndex = _isRubberBanding ? -1 : targetIndex; carousel.IsSwiping = true; if (_transition is not null) { _transition.Cancel(); _transition = null; if (_transitionFrom is not null) RecycleElement(_transitionFrom); _transitionFrom = null; _transitionFromIndex = -1; } if (!_isRubberBanding) { _swipeTarget = GetOrCreateElement(Items, _swipeTargetIndex); _swipeTarget.Measure(Bounds.Size); _swipeTarget.Arrange(new Rect(Bounds.Size)); _swipeTarget.IsVisible = true; } } _totalDelta += delta; // Clamp so totalDelta cannot cross zero (absorbs touch jitter). if (_isForward) _totalDelta = Math.Max(0, _totalDelta); else _totalDelta = Math.Min(0, _totalDelta); var size = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Bounds.Width : Bounds.Height; if (size <= 0) return; var rawProgress = Math.Clamp(Math.Abs(_totalDelta) / size, 0, 1); var progress = _isRubberBanding ? RubberBandFactor * Math.Sqrt(rawProgress) : rawProgress; if (GetTransition() is IProgressPageTransition progressive) { progressive.Update( progress, _realized, _isRubberBanding ? null : _swipeTarget, _isForward, size, Array.Empty()); } e.Handled = true; } private void OnViewportFractionSwipeGesture(Carousel carousel, SwipeGestureEventArgs e) { if (_offsetAnimationCts is { IsCancellationRequested: false }) { CancelOffsetAnimation(); SetOffset(WithPrimaryOffset(_offset, GetNearestLogicalOffset(carousel.SelectedIndex, GetPrimaryOffset(_offset)))); } if (_isDragging && e.Id != _swipeGestureId) return; var delta = _lockedAxis == PageSlide.SlideAxis.Horizontal ? e.Delta.X : e.Delta.Y; if (!_isDragging) { _lockedAxis = carousel.GetLayoutAxis(); _swipeGestureId = e.Id; _dragStartOffset = GetNearestLogicalOffset(carousel.SelectedIndex, GetPrimaryOffset(_offset)); _totalDelta = 0; _isDragging = true; _isRubberBanding = false; carousel.IsSwiping = true; _isForward = delta > 0; var targetIndex = _isForward ? carousel.SelectedIndex + 1 : carousel.SelectedIndex - 1; if (targetIndex >= Items.Count || targetIndex < 0) { if (carousel.WrapSelection && Items.Count > 1) targetIndex = NormalizeIndex(targetIndex, Items.Count); else _isRubberBanding = true; } var targetOffset = _isForward ? _dragStartOffset + 1 : _dragStartOffset - 1; SetFractionalProgressContext( carousel.SelectedIndex, _isRubberBanding ? -1 : targetIndex, _isForward, _dragStartOffset, targetOffset); ResetViewportTransitionState(); } _totalDelta += delta; if (_isForward) _totalDelta = Math.Max(0, _totalDelta); else _totalDelta = Math.Min(0, _totalDelta); var itemExtent = GetViewportItemExtent(Bounds.Size); if (itemExtent <= 0) return; var logicalDelta = Math.Clamp(Math.Abs(_totalDelta) / itemExtent, 0, 1); var proposedOffset = _dragStartOffset + (_isForward ? logicalDelta : -logicalDelta); if (!_isRubberBanding) { proposedOffset = Math.Clamp( proposedOffset, Math.Min(_dragStartOffset, _activeViewportTargetOffset), Math.Max(_dragStartOffset, _activeViewportTargetOffset)); } else if (proposedOffset < 0) { proposedOffset = -(RubberBandFactor * Math.Sqrt(-proposedOffset)); } else { var maxOffset = Math.Max(0, Items.Count - 1); proposedOffset = maxOffset + (RubberBandFactor * Math.Sqrt(proposedOffset - maxOffset)); } SetOffset(WithPrimaryOffset(_offset, proposedOffset)); if (GetTransition() is IProgressPageTransition progressive) { var currentOffset = GetPrimaryOffset(_offset); var progress = Math.Clamp(Math.Abs(currentOffset - _dragStartOffset), 0, 1); progressive.Update( progress, FindViewportControl(_progressFromIndex), FindViewportControl(_progressToIndex), _isForward, GetViewportItemExtent(Bounds.Size), BuildFractionalVisibleItems(currentOffset)); } e.Handled = true; } private void OnViewportFractionSwipeGestureEnded(Carousel carousel, SwipeGestureEndedEventArgs e) { var itemExtent = GetViewportItemExtent(Bounds.Size); var currentOffset = GetPrimaryOffset(_offset); var currentProgress = Math.Abs(currentOffset - _dragStartOffset); var velocity = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Math.Abs(e.Velocity.X) : Math.Abs(e.Velocity.Y); var targetIndex = _progressToIndex; var canCommit = !_isRubberBanding && targetIndex >= 0; var commit = canCommit && (currentProgress >= SwipeCommitThreshold || (velocity > VelocityCommitThreshold && currentProgress >= MinSwipeDistanceForVelocityCommit)); var endOffset = commit ? _activeViewportTargetOffset : GetNearestLogicalOffset(carousel.SelectedIndex, currentOffset); var remainingDistance = Math.Abs(endOffset - currentOffset); var durationSeconds = _isRubberBanding ? RubberBandReturnDuration : velocity > 0 && itemExtent > 0 ? Math.Clamp(remainingDistance * itemExtent / velocity, MinCompletionDuration, MaxCompletionDuration) : MaxCompletionDuration; var easing = _isRubberBanding ? (Easing)new SineEaseOut() : new QuadraticEaseOut(); _isDragging = false; _ = AnimateViewportOffsetAsync( currentOffset, endOffset, TimeSpan.FromSeconds(durationSeconds), easing, () => { _totalDelta = 0; _isRubberBanding = false; carousel.IsSwiping = false; if (commit) { SetOffset(WithPrimaryOffset(_offset, GetNearestLogicalOffset(targetIndex, endOffset))); carousel.SelectedIndex = targetIndex; } else { SetOffset(WithPrimaryOffset(_offset, GetNearestLogicalOffset(carousel.SelectedIndex, endOffset))); } ResetViewportTransitionState(); ClearFractionalProgressContext(); }); } private async Task AnimateViewportOffsetAsync( double fromOffset, double toOffset, TimeSpan duration, Easing easing, Action onCompleted) { CancelOffsetAnimation(); var offsetAnimationCts = new CancellationTokenSource(); _offsetAnimationCts = offsetAnimationCts; var cancellationToken = offsetAnimationCts.Token; var animation = new Animation.Animation { FillMode = FillMode.Forward, Duration = duration, Easing = easing, Children = { new KeyFrame { Setters = { new Setter(OffsetAnimationProgressProperty, 0d) }, Cue = new Cue(0d) }, new KeyFrame { Setters = { new Setter(OffsetAnimationProgressProperty, 1d) }, Cue = new Cue(1d) } } }; _offsetAnimationStart = fromOffset; _offsetAnimationTarget = toOffset; SetValue(OffsetAnimationProgressProperty, 0d); try { await animation.RunAsync(this, null, cancellationToken); if (cancellationToken.IsCancellationRequested) return; SetOffset(WithPrimaryOffset(_offset, toOffset)); if (UsesViewportFractionLayout() && GetTransition() is IProgressPageTransition progressive) { var transitionProgress = GetFractionalTransitionProgress(toOffset); progressive.Update( transitionProgress, FindViewportControl(_progressFromIndex), FindViewportControl(_progressToIndex), _isForward, GetViewportItemExtent(Bounds.Size), BuildFractionalVisibleItems(toOffset)); } onCompleted(); } finally { if (ReferenceEquals(_offsetAnimationCts, offsetAnimationCts)) _offsetAnimationCts = null; } } private void OnSwipeGestureEnded(object? sender, SwipeGestureEndedEventArgs e) { if (!_isDragging || e.Id != _swipeGestureId || ItemsControl is not Carousel carousel) return; if (UsesViewportFractionLayout()) { OnViewportFractionSwipeGestureEnded(carousel, e); return; } var size = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Bounds.Width : Bounds.Height; var rawProgress = size > 0 ? Math.Abs(_totalDelta) / size : 0; var currentProgress = _isRubberBanding ? RubberBandFactor * Math.Sqrt(rawProgress) : rawProgress; var velocity = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Math.Abs(e.Velocity.X) : Math.Abs(e.Velocity.Y); var commit = !_isRubberBanding && (currentProgress >= SwipeCommitThreshold || (velocity > VelocityCommitThreshold && currentProgress >= MinSwipeDistanceForVelocityCommit)) && _swipeTarget is not null; _completionEndProgress = commit ? 1.0 : 0.0; var remainingDistance = Math.Abs(_completionEndProgress - currentProgress); var durationSeconds = _isRubberBanding ? RubberBandReturnDuration : velocity > 0 ? Math.Clamp(remainingDistance * size / velocity, MinCompletionDuration, MaxCompletionDuration) : MaxCompletionDuration; Easing easing = _isRubberBanding ? new SineEaseOut() : new QuadraticEaseOut(); _completionCts?.Cancel(); var completionCts = new CancellationTokenSource(); _completionCts = completionCts; SetValue(CompletionProgressProperty, currentProgress); var animation = new Animation.Animation { FillMode = FillMode.Forward, Easing = easing, Duration = TimeSpan.FromSeconds(durationSeconds), Children = { new KeyFrame { Setters = { new Setter { Property = CompletionProgressProperty, Value = currentProgress } }, Cue = new Cue(0d) }, new KeyFrame { Setters = { new Setter { Property = CompletionProgressProperty, Value = _completionEndProgress } }, Cue = new Cue(1d) } } }; _isDragging = false; _ = RunCompletionAnimation(animation, carousel, completionCts); } private async Task RunCompletionAnimation( Animation.Animation animation, Carousel carousel, CancellationTokenSource completionCts) { var cancellationToken = completionCts.Token; try { await animation.RunAsync(this, null, cancellationToken); if (cancellationToken.IsCancellationRequested) return; if (GetTransition() is IProgressPageTransition progressive) { var swipeTarget = ReferenceEquals(_realized, _swipeTarget) ? null : _swipeTarget; var size = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Bounds.Width : Bounds.Height; progressive.Update( _completionEndProgress, _realized, swipeTarget, _isForward, size, Array.Empty()); } var commit = _completionEndProgress > 0.5; if (commit && _swipeTarget is not null) { var targetIndex = _swipeTargetIndex; var targetElement = _swipeTarget; // Clear swipe target state before promoting it to the realized element so // interactive transitions never receive the same control as both from/to. _swipeTarget = null; _swipeTargetIndex = -1; if (_realized != null) RecycleElement(_realized); _realized = targetElement; _realizedIndex = targetIndex; carousel.SelectedIndex = targetIndex; } else { ResetSwipeState(); } _totalDelta = 0; _swipeTarget = null; _swipeTargetIndex = -1; _isRubberBanding = false; carousel.IsSwiping = false; } finally { if (ReferenceEquals(_completionCts, completionCts)) _completionCts = null; } } /// protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); if (change.Property == OffsetAnimationProgressProperty) { if (_offsetAnimationCts is { IsCancellationRequested: false }) { var animProgress = change.GetNewValue(); var primaryOffset = _offsetAnimationStart + ((_offsetAnimationTarget - _offsetAnimationStart) * animProgress); SetOffset(WithPrimaryOffset(_offset, primaryOffset)); if (UsesViewportFractionLayout() && GetTransition() is IProgressPageTransition progressive) { var transitionProgress = GetFractionalTransitionProgress(primaryOffset); progressive.Update( transitionProgress, FindViewportControl(_progressFromIndex), FindViewportControl(_progressToIndex), _isForward, GetViewportItemExtent(Bounds.Size), BuildFractionalVisibleItems(primaryOffset)); } } } else if (change.Property == CompletionProgressProperty) { var isCompletionAnimating = _completionCts is { IsCancellationRequested: false }; if (!_isDragging && _swipeTarget is null && !isCompletionAnimating) return; var progress = change.GetNewValue(); if (GetTransition() is IProgressPageTransition progressive) { var swipeTarget = ReferenceEquals(_realized, _swipeTarget) ? null : _swipeTarget; var size = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Bounds.Width : Bounds.Height; progressive.Update( progress, _realized, swipeTarget, _isForward, size, Array.Empty()); } } } private IReadOnlyList BuildFractionalVisibleItems(double currentOffset) { var items = new PageTransitionItem[_viewportRealized.Count]; var i = 0; foreach (var entry in _viewportRealized.OrderBy(x => x.Key)) { items[i++] = new PageTransitionItem( entry.Value.ItemIndex, entry.Value.Control, entry.Key - currentOffset); } return items; } private void ResetSwipeState() { if (ItemsControl is Carousel carousel) carousel.IsSwiping = false; CancelOffsetAnimation(); ResetViewportTransitionState(); ResetTransitionState(_realized); if (_swipeTarget is not null) RecycleElement(_swipeTarget); _isDragging = false; _totalDelta = 0; _swipeTarget = null; _swipeTargetIndex = -1; _isRubberBanding = false; ClearFractionalProgressContext(); if (UsesViewportFractionLayout() && ItemsControl is Carousel viewportCarousel) SetOffset(WithPrimaryOffset(_offset, GetNearestLogicalOffset(viewportCarousel.SelectedIndex, GetPrimaryOffset(_offset)))); } private void ResetTransitionState(Control? control) { if (control is null) return; if (GetTransition() is IProgressPageTransition progressive) { progressive.Reset(control); } else { ResetVisualState(control); } } private static void ResetVisualState(Control? control) { if (control is null) return; control.RenderTransform = null; control.Opacity = 1; control.ZIndex = 0; control.Clip = null; } } }