// 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; /// /// Skia-rendered scroll view container with full XAML styling support. /// public class SkiaScrollView : SkiaView { #region BindableProperties /// /// Bindable property for Orientation. /// public static readonly BindableProperty OrientationProperty = BindableProperty.Create( nameof(Orientation), typeof(ScrollOrientation), typeof(SkiaScrollView), ScrollOrientation.Both, propertyChanged: (b, o, n) => ((SkiaScrollView)b).InvalidateMeasure()); /// /// Bindable property for HorizontalScrollBarVisibility. /// public static readonly BindableProperty HorizontalScrollBarVisibilityProperty = BindableProperty.Create( nameof(HorizontalScrollBarVisibility), typeof(ScrollBarVisibility), typeof(SkiaScrollView), ScrollBarVisibility.Auto, propertyChanged: (b, o, n) => ((SkiaScrollView)b).Invalidate()); /// /// Bindable property for VerticalScrollBarVisibility. /// public static readonly BindableProperty VerticalScrollBarVisibilityProperty = BindableProperty.Create( nameof(VerticalScrollBarVisibility), typeof(ScrollBarVisibility), typeof(SkiaScrollView), ScrollBarVisibility.Auto, propertyChanged: (b, o, n) => ((SkiaScrollView)b).Invalidate()); /// /// Bindable property for ScrollBarColor. /// public static readonly BindableProperty ScrollBarColorProperty = BindableProperty.Create( nameof(ScrollBarColor), typeof(SKColor), typeof(SkiaScrollView), new SKColor(0x80, 0x80, 0x80, 0x80), propertyChanged: (b, o, n) => ((SkiaScrollView)b).Invalidate()); /// /// Bindable property for ScrollBarWidth. /// public static readonly BindableProperty ScrollBarWidthProperty = BindableProperty.Create( nameof(ScrollBarWidth), typeof(float), typeof(SkiaScrollView), 8f, propertyChanged: (b, o, n) => ((SkiaScrollView)b).Invalidate()); #endregion #region Properties /// /// Gets or sets the scroll orientation. /// public ScrollOrientation Orientation { get => (ScrollOrientation)GetValue(OrientationProperty); set => SetValue(OrientationProperty, value); } /// /// Gets or sets whether to show horizontal scrollbar. /// public ScrollBarVisibility HorizontalScrollBarVisibility { get => (ScrollBarVisibility)GetValue(HorizontalScrollBarVisibilityProperty); set => SetValue(HorizontalScrollBarVisibilityProperty, value); } /// /// Gets or sets whether to show vertical scrollbar. /// public ScrollBarVisibility VerticalScrollBarVisibility { get => (ScrollBarVisibility)GetValue(VerticalScrollBarVisibilityProperty); set => SetValue(VerticalScrollBarVisibilityProperty, value); } /// /// Scrollbar color. /// public SKColor ScrollBarColor { get => (SKColor)GetValue(ScrollBarColorProperty); set => SetValue(ScrollBarColorProperty, value); } /// /// Scrollbar width. /// public float ScrollBarWidth { get => (float)GetValue(ScrollBarWidthProperty); set => SetValue(ScrollBarWidthProperty, value); } #endregion private SkiaView? _content; private float _scrollX; private float _scrollY; private float _velocityX; private float _velocityY; private bool _isDragging; private bool _isDraggingVerticalScrollbar; private bool _isDraggingHorizontalScrollbar; private float _scrollbarDragStartY; private float _scrollbarDragStartScrollY; private float _scrollbarDragStartX; private float _scrollbarDragStartScrollX; private float _scrollbarDragAvailableTrack; // Cache to prevent stutter private float _scrollbarDragScrollableExtent; // Cache to prevent stutter private float _lastPointerX; private float _lastPointerY; /// /// Gets or sets the content view. /// public SkiaView? Content { get => _content; set { if (_content != value) { if (_content != null) _content.Parent = null; _content = value; if (_content != null) { _content.Parent = this; // Propagate binding context to new content if (BindingContext != null) { SetInheritedBindingContext(_content, BindingContext); } } InvalidateMeasure(); Invalidate(); } } } /// /// Called when binding context changes. Propagates to content. /// protected override void OnBindingContextChanged() { base.OnBindingContextChanged(); // Propagate binding context to content if (_content != null) { SetInheritedBindingContext(_content, BindingContext); } } /// /// Gets or sets the horizontal scroll position. /// public float ScrollX { get => _scrollX; set { var clamped = ClampScrollX(value); if (_scrollX != clamped) { _scrollX = clamped; Scrolled?.Invoke(this, new ScrolledEventArgs(_scrollX, _scrollY)); Invalidate(); } } } /// /// Gets or sets the vertical scroll position. /// public float ScrollY { get => _scrollY; set { var clamped = ClampScrollY(value); if (_scrollY != clamped) { _scrollY = clamped; Scrolled?.Invoke(this, new ScrolledEventArgs(_scrollX, _scrollY)); Invalidate(); } } } /// /// Gets the maximum horizontal scroll extent. /// public float ScrollableWidth { get { // Handle infinite or NaN bounds - use a reasonable default viewport var viewportWidth = float.IsInfinity(Bounds.Width) || float.IsNaN(Bounds.Width) || Bounds.Width <= 0 ? 800f : Bounds.Width; return Math.Max(0, ContentSize.Width - viewportWidth); } } /// /// Gets the maximum vertical scroll extent. /// public float ScrollableHeight { get { // Handle infinite, NaN, or unreasonably large bounds - use a reasonable default viewport var boundsHeight = Bounds.Height; var viewportHeight = (float.IsInfinity(boundsHeight) || float.IsNaN(boundsHeight) || boundsHeight <= 0 || boundsHeight > 10000) ? 544f // Default viewport height (600 - 56 for shell header) : boundsHeight; return Math.Max(0, ContentSize.Height - viewportHeight); } } /// /// Gets the content size. /// public SKSize ContentSize { get; private set; } /// /// Event raised when scroll position changes. /// public event EventHandler? Scrolled; protected override void OnDraw(SKCanvas canvas, SKRect bounds) { // Clip to bounds canvas.Save(); canvas.ClipRect(bounds); // Draw content with scroll offset if (_content != null) { // Ensure content is measured and arranged var availableSize = new SKSize(bounds.Width, float.PositiveInfinity); _content.Measure(availableSize); // Apply content's margin var margin = _content.Margin; var contentBounds = new SKRect( bounds.Left + (float)margin.Left, bounds.Top + (float)margin.Top, bounds.Left + Math.Max(bounds.Width, _content.DesiredSize.Width) - (float)margin.Right, bounds.Top + Math.Max(bounds.Height, _content.DesiredSize.Height) - (float)margin.Bottom); _content.Arrange(contentBounds); canvas.Save(); canvas.Translate(-_scrollX, -_scrollY); _content.Draw(canvas); canvas.Restore(); } // Draw scrollbars DrawScrollbars(canvas, bounds); canvas.Restore(); } private void DrawScrollbars(SKCanvas canvas, SKRect bounds) { var showVertical = ShouldShowVerticalScrollbar(); var showHorizontal = ShouldShowHorizontalScrollbar(); if (showVertical && ScrollableHeight > 0) { DrawVerticalScrollbar(canvas, bounds, showHorizontal); } if (showHorizontal && ScrollableWidth > 0) { DrawHorizontalScrollbar(canvas, bounds, showVertical); } } private bool ShouldShowVerticalScrollbar() { if (Orientation == ScrollOrientation.Horizontal) return false; return VerticalScrollBarVisibility switch { ScrollBarVisibility.Always => true, ScrollBarVisibility.Never => false, _ => ScrollableHeight > 0 }; } private bool ShouldShowHorizontalScrollbar() { if (Orientation == ScrollOrientation.Vertical) return false; return HorizontalScrollBarVisibility switch { ScrollBarVisibility.Always => true, ScrollBarVisibility.Never => false, _ => ScrollableWidth > 0 }; } private void DrawVerticalScrollbar(SKCanvas canvas, SKRect bounds, bool hasHorizontal) { var trackHeight = bounds.Height - (hasHorizontal ? ScrollBarWidth : 0); var thumbHeight = Math.Max(20, (bounds.Height / ContentSize.Height) * trackHeight); var thumbY = (ScrollY / ScrollableHeight) * (trackHeight - thumbHeight); using var paint = new SKPaint { Color = ScrollBarColor, IsAntialias = true }; var thumbRect = new SKRoundRect( new SKRect( bounds.Right - ScrollBarWidth, bounds.Top + thumbY, bounds.Right, bounds.Top + thumbY + thumbHeight), ScrollBarWidth / 2); canvas.DrawRoundRect(thumbRect, paint); } private void DrawHorizontalScrollbar(SKCanvas canvas, SKRect bounds, bool hasVertical) { var trackWidth = bounds.Width - (hasVertical ? ScrollBarWidth : 0); var thumbWidth = Math.Max(20, (bounds.Width / ContentSize.Width) * trackWidth); var thumbX = (ScrollX / ScrollableWidth) * (trackWidth - thumbWidth); using var paint = new SKPaint { Color = ScrollBarColor, IsAntialias = true }; var thumbRect = new SKRoundRect( new SKRect( bounds.Left + thumbX, bounds.Bottom - ScrollBarWidth, bounds.Left + thumbX + thumbWidth, bounds.Bottom), ScrollBarWidth / 2); canvas.DrawRoundRect(thumbRect, paint); } public override void OnScroll(ScrollEventArgs e) { Console.WriteLine($"[SkiaScrollView] OnScroll - DeltaY={e.DeltaY}, ScrollableHeight={ScrollableHeight}, ContentSize={ContentSize}, Bounds={Bounds}"); // Handle mouse wheel scrolling var deltaMultiplier = 40f; // Scroll speed bool scrolled = false; if (Orientation != ScrollOrientation.Horizontal && ScrollableHeight > 0) { var oldScrollY = _scrollY; ScrollY += e.DeltaY * deltaMultiplier; Console.WriteLine($"[SkiaScrollView] ScrollY changed: {oldScrollY} -> {_scrollY}"); if (_scrollY != oldScrollY) scrolled = true; } if (Orientation != ScrollOrientation.Vertical && ScrollableWidth > 0) { var oldScrollX = _scrollX; ScrollX += e.DeltaX * deltaMultiplier; if (_scrollX != oldScrollX) scrolled = true; } // Mark as handled so parent scroll views don't also scroll if (scrolled) e.Handled = true; } public override void OnPointerPressed(PointerEventArgs e) { // Check if clicking on vertical scrollbar thumb if (ShouldShowVerticalScrollbar() && ScrollableHeight > 0) { var thumbBounds = GetVerticalScrollbarThumbBounds(); if (thumbBounds.Contains(e.X, e.Y)) { _isDraggingVerticalScrollbar = true; _scrollbarDragStartY = e.Y; _scrollbarDragStartScrollY = _scrollY; // Cache values to prevent stutter from floating-point recalculations var hasHorizontal = ShouldShowHorizontalScrollbar(); var trackHeight = Bounds.Height - (hasHorizontal ? ScrollBarWidth : 0); var thumbHeight = Math.Max(20, (Bounds.Height / ContentSize.Height) * trackHeight); _scrollbarDragAvailableTrack = trackHeight - thumbHeight; _scrollbarDragScrollableExtent = ScrollableHeight; return; } } // Check if clicking on horizontal scrollbar thumb if (ShouldShowHorizontalScrollbar() && ScrollableWidth > 0) { var thumbBounds = GetHorizontalScrollbarThumbBounds(); if (thumbBounds.Contains(e.X, e.Y)) { _isDraggingHorizontalScrollbar = true; _scrollbarDragStartX = e.X; _scrollbarDragStartScrollX = _scrollX; // Cache values to prevent stutter from floating-point recalculations var hasVertical = ShouldShowVerticalScrollbar(); var trackWidth = Bounds.Width - (hasVertical ? ScrollBarWidth : 0); var thumbWidth = Math.Max(20, (Bounds.Width / ContentSize.Width) * trackWidth); _scrollbarDragAvailableTrack = trackWidth - thumbWidth; _scrollbarDragScrollableExtent = ScrollableWidth; return; } } // Forward click to content first if (_content != null) { // Translate coordinates for scroll offset var contentE = new PointerEventArgs(e.X + _scrollX, e.Y + _scrollY, e.Button); var hit = _content.HitTest(contentE.X, contentE.Y); if (hit != null && hit != _content) { // A child view was hit - forward the event to it hit.OnPointerPressed(contentE); return; } } // Regular content dragging _isDragging = true; _lastPointerX = e.X; _lastPointerY = e.Y; _velocityX = 0; _velocityY = 0; } public override void OnPointerMoved(PointerEventArgs e) { // Handle vertical scrollbar dragging - use cached values to prevent stutter if (_isDraggingVerticalScrollbar) { if (_scrollbarDragAvailableTrack > 0) { var deltaY = e.Y - _scrollbarDragStartY; var scrollDelta = (deltaY / _scrollbarDragAvailableTrack) * _scrollbarDragScrollableExtent; ScrollY = _scrollbarDragStartScrollY + scrollDelta; } return; } // Handle horizontal scrollbar dragging - use cached values to prevent stutter if (_isDraggingHorizontalScrollbar) { if (_scrollbarDragAvailableTrack > 0) { var deltaX = e.X - _scrollbarDragStartX; var scrollDelta = (deltaX / _scrollbarDragAvailableTrack) * _scrollbarDragScrollableExtent; ScrollX = _scrollbarDragStartScrollX + scrollDelta; } return; } // Handle content dragging if (!_isDragging) return; var contentDeltaX = _lastPointerX - e.X; var contentDeltaY = _lastPointerY - e.Y; _velocityX = contentDeltaX; _velocityY = contentDeltaY; if (Orientation != ScrollOrientation.Horizontal) ScrollY += contentDeltaY; if (Orientation != ScrollOrientation.Vertical) ScrollX += contentDeltaX; _lastPointerX = e.X; _lastPointerY = e.Y; } public override void OnPointerReleased(PointerEventArgs e) { _isDragging = false; _isDraggingVerticalScrollbar = false; _isDraggingHorizontalScrollbar = false; // Momentum scrolling could be added here } private SKRect GetVerticalScrollbarThumbBounds() { var hasHorizontal = ShouldShowHorizontalScrollbar(); var trackHeight = Bounds.Height - (hasHorizontal ? ScrollBarWidth : 0); var thumbHeight = Math.Max(20, (Bounds.Height / ContentSize.Height) * trackHeight); var thumbY = ScrollableHeight > 0 ? (ScrollY / ScrollableHeight) * (trackHeight - thumbHeight) : 0; return new SKRect( Bounds.Right - ScrollBarWidth, Bounds.Top + thumbY, Bounds.Right, Bounds.Top + thumbY + thumbHeight); } private SKRect GetHorizontalScrollbarThumbBounds() { var hasVertical = ShouldShowVerticalScrollbar(); var trackWidth = Bounds.Width - (hasVertical ? ScrollBarWidth : 0); var thumbWidth = Math.Max(20, (Bounds.Width / ContentSize.Width) * trackWidth); var thumbX = ScrollableWidth > 0 ? (ScrollX / ScrollableWidth) * (trackWidth - thumbWidth) : 0; return new SKRect( Bounds.Left + thumbX, Bounds.Bottom - ScrollBarWidth, Bounds.Left + thumbX + thumbWidth, Bounds.Bottom); } public override SkiaView? HitTest(float x, float y) { if (!IsVisible || !IsEnabled || !Bounds.Contains(new SKPoint(x, y))) return null; // Check scrollbar areas FIRST before content // This ensures scrollbar clicks are handled by the ScrollView, not content underneath if (ShouldShowVerticalScrollbar() && ScrollableHeight > 0) { var thumbBounds = GetVerticalScrollbarThumbBounds(); // Check if click is in the scrollbar track area (not just thumb) var trackArea = new SKRect(Bounds.Right - ScrollBarWidth, Bounds.Top, Bounds.Right, Bounds.Bottom); if (trackArea.Contains(x, y)) return this; } if (ShouldShowHorizontalScrollbar() && ScrollableWidth > 0) { var trackArea = new SKRect(Bounds.Left, Bounds.Bottom - ScrollBarWidth, Bounds.Right, Bounds.Bottom); if (trackArea.Contains(x, y)) return this; } // Hit test content with scroll offset if (_content != null) { var hit = _content.HitTest(x + _scrollX, y + _scrollY); if (hit != null) return hit; } return this; } /// /// Scrolls to the specified position. /// public void ScrollTo(float x, float y, bool animated = false) { // TODO: Implement animation ScrollX = x; ScrollY = y; } /// /// Scrolls to make the specified view visible. /// public void ScrollToView(SkiaView view, bool animated = false) { if (_content == null) return; var viewBounds = view.Bounds; // Check if view is fully visible var visibleRect = new SKRect( ScrollX, ScrollY, ScrollX + Bounds.Width, ScrollY + Bounds.Height); if (visibleRect.Contains(viewBounds)) return; // Calculate scroll position to bring view into view float targetX = ScrollX; float targetY = ScrollY; if (viewBounds.Left < visibleRect.Left) targetX = viewBounds.Left; else if (viewBounds.Right > visibleRect.Right) targetX = viewBounds.Right - Bounds.Width; if (viewBounds.Top < visibleRect.Top) targetY = viewBounds.Top; else if (viewBounds.Bottom > visibleRect.Bottom) targetY = viewBounds.Bottom - Bounds.Height; ScrollTo(targetX, targetY, animated); } private float ClampScrollX(float value) { if (Orientation == ScrollOrientation.Vertical) return 0; return Math.Clamp(value, 0, ScrollableWidth); } private float ClampScrollY(float value) { if (Orientation == ScrollOrientation.Horizontal) return 0; return Math.Clamp(value, 0, ScrollableHeight); } protected override SKSize MeasureOverride(SKSize availableSize) { if (_content != null) { // For responsive layout: // - Vertical: give content viewport width, infinite height // - Horizontal: give content infinite width, viewport height // - Both: give content viewport width first (for responsive layout), // but if content exceeds it, horizontal scrollbar appears // - Neither: give content exact viewport size float contentWidth, contentHeight; switch (Orientation) { case ScrollOrientation.Horizontal: contentWidth = float.PositiveInfinity; contentHeight = float.IsInfinity(availableSize.Height) ? 400f : availableSize.Height; break; case ScrollOrientation.Neither: contentWidth = float.IsInfinity(availableSize.Width) ? 400f : availableSize.Width; contentHeight = float.IsInfinity(availableSize.Height) ? 400f : availableSize.Height; break; case ScrollOrientation.Both: // For Both: first measure with viewport width to get responsive layout // Content can still exceed viewport if it has minimum width constraints contentWidth = float.IsInfinity(availableSize.Width) ? 800f : availableSize.Width; contentHeight = float.PositiveInfinity; break; case ScrollOrientation.Vertical: default: contentWidth = float.IsInfinity(availableSize.Width) ? 800f : availableSize.Width; contentHeight = float.PositiveInfinity; break; } ContentSize = _content.Measure(new SKSize(contentWidth, contentHeight)); } else { ContentSize = SKSize.Empty; } // Return available size, but clamp infinite dimensions // IMPORTANT: When available is infinite, return a reasonable viewport size, NOT content size // A ScrollView should NOT expand to fit its content - it should stay at a fixed viewport // and scroll the content. Use a default viewport size when parent gives infinity. const float DefaultViewportWidth = 400f; const float DefaultViewportHeight = 400f; var width = float.IsInfinity(availableSize.Width) || float.IsNaN(availableSize.Width) ? Math.Min(ContentSize.Width, DefaultViewportWidth) : availableSize.Width; var height = float.IsInfinity(availableSize.Height) || float.IsNaN(availableSize.Height) ? Math.Min(ContentSize.Height, DefaultViewportHeight) : availableSize.Height; return new SKSize(width, height); } protected override SKRect ArrangeOverride(SKRect bounds) { // CRITICAL: If bounds has infinite height, use a fixed viewport size // NOT ContentSize.Height - that would make ScrollableHeight = 0 const float DefaultViewportHeight = 544f; // 600 - 56 for shell header var actualBounds = bounds; if (float.IsInfinity(bounds.Height) || float.IsNaN(bounds.Height)) { Console.WriteLine($"[SkiaScrollView] WARNING: Infinite/NaN height, using default viewport={DefaultViewportHeight}"); actualBounds = new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Top + DefaultViewportHeight); } if (_content != null) { // Apply content's margin and arrange content at its full size var margin = _content.Margin; var contentBounds = new SKRect( actualBounds.Left + (float)margin.Left, actualBounds.Top + (float)margin.Top, actualBounds.Left + Math.Max(actualBounds.Width, ContentSize.Width) - (float)margin.Right, actualBounds.Top + Math.Max(actualBounds.Height, ContentSize.Height) - (float)margin.Bottom); _content.Arrange(contentBounds); } return actualBounds; } } /// /// Scroll orientation options. /// public enum ScrollOrientation { Vertical, Horizontal, Both, Neither } /// /// Scrollbar visibility options. /// public enum ScrollBarVisibility { Default, Always, Never, Auto } /// /// Event args for scroll events. /// public class ScrolledEventArgs : EventArgs { public float ScrollX { get; } public float ScrollY { get; } public ScrolledEventArgs(float scrollX, float scrollY) { ScrollX = scrollX; ScrollY = scrollY; } }