// 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 pull-to-refresh container view. /// public class SkiaRefreshView : SkiaLayoutView { private SkiaView? _content; private bool _isRefreshing = false; private float _pullDistance = 0f; private float _refreshThreshold = 80f; private bool _isPulling = false; private float _pullStartY; private float _spinnerRotation = 0f; private DateTime _lastSpinnerUpdate; /// /// Gets or sets the content view. /// public SkiaView? Content { get => _content; set { if (_content != value) { if (_content != null) { RemoveChild(_content); } _content = value; if (_content != null) { AddChild(_content); } InvalidateMeasure(); Invalidate(); } } } /// /// Gets or sets whether the view is currently refreshing. /// public bool IsRefreshing { get => _isRefreshing; set { if (_isRefreshing != value) { _isRefreshing = value; if (!value) { _pullDistance = 0; } Invalidate(); } } } /// /// Gets or sets the pull distance required to trigger refresh. /// public float RefreshThreshold { get => _refreshThreshold; set => _refreshThreshold = Math.Max(40, value); } /// /// Gets or sets the refresh indicator color. /// public SKColor RefreshColor { get; set; } = new SKColor(33, 150, 243); /// /// Gets or sets the background color of the refresh indicator. /// public SKColor RefreshBackgroundColor { get; set; } = SKColors.White; /// /// Event raised when refresh is triggered. /// public event EventHandler? Refreshing; protected override SKSize MeasureOverride(SKSize availableSize) { if (_content != null) { _content.Measure(availableSize); } return availableSize; } protected override SKRect ArrangeOverride(SKRect bounds) { if (_content != null) { float offset = _isRefreshing ? _refreshThreshold : _pullDistance; var contentBounds = new SKRect( bounds.Left, bounds.Top + offset, bounds.Right, bounds.Bottom + offset); _content.Arrange(contentBounds); } return bounds; } protected override void OnDraw(SKCanvas canvas, SKRect bounds) { canvas.Save(); canvas.ClipRect(bounds); // Draw refresh indicator float indicatorY = bounds.Top + (_isRefreshing ? _refreshThreshold : _pullDistance) / 2; if (_pullDistance > 0 || _isRefreshing) { DrawRefreshIndicator(canvas, bounds.MidX, indicatorY); } // Draw content _content?.Draw(canvas); canvas.Restore(); } private void DrawRefreshIndicator(SKCanvas canvas, float x, float y) { float size = 36f; float progress = Math.Clamp(_pullDistance / _refreshThreshold, 0f, 1f); // Draw background circle using var bgPaint = new SKPaint { Color = RefreshBackgroundColor, Style = SKPaintStyle.Fill, IsAntialias = true }; // Add shadow bgPaint.ImageFilter = SKImageFilter.CreateDropShadow(0, 2, 4, 4, new SKColor(0, 0, 0, 40)); canvas.DrawCircle(x, y, size / 2, bgPaint); // Draw spinner using var spinnerPaint = new SKPaint { Color = RefreshColor, Style = SKPaintStyle.Stroke, StrokeWidth = 3, IsAntialias = true, StrokeCap = SKStrokeCap.Round }; if (_isRefreshing) { // Animate spinner var now = DateTime.UtcNow; float elapsed = (float)(now - _lastSpinnerUpdate).TotalMilliseconds; _spinnerRotation += elapsed * 0.36f; // 360 degrees per second _lastSpinnerUpdate = now; canvas.Save(); canvas.Translate(x, y); canvas.RotateDegrees(_spinnerRotation); // Draw spinning arc using var path = new SKPath(); var rect = new SKRect(-size / 3, -size / 3, size / 3, size / 3); path.AddArc(rect, 0, 270); canvas.DrawPath(path, spinnerPaint); canvas.Restore(); Invalidate(); // Continue animation } else { // Draw progress arc canvas.Save(); canvas.Translate(x, y); using var path = new SKPath(); var rect = new SKRect(-size / 3, -size / 3, size / 3, size / 3); float sweepAngle = 270 * progress; path.AddArc(rect, -90, sweepAngle); canvas.DrawPath(path, spinnerPaint); canvas.Restore(); } } public override SkiaView? HitTest(float x, float y) { if (!IsVisible || !Bounds.Contains(x, y)) return null; if (_content != null) { var hit = _content.HitTest(x, y); if (hit != null) return hit; } return this; } public override void OnPointerPressed(PointerEventArgs e) { if (!IsEnabled || _isRefreshing) return; // Check if content is at top (can pull to refresh) bool canPull = true; if (_content is SkiaScrollView scrollView) { canPull = scrollView.ScrollY <= 0; } if (canPull) { _isPulling = true; _pullStartY = e.Y; _pullDistance = 0; } base.OnPointerPressed(e); } public override void OnPointerMoved(PointerEventArgs e) { if (!_isPulling) return; float delta = e.Y - _pullStartY; if (delta > 0) { // Apply resistance _pullDistance = delta * 0.5f; _pullDistance = Math.Min(_pullDistance, _refreshThreshold * 1.5f); Invalidate(); e.Handled = true; } else { _pullDistance = 0; } base.OnPointerMoved(e); } public override void OnPointerReleased(PointerEventArgs e) { if (!_isPulling) return; _isPulling = false; if (_pullDistance >= _refreshThreshold) { _isRefreshing = true; _pullDistance = _refreshThreshold; _lastSpinnerUpdate = DateTime.UtcNow; Refreshing?.Invoke(this, EventArgs.Empty); } else { _pullDistance = 0; } Invalidate(); base.OnPointerReleased(e); } }