// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using SkiaSharp; namespace Microsoft.Maui.Platform; /// /// A horizontally scrolling carousel view with snap-to-item behavior. /// public class SkiaCarouselView : SkiaLayoutView { private readonly List _items = new(); private int _currentPosition = 0; private float _scrollOffset = 0f; private float _targetScrollOffset = 0f; private bool _isDragging = false; private float _dragStartX; private float _dragStartOffset; private float _velocity = 0f; private DateTime _lastDragTime; private float _lastDragX; // Animation private bool _isAnimating = false; private float _animationStartOffset; private float _animationTargetOffset; private DateTime _animationStartTime; private const float AnimationDurationMs = 300f; /// /// Gets or sets the current position (item index). /// public int Position { get => _currentPosition; set { if (value >= 0 && value < _items.Count && value != _currentPosition) { int oldPosition = _currentPosition; _currentPosition = value; AnimateToPosition(value); PositionChanged?.Invoke(this, new PositionChangedEventArgs(oldPosition, value)); } } } /// /// Gets the item count. /// public int ItemCount => _items.Count; /// /// Gets or sets whether looping is enabled. /// public bool Loop { get; set; } = false; /// /// Gets or sets the peek amount (how much of adjacent items to show). /// public float PeekAreaInsets { get; set; } = 0f; /// /// Gets or sets the spacing between items. /// public float ItemSpacing { get; set; } = 0f; /// /// Gets or sets whether swipe gestures are enabled. /// public bool IsSwipeEnabled { get; set; } = true; /// /// Gets or sets the indicator visibility. /// public bool ShowIndicators { get; set; } = true; /// /// Gets or sets the indicator color. /// public SKColor IndicatorColor { get; set; } = new SKColor(180, 180, 180); /// /// Gets or sets the selected indicator color. /// public SKColor SelectedIndicatorColor { get; set; } = new SKColor(33, 150, 243); /// /// Event raised when position changes. /// public event EventHandler? PositionChanged; /// /// Event raised when scrolling. /// public event EventHandler? Scrolled; /// /// Adds an item to the carousel. /// public void AddItem(SkiaView item) { _items.Add(item); AddChild(item); InvalidateMeasure(); Invalidate(); } /// /// Removes an item from the carousel. /// public void RemoveItem(SkiaView item) { if (_items.Remove(item)) { RemoveChild(item); if (_currentPosition >= _items.Count) { _currentPosition = Math.Max(0, _items.Count - 1); } InvalidateMeasure(); Invalidate(); } } /// /// Clears all items. /// public void ClearItems() { foreach (var item in _items) { RemoveChild(item); } _items.Clear(); _currentPosition = 0; _scrollOffset = 0; _targetScrollOffset = 0; InvalidateMeasure(); Invalidate(); } /// /// Scrolls to the specified position. /// public void ScrollTo(int position, bool animate = true) { if (position < 0 || position >= _items.Count) return; int oldPosition = _currentPosition; _currentPosition = position; if (animate) { AnimateToPosition(position); } else { _scrollOffset = GetOffsetForPosition(position); _targetScrollOffset = _scrollOffset; Invalidate(); } if (oldPosition != position) { PositionChanged?.Invoke(this, new PositionChangedEventArgs(oldPosition, position)); } } private void AnimateToPosition(int position) { _animationStartOffset = _scrollOffset; _animationTargetOffset = GetOffsetForPosition(position); _animationStartTime = DateTime.UtcNow; _isAnimating = true; Invalidate(); } private float GetOffsetForPosition(int position) { float itemWidth = Bounds.Width - PeekAreaInsets * 2; return position * (itemWidth + ItemSpacing); } private int GetPositionForOffset(float offset) { float itemWidth = Bounds.Width - PeekAreaInsets * 2; if (itemWidth <= 0) return 0; return Math.Clamp((int)Math.Round(offset / (itemWidth + ItemSpacing)), 0, Math.Max(0, _items.Count - 1)); } protected override SKSize MeasureOverride(SKSize availableSize) { float itemWidth = availableSize.Width - PeekAreaInsets * 2; float itemHeight = availableSize.Height - (ShowIndicators ? 30 : 0); foreach (var item in _items) { item.Measure(new SKSize(itemWidth, itemHeight)); } return availableSize; } protected override SKRect ArrangeOverride(SKRect bounds) { float itemWidth = bounds.Width - PeekAreaInsets * 2; float itemHeight = bounds.Height - (ShowIndicators ? 30 : 0); for (int i = 0; i < _items.Count; i++) { float x = bounds.Left + PeekAreaInsets + i * (itemWidth + ItemSpacing) - _scrollOffset; var itemBounds = new SKRect(x, bounds.Top, x + itemWidth, bounds.Top + itemHeight); _items[i].Arrange(itemBounds); } return bounds; } protected override void OnDraw(SKCanvas canvas, SKRect bounds) { // Update animation if (_isAnimating) { float elapsed = (float)(DateTime.UtcNow - _animationStartTime).TotalMilliseconds; float progress = Math.Clamp(elapsed / AnimationDurationMs, 0f, 1f); // Ease out cubic float t = 1f - (1f - progress) * (1f - progress) * (1f - progress); _scrollOffset = _animationStartOffset + (_animationTargetOffset - _animationStartOffset) * t; if (progress >= 1f) { _isAnimating = false; _scrollOffset = _animationTargetOffset; } else { Invalidate(); // Continue animation } } canvas.Save(); canvas.ClipRect(bounds); // Draw visible items float itemWidth = bounds.Width - PeekAreaInsets * 2; float contentHeight = bounds.Height - (ShowIndicators ? 30 : 0); for (int i = 0; i < _items.Count; i++) { float x = bounds.Left + PeekAreaInsets + i * (itemWidth + ItemSpacing) - _scrollOffset; // Only draw visible items if (x + itemWidth > bounds.Left && x < bounds.Right) { _items[i].Draw(canvas); } } // Draw indicators if (ShowIndicators && _items.Count > 1) { DrawIndicators(canvas, bounds); } canvas.Restore(); } private void DrawIndicators(SKCanvas canvas, SKRect bounds) { float indicatorSize = 8f; float indicatorSpacing = 12f; float totalWidth = _items.Count * indicatorSize + (_items.Count - 1) * (indicatorSpacing - indicatorSize); float startX = bounds.MidX - totalWidth / 2; float y = bounds.Bottom - 15; using var normalPaint = new SKPaint { Color = IndicatorColor, Style = SKPaintStyle.Fill, IsAntialias = true }; using var selectedPaint = new SKPaint { Color = SelectedIndicatorColor, Style = SKPaintStyle.Fill, IsAntialias = true }; for (int i = 0; i < _items.Count; i++) { float x = startX + i * indicatorSpacing; var paint = i == _currentPosition ? selectedPaint : normalPaint; canvas.DrawCircle(x, y, indicatorSize / 2, paint); } } public override SkiaView? HitTest(float x, float y) { if (!IsVisible || !Bounds.Contains(x, y)) return null; // Check items foreach (var item in _items) { var hit = item.HitTest(x, y); if (hit != null) return hit; } return this; } public override void OnPointerPressed(PointerEventArgs e) { if (!IsEnabled || !IsSwipeEnabled) return; _isDragging = true; _dragStartX = e.X; _dragStartOffset = _scrollOffset; _lastDragX = e.X; _lastDragTime = DateTime.UtcNow; _velocity = 0; _isAnimating = false; e.Handled = true; base.OnPointerPressed(e); } public override void OnPointerMoved(PointerEventArgs e) { if (!_isDragging) return; float delta = _dragStartX - e.X; _scrollOffset = _dragStartOffset + delta; // Clamp scrolling float maxOffset = GetOffsetForPosition(_items.Count - 1); _scrollOffset = Math.Clamp(_scrollOffset, 0, maxOffset); // Calculate velocity var now = DateTime.UtcNow; float timeDelta = (float)(now - _lastDragTime).TotalSeconds; if (timeDelta > 0) { _velocity = (_lastDragX - e.X) / timeDelta; } _lastDragX = e.X; _lastDragTime = now; Scrolled?.Invoke(this, EventArgs.Empty); Invalidate(); e.Handled = true; base.OnPointerMoved(e); } public override void OnPointerReleased(PointerEventArgs e) { if (!_isDragging) return; _isDragging = false; // Determine target position based on velocity and position float itemWidth = Bounds.Width - PeekAreaInsets * 2; int targetPosition = GetPositionForOffset(_scrollOffset); // Apply velocity influence if (Math.Abs(_velocity) > 500) { if (_velocity > 0 && targetPosition < _items.Count - 1) { targetPosition++; } else if (_velocity < 0 && targetPosition > 0) { targetPosition--; } } ScrollTo(targetPosition, true); e.Handled = true; base.OnPointerReleased(e); } } /// /// Event args for position changed events. /// public class PositionChangedEventArgs : EventArgs { public int PreviousPosition { get; } public int CurrentPosition { get; } public PositionChangedEventArgs(int previousPosition, int currentPosition) { PreviousPosition = previousPosition; CurrentPosition = currentPosition; } }