// 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.
///
public class SkiaScrollView : SkiaView
{
private SkiaView? _content;
private float _scrollX;
private float _scrollY;
private float _velocityX;
private float _velocityY;
private bool _isDragging;
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;
InvalidateMeasure();
Invalidate();
}
}
}
///
/// 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 => Math.Max(0, ContentSize.Width - Bounds.Width);
///
/// Gets the maximum vertical scroll extent.
///
public float ScrollableHeight => Math.Max(0, ContentSize.Height - Bounds.Height);
///
/// Gets the content size.
///
public SKSize ContentSize { get; private set; }
///
/// Gets or sets the scroll orientation.
///
public ScrollOrientation Orientation { get; set; } = ScrollOrientation.Both;
///
/// Gets or sets whether to show horizontal scrollbar.
///
public ScrollBarVisibility HorizontalScrollBarVisibility { get; set; } = ScrollBarVisibility.Auto;
///
/// Gets or sets whether to show vertical scrollbar.
///
public ScrollBarVisibility VerticalScrollBarVisibility { get; set; } = ScrollBarVisibility.Auto;
///
/// Scrollbar color.
///
public SKColor ScrollBarColor { get; set; } = new SKColor(0x80, 0x80, 0x80, 0x80);
///
/// Scrollbar width.
///
public float ScrollBarWidth { get; set; } = 8;
///
/// 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)
{
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)
{
// Handle mouse wheel scrolling
var deltaMultiplier = 40f; // Scroll speed
if (Orientation != ScrollOrientation.Horizontal)
{
ScrollY += e.DeltaY * deltaMultiplier;
}
if (Orientation != ScrollOrientation.Vertical)
{
ScrollX += e.DeltaX * deltaMultiplier;
}
}
public override void OnPointerPressed(PointerEventArgs e)
{
_isDragging = true;
_lastPointerX = e.X;
_lastPointerY = e.Y;
_velocityX = 0;
_velocityY = 0;
}
public override void OnPointerMoved(PointerEventArgs e)
{
if (!_isDragging) return;
var deltaX = _lastPointerX - e.X;
var deltaY = _lastPointerY - e.Y;
_velocityX = deltaX;
_velocityY = deltaY;
if (Orientation != ScrollOrientation.Horizontal)
ScrollY += deltaY;
if (Orientation != ScrollOrientation.Vertical)
ScrollX += deltaX;
_lastPointerX = e.X;
_lastPointerY = e.Y;
}
public override void OnPointerReleased(PointerEventArgs e)
{
_isDragging = false;
// Momentum scrolling could be added here
}
public override SkiaView? HitTest(float x, float y)
{
if (!IsVisible || !Bounds.Contains(new SKPoint(x, y)))
return null;
// 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)
{
// Give content unlimited size in scrollable directions
var contentAvailable = new SKSize(
Orientation == ScrollOrientation.Vertical ? availableSize.Width : float.PositiveInfinity,
Orientation == ScrollOrientation.Horizontal ? availableSize.Height : float.PositiveInfinity);
ContentSize = _content.Measure(contentAvailable);
}
else
{
ContentSize = SKSize.Empty;
}
return availableSize;
}
protected override SKRect ArrangeOverride(SKRect bounds)
{
if (_content != null)
{
// Arrange content at its full size, starting from scroll position
var contentBounds = new SKRect(
bounds.Left,
bounds.Top,
bounds.Left + Math.Max(bounds.Width, ContentSize.Width),
bounds.Top + Math.Max(bounds.Height, ContentSize.Height));
_content.Arrange(contentBounds);
}
return bounds;
}
}
///
/// 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;
}
}