Preview 3: Complete control implementation with XAML data binding
Major milestone adding full control functionality: Controls Enhanced: - Entry/Editor: Full keyboard input, cursor navigation, selection, clipboard - CollectionView: Data binding, selection highlighting, scrolling - CheckBox/Switch/Slider: Interactive state management - Picker/DatePicker/TimePicker: Dropdown selection with popup overlays - ProgressBar/ActivityIndicator: Animated progress display - Button: Press/release visual states - Border/Frame: Rounded corners, stroke styling - Label: Text wrapping, alignment, decorations - Grid/StackLayout: Margin and padding support Features Added: - DisplayAlert dialogs with button actions - NavigationPage with toolbar and back navigation - Shell with flyout menu navigation - XAML value converters for data binding - Margin support in all layout containers - Popup overlay system for pickers New Samples: - TodoApp: Full CRUD task manager with NavigationPage - ShellDemo: Comprehensive control showcase Removed: - ControlGallery (replaced by ShellDemo) - LinuxDemo (replaced by TodoApp) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,38 +6,169 @@ using SkiaSharp;
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered activity indicator (spinner) control.
|
||||
/// Skia-rendered activity indicator (spinner) control with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaActivityIndicator : SkiaView
|
||||
{
|
||||
private bool _isRunning;
|
||||
#region BindableProperties
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsRunning.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsRunningProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsRunning),
|
||||
typeof(bool),
|
||||
typeof(SkiaActivityIndicator),
|
||||
false,
|
||||
propertyChanged: (b, o, n) => ((SkiaActivityIndicator)b).OnIsRunningChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Color.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Color),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaActivityIndicator),
|
||||
new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaActivityIndicator)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for DisabledColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty DisabledColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(DisabledColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaActivityIndicator),
|
||||
new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaActivityIndicator)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Size.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty SizeProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Size),
|
||||
typeof(float),
|
||||
typeof(SkiaActivityIndicator),
|
||||
32f,
|
||||
propertyChanged: (b, o, n) => ((SkiaActivityIndicator)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for StrokeWidth.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty StrokeWidthProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(StrokeWidth),
|
||||
typeof(float),
|
||||
typeof(SkiaActivityIndicator),
|
||||
3f,
|
||||
propertyChanged: (b, o, n) => ((SkiaActivityIndicator)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for RotationSpeed.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty RotationSpeedProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(RotationSpeed),
|
||||
typeof(float),
|
||||
typeof(SkiaActivityIndicator),
|
||||
360f);
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ArcCount.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ArcCountProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ArcCount),
|
||||
typeof(int),
|
||||
typeof(SkiaActivityIndicator),
|
||||
12,
|
||||
propertyChanged: (b, o, n) => ((SkiaActivityIndicator)b).Invalidate());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the indicator is running.
|
||||
/// </summary>
|
||||
public bool IsRunning
|
||||
{
|
||||
get => (bool)GetValue(IsRunningProperty);
|
||||
set => SetValue(IsRunningProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the indicator color.
|
||||
/// </summary>
|
||||
public SKColor Color
|
||||
{
|
||||
get => (SKColor)GetValue(ColorProperty);
|
||||
set => SetValue(ColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the disabled color.
|
||||
/// </summary>
|
||||
public SKColor DisabledColor
|
||||
{
|
||||
get => (SKColor)GetValue(DisabledColorProperty);
|
||||
set => SetValue(DisabledColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the indicator size.
|
||||
/// </summary>
|
||||
public float Size
|
||||
{
|
||||
get => (float)GetValue(SizeProperty);
|
||||
set => SetValue(SizeProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the stroke width.
|
||||
/// </summary>
|
||||
public float StrokeWidth
|
||||
{
|
||||
get => (float)GetValue(StrokeWidthProperty);
|
||||
set => SetValue(StrokeWidthProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the rotation speed in degrees per second.
|
||||
/// </summary>
|
||||
public float RotationSpeed
|
||||
{
|
||||
get => (float)GetValue(RotationSpeedProperty);
|
||||
set => SetValue(RotationSpeedProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of arcs.
|
||||
/// </summary>
|
||||
public int ArcCount
|
||||
{
|
||||
get => (int)GetValue(ArcCountProperty);
|
||||
set => SetValue(ArcCountProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private float _rotationAngle;
|
||||
private DateTime _lastUpdateTime = DateTime.UtcNow;
|
||||
|
||||
public bool IsRunning
|
||||
private void OnIsRunningChanged()
|
||||
{
|
||||
get => _isRunning;
|
||||
set
|
||||
if (IsRunning)
|
||||
{
|
||||
if (_isRunning != value)
|
||||
{
|
||||
_isRunning = value;
|
||||
if (value)
|
||||
{
|
||||
_lastUpdateTime = DateTime.UtcNow;
|
||||
}
|
||||
Invalidate();
|
||||
}
|
||||
_lastUpdateTime = DateTime.UtcNow;
|
||||
}
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public SKColor Color { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public SKColor DisabledColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public float Size { get; set; } = 32;
|
||||
public float StrokeWidth { get; set; } = 3;
|
||||
public float RotationSpeed { get; set; } = 360; // Degrees per second
|
||||
public int ArcCount { get; set; } = 12;
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
if (!IsRunning && !IsEnabled)
|
||||
|
||||
385
Views/SkiaAlertDialog.cs
Normal file
385
Views/SkiaAlertDialog.cs
Normal file
@@ -0,0 +1,385 @@
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// A modal alert dialog rendered with Skia.
|
||||
/// Supports title, message, and up to two buttons (cancel/accept).
|
||||
/// </summary>
|
||||
public class SkiaAlertDialog : SkiaView
|
||||
{
|
||||
private readonly string _title;
|
||||
private readonly string _message;
|
||||
private readonly string? _cancel;
|
||||
private readonly string? _accept;
|
||||
private readonly TaskCompletionSource<bool> _tcs;
|
||||
|
||||
private SKRect _cancelButtonBounds;
|
||||
private SKRect _acceptButtonBounds;
|
||||
private bool _cancelHovered;
|
||||
private bool _acceptHovered;
|
||||
|
||||
// Dialog styling
|
||||
private static readonly SKColor OverlayColor = new SKColor(0, 0, 0, 128);
|
||||
private static readonly SKColor DialogBackground = SKColors.White;
|
||||
private static readonly SKColor TitleColor = new SKColor(0x21, 0x21, 0x21);
|
||||
private static readonly SKColor MessageColor = new SKColor(0x61, 0x61, 0x61);
|
||||
private static readonly SKColor ButtonColor = new SKColor(0x21, 0x96, 0xF3);
|
||||
private static readonly SKColor ButtonHoverColor = new SKColor(0x19, 0x76, 0xD2);
|
||||
private static readonly SKColor ButtonTextColor = SKColors.White;
|
||||
private static readonly SKColor CancelButtonColor = new SKColor(0x9E, 0x9E, 0x9E);
|
||||
private static readonly SKColor CancelButtonHoverColor = new SKColor(0x75, 0x75, 0x75);
|
||||
private static readonly SKColor BorderColor = new SKColor(0xE0, 0xE0, 0xE0);
|
||||
|
||||
private const float DialogWidth = 400;
|
||||
private const float DialogPadding = 24;
|
||||
private const float ButtonHeight = 44;
|
||||
private const float ButtonSpacing = 12;
|
||||
private const float CornerRadius = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new alert dialog.
|
||||
/// </summary>
|
||||
public SkiaAlertDialog(string title, string message, string? accept, string? cancel)
|
||||
{
|
||||
_title = title;
|
||||
_message = message;
|
||||
_accept = accept;
|
||||
_cancel = cancel;
|
||||
_tcs = new TaskCompletionSource<bool>();
|
||||
IsFocusable = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the task that completes when the dialog is dismissed.
|
||||
/// Returns true if accept was clicked, false if cancel was clicked.
|
||||
/// </summary>
|
||||
public Task<bool> Result => _tcs.Task;
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Draw semi-transparent overlay covering entire screen
|
||||
using var overlayPaint = new SKPaint
|
||||
{
|
||||
Color = OverlayColor,
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
canvas.DrawRect(bounds, overlayPaint);
|
||||
|
||||
// Calculate dialog dimensions
|
||||
var messageLines = WrapText(_message, DialogWidth - DialogPadding * 2, 16);
|
||||
var dialogHeight = CalculateDialogHeight(messageLines.Count);
|
||||
|
||||
var dialogLeft = bounds.MidX - DialogWidth / 2;
|
||||
var dialogTop = bounds.MidY - dialogHeight / 2;
|
||||
var dialogBounds = new SKRect(dialogLeft, dialogTop, dialogLeft + DialogWidth, dialogTop + dialogHeight);
|
||||
|
||||
// Draw dialog shadow
|
||||
using var shadowPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(0, 0, 0, 60),
|
||||
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 8),
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
var shadowRect = new SKRect(dialogBounds.Left + 4, dialogBounds.Top + 4,
|
||||
dialogBounds.Right + 4, dialogBounds.Bottom + 4);
|
||||
canvas.DrawRoundRect(shadowRect, CornerRadius, CornerRadius, shadowPaint);
|
||||
|
||||
// Draw dialog background
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = DialogBackground,
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawRoundRect(dialogBounds, CornerRadius, CornerRadius, bgPaint);
|
||||
|
||||
// Draw title
|
||||
var yOffset = dialogBounds.Top + DialogPadding;
|
||||
if (!string.IsNullOrEmpty(_title))
|
||||
{
|
||||
using var titleFont = new SKFont(SKTypeface.Default, 20) { Embolden = true };
|
||||
using var titlePaint = new SKPaint(titleFont)
|
||||
{
|
||||
Color = TitleColor,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawText(_title, dialogBounds.Left + DialogPadding, yOffset + 20, titlePaint);
|
||||
yOffset += 36;
|
||||
}
|
||||
|
||||
// Draw message
|
||||
if (!string.IsNullOrEmpty(_message))
|
||||
{
|
||||
using var messageFont = new SKFont(SKTypeface.Default, 16);
|
||||
using var messagePaint = new SKPaint(messageFont)
|
||||
{
|
||||
Color = MessageColor,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
foreach (var line in messageLines)
|
||||
{
|
||||
canvas.DrawText(line, dialogBounds.Left + DialogPadding, yOffset + 16, messagePaint);
|
||||
yOffset += 22;
|
||||
}
|
||||
yOffset += 8;
|
||||
}
|
||||
|
||||
// Draw buttons
|
||||
yOffset = dialogBounds.Bottom - DialogPadding - ButtonHeight;
|
||||
var buttonY = yOffset;
|
||||
|
||||
var buttonCount = (_accept != null ? 1 : 0) + (_cancel != null ? 1 : 0);
|
||||
var totalButtonWidth = DialogWidth - DialogPadding * 2;
|
||||
|
||||
if (buttonCount == 2)
|
||||
{
|
||||
var singleButtonWidth = (totalButtonWidth - ButtonSpacing) / 2;
|
||||
|
||||
// Cancel button (left)
|
||||
_cancelButtonBounds = new SKRect(
|
||||
dialogBounds.Left + DialogPadding,
|
||||
buttonY,
|
||||
dialogBounds.Left + DialogPadding + singleButtonWidth,
|
||||
buttonY + ButtonHeight);
|
||||
DrawButton(canvas, _cancelButtonBounds, _cancel!,
|
||||
_cancelHovered ? CancelButtonHoverColor : CancelButtonColor);
|
||||
|
||||
// Accept button (right)
|
||||
_acceptButtonBounds = new SKRect(
|
||||
dialogBounds.Right - DialogPadding - singleButtonWidth,
|
||||
buttonY,
|
||||
dialogBounds.Right - DialogPadding,
|
||||
buttonY + ButtonHeight);
|
||||
DrawButton(canvas, _acceptButtonBounds, _accept!,
|
||||
_acceptHovered ? ButtonHoverColor : ButtonColor);
|
||||
}
|
||||
else if (_accept != null)
|
||||
{
|
||||
_acceptButtonBounds = new SKRect(
|
||||
dialogBounds.Left + DialogPadding,
|
||||
buttonY,
|
||||
dialogBounds.Right - DialogPadding,
|
||||
buttonY + ButtonHeight);
|
||||
DrawButton(canvas, _acceptButtonBounds, _accept,
|
||||
_acceptHovered ? ButtonHoverColor : ButtonColor);
|
||||
}
|
||||
else if (_cancel != null)
|
||||
{
|
||||
_cancelButtonBounds = new SKRect(
|
||||
dialogBounds.Left + DialogPadding,
|
||||
buttonY,
|
||||
dialogBounds.Right - DialogPadding,
|
||||
buttonY + ButtonHeight);
|
||||
DrawButton(canvas, _cancelButtonBounds, _cancel,
|
||||
_cancelHovered ? CancelButtonHoverColor : CancelButtonColor);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawButton(SKCanvas canvas, SKRect bounds, string text, SKColor bgColor)
|
||||
{
|
||||
// Button background
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = bgColor,
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawRoundRect(bounds, 8, 8, bgPaint);
|
||||
|
||||
// Button text
|
||||
using var font = new SKFont(SKTypeface.Default, 16) { Embolden = true };
|
||||
using var textPaint = new SKPaint(font)
|
||||
{
|
||||
Color = ButtonTextColor,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(text, ref textBounds);
|
||||
|
||||
var x = bounds.MidX - textBounds.MidX;
|
||||
var y = bounds.MidY - textBounds.MidY;
|
||||
canvas.DrawText(text, x, y, textPaint);
|
||||
}
|
||||
|
||||
private float CalculateDialogHeight(int messageLineCount)
|
||||
{
|
||||
var height = DialogPadding * 2; // Top and bottom padding
|
||||
|
||||
if (!string.IsNullOrEmpty(_title))
|
||||
height += 36; // Title height
|
||||
|
||||
if (!string.IsNullOrEmpty(_message))
|
||||
height += messageLineCount * 22 + 8; // Message lines + spacing
|
||||
|
||||
height += ButtonHeight; // Buttons
|
||||
|
||||
return Math.Max(height, 180); // Minimum height
|
||||
}
|
||||
|
||||
private List<string> WrapText(string text, float maxWidth, float fontSize)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return lines;
|
||||
|
||||
using var font = new SKFont(SKTypeface.Default, fontSize);
|
||||
using var paint = new SKPaint(font);
|
||||
|
||||
var words = text.Split(' ');
|
||||
var currentLine = "";
|
||||
|
||||
foreach (var word in words)
|
||||
{
|
||||
var testLine = string.IsNullOrEmpty(currentLine) ? word : currentLine + " " + word;
|
||||
var width = paint.MeasureText(testLine);
|
||||
|
||||
if (width > maxWidth && !string.IsNullOrEmpty(currentLine))
|
||||
{
|
||||
lines.Add(currentLine);
|
||||
currentLine = word;
|
||||
}
|
||||
else
|
||||
{
|
||||
currentLine = testLine;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(currentLine))
|
||||
lines.Add(currentLine);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
public override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
var wasHovered = _cancelHovered || _acceptHovered;
|
||||
|
||||
_cancelHovered = _cancel != null && _cancelButtonBounds.Contains(e.X, e.Y);
|
||||
_acceptHovered = _accept != null && _acceptButtonBounds.Contains(e.X, e.Y);
|
||||
|
||||
if (wasHovered != (_cancelHovered || _acceptHovered))
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
// Check if clicking on buttons
|
||||
if (_cancel != null && _cancelButtonBounds.Contains(e.X, e.Y))
|
||||
{
|
||||
Dismiss(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_accept != null && _acceptButtonBounds.Contains(e.X, e.Y))
|
||||
{
|
||||
Dismiss(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clicking outside dialog doesn't dismiss it (it's modal)
|
||||
}
|
||||
|
||||
public override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
// Handle Escape to cancel
|
||||
if (e.Key == Key.Escape && _cancel != null)
|
||||
{
|
||||
Dismiss(false);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Enter to accept
|
||||
if (e.Key == Key.Enter && _accept != null)
|
||||
{
|
||||
Dismiss(true);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void Dismiss(bool result)
|
||||
{
|
||||
// Remove from dialog system
|
||||
LinuxDialogService.HideDialog(this);
|
||||
_tcs.TrySetResult(result);
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
// Dialog takes full screen for the overlay
|
||||
return availableSize;
|
||||
}
|
||||
|
||||
public override SkiaView? HitTest(float x, float y)
|
||||
{
|
||||
// Modal dialogs capture all input
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for showing modal dialogs in OpenMaui Linux.
|
||||
/// </summary>
|
||||
public static class LinuxDialogService
|
||||
{
|
||||
private static readonly List<SkiaAlertDialog> _activeDialogs = new();
|
||||
private static Action? _invalidateCallback;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the invalidation callback (called by LinuxApplication).
|
||||
/// </summary>
|
||||
public static void SetInvalidateCallback(Action callback)
|
||||
{
|
||||
_invalidateCallback = callback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows an alert dialog and returns when dismissed.
|
||||
/// </summary>
|
||||
public static Task<bool> ShowAlertAsync(string title, string message, string? accept, string? cancel)
|
||||
{
|
||||
var dialog = new SkiaAlertDialog(title, message, accept, cancel);
|
||||
_activeDialogs.Add(dialog);
|
||||
_invalidateCallback?.Invoke();
|
||||
return dialog.Result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hides a dialog.
|
||||
/// </summary>
|
||||
internal static void HideDialog(SkiaAlertDialog dialog)
|
||||
{
|
||||
_activeDialogs.Remove(dialog);
|
||||
_invalidateCallback?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether there are active dialogs.
|
||||
/// </summary>
|
||||
public static bool HasActiveDialog => _activeDialogs.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the topmost dialog.
|
||||
/// </summary>
|
||||
public static SkiaAlertDialog? TopDialog => _activeDialogs.Count > 0 ? _activeDialogs[^1] : null;
|
||||
|
||||
/// <summary>
|
||||
/// Draws all active dialogs.
|
||||
/// </summary>
|
||||
public static void DrawDialogs(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
foreach (var dialog in _activeDialogs)
|
||||
{
|
||||
dialog.Measure(new SKSize(bounds.Width, bounds.Height));
|
||||
dialog.Arrange(bounds);
|
||||
dialog.Draw(canvas);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,99 +6,192 @@ using SkiaSharp;
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered border/frame container control.
|
||||
/// Skia-rendered border/frame container control with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaBorder : SkiaLayoutView
|
||||
{
|
||||
private float _strokeThickness = 1;
|
||||
private float _cornerRadius = 0;
|
||||
private SKColor _stroke = SKColors.Black;
|
||||
private float _paddingLeft = 0;
|
||||
private float _paddingTop = 0;
|
||||
private float _paddingRight = 0;
|
||||
private float _paddingBottom = 0;
|
||||
private bool _hasShadow;
|
||||
#region BindableProperties
|
||||
|
||||
public static readonly BindableProperty StrokeThicknessProperty =
|
||||
BindableProperty.Create(nameof(StrokeThickness), typeof(float), typeof(SkiaBorder), 1f,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty CornerRadiusProperty =
|
||||
BindableProperty.Create(nameof(CornerRadius), typeof(float), typeof(SkiaBorder), 0f,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty StrokeProperty =
|
||||
BindableProperty.Create(nameof(Stroke), typeof(SKColor), typeof(SkiaBorder), SKColors.Black,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty PaddingLeftProperty =
|
||||
BindableProperty.Create(nameof(PaddingLeft), typeof(float), typeof(SkiaBorder), 0f,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty PaddingTopProperty =
|
||||
BindableProperty.Create(nameof(PaddingTop), typeof(float), typeof(SkiaBorder), 0f,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty PaddingRightProperty =
|
||||
BindableProperty.Create(nameof(PaddingRight), typeof(float), typeof(SkiaBorder), 0f,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty PaddingBottomProperty =
|
||||
BindableProperty.Create(nameof(PaddingBottom), typeof(float), typeof(SkiaBorder), 0f,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty HasShadowProperty =
|
||||
BindableProperty.Create(nameof(HasShadow), typeof(bool), typeof(SkiaBorder), false,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty ShadowColorProperty =
|
||||
BindableProperty.Create(nameof(ShadowColor), typeof(SKColor), typeof(SkiaBorder), new SKColor(0, 0, 0, 40),
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty ShadowBlurRadiusProperty =
|
||||
BindableProperty.Create(nameof(ShadowBlurRadius), typeof(float), typeof(SkiaBorder), 4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty ShadowOffsetXProperty =
|
||||
BindableProperty.Create(nameof(ShadowOffsetX), typeof(float), typeof(SkiaBorder), 2f,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty ShadowOffsetYProperty =
|
||||
BindableProperty.Create(nameof(ShadowOffsetY), typeof(float), typeof(SkiaBorder), 2f,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).Invalidate());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
public float StrokeThickness
|
||||
{
|
||||
get => _strokeThickness;
|
||||
set { _strokeThickness = value; Invalidate(); }
|
||||
get => (float)GetValue(StrokeThicknessProperty);
|
||||
set => SetValue(StrokeThicknessProperty, value);
|
||||
}
|
||||
|
||||
public float CornerRadius
|
||||
{
|
||||
get => _cornerRadius;
|
||||
set { _cornerRadius = value; Invalidate(); }
|
||||
get => (float)GetValue(CornerRadiusProperty);
|
||||
set => SetValue(CornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
public SKColor Stroke
|
||||
{
|
||||
get => _stroke;
|
||||
set { _stroke = value; Invalidate(); }
|
||||
get => (SKColor)GetValue(StrokeProperty);
|
||||
set => SetValue(StrokeProperty, value);
|
||||
}
|
||||
|
||||
public float PaddingLeft
|
||||
{
|
||||
get => _paddingLeft;
|
||||
set { _paddingLeft = value; InvalidateMeasure(); }
|
||||
get => (float)GetValue(PaddingLeftProperty);
|
||||
set => SetValue(PaddingLeftProperty, value);
|
||||
}
|
||||
|
||||
public float PaddingTop
|
||||
{
|
||||
get => _paddingTop;
|
||||
set { _paddingTop = value; InvalidateMeasure(); }
|
||||
get => (float)GetValue(PaddingTopProperty);
|
||||
set => SetValue(PaddingTopProperty, value);
|
||||
}
|
||||
|
||||
public float PaddingRight
|
||||
{
|
||||
get => _paddingRight;
|
||||
set { _paddingRight = value; InvalidateMeasure(); }
|
||||
get => (float)GetValue(PaddingRightProperty);
|
||||
set => SetValue(PaddingRightProperty, value);
|
||||
}
|
||||
|
||||
public float PaddingBottom
|
||||
{
|
||||
get => _paddingBottom;
|
||||
set { _paddingBottom = value; InvalidateMeasure(); }
|
||||
get => (float)GetValue(PaddingBottomProperty);
|
||||
set => SetValue(PaddingBottomProperty, value);
|
||||
}
|
||||
|
||||
public bool HasShadow
|
||||
{
|
||||
get => _hasShadow;
|
||||
set { _hasShadow = value; Invalidate(); }
|
||||
get => (bool)GetValue(HasShadowProperty);
|
||||
set => SetValue(HasShadowProperty, value);
|
||||
}
|
||||
|
||||
public SKColor ShadowColor
|
||||
{
|
||||
get => (SKColor)GetValue(ShadowColorProperty);
|
||||
set => SetValue(ShadowColorProperty, value);
|
||||
}
|
||||
|
||||
public float ShadowBlurRadius
|
||||
{
|
||||
get => (float)GetValue(ShadowBlurRadiusProperty);
|
||||
set => SetValue(ShadowBlurRadiusProperty, value);
|
||||
}
|
||||
|
||||
public float ShadowOffsetX
|
||||
{
|
||||
get => (float)GetValue(ShadowOffsetXProperty);
|
||||
set => SetValue(ShadowOffsetXProperty, value);
|
||||
}
|
||||
|
||||
public float ShadowOffsetY
|
||||
{
|
||||
get => (float)GetValue(ShadowOffsetYProperty);
|
||||
set => SetValue(ShadowOffsetYProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Sets uniform padding on all sides.
|
||||
/// </summary>
|
||||
public void SetPadding(float all)
|
||||
{
|
||||
_paddingLeft = _paddingTop = _paddingRight = _paddingBottom = all;
|
||||
InvalidateMeasure();
|
||||
PaddingLeft = PaddingTop = PaddingRight = PaddingBottom = all;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets padding with horizontal and vertical values.
|
||||
/// </summary>
|
||||
public void SetPadding(float horizontal, float vertical)
|
||||
{
|
||||
_paddingLeft = _paddingRight = horizontal;
|
||||
_paddingTop = _paddingBottom = vertical;
|
||||
InvalidateMeasure();
|
||||
PaddingLeft = PaddingRight = horizontal;
|
||||
PaddingTop = PaddingBottom = vertical;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets padding with individual values for each side.
|
||||
/// </summary>
|
||||
public void SetPadding(float left, float top, float right, float bottom)
|
||||
{
|
||||
PaddingLeft = left;
|
||||
PaddingTop = top;
|
||||
PaddingRight = right;
|
||||
PaddingBottom = bottom;
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
var strokeThickness = StrokeThickness;
|
||||
var cornerRadius = CornerRadius;
|
||||
|
||||
var borderRect = new SKRect(
|
||||
bounds.Left + _strokeThickness / 2,
|
||||
bounds.Top + _strokeThickness / 2,
|
||||
bounds.Right - _strokeThickness / 2,
|
||||
bounds.Bottom - _strokeThickness / 2);
|
||||
bounds.Left + strokeThickness / 2,
|
||||
bounds.Top + strokeThickness / 2,
|
||||
bounds.Right - strokeThickness / 2,
|
||||
bounds.Bottom - strokeThickness / 2);
|
||||
|
||||
// Draw shadow if enabled
|
||||
if (_hasShadow)
|
||||
if (HasShadow)
|
||||
{
|
||||
using var shadowPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(0, 0, 0, 40),
|
||||
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 4),
|
||||
Color = ShadowColor,
|
||||
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, ShadowBlurRadius),
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
var shadowRect = new SKRect(borderRect.Left + 2, borderRect.Top + 2, borderRect.Right + 2, borderRect.Bottom + 2);
|
||||
canvas.DrawRoundRect(new SKRoundRect(shadowRect, _cornerRadius), shadowPaint);
|
||||
var shadowRect = new SKRect(
|
||||
borderRect.Left + ShadowOffsetX,
|
||||
borderRect.Top + ShadowOffsetY,
|
||||
borderRect.Right + ShadowOffsetX,
|
||||
borderRect.Bottom + ShadowOffsetY);
|
||||
canvas.DrawRoundRect(new SKRoundRect(shadowRect, cornerRadius), shadowPaint);
|
||||
}
|
||||
|
||||
// Draw background
|
||||
@@ -108,22 +201,22 @@ public class SkiaBorder : SkiaLayoutView
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawRoundRect(new SKRoundRect(borderRect, _cornerRadius), bgPaint);
|
||||
canvas.DrawRoundRect(new SKRoundRect(borderRect, cornerRadius), bgPaint);
|
||||
|
||||
// Draw border
|
||||
if (_strokeThickness > 0)
|
||||
if (strokeThickness > 0)
|
||||
{
|
||||
using var borderPaint = new SKPaint
|
||||
{
|
||||
Color = _stroke,
|
||||
Color = Stroke,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = _strokeThickness,
|
||||
StrokeWidth = strokeThickness,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawRoundRect(new SKRoundRect(borderRect, _cornerRadius), borderPaint);
|
||||
canvas.DrawRoundRect(new SKRoundRect(borderRect, cornerRadius), borderPaint);
|
||||
}
|
||||
|
||||
// Draw children (call base which draws children)
|
||||
// Draw children
|
||||
foreach (var child in Children)
|
||||
{
|
||||
if (child.IsVisible)
|
||||
@@ -140,21 +233,27 @@ public class SkiaBorder : SkiaLayoutView
|
||||
|
||||
protected new SKRect GetContentBounds(SKRect bounds)
|
||||
{
|
||||
var strokeThickness = StrokeThickness;
|
||||
return new SKRect(
|
||||
bounds.Left + _paddingLeft + _strokeThickness,
|
||||
bounds.Top + _paddingTop + _strokeThickness,
|
||||
bounds.Right - _paddingRight - _strokeThickness,
|
||||
bounds.Bottom - _paddingBottom - _strokeThickness);
|
||||
bounds.Left + PaddingLeft + strokeThickness,
|
||||
bounds.Top + PaddingTop + strokeThickness,
|
||||
bounds.Right - PaddingRight - strokeThickness,
|
||||
bounds.Bottom - PaddingBottom - strokeThickness);
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
var paddingWidth = _paddingLeft + _paddingRight + _strokeThickness * 2;
|
||||
var paddingHeight = _paddingTop + _paddingBottom + _strokeThickness * 2;
|
||||
var strokeThickness = StrokeThickness;
|
||||
var paddingWidth = PaddingLeft + PaddingRight + strokeThickness * 2;
|
||||
var paddingHeight = PaddingTop + PaddingBottom + strokeThickness * 2;
|
||||
|
||||
// Respect explicit size requests
|
||||
var requestedWidth = WidthRequest >= 0 ? (float)WidthRequest : availableSize.Width;
|
||||
var requestedHeight = HeightRequest >= 0 ? (float)HeightRequest : availableSize.Height;
|
||||
|
||||
var childAvailable = new SKSize(
|
||||
availableSize.Width - paddingWidth,
|
||||
availableSize.Height - paddingHeight);
|
||||
Math.Max(0, requestedWidth - paddingWidth),
|
||||
Math.Max(0, requestedHeight - paddingHeight));
|
||||
|
||||
var maxChildSize = SKSize.Empty;
|
||||
|
||||
@@ -166,19 +265,27 @@ public class SkiaBorder : SkiaLayoutView
|
||||
Math.Max(maxChildSize.Height, childSize.Height));
|
||||
}
|
||||
|
||||
return new SKSize(
|
||||
maxChildSize.Width + paddingWidth,
|
||||
maxChildSize.Height + paddingHeight);
|
||||
// Use requested size if set, otherwise use child size + padding
|
||||
var width = WidthRequest >= 0 ? (float)WidthRequest : maxChildSize.Width + paddingWidth;
|
||||
var height = HeightRequest >= 0 ? (float)HeightRequest : maxChildSize.Height + paddingHeight;
|
||||
|
||||
return new SKSize(width, height);
|
||||
}
|
||||
|
||||
protected override SKRect ArrangeOverride(SKRect bounds)
|
||||
{
|
||||
|
||||
var contentBounds = GetContentBounds(bounds);
|
||||
|
||||
foreach (var child in Children)
|
||||
{
|
||||
child.Arrange(contentBounds);
|
||||
// Apply child's margin
|
||||
var margin = child.Margin;
|
||||
var marginedBounds = new SKRect(
|
||||
contentBounds.Left + (float)margin.Left,
|
||||
contentBounds.Top + (float)margin.Top,
|
||||
contentBounds.Right - (float)margin.Right,
|
||||
contentBounds.Bottom - (float)margin.Bottom);
|
||||
child.Arrange(marginedBounds);
|
||||
}
|
||||
|
||||
return bounds;
|
||||
@@ -186,7 +293,8 @@ public class SkiaBorder : SkiaLayoutView
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frame control (alias for Border with shadow enabled).
|
||||
/// Frame control - a Border with shadow enabled by default.
|
||||
/// Mimics the MAUI Frame control appearance.
|
||||
/// </summary>
|
||||
public class SkiaFrame : SkiaBorder
|
||||
{
|
||||
@@ -196,5 +304,7 @@ public class SkiaFrame : SkiaBorder
|
||||
CornerRadius = 4;
|
||||
SetPadding(10);
|
||||
BackgroundColor = SKColors.White;
|
||||
Stroke = SKColors.Transparent;
|
||||
StrokeThickness = 0;
|
||||
}
|
||||
}
|
||||
|
||||
66
Views/SkiaBoxView.cs
Normal file
66
Views/SkiaBoxView.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered BoxView - a simple colored rectangle.
|
||||
/// </summary>
|
||||
public class SkiaBoxView : SkiaView
|
||||
{
|
||||
public static readonly BindableProperty ColorProperty =
|
||||
BindableProperty.Create(nameof(Color), typeof(SKColor), typeof(SkiaBoxView), SKColors.Transparent,
|
||||
propertyChanged: (b, o, n) => ((SkiaBoxView)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty CornerRadiusProperty =
|
||||
BindableProperty.Create(nameof(CornerRadius), typeof(float), typeof(SkiaBoxView), 0f,
|
||||
propertyChanged: (b, o, n) => ((SkiaBoxView)b).Invalidate());
|
||||
|
||||
public SKColor Color
|
||||
{
|
||||
get => (SKColor)GetValue(ColorProperty);
|
||||
set => SetValue(ColorProperty, value);
|
||||
}
|
||||
|
||||
public float CornerRadius
|
||||
{
|
||||
get => (float)GetValue(CornerRadiusProperty);
|
||||
set => SetValue(CornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
using var paint = new SKPaint
|
||||
{
|
||||
Color = Color,
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
if (CornerRadius > 0)
|
||||
{
|
||||
canvas.DrawRoundRect(bounds, CornerRadius, CornerRadius, paint);
|
||||
}
|
||||
else
|
||||
{
|
||||
canvas.DrawRect(bounds, paint);
|
||||
}
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
// BoxView uses explicit size or a default size when in unbounded context
|
||||
var width = WidthRequest >= 0 ? (float)WidthRequest :
|
||||
(float.IsInfinity(availableSize.Width) ? 40f : availableSize.Width);
|
||||
var height = HeightRequest >= 0 ? (float)HeightRequest :
|
||||
(float.IsInfinity(availableSize.Height) ? 40f : availableSize.Height);
|
||||
|
||||
// Ensure no NaN values
|
||||
if (float.IsNaN(width)) width = 40f;
|
||||
if (float.IsNaN(height)) height = 40f;
|
||||
|
||||
return new SKSize(width, height);
|
||||
}
|
||||
}
|
||||
@@ -7,32 +7,382 @@ using Microsoft.Maui.Platform.Linux.Rendering;
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered button control.
|
||||
/// Skia-rendered button control with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaButton : SkiaView
|
||||
{
|
||||
public string Text { get; set; } = "";
|
||||
public SKColor TextColor { get; set; } = SKColors.White;
|
||||
public new SKColor BackgroundColor { get; set; } = new SKColor(0x21, 0x96, 0xF3); // Material Blue
|
||||
public SKColor PressedBackgroundColor { get; set; } = new SKColor(0x19, 0x76, 0xD2);
|
||||
public SKColor DisabledBackgroundColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public SKColor HoveredBackgroundColor { get; set; } = new SKColor(0x42, 0xA5, 0xF5);
|
||||
public SKColor BorderColor { get; set; } = SKColors.Transparent;
|
||||
public string FontFamily { get; set; } = "Sans";
|
||||
public float FontSize { get; set; } = 14;
|
||||
public bool IsBold { get; set; }
|
||||
public bool IsItalic { get; set; }
|
||||
public float CharacterSpacing { get; set; }
|
||||
public float CornerRadius { get; set; } = 4;
|
||||
public float BorderWidth { get; set; } = 0;
|
||||
public SKRect Padding { get; set; } = new SKRect(16, 8, 16, 8);
|
||||
#region BindableProperties
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Text.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TextProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Text),
|
||||
typeof(string),
|
||||
typeof(SkiaButton),
|
||||
"",
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).OnTextChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for TextColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TextColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(TextColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaButton),
|
||||
SKColors.White,
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ButtonBackgroundColor (distinct from base BackgroundColor).
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ButtonBackgroundColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ButtonBackgroundColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaButton),
|
||||
new SKColor(0x21, 0x96, 0xF3), // Material Blue
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for PressedBackgroundColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty PressedBackgroundColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(PressedBackgroundColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaButton),
|
||||
new SKColor(0x19, 0x76, 0xD2),
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for DisabledBackgroundColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty DisabledBackgroundColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(DisabledBackgroundColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaButton),
|
||||
new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for HoveredBackgroundColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty HoveredBackgroundColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(HoveredBackgroundColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaButton),
|
||||
new SKColor(0x42, 0xA5, 0xF5),
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for BorderColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty BorderColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(BorderColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaButton),
|
||||
SKColors.Transparent,
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for FontFamily.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty FontFamilyProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(FontFamily),
|
||||
typeof(string),
|
||||
typeof(SkiaButton),
|
||||
"Sans",
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).OnFontChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for FontSize.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty FontSizeProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(FontSize),
|
||||
typeof(float),
|
||||
typeof(SkiaButton),
|
||||
14f,
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).OnFontChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsBold.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsBoldProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsBold),
|
||||
typeof(bool),
|
||||
typeof(SkiaButton),
|
||||
false,
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).OnFontChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsItalic.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsItalicProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsItalic),
|
||||
typeof(bool),
|
||||
typeof(SkiaButton),
|
||||
false,
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).OnFontChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CharacterSpacing.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CharacterSpacingProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CharacterSpacing),
|
||||
typeof(float),
|
||||
typeof(SkiaButton),
|
||||
0f,
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CornerRadius.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CornerRadiusProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CornerRadius),
|
||||
typeof(float),
|
||||
typeof(SkiaButton),
|
||||
4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for BorderWidth.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty BorderWidthProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(BorderWidth),
|
||||
typeof(float),
|
||||
typeof(SkiaButton),
|
||||
0f,
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Padding.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty PaddingProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Padding),
|
||||
typeof(SKRect),
|
||||
typeof(SkiaButton),
|
||||
new SKRect(16, 8, 16, 8),
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Command.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CommandProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Command),
|
||||
typeof(System.Windows.Input.ICommand),
|
||||
typeof(SkiaButton),
|
||||
null,
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).OnCommandChanged((System.Windows.Input.ICommand?)o, (System.Windows.Input.ICommand?)n));
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CommandParameter.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CommandParameterProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CommandParameter),
|
||||
typeof(object),
|
||||
typeof(SkiaButton),
|
||||
null);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the button text.
|
||||
/// </summary>
|
||||
public string Text
|
||||
{
|
||||
get => (string)GetValue(TextProperty);
|
||||
set => SetValue(TextProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text color.
|
||||
/// </summary>
|
||||
public SKColor TextColor
|
||||
{
|
||||
get => (SKColor)GetValue(TextColorProperty);
|
||||
set => SetValue(TextColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the button background color.
|
||||
/// </summary>
|
||||
public SKColor ButtonBackgroundColor
|
||||
{
|
||||
get => (SKColor)GetValue(ButtonBackgroundColorProperty);
|
||||
set => SetValue(ButtonBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the pressed background color.
|
||||
/// </summary>
|
||||
public SKColor PressedBackgroundColor
|
||||
{
|
||||
get => (SKColor)GetValue(PressedBackgroundColorProperty);
|
||||
set => SetValue(PressedBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the disabled background color.
|
||||
/// </summary>
|
||||
public SKColor DisabledBackgroundColor
|
||||
{
|
||||
get => (SKColor)GetValue(DisabledBackgroundColorProperty);
|
||||
set => SetValue(DisabledBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the hovered background color.
|
||||
/// </summary>
|
||||
public SKColor HoveredBackgroundColor
|
||||
{
|
||||
get => (SKColor)GetValue(HoveredBackgroundColorProperty);
|
||||
set => SetValue(HoveredBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the border color.
|
||||
/// </summary>
|
||||
public SKColor BorderColor
|
||||
{
|
||||
get => (SKColor)GetValue(BorderColorProperty);
|
||||
set => SetValue(BorderColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the font family.
|
||||
/// </summary>
|
||||
public string FontFamily
|
||||
{
|
||||
get => (string)GetValue(FontFamilyProperty);
|
||||
set => SetValue(FontFamilyProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the font size.
|
||||
/// </summary>
|
||||
public float FontSize
|
||||
{
|
||||
get => (float)GetValue(FontSizeProperty);
|
||||
set => SetValue(FontSizeProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the text is bold.
|
||||
/// </summary>
|
||||
public bool IsBold
|
||||
{
|
||||
get => (bool)GetValue(IsBoldProperty);
|
||||
set => SetValue(IsBoldProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the text is italic.
|
||||
/// </summary>
|
||||
public bool IsItalic
|
||||
{
|
||||
get => (bool)GetValue(IsItalicProperty);
|
||||
set => SetValue(IsItalicProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the character spacing.
|
||||
/// </summary>
|
||||
public float CharacterSpacing
|
||||
{
|
||||
get => (float)GetValue(CharacterSpacingProperty);
|
||||
set => SetValue(CharacterSpacingProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the corner radius.
|
||||
/// </summary>
|
||||
public float CornerRadius
|
||||
{
|
||||
get => (float)GetValue(CornerRadiusProperty);
|
||||
set => SetValue(CornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the border width.
|
||||
/// </summary>
|
||||
public float BorderWidth
|
||||
{
|
||||
get => (float)GetValue(BorderWidthProperty);
|
||||
set => SetValue(BorderWidthProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the padding.
|
||||
/// </summary>
|
||||
public SKRect Padding
|
||||
{
|
||||
get => (SKRect)GetValue(PaddingProperty);
|
||||
set => SetValue(PaddingProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the command to execute when clicked.
|
||||
/// </summary>
|
||||
public System.Windows.Input.ICommand? Command
|
||||
{
|
||||
get => (System.Windows.Input.ICommand?)GetValue(CommandProperty);
|
||||
set => SetValue(CommandProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the command parameter.
|
||||
/// </summary>
|
||||
public object? CommandParameter
|
||||
{
|
||||
get => GetValue(CommandParameterProperty);
|
||||
set => SetValue(CommandParameterProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the button is currently pressed.
|
||||
/// </summary>
|
||||
public bool IsPressed { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the pointer is currently over the button.
|
||||
/// </summary>
|
||||
public bool IsHovered { get; private set; }
|
||||
|
||||
#endregion
|
||||
|
||||
private bool _focusFromKeyboard;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when the button is clicked.
|
||||
/// </summary>
|
||||
public event EventHandler? Clicked;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when the button is pressed.
|
||||
/// </summary>
|
||||
public event EventHandler? Pressed;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when the button is released.
|
||||
/// </summary>
|
||||
public event EventHandler? Released;
|
||||
|
||||
public SkiaButton()
|
||||
@@ -40,30 +390,91 @@ public class SkiaButton : SkiaView
|
||||
IsFocusable = true;
|
||||
}
|
||||
|
||||
private void OnTextChanged()
|
||||
{
|
||||
InvalidateMeasure();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void OnFontChanged()
|
||||
{
|
||||
InvalidateMeasure();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void OnCommandChanged(System.Windows.Input.ICommand? oldCommand, System.Windows.Input.ICommand? newCommand)
|
||||
{
|
||||
if (oldCommand != null)
|
||||
{
|
||||
oldCommand.CanExecuteChanged -= OnCanExecuteChanged;
|
||||
}
|
||||
|
||||
if (newCommand != null)
|
||||
{
|
||||
newCommand.CanExecuteChanged += OnCanExecuteChanged;
|
||||
UpdateIsEnabled();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCanExecuteChanged(object? sender, EventArgs e)
|
||||
{
|
||||
UpdateIsEnabled();
|
||||
}
|
||||
|
||||
private void UpdateIsEnabled()
|
||||
{
|
||||
if (Command != null)
|
||||
{
|
||||
IsEnabled = Command.CanExecute(CommandParameter);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Determine background color based on state
|
||||
var bgColor = !IsEnabled ? DisabledBackgroundColor
|
||||
: IsPressed ? PressedBackgroundColor
|
||||
: IsHovered ? HoveredBackgroundColor
|
||||
: BackgroundColor;
|
||||
// Check if this is a "text only" button (transparent background)
|
||||
var isTextOnly = ButtonBackgroundColor.Alpha == 0;
|
||||
|
||||
// Draw shadow (for elevation effect)
|
||||
if (IsEnabled && !IsPressed)
|
||||
// Determine background color based on state
|
||||
SKColor bgColor;
|
||||
if (!IsEnabled)
|
||||
{
|
||||
bgColor = isTextOnly ? SKColors.Transparent : DisabledBackgroundColor;
|
||||
}
|
||||
else if (IsPressed)
|
||||
{
|
||||
// For text-only buttons, use a subtle press effect
|
||||
bgColor = isTextOnly ? new SKColor(0, 0, 0, 20) : PressedBackgroundColor;
|
||||
}
|
||||
else if (IsHovered)
|
||||
{
|
||||
// For text-only buttons, use a subtle hover effect instead of full background
|
||||
bgColor = isTextOnly ? new SKColor(0, 0, 0, 10) : HoveredBackgroundColor;
|
||||
}
|
||||
else
|
||||
{
|
||||
bgColor = ButtonBackgroundColor;
|
||||
}
|
||||
|
||||
// Draw shadow (for elevation effect) - skip for text-only buttons
|
||||
if (IsEnabled && !IsPressed && !isTextOnly)
|
||||
{
|
||||
DrawShadow(canvas, bounds);
|
||||
}
|
||||
|
||||
// Draw background with rounded corners
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = bgColor,
|
||||
IsAntialias = true,
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
|
||||
// Create rounded rect for background and border
|
||||
var rect = new SKRoundRect(bounds, CornerRadius);
|
||||
canvas.DrawRoundRect(rect, bgPaint);
|
||||
|
||||
// Draw background with rounded corners (skip if fully transparent)
|
||||
if (bgColor.Alpha > 0)
|
||||
{
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = bgColor,
|
||||
IsAntialias = true,
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
canvas.DrawRoundRect(rect, bgPaint);
|
||||
}
|
||||
|
||||
// Draw border
|
||||
if (BorderWidth > 0 && BorderColor != SKColors.Transparent)
|
||||
@@ -104,9 +515,30 @@ public class SkiaButton : SkiaView
|
||||
?? SKTypeface.Default;
|
||||
|
||||
using var font = new SKFont(typeface, FontSize);
|
||||
|
||||
// For text-only buttons, darken text on hover/press for feedback
|
||||
SKColor textColorToUse;
|
||||
if (!IsEnabled)
|
||||
{
|
||||
textColorToUse = TextColor.WithAlpha(128);
|
||||
}
|
||||
else if (isTextOnly && (IsHovered || IsPressed))
|
||||
{
|
||||
// Darken the text color slightly for hover/press feedback
|
||||
textColorToUse = new SKColor(
|
||||
(byte)Math.Max(0, TextColor.Red - 40),
|
||||
(byte)Math.Max(0, TextColor.Green - 40),
|
||||
(byte)Math.Max(0, TextColor.Blue - 40),
|
||||
TextColor.Alpha);
|
||||
}
|
||||
else
|
||||
{
|
||||
textColorToUse = TextColor;
|
||||
}
|
||||
|
||||
using var paint = new SKPaint(font)
|
||||
{
|
||||
Color = IsEnabled ? TextColor : TextColor.WithAlpha(128),
|
||||
Color = textColorToUse,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
@@ -145,6 +577,7 @@ public class SkiaButton : SkiaView
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
IsHovered = true;
|
||||
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.PointerOver);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
@@ -155,15 +588,18 @@ public class SkiaButton : SkiaView
|
||||
{
|
||||
IsPressed = false;
|
||||
}
|
||||
SkiaVisualStateManager.GoToState(this, IsEnabled ? SkiaVisualStateManager.CommonStates.Normal : SkiaVisualStateManager.CommonStates.Disabled);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"[SkiaButton] OnPointerPressed - Text='{Text}', IsEnabled={IsEnabled}");
|
||||
if (!IsEnabled) return;
|
||||
|
||||
IsPressed = true;
|
||||
_focusFromKeyboard = false;
|
||||
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Pressed);
|
||||
Invalidate();
|
||||
Pressed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
@@ -174,14 +610,18 @@ public class SkiaButton : SkiaView
|
||||
|
||||
var wasPressed = IsPressed;
|
||||
IsPressed = false;
|
||||
SkiaVisualStateManager.GoToState(this, IsHovered ? SkiaVisualStateManager.CommonStates.PointerOver : SkiaVisualStateManager.CommonStates.Normal);
|
||||
Invalidate();
|
||||
|
||||
Released?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
// Fire click if released within bounds
|
||||
if (wasPressed && Bounds.Contains(new SKPoint(e.X, e.Y)))
|
||||
// Fire click if button was pressed
|
||||
// Note: Hit testing already verified the pointer is over this button,
|
||||
// so we don't need to re-check bounds (which would fail due to coordinate system differences)
|
||||
if (wasPressed)
|
||||
{
|
||||
Clicked?.Invoke(this, EventArgs.Empty);
|
||||
Command?.Execute(CommandParameter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +633,8 @@ public class SkiaButton : SkiaView
|
||||
if (e.Key == Key.Enter || e.Key == Key.Space)
|
||||
{
|
||||
IsPressed = true;
|
||||
_focusFromKeyboard = true;
|
||||
_focusFromKeyboard = true;
|
||||
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Pressed);
|
||||
Invalidate();
|
||||
Pressed?.Invoke(this, EventArgs.Empty);
|
||||
e.Handled = true;
|
||||
@@ -209,21 +650,36 @@ public class SkiaButton : SkiaView
|
||||
if (IsPressed)
|
||||
{
|
||||
IsPressed = false;
|
||||
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Normal);
|
||||
Invalidate();
|
||||
Released?.Invoke(this, EventArgs.Empty);
|
||||
Clicked?.Invoke(this, EventArgs.Empty);
|
||||
Command?.Execute(CommandParameter);
|
||||
}
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnEnabledChanged()
|
||||
{
|
||||
base.OnEnabledChanged();
|
||||
SkiaVisualStateManager.GoToState(this, IsEnabled ? SkiaVisualStateManager.CommonStates.Normal : SkiaVisualStateManager.CommonStates.Disabled);
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
// Ensure we never return NaN - use safe defaults
|
||||
var paddingLeft = float.IsNaN(Padding.Left) ? 16f : Padding.Left;
|
||||
var paddingRight = float.IsNaN(Padding.Right) ? 16f : Padding.Right;
|
||||
var paddingTop = float.IsNaN(Padding.Top) ? 8f : Padding.Top;
|
||||
var paddingBottom = float.IsNaN(Padding.Bottom) ? 8f : Padding.Bottom;
|
||||
var fontSize = float.IsNaN(FontSize) || FontSize <= 0 ? 14f : FontSize;
|
||||
|
||||
if (string.IsNullOrEmpty(Text))
|
||||
{
|
||||
return new SKSize(
|
||||
Padding.Left + Padding.Right + 40, // Minimum width
|
||||
Padding.Top + Padding.Bottom + FontSize);
|
||||
paddingLeft + paddingRight + 40, // Minimum width
|
||||
paddingTop + paddingBottom + fontSize);
|
||||
}
|
||||
|
||||
var fontStyle = new SKFontStyle(
|
||||
@@ -233,14 +689,25 @@ public class SkiaButton : SkiaView
|
||||
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle)
|
||||
?? SKTypeface.Default;
|
||||
|
||||
using var font = new SKFont(typeface, FontSize);
|
||||
using var font = new SKFont(typeface, fontSize);
|
||||
using var paint = new SKPaint(font);
|
||||
|
||||
var textBounds = new SKRect();
|
||||
paint.MeasureText(Text, ref textBounds);
|
||||
|
||||
return new SKSize(
|
||||
textBounds.Width + Padding.Left + Padding.Right,
|
||||
textBounds.Height + Padding.Top + Padding.Bottom);
|
||||
var width = textBounds.Width + paddingLeft + paddingRight;
|
||||
var height = textBounds.Height + paddingTop + paddingBottom;
|
||||
|
||||
// Ensure valid, non-NaN return values
|
||||
if (float.IsNaN(width) || width < 0) width = 72f;
|
||||
if (float.IsNaN(height) || height < 0) height = 30f;
|
||||
|
||||
// Respect WidthRequest and HeightRequest when set
|
||||
if (WidthRequest >= 0)
|
||||
width = (float)WidthRequest;
|
||||
if (HeightRequest >= 0)
|
||||
height = (float)HeightRequest;
|
||||
|
||||
return new SKSize(width, height);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,39 +7,247 @@ using Microsoft.Maui.Platform.Linux.Rendering;
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered checkbox control.
|
||||
/// Skia-rendered checkbox control with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaCheckBox : SkiaView
|
||||
{
|
||||
private bool _isChecked;
|
||||
#region BindableProperties
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsChecked.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsCheckedProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsChecked),
|
||||
typeof(bool),
|
||||
typeof(SkiaCheckBox),
|
||||
false,
|
||||
BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).OnIsCheckedChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CheckColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CheckColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CheckColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaCheckBox),
|
||||
SKColors.White,
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for BoxColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty BoxColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(BoxColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaCheckBox),
|
||||
new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for UncheckedBoxColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty UncheckedBoxColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(UncheckedBoxColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaCheckBox),
|
||||
SKColors.White,
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for BorderColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty BorderColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(BorderColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaCheckBox),
|
||||
new SKColor(0x75, 0x75, 0x75),
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for DisabledColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty DisabledColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(DisabledColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaCheckBox),
|
||||
new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for HoveredBorderColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty HoveredBorderColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(HoveredBorderColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaCheckBox),
|
||||
new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for BoxSize.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty BoxSizeProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(BoxSize),
|
||||
typeof(float),
|
||||
typeof(SkiaCheckBox),
|
||||
20f,
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CornerRadius.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CornerRadiusProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CornerRadius),
|
||||
typeof(float),
|
||||
typeof(SkiaCheckBox),
|
||||
3f,
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for BorderWidth.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty BorderWidthProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(BorderWidth),
|
||||
typeof(float),
|
||||
typeof(SkiaCheckBox),
|
||||
2f,
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CheckStrokeWidth.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CheckStrokeWidthProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CheckStrokeWidth),
|
||||
typeof(float),
|
||||
typeof(SkiaCheckBox),
|
||||
2.5f,
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).Invalidate());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the checkbox is checked.
|
||||
/// </summary>
|
||||
public bool IsChecked
|
||||
{
|
||||
get => _isChecked;
|
||||
set
|
||||
{
|
||||
if (_isChecked != value)
|
||||
{
|
||||
_isChecked = value;
|
||||
CheckedChanged?.Invoke(this, new CheckedChangedEventArgs(value));
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (bool)GetValue(IsCheckedProperty);
|
||||
set => SetValue(IsCheckedProperty, value);
|
||||
}
|
||||
|
||||
public SKColor CheckColor { get; set; } = SKColors.White;
|
||||
public SKColor BoxColor { get; set; } = new SKColor(0x21, 0x96, 0xF3); // Material Blue
|
||||
public SKColor UncheckedBoxColor { get; set; } = SKColors.White;
|
||||
public SKColor BorderColor { get; set; } = new SKColor(0x75, 0x75, 0x75);
|
||||
public SKColor DisabledColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public SKColor HoveredBorderColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public float BoxSize { get; set; } = 20;
|
||||
public float CornerRadius { get; set; } = 3;
|
||||
public float BorderWidth { get; set; } = 2;
|
||||
public float CheckStrokeWidth { get; set; } = 2.5f;
|
||||
/// <summary>
|
||||
/// Gets or sets the check color.
|
||||
/// </summary>
|
||||
public SKColor CheckColor
|
||||
{
|
||||
get => (SKColor)GetValue(CheckColorProperty);
|
||||
set => SetValue(CheckColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the box color when checked.
|
||||
/// </summary>
|
||||
public SKColor BoxColor
|
||||
{
|
||||
get => (SKColor)GetValue(BoxColorProperty);
|
||||
set => SetValue(BoxColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the box color when unchecked.
|
||||
/// </summary>
|
||||
public SKColor UncheckedBoxColor
|
||||
{
|
||||
get => (SKColor)GetValue(UncheckedBoxColorProperty);
|
||||
set => SetValue(UncheckedBoxColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the border color.
|
||||
/// </summary>
|
||||
public SKColor BorderColor
|
||||
{
|
||||
get => (SKColor)GetValue(BorderColorProperty);
|
||||
set => SetValue(BorderColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the disabled color.
|
||||
/// </summary>
|
||||
public SKColor DisabledColor
|
||||
{
|
||||
get => (SKColor)GetValue(DisabledColorProperty);
|
||||
set => SetValue(DisabledColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the hovered border color.
|
||||
/// </summary>
|
||||
public SKColor HoveredBorderColor
|
||||
{
|
||||
get => (SKColor)GetValue(HoveredBorderColorProperty);
|
||||
set => SetValue(HoveredBorderColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the box size.
|
||||
/// </summary>
|
||||
public float BoxSize
|
||||
{
|
||||
get => (float)GetValue(BoxSizeProperty);
|
||||
set => SetValue(BoxSizeProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the corner radius.
|
||||
/// </summary>
|
||||
public float CornerRadius
|
||||
{
|
||||
get => (float)GetValue(CornerRadiusProperty);
|
||||
set => SetValue(CornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the border width.
|
||||
/// </summary>
|
||||
public float BorderWidth
|
||||
{
|
||||
get => (float)GetValue(BorderWidthProperty);
|
||||
set => SetValue(BorderWidthProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the check stroke width.
|
||||
/// </summary>
|
||||
public float CheckStrokeWidth
|
||||
{
|
||||
get => (float)GetValue(CheckStrokeWidthProperty);
|
||||
set => SetValue(CheckStrokeWidthProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the pointer is over the checkbox.
|
||||
/// </summary>
|
||||
public bool IsHovered { get; private set; }
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when checked state changes.
|
||||
/// </summary>
|
||||
public event EventHandler<CheckedChangedEventArgs>? CheckedChanged;
|
||||
|
||||
public SkiaCheckBox()
|
||||
@@ -47,6 +255,13 @@ public class SkiaCheckBox : SkiaView
|
||||
IsFocusable = true;
|
||||
}
|
||||
|
||||
private void OnIsCheckedChanged()
|
||||
{
|
||||
CheckedChanged?.Invoke(this, new CheckedChangedEventArgs(IsChecked));
|
||||
SkiaVisualStateManager.GoToState(this, IsChecked ? SkiaVisualStateManager.CommonStates.Checked : SkiaVisualStateManager.CommonStates.Unchecked);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Center the checkbox box in bounds
|
||||
@@ -136,12 +351,14 @@ public class SkiaCheckBox : SkiaView
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
IsHovered = true;
|
||||
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.PointerOver);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public override void OnPointerExited(PointerEventArgs e)
|
||||
{
|
||||
IsHovered = false;
|
||||
SkiaVisualStateManager.GoToState(this, IsEnabled ? SkiaVisualStateManager.CommonStates.Normal : SkiaVisualStateManager.CommonStates.Disabled);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
@@ -169,6 +386,12 @@ public class SkiaCheckBox : SkiaView
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnEnabledChanged()
|
||||
{
|
||||
base.OnEnabledChanged();
|
||||
SkiaVisualStateManager.GoToState(this, IsEnabled ? SkiaVisualStateManager.CommonStates.Normal : SkiaVisualStateManager.CommonStates.Disabled);
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
// Add some padding around the box for touch targets
|
||||
|
||||
@@ -47,6 +47,21 @@ public class SkiaCollectionView : SkiaItemsView
|
||||
private float _headerHeight = 0;
|
||||
private float _footerHeight = 0;
|
||||
|
||||
// Track if heights changed during draw (requires redraw for correct positioning)
|
||||
private bool _heightsChangedDuringDraw;
|
||||
|
||||
// Uses parent's _itemViewCache for virtualization
|
||||
|
||||
protected override void RefreshItems()
|
||||
{
|
||||
// Clear selection when items change to avoid stale references
|
||||
_selectedItems.Clear();
|
||||
_selectedItem = null;
|
||||
_selectedIndex = -1;
|
||||
|
||||
base.RefreshItems();
|
||||
}
|
||||
|
||||
public SkiaSelectionMode SelectionMode
|
||||
{
|
||||
get => _selectionMode;
|
||||
@@ -175,7 +190,7 @@ public class SkiaCollectionView : SkiaItemsView
|
||||
}
|
||||
}
|
||||
|
||||
public SKColor SelectionColor { get; set; } = new SKColor(0x21, 0x96, 0xF3, 0x40);
|
||||
public SKColor SelectionColor { get; set; } = new SKColor(0x21, 0x96, 0xF3, 0x59); // 35% opacity
|
||||
public SKColor HeaderBackgroundColor { get; set; } = new SKColor(0xF5, 0xF5, 0xF5);
|
||||
public SKColor FooterBackgroundColor { get; set; } = new SKColor(0xF5, 0xF5, 0xF5);
|
||||
|
||||
@@ -261,14 +276,7 @@ public class SkiaCollectionView : SkiaItemsView
|
||||
|
||||
protected override void DrawItem(SKCanvas canvas, object item, int index, SKRect bounds, SKPaint paint)
|
||||
{
|
||||
// Draw selection highlight
|
||||
bool isSelected = _selectedItems.Contains(item);
|
||||
if (isSelected)
|
||||
{
|
||||
paint.Color = SelectionColor;
|
||||
paint.Style = SKPaintStyle.Fill;
|
||||
canvas.DrawRect(bounds, paint);
|
||||
}
|
||||
|
||||
// Draw separator (only for vertical list layout)
|
||||
if (_orientation == ItemsLayoutOrientation.Vertical && _spanCount == 1)
|
||||
@@ -279,6 +287,70 @@ public class SkiaCollectionView : SkiaItemsView
|
||||
canvas.DrawLine(bounds.Left, bounds.Bottom, bounds.Right, bounds.Bottom, paint);
|
||||
}
|
||||
|
||||
// Try to use ItemViewCreator for templated rendering (from DataTemplate)
|
||||
if (ItemViewCreator != null)
|
||||
{
|
||||
// Get or create cached view for this index
|
||||
if (!_itemViewCache.TryGetValue(index, out var itemView) || itemView == null)
|
||||
{
|
||||
itemView = ItemViewCreator(item);
|
||||
if (itemView != null)
|
||||
{
|
||||
itemView.Parent = this;
|
||||
_itemViewCache[index] = itemView;
|
||||
}
|
||||
}
|
||||
|
||||
if (itemView != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Measure with large height to get natural size
|
||||
var availableSize = new SKSize(bounds.Width, float.MaxValue);
|
||||
var measuredSize = itemView.Measure(availableSize);
|
||||
|
||||
// Cap measured height - if item returns infinity/MaxValue, use ItemHeight as default
|
||||
// This happens with Star-sized Grids that have no natural height preference
|
||||
var rawHeight = measuredSize.Height;
|
||||
if (float.IsNaN(rawHeight) || float.IsInfinity(rawHeight) || rawHeight > 10000)
|
||||
{
|
||||
rawHeight = ItemHeight;
|
||||
}
|
||||
// Ensure minimum height
|
||||
var measuredHeight = Math.Max(rawHeight, ItemHeight);
|
||||
if (!_itemHeights.TryGetValue(index, out var cachedHeight) || Math.Abs(cachedHeight - measuredHeight) > 1)
|
||||
{
|
||||
_itemHeights[index] = measuredHeight;
|
||||
_heightsChangedDuringDraw = true; // Flag for redraw with correct positions
|
||||
}
|
||||
|
||||
// Arrange with the actual measured height
|
||||
var actualBounds = new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Top + measuredHeight);
|
||||
itemView.Arrange(actualBounds);
|
||||
itemView.Draw(canvas);
|
||||
|
||||
// Draw selection highlight ON TOP of the item content (semi-transparent overlay)
|
||||
if (isSelected)
|
||||
{
|
||||
paint.Color = SelectionColor;
|
||||
paint.Style = SKPaintStyle.Fill;
|
||||
canvas.DrawRoundRect(actualBounds, 12, 12, paint);
|
||||
}
|
||||
|
||||
// Draw checkmark for selected items in multiple selection mode
|
||||
if (isSelected && _selectionMode == SkiaSelectionMode.Multiple)
|
||||
{
|
||||
DrawCheckmark(canvas, new SKRect(actualBounds.Right - 32, actualBounds.MidY - 8, actualBounds.Right - 16, actualBounds.MidY + 8));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SkiaCollectionView.DrawItem] EXCEPTION: {ex.Message}\n{ex.StackTrace}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Use custom renderer if provided
|
||||
if (ItemRenderer != null)
|
||||
{
|
||||
@@ -286,7 +358,7 @@ public class SkiaCollectionView : SkiaItemsView
|
||||
return;
|
||||
}
|
||||
|
||||
// Default rendering
|
||||
// Default rendering - fall back to ToString
|
||||
paint.Color = SKColors.Black;
|
||||
paint.Style = SKPaintStyle.Fill;
|
||||
|
||||
@@ -333,7 +405,10 @@ public class SkiaCollectionView : SkiaItemsView
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Draw background
|
||||
// Reset the heights-changed flag at the start of each draw
|
||||
_heightsChangedDuringDraw = false;
|
||||
|
||||
// Draw background if set
|
||||
if (BackgroundColor != SKColors.Transparent)
|
||||
{
|
||||
using var bgPaint = new SKPaint
|
||||
@@ -381,40 +456,67 @@ public class SkiaCollectionView : SkiaItemsView
|
||||
{
|
||||
DrawListItems(canvas, contentBounds);
|
||||
}
|
||||
|
||||
// If heights changed during this draw, schedule a redraw with correct positions
|
||||
// This will queue another frame to be drawn with the correct cached heights
|
||||
if (_heightsChangedDuringDraw)
|
||||
{
|
||||
_heightsChangedDuringDraw = false;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawListItems(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Standard list drawing (delegate to base implementation via manual drawing)
|
||||
// Standard list drawing with variable item heights
|
||||
canvas.Save();
|
||||
canvas.ClipRect(bounds);
|
||||
|
||||
using var paint = new SKPaint { IsAntialias = true };
|
||||
|
||||
var scrollOffset = GetScrollOffset();
|
||||
var firstVisible = Math.Max(0, (int)(scrollOffset / (ItemHeight + ItemSpacing)));
|
||||
var lastVisible = Math.Min(ItemCount - 1,
|
||||
(int)((scrollOffset + bounds.Height) / (ItemHeight + ItemSpacing)) + 1);
|
||||
|
||||
for (int i = firstVisible; i <= lastVisible; i++)
|
||||
// Find first visible item by walking through items
|
||||
int firstVisible = 0;
|
||||
float cumulativeOffset = 0;
|
||||
for (int i = 0; i < ItemCount; i++)
|
||||
{
|
||||
var itemY = bounds.Top + (i * (ItemHeight + ItemSpacing)) - scrollOffset;
|
||||
var itemRect = new SKRect(bounds.Left, itemY, bounds.Right - 8, itemY + ItemHeight);
|
||||
|
||||
if (itemRect.Bottom < bounds.Top || itemRect.Top > bounds.Bottom)
|
||||
continue;
|
||||
|
||||
var item = GetItemAt(i);
|
||||
if (item != null)
|
||||
var itemH = GetItemHeight(i);
|
||||
if (cumulativeOffset + itemH > scrollOffset)
|
||||
{
|
||||
DrawItem(canvas, item, i, itemRect, paint);
|
||||
firstVisible = i;
|
||||
break;
|
||||
}
|
||||
cumulativeOffset += itemH + ItemSpacing;
|
||||
}
|
||||
|
||||
// Draw visible items using variable heights
|
||||
float currentY = bounds.Top + GetItemOffset(firstVisible) - scrollOffset;
|
||||
for (int i = firstVisible; i < ItemCount; i++)
|
||||
{
|
||||
var itemH = GetItemHeight(i);
|
||||
var itemRect = new SKRect(bounds.Left, currentY, bounds.Right - 8, currentY + itemH);
|
||||
|
||||
// Stop if we've passed the visible area
|
||||
if (itemRect.Top > bounds.Bottom)
|
||||
break;
|
||||
|
||||
if (itemRect.Bottom >= bounds.Top)
|
||||
{
|
||||
var item = GetItemAt(i);
|
||||
if (item != null)
|
||||
{
|
||||
DrawItem(canvas, item, i, itemRect, paint);
|
||||
}
|
||||
}
|
||||
|
||||
currentY += itemH + ItemSpacing;
|
||||
}
|
||||
|
||||
canvas.Restore();
|
||||
|
||||
// Draw scrollbar
|
||||
var totalHeight = ItemCount * (ItemHeight + ItemSpacing) - ItemSpacing;
|
||||
var totalHeight = TotalContentHeight;
|
||||
if (totalHeight > bounds.Height)
|
||||
{
|
||||
DrawScrollBarInternal(canvas, bounds, scrollOffset, totalHeight);
|
||||
@@ -480,35 +582,41 @@ public class SkiaCollectionView : SkiaItemsView
|
||||
|
||||
private void DrawScrollBarInternal(SKCanvas canvas, SKRect bounds, float scrollOffset, float totalHeight)
|
||||
{
|
||||
var scrollBarWidth = 8f;
|
||||
var scrollBarWidth = 6f;
|
||||
var scrollBarMargin = 2f;
|
||||
|
||||
// Draw scrollbar track (subtle)
|
||||
var trackRect = new SKRect(
|
||||
bounds.Right - scrollBarWidth,
|
||||
bounds.Top,
|
||||
bounds.Right,
|
||||
bounds.Bottom);
|
||||
bounds.Right - scrollBarWidth - scrollBarMargin,
|
||||
bounds.Top + scrollBarMargin,
|
||||
bounds.Right - scrollBarMargin,
|
||||
bounds.Bottom - scrollBarMargin);
|
||||
|
||||
using var trackPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(200, 200, 200, 64),
|
||||
Color = new SKColor(0, 0, 0, 20),
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
canvas.DrawRect(trackRect, trackPaint);
|
||||
canvas.DrawRoundRect(new SKRoundRect(trackRect, 3), trackPaint);
|
||||
|
||||
// Calculate thumb position and size
|
||||
var maxOffset = Math.Max(0, totalHeight - bounds.Height);
|
||||
var viewportRatio = bounds.Height / totalHeight;
|
||||
var thumbHeight = Math.Max(20, bounds.Height * viewportRatio);
|
||||
var availableTrackHeight = trackRect.Height;
|
||||
var thumbHeight = Math.Max(30, availableTrackHeight * viewportRatio);
|
||||
var scrollRatio = maxOffset > 0 ? scrollOffset / maxOffset : 0;
|
||||
var thumbY = bounds.Top + (bounds.Height - thumbHeight) * scrollRatio;
|
||||
var thumbY = trackRect.Top + (availableTrackHeight - thumbHeight) * scrollRatio;
|
||||
|
||||
var thumbRect = new SKRect(
|
||||
bounds.Right - scrollBarWidth + 1,
|
||||
trackRect.Left,
|
||||
thumbY,
|
||||
bounds.Right - 1,
|
||||
trackRect.Right,
|
||||
thumbY + thumbHeight);
|
||||
|
||||
// Draw thumb with more visible color
|
||||
using var thumbPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(128, 128, 128, 128),
|
||||
Color = new SKColor(100, 100, 100, 180),
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
257
Views/SkiaContentPresenter.cs
Normal file
257
Views/SkiaContentPresenter.cs
Normal file
@@ -0,0 +1,257 @@
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Presents content within a ControlTemplate.
|
||||
/// This control acts as a placeholder that gets replaced with the actual content
|
||||
/// when the template is applied to a control.
|
||||
/// </summary>
|
||||
public class SkiaContentPresenter : SkiaView
|
||||
{
|
||||
#region BindableProperties
|
||||
|
||||
public static readonly BindableProperty ContentProperty =
|
||||
BindableProperty.Create(nameof(Content), typeof(SkiaView), typeof(SkiaContentPresenter), null,
|
||||
propertyChanged: (b, o, n) => ((SkiaContentPresenter)b).OnContentChanged((SkiaView?)o, (SkiaView?)n));
|
||||
|
||||
public static readonly BindableProperty HorizontalContentAlignmentProperty =
|
||||
BindableProperty.Create(nameof(HorizontalContentAlignment), typeof(LayoutAlignment), typeof(SkiaContentPresenter), LayoutAlignment.Fill,
|
||||
propertyChanged: (b, o, n) => ((SkiaContentPresenter)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty VerticalContentAlignmentProperty =
|
||||
BindableProperty.Create(nameof(VerticalContentAlignment), typeof(LayoutAlignment), typeof(SkiaContentPresenter), LayoutAlignment.Fill,
|
||||
propertyChanged: (b, o, n) => ((SkiaContentPresenter)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty PaddingProperty =
|
||||
BindableProperty.Create(nameof(Padding), typeof(SKRect), typeof(SkiaContentPresenter), SKRect.Empty,
|
||||
propertyChanged: (b, o, n) => ((SkiaContentPresenter)b).InvalidateMeasure());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the content to present.
|
||||
/// </summary>
|
||||
public SkiaView? Content
|
||||
{
|
||||
get => (SkiaView?)GetValue(ContentProperty);
|
||||
set => SetValue(ContentProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal alignment of the content.
|
||||
/// </summary>
|
||||
public LayoutAlignment HorizontalContentAlignment
|
||||
{
|
||||
get => (LayoutAlignment)GetValue(HorizontalContentAlignmentProperty);
|
||||
set => SetValue(HorizontalContentAlignmentProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical alignment of the content.
|
||||
/// </summary>
|
||||
public LayoutAlignment VerticalContentAlignment
|
||||
{
|
||||
get => (LayoutAlignment)GetValue(VerticalContentAlignmentProperty);
|
||||
set => SetValue(VerticalContentAlignmentProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the padding around the content.
|
||||
/// </summary>
|
||||
public SKRect Padding
|
||||
{
|
||||
get => (SKRect)GetValue(PaddingProperty);
|
||||
set => SetValue(PaddingProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private void OnContentChanged(SkiaView? oldContent, SkiaView? newContent)
|
||||
{
|
||||
if (oldContent != null)
|
||||
{
|
||||
oldContent.Parent = null;
|
||||
}
|
||||
|
||||
if (newContent != null)
|
||||
{
|
||||
newContent.Parent = this;
|
||||
|
||||
// Propagate binding context to new content
|
||||
if (BindingContext != null)
|
||||
{
|
||||
SetInheritedBindingContext(newContent, BindingContext);
|
||||
}
|
||||
}
|
||||
|
||||
InvalidateMeasure();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when binding context changes. Propagates to content.
|
||||
/// </summary>
|
||||
protected override void OnBindingContextChanged()
|
||||
{
|
||||
base.OnBindingContextChanged();
|
||||
|
||||
// Propagate binding context to content
|
||||
if (Content != null)
|
||||
{
|
||||
SetInheritedBindingContext(Content, BindingContext);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Draw background if set
|
||||
if (BackgroundColor != SKColors.Transparent)
|
||||
{
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = BackgroundColor,
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
canvas.DrawRect(bounds, bgPaint);
|
||||
}
|
||||
|
||||
// Draw content
|
||||
Content?.Draw(canvas);
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
var padding = Padding;
|
||||
|
||||
if (Content == null)
|
||||
return new SKSize(padding.Left + padding.Right, padding.Top + padding.Bottom);
|
||||
|
||||
// When alignment is not Fill, give content unlimited size in that dimension
|
||||
// so it can measure its natural size without truncation
|
||||
var measureWidth = HorizontalContentAlignment == LayoutAlignment.Fill
|
||||
? Math.Max(0, availableSize.Width - padding.Left - padding.Right)
|
||||
: float.PositiveInfinity;
|
||||
var measureHeight = VerticalContentAlignment == LayoutAlignment.Fill
|
||||
? Math.Max(0, availableSize.Height - padding.Top - padding.Bottom)
|
||||
: float.PositiveInfinity;
|
||||
|
||||
var contentSize = Content.Measure(new SKSize(measureWidth, measureHeight));
|
||||
return new SKSize(
|
||||
contentSize.Width + padding.Left + padding.Right,
|
||||
contentSize.Height + padding.Top + padding.Bottom);
|
||||
}
|
||||
|
||||
protected override SKRect ArrangeOverride(SKRect bounds)
|
||||
{
|
||||
if (Content != null)
|
||||
{
|
||||
var padding = Padding;
|
||||
var contentBounds = new SKRect(
|
||||
bounds.Left + padding.Left,
|
||||
bounds.Top + padding.Top,
|
||||
bounds.Right - padding.Right,
|
||||
bounds.Bottom - padding.Bottom);
|
||||
|
||||
// Apply alignment
|
||||
var contentSize = Content.DesiredSize;
|
||||
var arrangedBounds = ApplyAlignment(contentBounds, contentSize, HorizontalContentAlignment, VerticalContentAlignment);
|
||||
Content.Arrange(arrangedBounds);
|
||||
}
|
||||
|
||||
return bounds;
|
||||
}
|
||||
|
||||
private static SKRect ApplyAlignment(SKRect availableBounds, SKSize contentSize, LayoutAlignment horizontal, LayoutAlignment vertical)
|
||||
{
|
||||
float x = availableBounds.Left;
|
||||
float y = availableBounds.Top;
|
||||
float width = horizontal == LayoutAlignment.Fill ? availableBounds.Width : contentSize.Width;
|
||||
float height = vertical == LayoutAlignment.Fill ? availableBounds.Height : contentSize.Height;
|
||||
|
||||
// Horizontal alignment
|
||||
switch (horizontal)
|
||||
{
|
||||
case LayoutAlignment.Center:
|
||||
x = availableBounds.Left + (availableBounds.Width - width) / 2;
|
||||
break;
|
||||
case LayoutAlignment.End:
|
||||
x = availableBounds.Right - width;
|
||||
break;
|
||||
}
|
||||
|
||||
// Vertical alignment
|
||||
switch (vertical)
|
||||
{
|
||||
case LayoutAlignment.Center:
|
||||
y = availableBounds.Top + (availableBounds.Height - height) / 2;
|
||||
break;
|
||||
case LayoutAlignment.End:
|
||||
y = availableBounds.Bottom - height;
|
||||
break;
|
||||
}
|
||||
|
||||
return new SKRect(x, y, x + width, y + height);
|
||||
}
|
||||
|
||||
public override SkiaView? HitTest(float x, float y)
|
||||
{
|
||||
if (!IsVisible || !Bounds.Contains(x, y))
|
||||
return null;
|
||||
|
||||
// Check content first
|
||||
if (Content != null)
|
||||
{
|
||||
var hit = Content.HitTest(x, y);
|
||||
if (hit != null)
|
||||
return hit;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
Content?.OnPointerPressed(e);
|
||||
}
|
||||
|
||||
public override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
Content?.OnPointerMoved(e);
|
||||
}
|
||||
|
||||
public override void OnPointerReleased(PointerEventArgs e)
|
||||
{
|
||||
Content?.OnPointerReleased(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layout alignment options.
|
||||
/// </summary>
|
||||
public enum LayoutAlignment
|
||||
{
|
||||
/// <summary>
|
||||
/// Fill the available space.
|
||||
/// </summary>
|
||||
Fill,
|
||||
|
||||
/// <summary>
|
||||
/// Align to the start (left or top).
|
||||
/// </summary>
|
||||
Start,
|
||||
|
||||
/// <summary>
|
||||
/// Align to the center.
|
||||
/// </summary>
|
||||
Center,
|
||||
|
||||
/// <summary>
|
||||
/// Align to the end (right or bottom).
|
||||
/// </summary>
|
||||
End
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using SkiaSharp;
|
||||
using Microsoft.Maui.Platform.Linux;
|
||||
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
@@ -10,97 +11,234 @@ namespace Microsoft.Maui.Platform;
|
||||
/// </summary>
|
||||
public class SkiaDatePicker : SkiaView
|
||||
{
|
||||
private DateTime _date = DateTime.Today;
|
||||
private DateTime _minimumDate = new DateTime(1900, 1, 1);
|
||||
private DateTime _maximumDate = new DateTime(2100, 12, 31);
|
||||
private DateTime _displayMonth;
|
||||
private bool _isOpen;
|
||||
private string _format = "d";
|
||||
#region BindableProperties
|
||||
|
||||
// Styling
|
||||
public SKColor TextColor { get; set; } = SKColors.Black;
|
||||
public SKColor BorderColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public SKColor CalendarBackgroundColor { get; set; } = SKColors.White;
|
||||
public SKColor SelectedDayColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public SKColor TodayColor { get; set; } = new SKColor(0x21, 0x96, 0xF3, 0x40);
|
||||
public SKColor HeaderColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public SKColor DisabledDayColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public float FontSize { get; set; } = 14;
|
||||
public float CornerRadius { get; set; } = 4;
|
||||
public static readonly BindableProperty DateProperty =
|
||||
BindableProperty.Create(nameof(Date), typeof(DateTime), typeof(SkiaDatePicker), DateTime.Today, BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).OnDatePropertyChanged());
|
||||
|
||||
private const float CalendarWidth = 280;
|
||||
private const float CalendarHeight = 320;
|
||||
private const float DayCellSize = 36;
|
||||
private const float HeaderHeight = 48;
|
||||
public static readonly BindableProperty MinimumDateProperty =
|
||||
BindableProperty.Create(nameof(MinimumDate), typeof(DateTime), typeof(SkiaDatePicker), new DateTime(1900, 1, 1),
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty MaximumDateProperty =
|
||||
BindableProperty.Create(nameof(MaximumDate), typeof(DateTime), typeof(SkiaDatePicker), new DateTime(2100, 12, 31),
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty FormatProperty =
|
||||
BindableProperty.Create(nameof(Format), typeof(string), typeof(SkiaDatePicker), "d",
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty TextColorProperty =
|
||||
BindableProperty.Create(nameof(TextColor), typeof(SKColor), typeof(SkiaDatePicker), SKColors.Black,
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty BorderColorProperty =
|
||||
BindableProperty.Create(nameof(BorderColor), typeof(SKColor), typeof(SkiaDatePicker), new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty CalendarBackgroundColorProperty =
|
||||
BindableProperty.Create(nameof(CalendarBackgroundColor), typeof(SKColor), typeof(SkiaDatePicker), SKColors.White,
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty SelectedDayColorProperty =
|
||||
BindableProperty.Create(nameof(SelectedDayColor), typeof(SKColor), typeof(SkiaDatePicker), new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty TodayColorProperty =
|
||||
BindableProperty.Create(nameof(TodayColor), typeof(SKColor), typeof(SkiaDatePicker), new SKColor(0x21, 0x96, 0xF3, 0x40),
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty HeaderColorProperty =
|
||||
BindableProperty.Create(nameof(HeaderColor), typeof(SKColor), typeof(SkiaDatePicker), new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty DisabledDayColorProperty =
|
||||
BindableProperty.Create(nameof(DisabledDayColor), typeof(SKColor), typeof(SkiaDatePicker), new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty FontSizeProperty =
|
||||
BindableProperty.Create(nameof(FontSize), typeof(float), typeof(SkiaDatePicker), 14f,
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty CornerRadiusProperty =
|
||||
BindableProperty.Create(nameof(CornerRadius), typeof(float), typeof(SkiaDatePicker), 4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
public DateTime Date
|
||||
{
|
||||
get => _date;
|
||||
set
|
||||
{
|
||||
var clamped = ClampDate(value);
|
||||
if (_date != clamped)
|
||||
{
|
||||
_date = clamped;
|
||||
_displayMonth = new DateTime(_date.Year, _date.Month, 1);
|
||||
DateSelected?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (DateTime)GetValue(DateProperty);
|
||||
set => SetValue(DateProperty, ClampDate(value));
|
||||
}
|
||||
|
||||
public DateTime MinimumDate
|
||||
{
|
||||
get => _minimumDate;
|
||||
set { _minimumDate = value; Invalidate(); }
|
||||
get => (DateTime)GetValue(MinimumDateProperty);
|
||||
set => SetValue(MinimumDateProperty, value);
|
||||
}
|
||||
|
||||
public DateTime MaximumDate
|
||||
{
|
||||
get => _maximumDate;
|
||||
set { _maximumDate = value; Invalidate(); }
|
||||
get => (DateTime)GetValue(MaximumDateProperty);
|
||||
set => SetValue(MaximumDateProperty, value);
|
||||
}
|
||||
|
||||
public string Format
|
||||
{
|
||||
get => _format;
|
||||
set { _format = value; Invalidate(); }
|
||||
get => (string)GetValue(FormatProperty);
|
||||
set => SetValue(FormatProperty, value);
|
||||
}
|
||||
|
||||
public SKColor TextColor
|
||||
{
|
||||
get => (SKColor)GetValue(TextColorProperty);
|
||||
set => SetValue(TextColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor BorderColor
|
||||
{
|
||||
get => (SKColor)GetValue(BorderColorProperty);
|
||||
set => SetValue(BorderColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor CalendarBackgroundColor
|
||||
{
|
||||
get => (SKColor)GetValue(CalendarBackgroundColorProperty);
|
||||
set => SetValue(CalendarBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor SelectedDayColor
|
||||
{
|
||||
get => (SKColor)GetValue(SelectedDayColorProperty);
|
||||
set => SetValue(SelectedDayColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor TodayColor
|
||||
{
|
||||
get => (SKColor)GetValue(TodayColorProperty);
|
||||
set => SetValue(TodayColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor HeaderColor
|
||||
{
|
||||
get => (SKColor)GetValue(HeaderColorProperty);
|
||||
set => SetValue(HeaderColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor DisabledDayColor
|
||||
{
|
||||
get => (SKColor)GetValue(DisabledDayColorProperty);
|
||||
set => SetValue(DisabledDayColorProperty, value);
|
||||
}
|
||||
|
||||
public float FontSize
|
||||
{
|
||||
get => (float)GetValue(FontSizeProperty);
|
||||
set => SetValue(FontSizeProperty, value);
|
||||
}
|
||||
|
||||
public float CornerRadius
|
||||
{
|
||||
get => (float)GetValue(CornerRadiusProperty);
|
||||
set => SetValue(CornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
public bool IsOpen
|
||||
{
|
||||
get => _isOpen;
|
||||
set { _isOpen = value; Invalidate(); }
|
||||
set
|
||||
{
|
||||
if (_isOpen != value)
|
||||
{
|
||||
_isOpen = value;
|
||||
if (_isOpen)
|
||||
RegisterPopupOverlay(this, DrawCalendarOverlay);
|
||||
else
|
||||
UnregisterPopupOverlay(this);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private DateTime _displayMonth;
|
||||
private bool _isOpen;
|
||||
|
||||
private const float CalendarWidth = 280;
|
||||
private const float CalendarHeight = 320;
|
||||
private const float HeaderHeight = 48;
|
||||
|
||||
public event EventHandler? DateSelected;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the calendar popup rectangle with edge detection applied.
|
||||
/// </summary>
|
||||
private SKRect GetCalendarRect(SKRect pickerBounds)
|
||||
{
|
||||
// Get window dimensions for edge detection
|
||||
var windowWidth = LinuxApplication.Current?.MainWindow?.Width ?? 800;
|
||||
var windowHeight = LinuxApplication.Current?.MainWindow?.Height ?? 600;
|
||||
|
||||
// Calculate default position (below the picker)
|
||||
var calendarLeft = pickerBounds.Left;
|
||||
var calendarTop = pickerBounds.Bottom + 4;
|
||||
|
||||
// Edge detection: adjust horizontal position if popup would go off-screen
|
||||
if (calendarLeft + CalendarWidth > windowWidth)
|
||||
{
|
||||
calendarLeft = windowWidth - CalendarWidth - 4;
|
||||
}
|
||||
if (calendarLeft < 0) calendarLeft = 4;
|
||||
|
||||
// Edge detection: show above if popup would go off-screen vertically
|
||||
if (calendarTop + CalendarHeight > windowHeight)
|
||||
{
|
||||
calendarTop = pickerBounds.Top - CalendarHeight - 4;
|
||||
}
|
||||
if (calendarTop < 0) calendarTop = 4;
|
||||
|
||||
return new SKRect(calendarLeft, calendarTop, calendarLeft + CalendarWidth, calendarTop + CalendarHeight);
|
||||
}
|
||||
|
||||
public SkiaDatePicker()
|
||||
{
|
||||
IsFocusable = true;
|
||||
_displayMonth = new DateTime(_date.Year, _date.Month, 1);
|
||||
_displayMonth = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
|
||||
}
|
||||
|
||||
private void OnDatePropertyChanged()
|
||||
{
|
||||
_displayMonth = new DateTime(Date.Year, Date.Month, 1);
|
||||
DateSelected?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private DateTime ClampDate(DateTime date)
|
||||
{
|
||||
if (date < _minimumDate) return _minimumDate;
|
||||
if (date > _maximumDate) return _maximumDate;
|
||||
if (date < MinimumDate) return MinimumDate;
|
||||
if (date > MaximumDate) return MaximumDate;
|
||||
return date;
|
||||
}
|
||||
|
||||
private void DrawCalendarOverlay(SKCanvas canvas)
|
||||
{
|
||||
if (!_isOpen) return;
|
||||
// Use ScreenBounds for popup drawing (accounts for scroll offset)
|
||||
DrawCalendar(canvas, ScreenBounds);
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
DrawPickerButton(canvas, bounds);
|
||||
|
||||
if (_isOpen)
|
||||
{
|
||||
DrawCalendar(canvas, bounds);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawPickerButton(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Draw background
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = IsEnabled ? BackgroundColor : new SKColor(0xF5, 0xF5, 0xF5),
|
||||
@@ -109,7 +247,6 @@ public class SkiaDatePicker : SkiaView
|
||||
};
|
||||
canvas.DrawRoundRect(new SKRoundRect(bounds, CornerRadius), bgPaint);
|
||||
|
||||
// Draw border
|
||||
using var borderPaint = new SKPaint
|
||||
{
|
||||
Color = IsFocused ? SelectedDayColor : BorderColor,
|
||||
@@ -119,7 +256,6 @@ public class SkiaDatePicker : SkiaView
|
||||
};
|
||||
canvas.DrawRoundRect(new SKRoundRect(bounds, CornerRadius), borderPaint);
|
||||
|
||||
// Draw date text
|
||||
using var font = new SKFont(SKTypeface.Default, FontSize);
|
||||
using var textPaint = new SKPaint(font)
|
||||
{
|
||||
@@ -127,15 +263,11 @@ public class SkiaDatePicker : SkiaView
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
var dateText = _date.ToString(_format);
|
||||
var dateText = Date.ToString(Format);
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(dateText, ref textBounds);
|
||||
canvas.DrawText(dateText, bounds.Left + 12, bounds.MidY - textBounds.MidY, textPaint);
|
||||
|
||||
var textX = bounds.Left + 12;
|
||||
var textY = bounds.MidY - textBounds.MidY;
|
||||
canvas.DrawText(dateText, textX, textY, textPaint);
|
||||
|
||||
// Draw calendar icon
|
||||
DrawCalendarIcon(canvas, new SKRect(bounds.Right - 36, bounds.MidY - 10, bounds.Right - 12, bounds.MidY + 10));
|
||||
}
|
||||
|
||||
@@ -149,40 +281,22 @@ public class SkiaDatePicker : SkiaView
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
// Calendar outline
|
||||
var calRect = new SKRect(bounds.Left, bounds.Top + 3, bounds.Right, bounds.Bottom);
|
||||
canvas.DrawRoundRect(new SKRoundRect(calRect, 2), paint);
|
||||
|
||||
// Top tabs
|
||||
canvas.DrawLine(bounds.Left + 5, bounds.Top, bounds.Left + 5, bounds.Top + 5, paint);
|
||||
canvas.DrawLine(bounds.Right - 5, bounds.Top, bounds.Right - 5, bounds.Top + 5, paint);
|
||||
|
||||
// Header line
|
||||
canvas.DrawLine(bounds.Left, bounds.Top + 8, bounds.Right, bounds.Top + 8, paint);
|
||||
|
||||
// Dots for days
|
||||
paint.Style = SKPaintStyle.Fill;
|
||||
paint.StrokeWidth = 0;
|
||||
for (int row = 0; row < 2; row++)
|
||||
{
|
||||
for (int col = 0; col < 3; col++)
|
||||
{
|
||||
var dotX = bounds.Left + 4 + col * 6;
|
||||
var dotY = bounds.Top + 12 + row * 4;
|
||||
canvas.DrawCircle(dotX, dotY, 1, paint);
|
||||
}
|
||||
}
|
||||
canvas.DrawCircle(bounds.Left + 4 + col * 6, bounds.Top + 12 + row * 4, 1, paint);
|
||||
}
|
||||
|
||||
private void DrawCalendar(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
var calendarRect = new SKRect(
|
||||
bounds.Left,
|
||||
bounds.Bottom + 4,
|
||||
bounds.Left + CalendarWidth,
|
||||
bounds.Bottom + 4 + CalendarHeight);
|
||||
var calendarRect = GetCalendarRect(bounds);
|
||||
|
||||
// Draw shadow
|
||||
using var shadowPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(0, 0, 0, 40),
|
||||
@@ -191,88 +305,44 @@ public class SkiaDatePicker : SkiaView
|
||||
};
|
||||
canvas.DrawRoundRect(new SKRoundRect(new SKRect(calendarRect.Left + 2, calendarRect.Top + 2, calendarRect.Right + 2, calendarRect.Bottom + 2), CornerRadius), shadowPaint);
|
||||
|
||||
// Draw background
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = CalendarBackgroundColor,
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
using var bgPaint = new SKPaint { Color = CalendarBackgroundColor, Style = SKPaintStyle.Fill, IsAntialias = true };
|
||||
canvas.DrawRoundRect(new SKRoundRect(calendarRect, CornerRadius), bgPaint);
|
||||
|
||||
// Draw border
|
||||
using var borderPaint = new SKPaint
|
||||
{
|
||||
Color = BorderColor,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 1,
|
||||
IsAntialias = true
|
||||
};
|
||||
using var borderPaint = new SKPaint { Color = BorderColor, Style = SKPaintStyle.Stroke, StrokeWidth = 1, IsAntialias = true };
|
||||
canvas.DrawRoundRect(new SKRoundRect(calendarRect, CornerRadius), borderPaint);
|
||||
|
||||
// Draw header
|
||||
DrawCalendarHeader(canvas, new SKRect(calendarRect.Left, calendarRect.Top, calendarRect.Right, calendarRect.Top + HeaderHeight));
|
||||
|
||||
// Draw weekday headers
|
||||
DrawWeekdayHeaders(canvas, new SKRect(calendarRect.Left, calendarRect.Top + HeaderHeight, calendarRect.Right, calendarRect.Top + HeaderHeight + 30));
|
||||
|
||||
// Draw days
|
||||
DrawDays(canvas, new SKRect(calendarRect.Left, calendarRect.Top + HeaderHeight + 30, calendarRect.Right, calendarRect.Bottom));
|
||||
}
|
||||
|
||||
private void DrawCalendarHeader(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Draw header background
|
||||
using var headerPaint = new SKPaint
|
||||
{
|
||||
Color = HeaderColor,
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
|
||||
var headerRect = new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Bottom);
|
||||
using var headerPaint = new SKPaint { Color = HeaderColor, Style = SKPaintStyle.Fill };
|
||||
canvas.Save();
|
||||
canvas.ClipRoundRect(new SKRoundRect(new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Top + CornerRadius * 2), CornerRadius));
|
||||
canvas.DrawRect(headerRect, headerPaint);
|
||||
canvas.DrawRect(bounds, headerPaint);
|
||||
canvas.Restore();
|
||||
canvas.DrawRect(new SKRect(bounds.Left, bounds.Top + CornerRadius, bounds.Right, bounds.Bottom), headerPaint);
|
||||
|
||||
// Draw month/year text
|
||||
using var font = new SKFont(SKTypeface.Default, 16);
|
||||
using var textPaint = new SKPaint(font)
|
||||
{
|
||||
Color = SKColors.White,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
using var textPaint = new SKPaint(font) { Color = SKColors.White, IsAntialias = true };
|
||||
var monthYear = _displayMonth.ToString("MMMM yyyy");
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(monthYear, ref textBounds);
|
||||
canvas.DrawText(monthYear, bounds.MidX - textBounds.MidX, bounds.MidY - textBounds.MidY, textPaint);
|
||||
|
||||
// Draw navigation arrows
|
||||
using var arrowPaint = new SKPaint
|
||||
{
|
||||
Color = SKColors.White,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 2,
|
||||
IsAntialias = true,
|
||||
StrokeCap = SKStrokeCap.Round
|
||||
};
|
||||
|
||||
// Left arrow
|
||||
var leftArrowX = bounds.Left + 20;
|
||||
using var arrowPaint = new SKPaint { Color = SKColors.White, Style = SKPaintStyle.Stroke, StrokeWidth = 2, IsAntialias = true, StrokeCap = SKStrokeCap.Round };
|
||||
using var leftPath = new SKPath();
|
||||
leftPath.MoveTo(leftArrowX + 6, bounds.MidY - 6);
|
||||
leftPath.LineTo(leftArrowX, bounds.MidY);
|
||||
leftPath.LineTo(leftArrowX + 6, bounds.MidY + 6);
|
||||
leftPath.MoveTo(bounds.Left + 26, bounds.MidY - 6);
|
||||
leftPath.LineTo(bounds.Left + 20, bounds.MidY);
|
||||
leftPath.LineTo(bounds.Left + 26, bounds.MidY + 6);
|
||||
canvas.DrawPath(leftPath, arrowPaint);
|
||||
|
||||
// Right arrow
|
||||
var rightArrowX = bounds.Right - 20;
|
||||
using var rightPath = new SKPath();
|
||||
rightPath.MoveTo(rightArrowX - 6, bounds.MidY - 6);
|
||||
rightPath.LineTo(rightArrowX, bounds.MidY);
|
||||
rightPath.LineTo(rightArrowX - 6, bounds.MidY + 6);
|
||||
rightPath.MoveTo(bounds.Right - 26, bounds.MidY - 6);
|
||||
rightPath.LineTo(bounds.Right - 20, bounds.MidY);
|
||||
rightPath.LineTo(bounds.Right - 26, bounds.MidY + 6);
|
||||
canvas.DrawPath(rightPath, arrowPaint);
|
||||
}
|
||||
|
||||
@@ -280,21 +350,13 @@ public class SkiaDatePicker : SkiaView
|
||||
{
|
||||
var dayNames = new[] { "Su", "Mo", "Tu", "We", "Th", "Fr", "Sa" };
|
||||
var cellWidth = bounds.Width / 7;
|
||||
|
||||
using var font = new SKFont(SKTypeface.Default, 12);
|
||||
using var paint = new SKPaint(font)
|
||||
{
|
||||
Color = new SKColor(0x80, 0x80, 0x80),
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
using var paint = new SKPaint(font) { Color = new SKColor(0x80, 0x80, 0x80), IsAntialias = true };
|
||||
for (int i = 0; i < 7; i++)
|
||||
{
|
||||
var textBounds = new SKRect();
|
||||
paint.MeasureText(dayNames[i], ref textBounds);
|
||||
var x = bounds.Left + i * cellWidth + cellWidth / 2 - textBounds.MidX;
|
||||
var y = bounds.MidY - textBounds.MidY;
|
||||
canvas.DrawText(dayNames[i], x, y, paint);
|
||||
canvas.DrawText(dayNames[i], bounds.Left + i * cellWidth + cellWidth / 2 - textBounds.MidX, bounds.MidY - textBounds.MidY, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,14 +365,11 @@ public class SkiaDatePicker : SkiaView
|
||||
var firstDay = new DateTime(_displayMonth.Year, _displayMonth.Month, 1);
|
||||
var daysInMonth = DateTime.DaysInMonth(_displayMonth.Year, _displayMonth.Month);
|
||||
var startDayOfWeek = (int)firstDay.DayOfWeek;
|
||||
|
||||
var cellWidth = bounds.Width / 7;
|
||||
var cellHeight = (bounds.Height - 10) / 6;
|
||||
|
||||
using var font = new SKFont(SKTypeface.Default, 14);
|
||||
using var textPaint = new SKPaint(font) { IsAntialias = true };
|
||||
using var bgPaint = new SKPaint { Style = SKPaintStyle.Fill, IsAntialias = true };
|
||||
|
||||
var today = DateTime.Today;
|
||||
|
||||
for (int day = 1; day <= daysInMonth; day++)
|
||||
@@ -319,16 +378,12 @@ public class SkiaDatePicker : SkiaView
|
||||
var cellIndex = startDayOfWeek + day - 1;
|
||||
var row = cellIndex / 7;
|
||||
var col = cellIndex % 7;
|
||||
var cellRect = new SKRect(bounds.Left + col * cellWidth + 2, bounds.Top + row * cellHeight + 2, bounds.Left + (col + 1) * cellWidth - 2, bounds.Top + (row + 1) * cellHeight - 2);
|
||||
|
||||
var cellX = bounds.Left + col * cellWidth;
|
||||
var cellY = bounds.Top + row * cellHeight;
|
||||
var cellRect = new SKRect(cellX + 2, cellY + 2, cellX + cellWidth - 2, cellY + cellHeight - 2);
|
||||
|
||||
var isSelected = dayDate.Date == _date.Date;
|
||||
var isSelected = dayDate.Date == Date.Date;
|
||||
var isToday = dayDate.Date == today;
|
||||
var isDisabled = dayDate < _minimumDate || dayDate > _maximumDate;
|
||||
var isDisabled = dayDate < MinimumDate || dayDate > MaximumDate;
|
||||
|
||||
// Draw day background
|
||||
if (isSelected)
|
||||
{
|
||||
bgPaint.Color = SelectedDayColor;
|
||||
@@ -340,7 +395,6 @@ public class SkiaDatePicker : SkiaView
|
||||
canvas.DrawCircle(cellRect.MidX, cellRect.MidY, Math.Min(cellRect.Width, cellRect.Height) / 2 - 2, bgPaint);
|
||||
}
|
||||
|
||||
// Draw day text
|
||||
textPaint.Color = isSelected ? SKColors.White : isDisabled ? DisabledDayColor : TextColor;
|
||||
var dayText = day.ToString();
|
||||
var textBounds = new SKRect();
|
||||
@@ -353,115 +407,104 @@ public class SkiaDatePicker : SkiaView
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
|
||||
if (_isOpen)
|
||||
if (IsOpen)
|
||||
{
|
||||
var calendarTop = Bounds.Bottom + 4;
|
||||
// Use ScreenBounds for popup coordinate calculations (accounts for scroll offset)
|
||||
var screenBounds = ScreenBounds;
|
||||
var calendarRect = GetCalendarRect(screenBounds);
|
||||
|
||||
// Check header navigation
|
||||
if (e.Y >= calendarTop && e.Y < calendarTop + HeaderHeight)
|
||||
// Check if click is in header area (navigation arrows)
|
||||
var headerRect = new SKRect(calendarRect.Left, calendarRect.Top, calendarRect.Right, calendarRect.Top + HeaderHeight);
|
||||
if (headerRect.Contains(e.X, e.Y))
|
||||
{
|
||||
if (e.X < Bounds.Left + 40)
|
||||
{
|
||||
// Previous month
|
||||
_displayMonth = _displayMonth.AddMonths(-1);
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
else if (e.X > Bounds.Left + CalendarWidth - 40)
|
||||
{
|
||||
// Next month
|
||||
_displayMonth = _displayMonth.AddMonths(1);
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
if (e.X < calendarRect.Left + 40) { _displayMonth = _displayMonth.AddMonths(-1); Invalidate(); return; }
|
||||
if (e.X > calendarRect.Right - 40) { _displayMonth = _displayMonth.AddMonths(1); Invalidate(); return; }
|
||||
return;
|
||||
}
|
||||
|
||||
// Check day selection
|
||||
var daysTop = calendarTop + HeaderHeight + 30;
|
||||
if (e.Y >= daysTop && e.Y < calendarTop + CalendarHeight)
|
||||
// Check if click is in days area
|
||||
var daysTop = calendarRect.Top + HeaderHeight + 30;
|
||||
var daysRect = new SKRect(calendarRect.Left, daysTop, calendarRect.Right, calendarRect.Bottom);
|
||||
if (daysRect.Contains(e.X, e.Y))
|
||||
{
|
||||
var cellWidth = CalendarWidth / 7;
|
||||
var cellHeight = (CalendarHeight - HeaderHeight - 40) / 6;
|
||||
|
||||
var col = (int)((e.X - Bounds.Left) / cellWidth);
|
||||
var col = (int)((e.X - calendarRect.Left) / cellWidth);
|
||||
var row = (int)((e.Y - daysTop) / cellHeight);
|
||||
|
||||
var firstDay = new DateTime(_displayMonth.Year, _displayMonth.Month, 1);
|
||||
var startDayOfWeek = (int)firstDay.DayOfWeek;
|
||||
var dayIndex = row * 7 + col - startDayOfWeek + 1;
|
||||
var dayIndex = row * 7 + col - (int)firstDay.DayOfWeek + 1;
|
||||
var daysInMonth = DateTime.DaysInMonth(_displayMonth.Year, _displayMonth.Month);
|
||||
|
||||
if (dayIndex >= 1 && dayIndex <= daysInMonth)
|
||||
{
|
||||
var selectedDate = new DateTime(_displayMonth.Year, _displayMonth.Month, dayIndex);
|
||||
if (selectedDate >= _minimumDate && selectedDate <= _maximumDate)
|
||||
if (selectedDate >= MinimumDate && selectedDate <= MaximumDate)
|
||||
{
|
||||
Date = selectedDate;
|
||||
_isOpen = false;
|
||||
IsOpen = false;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
else if (e.Y < calendarTop)
|
||||
{
|
||||
_isOpen = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_isOpen = true;
|
||||
}
|
||||
|
||||
// Click is outside calendar - check if it's on the picker itself
|
||||
if (screenBounds.Contains(e.X, e.Y))
|
||||
{
|
||||
IsOpen = false;
|
||||
}
|
||||
}
|
||||
else IsOpen = true;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Enter:
|
||||
case Key.Space:
|
||||
_isOpen = !_isOpen;
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Escape:
|
||||
if (_isOpen)
|
||||
{
|
||||
_isOpen = false;
|
||||
e.Handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.Left:
|
||||
Date = _date.AddDays(-1);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Right:
|
||||
Date = _date.AddDays(1);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Up:
|
||||
Date = _date.AddDays(-7);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Down:
|
||||
Date = _date.AddDays(7);
|
||||
e.Handled = true;
|
||||
break;
|
||||
case Key.Enter: case Key.Space: IsOpen = !IsOpen; e.Handled = true; break;
|
||||
case Key.Escape: if (IsOpen) { IsOpen = false; e.Handled = true; } break;
|
||||
case Key.Left: Date = Date.AddDays(-1); e.Handled = true; break;
|
||||
case Key.Right: Date = Date.AddDays(1); e.Handled = true; break;
|
||||
case Key.Up: Date = Date.AddDays(-7); e.Handled = true; break;
|
||||
case Key.Down: Date = Date.AddDays(7); e.Handled = true; break;
|
||||
}
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public override void OnFocusLost()
|
||||
{
|
||||
base.OnFocusLost();
|
||||
// Close popup when focus is lost (clicking outside)
|
||||
if (IsOpen)
|
||||
{
|
||||
IsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
return new SKSize(
|
||||
availableSize.Width < float.MaxValue ? Math.Min(availableSize.Width, 200) : 200,
|
||||
40);
|
||||
return new SKSize(availableSize.Width < float.MaxValue ? Math.Min(availableSize.Width, 200) : 200, 40);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override to include calendar popup area in hit testing.
|
||||
/// </summary>
|
||||
protected override bool HitTestPopupArea(float x, float y)
|
||||
{
|
||||
// Use ScreenBounds for hit testing (accounts for scroll offset)
|
||||
var screenBounds = ScreenBounds;
|
||||
|
||||
// Always include the picker button itself
|
||||
if (screenBounds.Contains(x, y))
|
||||
return true;
|
||||
|
||||
// When open, also include the calendar area (with edge detection)
|
||||
if (_isOpen)
|
||||
{
|
||||
var calendarRect = GetCalendarRect(screenBounds);
|
||||
return calendarRect.Contains(x, y);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,90 +6,354 @@ using SkiaSharp;
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered multiline text editor control.
|
||||
/// Skia-rendered multiline text editor control with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaEditor : SkiaView
|
||||
{
|
||||
private string _text = "";
|
||||
private string _placeholder = "";
|
||||
private int _cursorPosition;
|
||||
private int _selectionStart = -1;
|
||||
private int _selectionLength;
|
||||
private float _scrollOffsetY;
|
||||
private bool _isReadOnly;
|
||||
private int _maxLength = -1;
|
||||
private bool _cursorVisible = true;
|
||||
private DateTime _lastCursorBlink = DateTime.Now;
|
||||
#region BindableProperties
|
||||
|
||||
// Cached line information
|
||||
private List<string> _lines = new() { "" };
|
||||
private List<float> _lineHeights = new();
|
||||
/// <summary>
|
||||
/// Bindable property for Text.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TextProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Text),
|
||||
typeof(string),
|
||||
typeof(SkiaEditor),
|
||||
"",
|
||||
BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).OnTextPropertyChanged((string)o, (string)n));
|
||||
|
||||
// Styling
|
||||
public SKColor TextColor { get; set; } = SKColors.Black;
|
||||
public SKColor PlaceholderColor { get; set; } = new SKColor(0x80, 0x80, 0x80);
|
||||
public SKColor BorderColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public SKColor SelectionColor { get; set; } = new SKColor(0x21, 0x96, 0xF3, 0x60);
|
||||
public SKColor CursorColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public string FontFamily { get; set; } = "Sans";
|
||||
public float FontSize { get; set; } = 14;
|
||||
public float LineHeight { get; set; } = 1.4f;
|
||||
public float CornerRadius { get; set; } = 4;
|
||||
public float Padding { get; set; } = 12;
|
||||
public bool AutoSize { get; set; }
|
||||
/// <summary>
|
||||
/// Bindable property for Placeholder.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty PlaceholderProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Placeholder),
|
||||
typeof(string),
|
||||
typeof(SkiaEditor),
|
||||
"",
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for TextColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TextColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(TextColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaEditor),
|
||||
SKColors.Black,
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for PlaceholderColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty PlaceholderColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(PlaceholderColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaEditor),
|
||||
new SKColor(0x80, 0x80, 0x80),
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for BorderColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty BorderColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(BorderColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaEditor),
|
||||
new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for SelectionColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty SelectionColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(SelectionColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaEditor),
|
||||
new SKColor(0x21, 0x96, 0xF3, 0x60),
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CursorColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CursorColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CursorColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaEditor),
|
||||
new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for FontFamily.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty FontFamilyProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(FontFamily),
|
||||
typeof(string),
|
||||
typeof(SkiaEditor),
|
||||
"Sans",
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for FontSize.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty FontSizeProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(FontSize),
|
||||
typeof(float),
|
||||
typeof(SkiaEditor),
|
||||
14f,
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for LineHeight.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty LineHeightProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(LineHeight),
|
||||
typeof(float),
|
||||
typeof(SkiaEditor),
|
||||
1.4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CornerRadius.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CornerRadiusProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CornerRadius),
|
||||
typeof(float),
|
||||
typeof(SkiaEditor),
|
||||
4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Padding.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty PaddingProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Padding),
|
||||
typeof(float),
|
||||
typeof(SkiaEditor),
|
||||
12f,
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsReadOnly.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsReadOnlyProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsReadOnly),
|
||||
typeof(bool),
|
||||
typeof(SkiaEditor),
|
||||
false,
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for MaxLength.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty MaxLengthProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(MaxLength),
|
||||
typeof(int),
|
||||
typeof(SkiaEditor),
|
||||
-1);
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for AutoSize.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty AutoSizeProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(AutoSize),
|
||||
typeof(bool),
|
||||
typeof(SkiaEditor),
|
||||
false,
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text content.
|
||||
/// </summary>
|
||||
public string Text
|
||||
{
|
||||
get => _text;
|
||||
set
|
||||
{
|
||||
var newText = value ?? "";
|
||||
if (_maxLength > 0 && newText.Length > _maxLength)
|
||||
{
|
||||
newText = newText.Substring(0, _maxLength);
|
||||
}
|
||||
|
||||
if (_text != newText)
|
||||
{
|
||||
_text = newText;
|
||||
UpdateLines();
|
||||
_cursorPosition = Math.Min(_cursorPosition, _text.Length);
|
||||
TextChanged?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (string)GetValue(TextProperty);
|
||||
set => SetValue(TextProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the placeholder text.
|
||||
/// </summary>
|
||||
public string Placeholder
|
||||
{
|
||||
get => _placeholder;
|
||||
set { _placeholder = value ?? ""; Invalidate(); }
|
||||
get => (string)GetValue(PlaceholderProperty);
|
||||
set => SetValue(PlaceholderProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text color.
|
||||
/// </summary>
|
||||
public SKColor TextColor
|
||||
{
|
||||
get => (SKColor)GetValue(TextColorProperty);
|
||||
set => SetValue(TextColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the placeholder color.
|
||||
/// </summary>
|
||||
public SKColor PlaceholderColor
|
||||
{
|
||||
get => (SKColor)GetValue(PlaceholderColorProperty);
|
||||
set => SetValue(PlaceholderColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the border color.
|
||||
/// </summary>
|
||||
public SKColor BorderColor
|
||||
{
|
||||
get => (SKColor)GetValue(BorderColorProperty);
|
||||
set => SetValue(BorderColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the selection color.
|
||||
/// </summary>
|
||||
public SKColor SelectionColor
|
||||
{
|
||||
get => (SKColor)GetValue(SelectionColorProperty);
|
||||
set => SetValue(SelectionColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cursor color.
|
||||
/// </summary>
|
||||
public SKColor CursorColor
|
||||
{
|
||||
get => (SKColor)GetValue(CursorColorProperty);
|
||||
set => SetValue(CursorColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the font family.
|
||||
/// </summary>
|
||||
public string FontFamily
|
||||
{
|
||||
get => (string)GetValue(FontFamilyProperty);
|
||||
set => SetValue(FontFamilyProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the font size.
|
||||
/// </summary>
|
||||
public float FontSize
|
||||
{
|
||||
get => (float)GetValue(FontSizeProperty);
|
||||
set => SetValue(FontSizeProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the line height multiplier.
|
||||
/// </summary>
|
||||
public float LineHeight
|
||||
{
|
||||
get => (float)GetValue(LineHeightProperty);
|
||||
set => SetValue(LineHeightProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the corner radius.
|
||||
/// </summary>
|
||||
public float CornerRadius
|
||||
{
|
||||
get => (float)GetValue(CornerRadiusProperty);
|
||||
set => SetValue(CornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the padding.
|
||||
/// </summary>
|
||||
public float Padding
|
||||
{
|
||||
get => (float)GetValue(PaddingProperty);
|
||||
set => SetValue(PaddingProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the editor is read-only.
|
||||
/// </summary>
|
||||
public bool IsReadOnly
|
||||
{
|
||||
get => _isReadOnly;
|
||||
set { _isReadOnly = value; Invalidate(); }
|
||||
get => (bool)GetValue(IsReadOnlyProperty);
|
||||
set => SetValue(IsReadOnlyProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum length. -1 for unlimited.
|
||||
/// </summary>
|
||||
public int MaxLength
|
||||
{
|
||||
get => _maxLength;
|
||||
set { _maxLength = value; }
|
||||
get => (int)GetValue(MaxLengthProperty);
|
||||
set => SetValue(MaxLengthProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the editor auto-sizes to content.
|
||||
/// </summary>
|
||||
public bool AutoSize
|
||||
{
|
||||
get => (bool)GetValue(AutoSizeProperty);
|
||||
set => SetValue(AutoSizeProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cursor position.
|
||||
/// </summary>
|
||||
public int CursorPosition
|
||||
{
|
||||
get => _cursorPosition;
|
||||
set
|
||||
{
|
||||
_cursorPosition = Math.Clamp(value, 0, _text.Length);
|
||||
_cursorPosition = Math.Clamp(value, 0, Text.Length);
|
||||
EnsureCursorVisible();
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private int _cursorPosition;
|
||||
private int _selectionStart = -1;
|
||||
private int _selectionLength;
|
||||
private float _scrollOffsetY;
|
||||
private bool _cursorVisible = true;
|
||||
private DateTime _lastCursorBlink = DateTime.Now;
|
||||
private List<string> _lines = new() { "" };
|
||||
private float _wrapWidth = 0; // Available width for word wrapping
|
||||
private bool _isSelecting; // For mouse-based text selection
|
||||
private DateTime _lastClickTime = DateTime.MinValue;
|
||||
private float _lastClickX;
|
||||
private float _lastClickY;
|
||||
private const double DoubleClickThresholdMs = 400;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when text changes.
|
||||
/// </summary>
|
||||
public event EventHandler? TextChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when editing is completed.
|
||||
/// </summary>
|
||||
public event EventHandler? Completed;
|
||||
|
||||
public SkiaEditor()
|
||||
@@ -97,29 +361,92 @@ public class SkiaEditor : SkiaView
|
||||
IsFocusable = true;
|
||||
}
|
||||
|
||||
private void OnTextPropertyChanged(string oldText, string newText)
|
||||
{
|
||||
var text = newText ?? "";
|
||||
|
||||
if (MaxLength > 0 && text.Length > MaxLength)
|
||||
{
|
||||
text = text.Substring(0, MaxLength);
|
||||
SetValue(TextProperty, text);
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateLines();
|
||||
_cursorPosition = Math.Min(_cursorPosition, text.Length);
|
||||
_scrollOffsetY = 0; // Reset scroll when text changes externally
|
||||
_selectionLength = 0;
|
||||
TextChanged?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void UpdateLines()
|
||||
{
|
||||
_lines.Clear();
|
||||
if (string.IsNullOrEmpty(_text))
|
||||
var text = Text ?? "";
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
_lines.Add("");
|
||||
return;
|
||||
}
|
||||
|
||||
var currentLine = "";
|
||||
foreach (var ch in _text)
|
||||
using var font = new SKFont(SKTypeface.Default, FontSize);
|
||||
|
||||
// Split by actual newlines first
|
||||
var paragraphs = text.Split('\n');
|
||||
|
||||
foreach (var paragraph in paragraphs)
|
||||
{
|
||||
if (ch == '\n')
|
||||
if (string.IsNullOrEmpty(paragraph))
|
||||
{
|
||||
_lines.Add(currentLine);
|
||||
currentLine = "";
|
||||
_lines.Add("");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Word wrap this paragraph if we have a known width
|
||||
if (_wrapWidth > 0)
|
||||
{
|
||||
WrapParagraph(paragraph, font, _wrapWidth);
|
||||
}
|
||||
else
|
||||
{
|
||||
currentLine += ch;
|
||||
_lines.Add(paragraph);
|
||||
}
|
||||
}
|
||||
_lines.Add(currentLine);
|
||||
|
||||
if (_lines.Count == 0)
|
||||
{
|
||||
_lines.Add("");
|
||||
}
|
||||
}
|
||||
|
||||
private void WrapParagraph(string paragraph, SKFont font, float maxWidth)
|
||||
{
|
||||
var words = paragraph.Split(' ');
|
||||
var currentLine = "";
|
||||
|
||||
foreach (var word in words)
|
||||
{
|
||||
var testLine = string.IsNullOrEmpty(currentLine) ? word : currentLine + " " + word;
|
||||
var lineWidth = MeasureText(testLine, font);
|
||||
|
||||
if (lineWidth > maxWidth && !string.IsNullOrEmpty(currentLine))
|
||||
{
|
||||
// Line too long, save current and start new
|
||||
_lines.Add(currentLine);
|
||||
currentLine = word;
|
||||
}
|
||||
else
|
||||
{
|
||||
currentLine = testLine;
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (!string.IsNullOrEmpty(currentLine))
|
||||
{
|
||||
_lines.Add(currentLine);
|
||||
}
|
||||
}
|
||||
|
||||
private (int line, int column) GetLineColumn(int position)
|
||||
@@ -132,7 +459,7 @@ public class SkiaEditor : SkiaView
|
||||
{
|
||||
return (i, position - pos);
|
||||
}
|
||||
pos += lineLength + 1; // +1 for newline
|
||||
pos += lineLength + 1;
|
||||
}
|
||||
return (_lines.Count - 1, _lines[^1].Length);
|
||||
}
|
||||
@@ -148,11 +475,19 @@ public class SkiaEditor : SkiaView
|
||||
{
|
||||
pos += Math.Min(column, _lines[line].Length);
|
||||
}
|
||||
return Math.Min(pos, _text.Length);
|
||||
return Math.Min(pos, Text.Length);
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Update wrap width if bounds changed and re-wrap text
|
||||
var newWrapWidth = bounds.Width - Padding * 2;
|
||||
if (Math.Abs(newWrapWidth - _wrapWidth) > 1)
|
||||
{
|
||||
_wrapWidth = newWrapWidth;
|
||||
UpdateLines();
|
||||
}
|
||||
|
||||
// Handle cursor blinking
|
||||
if (IsFocused && (DateTime.Now - _lastCursorBlink).TotalMilliseconds > 500)
|
||||
{
|
||||
@@ -192,21 +527,20 @@ public class SkiaEditor : SkiaView
|
||||
|
||||
canvas.Save();
|
||||
canvas.ClipRect(contentRect);
|
||||
canvas.Translate(0, -_scrollOffsetY);
|
||||
// Don't translate - let the text draw at absolute positions
|
||||
// canvas.Translate(0, -_scrollOffsetY);
|
||||
|
||||
if (string.IsNullOrEmpty(_text) && !string.IsNullOrEmpty(_placeholder))
|
||||
if (string.IsNullOrEmpty(Text) && !string.IsNullOrEmpty(Placeholder))
|
||||
{
|
||||
// Draw placeholder
|
||||
using var placeholderPaint = new SKPaint(font)
|
||||
{
|
||||
Color = PlaceholderColor,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawText(_placeholder, contentRect.Left, contentRect.Top + FontSize, placeholderPaint);
|
||||
canvas.DrawText(Placeholder, contentRect.Left, contentRect.Top + FontSize, placeholderPaint);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Draw text with selection
|
||||
using var textPaint = new SKPaint(font)
|
||||
{
|
||||
Color = IsEnabled ? TextColor : TextColor.WithAlpha(128),
|
||||
@@ -227,15 +561,17 @@ public class SkiaEditor : SkiaView
|
||||
var x = contentRect.Left;
|
||||
|
||||
// Draw selection for this line if applicable
|
||||
if (_selectionStart >= 0 && _selectionLength > 0)
|
||||
if (_selectionStart >= 0 && _selectionLength != 0)
|
||||
{
|
||||
var selEnd = _selectionStart + _selectionLength;
|
||||
// Handle both positive and negative selection lengths
|
||||
var selStart = _selectionLength > 0 ? _selectionStart : _selectionStart + _selectionLength;
|
||||
var selEnd = _selectionLength > 0 ? _selectionStart + _selectionLength : _selectionStart;
|
||||
var lineStart = charIndex;
|
||||
var lineEnd = charIndex + line.Length;
|
||||
|
||||
if (selEnd > lineStart && _selectionStart < lineEnd)
|
||||
if (selEnd > lineStart && selStart < lineEnd)
|
||||
{
|
||||
var selStartInLine = Math.Max(0, _selectionStart - lineStart);
|
||||
var selStartInLine = Math.Max(0, selStart - lineStart);
|
||||
var selEndInLine = Math.Min(line.Length, selEnd - lineStart);
|
||||
|
||||
var startX = x + MeasureText(line.Substring(0, selStartInLine), font);
|
||||
@@ -245,7 +581,6 @@ public class SkiaEditor : SkiaView
|
||||
}
|
||||
}
|
||||
|
||||
// Draw line text
|
||||
canvas.DrawText(line, x, y, textPaint);
|
||||
|
||||
// Draw cursor if on this line
|
||||
@@ -267,7 +602,7 @@ public class SkiaEditor : SkiaView
|
||||
}
|
||||
|
||||
y += lineSpacing;
|
||||
charIndex += line.Length + 1; // +1 for newline
|
||||
charIndex += line.Length + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,12 +667,12 @@ public class SkiaEditor : SkiaView
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
|
||||
// Request focus by notifying parent
|
||||
IsFocused = true;
|
||||
|
||||
// Calculate cursor position from click
|
||||
var contentX = e.X - Bounds.Left - Padding;
|
||||
var contentY = e.Y - Bounds.Top - Padding + _scrollOffsetY;
|
||||
// Use screen coordinates for proper hit detection
|
||||
var screenBounds = ScreenBounds;
|
||||
var contentX = e.X - screenBounds.Left - Padding;
|
||||
var contentY = e.Y - screenBounds.Top - Padding + _scrollOffsetY;
|
||||
|
||||
var lineSpacing = FontSize * LineHeight;
|
||||
var clickedLine = Math.Clamp((int)(contentY / lineSpacing), 0, _lines.Count - 1);
|
||||
@@ -346,7 +681,6 @@ public class SkiaEditor : SkiaView
|
||||
var line = _lines[clickedLine];
|
||||
var clickedCol = 0;
|
||||
|
||||
// Find closest character position
|
||||
for (int i = 0; i <= line.Length; i++)
|
||||
{
|
||||
var charX = MeasureText(line.Substring(0, i), font);
|
||||
@@ -359,14 +693,79 @@ public class SkiaEditor : SkiaView
|
||||
}
|
||||
|
||||
_cursorPosition = GetPosition(clickedLine, clickedCol);
|
||||
_selectionStart = -1;
|
||||
_selectionLength = 0;
|
||||
|
||||
// Check for double-click (select word)
|
||||
var now = DateTime.UtcNow;
|
||||
var timeSinceLastClick = (now - _lastClickTime).TotalMilliseconds;
|
||||
var distanceFromLastClick = Math.Sqrt(Math.Pow(e.X - _lastClickX, 2) + Math.Pow(e.Y - _lastClickY, 2));
|
||||
|
||||
if (timeSinceLastClick < DoubleClickThresholdMs && distanceFromLastClick < 10)
|
||||
{
|
||||
// Double-click: select the word at cursor
|
||||
SelectWordAtCursor();
|
||||
_lastClickTime = DateTime.MinValue; // Reset to prevent triple-click issues
|
||||
_isSelecting = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single click: start selection
|
||||
_selectionStart = _cursorPosition;
|
||||
_selectionLength = 0;
|
||||
_isSelecting = true;
|
||||
_lastClickTime = now;
|
||||
_lastClickX = e.X;
|
||||
_lastClickY = e.Y;
|
||||
}
|
||||
|
||||
_cursorVisible = true;
|
||||
_lastCursorBlink = DateTime.Now;
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
if (!IsEnabled || !_isSelecting) return;
|
||||
|
||||
// Calculate position from mouse coordinates
|
||||
var screenBounds = ScreenBounds;
|
||||
var contentX = e.X - screenBounds.Left - Padding;
|
||||
var contentY = e.Y - screenBounds.Top - Padding + _scrollOffsetY;
|
||||
|
||||
var lineSpacing = FontSize * LineHeight;
|
||||
var clickedLine = Math.Clamp((int)(contentY / lineSpacing), 0, _lines.Count - 1);
|
||||
|
||||
using var font = new SKFont(SKTypeface.Default, FontSize);
|
||||
var line = _lines[clickedLine];
|
||||
var clickedCol = 0;
|
||||
|
||||
for (int i = 0; i <= line.Length; i++)
|
||||
{
|
||||
var charX = MeasureText(line.Substring(0, i), font);
|
||||
if (charX > contentX)
|
||||
{
|
||||
clickedCol = i > 0 ? i - 1 : 0;
|
||||
break;
|
||||
}
|
||||
clickedCol = i;
|
||||
}
|
||||
|
||||
var newPosition = GetPosition(clickedLine, clickedCol);
|
||||
if (newPosition != _cursorPosition)
|
||||
{
|
||||
_cursorPosition = newPosition;
|
||||
_selectionLength = _cursorPosition - _selectionStart;
|
||||
_cursorVisible = true;
|
||||
_lastCursorBlink = DateTime.Now;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnPointerReleased(PointerEventArgs e)
|
||||
{
|
||||
_isSelecting = false;
|
||||
}
|
||||
|
||||
public override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
@@ -387,7 +786,7 @@ public class SkiaEditor : SkiaView
|
||||
break;
|
||||
|
||||
case Key.Right:
|
||||
if (_cursorPosition < _text.Length)
|
||||
if (_cursorPosition < Text.Length)
|
||||
{
|
||||
_cursorPosition++;
|
||||
EnsureCursorVisible();
|
||||
@@ -426,7 +825,7 @@ public class SkiaEditor : SkiaView
|
||||
break;
|
||||
|
||||
case Key.Enter:
|
||||
if (!_isReadOnly)
|
||||
if (!IsReadOnly)
|
||||
{
|
||||
InsertText("\n");
|
||||
}
|
||||
@@ -434,30 +833,76 @@ public class SkiaEditor : SkiaView
|
||||
break;
|
||||
|
||||
case Key.Backspace:
|
||||
if (!_isReadOnly && _cursorPosition > 0)
|
||||
if (!IsReadOnly)
|
||||
{
|
||||
Text = _text.Remove(_cursorPosition - 1, 1);
|
||||
_cursorPosition--;
|
||||
if (_selectionLength != 0)
|
||||
{
|
||||
DeleteSelection();
|
||||
}
|
||||
else if (_cursorPosition > 0)
|
||||
{
|
||||
Text = Text.Remove(_cursorPosition - 1, 1);
|
||||
_cursorPosition--;
|
||||
}
|
||||
EnsureCursorVisible();
|
||||
}
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Delete:
|
||||
if (!_isReadOnly && _cursorPosition < _text.Length)
|
||||
if (!IsReadOnly)
|
||||
{
|
||||
Text = _text.Remove(_cursorPosition, 1);
|
||||
if (_selectionLength != 0)
|
||||
{
|
||||
DeleteSelection();
|
||||
}
|
||||
else if (_cursorPosition < Text.Length)
|
||||
{
|
||||
Text = Text.Remove(_cursorPosition, 1);
|
||||
}
|
||||
}
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Tab:
|
||||
if (!_isReadOnly)
|
||||
if (!IsReadOnly)
|
||||
{
|
||||
InsertText(" "); // 4 spaces for tab
|
||||
InsertText(" ");
|
||||
}
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.A:
|
||||
if (e.Modifiers.HasFlag(KeyModifiers.Control))
|
||||
{
|
||||
SelectAll();
|
||||
e.Handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.C:
|
||||
if (e.Modifiers.HasFlag(KeyModifiers.Control))
|
||||
{
|
||||
CopyToClipboard();
|
||||
e.Handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.V:
|
||||
if (e.Modifiers.HasFlag(KeyModifiers.Control) && !IsReadOnly)
|
||||
{
|
||||
PasteFromClipboard();
|
||||
e.Handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.X:
|
||||
if (e.Modifiers.HasFlag(KeyModifiers.Control) && !IsReadOnly)
|
||||
{
|
||||
CutToClipboard();
|
||||
e.Handled = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
Invalidate();
|
||||
@@ -465,7 +910,11 @@ public class SkiaEditor : SkiaView
|
||||
|
||||
public override void OnTextInput(TextInputEventArgs e)
|
||||
{
|
||||
if (!IsEnabled || _isReadOnly) return;
|
||||
if (!IsEnabled || IsReadOnly) return;
|
||||
|
||||
// Ignore control characters (Ctrl+key combinations send ASCII control codes)
|
||||
if (!string.IsNullOrEmpty(e.Text) && e.Text.Length == 1 && e.Text[0] < 32)
|
||||
return;
|
||||
|
||||
if (!string.IsNullOrEmpty(e.Text))
|
||||
{
|
||||
@@ -478,21 +927,21 @@ public class SkiaEditor : SkiaView
|
||||
{
|
||||
if (_selectionLength > 0)
|
||||
{
|
||||
// Replace selection
|
||||
_text = _text.Remove(_selectionStart, _selectionLength);
|
||||
var currentText = Text;
|
||||
Text = currentText.Remove(_selectionStart, _selectionLength);
|
||||
_cursorPosition = _selectionStart;
|
||||
_selectionStart = -1;
|
||||
_selectionLength = 0;
|
||||
}
|
||||
|
||||
if (_maxLength > 0 && _text.Length + text.Length > _maxLength)
|
||||
if (MaxLength > 0 && Text.Length + text.Length > MaxLength)
|
||||
{
|
||||
text = text.Substring(0, Math.Max(0, _maxLength - _text.Length));
|
||||
text = text.Substring(0, Math.Max(0, MaxLength - Text.Length));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
Text = _text.Insert(_cursorPosition, text);
|
||||
Text = Text.Insert(_cursorPosition, text);
|
||||
_cursorPosition += text.Length;
|
||||
EnsureCursorVisible();
|
||||
}
|
||||
@@ -509,6 +958,102 @@ public class SkiaEditor : SkiaView
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public override void OnFocusGained()
|
||||
{
|
||||
base.OnFocusGained();
|
||||
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Focused);
|
||||
}
|
||||
|
||||
public override void OnFocusLost()
|
||||
{
|
||||
base.OnFocusLost();
|
||||
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Normal);
|
||||
}
|
||||
|
||||
#region Selection and Clipboard
|
||||
|
||||
public void SelectAll()
|
||||
{
|
||||
_selectionStart = 0;
|
||||
_cursorPosition = Text.Length;
|
||||
_selectionLength = Text.Length;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void SelectWordAtCursor()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Text)) return;
|
||||
|
||||
// Find word boundaries
|
||||
int start = _cursorPosition;
|
||||
int end = _cursorPosition;
|
||||
|
||||
// Move start backwards to beginning of word
|
||||
while (start > 0 && IsWordChar(Text[start - 1]))
|
||||
start--;
|
||||
|
||||
// Move end forwards to end of word
|
||||
while (end < Text.Length && IsWordChar(Text[end]))
|
||||
end++;
|
||||
|
||||
_selectionStart = start;
|
||||
_cursorPosition = end;
|
||||
_selectionLength = end - start;
|
||||
}
|
||||
|
||||
private static bool IsWordChar(char c)
|
||||
{
|
||||
return char.IsLetterOrDigit(c) || c == '_';
|
||||
}
|
||||
|
||||
private void CopyToClipboard()
|
||||
{
|
||||
if (_selectionLength == 0) return;
|
||||
|
||||
var start = Math.Min(_selectionStart, _selectionStart + _selectionLength);
|
||||
var length = Math.Abs(_selectionLength);
|
||||
var selectedText = Text.Substring(start, length);
|
||||
|
||||
// Use system clipboard via xclip/xsel
|
||||
SystemClipboard.SetText(selectedText);
|
||||
}
|
||||
|
||||
private void CutToClipboard()
|
||||
{
|
||||
CopyToClipboard();
|
||||
DeleteSelection();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void PasteFromClipboard()
|
||||
{
|
||||
// Get from system clipboard
|
||||
var text = SystemClipboard.GetText();
|
||||
if (string.IsNullOrEmpty(text)) return;
|
||||
|
||||
if (_selectionLength != 0)
|
||||
{
|
||||
DeleteSelection();
|
||||
}
|
||||
|
||||
InsertText(text);
|
||||
}
|
||||
|
||||
private void DeleteSelection()
|
||||
{
|
||||
if (_selectionLength == 0) return;
|
||||
|
||||
var start = Math.Min(_selectionStart, _selectionStart + _selectionLength);
|
||||
var length = Math.Abs(_selectionLength);
|
||||
|
||||
Text = Text.Remove(start, length);
|
||||
_cursorPosition = start;
|
||||
_selectionStart = -1;
|
||||
_selectionLength = 0;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
if (AutoSize)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,8 +22,13 @@ public class SkiaItemsView : SkiaView
|
||||
private int _firstVisibleIndex;
|
||||
private int _lastVisibleIndex;
|
||||
private bool _isDragging;
|
||||
private bool _isDraggingScrollbar;
|
||||
private float _dragStartY;
|
||||
private float _dragStartOffset;
|
||||
private float _scrollbarDragStartY;
|
||||
private float _scrollbarDragStartScrollOffset;
|
||||
private float _scrollbarDragAvailableTrack;
|
||||
private float _scrollbarDragMaxScroll;
|
||||
private float _velocity;
|
||||
private DateTime _lastDragTime;
|
||||
|
||||
@@ -81,9 +86,21 @@ public class SkiaItemsView : SkiaView
|
||||
public object? EmptyView { get; set; }
|
||||
public string? EmptyViewText { get; set; } = "No items";
|
||||
|
||||
// Item rendering delegate
|
||||
// Item rendering delegate (legacy)
|
||||
public Func<object, int, SKRect, SKCanvas, SKPaint, bool>? ItemRenderer { get; set; }
|
||||
|
||||
// Item view creator - creates SkiaView from data item using DataTemplate
|
||||
public Func<object, SkiaView?>? ItemViewCreator { get; set; }
|
||||
|
||||
// Cache of created item views for virtualization
|
||||
protected readonly Dictionary<int, SkiaView> _itemViewCache = new();
|
||||
|
||||
// Cache of individual item heights for variable height items
|
||||
protected readonly Dictionary<int, float> _itemHeights = new();
|
||||
|
||||
// Track last measured width to clear cache when width changes
|
||||
private float _lastMeasuredWidth = 0;
|
||||
|
||||
// Selection support (overridden in SkiaCollectionView)
|
||||
public virtual int SelectedIndex { get; set; } = -1;
|
||||
|
||||
@@ -95,9 +112,12 @@ public class SkiaItemsView : SkiaView
|
||||
IsFocusable = true;
|
||||
}
|
||||
|
||||
private void RefreshItems()
|
||||
protected virtual void RefreshItems()
|
||||
{
|
||||
Console.WriteLine($"[SkiaItemsView] RefreshItems called, clearing {_items.Count} items and {_itemViewCache.Count} cached views");
|
||||
_items.Clear();
|
||||
_itemViewCache.Clear(); // Clear cached views when items change
|
||||
_itemHeights.Clear(); // Clear cached heights
|
||||
if (_itemsSource != null)
|
||||
{
|
||||
foreach (var item in _itemsSource)
|
||||
@@ -105,6 +125,7 @@ public class SkiaItemsView : SkiaView
|
||||
_items.Add(item);
|
||||
}
|
||||
}
|
||||
Console.WriteLine($"[SkiaItemsView] RefreshItems done, now have {_items.Count} items");
|
||||
_scrollOffset = 0;
|
||||
}
|
||||
|
||||
@@ -114,11 +135,53 @@ public class SkiaItemsView : SkiaView
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected float TotalContentHeight => _items.Count * (_itemHeight + _itemSpacing) - _itemSpacing;
|
||||
protected float MaxScrollOffset => Math.Max(0, TotalContentHeight - Bounds.Height);
|
||||
/// <summary>
|
||||
/// Gets the height for a specific item, using cached height or default.
|
||||
/// </summary>
|
||||
protected float GetItemHeight(int index)
|
||||
{
|
||||
return _itemHeights.TryGetValue(index, out var height) ? height : _itemHeight;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Y offset for a specific item (cumulative height of all previous items).
|
||||
/// </summary>
|
||||
protected float GetItemOffset(int index)
|
||||
{
|
||||
float offset = 0;
|
||||
for (int i = 0; i < index && i < _items.Count; i++)
|
||||
{
|
||||
offset += GetItemHeight(i) + _itemSpacing;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates total content height based on individual item heights.
|
||||
/// </summary>
|
||||
protected float TotalContentHeight
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_items.Count == 0) return 0;
|
||||
|
||||
float total = 0;
|
||||
for (int i = 0; i < _items.Count; i++)
|
||||
{
|
||||
total += GetItemHeight(i);
|
||||
if (i < _items.Count - 1) total += _itemSpacing;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
// Use ScreenBounds.Height for visible viewport
|
||||
protected float MaxScrollOffset => Math.Max(0, TotalContentHeight - ScreenBounds.Height);
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
Console.WriteLine($"[SkiaItemsView] OnDraw - bounds={bounds}, items={_items.Count}, ItemViewCreator={(ItemViewCreator != null ? "set" : "null")}");
|
||||
|
||||
// Draw background
|
||||
if (BackgroundColor != SKColors.Transparent)
|
||||
{
|
||||
@@ -137,30 +200,51 @@ public class SkiaItemsView : SkiaView
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate visible range
|
||||
_firstVisibleIndex = Math.Max(0, (int)(_scrollOffset / (_itemHeight + _itemSpacing)));
|
||||
_lastVisibleIndex = Math.Min(_items.Count - 1,
|
||||
(int)((_scrollOffset + bounds.Height) / (_itemHeight + _itemSpacing)) + 1);
|
||||
// Find first visible index by walking through items
|
||||
_firstVisibleIndex = 0;
|
||||
float cumulativeOffset = 0;
|
||||
for (int i = 0; i < _items.Count; i++)
|
||||
{
|
||||
var itemH = GetItemHeight(i);
|
||||
if (cumulativeOffset + itemH > _scrollOffset)
|
||||
{
|
||||
_firstVisibleIndex = i;
|
||||
break;
|
||||
}
|
||||
cumulativeOffset += itemH + _itemSpacing;
|
||||
}
|
||||
|
||||
// Clip to bounds
|
||||
canvas.Save();
|
||||
canvas.ClipRect(bounds);
|
||||
|
||||
// Draw visible items
|
||||
// Draw visible items using variable heights
|
||||
using var paint = new SKPaint
|
||||
{
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
for (int i = _firstVisibleIndex; i <= _lastVisibleIndex; i++)
|
||||
float currentY = bounds.Top + GetItemOffset(_firstVisibleIndex) - _scrollOffset;
|
||||
for (int i = _firstVisibleIndex; i < _items.Count; i++)
|
||||
{
|
||||
var itemY = bounds.Top + (i * (_itemHeight + _itemSpacing)) - _scrollOffset;
|
||||
var itemRect = new SKRect(bounds.Left, itemY, bounds.Right - (_showVerticalScrollBar ? _scrollBarWidth : 0), itemY + _itemHeight);
|
||||
var itemH = GetItemHeight(i);
|
||||
var itemRect = new SKRect(bounds.Left, currentY, bounds.Right - (_showVerticalScrollBar ? _scrollBarWidth : 0), currentY + itemH);
|
||||
|
||||
if (itemRect.Bottom < bounds.Top || itemRect.Top > bounds.Bottom)
|
||||
continue;
|
||||
// Stop if we've passed the visible area
|
||||
if (itemRect.Top > bounds.Bottom)
|
||||
{
|
||||
_lastVisibleIndex = i - 1;
|
||||
break;
|
||||
}
|
||||
|
||||
DrawItem(canvas, _items[i], i, itemRect, paint);
|
||||
_lastVisibleIndex = i;
|
||||
|
||||
if (itemRect.Bottom >= bounds.Top)
|
||||
{
|
||||
DrawItem(canvas, _items[i], i, itemRect, paint);
|
||||
}
|
||||
|
||||
currentY += itemH + _itemSpacing;
|
||||
}
|
||||
|
||||
canvas.Restore();
|
||||
@@ -177,11 +261,56 @@ public class SkiaItemsView : SkiaView
|
||||
// Draw selection highlight
|
||||
if (index == SelectedIndex)
|
||||
{
|
||||
paint.Color = new SKColor(0x21, 0x96, 0xF3, 0x40); // Light blue
|
||||
paint.Color = new SKColor(0x21, 0x96, 0xF3, 0x59); // Light blue with 35% opacity
|
||||
paint.Style = SKPaintStyle.Fill;
|
||||
canvas.DrawRect(bounds, paint);
|
||||
}
|
||||
|
||||
// Try to use ItemViewCreator for templated rendering
|
||||
if (ItemViewCreator != null)
|
||||
{
|
||||
Console.WriteLine($"[SkiaItemsView] DrawItem {index} - ItemViewCreator exists, item: {item}");
|
||||
// Get or create cached view for this index
|
||||
if (!_itemViewCache.TryGetValue(index, out var itemView) || itemView == null)
|
||||
{
|
||||
itemView = ItemViewCreator(item);
|
||||
if (itemView != null)
|
||||
{
|
||||
itemView.Parent = this;
|
||||
_itemViewCache[index] = itemView;
|
||||
}
|
||||
}
|
||||
|
||||
if (itemView != null)
|
||||
{
|
||||
// Measure with large height to get natural size
|
||||
var availableSize = new SKSize(bounds.Width, float.MaxValue);
|
||||
var measuredSize = itemView.Measure(availableSize);
|
||||
|
||||
// Store individual item height (with minimum of default height)
|
||||
var measuredHeight = Math.Max(measuredSize.Height, _itemHeight);
|
||||
if (!_itemHeights.TryGetValue(index, out var cachedHeight) || Math.Abs(cachedHeight - measuredHeight) > 1)
|
||||
{
|
||||
_itemHeights[index] = measuredHeight;
|
||||
// Request redraw if height changed significantly
|
||||
if (Math.Abs(cachedHeight - measuredHeight) > 5)
|
||||
{
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
// Arrange with the actual measured height
|
||||
var actualBounds = new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Top + measuredHeight);
|
||||
itemView.Arrange(actualBounds);
|
||||
itemView.Draw(canvas);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[SkiaItemsView] DrawItem {index} - ItemViewCreator is NULL, falling back to ToString");
|
||||
}
|
||||
|
||||
// Draw separator
|
||||
paint.Color = new SKColor(0xE0, 0xE0, 0xE0);
|
||||
paint.Style = SKPaintStyle.Stroke;
|
||||
@@ -281,8 +410,27 @@ public class SkiaItemsView : SkiaView
|
||||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"[SkiaItemsView] OnPointerPressed - x={e.X}, y={e.Y}, Bounds={Bounds}, ScreenBounds={ScreenBounds}, ItemCount={_items.Count}");
|
||||
if (!IsEnabled) return;
|
||||
|
||||
// Check if clicking on scrollbar thumb
|
||||
if (_showVerticalScrollBar && TotalContentHeight > Bounds.Height)
|
||||
{
|
||||
var thumbBounds = GetScrollbarThumbBounds();
|
||||
if (thumbBounds.Contains(e.X, e.Y))
|
||||
{
|
||||
_isDraggingScrollbar = true;
|
||||
_scrollbarDragStartY = e.Y;
|
||||
_scrollbarDragStartScrollOffset = _scrollOffset;
|
||||
// Cache values to prevent stutter
|
||||
var thumbHeight = Math.Max(20, Bounds.Height * (Bounds.Height / TotalContentHeight));
|
||||
_scrollbarDragAvailableTrack = Bounds.Height - thumbHeight;
|
||||
_scrollbarDragMaxScroll = MaxScrollOffset;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular content drag
|
||||
_isDragging = true;
|
||||
_dragStartY = e.Y;
|
||||
_dragStartOffset = _scrollOffset;
|
||||
@@ -290,8 +438,39 @@ public class SkiaItemsView : SkiaView
|
||||
_velocity = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bounds of the scrollbar thumb in screen coordinates.
|
||||
/// </summary>
|
||||
private SKRect GetScrollbarThumbBounds()
|
||||
{
|
||||
// Use ScreenBounds for hit testing (input events use screen coordinates)
|
||||
var screenBounds = ScreenBounds;
|
||||
var viewportRatio = screenBounds.Height / TotalContentHeight;
|
||||
var thumbHeight = Math.Max(20, screenBounds.Height * viewportRatio);
|
||||
var scrollRatio = MaxScrollOffset > 0 ? _scrollOffset / MaxScrollOffset : 0;
|
||||
var thumbY = screenBounds.Top + (screenBounds.Height - thumbHeight) * scrollRatio;
|
||||
|
||||
return new SKRect(
|
||||
screenBounds.Right - _scrollBarWidth,
|
||||
thumbY,
|
||||
screenBounds.Right,
|
||||
thumbY + thumbHeight);
|
||||
}
|
||||
|
||||
public override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
// Handle scrollbar dragging - use cached values to prevent stutter
|
||||
if (_isDraggingScrollbar)
|
||||
{
|
||||
if (_scrollbarDragAvailableTrack > 0)
|
||||
{
|
||||
var deltaY = e.Y - _scrollbarDragStartY;
|
||||
var scrollDelta = (deltaY / _scrollbarDragAvailableTrack) * _scrollbarDragMaxScroll;
|
||||
SetScrollOffset(_scrollbarDragStartScrollOffset + scrollDelta);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isDragging) return;
|
||||
|
||||
var delta = _dragStartY - e.Y;
|
||||
@@ -311,6 +490,13 @@ public class SkiaItemsView : SkiaView
|
||||
|
||||
public override void OnPointerReleased(PointerEventArgs e)
|
||||
{
|
||||
// Handle scrollbar drag release
|
||||
if (_isDraggingScrollbar)
|
||||
{
|
||||
_isDraggingScrollbar = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isDragging)
|
||||
{
|
||||
_isDragging = false;
|
||||
@@ -319,9 +505,25 @@ public class SkiaItemsView : SkiaView
|
||||
var totalDrag = Math.Abs(e.Y - _dragStartY);
|
||||
if (totalDrag < 5)
|
||||
{
|
||||
// This was a tap - find which item was tapped
|
||||
var tapY = e.Y + _scrollOffset - Bounds.Top;
|
||||
var tappedIndex = (int)(tapY / (_itemHeight + _itemSpacing));
|
||||
// This was a tap - find which item was tapped using variable heights
|
||||
var screenBounds = ScreenBounds;
|
||||
var localY = e.Y - screenBounds.Top + _scrollOffset;
|
||||
|
||||
// Find tapped index by walking through item heights
|
||||
int tappedIndex = -1;
|
||||
float cumulativeY = 0;
|
||||
for (int i = 0; i < _items.Count; i++)
|
||||
{
|
||||
var itemH = GetItemHeight(i);
|
||||
if (localY >= cumulativeY && localY < cumulativeY + itemH)
|
||||
{
|
||||
tappedIndex = i;
|
||||
break;
|
||||
}
|
||||
cumulativeY += itemH + _itemSpacing;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[SkiaItemsView] Tap at Y={e.Y}, screenBounds.Top={screenBounds.Top}, scrollOffset={_scrollOffset}, localY={localY}, index={tappedIndex}");
|
||||
|
||||
if (tappedIndex >= 0 && tappedIndex < _items.Count)
|
||||
{
|
||||
@@ -331,6 +533,24 @@ public class SkiaItemsView : SkiaView
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total Y scroll offset from all parent ScrollViews.
|
||||
/// </summary>
|
||||
private float GetTotalParentScrollY()
|
||||
{
|
||||
float total = 0;
|
||||
var parent = Parent;
|
||||
while (parent != null)
|
||||
{
|
||||
if (parent is SkiaScrollView scrollView)
|
||||
{
|
||||
total += scrollView.ScrollY;
|
||||
}
|
||||
parent = parent.Parent;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
protected virtual void OnItemTapped(int index, object item)
|
||||
{
|
||||
SelectedIndex = index;
|
||||
@@ -361,7 +581,7 @@ public class SkiaItemsView : SkiaView
|
||||
{
|
||||
if (index < 0 || index >= _items.Count) return;
|
||||
|
||||
var targetOffset = index * (_itemHeight + _itemSpacing);
|
||||
var targetOffset = GetItemOffset(index);
|
||||
SetScrollOffset(targetOffset);
|
||||
}
|
||||
|
||||
@@ -436,8 +656,8 @@ public class SkiaItemsView : SkiaView
|
||||
|
||||
private void EnsureIndexVisible(int index)
|
||||
{
|
||||
var itemTop = index * (_itemHeight + _itemSpacing);
|
||||
var itemBottom = itemTop + _itemHeight;
|
||||
var itemTop = GetItemOffset(index);
|
||||
var itemBottom = itemTop + GetItemHeight(index);
|
||||
|
||||
if (itemTop < _scrollOffset)
|
||||
{
|
||||
@@ -452,12 +672,43 @@ public class SkiaItemsView : SkiaView
|
||||
protected int ItemCount => _items.Count;
|
||||
protected object? GetItemAt(int index) => index >= 0 && index < _items.Count ? _items[index] : null;
|
||||
|
||||
/// <summary>
|
||||
/// Override HitTest to handle scrollbar clicks properly.
|
||||
/// HitTest receives content-space coordinates (already transformed by parent ScrollView).
|
||||
/// </summary>
|
||||
public override SkiaView? HitTest(float x, float y)
|
||||
{
|
||||
// HitTest uses Bounds (content space) - coordinates are transformed by parent
|
||||
if (!IsVisible || !Bounds.Contains(new SKPoint(x, y)))
|
||||
return null;
|
||||
|
||||
// Check scrollbar area FIRST before content
|
||||
// This ensures scrollbar clicks are handled by this view
|
||||
if (_showVerticalScrollBar && TotalContentHeight > Bounds.Height)
|
||||
{
|
||||
var trackArea = new SKRect(Bounds.Right - _scrollBarWidth, Bounds.Top, Bounds.Right, Bounds.Bottom);
|
||||
if (trackArea.Contains(x, y))
|
||||
return this;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
var width = availableSize.Width < float.MaxValue ? availableSize.Width : 200;
|
||||
var height = availableSize.Height < float.MaxValue ? availableSize.Height : 300;
|
||||
|
||||
// Clear item caches when width changes significantly (items need re-measurement for text wrapping)
|
||||
if (Math.Abs(width - _lastMeasuredWidth) > 5)
|
||||
{
|
||||
_itemHeights.Clear();
|
||||
_itemViewCache.Clear();
|
||||
_lastMeasuredWidth = width;
|
||||
}
|
||||
|
||||
// Items view takes all available space
|
||||
return new SKSize(
|
||||
availableSize.Width < float.MaxValue ? availableSize.Width : 200,
|
||||
availableSize.Height < float.MaxValue ? availableSize.Height : 300);
|
||||
return new SKSize(width, height);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
|
||||
@@ -7,24 +7,319 @@ using Microsoft.Maui.Platform.Linux.Rendering;
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered label control for displaying text.
|
||||
/// Skia-rendered label control for displaying text with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaLabel : SkiaView
|
||||
{
|
||||
public string Text { get; set; } = "";
|
||||
public SKColor TextColor { get; set; } = SKColors.Black;
|
||||
public string FontFamily { get; set; } = "Sans";
|
||||
public float FontSize { get; set; } = 14;
|
||||
public bool IsBold { get; set; }
|
||||
public bool IsItalic { get; set; }
|
||||
public bool IsUnderline { get; set; }
|
||||
public bool IsStrikethrough { get; set; }
|
||||
public TextAlignment HorizontalTextAlignment { get; set; } = TextAlignment.Start;
|
||||
public TextAlignment VerticalTextAlignment { get; set; } = TextAlignment.Center;
|
||||
public LineBreakMode LineBreakMode { get; set; } = LineBreakMode.TailTruncation;
|
||||
public int MaxLines { get; set; } = 0; // 0 = unlimited
|
||||
public float LineHeight { get; set; } = 1.2f;
|
||||
public float CharacterSpacing { get; set; }
|
||||
#region BindableProperties
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Text.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TextProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Text),
|
||||
typeof(string),
|
||||
typeof(SkiaLabel),
|
||||
"",
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for TextColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TextColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(TextColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaLabel),
|
||||
SKColors.Black,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for FontFamily.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty FontFamilyProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(FontFamily),
|
||||
typeof(string),
|
||||
typeof(SkiaLabel),
|
||||
"Sans",
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFontChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for FontSize.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty FontSizeProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(FontSize),
|
||||
typeof(float),
|
||||
typeof(SkiaLabel),
|
||||
14f,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFontChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsBold.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsBoldProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsBold),
|
||||
typeof(bool),
|
||||
typeof(SkiaLabel),
|
||||
false,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFontChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsItalic.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsItalicProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsItalic),
|
||||
typeof(bool),
|
||||
typeof(SkiaLabel),
|
||||
false,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFontChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsUnderline.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsUnderlineProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsUnderline),
|
||||
typeof(bool),
|
||||
typeof(SkiaLabel),
|
||||
false,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsStrikethrough.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsStrikethroughProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsStrikethrough),
|
||||
typeof(bool),
|
||||
typeof(SkiaLabel),
|
||||
false,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for HorizontalTextAlignment.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty HorizontalTextAlignmentProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(HorizontalTextAlignment),
|
||||
typeof(TextAlignment),
|
||||
typeof(SkiaLabel),
|
||||
TextAlignment.Start,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for VerticalTextAlignment.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty VerticalTextAlignmentProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(VerticalTextAlignment),
|
||||
typeof(TextAlignment),
|
||||
typeof(SkiaLabel),
|
||||
TextAlignment.Center,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for LineBreakMode.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty LineBreakModeProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(LineBreakMode),
|
||||
typeof(LineBreakMode),
|
||||
typeof(SkiaLabel),
|
||||
LineBreakMode.TailTruncation,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for MaxLines.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty MaxLinesProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(MaxLines),
|
||||
typeof(int),
|
||||
typeof(SkiaLabel),
|
||||
0,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for LineHeight.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty LineHeightProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(LineHeight),
|
||||
typeof(float),
|
||||
typeof(SkiaLabel),
|
||||
1.2f,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CharacterSpacing.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CharacterSpacingProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CharacterSpacing),
|
||||
typeof(float),
|
||||
typeof(SkiaLabel),
|
||||
0f,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Padding.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty PaddingProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Padding),
|
||||
typeof(SKRect),
|
||||
typeof(SkiaLabel),
|
||||
SKRect.Empty,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text content.
|
||||
/// </summary>
|
||||
public string Text
|
||||
{
|
||||
get => (string)GetValue(TextProperty);
|
||||
set => SetValue(TextProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text color.
|
||||
/// </summary>
|
||||
public SKColor TextColor
|
||||
{
|
||||
get => (SKColor)GetValue(TextColorProperty);
|
||||
set => SetValue(TextColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the font family.
|
||||
/// </summary>
|
||||
public string FontFamily
|
||||
{
|
||||
get => (string)GetValue(FontFamilyProperty);
|
||||
set => SetValue(FontFamilyProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the font size.
|
||||
/// </summary>
|
||||
public float FontSize
|
||||
{
|
||||
get => (float)GetValue(FontSizeProperty);
|
||||
set => SetValue(FontSizeProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the text is bold.
|
||||
/// </summary>
|
||||
public bool IsBold
|
||||
{
|
||||
get => (bool)GetValue(IsBoldProperty);
|
||||
set => SetValue(IsBoldProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the text is italic.
|
||||
/// </summary>
|
||||
public bool IsItalic
|
||||
{
|
||||
get => (bool)GetValue(IsItalicProperty);
|
||||
set => SetValue(IsItalicProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the text has underline.
|
||||
/// </summary>
|
||||
public bool IsUnderline
|
||||
{
|
||||
get => (bool)GetValue(IsUnderlineProperty);
|
||||
set => SetValue(IsUnderlineProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the text has strikethrough.
|
||||
/// </summary>
|
||||
public bool IsStrikethrough
|
||||
{
|
||||
get => (bool)GetValue(IsStrikethroughProperty);
|
||||
set => SetValue(IsStrikethroughProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal text alignment.
|
||||
/// </summary>
|
||||
public TextAlignment HorizontalTextAlignment
|
||||
{
|
||||
get => (TextAlignment)GetValue(HorizontalTextAlignmentProperty);
|
||||
set => SetValue(HorizontalTextAlignmentProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical text alignment.
|
||||
/// </summary>
|
||||
public TextAlignment VerticalTextAlignment
|
||||
{
|
||||
get => (TextAlignment)GetValue(VerticalTextAlignmentProperty);
|
||||
set => SetValue(VerticalTextAlignmentProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the line break mode.
|
||||
/// </summary>
|
||||
public LineBreakMode LineBreakMode
|
||||
{
|
||||
get => (LineBreakMode)GetValue(LineBreakModeProperty);
|
||||
set => SetValue(LineBreakModeProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of lines. 0 = unlimited.
|
||||
/// </summary>
|
||||
public int MaxLines
|
||||
{
|
||||
get => (int)GetValue(MaxLinesProperty);
|
||||
set => SetValue(MaxLinesProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the line height multiplier.
|
||||
/// </summary>
|
||||
public float LineHeight
|
||||
{
|
||||
get => (float)GetValue(LineHeightProperty);
|
||||
set => SetValue(LineHeightProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the character spacing.
|
||||
/// </summary>
|
||||
public float CharacterSpacing
|
||||
{
|
||||
get => (float)GetValue(CharacterSpacingProperty);
|
||||
set => SetValue(CharacterSpacingProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the padding.
|
||||
/// </summary>
|
||||
public SKRect Padding
|
||||
{
|
||||
get => (SKRect)GetValue(PaddingProperty);
|
||||
set => SetValue(PaddingProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal alignment (compatibility property).
|
||||
/// </summary>
|
||||
public SkiaTextAlignment HorizontalAlignment
|
||||
{
|
||||
get => HorizontalTextAlignment switch
|
||||
@@ -42,6 +337,10 @@ public class SkiaLabel : SkiaView
|
||||
_ => TextAlignment.Start
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical alignment (compatibility property).
|
||||
/// </summary>
|
||||
public SkiaVerticalAlignment VerticalAlignment
|
||||
{
|
||||
get => VerticalTextAlignment switch
|
||||
@@ -59,7 +358,45 @@ public class SkiaLabel : SkiaView
|
||||
_ => TextAlignment.Start
|
||||
};
|
||||
}
|
||||
public SKRect Padding { get; set; } = new SKRect(0, 0, 0, 0);
|
||||
|
||||
#endregion
|
||||
|
||||
private static SKTypeface? _cachedTypeface;
|
||||
|
||||
private void OnTextChanged()
|
||||
{
|
||||
InvalidateMeasure();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void OnFontChanged()
|
||||
{
|
||||
InvalidateMeasure();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private static SKTypeface GetLinuxTypeface()
|
||||
{
|
||||
if (_cachedTypeface != null) return _cachedTypeface;
|
||||
|
||||
// Try common Linux font paths
|
||||
string[] fontPaths = {
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/truetype/ubuntu/Ubuntu-R.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf"
|
||||
};
|
||||
|
||||
foreach (var path in fontPaths)
|
||||
{
|
||||
if (System.IO.File.Exists(path))
|
||||
{
|
||||
_cachedTypeface = SKTypeface.FromFile(path);
|
||||
if (_cachedTypeface != null) return _cachedTypeface;
|
||||
}
|
||||
}
|
||||
return SKTypeface.Default;
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
@@ -71,8 +408,11 @@ public class SkiaLabel : SkiaView
|
||||
SKFontStyleWidth.Normal,
|
||||
IsItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright);
|
||||
|
||||
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle)
|
||||
?? SKTypeface.Default;
|
||||
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle);
|
||||
if (typeface == null || typeface == SKTypeface.Default)
|
||||
{
|
||||
typeface = GetLinuxTypeface();
|
||||
}
|
||||
|
||||
using var font = new SKFont(typeface, FontSize);
|
||||
using var paint = new SKPaint(font)
|
||||
@@ -89,13 +429,16 @@ public class SkiaLabel : SkiaView
|
||||
bounds.Bottom - Padding.Bottom);
|
||||
|
||||
// Handle single line vs multiline
|
||||
if (MaxLines == 1 || !Text.Contains('\n'))
|
||||
// Use DrawSingleLine for normal labels (MaxLines <= 1 or unlimited) without newlines
|
||||
// Use DrawMultiLineWithWrapping only when MaxLines > 1 (word wrap needed) or text has newlines
|
||||
bool needsMultiLine = MaxLines > 1 || Text.Contains('\n');
|
||||
if (needsMultiLine)
|
||||
{
|
||||
DrawSingleLine(canvas, paint, font, contentBounds);
|
||||
DrawMultiLineWithWrapping(canvas, paint, font, contentBounds);
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawMultiLine(canvas, paint, font, contentBounds);
|
||||
DrawSingleLine(canvas, paint, font, contentBounds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,10 +503,140 @@ public class SkiaLabel : SkiaView
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawMultiLineWithWrapping(SKCanvas canvas, SKPaint paint, SKFont font, SKRect bounds)
|
||||
{
|
||||
// Handle inverted or zero-height/width bounds
|
||||
var effectiveBounds = bounds;
|
||||
|
||||
// Fix invalid height
|
||||
if (bounds.Height <= 0)
|
||||
{
|
||||
var effectiveLH = LineHeight <= 0 ? 1.2f : LineHeight;
|
||||
var estimatedHeight = MaxLines > 0 ? MaxLines * FontSize * effectiveLH : FontSize * effectiveLH * 10;
|
||||
effectiveBounds = new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Top + estimatedHeight);
|
||||
}
|
||||
|
||||
// Fix invalid width - use a reasonable default if width is invalid or extremely large
|
||||
float effectiveWidth = effectiveBounds.Width;
|
||||
if (effectiveWidth <= 0)
|
||||
{
|
||||
// Use a default width based on canvas
|
||||
effectiveWidth = 400; // Reasonable default
|
||||
}
|
||||
|
||||
// Note: Previously had width capping logic here that reduced effective width
|
||||
// to 60% for multiline labels. Removed - the layout system should now provide
|
||||
// correct widths, and artificially capping causes text to wrap too early.
|
||||
|
||||
// First, word-wrap the text to fit within bounds
|
||||
var wrappedLines = WrapText(paint, Text, effectiveWidth);
|
||||
|
||||
// LineHeight of -1 or <= 0 means "use default" - use 1.2 as default multiplier
|
||||
var effectiveLineHeight = LineHeight <= 0 ? 1.2f : LineHeight;
|
||||
var lineSpacing = FontSize * effectiveLineHeight;
|
||||
var maxLinesToDraw = MaxLines > 0 ? Math.Min(MaxLines, wrappedLines.Count) : wrappedLines.Count;
|
||||
|
||||
// Calculate total height
|
||||
var totalHeight = maxLinesToDraw * lineSpacing;
|
||||
|
||||
// Calculate starting Y based on vertical alignment
|
||||
float startY = VerticalTextAlignment switch
|
||||
{
|
||||
TextAlignment.Start => effectiveBounds.Top + FontSize,
|
||||
TextAlignment.Center => effectiveBounds.MidY - totalHeight / 2 + FontSize,
|
||||
TextAlignment.End => effectiveBounds.Bottom - totalHeight + FontSize,
|
||||
_ => effectiveBounds.Top + FontSize
|
||||
};
|
||||
|
||||
for (int i = 0; i < maxLinesToDraw; i++)
|
||||
{
|
||||
var line = wrappedLines[i];
|
||||
|
||||
// Add ellipsis if this is the last line and there are more lines
|
||||
bool isLastLine = i == maxLinesToDraw - 1;
|
||||
bool hasMoreContent = maxLinesToDraw < wrappedLines.Count;
|
||||
if (isLastLine && hasMoreContent && LineBreakMode == LineBreakMode.TailTruncation)
|
||||
{
|
||||
line = TruncateTextWithEllipsis(paint, line, effectiveWidth);
|
||||
}
|
||||
|
||||
var textBounds = new SKRect();
|
||||
paint.MeasureText(line, ref textBounds);
|
||||
|
||||
float x = HorizontalTextAlignment switch
|
||||
{
|
||||
TextAlignment.Start => effectiveBounds.Left,
|
||||
TextAlignment.Center => effectiveBounds.MidX - textBounds.Width / 2,
|
||||
TextAlignment.End => effectiveBounds.Right - textBounds.Width,
|
||||
_ => effectiveBounds.Left
|
||||
};
|
||||
|
||||
float y = startY + i * lineSpacing;
|
||||
|
||||
// Don't break early for inverted bounds - just draw
|
||||
if (effectiveBounds.Height > 0 && y > effectiveBounds.Bottom)
|
||||
break;
|
||||
|
||||
canvas.DrawText(line, x, y, paint);
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> WrapText(SKPaint paint, string text, float maxWidth)
|
||||
{
|
||||
var result = new List<string>();
|
||||
|
||||
// Split by newlines first
|
||||
var paragraphs = text.Split('\n');
|
||||
|
||||
foreach (var paragraph in paragraphs)
|
||||
{
|
||||
if (string.IsNullOrEmpty(paragraph))
|
||||
{
|
||||
result.Add("");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if paragraph fits in one line
|
||||
if (paint.MeasureText(paragraph) <= maxWidth)
|
||||
{
|
||||
result.Add(paragraph);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Word wrap this paragraph
|
||||
var words = paragraph.Split(' ');
|
||||
var currentLine = "";
|
||||
|
||||
foreach (var word in words)
|
||||
{
|
||||
var testLine = string.IsNullOrEmpty(currentLine) ? word : currentLine + " " + word;
|
||||
var lineWidth = paint.MeasureText(testLine);
|
||||
|
||||
if (lineWidth > maxWidth && !string.IsNullOrEmpty(currentLine))
|
||||
{
|
||||
result.Add(currentLine);
|
||||
currentLine = word;
|
||||
}
|
||||
else
|
||||
{
|
||||
currentLine = testLine;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(currentLine))
|
||||
{
|
||||
result.Add(currentLine);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void DrawMultiLine(SKCanvas canvas, SKPaint paint, SKFont font, SKRect bounds)
|
||||
{
|
||||
var lines = Text.Split('\n');
|
||||
var lineSpacing = FontSize * LineHeight;
|
||||
var effectiveLineHeight = LineHeight <= 0 ? 1.2f : LineHeight;
|
||||
var lineSpacing = FontSize * effectiveLineHeight;
|
||||
var maxLinesToDraw = MaxLines > 0 ? Math.Min(MaxLines, lines.Length) : lines.Length;
|
||||
|
||||
// Calculate total height
|
||||
@@ -208,6 +681,42 @@ public class SkiaLabel : SkiaView
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Truncates text and ALWAYS adds ellipsis (used when there's more content to indicate).
|
||||
/// </summary>
|
||||
private string TruncateTextWithEllipsis(SKPaint paint, string text, float maxWidth)
|
||||
{
|
||||
const string ellipsis = "...";
|
||||
var ellipsisWidth = paint.MeasureText(ellipsis);
|
||||
var textWidth = paint.MeasureText(text);
|
||||
|
||||
// If text + ellipsis fits, just append ellipsis
|
||||
if (textWidth + ellipsisWidth <= maxWidth)
|
||||
return text + ellipsis;
|
||||
|
||||
// Otherwise, truncate to make room for ellipsis
|
||||
var availableWidth = maxWidth - ellipsisWidth;
|
||||
if (availableWidth <= 0)
|
||||
return ellipsis;
|
||||
|
||||
// Binary search for the right length
|
||||
int low = 0;
|
||||
int high = text.Length;
|
||||
|
||||
while (low < high)
|
||||
{
|
||||
int mid = (low + high + 1) / 2;
|
||||
var substring = text.Substring(0, mid);
|
||||
|
||||
if (paint.MeasureText(substring) <= availableWidth)
|
||||
low = mid;
|
||||
else
|
||||
high = mid - 1;
|
||||
}
|
||||
|
||||
return text.Substring(0, low) + ellipsis;
|
||||
}
|
||||
|
||||
private string TruncateText(SKPaint paint, string text, float maxWidth)
|
||||
{
|
||||
const string ellipsis = "...";
|
||||
@@ -252,33 +761,51 @@ public class SkiaLabel : SkiaView
|
||||
SKFontStyleWidth.Normal,
|
||||
IsItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright);
|
||||
|
||||
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle)
|
||||
?? SKTypeface.Default;
|
||||
// Use same typeface logic as OnDraw to ensure consistent measurement
|
||||
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle);
|
||||
if (typeface == null || typeface == SKTypeface.Default)
|
||||
{
|
||||
typeface = GetLinuxTypeface();
|
||||
}
|
||||
|
||||
using var font = new SKFont(typeface, FontSize);
|
||||
using var paint = new SKPaint(font);
|
||||
|
||||
if (MaxLines == 1 || !Text.Contains('\n'))
|
||||
// Use same logic as OnDraw: multiline only when MaxLines > 1 or text has newlines
|
||||
bool needsMultiLine = MaxLines > 1 || Text.Contains('\n');
|
||||
if (!needsMultiLine)
|
||||
{
|
||||
var textBounds = new SKRect();
|
||||
paint.MeasureText(Text, ref textBounds);
|
||||
|
||||
// Add small buffer for font rendering tolerance
|
||||
const float widthBuffer = 4f;
|
||||
|
||||
return new SKSize(
|
||||
textBounds.Width + Padding.Left + Padding.Right,
|
||||
textBounds.Width + Padding.Left + Padding.Right + widthBuffer,
|
||||
textBounds.Height + Padding.Top + Padding.Bottom);
|
||||
}
|
||||
else
|
||||
{
|
||||
var lines = Text.Split('\n');
|
||||
var maxLinesToMeasure = MaxLines > 0 ? Math.Min(MaxLines, lines.Length) : lines.Length;
|
||||
// Use available width for word wrapping measurement
|
||||
var wrapWidth = availableSize.Width - Padding.Left - Padding.Right;
|
||||
if (wrapWidth <= 0)
|
||||
{
|
||||
wrapWidth = float.MaxValue; // No wrapping if no width constraint
|
||||
}
|
||||
|
||||
// Wrap text to get actual line count
|
||||
var wrappedLines = WrapText(paint, Text, wrapWidth);
|
||||
var maxLinesToMeasure = MaxLines > 0 ? Math.Min(MaxLines, wrappedLines.Count) : wrappedLines.Count;
|
||||
|
||||
float maxWidth = 0;
|
||||
foreach (var line in lines.Take(maxLinesToMeasure))
|
||||
foreach (var line in wrappedLines.Take(maxLinesToMeasure))
|
||||
{
|
||||
maxWidth = Math.Max(maxWidth, paint.MeasureText(line));
|
||||
}
|
||||
|
||||
var totalHeight = maxLinesToMeasure * FontSize * LineHeight;
|
||||
var effectiveLineHeight = LineHeight <= 0 ? 1.2f : LineHeight;
|
||||
var totalHeight = maxLinesToMeasure * FontSize * effectiveLineHeight;
|
||||
|
||||
return new SKSize(
|
||||
maxWidth + Padding.Left + Padding.Right,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using SkiaSharp;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
@@ -32,6 +33,20 @@ public abstract class SkiaLayoutView : SkiaView
|
||||
/// </summary>
|
||||
public bool ClipToBounds { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Called when binding context changes. Propagates to layout children.
|
||||
/// </summary>
|
||||
protected override void OnBindingContextChanged()
|
||||
{
|
||||
base.OnBindingContextChanged();
|
||||
|
||||
// Propagate binding context to layout children
|
||||
foreach (var child in _children)
|
||||
{
|
||||
SetInheritedBindingContext(child, BindingContext);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a child view.
|
||||
/// </summary>
|
||||
@@ -44,6 +59,13 @@ public abstract class SkiaLayoutView : SkiaView
|
||||
|
||||
_children.Add(child);
|
||||
child.Parent = this;
|
||||
|
||||
// Propagate binding context to new child
|
||||
if (BindingContext != null)
|
||||
{
|
||||
SetInheritedBindingContext(child, BindingContext);
|
||||
}
|
||||
|
||||
InvalidateMeasure();
|
||||
Invalidate();
|
||||
}
|
||||
@@ -88,6 +110,13 @@ public abstract class SkiaLayoutView : SkiaView
|
||||
|
||||
_children.Insert(index, child);
|
||||
child.Parent = this;
|
||||
|
||||
// Propagate binding context to new child
|
||||
if (BindingContext != null)
|
||||
{
|
||||
SetInheritedBindingContext(child, BindingContext);
|
||||
}
|
||||
|
||||
InvalidateMeasure();
|
||||
Invalidate();
|
||||
}
|
||||
@@ -128,6 +157,31 @@ public abstract class SkiaLayoutView : SkiaView
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Draw background if set (for layouts inside CollectionView items)
|
||||
if (BackgroundColor != SKColors.Transparent)
|
||||
{
|
||||
using var bgPaint = new SKPaint { Color = BackgroundColor, Style = SKPaintStyle.Fill };
|
||||
canvas.DrawRect(bounds, bgPaint);
|
||||
}
|
||||
|
||||
// Log for StackLayout
|
||||
if (this is SkiaStackLayout)
|
||||
{
|
||||
bool hasCV = false;
|
||||
foreach (var c in _children)
|
||||
{
|
||||
if (c is SkiaCollectionView) hasCV = true;
|
||||
}
|
||||
if (hasCV)
|
||||
{
|
||||
Console.WriteLine($"[SkiaStackLayout+CV] OnDraw - bounds={bounds}, children={_children.Count}");
|
||||
foreach (var c in _children)
|
||||
{
|
||||
Console.WriteLine($"[SkiaStackLayout+CV] Child: {c.GetType().Name}, IsVisible={c.IsVisible}, Bounds={c.Bounds}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw children in order
|
||||
foreach (var child in _children)
|
||||
{
|
||||
@@ -140,8 +194,14 @@ public abstract class SkiaLayoutView : SkiaView
|
||||
|
||||
public override SkiaView? HitTest(float x, float y)
|
||||
{
|
||||
if (!IsVisible || !Bounds.Contains(new SKPoint(x, y)))
|
||||
if (!IsVisible || !IsEnabled || !Bounds.Contains(new SKPoint(x, y)))
|
||||
{
|
||||
if (this is SkiaBorder)
|
||||
{
|
||||
Console.WriteLine($"[SkiaBorder.HitTest] Miss - x={x}, y={y}, Bounds={Bounds}, IsVisible={IsVisible}, IsEnabled={IsEnabled}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hit test children in reverse order (top-most first)
|
||||
for (int i = _children.Count - 1; i >= 0; i--)
|
||||
@@ -149,11 +209,73 @@ public abstract class SkiaLayoutView : SkiaView
|
||||
var child = _children[i];
|
||||
var hit = child.HitTest(x, y);
|
||||
if (hit != null)
|
||||
{
|
||||
if (this is SkiaBorder)
|
||||
{
|
||||
Console.WriteLine($"[SkiaBorder.HitTest] Hit child - x={x}, y={y}, Bounds={Bounds}, child={hit.GetType().Name}");
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
|
||||
if (this is SkiaBorder)
|
||||
{
|
||||
Console.WriteLine($"[SkiaBorder.HitTest] Hit self - x={x}, y={y}, Bounds={Bounds}, children={_children.Count}");
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forward pointer pressed events to the appropriate child.
|
||||
/// </summary>
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
// Find which child was hit and forward the event
|
||||
var hit = HitTest(e.X, e.Y);
|
||||
if (hit != null && hit != this)
|
||||
{
|
||||
hit.OnPointerPressed(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forward pointer released events to the appropriate child.
|
||||
/// </summary>
|
||||
public override void OnPointerReleased(PointerEventArgs e)
|
||||
{
|
||||
// Find which child was hit and forward the event
|
||||
var hit = HitTest(e.X, e.Y);
|
||||
if (hit != null && hit != this)
|
||||
{
|
||||
hit.OnPointerReleased(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forward pointer moved events to the appropriate child.
|
||||
/// </summary>
|
||||
public override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
// Find which child was hit and forward the event
|
||||
var hit = HitTest(e.X, e.Y);
|
||||
if (hit != null && hit != this)
|
||||
{
|
||||
hit.OnPointerMoved(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forward scroll events to the appropriate child.
|
||||
/// </summary>
|
||||
public override void OnScroll(ScrollEventArgs e)
|
||||
{
|
||||
// Find which child was hit and forward the event
|
||||
var hit = HitTest(e.X, e.Y);
|
||||
if (hit != null && hit != this)
|
||||
{
|
||||
hit.OnScroll(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -168,8 +290,18 @@ public class SkiaStackLayout : SkiaLayoutView
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
var contentWidth = availableSize.Width - Padding.Left - Padding.Right;
|
||||
var contentHeight = availableSize.Height - Padding.Top - Padding.Bottom;
|
||||
// Handle NaN/Infinity in padding
|
||||
var paddingLeft = float.IsNaN(Padding.Left) ? 0 : Padding.Left;
|
||||
var paddingRight = float.IsNaN(Padding.Right) ? 0 : Padding.Right;
|
||||
var paddingTop = float.IsNaN(Padding.Top) ? 0 : Padding.Top;
|
||||
var paddingBottom = float.IsNaN(Padding.Bottom) ? 0 : Padding.Bottom;
|
||||
|
||||
var contentWidth = availableSize.Width - paddingLeft - paddingRight;
|
||||
var contentHeight = availableSize.Height - paddingTop - paddingBottom;
|
||||
|
||||
// Clamp negative sizes to 0
|
||||
if (contentWidth < 0 || float.IsNaN(contentWidth)) contentWidth = 0;
|
||||
if (contentHeight < 0 || float.IsNaN(contentHeight)) contentHeight = 0;
|
||||
|
||||
float totalWidth = 0;
|
||||
float totalHeight = 0;
|
||||
@@ -184,15 +316,19 @@ public class SkiaStackLayout : SkiaLayoutView
|
||||
|
||||
var childSize = child.Measure(childAvailable);
|
||||
|
||||
// Skip NaN sizes from child measurements
|
||||
var childWidth = float.IsNaN(childSize.Width) ? 0 : childSize.Width;
|
||||
var childHeight = float.IsNaN(childSize.Height) ? 0 : childSize.Height;
|
||||
|
||||
if (Orientation == StackOrientation.Vertical)
|
||||
{
|
||||
totalHeight += childSize.Height;
|
||||
maxWidth = Math.Max(maxWidth, childSize.Width);
|
||||
totalHeight += childHeight;
|
||||
maxWidth = Math.Max(maxWidth, childWidth);
|
||||
}
|
||||
else
|
||||
{
|
||||
totalWidth += childSize.Width;
|
||||
maxHeight = Math.Max(maxHeight, childSize.Height);
|
||||
totalWidth += childWidth;
|
||||
maxHeight = Math.Max(maxHeight, childHeight);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,21 +340,26 @@ public class SkiaStackLayout : SkiaLayoutView
|
||||
{
|
||||
totalHeight += totalSpacing;
|
||||
return new SKSize(
|
||||
maxWidth + Padding.Left + Padding.Right,
|
||||
totalHeight + Padding.Top + Padding.Bottom);
|
||||
maxWidth + paddingLeft + paddingRight,
|
||||
totalHeight + paddingTop + paddingBottom);
|
||||
}
|
||||
else
|
||||
{
|
||||
totalWidth += totalSpacing;
|
||||
return new SKSize(
|
||||
totalWidth + Padding.Left + Padding.Right,
|
||||
maxHeight + Padding.Top + Padding.Bottom);
|
||||
totalWidth + paddingLeft + paddingRight,
|
||||
maxHeight + paddingTop + paddingBottom);
|
||||
}
|
||||
}
|
||||
|
||||
protected override SKRect ArrangeOverride(SKRect bounds)
|
||||
{
|
||||
var content = GetContentBounds(bounds);
|
||||
|
||||
// Clamp content dimensions if infinite - use reasonable defaults
|
||||
var contentWidth = float.IsInfinity(content.Width) || float.IsNaN(content.Width) ? 800f : content.Width;
|
||||
var contentHeight = float.IsInfinity(content.Height) || float.IsNaN(content.Height) ? 600f : content.Height;
|
||||
|
||||
float offset = 0;
|
||||
|
||||
foreach (var child in Children)
|
||||
@@ -227,27 +368,80 @@ public class SkiaStackLayout : SkiaLayoutView
|
||||
|
||||
var childDesired = child.DesiredSize;
|
||||
|
||||
// Handle NaN and Infinity in desired size
|
||||
var childWidth = float.IsNaN(childDesired.Width) || float.IsInfinity(childDesired.Width)
|
||||
? contentWidth
|
||||
: childDesired.Width;
|
||||
var childHeight = float.IsNaN(childDesired.Height) || float.IsInfinity(childDesired.Height)
|
||||
? contentHeight
|
||||
: childDesired.Height;
|
||||
|
||||
SKRect childBounds;
|
||||
if (Orientation == StackOrientation.Vertical)
|
||||
{
|
||||
// For ScrollView children, give them the remaining viewport height
|
||||
// Clamp to avoid giving them their content size
|
||||
var remainingHeight = Math.Max(0, contentHeight - offset);
|
||||
var useHeight = child is SkiaScrollView
|
||||
? remainingHeight
|
||||
: Math.Min(childHeight, remainingHeight > 0 ? remainingHeight : childHeight);
|
||||
|
||||
childBounds = new SKRect(
|
||||
content.Left,
|
||||
content.Top + offset,
|
||||
content.Right,
|
||||
content.Top + offset + childDesired.Height);
|
||||
offset += childDesired.Height + Spacing;
|
||||
content.Left + contentWidth,
|
||||
content.Top + offset + useHeight);
|
||||
offset += useHeight + Spacing;
|
||||
}
|
||||
else
|
||||
{
|
||||
// For ScrollView children, give them the remaining viewport width
|
||||
var remainingWidth = Math.Max(0, contentWidth - offset);
|
||||
var useWidth = child is SkiaScrollView
|
||||
? remainingWidth
|
||||
: Math.Min(childWidth, remainingWidth > 0 ? remainingWidth : childWidth);
|
||||
|
||||
// Respect child's VerticalOptions for horizontal layouts
|
||||
var useHeight = Math.Min(childHeight, contentHeight);
|
||||
float childTop = content.Top;
|
||||
float childBottom = content.Top + useHeight;
|
||||
|
||||
var verticalOptions = child.VerticalOptions;
|
||||
var alignmentValue = (int)verticalOptions.Alignment;
|
||||
|
||||
// LayoutAlignment: Start=0, Center=1, End=2, Fill=3
|
||||
if (alignmentValue == 1) // Center
|
||||
{
|
||||
childTop = content.Top + (contentHeight - useHeight) / 2;
|
||||
childBottom = childTop + useHeight;
|
||||
}
|
||||
else if (alignmentValue == 2) // End
|
||||
{
|
||||
childTop = content.Top + contentHeight - useHeight;
|
||||
childBottom = content.Top + contentHeight;
|
||||
}
|
||||
else if (alignmentValue == 3) // Fill
|
||||
{
|
||||
childTop = content.Top;
|
||||
childBottom = content.Top + contentHeight;
|
||||
}
|
||||
|
||||
childBounds = new SKRect(
|
||||
content.Left + offset,
|
||||
content.Top,
|
||||
content.Left + offset + childDesired.Width,
|
||||
content.Bottom);
|
||||
offset += childDesired.Width + Spacing;
|
||||
childTop,
|
||||
content.Left + offset + useWidth,
|
||||
childBottom);
|
||||
offset += useWidth + Spacing;
|
||||
}
|
||||
|
||||
child.Arrange(childBounds);
|
||||
// Apply child's margin
|
||||
var margin = child.Margin;
|
||||
var marginedBounds = new SKRect(
|
||||
childBounds.Left + (float)margin.Left,
|
||||
childBounds.Top + (float)margin.Top,
|
||||
childBounds.Right - (float)margin.Right,
|
||||
childBounds.Bottom - (float)margin.Bottom);
|
||||
child.Arrange(marginedBounds);
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
@@ -332,14 +526,73 @@ public class SkiaGrid : SkiaLayoutView
|
||||
var contentWidth = availableSize.Width - Padding.Left - Padding.Right;
|
||||
var contentHeight = availableSize.Height - Padding.Top - Padding.Bottom;
|
||||
|
||||
var rowCount = Math.Max(1, _rowDefinitions.Count);
|
||||
var columnCount = Math.Max(1, _columnDefinitions.Count);
|
||||
// Handle NaN/Infinity
|
||||
if (float.IsNaN(contentWidth) || float.IsInfinity(contentWidth)) contentWidth = 800;
|
||||
if (float.IsNaN(contentHeight) || float.IsInfinity(contentHeight)) contentHeight = float.PositiveInfinity;
|
||||
|
||||
// Calculate column widths
|
||||
_columnWidths = CalculateSizes(_columnDefinitions, contentWidth, ColumnSpacing, columnCount);
|
||||
_rowHeights = CalculateSizes(_rowDefinitions, contentHeight, RowSpacing, rowCount);
|
||||
var rowCount = Math.Max(1, _rowDefinitions.Count > 0 ? _rowDefinitions.Count : GetMaxRow() + 1);
|
||||
var columnCount = Math.Max(1, _columnDefinitions.Count > 0 ? _columnDefinitions.Count : GetMaxColumn() + 1);
|
||||
|
||||
// Measure children to adjust auto sizes
|
||||
// First pass: measure children in Auto columns to get natural widths
|
||||
var columnNaturalWidths = new float[columnCount];
|
||||
var rowNaturalHeights = new float[rowCount];
|
||||
|
||||
foreach (var child in Children)
|
||||
{
|
||||
if (!child.IsVisible) continue;
|
||||
|
||||
var pos = GetPosition(child);
|
||||
|
||||
// For Auto columns, measure with infinite width to get natural size
|
||||
var def = pos.Column < _columnDefinitions.Count ? _columnDefinitions[pos.Column] : GridLength.Star;
|
||||
if (def.IsAuto && pos.ColumnSpan == 1)
|
||||
{
|
||||
var childSize = child.Measure(new SKSize(float.PositiveInfinity, float.PositiveInfinity));
|
||||
var childWidth = float.IsNaN(childSize.Width) ? 0 : childSize.Width;
|
||||
columnNaturalWidths[pos.Column] = Math.Max(columnNaturalWidths[pos.Column], childWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate column widths - handle Auto, Absolute, and Star
|
||||
_columnWidths = CalculateSizesWithAuto(_columnDefinitions, contentWidth, ColumnSpacing, columnCount, columnNaturalWidths);
|
||||
|
||||
// Second pass: measure all children with calculated column widths
|
||||
foreach (var child in Children)
|
||||
{
|
||||
if (!child.IsVisible) continue;
|
||||
|
||||
var pos = GetPosition(child);
|
||||
var cellWidth = GetCellWidth(pos.Column, pos.ColumnSpan);
|
||||
|
||||
// Give infinite height for initial measure
|
||||
var childSize = child.Measure(new SKSize(cellWidth, float.PositiveInfinity));
|
||||
|
||||
// Track max height for each row
|
||||
// Cap infinite/very large heights - child returning infinity means it doesn't have a natural height
|
||||
var childHeight = childSize.Height;
|
||||
if (float.IsNaN(childHeight) || float.IsInfinity(childHeight) || childHeight > 100000)
|
||||
{
|
||||
// Use a default minimum - will be expanded by Star sizing if finite height is available
|
||||
childHeight = 44; // Standard row height
|
||||
}
|
||||
if (pos.RowSpan == 1)
|
||||
{
|
||||
rowNaturalHeights[pos.Row] = Math.Max(rowNaturalHeights[pos.Row], childHeight);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate row heights - use natural heights when available height is infinite or very large
|
||||
// (Some layouts pass float.MaxValue instead of PositiveInfinity)
|
||||
if (float.IsInfinity(contentHeight) || contentHeight > 100000)
|
||||
{
|
||||
_rowHeights = rowNaturalHeights;
|
||||
}
|
||||
else
|
||||
{
|
||||
_rowHeights = CalculateSizesWithAuto(_rowDefinitions, contentHeight, RowSpacing, rowCount, rowNaturalHeights);
|
||||
}
|
||||
|
||||
// Third pass: re-measure children with actual cell sizes
|
||||
foreach (var child in Children)
|
||||
{
|
||||
if (!child.IsVisible) continue;
|
||||
@@ -360,7 +613,27 @@ public class SkiaGrid : SkiaLayoutView
|
||||
totalHeight + Padding.Top + Padding.Bottom);
|
||||
}
|
||||
|
||||
private float[] CalculateSizes(List<GridLength> definitions, float available, float spacing, int count)
|
||||
private int GetMaxRow()
|
||||
{
|
||||
int maxRow = 0;
|
||||
foreach (var pos in _childPositions.Values)
|
||||
{
|
||||
maxRow = Math.Max(maxRow, pos.Row + pos.RowSpan - 1);
|
||||
}
|
||||
return maxRow;
|
||||
}
|
||||
|
||||
private int GetMaxColumn()
|
||||
{
|
||||
int maxCol = 0;
|
||||
foreach (var pos in _childPositions.Values)
|
||||
{
|
||||
maxCol = Math.Max(maxCol, pos.Column + pos.ColumnSpan - 1);
|
||||
}
|
||||
return maxCol;
|
||||
}
|
||||
|
||||
private float[] CalculateSizesWithAuto(List<GridLength> definitions, float available, float spacing, int count, float[] naturalSizes)
|
||||
{
|
||||
if (count == 0) return new float[] { available };
|
||||
|
||||
@@ -381,7 +654,9 @@ public class SkiaGrid : SkiaLayoutView
|
||||
}
|
||||
else if (def.IsAuto)
|
||||
{
|
||||
sizes[i] = 0; // Will be calculated from children
|
||||
// Use natural size from measured children
|
||||
sizes[i] = naturalSizes[i];
|
||||
remainingSpace -= sizes[i];
|
||||
}
|
||||
else if (def.IsStar)
|
||||
{
|
||||
@@ -389,7 +664,7 @@ public class SkiaGrid : SkiaLayoutView
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: star sizes
|
||||
// Second pass: star sizes (distribute remaining space)
|
||||
if (starTotal > 0 && remainingSpace > 0)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
@@ -449,7 +724,52 @@ public class SkiaGrid : SkiaLayoutView
|
||||
|
||||
protected override SKRect ArrangeOverride(SKRect bounds)
|
||||
{
|
||||
var content = GetContentBounds(bounds);
|
||||
try
|
||||
{
|
||||
var content = GetContentBounds(bounds);
|
||||
|
||||
// Recalculate row heights for arrange bounds if they differ from measurement
|
||||
// This ensures Star rows expand to fill available space
|
||||
var rowCount = _rowHeights.Length > 0 ? _rowHeights.Length : 1;
|
||||
var columnCount = _columnWidths.Length > 0 ? _columnWidths.Length : 1;
|
||||
var arrangeRowHeights = _rowHeights;
|
||||
|
||||
// If we have arrange height and rows need recalculating
|
||||
if (content.Height > 0 && !float.IsInfinity(content.Height))
|
||||
{
|
||||
var measuredRowsTotal = _rowHeights.Sum() + Math.Max(0, rowCount - 1) * RowSpacing;
|
||||
|
||||
// If arrange height is larger than measured, redistribute to Star rows
|
||||
if (content.Height > measuredRowsTotal + 1)
|
||||
{
|
||||
arrangeRowHeights = new float[rowCount];
|
||||
var extraHeight = content.Height - measuredRowsTotal;
|
||||
|
||||
// Count Star rows (implicit rows without definitions are Star)
|
||||
float totalStarWeight = 0;
|
||||
for (int i = 0; i < rowCount; i++)
|
||||
{
|
||||
var def = i < _rowDefinitions.Count ? _rowDefinitions[i] : GridLength.Star;
|
||||
if (def.IsStar) totalStarWeight += def.Value;
|
||||
}
|
||||
|
||||
// Distribute extra height to Star rows
|
||||
for (int i = 0; i < rowCount; i++)
|
||||
{
|
||||
var def = i < _rowDefinitions.Count ? _rowDefinitions[i] : GridLength.Star;
|
||||
arrangeRowHeights[i] = i < _rowHeights.Length ? _rowHeights[i] : 0;
|
||||
|
||||
if (def.IsStar && totalStarWeight > 0)
|
||||
{
|
||||
arrangeRowHeights[i] += extraHeight * (def.Value / totalStarWeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
arrangeRowHeights = _rowHeights;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var child in Children)
|
||||
{
|
||||
@@ -458,13 +778,48 @@ public class SkiaGrid : SkiaLayoutView
|
||||
var pos = GetPosition(child);
|
||||
|
||||
var x = content.Left + GetColumnOffset(pos.Column);
|
||||
var y = content.Top + GetRowOffset(pos.Row);
|
||||
var width = GetCellWidth(pos.Column, pos.ColumnSpan);
|
||||
var height = GetCellHeight(pos.Row, pos.RowSpan);
|
||||
|
||||
child.Arrange(new SKRect(x, y, x + width, y + height));
|
||||
// Calculate y using arrange row heights
|
||||
float y = content.Top;
|
||||
for (int i = 0; i < Math.Min(pos.Row, arrangeRowHeights.Length); i++)
|
||||
{
|
||||
y += arrangeRowHeights[i] + RowSpacing;
|
||||
}
|
||||
|
||||
var width = GetCellWidth(pos.Column, pos.ColumnSpan);
|
||||
|
||||
// Calculate height using arrange row heights
|
||||
float height = 0;
|
||||
for (int i = pos.Row; i < Math.Min(pos.Row + pos.RowSpan, arrangeRowHeights.Length); i++)
|
||||
{
|
||||
height += arrangeRowHeights[i];
|
||||
if (i > pos.Row) height += RowSpacing;
|
||||
}
|
||||
|
||||
// Clamp infinite dimensions
|
||||
if (float.IsInfinity(width) || float.IsNaN(width))
|
||||
width = content.Width;
|
||||
if (float.IsInfinity(height) || float.IsNaN(height) || height <= 0)
|
||||
height = content.Height;
|
||||
|
||||
// Apply child's margin
|
||||
var margin = child.Margin;
|
||||
var marginedBounds = new SKRect(
|
||||
x + (float)margin.Left,
|
||||
y + (float)margin.Top,
|
||||
x + width - (float)margin.Right,
|
||||
y + height - (float)margin.Bottom);
|
||||
child.Arrange(marginedBounds);
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SkiaGrid] EXCEPTION in ArrangeOverride: {ex.GetType().Name}: {ex.Message}");
|
||||
Console.WriteLine($"[SkiaGrid] Bounds: {bounds}, RowHeights: {_rowHeights.Length}, RowDefs: {_rowDefinitions.Count}, Children: {Children.Count}");
|
||||
Console.WriteLine($"[SkiaGrid] Stack trace: {ex.StackTrace}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,7 +984,14 @@ public class SkiaAbsoluteLayout : SkiaLayoutView
|
||||
else
|
||||
height = childBounds.Height;
|
||||
|
||||
child.Arrange(new SKRect(x, y, x + width, y + height));
|
||||
// Apply child's margin
|
||||
var margin = child.Margin;
|
||||
var marginedBounds = new SKRect(
|
||||
x + (float)margin.Left,
|
||||
y + (float)margin.Top,
|
||||
x + width - (float)margin.Right,
|
||||
y + height - (float)margin.Bottom);
|
||||
child.Arrange(marginedBounds);
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
|
||||
@@ -350,6 +350,7 @@ public class SkiaNavigationPage : SkiaView
|
||||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"[SkiaNavigationPage] OnPointerPressed at ({e.X}, {e.Y}), _isAnimating={_isAnimating}");
|
||||
if (_isAnimating) return;
|
||||
|
||||
// Check for back button click
|
||||
@@ -357,11 +358,13 @@ public class SkiaNavigationPage : SkiaView
|
||||
{
|
||||
if (e.X < 56 && e.Y < _navigationBarHeight)
|
||||
{
|
||||
Console.WriteLine($"[SkiaNavigationPage] Back button clicked");
|
||||
Pop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[SkiaNavigationPage] Forwarding to _currentPage: {_currentPage?.GetType().Name}");
|
||||
_currentPage?.OnPointerPressed(e);
|
||||
}
|
||||
|
||||
@@ -403,6 +406,35 @@ public class SkiaNavigationPage : SkiaView
|
||||
if (_isAnimating) return;
|
||||
_currentPage?.OnScroll(e);
|
||||
}
|
||||
|
||||
public override SkiaView? HitTest(float x, float y)
|
||||
{
|
||||
if (!IsVisible)
|
||||
return null;
|
||||
|
||||
// Back button area - return self so OnPointerPressed handles it
|
||||
if (_showBackButton && _navigationStack.Count > 0 && x < 56 && y < _navigationBarHeight)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
// Check current page
|
||||
if (_currentPage != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hit = _currentPage.HitTest(x, y);
|
||||
if (hit != null)
|
||||
return hit;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SkiaNavigationPage] HitTest error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -153,7 +153,19 @@ public class SkiaPage : SkiaView
|
||||
// Draw content
|
||||
if (_content != null)
|
||||
{
|
||||
_content.Bounds = contentBounds;
|
||||
// Apply content's margin to the content bounds
|
||||
var margin = _content.Margin;
|
||||
var adjustedBounds = new SKRect(
|
||||
contentBounds.Left + (float)margin.Left,
|
||||
contentBounds.Top + (float)margin.Top,
|
||||
contentBounds.Right - (float)margin.Right,
|
||||
contentBounds.Bottom - (float)margin.Bottom);
|
||||
|
||||
// Measure and arrange the content before drawing
|
||||
var availableSize = new SKSize(adjustedBounds.Width, adjustedBounds.Height);
|
||||
_content.Measure(availableSize);
|
||||
_content.Arrange(adjustedBounds);
|
||||
Console.WriteLine($"[SkiaPage] Drawing content: {_content.GetType().Name}, Bounds={_content.Bounds}, IsVisible={_content.IsVisible}");
|
||||
_content.Draw(canvas);
|
||||
}
|
||||
|
||||
@@ -233,6 +245,7 @@ public class SkiaPage : SkiaView
|
||||
|
||||
public void OnAppearing()
|
||||
{
|
||||
Console.WriteLine($"[SkiaPage] OnAppearing called for: {Title}, HasListeners={Appearing != null}");
|
||||
Appearing?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
@@ -292,13 +305,160 @@ public class SkiaPage : SkiaView
|
||||
{
|
||||
_content?.OnScroll(e);
|
||||
}
|
||||
|
||||
public override SkiaView? HitTest(float x, float y)
|
||||
{
|
||||
if (!IsVisible)
|
||||
return null;
|
||||
|
||||
// Don't check Bounds.Contains for page - it may not be set
|
||||
// Just forward to content
|
||||
|
||||
// Check content
|
||||
if (_content != null)
|
||||
{
|
||||
var hit = _content.HitTest(x, y);
|
||||
if (hit != null)
|
||||
return hit;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple content page view.
|
||||
/// Simple content page view with toolbar items support.
|
||||
/// </summary>
|
||||
public class SkiaContentPage : SkiaPage
|
||||
{
|
||||
// SkiaContentPage is essentially the same as SkiaPage
|
||||
// but represents a ContentPage specifically
|
||||
private readonly List<SkiaToolbarItem> _toolbarItems = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolbar items for this page.
|
||||
/// </summary>
|
||||
public IList<SkiaToolbarItem> ToolbarItems => _toolbarItems;
|
||||
|
||||
protected override void DrawNavigationBar(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Draw navigation bar background
|
||||
using var barPaint = new SKPaint
|
||||
{
|
||||
Color = TitleBarColor,
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
canvas.DrawRect(bounds, barPaint);
|
||||
|
||||
// Draw title
|
||||
if (!string.IsNullOrEmpty(Title))
|
||||
{
|
||||
using var font = new SKFont(SKTypeface.Default, 20);
|
||||
using var textPaint = new SKPaint(font)
|
||||
{
|
||||
Color = TitleTextColor,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(Title, ref textBounds);
|
||||
|
||||
var x = bounds.Left + 56; // Leave space for back button
|
||||
var y = bounds.MidY - textBounds.MidY;
|
||||
canvas.DrawText(Title, x, y, textPaint);
|
||||
}
|
||||
|
||||
// Draw toolbar items on the right
|
||||
DrawToolbarItems(canvas, bounds);
|
||||
|
||||
// Draw shadow
|
||||
using var shadowPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(0, 0, 0, 30),
|
||||
Style = SKPaintStyle.Fill,
|
||||
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 2)
|
||||
};
|
||||
canvas.DrawRect(new SKRect(bounds.Left, bounds.Bottom, bounds.Right, bounds.Bottom + 4), shadowPaint);
|
||||
}
|
||||
|
||||
private void DrawToolbarItems(SKCanvas canvas, SKRect navBarBounds)
|
||||
{
|
||||
var primaryItems = _toolbarItems.Where(t => t.Order == SkiaToolbarItemOrder.Primary).ToList();
|
||||
Console.WriteLine($"[SkiaContentPage] DrawToolbarItems: {primaryItems.Count} primary items, navBarBounds={navBarBounds}");
|
||||
if (primaryItems.Count == 0) return;
|
||||
|
||||
using var font = new SKFont(SKTypeface.Default, 14);
|
||||
using var textPaint = new SKPaint(font)
|
||||
{
|
||||
Color = TitleTextColor,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
float rightEdge = navBarBounds.Right - 16;
|
||||
|
||||
foreach (var item in primaryItems.AsEnumerable().Reverse())
|
||||
{
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(item.Text, ref textBounds);
|
||||
|
||||
var itemWidth = textBounds.Width + 24; // Padding
|
||||
var itemLeft = rightEdge - itemWidth;
|
||||
|
||||
// Store hit area for click handling
|
||||
item.HitBounds = new SKRect(itemLeft, navBarBounds.Top, rightEdge, navBarBounds.Bottom);
|
||||
Console.WriteLine($"[SkiaContentPage] Toolbar item '{item.Text}' HitBounds set to {item.HitBounds}");
|
||||
|
||||
// Draw text
|
||||
var x = itemLeft + 12;
|
||||
var y = navBarBounds.MidY - textBounds.MidY;
|
||||
canvas.DrawText(item.Text, x, y, textPaint);
|
||||
|
||||
rightEdge = itemLeft - 8; // Gap between items
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"[SkiaContentPage] OnPointerPressed at ({e.X}, {e.Y}), ShowNavigationBar={ShowNavigationBar}, NavigationBarHeight={NavigationBarHeight}");
|
||||
Console.WriteLine($"[SkiaContentPage] ToolbarItems count: {_toolbarItems.Count}");
|
||||
|
||||
// Check toolbar item clicks
|
||||
if (ShowNavigationBar && e.Y < NavigationBarHeight)
|
||||
{
|
||||
Console.WriteLine($"[SkiaContentPage] In navigation bar area, checking toolbar items");
|
||||
foreach (var item in _toolbarItems.Where(t => t.Order == SkiaToolbarItemOrder.Primary))
|
||||
{
|
||||
var bounds = item.HitBounds;
|
||||
var contains = bounds.Contains(e.X, e.Y);
|
||||
Console.WriteLine($"[SkiaContentPage] Checking item '{item.Text}', HitBounds=({bounds.Left},{bounds.Top},{bounds.Right},{bounds.Bottom}), Click=({e.X},{e.Y}), Contains={contains}, Command={item.Command != null}");
|
||||
if (contains)
|
||||
{
|
||||
Console.WriteLine($"[SkiaContentPage] Toolbar item clicked: {item.Text}");
|
||||
item.Command?.Execute(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Console.WriteLine($"[SkiaContentPage] No toolbar item hit");
|
||||
}
|
||||
|
||||
base.OnPointerPressed(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a toolbar item in the navigation bar.
|
||||
/// </summary>
|
||||
public class SkiaToolbarItem
|
||||
{
|
||||
public string Text { get; set; } = "";
|
||||
public SkiaToolbarItemOrder Order { get; set; } = SkiaToolbarItemOrder.Primary;
|
||||
public System.Windows.Input.ICommand? Command { get; set; }
|
||||
public SKRect HitBounds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Order of toolbar items.
|
||||
/// </summary>
|
||||
public enum SkiaToolbarItemOrder
|
||||
{
|
||||
Primary,
|
||||
Secondary
|
||||
}
|
||||
|
||||
@@ -6,67 +6,301 @@ using SkiaSharp;
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered picker/dropdown control.
|
||||
/// Skia-rendered picker/dropdown control with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaPicker : SkiaView
|
||||
{
|
||||
private List<string> _items = new();
|
||||
private int _selectedIndex = -1;
|
||||
private bool _isOpen;
|
||||
private string _title = "";
|
||||
private float _dropdownMaxHeight = 200;
|
||||
private int _hoveredItemIndex = -1;
|
||||
#region BindableProperties
|
||||
|
||||
// Styling
|
||||
public SKColor TextColor { get; set; } = SKColors.Black;
|
||||
public SKColor TitleColor { get; set; } = new SKColor(0x80, 0x80, 0x80);
|
||||
public SKColor BorderColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public SKColor DropdownBackgroundColor { get; set; } = SKColors.White;
|
||||
public SKColor SelectedItemBackgroundColor { get; set; } = new SKColor(0x21, 0x96, 0xF3, 0x30);
|
||||
public SKColor HoverItemBackgroundColor { get; set; } = new SKColor(0xE0, 0xE0, 0xE0);
|
||||
public string FontFamily { get; set; } = "Sans";
|
||||
public float FontSize { get; set; } = 14;
|
||||
public float ItemHeight { get; set; } = 40;
|
||||
public float CornerRadius { get; set; } = 4;
|
||||
/// <summary>
|
||||
/// Bindable property for SelectedIndex.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty SelectedIndexProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(SelectedIndex),
|
||||
typeof(int),
|
||||
typeof(SkiaPicker),
|
||||
-1,
|
||||
BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).OnSelectedIndexChanged());
|
||||
|
||||
public IList<string> Items => _items;
|
||||
/// <summary>
|
||||
/// Bindable property for Title.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TitleProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Title),
|
||||
typeof(string),
|
||||
typeof(SkiaPicker),
|
||||
"",
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for TextColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TextColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(TextColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaPicker),
|
||||
SKColors.Black,
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for TitleColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TitleColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(TitleColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaPicker),
|
||||
new SKColor(0x80, 0x80, 0x80),
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for BorderColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty BorderColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(BorderColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaPicker),
|
||||
new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for DropdownBackgroundColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty DropdownBackgroundColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(DropdownBackgroundColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaPicker),
|
||||
SKColors.White,
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for SelectedItemBackgroundColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty SelectedItemBackgroundColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(SelectedItemBackgroundColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaPicker),
|
||||
new SKColor(0x21, 0x96, 0xF3, 0x30),
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for HoverItemBackgroundColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty HoverItemBackgroundColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(HoverItemBackgroundColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaPicker),
|
||||
new SKColor(0xE0, 0xE0, 0xE0),
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for FontFamily.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty FontFamilyProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(FontFamily),
|
||||
typeof(string),
|
||||
typeof(SkiaPicker),
|
||||
"Sans",
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for FontSize.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty FontSizeProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(FontSize),
|
||||
typeof(float),
|
||||
typeof(SkiaPicker),
|
||||
14f,
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ItemHeight.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ItemHeightProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ItemHeight),
|
||||
typeof(float),
|
||||
typeof(SkiaPicker),
|
||||
40f,
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CornerRadius.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CornerRadiusProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CornerRadius),
|
||||
typeof(float),
|
||||
typeof(SkiaPicker),
|
||||
4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).Invalidate());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the selected index.
|
||||
/// </summary>
|
||||
public int SelectedIndex
|
||||
{
|
||||
get => _selectedIndex;
|
||||
set
|
||||
{
|
||||
if (_selectedIndex != value)
|
||||
{
|
||||
_selectedIndex = value;
|
||||
SelectedIndexChanged?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (int)GetValue(SelectedIndexProperty);
|
||||
set => SetValue(SelectedIndexProperty, value);
|
||||
}
|
||||
|
||||
public string? SelectedItem => _selectedIndex >= 0 && _selectedIndex < _items.Count ? _items[_selectedIndex] : null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the title/placeholder.
|
||||
/// </summary>
|
||||
public string Title
|
||||
{
|
||||
get => _title;
|
||||
set
|
||||
{
|
||||
_title = value;
|
||||
Invalidate();
|
||||
}
|
||||
get => (string)GetValue(TitleProperty);
|
||||
set => SetValue(TitleProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text color.
|
||||
/// </summary>
|
||||
public SKColor TextColor
|
||||
{
|
||||
get => (SKColor)GetValue(TextColorProperty);
|
||||
set => SetValue(TextColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the title color.
|
||||
/// </summary>
|
||||
public SKColor TitleColor
|
||||
{
|
||||
get => (SKColor)GetValue(TitleColorProperty);
|
||||
set => SetValue(TitleColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the border color.
|
||||
/// </summary>
|
||||
public SKColor BorderColor
|
||||
{
|
||||
get => (SKColor)GetValue(BorderColorProperty);
|
||||
set => SetValue(BorderColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the dropdown background color.
|
||||
/// </summary>
|
||||
public SKColor DropdownBackgroundColor
|
||||
{
|
||||
get => (SKColor)GetValue(DropdownBackgroundColorProperty);
|
||||
set => SetValue(DropdownBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the selected item background color.
|
||||
/// </summary>
|
||||
public SKColor SelectedItemBackgroundColor
|
||||
{
|
||||
get => (SKColor)GetValue(SelectedItemBackgroundColorProperty);
|
||||
set => SetValue(SelectedItemBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the hover item background color.
|
||||
/// </summary>
|
||||
public SKColor HoverItemBackgroundColor
|
||||
{
|
||||
get => (SKColor)GetValue(HoverItemBackgroundColorProperty);
|
||||
set => SetValue(HoverItemBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the font family.
|
||||
/// </summary>
|
||||
public string FontFamily
|
||||
{
|
||||
get => (string)GetValue(FontFamilyProperty);
|
||||
set => SetValue(FontFamilyProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the font size.
|
||||
/// </summary>
|
||||
public float FontSize
|
||||
{
|
||||
get => (float)GetValue(FontSizeProperty);
|
||||
set => SetValue(FontSizeProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the item height.
|
||||
/// </summary>
|
||||
public float ItemHeight
|
||||
{
|
||||
get => (float)GetValue(ItemHeightProperty);
|
||||
set => SetValue(ItemHeightProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the corner radius.
|
||||
/// </summary>
|
||||
public float CornerRadius
|
||||
{
|
||||
get => (float)GetValue(CornerRadiusProperty);
|
||||
set => SetValue(CornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the items list.
|
||||
/// </summary>
|
||||
public IList<string> Items => _items;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the selected item.
|
||||
/// </summary>
|
||||
public string? SelectedItem => SelectedIndex >= 0 && SelectedIndex < _items.Count ? _items[SelectedIndex] : null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the dropdown is open.
|
||||
/// </summary>
|
||||
public bool IsOpen
|
||||
{
|
||||
get => _isOpen;
|
||||
set
|
||||
{
|
||||
_isOpen = value;
|
||||
Invalidate();
|
||||
if (_isOpen != value)
|
||||
{
|
||||
_isOpen = value;
|
||||
if (_isOpen)
|
||||
{
|
||||
RegisterPopupOverlay(this, DrawDropdownOverlay);
|
||||
}
|
||||
else
|
||||
{
|
||||
UnregisterPopupOverlay(this);
|
||||
}
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private readonly List<string> _items = new();
|
||||
private bool _isOpen;
|
||||
private float _dropdownMaxHeight = 200;
|
||||
private int _hoveredItemIndex = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when selected index changes.
|
||||
/// </summary>
|
||||
public event EventHandler? SelectedIndexChanged;
|
||||
|
||||
public SkiaPicker()
|
||||
@@ -74,25 +308,36 @@ public class SkiaPicker : SkiaView
|
||||
IsFocusable = true;
|
||||
}
|
||||
|
||||
private void OnSelectedIndexChanged()
|
||||
{
|
||||
SelectedIndexChanged?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the items in the picker.
|
||||
/// </summary>
|
||||
public void SetItems(IEnumerable<string> items)
|
||||
{
|
||||
_items.Clear();
|
||||
_items.AddRange(items);
|
||||
if (_selectedIndex >= _items.Count)
|
||||
if (SelectedIndex >= _items.Count)
|
||||
{
|
||||
_selectedIndex = _items.Count > 0 ? 0 : -1;
|
||||
SelectedIndex = _items.Count > 0 ? 0 : -1;
|
||||
}
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void DrawDropdownOverlay(SKCanvas canvas)
|
||||
{
|
||||
if (_items.Count == 0 || !_isOpen) return;
|
||||
// Use ScreenBounds for overlay drawing to account for scroll offset
|
||||
DrawDropdown(canvas, ScreenBounds);
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
DrawPickerButton(canvas, bounds);
|
||||
|
||||
if (_isOpen)
|
||||
{
|
||||
DrawDropdown(canvas, bounds);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawPickerButton(SKCanvas canvas, SKRect bounds)
|
||||
@@ -126,14 +371,14 @@ public class SkiaPicker : SkiaView
|
||||
};
|
||||
|
||||
string displayText;
|
||||
if (_selectedIndex >= 0 && _selectedIndex < _items.Count)
|
||||
if (SelectedIndex >= 0 && SelectedIndex < _items.Count)
|
||||
{
|
||||
displayText = _items[_selectedIndex];
|
||||
displayText = _items[SelectedIndex];
|
||||
textPaint.Color = IsEnabled ? TextColor : TextColor.WithAlpha(128);
|
||||
}
|
||||
else
|
||||
{
|
||||
displayText = _title;
|
||||
displayText = Title;
|
||||
textPaint.Color = TitleColor;
|
||||
}
|
||||
|
||||
@@ -166,14 +411,12 @@ public class SkiaPicker : SkiaView
|
||||
using var path = new SKPath();
|
||||
if (_isOpen)
|
||||
{
|
||||
// Up arrow
|
||||
path.MoveTo(centerX - arrowSize, centerY + arrowSize / 2);
|
||||
path.LineTo(centerX, centerY - arrowSize / 2);
|
||||
path.LineTo(centerX + arrowSize, centerY + arrowSize / 2);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Down arrow
|
||||
path.MoveTo(centerX - arrowSize, centerY - arrowSize / 2);
|
||||
path.LineTo(centerX, centerY + arrowSize / 2);
|
||||
path.LineTo(centerX + arrowSize, centerY - arrowSize / 2);
|
||||
@@ -242,7 +485,7 @@ public class SkiaPicker : SkiaView
|
||||
var itemRect = new SKRect(dropdownRect.Left, itemTop, dropdownRect.Right, itemTop + ItemHeight);
|
||||
|
||||
// Draw item background
|
||||
if (i == _selectedIndex)
|
||||
if (i == SelectedIndex)
|
||||
{
|
||||
using var selectedPaint = new SKPaint
|
||||
{
|
||||
@@ -277,10 +520,11 @@ public class SkiaPicker : SkiaView
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
|
||||
if (_isOpen)
|
||||
if (IsOpen)
|
||||
{
|
||||
// Check if clicked on dropdown item
|
||||
var dropdownTop = Bounds.Bottom + 4;
|
||||
// Use ScreenBounds for popup coordinate calculations (accounts for scroll offset)
|
||||
var screenBounds = ScreenBounds;
|
||||
var dropdownTop = screenBounds.Bottom + 4;
|
||||
if (e.Y >= dropdownTop)
|
||||
{
|
||||
var itemIndex = (int)((e.Y - dropdownTop) / ItemHeight);
|
||||
@@ -289,15 +533,11 @@ public class SkiaPicker : SkiaView
|
||||
SelectedIndex = itemIndex;
|
||||
}
|
||||
}
|
||||
_isOpen = false;
|
||||
IsOpen = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if clicked on picker button
|
||||
if (e.Y < Bounds.Bottom)
|
||||
{
|
||||
_isOpen = true;
|
||||
}
|
||||
IsOpen = true;
|
||||
}
|
||||
|
||||
Invalidate();
|
||||
@@ -307,7 +547,9 @@ public class SkiaPicker : SkiaView
|
||||
{
|
||||
if (!_isOpen) return;
|
||||
|
||||
var dropdownTop = Bounds.Bottom + 4;
|
||||
// Use ScreenBounds for popup coordinate calculations (accounts for scroll offset)
|
||||
var screenBounds = ScreenBounds;
|
||||
var dropdownTop = screenBounds.Bottom + 4;
|
||||
if (e.Y >= dropdownTop)
|
||||
{
|
||||
var newHovered = (int)((e.Y - dropdownTop) / ItemHeight);
|
||||
@@ -341,27 +583,22 @@ public class SkiaPicker : SkiaView
|
||||
{
|
||||
case Key.Enter:
|
||||
case Key.Space:
|
||||
_isOpen = !_isOpen;
|
||||
IsOpen = !IsOpen;
|
||||
e.Handled = true;
|
||||
Invalidate();
|
||||
break;
|
||||
|
||||
case Key.Escape:
|
||||
if (_isOpen)
|
||||
if (IsOpen)
|
||||
{
|
||||
_isOpen = false;
|
||||
IsOpen = false;
|
||||
e.Handled = true;
|
||||
Invalidate();
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.Up:
|
||||
if (_isOpen && _selectedIndex > 0)
|
||||
{
|
||||
SelectedIndex--;
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (!_isOpen && _selectedIndex > 0)
|
||||
if (SelectedIndex > 0)
|
||||
{
|
||||
SelectedIndex--;
|
||||
e.Handled = true;
|
||||
@@ -369,12 +606,7 @@ public class SkiaPicker : SkiaView
|
||||
break;
|
||||
|
||||
case Key.Down:
|
||||
if (_isOpen && _selectedIndex < _items.Count - 1)
|
||||
{
|
||||
SelectedIndex++;
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (!_isOpen && _selectedIndex < _items.Count - 1)
|
||||
if (SelectedIndex < _items.Count - 1)
|
||||
{
|
||||
SelectedIndex++;
|
||||
e.Handled = true;
|
||||
@@ -383,10 +615,47 @@ public class SkiaPicker : SkiaView
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnFocusLost()
|
||||
{
|
||||
base.OnFocusLost();
|
||||
if (IsOpen)
|
||||
{
|
||||
IsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
return new SKSize(
|
||||
availableSize.Width < float.MaxValue ? Math.Min(availableSize.Width, 200) : 200,
|
||||
40);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override to include dropdown area in hit testing.
|
||||
/// </summary>
|
||||
protected override bool HitTestPopupArea(float x, float y)
|
||||
{
|
||||
// Use ScreenBounds for hit testing (accounts for scroll offset)
|
||||
var screenBounds = ScreenBounds;
|
||||
|
||||
// Always include the picker button itself
|
||||
if (screenBounds.Contains(x, y))
|
||||
return true;
|
||||
|
||||
// When open, also include the dropdown area
|
||||
if (_isOpen && _items.Count > 0)
|
||||
{
|
||||
var dropdownHeight = Math.Min(_items.Count * ItemHeight, _dropdownMaxHeight);
|
||||
var dropdownRect = new SKRect(
|
||||
screenBounds.Left,
|
||||
screenBounds.Bottom + 4,
|
||||
screenBounds.Right,
|
||||
screenBounds.Bottom + 4 + dropdownHeight);
|
||||
|
||||
return dropdownRect.Contains(x, y);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,40 +6,156 @@ using SkiaSharp;
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered progress bar control.
|
||||
/// Skia-rendered progress bar control with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaProgressBar : SkiaView
|
||||
{
|
||||
private double _progress;
|
||||
#region BindableProperties
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Progress.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ProgressProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Progress),
|
||||
typeof(double),
|
||||
typeof(SkiaProgressBar),
|
||||
0.0,
|
||||
BindingMode.TwoWay,
|
||||
coerceValue: (b, v) => Math.Clamp((double)v, 0, 1),
|
||||
propertyChanged: (b, o, n) => ((SkiaProgressBar)b).OnProgressChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for TrackColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TrackColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(TrackColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaProgressBar),
|
||||
new SKColor(0xE0, 0xE0, 0xE0),
|
||||
propertyChanged: (b, o, n) => ((SkiaProgressBar)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ProgressColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ProgressColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ProgressColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaProgressBar),
|
||||
new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaProgressBar)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for DisabledColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty DisabledColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(DisabledColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaProgressBar),
|
||||
new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaProgressBar)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for BarHeight.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty BarHeightProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(BarHeight),
|
||||
typeof(float),
|
||||
typeof(SkiaProgressBar),
|
||||
4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaProgressBar)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CornerRadius.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CornerRadiusProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CornerRadius),
|
||||
typeof(float),
|
||||
typeof(SkiaProgressBar),
|
||||
2f,
|
||||
propertyChanged: (b, o, n) => ((SkiaProgressBar)b).Invalidate());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the progress value (0.0 to 1.0).
|
||||
/// </summary>
|
||||
public double Progress
|
||||
{
|
||||
get => _progress;
|
||||
set
|
||||
{
|
||||
var clamped = Math.Clamp(value, 0, 1);
|
||||
if (_progress != clamped)
|
||||
{
|
||||
_progress = clamped;
|
||||
ProgressChanged?.Invoke(this, new ProgressChangedEventArgs(_progress));
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (double)GetValue(ProgressProperty);
|
||||
set => SetValue(ProgressProperty, value);
|
||||
}
|
||||
|
||||
public SKColor TrackColor { get; set; } = new SKColor(0xE0, 0xE0, 0xE0);
|
||||
public SKColor ProgressColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public SKColor DisabledColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public float Height { get; set; } = 4;
|
||||
public float CornerRadius { get; set; } = 2;
|
||||
/// <summary>
|
||||
/// Gets or sets the track color.
|
||||
/// </summary>
|
||||
public SKColor TrackColor
|
||||
{
|
||||
get => (SKColor)GetValue(TrackColorProperty);
|
||||
set => SetValue(TrackColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the progress color.
|
||||
/// </summary>
|
||||
public SKColor ProgressColor
|
||||
{
|
||||
get => (SKColor)GetValue(ProgressColorProperty);
|
||||
set => SetValue(ProgressColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the disabled color.
|
||||
/// </summary>
|
||||
public SKColor DisabledColor
|
||||
{
|
||||
get => (SKColor)GetValue(DisabledColorProperty);
|
||||
set => SetValue(DisabledColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the bar height.
|
||||
/// </summary>
|
||||
public float BarHeight
|
||||
{
|
||||
get => (float)GetValue(BarHeightProperty);
|
||||
set => SetValue(BarHeightProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the corner radius.
|
||||
/// </summary>
|
||||
public float CornerRadius
|
||||
{
|
||||
get => (float)GetValue(CornerRadiusProperty);
|
||||
set => SetValue(CornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when progress changes.
|
||||
/// </summary>
|
||||
public event EventHandler<ProgressChangedEventArgs>? ProgressChanged;
|
||||
|
||||
private void OnProgressChanged()
|
||||
{
|
||||
ProgressChanged?.Invoke(this, new ProgressChangedEventArgs(Progress));
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
var trackY = bounds.MidY;
|
||||
var trackTop = trackY - Height / 2;
|
||||
var trackBottom = trackY + Height / 2;
|
||||
var trackTop = trackY - BarHeight / 2;
|
||||
var trackBottom = trackY + BarHeight / 2;
|
||||
|
||||
// Draw track
|
||||
using var trackPaint = new SKPaint
|
||||
@@ -75,10 +191,13 @@ public class SkiaProgressBar : SkiaView
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
return new SKSize(200, Height + 8);
|
||||
return new SKSize(200, BarHeight + 8);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event args for progress changed events.
|
||||
/// </summary>
|
||||
public class ProgressChangedEventArgs : EventArgs
|
||||
{
|
||||
public double Progress { get; }
|
||||
|
||||
@@ -6,73 +6,129 @@ using SkiaSharp;
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered radio button control.
|
||||
/// Skia-rendered radio button control with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaRadioButton : SkiaView
|
||||
{
|
||||
private bool _isChecked;
|
||||
private string _content = "";
|
||||
private object? _value;
|
||||
private string? _groupName;
|
||||
#region BindableProperties
|
||||
|
||||
// Styling
|
||||
public SKColor RadioColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public SKColor UncheckedColor { get; set; } = new SKColor(0x75, 0x75, 0x75);
|
||||
public SKColor TextColor { get; set; } = SKColors.Black;
|
||||
public SKColor DisabledColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public float FontSize { get; set; } = 14;
|
||||
public float RadioSize { get; set; } = 20;
|
||||
public float Spacing { get; set; } = 8;
|
||||
public static readonly BindableProperty IsCheckedProperty =
|
||||
BindableProperty.Create(nameof(IsChecked), typeof(bool), typeof(SkiaRadioButton), false, BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaRadioButton)b).OnIsCheckedChanged());
|
||||
|
||||
// Static group management
|
||||
private static readonly Dictionary<string, List<WeakReference<SkiaRadioButton>>> _groups = new();
|
||||
public static readonly BindableProperty ContentProperty =
|
||||
BindableProperty.Create(nameof(Content), typeof(string), typeof(SkiaRadioButton), "",
|
||||
propertyChanged: (b, o, n) => ((SkiaRadioButton)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty ValueProperty =
|
||||
BindableProperty.Create(nameof(Value), typeof(object), typeof(SkiaRadioButton), null);
|
||||
|
||||
public static readonly BindableProperty GroupNameProperty =
|
||||
BindableProperty.Create(nameof(GroupName), typeof(string), typeof(SkiaRadioButton), null,
|
||||
propertyChanged: (b, o, n) => ((SkiaRadioButton)b).OnGroupNameChanged((string?)o, (string?)n));
|
||||
|
||||
public static readonly BindableProperty RadioColorProperty =
|
||||
BindableProperty.Create(nameof(RadioColor), typeof(SKColor), typeof(SkiaRadioButton), new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaRadioButton)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty UncheckedColorProperty =
|
||||
BindableProperty.Create(nameof(UncheckedColor), typeof(SKColor), typeof(SkiaRadioButton), new SKColor(0x75, 0x75, 0x75),
|
||||
propertyChanged: (b, o, n) => ((SkiaRadioButton)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty TextColorProperty =
|
||||
BindableProperty.Create(nameof(TextColor), typeof(SKColor), typeof(SkiaRadioButton), SKColors.Black,
|
||||
propertyChanged: (b, o, n) => ((SkiaRadioButton)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty DisabledColorProperty =
|
||||
BindableProperty.Create(nameof(DisabledColor), typeof(SKColor), typeof(SkiaRadioButton), new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaRadioButton)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty FontSizeProperty =
|
||||
BindableProperty.Create(nameof(FontSize), typeof(float), typeof(SkiaRadioButton), 14f,
|
||||
propertyChanged: (b, o, n) => ((SkiaRadioButton)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty RadioSizeProperty =
|
||||
BindableProperty.Create(nameof(RadioSize), typeof(float), typeof(SkiaRadioButton), 20f,
|
||||
propertyChanged: (b, o, n) => ((SkiaRadioButton)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty SpacingProperty =
|
||||
BindableProperty.Create(nameof(Spacing), typeof(float), typeof(SkiaRadioButton), 8f,
|
||||
propertyChanged: (b, o, n) => ((SkiaRadioButton)b).InvalidateMeasure());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
public bool IsChecked
|
||||
{
|
||||
get => _isChecked;
|
||||
set
|
||||
{
|
||||
if (_isChecked != value)
|
||||
{
|
||||
_isChecked = value;
|
||||
|
||||
if (_isChecked && !string.IsNullOrEmpty(_groupName))
|
||||
{
|
||||
UncheckOthersInGroup();
|
||||
}
|
||||
|
||||
CheckedChanged?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (bool)GetValue(IsCheckedProperty);
|
||||
set => SetValue(IsCheckedProperty, value);
|
||||
}
|
||||
|
||||
public string Content
|
||||
{
|
||||
get => _content;
|
||||
set { _content = value ?? ""; Invalidate(); }
|
||||
get => (string)GetValue(ContentProperty);
|
||||
set => SetValue(ContentProperty, value);
|
||||
}
|
||||
|
||||
public object? Value
|
||||
{
|
||||
get => _value;
|
||||
set { _value = value; }
|
||||
get => GetValue(ValueProperty);
|
||||
set => SetValue(ValueProperty, value);
|
||||
}
|
||||
|
||||
public string? GroupName
|
||||
{
|
||||
get => _groupName;
|
||||
set
|
||||
{
|
||||
if (_groupName != value)
|
||||
{
|
||||
RemoveFromGroup();
|
||||
_groupName = value;
|
||||
AddToGroup();
|
||||
}
|
||||
}
|
||||
get => (string?)GetValue(GroupNameProperty);
|
||||
set => SetValue(GroupNameProperty, value);
|
||||
}
|
||||
|
||||
public SKColor RadioColor
|
||||
{
|
||||
get => (SKColor)GetValue(RadioColorProperty);
|
||||
set => SetValue(RadioColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor UncheckedColor
|
||||
{
|
||||
get => (SKColor)GetValue(UncheckedColorProperty);
|
||||
set => SetValue(UncheckedColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor TextColor
|
||||
{
|
||||
get => (SKColor)GetValue(TextColorProperty);
|
||||
set => SetValue(TextColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor DisabledColor
|
||||
{
|
||||
get => (SKColor)GetValue(DisabledColorProperty);
|
||||
set => SetValue(DisabledColorProperty, value);
|
||||
}
|
||||
|
||||
public float FontSize
|
||||
{
|
||||
get => (float)GetValue(FontSizeProperty);
|
||||
set => SetValue(FontSizeProperty, value);
|
||||
}
|
||||
|
||||
public float RadioSize
|
||||
{
|
||||
get => (float)GetValue(RadioSizeProperty);
|
||||
set => SetValue(RadioSizeProperty, value);
|
||||
}
|
||||
|
||||
public float Spacing
|
||||
{
|
||||
get => (float)GetValue(SpacingProperty);
|
||||
set => SetValue(SpacingProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static readonly Dictionary<string, List<WeakReference<SkiaRadioButton>>> _groups = new();
|
||||
|
||||
public event EventHandler? CheckedChanged;
|
||||
|
||||
public SkiaRadioButton()
|
||||
@@ -80,48 +136,59 @@ public class SkiaRadioButton : SkiaView
|
||||
IsFocusable = true;
|
||||
}
|
||||
|
||||
private void AddToGroup()
|
||||
private void OnIsCheckedChanged()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_groupName)) return;
|
||||
if (IsChecked && !string.IsNullOrEmpty(GroupName))
|
||||
{
|
||||
UncheckOthersInGroup();
|
||||
}
|
||||
CheckedChanged?.Invoke(this, EventArgs.Empty);
|
||||
SkiaVisualStateManager.GoToState(this, IsChecked ? SkiaVisualStateManager.CommonStates.Checked : SkiaVisualStateManager.CommonStates.Unchecked);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
if (!_groups.TryGetValue(_groupName, out var group))
|
||||
private void OnGroupNameChanged(string? oldValue, string? newValue)
|
||||
{
|
||||
RemoveFromGroup(oldValue);
|
||||
AddToGroup(newValue);
|
||||
}
|
||||
|
||||
private void AddToGroup(string? groupName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(groupName)) return;
|
||||
|
||||
if (!_groups.TryGetValue(groupName, out var group))
|
||||
{
|
||||
group = new List<WeakReference<SkiaRadioButton>>();
|
||||
_groups[_groupName] = group;
|
||||
_groups[groupName] = group;
|
||||
}
|
||||
|
||||
// Clean up dead references and add this one
|
||||
group.RemoveAll(wr => !wr.TryGetTarget(out _));
|
||||
group.Add(new WeakReference<SkiaRadioButton>(this));
|
||||
}
|
||||
|
||||
private void RemoveFromGroup()
|
||||
private void RemoveFromGroup(string? groupName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_groupName)) return;
|
||||
if (string.IsNullOrEmpty(groupName)) return;
|
||||
|
||||
if (_groups.TryGetValue(_groupName, out var group))
|
||||
if (_groups.TryGetValue(groupName, out var group))
|
||||
{
|
||||
group.RemoveAll(wr => !wr.TryGetTarget(out var target) || target == this);
|
||||
if (group.Count == 0)
|
||||
{
|
||||
_groups.Remove(_groupName);
|
||||
}
|
||||
if (group.Count == 0) _groups.Remove(groupName);
|
||||
}
|
||||
}
|
||||
|
||||
private void UncheckOthersInGroup()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_groupName)) return;
|
||||
if (string.IsNullOrEmpty(GroupName)) return;
|
||||
|
||||
if (_groups.TryGetValue(_groupName, out var group))
|
||||
if (_groups.TryGetValue(GroupName, out var group))
|
||||
{
|
||||
foreach (var weakRef in group)
|
||||
{
|
||||
if (weakRef.TryGetTarget(out var radioButton) && radioButton != this)
|
||||
if (weakRef.TryGetTarget(out var radioButton) && radioButton != this && radioButton.IsChecked)
|
||||
{
|
||||
radioButton._isChecked = false;
|
||||
radioButton.CheckedChanged?.Invoke(radioButton, EventArgs.Empty);
|
||||
radioButton.Invalidate();
|
||||
radioButton.SetValue(IsCheckedProperty, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,18 +200,16 @@ public class SkiaRadioButton : SkiaView
|
||||
var radioCenterX = bounds.Left + radioRadius;
|
||||
var radioCenterY = bounds.MidY;
|
||||
|
||||
// Draw outer circle
|
||||
using var outerPaint = new SKPaint
|
||||
{
|
||||
Color = IsEnabled ? (_isChecked ? RadioColor : UncheckedColor) : DisabledColor,
|
||||
Color = IsEnabled ? (IsChecked ? RadioColor : UncheckedColor) : DisabledColor,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 2,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawCircle(radioCenterX, radioCenterY, radioRadius - 1, outerPaint);
|
||||
|
||||
// Draw inner circle if checked
|
||||
if (_isChecked)
|
||||
if (IsChecked)
|
||||
{
|
||||
using var innerPaint = new SKPaint
|
||||
{
|
||||
@@ -155,7 +220,6 @@ public class SkiaRadioButton : SkiaView
|
||||
canvas.DrawCircle(radioCenterX, radioCenterY, radioRadius - 5, innerPaint);
|
||||
}
|
||||
|
||||
// Draw focus ring
|
||||
if (IsFocused)
|
||||
{
|
||||
using var focusPaint = new SKPaint
|
||||
@@ -167,8 +231,7 @@ public class SkiaRadioButton : SkiaView
|
||||
canvas.DrawCircle(radioCenterX, radioCenterY, radioRadius + 4, focusPaint);
|
||||
}
|
||||
|
||||
// Draw content text
|
||||
if (!string.IsNullOrEmpty(_content))
|
||||
if (!string.IsNullOrEmpty(Content))
|
||||
{
|
||||
using var font = new SKFont(SKTypeface.Default, FontSize);
|
||||
using var textPaint = new SKPaint(font)
|
||||
@@ -179,48 +242,43 @@ public class SkiaRadioButton : SkiaView
|
||||
|
||||
var textX = bounds.Left + RadioSize + Spacing;
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(_content, ref textBounds);
|
||||
canvas.DrawText(_content, textX, bounds.MidY - textBounds.MidY, textPaint);
|
||||
textPaint.MeasureText(Content, ref textBounds);
|
||||
canvas.DrawText(Content, textX, bounds.MidY - textBounds.MidY, textPaint);
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
|
||||
if (!_isChecked)
|
||||
{
|
||||
IsChecked = true;
|
||||
}
|
||||
if (!IsChecked) IsChecked = true;
|
||||
}
|
||||
|
||||
public override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
|
||||
switch (e.Key)
|
||||
if (e.Key == Key.Space || e.Key == Key.Enter)
|
||||
{
|
||||
case Key.Space:
|
||||
case Key.Enter:
|
||||
if (!_isChecked)
|
||||
{
|
||||
IsChecked = true;
|
||||
}
|
||||
e.Handled = true;
|
||||
break;
|
||||
if (!IsChecked) IsChecked = true;
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnEnabledChanged()
|
||||
{
|
||||
base.OnEnabledChanged();
|
||||
SkiaVisualStateManager.GoToState(this, IsEnabled ? SkiaVisualStateManager.CommonStates.Normal : SkiaVisualStateManager.CommonStates.Disabled);
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
var textWidth = 0f;
|
||||
if (!string.IsNullOrEmpty(_content))
|
||||
if (!string.IsNullOrEmpty(Content))
|
||||
{
|
||||
using var font = new SKFont(SKTypeface.Default, FontSize);
|
||||
using var paint = new SKPaint(font);
|
||||
textWidth = paint.MeasureText(_content) + Spacing;
|
||||
textWidth = paint.MeasureText(Content) + Spacing;
|
||||
}
|
||||
|
||||
return new SKSize(RadioSize + textWidth, Math.Max(RadioSize, FontSize * 1.5f));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,16 +6,132 @@ using SkiaSharp;
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered scroll view container.
|
||||
/// Skia-rendered scroll view container with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaScrollView : SkiaView
|
||||
{
|
||||
#region BindableProperties
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Orientation.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty OrientationProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Orientation),
|
||||
typeof(ScrollOrientation),
|
||||
typeof(SkiaScrollView),
|
||||
ScrollOrientation.Both,
|
||||
propertyChanged: (b, o, n) => ((SkiaScrollView)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for HorizontalScrollBarVisibility.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty HorizontalScrollBarVisibilityProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(HorizontalScrollBarVisibility),
|
||||
typeof(ScrollBarVisibility),
|
||||
typeof(SkiaScrollView),
|
||||
ScrollBarVisibility.Auto,
|
||||
propertyChanged: (b, o, n) => ((SkiaScrollView)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for VerticalScrollBarVisibility.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty VerticalScrollBarVisibilityProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(VerticalScrollBarVisibility),
|
||||
typeof(ScrollBarVisibility),
|
||||
typeof(SkiaScrollView),
|
||||
ScrollBarVisibility.Auto,
|
||||
propertyChanged: (b, o, n) => ((SkiaScrollView)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ScrollBarColor.
|
||||
/// </summary>
|
||||
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());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ScrollBarWidth.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ScrollBarWidthProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ScrollBarWidth),
|
||||
typeof(float),
|
||||
typeof(SkiaScrollView),
|
||||
8f,
|
||||
propertyChanged: (b, o, n) => ((SkiaScrollView)b).Invalidate());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the scroll orientation.
|
||||
/// </summary>
|
||||
public ScrollOrientation Orientation
|
||||
{
|
||||
get => (ScrollOrientation)GetValue(OrientationProperty);
|
||||
set => SetValue(OrientationProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to show horizontal scrollbar.
|
||||
/// </summary>
|
||||
public ScrollBarVisibility HorizontalScrollBarVisibility
|
||||
{
|
||||
get => (ScrollBarVisibility)GetValue(HorizontalScrollBarVisibilityProperty);
|
||||
set => SetValue(HorizontalScrollBarVisibilityProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to show vertical scrollbar.
|
||||
/// </summary>
|
||||
public ScrollBarVisibility VerticalScrollBarVisibility
|
||||
{
|
||||
get => (ScrollBarVisibility)GetValue(VerticalScrollBarVisibilityProperty);
|
||||
set => SetValue(VerticalScrollBarVisibilityProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scrollbar color.
|
||||
/// </summary>
|
||||
public SKColor ScrollBarColor
|
||||
{
|
||||
get => (SKColor)GetValue(ScrollBarColorProperty);
|
||||
set => SetValue(ScrollBarColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scrollbar width.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
@@ -35,14 +151,36 @@ public class SkiaScrollView : SkiaView
|
||||
_content = value;
|
||||
|
||||
if (_content != null)
|
||||
{
|
||||
_content.Parent = this;
|
||||
|
||||
// Propagate binding context to new content
|
||||
if (BindingContext != null)
|
||||
{
|
||||
SetInheritedBindingContext(_content, BindingContext);
|
||||
}
|
||||
}
|
||||
|
||||
InvalidateMeasure();
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when binding context changes. Propagates to content.
|
||||
/// </summary>
|
||||
protected override void OnBindingContextChanged()
|
||||
{
|
||||
base.OnBindingContextChanged();
|
||||
|
||||
// Propagate binding context to content
|
||||
if (_content != null)
|
||||
{
|
||||
SetInheritedBindingContext(_content, BindingContext);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal scroll position.
|
||||
/// </summary>
|
||||
@@ -82,43 +220,39 @@ public class SkiaScrollView : SkiaView
|
||||
/// <summary>
|
||||
/// Gets the maximum horizontal scroll extent.
|
||||
/// </summary>
|
||||
public float ScrollableWidth => Math.Max(0, ContentSize.Width - Bounds.Width);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum vertical scroll extent.
|
||||
/// </summary>
|
||||
public float ScrollableHeight => Math.Max(0, ContentSize.Height - Bounds.Height);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content size.
|
||||
/// </summary>
|
||||
public SKSize ContentSize { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the scroll orientation.
|
||||
/// </summary>
|
||||
public ScrollOrientation Orientation { get; set; } = ScrollOrientation.Both;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to show horizontal scrollbar.
|
||||
/// </summary>
|
||||
public ScrollBarVisibility HorizontalScrollBarVisibility { get; set; } = ScrollBarVisibility.Auto;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to show vertical scrollbar.
|
||||
/// </summary>
|
||||
public ScrollBarVisibility VerticalScrollBarVisibility { get; set; } = ScrollBarVisibility.Auto;
|
||||
|
||||
/// <summary>
|
||||
/// Scrollbar color.
|
||||
/// </summary>
|
||||
public SKColor ScrollBarColor { get; set; } = new SKColor(0x80, 0x80, 0x80, 0x80);
|
||||
|
||||
/// <summary>
|
||||
/// Scrollbar width.
|
||||
/// </summary>
|
||||
public float ScrollBarWidth { get; set; } = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when scroll position changes.
|
||||
/// </summary>
|
||||
@@ -133,6 +267,19 @@ public class SkiaScrollView : SkiaView
|
||||
// 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);
|
||||
@@ -233,22 +380,89 @@ public class SkiaScrollView : SkiaView
|
||||
|
||||
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)
|
||||
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)
|
||||
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;
|
||||
@@ -258,19 +472,44 @@ public class SkiaScrollView : SkiaView
|
||||
|
||||
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 deltaX = _lastPointerX - e.X;
|
||||
var deltaY = _lastPointerY - e.Y;
|
||||
var contentDeltaX = _lastPointerX - e.X;
|
||||
var contentDeltaY = _lastPointerY - e.Y;
|
||||
|
||||
_velocityX = deltaX;
|
||||
_velocityY = deltaY;
|
||||
_velocityX = contentDeltaX;
|
||||
_velocityY = contentDeltaY;
|
||||
|
||||
if (Orientation != ScrollOrientation.Horizontal)
|
||||
ScrollY += deltaY;
|
||||
ScrollY += contentDeltaY;
|
||||
|
||||
if (Orientation != ScrollOrientation.Vertical)
|
||||
ScrollX += deltaX;
|
||||
ScrollX += contentDeltaX;
|
||||
|
||||
_lastPointerX = e.X;
|
||||
_lastPointerY = e.Y;
|
||||
@@ -279,14 +518,62 @@ public class SkiaScrollView : SkiaView
|
||||
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 || !Bounds.Contains(new SKPoint(x, 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)
|
||||
{
|
||||
@@ -360,35 +647,88 @@ public class SkiaScrollView : SkiaView
|
||||
{
|
||||
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);
|
||||
// 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
|
||||
|
||||
ContentSize = _content.Measure(contentAvailable);
|
||||
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 availableSize;
|
||||
// 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)
|
||||
{
|
||||
// Arrange content at its full size, starting from scroll position
|
||||
// Apply content's margin and arrange content at its full size
|
||||
var margin = _content.Margin;
|
||||
var contentBounds = new SKRect(
|
||||
bounds.Left,
|
||||
bounds.Top,
|
||||
bounds.Left + Math.Max(bounds.Width, ContentSize.Width),
|
||||
bounds.Top + Math.Max(bounds.Height, ContentSize.Height));
|
||||
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 bounds;
|
||||
return actualBounds;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,9 +55,11 @@ public class SkiaSearchBar : SkiaView
|
||||
_entry = new SkiaEntry
|
||||
{
|
||||
Placeholder = "Search...",
|
||||
EntryBackgroundColor = SKColors.Transparent,
|
||||
BackgroundColor = SKColors.Transparent,
|
||||
BorderColor = SKColors.Transparent,
|
||||
FocusedBorderColor = SKColors.Transparent
|
||||
FocusedBorderColor = SKColors.Transparent,
|
||||
BorderWidth = 0
|
||||
};
|
||||
|
||||
_entry.TextChanged += (s, e) =>
|
||||
@@ -193,12 +195,24 @@ public class SkiaSearchBar : SkiaView
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward to entry for text input focus
|
||||
// Forward to entry for text input focus and selection
|
||||
_entry.IsFocused = true;
|
||||
IsFocused = true;
|
||||
_entry.OnPointerPressed(e);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
_entry.OnPointerMoved(e);
|
||||
}
|
||||
|
||||
public override void OnPointerReleased(PointerEventArgs e)
|
||||
{
|
||||
_entry.OnPointerReleased(e);
|
||||
}
|
||||
|
||||
public override void OnTextInput(TextInputEventArgs e)
|
||||
{
|
||||
_entry.OnTextInput(e);
|
||||
|
||||
@@ -19,6 +19,9 @@ public class SkiaShell : SkiaLayoutView
|
||||
private int _selectedSectionIndex = 0;
|
||||
private int _selectedItemIndex = 0;
|
||||
|
||||
// Navigation stack for push/pop navigation
|
||||
private readonly Stack<(SkiaView Content, string Title)> _navigationStack = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the flyout is presented.
|
||||
/// </summary>
|
||||
@@ -93,6 +96,12 @@ public class SkiaShell : SkiaLayoutView
|
||||
/// </summary>
|
||||
public bool TabBarIsVisible { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the padding applied to page content.
|
||||
/// Default is 16 pixels on all sides.
|
||||
/// </summary>
|
||||
public float ContentPadding { get; set; } = 16f;
|
||||
|
||||
/// <summary>
|
||||
/// Current title displayed in the navigation bar.
|
||||
/// </summary>
|
||||
@@ -103,6 +112,11 @@ public class SkiaShell : SkiaLayoutView
|
||||
/// </summary>
|
||||
public IReadOnlyList<ShellSection> Sections => _sections;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently selected section index.
|
||||
/// </summary>
|
||||
public int CurrentSectionIndex => _selectedSectionIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when FlyoutIsPresented changes.
|
||||
/// </summary>
|
||||
@@ -147,6 +161,9 @@ public class SkiaShell : SkiaLayoutView
|
||||
var section = _sections[sectionIndex];
|
||||
if (itemIndex < 0 || itemIndex >= section.Items.Count) return;
|
||||
|
||||
// Clear navigation stack when navigating to a new section
|
||||
_navigationStack.Clear();
|
||||
|
||||
_selectedSectionIndex = sectionIndex;
|
||||
_selectedItemIndex = itemIndex;
|
||||
|
||||
@@ -193,6 +210,66 @@ public class SkiaShell : SkiaLayoutView
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether there are pages on the navigation stack.
|
||||
/// </summary>
|
||||
public bool CanGoBack => _navigationStack.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current navigation stack depth.
|
||||
/// </summary>
|
||||
public int NavigationStackDepth => _navigationStack.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a new page onto the navigation stack.
|
||||
/// </summary>
|
||||
public void PushAsync(SkiaView page, string title)
|
||||
{
|
||||
// Save current content to stack
|
||||
if (_currentContent != null)
|
||||
{
|
||||
_navigationStack.Push((_currentContent, Title));
|
||||
}
|
||||
|
||||
// Set new content
|
||||
SetCurrentContent(page);
|
||||
Title = title;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pops the current page from the navigation stack.
|
||||
/// </summary>
|
||||
public bool PopAsync()
|
||||
{
|
||||
if (_navigationStack.Count == 0) return false;
|
||||
|
||||
var (previousContent, previousTitle) = _navigationStack.Pop();
|
||||
SetCurrentContent(previousContent);
|
||||
Title = previousTitle;
|
||||
Invalidate();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pops all pages from the navigation stack, returning to the root.
|
||||
/// </summary>
|
||||
public void PopToRootAsync()
|
||||
{
|
||||
if (_navigationStack.Count == 0) return;
|
||||
|
||||
// Get the root content
|
||||
(SkiaView Content, string Title) root = default;
|
||||
while (_navigationStack.Count > 0)
|
||||
{
|
||||
root = _navigationStack.Pop();
|
||||
}
|
||||
|
||||
SetCurrentContent(root.Content);
|
||||
Title = root.Title;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void SetCurrentContent(SkiaView? content)
|
||||
{
|
||||
if (_currentContent != null)
|
||||
@@ -226,16 +303,19 @@ public class SkiaShell : SkiaLayoutView
|
||||
|
||||
protected override SKRect ArrangeOverride(SKRect bounds)
|
||||
{
|
||||
// Arrange current content
|
||||
Console.WriteLine($"[SkiaShell] ArrangeOverride - bounds={bounds}");
|
||||
|
||||
// Arrange current content with padding
|
||||
if (_currentContent != null)
|
||||
{
|
||||
float contentTop = bounds.Top + (NavBarIsVisible ? NavBarHeight : 0);
|
||||
float contentBottom = bounds.Bottom - (TabBarIsVisible ? TabBarHeight : 0);
|
||||
float contentTop = bounds.Top + (NavBarIsVisible ? NavBarHeight : 0) + ContentPadding;
|
||||
float contentBottom = bounds.Bottom - (TabBarIsVisible ? TabBarHeight : 0) - ContentPadding;
|
||||
var contentBounds = new SKRect(
|
||||
bounds.Left,
|
||||
bounds.Left + ContentPadding,
|
||||
contentTop,
|
||||
bounds.Right,
|
||||
bounds.Right - ContentPadding,
|
||||
contentBottom);
|
||||
Console.WriteLine($"[SkiaShell] Arranging content with bounds={contentBounds}, padding={ContentPadding}");
|
||||
_currentContent.Arrange(contentBounds);
|
||||
}
|
||||
|
||||
@@ -288,20 +368,41 @@ public class SkiaShell : SkiaLayoutView
|
||||
};
|
||||
canvas.DrawRect(navBarBounds, bgPaint);
|
||||
|
||||
// Draw hamburger menu icon
|
||||
if (FlyoutBehavior == ShellFlyoutBehavior.Flyout)
|
||||
// Draw nav icon (back arrow if can go back, else hamburger menu if flyout enabled)
|
||||
using var iconPaint = new SKPaint
|
||||
{
|
||||
using var iconPaint = new SKPaint
|
||||
Color = NavBarTextColor,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 2,
|
||||
StrokeCap = SKStrokeCap.Round,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
float iconLeft = navBarBounds.Left + 16;
|
||||
float iconCenter = navBarBounds.MidY;
|
||||
|
||||
if (CanGoBack)
|
||||
{
|
||||
// Draw iOS-style back chevron "<"
|
||||
using var chevronPaint = new SKPaint
|
||||
{
|
||||
Color = NavBarTextColor,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 2,
|
||||
StrokeWidth = 2.5f,
|
||||
StrokeCap = SKStrokeCap.Round,
|
||||
StrokeJoin = SKStrokeJoin.Round,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
float iconLeft = navBarBounds.Left + 16;
|
||||
float iconCenter = navBarBounds.MidY;
|
||||
|
||||
// Clean chevron pointing left
|
||||
float chevronX = iconLeft + 6;
|
||||
float chevronSize = 10;
|
||||
canvas.DrawLine(chevronX + chevronSize, iconCenter - chevronSize, chevronX, iconCenter, chevronPaint);
|
||||
canvas.DrawLine(chevronX, iconCenter, chevronX + chevronSize, iconCenter + chevronSize, chevronPaint);
|
||||
}
|
||||
else if (FlyoutBehavior == ShellFlyoutBehavior.Flyout)
|
||||
{
|
||||
// Draw hamburger menu icon
|
||||
canvas.DrawLine(iconLeft, iconCenter - 8, iconLeft + 18, iconCenter - 8, iconPaint);
|
||||
canvas.DrawLine(iconLeft, iconCenter, iconLeft + 18, iconCenter, iconPaint);
|
||||
canvas.DrawLine(iconLeft, iconCenter + 8, iconLeft + 18, iconCenter + 8, iconPaint);
|
||||
@@ -316,7 +417,7 @@ public class SkiaShell : SkiaLayoutView
|
||||
FakeBoldText = true
|
||||
};
|
||||
|
||||
float titleX = FlyoutBehavior == ShellFlyoutBehavior.Flyout ? navBarBounds.Left + 56 : navBarBounds.Left + 16;
|
||||
float titleX = (CanGoBack || FlyoutBehavior == ShellFlyoutBehavior.Flyout) ? navBarBounds.Left + 56 : navBarBounds.Left + 16;
|
||||
float titleY = navBarBounds.MidY + 6;
|
||||
canvas.DrawText(Title, titleX, titleY, titlePaint);
|
||||
}
|
||||
@@ -427,7 +528,8 @@ public class SkiaShell : SkiaLayoutView
|
||||
Color = new SKColor(33, 150, 243, 30),
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
canvas.DrawRect(flyoutBounds.Left, itemY, flyoutBounds.Right, itemY + itemHeight, selectionPaint);
|
||||
var selectionRect = new SKRect(flyoutBounds.Left, itemY, flyoutBounds.Right, itemY + itemHeight);
|
||||
canvas.DrawRect(selectionRect, selectionPaint);
|
||||
}
|
||||
|
||||
itemTextPaint.Color = isSelected ? NavBarBackgroundColor : new SKColor(33, 33, 33);
|
||||
@@ -518,12 +620,23 @@ public class SkiaShell : SkiaLayoutView
|
||||
}
|
||||
}
|
||||
|
||||
// Check nav bar hamburger tap
|
||||
if (NavBarIsVisible && e.Y < Bounds.Top + NavBarHeight && e.X < 56 && FlyoutBehavior == ShellFlyoutBehavior.Flyout)
|
||||
// Check nav bar icon tap (back button or hamburger menu)
|
||||
if (NavBarIsVisible && e.Y < Bounds.Top + NavBarHeight && e.X < 56)
|
||||
{
|
||||
FlyoutIsPresented = !FlyoutIsPresented;
|
||||
e.Handled = true;
|
||||
return;
|
||||
if (CanGoBack)
|
||||
{
|
||||
// Back button pressed
|
||||
PopAsync();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
else if (FlyoutBehavior == ShellFlyoutBehavior.Flyout)
|
||||
{
|
||||
// Hamburger menu pressed
|
||||
FlyoutIsPresented = !FlyoutIsPresented;
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check tab bar tap
|
||||
|
||||
@@ -6,40 +6,214 @@ using SkiaSharp;
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered slider control.
|
||||
/// Skia-rendered slider control with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaSlider : SkiaView
|
||||
{
|
||||
private bool _isDragging;
|
||||
private double _value;
|
||||
#region BindableProperties
|
||||
|
||||
public double Minimum { get; set; } = 0;
|
||||
public double Maximum { get; set; } = 100;
|
||||
/// <summary>
|
||||
/// Bindable property for Minimum.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty MinimumProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Minimum),
|
||||
typeof(double),
|
||||
typeof(SkiaSlider),
|
||||
0.0,
|
||||
propertyChanged: (b, o, n) => ((SkiaSlider)b).OnRangeChanged());
|
||||
|
||||
public double Value
|
||||
/// <summary>
|
||||
/// Bindable property for Maximum.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty MaximumProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Maximum),
|
||||
typeof(double),
|
||||
typeof(SkiaSlider),
|
||||
100.0,
|
||||
propertyChanged: (b, o, n) => ((SkiaSlider)b).OnRangeChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Value.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ValueProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Value),
|
||||
typeof(double),
|
||||
typeof(SkiaSlider),
|
||||
0.0,
|
||||
BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaSlider)b).OnValuePropertyChanged((double)o, (double)n));
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for TrackColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TrackColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(TrackColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaSlider),
|
||||
new SKColor(0xE0, 0xE0, 0xE0),
|
||||
propertyChanged: (b, o, n) => ((SkiaSlider)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ActiveTrackColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ActiveTrackColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ActiveTrackColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaSlider),
|
||||
new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaSlider)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ThumbColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ThumbColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ThumbColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaSlider),
|
||||
new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaSlider)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for DisabledColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty DisabledColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(DisabledColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaSlider),
|
||||
new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaSlider)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for TrackHeight.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TrackHeightProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(TrackHeight),
|
||||
typeof(float),
|
||||
typeof(SkiaSlider),
|
||||
4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaSlider)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ThumbRadius.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ThumbRadiusProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ThumbRadius),
|
||||
typeof(float),
|
||||
typeof(SkiaSlider),
|
||||
10f,
|
||||
propertyChanged: (b, o, n) => ((SkiaSlider)b).InvalidateMeasure());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum value.
|
||||
/// </summary>
|
||||
public double Minimum
|
||||
{
|
||||
get => _value;
|
||||
set
|
||||
{
|
||||
var clamped = Math.Clamp(value, Minimum, Maximum);
|
||||
if (_value != clamped)
|
||||
{
|
||||
_value = clamped;
|
||||
ValueChanged?.Invoke(this, new SliderValueChangedEventArgs(_value));
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (double)GetValue(MinimumProperty);
|
||||
set => SetValue(MinimumProperty, value);
|
||||
}
|
||||
|
||||
public SKColor TrackColor { get; set; } = new SKColor(0xE0, 0xE0, 0xE0);
|
||||
public SKColor ActiveTrackColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public SKColor ThumbColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public SKColor DisabledColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public float TrackHeight { get; set; } = 4;
|
||||
public float ThumbRadius { get; set; } = 10;
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum value.
|
||||
/// </summary>
|
||||
public double Maximum
|
||||
{
|
||||
get => (double)GetValue(MaximumProperty);
|
||||
set => SetValue(MaximumProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current value.
|
||||
/// </summary>
|
||||
public double Value
|
||||
{
|
||||
get => (double)GetValue(ValueProperty);
|
||||
set => SetValue(ValueProperty, Math.Clamp(value, Minimum, Maximum));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the track color.
|
||||
/// </summary>
|
||||
public SKColor TrackColor
|
||||
{
|
||||
get => (SKColor)GetValue(TrackColorProperty);
|
||||
set => SetValue(TrackColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the active track color.
|
||||
/// </summary>
|
||||
public SKColor ActiveTrackColor
|
||||
{
|
||||
get => (SKColor)GetValue(ActiveTrackColorProperty);
|
||||
set => SetValue(ActiveTrackColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the thumb color.
|
||||
/// </summary>
|
||||
public SKColor ThumbColor
|
||||
{
|
||||
get => (SKColor)GetValue(ThumbColorProperty);
|
||||
set => SetValue(ThumbColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the disabled color.
|
||||
/// </summary>
|
||||
public SKColor DisabledColor
|
||||
{
|
||||
get => (SKColor)GetValue(DisabledColorProperty);
|
||||
set => SetValue(DisabledColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the track height.
|
||||
/// </summary>
|
||||
public float TrackHeight
|
||||
{
|
||||
get => (float)GetValue(TrackHeightProperty);
|
||||
set => SetValue(TrackHeightProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the thumb radius.
|
||||
/// </summary>
|
||||
public float ThumbRadius
|
||||
{
|
||||
get => (float)GetValue(ThumbRadiusProperty);
|
||||
set => SetValue(ThumbRadiusProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private bool _isDragging;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when the value changes.
|
||||
/// </summary>
|
||||
public event EventHandler<SliderValueChangedEventArgs>? ValueChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when drag starts.
|
||||
/// </summary>
|
||||
public event EventHandler? DragStarted;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when drag completes.
|
||||
/// </summary>
|
||||
public event EventHandler? DragCompleted;
|
||||
|
||||
public SkiaSlider()
|
||||
@@ -47,6 +221,23 @@ public class SkiaSlider : SkiaView
|
||||
IsFocusable = true;
|
||||
}
|
||||
|
||||
private void OnRangeChanged()
|
||||
{
|
||||
// Clamp value to new range
|
||||
var clamped = Math.Clamp(Value, Minimum, Maximum);
|
||||
if (Value != clamped)
|
||||
{
|
||||
Value = clamped;
|
||||
}
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void OnValuePropertyChanged(double oldValue, double newValue)
|
||||
{
|
||||
ValueChanged?.Invoke(this, new SliderValueChangedEventArgs(newValue));
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
var trackY = bounds.MidY;
|
||||
@@ -54,7 +245,7 @@ public class SkiaSlider : SkiaView
|
||||
var trackRight = bounds.Right - ThumbRadius;
|
||||
var trackWidth = trackRight - trackLeft;
|
||||
|
||||
var percentage = (Value - Minimum) / (Maximum - Minimum);
|
||||
var percentage = Maximum > Minimum ? (Value - Minimum) / (Maximum - Minimum) : 0;
|
||||
var thumbX = trackLeft + (float)(percentage * trackWidth);
|
||||
|
||||
// Draw inactive track
|
||||
@@ -127,6 +318,7 @@ public class SkiaSlider : SkiaView
|
||||
_isDragging = true;
|
||||
UpdateValueFromPosition(e.X);
|
||||
DragStarted?.Invoke(this, EventArgs.Empty);
|
||||
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Pressed);
|
||||
}
|
||||
|
||||
public override void OnPointerMoved(PointerEventArgs e)
|
||||
@@ -141,6 +333,7 @@ public class SkiaSlider : SkiaView
|
||||
{
|
||||
_isDragging = false;
|
||||
DragCompleted?.Invoke(this, EventArgs.Empty);
|
||||
SkiaVisualStateManager.GoToState(this, IsEnabled ? SkiaVisualStateManager.CommonStates.Normal : SkiaVisualStateManager.CommonStates.Disabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,12 +376,21 @@ public class SkiaSlider : SkiaView
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnEnabledChanged()
|
||||
{
|
||||
base.OnEnabledChanged();
|
||||
SkiaVisualStateManager.GoToState(this, IsEnabled ? SkiaVisualStateManager.CommonStates.Normal : SkiaVisualStateManager.CommonStates.Disabled);
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
return new SKSize(200, ThumbRadius * 2 + 16);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event args for slider value changed events.
|
||||
/// </summary>
|
||||
public class SliderValueChangedEventArgs : EventArgs
|
||||
{
|
||||
public double NewValue { get; }
|
||||
|
||||
@@ -10,66 +10,136 @@ namespace Microsoft.Maui.Platform;
|
||||
/// </summary>
|
||||
public class SkiaStepper : SkiaView
|
||||
{
|
||||
private double _value;
|
||||
private double _minimum;
|
||||
private double _maximum = 100;
|
||||
private double _increment = 1;
|
||||
private bool _isMinusPressed;
|
||||
private bool _isPlusPressed;
|
||||
#region BindableProperties
|
||||
|
||||
// Styling
|
||||
public SKColor ButtonBackgroundColor { get; set; } = new SKColor(0xE0, 0xE0, 0xE0);
|
||||
public SKColor ButtonPressedColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public SKColor ButtonDisabledColor { get; set; } = new SKColor(0xF5, 0xF5, 0xF5);
|
||||
public SKColor BorderColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public SKColor SymbolColor { get; set; } = SKColors.Black;
|
||||
public SKColor SymbolDisabledColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public float CornerRadius { get; set; } = 4;
|
||||
public float ButtonWidth { get; set; } = 40;
|
||||
public static readonly BindableProperty ValueProperty =
|
||||
BindableProperty.Create(nameof(Value), typeof(double), typeof(SkiaStepper), 0.0, BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).OnValuePropertyChanged((double)o, (double)n));
|
||||
|
||||
public static readonly BindableProperty MinimumProperty =
|
||||
BindableProperty.Create(nameof(Minimum), typeof(double), typeof(SkiaStepper), 0.0,
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).OnRangeChanged());
|
||||
|
||||
public static readonly BindableProperty MaximumProperty =
|
||||
BindableProperty.Create(nameof(Maximum), typeof(double), typeof(SkiaStepper), 100.0,
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).OnRangeChanged());
|
||||
|
||||
public static readonly BindableProperty IncrementProperty =
|
||||
BindableProperty.Create(nameof(Increment), typeof(double), typeof(SkiaStepper), 1.0);
|
||||
|
||||
public static readonly BindableProperty ButtonBackgroundColorProperty =
|
||||
BindableProperty.Create(nameof(ButtonBackgroundColor), typeof(SKColor), typeof(SkiaStepper), new SKColor(0xE0, 0xE0, 0xE0),
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty ButtonPressedColorProperty =
|
||||
BindableProperty.Create(nameof(ButtonPressedColor), typeof(SKColor), typeof(SkiaStepper), new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty ButtonDisabledColorProperty =
|
||||
BindableProperty.Create(nameof(ButtonDisabledColor), typeof(SKColor), typeof(SkiaStepper), new SKColor(0xF5, 0xF5, 0xF5),
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty BorderColorProperty =
|
||||
BindableProperty.Create(nameof(BorderColor), typeof(SKColor), typeof(SkiaStepper), new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty SymbolColorProperty =
|
||||
BindableProperty.Create(nameof(SymbolColor), typeof(SKColor), typeof(SkiaStepper), SKColors.Black,
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty SymbolDisabledColorProperty =
|
||||
BindableProperty.Create(nameof(SymbolDisabledColor), typeof(SKColor), typeof(SkiaStepper), new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty CornerRadiusProperty =
|
||||
BindableProperty.Create(nameof(CornerRadius), typeof(float), typeof(SkiaStepper), 4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty ButtonWidthProperty =
|
||||
BindableProperty.Create(nameof(ButtonWidth), typeof(float), typeof(SkiaStepper), 40f,
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).InvalidateMeasure());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
public double Value
|
||||
{
|
||||
get => _value;
|
||||
set
|
||||
{
|
||||
var clamped = Math.Clamp(value, _minimum, _maximum);
|
||||
if (_value != clamped)
|
||||
{
|
||||
_value = clamped;
|
||||
ValueChanged?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (double)GetValue(ValueProperty);
|
||||
set => SetValue(ValueProperty, Math.Clamp(value, Minimum, Maximum));
|
||||
}
|
||||
|
||||
public double Minimum
|
||||
{
|
||||
get => _minimum;
|
||||
set
|
||||
{
|
||||
_minimum = value;
|
||||
if (_value < _minimum) Value = _minimum;
|
||||
Invalidate();
|
||||
}
|
||||
get => (double)GetValue(MinimumProperty);
|
||||
set => SetValue(MinimumProperty, value);
|
||||
}
|
||||
|
||||
public double Maximum
|
||||
{
|
||||
get => _maximum;
|
||||
set
|
||||
{
|
||||
_maximum = value;
|
||||
if (_value > _maximum) Value = _maximum;
|
||||
Invalidate();
|
||||
}
|
||||
get => (double)GetValue(MaximumProperty);
|
||||
set => SetValue(MaximumProperty, value);
|
||||
}
|
||||
|
||||
public double Increment
|
||||
{
|
||||
get => _increment;
|
||||
set { _increment = Math.Max(0.001, value); Invalidate(); }
|
||||
get => (double)GetValue(IncrementProperty);
|
||||
set => SetValue(IncrementProperty, Math.Max(0.001, value));
|
||||
}
|
||||
|
||||
public SKColor ButtonBackgroundColor
|
||||
{
|
||||
get => (SKColor)GetValue(ButtonBackgroundColorProperty);
|
||||
set => SetValue(ButtonBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor ButtonPressedColor
|
||||
{
|
||||
get => (SKColor)GetValue(ButtonPressedColorProperty);
|
||||
set => SetValue(ButtonPressedColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor ButtonDisabledColor
|
||||
{
|
||||
get => (SKColor)GetValue(ButtonDisabledColorProperty);
|
||||
set => SetValue(ButtonDisabledColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor BorderColor
|
||||
{
|
||||
get => (SKColor)GetValue(BorderColorProperty);
|
||||
set => SetValue(BorderColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor SymbolColor
|
||||
{
|
||||
get => (SKColor)GetValue(SymbolColorProperty);
|
||||
set => SetValue(SymbolColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor SymbolDisabledColor
|
||||
{
|
||||
get => (SKColor)GetValue(SymbolDisabledColorProperty);
|
||||
set => SetValue(SymbolDisabledColorProperty, value);
|
||||
}
|
||||
|
||||
public float CornerRadius
|
||||
{
|
||||
get => (float)GetValue(CornerRadiusProperty);
|
||||
set => SetValue(CornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
public float ButtonWidth
|
||||
{
|
||||
get => (float)GetValue(ButtonWidthProperty);
|
||||
set => SetValue(ButtonWidthProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private bool _isMinusPressed;
|
||||
private bool _isPlusPressed;
|
||||
|
||||
public event EventHandler? ValueChanged;
|
||||
|
||||
public SkiaStepper()
|
||||
@@ -77,19 +147,30 @@ public class SkiaStepper : SkiaView
|
||||
IsFocusable = true;
|
||||
}
|
||||
|
||||
private void OnValuePropertyChanged(double oldValue, double newValue)
|
||||
{
|
||||
ValueChanged?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void OnRangeChanged()
|
||||
{
|
||||
var clamped = Math.Clamp(Value, Minimum, Maximum);
|
||||
if (Value != clamped)
|
||||
{
|
||||
Value = clamped;
|
||||
}
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
var buttonHeight = bounds.Height;
|
||||
var minusRect = new SKRect(bounds.Left, bounds.Top, bounds.Left + ButtonWidth, bounds.Bottom);
|
||||
var plusRect = new SKRect(bounds.Right - ButtonWidth, bounds.Top, bounds.Right, bounds.Bottom);
|
||||
|
||||
// Draw minus button
|
||||
DrawButton(canvas, minusRect, "-", _isMinusPressed, !CanDecrement());
|
||||
|
||||
// Draw plus button
|
||||
DrawButton(canvas, plusRect, "+", _isPlusPressed, !CanIncrement());
|
||||
|
||||
// Draw border
|
||||
using var borderPaint = new SKPaint
|
||||
{
|
||||
Color = BorderColor,
|
||||
@@ -98,29 +179,23 @@ public class SkiaStepper : SkiaView
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
// Overall border with rounded corners
|
||||
var totalRect = new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Bottom);
|
||||
canvas.DrawRoundRect(new SKRoundRect(totalRect, CornerRadius), borderPaint);
|
||||
|
||||
// Center divider
|
||||
var centerX = bounds.MidX;
|
||||
canvas.DrawLine(centerX, bounds.Top, centerX, bounds.Bottom, borderPaint);
|
||||
}
|
||||
|
||||
private void DrawButton(SKCanvas canvas, SKRect rect, string symbol, bool isPressed, bool isDisabled)
|
||||
{
|
||||
// Draw background
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = isDisabled ? ButtonDisabledColor : (isPressed ? ButtonPressedColor : ButtonBackgroundColor),
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
// Draw button background (clipped by overall border)
|
||||
canvas.DrawRect(rect, bgPaint);
|
||||
|
||||
// Draw symbol
|
||||
using var font = new SKFont(SKTypeface.Default, 20);
|
||||
using var textPaint = new SKPaint(font)
|
||||
{
|
||||
@@ -133,23 +208,22 @@ public class SkiaStepper : SkiaView
|
||||
canvas.DrawText(symbol, rect.MidX - textBounds.MidX, rect.MidY - textBounds.MidY, textPaint);
|
||||
}
|
||||
|
||||
private bool CanIncrement() => IsEnabled && _value < _maximum;
|
||||
private bool CanDecrement() => IsEnabled && _value > _minimum;
|
||||
private bool CanIncrement() => IsEnabled && Value < Maximum;
|
||||
private bool CanDecrement() => IsEnabled && Value > Minimum;
|
||||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
|
||||
var x = e.X;
|
||||
if (x < ButtonWidth)
|
||||
if (e.X < ButtonWidth)
|
||||
{
|
||||
_isMinusPressed = true;
|
||||
if (CanDecrement()) Value -= _increment;
|
||||
if (CanDecrement()) Value -= Increment;
|
||||
}
|
||||
else if (x > Bounds.Width - ButtonWidth)
|
||||
else if (e.X > Bounds.Width - ButtonWidth)
|
||||
{
|
||||
_isPlusPressed = true;
|
||||
if (CanIncrement()) Value += _increment;
|
||||
if (CanIncrement()) Value += Increment;
|
||||
}
|
||||
Invalidate();
|
||||
}
|
||||
@@ -169,12 +243,12 @@ public class SkiaStepper : SkiaView
|
||||
{
|
||||
case Key.Up:
|
||||
case Key.Right:
|
||||
if (CanIncrement()) Value += _increment;
|
||||
if (CanIncrement()) Value += Increment;
|
||||
e.Handled = true;
|
||||
break;
|
||||
case Key.Down:
|
||||
case Key.Left:
|
||||
if (CanDecrement()) Value -= _increment;
|
||||
if (CanDecrement()) Value -= Increment;
|
||||
e.Handled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -6,37 +6,204 @@ using SkiaSharp;
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered toggle switch control.
|
||||
/// Skia-rendered toggle switch control with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaSwitch : SkiaView
|
||||
{
|
||||
private bool _isOn;
|
||||
private float _animationProgress; // 0 = off, 1 = on
|
||||
#region BindableProperties
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsOn.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsOnProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsOn),
|
||||
typeof(bool),
|
||||
typeof(SkiaSwitch),
|
||||
false,
|
||||
BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaSwitch)b).OnIsOnChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for OnTrackColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty OnTrackColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(OnTrackColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaSwitch),
|
||||
new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaSwitch)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for OffTrackColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty OffTrackColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(OffTrackColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaSwitch),
|
||||
new SKColor(0x9E, 0x9E, 0x9E),
|
||||
propertyChanged: (b, o, n) => ((SkiaSwitch)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ThumbColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ThumbColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ThumbColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaSwitch),
|
||||
SKColors.White,
|
||||
propertyChanged: (b, o, n) => ((SkiaSwitch)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for DisabledColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty DisabledColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(DisabledColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaSwitch),
|
||||
new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaSwitch)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for TrackWidth.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TrackWidthProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(TrackWidth),
|
||||
typeof(float),
|
||||
typeof(SkiaSwitch),
|
||||
52f,
|
||||
propertyChanged: (b, o, n) => ((SkiaSwitch)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for TrackHeight.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TrackHeightProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(TrackHeight),
|
||||
typeof(float),
|
||||
typeof(SkiaSwitch),
|
||||
32f,
|
||||
propertyChanged: (b, o, n) => ((SkiaSwitch)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ThumbRadius.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ThumbRadiusProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ThumbRadius),
|
||||
typeof(float),
|
||||
typeof(SkiaSwitch),
|
||||
12f,
|
||||
propertyChanged: (b, o, n) => ((SkiaSwitch)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ThumbPadding.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ThumbPaddingProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ThumbPadding),
|
||||
typeof(float),
|
||||
typeof(SkiaSwitch),
|
||||
4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaSwitch)b).Invalidate());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the switch is on.
|
||||
/// </summary>
|
||||
public bool IsOn
|
||||
{
|
||||
get => _isOn;
|
||||
set
|
||||
{
|
||||
if (_isOn != value)
|
||||
{
|
||||
_isOn = value;
|
||||
_animationProgress = value ? 1f : 0f;
|
||||
Toggled?.Invoke(this, new ToggledEventArgs(value));
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (bool)GetValue(IsOnProperty);
|
||||
set => SetValue(IsOnProperty, value);
|
||||
}
|
||||
|
||||
public SKColor OnTrackColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public SKColor OffTrackColor { get; set; } = new SKColor(0x9E, 0x9E, 0x9E);
|
||||
public SKColor ThumbColor { get; set; } = SKColors.White;
|
||||
public SKColor DisabledColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public float TrackWidth { get; set; } = 52;
|
||||
public float TrackHeight { get; set; } = 32;
|
||||
public float ThumbRadius { get; set; } = 12;
|
||||
public float ThumbPadding { get; set; } = 4;
|
||||
/// <summary>
|
||||
/// Gets or sets the on track color.
|
||||
/// </summary>
|
||||
public SKColor OnTrackColor
|
||||
{
|
||||
get => (SKColor)GetValue(OnTrackColorProperty);
|
||||
set => SetValue(OnTrackColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the off track color.
|
||||
/// </summary>
|
||||
public SKColor OffTrackColor
|
||||
{
|
||||
get => (SKColor)GetValue(OffTrackColorProperty);
|
||||
set => SetValue(OffTrackColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the thumb color.
|
||||
/// </summary>
|
||||
public SKColor ThumbColor
|
||||
{
|
||||
get => (SKColor)GetValue(ThumbColorProperty);
|
||||
set => SetValue(ThumbColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the disabled color.
|
||||
/// </summary>
|
||||
public SKColor DisabledColor
|
||||
{
|
||||
get => (SKColor)GetValue(DisabledColorProperty);
|
||||
set => SetValue(DisabledColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the track width.
|
||||
/// </summary>
|
||||
public float TrackWidth
|
||||
{
|
||||
get => (float)GetValue(TrackWidthProperty);
|
||||
set => SetValue(TrackWidthProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the track height.
|
||||
/// </summary>
|
||||
public float TrackHeight
|
||||
{
|
||||
get => (float)GetValue(TrackHeightProperty);
|
||||
set => SetValue(TrackHeightProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the thumb radius.
|
||||
/// </summary>
|
||||
public float ThumbRadius
|
||||
{
|
||||
get => (float)GetValue(ThumbRadiusProperty);
|
||||
set => SetValue(ThumbRadiusProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the thumb padding.
|
||||
/// </summary>
|
||||
public float ThumbPadding
|
||||
{
|
||||
get => (float)GetValue(ThumbPaddingProperty);
|
||||
set => SetValue(ThumbPaddingProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private float _animationProgress; // 0 = off, 1 = on
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when the switch is toggled.
|
||||
/// </summary>
|
||||
public event EventHandler<ToggledEventArgs>? Toggled;
|
||||
|
||||
public SkiaSwitch()
|
||||
@@ -44,6 +211,14 @@ public class SkiaSwitch : SkiaView
|
||||
IsFocusable = true;
|
||||
}
|
||||
|
||||
private void OnIsOnChanged()
|
||||
{
|
||||
_animationProgress = IsOn ? 1f : 0f;
|
||||
Toggled?.Invoke(this, new ToggledEventArgs(IsOn));
|
||||
SkiaVisualStateManager.GoToState(this, IsOn ? SkiaVisualStateManager.CommonStates.On : SkiaVisualStateManager.CommonStates.Off);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
var centerY = bounds.MidY;
|
||||
@@ -142,12 +317,21 @@ public class SkiaSwitch : SkiaView
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnEnabledChanged()
|
||||
{
|
||||
base.OnEnabledChanged();
|
||||
SkiaVisualStateManager.GoToState(this, IsEnabled ? SkiaVisualStateManager.CommonStates.Normal : SkiaVisualStateManager.CommonStates.Disabled);
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
return new SKSize(TrackWidth + 8, TrackHeight + 8);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event args for toggled events.
|
||||
/// </summary>
|
||||
public class ToggledEventArgs : EventArgs
|
||||
{
|
||||
public bool Value { get; }
|
||||
|
||||
367
Views/SkiaTemplatedView.cs
Normal file
367
Views/SkiaTemplatedView.cs
Normal file
@@ -0,0 +1,367 @@
|
||||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Microsoft.Maui.Controls;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for Skia controls that support ControlTemplates.
|
||||
/// Provides infrastructure for completely redefining control appearance via XAML.
|
||||
/// </summary>
|
||||
public abstract class SkiaTemplatedView : SkiaView
|
||||
{
|
||||
private SkiaView? _templateRoot;
|
||||
private bool _templateApplied;
|
||||
|
||||
#region BindableProperties
|
||||
|
||||
public static readonly BindableProperty ControlTemplateProperty =
|
||||
BindableProperty.Create(nameof(ControlTemplate), typeof(ControlTemplate), typeof(SkiaTemplatedView), null,
|
||||
propertyChanged: OnControlTemplateChanged);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the control template that defines the visual appearance.
|
||||
/// </summary>
|
||||
public ControlTemplate? ControlTemplate
|
||||
{
|
||||
get => (ControlTemplate?)GetValue(ControlTemplateProperty);
|
||||
set => SetValue(ControlTemplateProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the root element created from the ControlTemplate.
|
||||
/// </summary>
|
||||
protected SkiaView? TemplateRoot => _templateRoot;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether a template has been applied.
|
||||
/// </summary>
|
||||
protected bool IsTemplateApplied => _templateApplied;
|
||||
|
||||
#endregion
|
||||
|
||||
private static void OnControlTemplateChanged(BindableObject bindable, object oldValue, object newValue)
|
||||
{
|
||||
if (bindable is SkiaTemplatedView view)
|
||||
{
|
||||
view.OnControlTemplateChanged((ControlTemplate?)oldValue, (ControlTemplate?)newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the ControlTemplate changes.
|
||||
/// </summary>
|
||||
protected virtual void OnControlTemplateChanged(ControlTemplate? oldTemplate, ControlTemplate? newTemplate)
|
||||
{
|
||||
_templateApplied = false;
|
||||
_templateRoot = null;
|
||||
|
||||
if (newTemplate != null)
|
||||
{
|
||||
ApplyTemplate();
|
||||
}
|
||||
|
||||
InvalidateMeasure();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the current ControlTemplate if one is set.
|
||||
/// </summary>
|
||||
protected virtual void ApplyTemplate()
|
||||
{
|
||||
if (ControlTemplate == null || _templateApplied)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
// Create content from template
|
||||
var content = ControlTemplate.CreateContent();
|
||||
|
||||
// If the content is a MAUI Element, try to convert it to a SkiaView
|
||||
if (content is Element element)
|
||||
{
|
||||
_templateRoot = ConvertElementToSkiaView(element);
|
||||
}
|
||||
else if (content is SkiaView skiaView)
|
||||
{
|
||||
_templateRoot = skiaView;
|
||||
}
|
||||
|
||||
if (_templateRoot != null)
|
||||
{
|
||||
_templateRoot.Parent = this;
|
||||
OnTemplateApplied();
|
||||
}
|
||||
|
||||
_templateApplied = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Error applying template: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called after a template has been successfully applied.
|
||||
/// Override to perform template-specific initialization.
|
||||
/// </summary>
|
||||
protected virtual void OnTemplateApplied()
|
||||
{
|
||||
// Find and bind ContentPresenter if present
|
||||
var presenter = FindTemplateChild<SkiaContentPresenter>("PART_ContentPresenter");
|
||||
if (presenter != null)
|
||||
{
|
||||
OnContentPresenterFound(presenter);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a ContentPresenter is found in the template.
|
||||
/// Override to set up the content binding.
|
||||
/// </summary>
|
||||
protected virtual void OnContentPresenterFound(SkiaContentPresenter presenter)
|
||||
{
|
||||
// Derived classes should override to bind their content
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a named element in the template tree.
|
||||
/// </summary>
|
||||
protected T? FindTemplateChild<T>(string name) where T : SkiaView
|
||||
{
|
||||
if (_templateRoot == null)
|
||||
return null;
|
||||
|
||||
return FindChild<T>(_templateRoot, name);
|
||||
}
|
||||
|
||||
private static T? FindChild<T>(SkiaView root, string name) where T : SkiaView
|
||||
{
|
||||
if (root is T typed && root.Name == name)
|
||||
return typed;
|
||||
|
||||
if (root is SkiaLayoutView layout)
|
||||
{
|
||||
foreach (var child in layout.Children)
|
||||
{
|
||||
var found = FindChild<T>(child, name);
|
||||
if (found != null)
|
||||
return found;
|
||||
}
|
||||
}
|
||||
else if (root is SkiaContentPresenter presenter && presenter.Content != null)
|
||||
{
|
||||
return FindChild<T>(presenter.Content, name);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a MAUI Element to a SkiaView.
|
||||
/// Override to provide custom conversion logic.
|
||||
/// </summary>
|
||||
protected virtual SkiaView? ConvertElementToSkiaView(Element element)
|
||||
{
|
||||
// This is a simplified conversion - in a full implementation,
|
||||
// you would use the handler system to create proper platform views
|
||||
|
||||
return element switch
|
||||
{
|
||||
// Handle common layout types
|
||||
Microsoft.Maui.Controls.StackLayout sl => CreateSkiaStackLayout(sl),
|
||||
Microsoft.Maui.Controls.Grid grid => CreateSkiaGrid(grid),
|
||||
Microsoft.Maui.Controls.Border border => CreateSkiaBorder(border),
|
||||
Microsoft.Maui.Controls.Label label => CreateSkiaLabel(label),
|
||||
Microsoft.Maui.Controls.ContentPresenter cp => new SkiaContentPresenter(),
|
||||
_ => new SkiaLabel { Text = $"[{element.GetType().Name}]", TextColor = SKColors.Gray }
|
||||
};
|
||||
}
|
||||
|
||||
private SkiaStackLayout CreateSkiaStackLayout(Microsoft.Maui.Controls.StackLayout sl)
|
||||
{
|
||||
var layout = new SkiaStackLayout
|
||||
{
|
||||
Orientation = sl.Orientation == Microsoft.Maui.Controls.StackOrientation.Vertical
|
||||
? StackOrientation.Vertical
|
||||
: StackOrientation.Horizontal,
|
||||
Spacing = (float)sl.Spacing
|
||||
};
|
||||
|
||||
foreach (var child in sl.Children)
|
||||
{
|
||||
if (child is Element element)
|
||||
{
|
||||
var skiaChild = ConvertElementToSkiaView(element);
|
||||
if (skiaChild != null)
|
||||
layout.AddChild(skiaChild);
|
||||
}
|
||||
}
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
private SkiaGrid CreateSkiaGrid(Microsoft.Maui.Controls.Grid grid)
|
||||
{
|
||||
var layout = new SkiaGrid();
|
||||
|
||||
// Set row definitions
|
||||
foreach (var rowDef in grid.RowDefinitions)
|
||||
{
|
||||
var gridLength = rowDef.Height.IsAuto ? GridLength.Auto :
|
||||
rowDef.Height.IsStar ? new GridLength((float)rowDef.Height.Value, GridUnitType.Star) :
|
||||
new GridLength((float)rowDef.Height.Value, GridUnitType.Absolute);
|
||||
layout.RowDefinitions.Add(gridLength);
|
||||
}
|
||||
|
||||
// Set column definitions
|
||||
foreach (var colDef in grid.ColumnDefinitions)
|
||||
{
|
||||
var gridLength = colDef.Width.IsAuto ? GridLength.Auto :
|
||||
colDef.Width.IsStar ? new GridLength((float)colDef.Width.Value, GridUnitType.Star) :
|
||||
new GridLength((float)colDef.Width.Value, GridUnitType.Absolute);
|
||||
layout.ColumnDefinitions.Add(gridLength);
|
||||
}
|
||||
|
||||
// Add children
|
||||
foreach (var child in grid.Children)
|
||||
{
|
||||
if (child is Element element)
|
||||
{
|
||||
var skiaChild = ConvertElementToSkiaView(element);
|
||||
if (skiaChild != null)
|
||||
{
|
||||
var row = Microsoft.Maui.Controls.Grid.GetRow((BindableObject)child);
|
||||
var col = Microsoft.Maui.Controls.Grid.GetColumn((BindableObject)child);
|
||||
var rowSpan = Microsoft.Maui.Controls.Grid.GetRowSpan((BindableObject)child);
|
||||
var colSpan = Microsoft.Maui.Controls.Grid.GetColumnSpan((BindableObject)child);
|
||||
|
||||
layout.AddChild(skiaChild, row, col, rowSpan, colSpan);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
private SkiaBorder CreateSkiaBorder(Microsoft.Maui.Controls.Border border)
|
||||
{
|
||||
float cornerRadius = 0;
|
||||
if (border.StrokeShape is Microsoft.Maui.Controls.Shapes.RoundRectangle rr)
|
||||
{
|
||||
cornerRadius = (float)rr.CornerRadius.TopLeft;
|
||||
}
|
||||
|
||||
var skiaBorder = new SkiaBorder
|
||||
{
|
||||
CornerRadius = cornerRadius,
|
||||
StrokeThickness = (float)border.StrokeThickness
|
||||
};
|
||||
|
||||
if (border.Stroke is SolidColorBrush strokeBrush)
|
||||
{
|
||||
skiaBorder.Stroke = strokeBrush.Color.ToSKColor();
|
||||
}
|
||||
|
||||
if (border.Background is SolidColorBrush bgBrush)
|
||||
{
|
||||
skiaBorder.BackgroundColor = bgBrush.Color.ToSKColor();
|
||||
}
|
||||
|
||||
if (border.Content is Element content)
|
||||
{
|
||||
var skiaContent = ConvertElementToSkiaView(content);
|
||||
if (skiaContent != null)
|
||||
skiaBorder.AddChild(skiaContent);
|
||||
}
|
||||
|
||||
return skiaBorder;
|
||||
}
|
||||
|
||||
private SkiaLabel CreateSkiaLabel(Microsoft.Maui.Controls.Label label)
|
||||
{
|
||||
var skiaLabel = new SkiaLabel
|
||||
{
|
||||
Text = label.Text ?? "",
|
||||
FontSize = (float)label.FontSize
|
||||
};
|
||||
|
||||
if (label.TextColor != null)
|
||||
{
|
||||
skiaLabel.TextColor = label.TextColor.ToSKColor();
|
||||
}
|
||||
|
||||
return skiaLabel;
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
if (_templateRoot != null && _templateApplied)
|
||||
{
|
||||
// Render the template
|
||||
_templateRoot.Draw(canvas);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Render default appearance
|
||||
DrawDefaultAppearance(canvas, bounds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws the default appearance when no template is applied.
|
||||
/// Override in derived classes to provide default rendering.
|
||||
/// </summary>
|
||||
protected abstract void DrawDefaultAppearance(SKCanvas canvas, SKRect bounds);
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
if (_templateRoot != null && _templateApplied)
|
||||
{
|
||||
return _templateRoot.Measure(availableSize);
|
||||
}
|
||||
|
||||
return MeasureDefaultAppearance(availableSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Measures the default appearance when no template is applied.
|
||||
/// Override in derived classes.
|
||||
/// </summary>
|
||||
protected virtual SKSize MeasureDefaultAppearance(SKSize availableSize)
|
||||
{
|
||||
return new SKSize(100, 40);
|
||||
}
|
||||
|
||||
public new void Arrange(SKRect bounds)
|
||||
{
|
||||
base.Arrange(bounds);
|
||||
|
||||
if (_templateRoot != null && _templateApplied)
|
||||
{
|
||||
_templateRoot.Arrange(bounds);
|
||||
}
|
||||
}
|
||||
|
||||
public override SkiaView? HitTest(float x, float y)
|
||||
{
|
||||
if (!IsVisible || !Bounds.Contains(x, y))
|
||||
return null;
|
||||
|
||||
if (_templateRoot != null && _templateApplied)
|
||||
{
|
||||
var hit = _templateRoot.HitTest(x, y);
|
||||
if (hit != null)
|
||||
return hit;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using SkiaSharp;
|
||||
using Microsoft.Maui.Platform.Linux;
|
||||
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
@@ -10,77 +11,202 @@ namespace Microsoft.Maui.Platform;
|
||||
/// </summary>
|
||||
public class SkiaTimePicker : SkiaView
|
||||
{
|
||||
private TimeSpan _time = DateTime.Now.TimeOfDay;
|
||||
private bool _isOpen;
|
||||
private string _format = "t";
|
||||
private int _selectedHour;
|
||||
private int _selectedMinute;
|
||||
private bool _isSelectingHours = true;
|
||||
#region BindableProperties
|
||||
|
||||
// Styling
|
||||
public SKColor TextColor { get; set; } = SKColors.Black;
|
||||
public SKColor BorderColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public SKColor ClockBackgroundColor { get; set; } = SKColors.White;
|
||||
public SKColor ClockFaceColor { get; set; } = new SKColor(0xF5, 0xF5, 0xF5);
|
||||
public SKColor SelectedColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public SKColor HeaderColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public float FontSize { get; set; } = 14;
|
||||
public float CornerRadius { get; set; } = 4;
|
||||
public static readonly BindableProperty TimeProperty =
|
||||
BindableProperty.Create(nameof(Time), typeof(TimeSpan), typeof(SkiaTimePicker), DateTime.Now.TimeOfDay, BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaTimePicker)b).OnTimePropertyChanged());
|
||||
|
||||
private const float ClockSize = 280;
|
||||
private const float ClockRadius = 100;
|
||||
private const float HeaderHeight = 80;
|
||||
public static readonly BindableProperty FormatProperty =
|
||||
BindableProperty.Create(nameof(Format), typeof(string), typeof(SkiaTimePicker), "t",
|
||||
propertyChanged: (b, o, n) => ((SkiaTimePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty TextColorProperty =
|
||||
BindableProperty.Create(nameof(TextColor), typeof(SKColor), typeof(SkiaTimePicker), SKColors.Black,
|
||||
propertyChanged: (b, o, n) => ((SkiaTimePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty BorderColorProperty =
|
||||
BindableProperty.Create(nameof(BorderColor), typeof(SKColor), typeof(SkiaTimePicker), new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaTimePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty ClockBackgroundColorProperty =
|
||||
BindableProperty.Create(nameof(ClockBackgroundColor), typeof(SKColor), typeof(SkiaTimePicker), SKColors.White,
|
||||
propertyChanged: (b, o, n) => ((SkiaTimePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty ClockFaceColorProperty =
|
||||
BindableProperty.Create(nameof(ClockFaceColor), typeof(SKColor), typeof(SkiaTimePicker), new SKColor(0xF5, 0xF5, 0xF5),
|
||||
propertyChanged: (b, o, n) => ((SkiaTimePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty SelectedColorProperty =
|
||||
BindableProperty.Create(nameof(SelectedColor), typeof(SKColor), typeof(SkiaTimePicker), new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaTimePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty HeaderColorProperty =
|
||||
BindableProperty.Create(nameof(HeaderColor), typeof(SKColor), typeof(SkiaTimePicker), new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaTimePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty FontSizeProperty =
|
||||
BindableProperty.Create(nameof(FontSize), typeof(float), typeof(SkiaTimePicker), 14f,
|
||||
propertyChanged: (b, o, n) => ((SkiaTimePicker)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty CornerRadiusProperty =
|
||||
BindableProperty.Create(nameof(CornerRadius), typeof(float), typeof(SkiaTimePicker), 4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaTimePicker)b).Invalidate());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
public TimeSpan Time
|
||||
{
|
||||
get => _time;
|
||||
set
|
||||
{
|
||||
if (_time != value)
|
||||
{
|
||||
_time = value;
|
||||
_selectedHour = _time.Hours;
|
||||
_selectedMinute = _time.Minutes;
|
||||
TimeSelected?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (TimeSpan)GetValue(TimeProperty);
|
||||
set => SetValue(TimeProperty, value);
|
||||
}
|
||||
|
||||
public string Format
|
||||
{
|
||||
get => _format;
|
||||
set { _format = value; Invalidate(); }
|
||||
get => (string)GetValue(FormatProperty);
|
||||
set => SetValue(FormatProperty, value);
|
||||
}
|
||||
|
||||
public SKColor TextColor
|
||||
{
|
||||
get => (SKColor)GetValue(TextColorProperty);
|
||||
set => SetValue(TextColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor BorderColor
|
||||
{
|
||||
get => (SKColor)GetValue(BorderColorProperty);
|
||||
set => SetValue(BorderColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor ClockBackgroundColor
|
||||
{
|
||||
get => (SKColor)GetValue(ClockBackgroundColorProperty);
|
||||
set => SetValue(ClockBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor ClockFaceColor
|
||||
{
|
||||
get => (SKColor)GetValue(ClockFaceColorProperty);
|
||||
set => SetValue(ClockFaceColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor SelectedColor
|
||||
{
|
||||
get => (SKColor)GetValue(SelectedColorProperty);
|
||||
set => SetValue(SelectedColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor HeaderColor
|
||||
{
|
||||
get => (SKColor)GetValue(HeaderColorProperty);
|
||||
set => SetValue(HeaderColorProperty, value);
|
||||
}
|
||||
|
||||
public float FontSize
|
||||
{
|
||||
get => (float)GetValue(FontSizeProperty);
|
||||
set => SetValue(FontSizeProperty, value);
|
||||
}
|
||||
|
||||
public float CornerRadius
|
||||
{
|
||||
get => (float)GetValue(CornerRadiusProperty);
|
||||
set => SetValue(CornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
public bool IsOpen
|
||||
{
|
||||
get => _isOpen;
|
||||
set { _isOpen = value; Invalidate(); }
|
||||
set
|
||||
{
|
||||
if (_isOpen != value)
|
||||
{
|
||||
_isOpen = value;
|
||||
if (_isOpen)
|
||||
RegisterPopupOverlay(this, DrawClockOverlay);
|
||||
else
|
||||
UnregisterPopupOverlay(this);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private bool _isOpen;
|
||||
private int _selectedHour;
|
||||
private int _selectedMinute;
|
||||
private bool _isSelectingHours = true;
|
||||
|
||||
private const float ClockSize = 280;
|
||||
private const float ClockRadius = 100;
|
||||
private const float HeaderHeight = 80;
|
||||
private const float PopupHeight = ClockSize + HeaderHeight;
|
||||
|
||||
public event EventHandler? TimeSelected;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the clock popup rectangle with edge detection applied.
|
||||
/// </summary>
|
||||
private SKRect GetPopupRect(SKRect pickerBounds)
|
||||
{
|
||||
// Get window dimensions for edge detection
|
||||
var windowWidth = LinuxApplication.Current?.MainWindow?.Width ?? 800;
|
||||
var windowHeight = LinuxApplication.Current?.MainWindow?.Height ?? 600;
|
||||
|
||||
// Calculate default position (below the picker)
|
||||
var popupLeft = pickerBounds.Left;
|
||||
var popupTop = pickerBounds.Bottom + 4;
|
||||
|
||||
// Edge detection: adjust horizontal position if popup would go off-screen
|
||||
if (popupLeft + ClockSize > windowWidth)
|
||||
{
|
||||
popupLeft = windowWidth - ClockSize - 4;
|
||||
}
|
||||
if (popupLeft < 0) popupLeft = 4;
|
||||
|
||||
// Edge detection: show above if popup would go off-screen vertically
|
||||
if (popupTop + PopupHeight > windowHeight)
|
||||
{
|
||||
popupTop = pickerBounds.Top - PopupHeight - 4;
|
||||
}
|
||||
if (popupTop < 0) popupTop = 4;
|
||||
|
||||
return new SKRect(popupLeft, popupTop, popupLeft + ClockSize, popupTop + PopupHeight);
|
||||
}
|
||||
|
||||
public SkiaTimePicker()
|
||||
{
|
||||
IsFocusable = true;
|
||||
_selectedHour = _time.Hours;
|
||||
_selectedMinute = _time.Minutes;
|
||||
_selectedHour = DateTime.Now.Hour;
|
||||
_selectedMinute = DateTime.Now.Minute;
|
||||
}
|
||||
|
||||
private void OnTimePropertyChanged()
|
||||
{
|
||||
_selectedHour = Time.Hours;
|
||||
_selectedMinute = Time.Minutes;
|
||||
TimeSelected?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void DrawClockOverlay(SKCanvas canvas)
|
||||
{
|
||||
if (!_isOpen) return;
|
||||
// Use ScreenBounds for popup drawing (accounts for scroll offset)
|
||||
DrawClockPopup(canvas, ScreenBounds);
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
DrawPickerButton(canvas, bounds);
|
||||
|
||||
if (_isOpen)
|
||||
{
|
||||
DrawClockPopup(canvas, bounds);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawPickerButton(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Draw background
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = IsEnabled ? BackgroundColor : new SKColor(0xF5, 0xF5, 0xF5),
|
||||
@@ -89,7 +215,6 @@ public class SkiaTimePicker : SkiaView
|
||||
};
|
||||
canvas.DrawRoundRect(new SKRoundRect(bounds, CornerRadius), bgPaint);
|
||||
|
||||
// Draw border
|
||||
using var borderPaint = new SKPaint
|
||||
{
|
||||
Color = IsFocused ? SelectedColor : BorderColor,
|
||||
@@ -99,23 +224,17 @@ public class SkiaTimePicker : SkiaView
|
||||
};
|
||||
canvas.DrawRoundRect(new SKRoundRect(bounds, CornerRadius), borderPaint);
|
||||
|
||||
// Draw time text
|
||||
using var font = new SKFont(SKTypeface.Default, FontSize);
|
||||
using var textPaint = new SKPaint(font)
|
||||
{
|
||||
Color = IsEnabled ? TextColor : TextColor.WithAlpha(128),
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
var timeText = DateTime.Today.Add(_time).ToString(_format);
|
||||
var timeText = DateTime.Today.Add(Time).ToString(Format);
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(timeText, ref textBounds);
|
||||
canvas.DrawText(timeText, bounds.Left + 12, bounds.MidY - textBounds.MidY, textPaint);
|
||||
|
||||
var textX = bounds.Left + 12;
|
||||
var textY = bounds.MidY - textBounds.MidY;
|
||||
canvas.DrawText(timeText, textX, textY, textPaint);
|
||||
|
||||
// Draw clock icon
|
||||
DrawClockIcon(canvas, new SKRect(bounds.Right - 36, bounds.MidY - 10, bounds.Right - 12, bounds.MidY + 10));
|
||||
}
|
||||
|
||||
@@ -128,108 +247,52 @@ public class SkiaTimePicker : SkiaView
|
||||
StrokeWidth = 1.5f,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
var centerX = bounds.MidX;
|
||||
var centerY = bounds.MidY;
|
||||
var radius = Math.Min(bounds.Width, bounds.Height) / 2 - 2;
|
||||
|
||||
// Clock circle
|
||||
canvas.DrawCircle(centerX, centerY, radius, paint);
|
||||
|
||||
// Hour hand
|
||||
canvas.DrawLine(centerX, centerY, centerX, centerY - radius * 0.5f, paint);
|
||||
|
||||
// Minute hand
|
||||
canvas.DrawLine(centerX, centerY, centerX + radius * 0.4f, centerY, paint);
|
||||
|
||||
// Center dot
|
||||
canvas.DrawCircle(bounds.MidX, bounds.MidY, radius, paint);
|
||||
canvas.DrawLine(bounds.MidX, bounds.MidY, bounds.MidX, bounds.MidY - radius * 0.5f, paint);
|
||||
canvas.DrawLine(bounds.MidX, bounds.MidY, bounds.MidX + radius * 0.4f, bounds.MidY, paint);
|
||||
paint.Style = SKPaintStyle.Fill;
|
||||
canvas.DrawCircle(centerX, centerY, 1.5f, paint);
|
||||
canvas.DrawCircle(bounds.MidX, bounds.MidY, 1.5f, paint);
|
||||
}
|
||||
|
||||
private void DrawClockPopup(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
var popupRect = new SKRect(
|
||||
bounds.Left,
|
||||
bounds.Bottom + 4,
|
||||
bounds.Left + ClockSize,
|
||||
bounds.Bottom + 4 + HeaderHeight + ClockSize);
|
||||
var popupRect = GetPopupRect(bounds);
|
||||
|
||||
// Draw shadow
|
||||
using var shadowPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(0, 0, 0, 40),
|
||||
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 4),
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
using var shadowPaint = new SKPaint { Color = new SKColor(0, 0, 0, 40), MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 4), Style = SKPaintStyle.Fill };
|
||||
canvas.DrawRoundRect(new SKRoundRect(new SKRect(popupRect.Left + 2, popupRect.Top + 2, popupRect.Right + 2, popupRect.Bottom + 2), CornerRadius), shadowPaint);
|
||||
|
||||
// Draw background
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = ClockBackgroundColor,
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
using var bgPaint = new SKPaint { Color = ClockBackgroundColor, Style = SKPaintStyle.Fill, IsAntialias = true };
|
||||
canvas.DrawRoundRect(new SKRoundRect(popupRect, CornerRadius), bgPaint);
|
||||
|
||||
// Draw border
|
||||
using var borderPaint = new SKPaint
|
||||
{
|
||||
Color = BorderColor,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 1,
|
||||
IsAntialias = true
|
||||
};
|
||||
using var borderPaint = new SKPaint { Color = BorderColor, Style = SKPaintStyle.Stroke, StrokeWidth = 1, IsAntialias = true };
|
||||
canvas.DrawRoundRect(new SKRoundRect(popupRect, CornerRadius), borderPaint);
|
||||
|
||||
// Draw header with time display
|
||||
DrawTimeHeader(canvas, new SKRect(popupRect.Left, popupRect.Top, popupRect.Right, popupRect.Top + HeaderHeight));
|
||||
|
||||
// Draw clock face
|
||||
DrawClockFace(canvas, new SKRect(popupRect.Left, popupRect.Top + HeaderHeight, popupRect.Right, popupRect.Bottom));
|
||||
}
|
||||
|
||||
private void DrawTimeHeader(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Draw header background
|
||||
using var headerPaint = new SKPaint
|
||||
{
|
||||
Color = HeaderColor,
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
|
||||
using var headerPaint = new SKPaint { Color = HeaderColor, Style = SKPaintStyle.Fill };
|
||||
canvas.Save();
|
||||
canvas.ClipRoundRect(new SKRoundRect(new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Top + CornerRadius * 2), CornerRadius));
|
||||
canvas.DrawRect(bounds, headerPaint);
|
||||
canvas.Restore();
|
||||
canvas.DrawRect(new SKRect(bounds.Left, bounds.Top + CornerRadius, bounds.Right, bounds.Bottom), headerPaint);
|
||||
|
||||
// Draw time display
|
||||
using var font = new SKFont(SKTypeface.Default, 32);
|
||||
using var selectedPaint = new SKPaint(font)
|
||||
{
|
||||
Color = SKColors.White,
|
||||
IsAntialias = true
|
||||
};
|
||||
using var unselectedPaint = new SKPaint(font)
|
||||
{
|
||||
Color = new SKColor(255, 255, 255, 150),
|
||||
IsAntialias = true
|
||||
};
|
||||
using var selectedPaint = new SKPaint(font) { Color = SKColors.White, IsAntialias = true };
|
||||
using var unselectedPaint = new SKPaint(font) { Color = new SKColor(255, 255, 255, 150), IsAntialias = true };
|
||||
|
||||
var hourText = _selectedHour.ToString("D2");
|
||||
var minuteText = _selectedMinute.ToString("D2");
|
||||
var colonText = ":";
|
||||
|
||||
var hourPaint = _isSelectingHours ? selectedPaint : unselectedPaint;
|
||||
var minutePaint = _isSelectingHours ? unselectedPaint : selectedPaint;
|
||||
|
||||
var hourBounds = new SKRect();
|
||||
var colonBounds = new SKRect();
|
||||
var minuteBounds = new SKRect();
|
||||
var hourBounds = new SKRect(); var colonBounds = new SKRect(); var minuteBounds = new SKRect();
|
||||
hourPaint.MeasureText(hourText, ref hourBounds);
|
||||
selectedPaint.MeasureText(colonText, ref colonBounds);
|
||||
selectedPaint.MeasureText(":", ref colonBounds);
|
||||
minutePaint.MeasureText(minuteText, ref minuteBounds);
|
||||
|
||||
var totalWidth = hourBounds.Width + colonBounds.Width + minuteBounds.Width + 8;
|
||||
@@ -237,7 +300,7 @@ public class SkiaTimePicker : SkiaView
|
||||
var centerY = bounds.MidY - hourBounds.MidY;
|
||||
|
||||
canvas.DrawText(hourText, startX, centerY, hourPaint);
|
||||
canvas.DrawText(colonText, startX + hourBounds.Width + 4, centerY, selectedPaint);
|
||||
canvas.DrawText(":", startX + hourBounds.Width + 4, centerY, selectedPaint);
|
||||
canvas.DrawText(minuteText, startX + hourBounds.Width + colonBounds.Width + 8, centerY, minutePaint);
|
||||
}
|
||||
|
||||
@@ -246,94 +309,53 @@ public class SkiaTimePicker : SkiaView
|
||||
var centerX = bounds.MidX;
|
||||
var centerY = bounds.MidY;
|
||||
|
||||
// Draw clock face background
|
||||
using var facePaint = new SKPaint
|
||||
{
|
||||
Color = ClockFaceColor,
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
using var facePaint = new SKPaint { Color = ClockFaceColor, Style = SKPaintStyle.Fill, IsAntialias = true };
|
||||
canvas.DrawCircle(centerX, centerY, ClockRadius + 20, facePaint);
|
||||
|
||||
// Draw numbers
|
||||
using var font = new SKFont(SKTypeface.Default, 14);
|
||||
using var textPaint = new SKPaint(font)
|
||||
{
|
||||
Color = TextColor,
|
||||
IsAntialias = true
|
||||
};
|
||||
using var textPaint = new SKPaint(font) { Color = TextColor, IsAntialias = true };
|
||||
|
||||
if (_isSelectingHours)
|
||||
{
|
||||
// Draw hour numbers (1-12)
|
||||
for (int i = 1; i <= 12; i++)
|
||||
{
|
||||
var angle = (i * 30 - 90) * Math.PI / 180;
|
||||
var x = centerX + (float)(ClockRadius * Math.Cos(angle));
|
||||
var y = centerY + (float)(ClockRadius * Math.Sin(angle));
|
||||
|
||||
var numText = i.ToString();
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(numText, ref textBounds);
|
||||
|
||||
var isSelected = (_selectedHour % 12 == i % 12);
|
||||
if (isSelected)
|
||||
{
|
||||
using var selectedBgPaint = new SKPaint
|
||||
{
|
||||
Color = SelectedColor,
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawCircle(x, y, 18, selectedBgPaint);
|
||||
using var selBgPaint = new SKPaint { Color = SelectedColor, Style = SKPaintStyle.Fill, IsAntialias = true };
|
||||
canvas.DrawCircle(x, y, 18, selBgPaint);
|
||||
textPaint.Color = SKColors.White;
|
||||
}
|
||||
else
|
||||
{
|
||||
textPaint.Color = TextColor;
|
||||
}
|
||||
|
||||
canvas.DrawText(numText, x - textBounds.MidX, y - textBounds.MidY, textPaint);
|
||||
else textPaint.Color = TextColor;
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(i.ToString(), ref textBounds);
|
||||
canvas.DrawText(i.ToString(), x - textBounds.MidX, y - textBounds.MidY, textPaint);
|
||||
}
|
||||
|
||||
// Draw center point and hand
|
||||
DrawClockHand(canvas, centerX, centerY, (_selectedHour % 12) * 30 - 90, ClockRadius - 18);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Draw minute numbers (0, 5, 10, ... 55)
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
var minute = i * 5;
|
||||
var angle = (minute * 6 - 90) * Math.PI / 180;
|
||||
var x = centerX + (float)(ClockRadius * Math.Cos(angle));
|
||||
var y = centerY + (float)(ClockRadius * Math.Sin(angle));
|
||||
|
||||
var numText = minute.ToString("D2");
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(numText, ref textBounds);
|
||||
|
||||
var isSelected = (_selectedMinute / 5 == i);
|
||||
if (isSelected)
|
||||
{
|
||||
using var selectedBgPaint = new SKPaint
|
||||
{
|
||||
Color = SelectedColor,
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawCircle(x, y, 18, selectedBgPaint);
|
||||
using var selBgPaint = new SKPaint { Color = SelectedColor, Style = SKPaintStyle.Fill, IsAntialias = true };
|
||||
canvas.DrawCircle(x, y, 18, selBgPaint);
|
||||
textPaint.Color = SKColors.White;
|
||||
}
|
||||
else
|
||||
{
|
||||
textPaint.Color = TextColor;
|
||||
}
|
||||
|
||||
canvas.DrawText(numText, x - textBounds.MidX, y - textBounds.MidY, textPaint);
|
||||
else textPaint.Color = TextColor;
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(minute.ToString("D2"), ref textBounds);
|
||||
canvas.DrawText(minute.ToString("D2"), x - textBounds.MidX, y - textBounds.MidY, textPaint);
|
||||
}
|
||||
|
||||
// Draw center point and hand
|
||||
DrawClockHand(canvas, centerX, centerY, _selectedMinute * 6 - 90, ClockRadius - 18);
|
||||
}
|
||||
}
|
||||
@@ -341,19 +363,8 @@ public class SkiaTimePicker : SkiaView
|
||||
private void DrawClockHand(SKCanvas canvas, float centerX, float centerY, float angleDegrees, float length)
|
||||
{
|
||||
var angle = angleDegrees * Math.PI / 180;
|
||||
var endX = centerX + (float)(length * Math.Cos(angle));
|
||||
var endY = centerY + (float)(length * Math.Sin(angle));
|
||||
|
||||
using var handPaint = new SKPaint
|
||||
{
|
||||
Color = SelectedColor,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 2,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawLine(centerX, centerY, endX, endY, handPaint);
|
||||
|
||||
// Center dot
|
||||
using var handPaint = new SKPaint { Color = SelectedColor, Style = SKPaintStyle.Stroke, StrokeWidth = 2, IsAntialias = true };
|
||||
canvas.DrawLine(centerX, centerY, centerX + (float)(length * Math.Cos(angle)), centerY + (float)(length * Math.Sin(angle)), handPaint);
|
||||
handPaint.Style = SKPaintStyle.Fill;
|
||||
canvas.DrawCircle(centerX, centerY, 6, handPaint);
|
||||
}
|
||||
@@ -362,31 +373,24 @@ public class SkiaTimePicker : SkiaView
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
|
||||
if (_isOpen)
|
||||
if (IsOpen)
|
||||
{
|
||||
var popupTop = Bounds.Bottom + 4;
|
||||
var popupLeft = Bounds.Left;
|
||||
// Use ScreenBounds for popup coordinate calculations (accounts for scroll offset)
|
||||
var screenBounds = ScreenBounds;
|
||||
var popupRect = GetPopupRect(screenBounds);
|
||||
|
||||
// Check header click (toggle hours/minutes)
|
||||
if (e.Y >= popupTop && e.Y < popupTop + HeaderHeight)
|
||||
// Check if click is in header area
|
||||
var headerRect = new SKRect(popupRect.Left, popupRect.Top, popupRect.Right, popupRect.Top + HeaderHeight);
|
||||
if (headerRect.Contains(e.X, e.Y))
|
||||
{
|
||||
var centerX = popupLeft + ClockSize / 2;
|
||||
if (e.X < centerX)
|
||||
{
|
||||
_isSelectingHours = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_isSelectingHours = false;
|
||||
}
|
||||
_isSelectingHours = e.X < popupRect.Left + ClockSize / 2;
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check clock face click
|
||||
var clockCenterX = popupLeft + ClockSize / 2;
|
||||
var clockCenterY = popupTop + HeaderHeight + ClockSize / 2;
|
||||
|
||||
// Check if click is in clock face area
|
||||
var clockCenterX = popupRect.Left + ClockSize / 2;
|
||||
var clockCenterY = popupRect.Top + HeaderHeight + ClockSize / 2;
|
||||
var dx = e.X - clockCenterX;
|
||||
var dy = e.Y - clockCenterY;
|
||||
var distance = Math.Sqrt(dx * dx + dy * dy);
|
||||
@@ -400,114 +404,86 @@ public class SkiaTimePicker : SkiaView
|
||||
{
|
||||
_selectedHour = ((int)Math.Round(angle / 30) % 12);
|
||||
if (_selectedHour == 0) _selectedHour = 12;
|
||||
// Preserve AM/PM
|
||||
if (_time.Hours >= 12 && _selectedHour != 12)
|
||||
_selectedHour += 12;
|
||||
else if (_time.Hours < 12 && _selectedHour == 12)
|
||||
_selectedHour = 0;
|
||||
|
||||
_isSelectingHours = false; // Move to minutes
|
||||
if (Time.Hours >= 12 && _selectedHour != 12) _selectedHour += 12;
|
||||
else if (Time.Hours < 12 && _selectedHour == 12) _selectedHour = 0;
|
||||
_isSelectingHours = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedMinute = ((int)Math.Round(angle / 6) % 60);
|
||||
// Apply the time
|
||||
Time = new TimeSpan(_selectedHour, _selectedMinute, 0);
|
||||
_isOpen = false;
|
||||
IsOpen = false;
|
||||
}
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Click outside popup - close
|
||||
if (e.Y < popupTop)
|
||||
// Click is outside clock - check if it's on the picker itself to toggle
|
||||
if (screenBounds.Contains(e.X, e.Y))
|
||||
{
|
||||
_isOpen = false;
|
||||
IsOpen = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_isOpen = true;
|
||||
IsOpen = true;
|
||||
_isSelectingHours = true;
|
||||
}
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public override void OnFocusLost()
|
||||
{
|
||||
base.OnFocusLost();
|
||||
// Close popup when focus is lost (clicking outside)
|
||||
if (IsOpen)
|
||||
{
|
||||
IsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Enter:
|
||||
case Key.Space:
|
||||
if (_isOpen)
|
||||
{
|
||||
if (_isSelectingHours)
|
||||
{
|
||||
_isSelectingHours = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
Time = new TimeSpan(_selectedHour, _selectedMinute, 0);
|
||||
_isOpen = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_isOpen = true;
|
||||
_isSelectingHours = true;
|
||||
}
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Escape:
|
||||
if (_isOpen)
|
||||
{
|
||||
_isOpen = false;
|
||||
e.Handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.Up:
|
||||
if (_isSelectingHours)
|
||||
{
|
||||
_selectedHour = (_selectedHour + 1) % 24;
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedMinute = (_selectedMinute + 1) % 60;
|
||||
}
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Down:
|
||||
if (_isSelectingHours)
|
||||
{
|
||||
_selectedHour = (_selectedHour - 1 + 24) % 24;
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedMinute = (_selectedMinute - 1 + 60) % 60;
|
||||
}
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Left:
|
||||
case Key.Right:
|
||||
_isSelectingHours = !_isSelectingHours;
|
||||
e.Handled = true;
|
||||
break;
|
||||
case Key.Enter: case Key.Space:
|
||||
if (IsOpen) { if (_isSelectingHours) _isSelectingHours = false; else { Time = new TimeSpan(_selectedHour, _selectedMinute, 0); IsOpen = false; } }
|
||||
else { IsOpen = true; _isSelectingHours = true; }
|
||||
e.Handled = true; break;
|
||||
case Key.Escape: if (IsOpen) { IsOpen = false; e.Handled = true; } break;
|
||||
case Key.Up: if (_isSelectingHours) _selectedHour = (_selectedHour + 1) % 24; else _selectedMinute = (_selectedMinute + 1) % 60; e.Handled = true; break;
|
||||
case Key.Down: if (_isSelectingHours) _selectedHour = (_selectedHour - 1 + 24) % 24; else _selectedMinute = (_selectedMinute - 1 + 60) % 60; e.Handled = true; break;
|
||||
case Key.Left: case Key.Right: _isSelectingHours = !_isSelectingHours; e.Handled = true; break;
|
||||
}
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
return new SKSize(
|
||||
availableSize.Width < float.MaxValue ? Math.Min(availableSize.Width, 200) : 200,
|
||||
40);
|
||||
return new SKSize(availableSize.Width < float.MaxValue ? Math.Min(availableSize.Width, 200) : 200, 40);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override to include clock popup area in hit testing.
|
||||
/// </summary>
|
||||
protected override bool HitTestPopupArea(float x, float y)
|
||||
{
|
||||
// Use ScreenBounds for hit testing (accounts for scroll offset)
|
||||
var screenBounds = ScreenBounds;
|
||||
|
||||
// Always include the picker button itself
|
||||
if (screenBounds.Contains(x, y))
|
||||
return true;
|
||||
|
||||
// When open, also include the clock popup area (with edge detection)
|
||||
if (_isOpen)
|
||||
{
|
||||
var popupRect = GetPopupRect(screenBounds);
|
||||
return popupRect.Contains(x, y);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for all Skia-rendered views on Linux.
|
||||
/// Inherits from BindableObject to enable XAML styling, data binding, and Visual State Manager.
|
||||
/// </summary>
|
||||
public abstract class SkiaView : IDisposable
|
||||
public abstract class SkiaView : BindableObject, IDisposable
|
||||
{
|
||||
// Popup overlay system for dropdowns, calendars, etc.
|
||||
private static readonly List<(SkiaView Owner, Action<SKCanvas> Draw)> _popupOverlays = new();
|
||||
@@ -32,7 +33,7 @@ public abstract class SkiaView : IDisposable
|
||||
{
|
||||
canvas.Restore();
|
||||
}
|
||||
|
||||
|
||||
foreach (var (_, draw) in _popupOverlays)
|
||||
{
|
||||
canvas.Save();
|
||||
@@ -41,6 +42,189 @@ public abstract class SkiaView : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the popup owner that should receive pointer events at the given coordinates.
|
||||
/// This allows popups to receive events even outside their normal bounds.
|
||||
/// </summary>
|
||||
public static SkiaView? GetPopupOwnerAt(float x, float y)
|
||||
{
|
||||
// Check in reverse order (topmost popup first)
|
||||
for (int i = _popupOverlays.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var owner = _popupOverlays[i].Owner;
|
||||
if (owner.HitTestPopupArea(x, y))
|
||||
{
|
||||
return owner;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if there are any active popup overlays.
|
||||
/// </summary>
|
||||
public static bool HasActivePopup => _popupOverlays.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Override this to define the popup area for hit testing.
|
||||
/// </summary>
|
||||
protected virtual bool HitTestPopupArea(float x, float y)
|
||||
{
|
||||
// Default: no popup area beyond normal bounds
|
||||
return Bounds.Contains(x, y);
|
||||
}
|
||||
|
||||
#region BindableProperties
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsVisible.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsVisibleProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsVisible),
|
||||
typeof(bool),
|
||||
typeof(SkiaView),
|
||||
true,
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).OnVisibilityChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsEnabled.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsEnabledProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsEnabled),
|
||||
typeof(bool),
|
||||
typeof(SkiaView),
|
||||
true,
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).OnEnabledChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Opacity.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty OpacityProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Opacity),
|
||||
typeof(float),
|
||||
typeof(SkiaView),
|
||||
1.0f,
|
||||
coerceValue: (b, v) => Math.Clamp((float)v, 0f, 1f),
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for BackgroundColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty BackgroundColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(BackgroundColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaView),
|
||||
SKColors.Transparent,
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for WidthRequest.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty WidthRequestProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(WidthRequest),
|
||||
typeof(double),
|
||||
typeof(SkiaView),
|
||||
-1.0,
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for HeightRequest.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty HeightRequestProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(HeightRequest),
|
||||
typeof(double),
|
||||
typeof(SkiaView),
|
||||
-1.0,
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for MinimumWidthRequest.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty MinimumWidthRequestProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(MinimumWidthRequest),
|
||||
typeof(double),
|
||||
typeof(SkiaView),
|
||||
0.0,
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for MinimumHeightRequest.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty MinimumHeightRequestProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(MinimumHeightRequest),
|
||||
typeof(double),
|
||||
typeof(SkiaView),
|
||||
0.0,
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsFocusable.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsFocusableProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsFocusable),
|
||||
typeof(bool),
|
||||
typeof(SkiaView),
|
||||
false);
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Margin.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty MarginProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Margin),
|
||||
typeof(Thickness),
|
||||
typeof(SkiaView),
|
||||
default(Thickness),
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for HorizontalOptions.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty HorizontalOptionsProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(HorizontalOptions),
|
||||
typeof(LayoutOptions),
|
||||
typeof(SkiaView),
|
||||
LayoutOptions.Fill,
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for VerticalOptions.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty VerticalOptionsProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(VerticalOptions),
|
||||
typeof(LayoutOptions),
|
||||
typeof(SkiaView),
|
||||
LayoutOptions.Fill,
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Name (used for template child lookup).
|
||||
/// </summary>
|
||||
public static readonly BindableProperty NameProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Name),
|
||||
typeof(string),
|
||||
typeof(SkiaView),
|
||||
string.Empty);
|
||||
|
||||
#endregion
|
||||
|
||||
private bool _disposed;
|
||||
private SKRect _bounds;
|
||||
private SkiaView? _parent;
|
||||
private readonly List<SkiaView> _children = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the absolute bounds of this view in screen coordinates.
|
||||
/// </summary>
|
||||
@@ -64,15 +248,6 @@ public abstract class SkiaView : IDisposable
|
||||
return bounds;
|
||||
}
|
||||
|
||||
private bool _disposed;
|
||||
private SKRect _bounds;
|
||||
private bool _isVisible = true;
|
||||
private bool _isEnabled = true;
|
||||
private float _opacity = 1.0f;
|
||||
private SKColor _backgroundColor = SKColors.Transparent;
|
||||
private SkiaView? _parent;
|
||||
private readonly List<SkiaView> _children = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the bounds of this view in parent coordinates.
|
||||
/// </summary>
|
||||
@@ -94,15 +269,8 @@ public abstract class SkiaView : IDisposable
|
||||
/// </summary>
|
||||
public bool IsVisible
|
||||
{
|
||||
get => _isVisible;
|
||||
set
|
||||
{
|
||||
if (_isVisible != value)
|
||||
{
|
||||
_isVisible = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (bool)GetValue(IsVisibleProperty);
|
||||
set => SetValue(IsVisibleProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -110,15 +278,8 @@ public abstract class SkiaView : IDisposable
|
||||
/// </summary>
|
||||
public bool IsEnabled
|
||||
{
|
||||
get => _isEnabled;
|
||||
set
|
||||
{
|
||||
if (_isEnabled != value)
|
||||
{
|
||||
_isEnabled = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (bool)GetValue(IsEnabledProperty);
|
||||
set => SetValue(IsEnabledProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -126,21 +287,14 @@ public abstract class SkiaView : IDisposable
|
||||
/// </summary>
|
||||
public float Opacity
|
||||
{
|
||||
get => _opacity;
|
||||
set
|
||||
{
|
||||
var clamped = Math.Clamp(value, 0f, 1f);
|
||||
if (_opacity != clamped)
|
||||
{
|
||||
_opacity = clamped;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (float)GetValue(OpacityProperty);
|
||||
set => SetValue(OpacityProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the background color.
|
||||
/// </summary>
|
||||
private SKColor _backgroundColor = SKColors.Transparent;
|
||||
public SKColor BackgroundColor
|
||||
{
|
||||
get => _backgroundColor;
|
||||
@@ -149,6 +303,7 @@ public abstract class SkiaView : IDisposable
|
||||
if (_backgroundColor != value)
|
||||
{
|
||||
_backgroundColor = value;
|
||||
SetValue(BackgroundColorProperty, value); // Keep BindableProperty in sync for bindings
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
@@ -157,17 +312,101 @@ public abstract class SkiaView : IDisposable
|
||||
/// <summary>
|
||||
/// Gets or sets the requested width.
|
||||
/// </summary>
|
||||
public double RequestedWidth { get; set; } = -1;
|
||||
public double WidthRequest
|
||||
{
|
||||
get => (double)GetValue(WidthRequestProperty);
|
||||
set => SetValue(WidthRequestProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the requested height.
|
||||
/// </summary>
|
||||
public double RequestedHeight { get; set; } = -1;
|
||||
public double HeightRequest
|
||||
{
|
||||
get => (double)GetValue(HeightRequestProperty);
|
||||
set => SetValue(HeightRequestProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum width request.
|
||||
/// </summary>
|
||||
public double MinimumWidthRequest
|
||||
{
|
||||
get => (double)GetValue(MinimumWidthRequestProperty);
|
||||
set => SetValue(MinimumWidthRequestProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum height request.
|
||||
/// </summary>
|
||||
public double MinimumHeightRequest
|
||||
{
|
||||
get => (double)GetValue(MinimumHeightRequestProperty);
|
||||
set => SetValue(MinimumHeightRequestProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the requested width (backwards compatibility alias).
|
||||
/// </summary>
|
||||
public double RequestedWidth
|
||||
{
|
||||
get => WidthRequest;
|
||||
set => WidthRequest = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the requested height (backwards compatibility alias).
|
||||
/// </summary>
|
||||
public double RequestedHeight
|
||||
{
|
||||
get => HeightRequest;
|
||||
set => HeightRequest = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether this view can receive keyboard focus.
|
||||
/// </summary>
|
||||
public bool IsFocusable { get; set; }
|
||||
public bool IsFocusable
|
||||
{
|
||||
get => (bool)GetValue(IsFocusableProperty);
|
||||
set => SetValue(IsFocusableProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the margin around this view.
|
||||
/// </summary>
|
||||
public Thickness Margin
|
||||
{
|
||||
get => (Thickness)GetValue(MarginProperty);
|
||||
set => SetValue(MarginProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal layout options.
|
||||
/// </summary>
|
||||
public LayoutOptions HorizontalOptions
|
||||
{
|
||||
get => (LayoutOptions)GetValue(HorizontalOptionsProperty);
|
||||
set => SetValue(HorizontalOptionsProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical layout options.
|
||||
/// </summary>
|
||||
public LayoutOptions VerticalOptions
|
||||
{
|
||||
get => (LayoutOptions)GetValue(VerticalOptionsProperty);
|
||||
set => SetValue(VerticalOptionsProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name of this view (used for template child lookup).
|
||||
/// </summary>
|
||||
public string Name
|
||||
{
|
||||
get => (string)GetValue(NameProperty);
|
||||
set => SetValue(NameProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether this view currently has keyboard focus.
|
||||
@@ -183,6 +422,34 @@ public abstract class SkiaView : IDisposable
|
||||
internal set => _parent = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bounds of this view in screen coordinates (accounting for scroll offsets).
|
||||
/// </summary>
|
||||
public SKRect ScreenBounds
|
||||
{
|
||||
get
|
||||
{
|
||||
var bounds = Bounds;
|
||||
var parent = _parent;
|
||||
|
||||
// Walk up the tree and adjust for scroll offsets
|
||||
while (parent != null)
|
||||
{
|
||||
if (parent is SkiaScrollView scrollView)
|
||||
{
|
||||
bounds = new SKRect(
|
||||
bounds.Left - scrollView.ScrollX,
|
||||
bounds.Top - scrollView.ScrollY,
|
||||
bounds.Right - scrollView.ScrollX,
|
||||
bounds.Bottom - scrollView.ScrollY);
|
||||
}
|
||||
parent = parent.Parent;
|
||||
}
|
||||
|
||||
return bounds;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the desired size calculated during measure.
|
||||
/// </summary>
|
||||
@@ -198,6 +465,36 @@ public abstract class SkiaView : IDisposable
|
||||
/// </summary>
|
||||
public event EventHandler? Invalidated;
|
||||
|
||||
/// <summary>
|
||||
/// Called when visibility changes.
|
||||
/// </summary>
|
||||
protected virtual void OnVisibilityChanged()
|
||||
{
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when enabled state changes.
|
||||
/// </summary>
|
||||
protected virtual void OnEnabledChanged()
|
||||
{
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when binding context changes. Propagates to children.
|
||||
/// </summary>
|
||||
protected override void OnBindingContextChanged()
|
||||
{
|
||||
base.OnBindingContextChanged();
|
||||
|
||||
// Propagate binding context to children
|
||||
foreach (var child in _children)
|
||||
{
|
||||
SetInheritedBindingContext(child, BindingContext);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a child view.
|
||||
/// </summary>
|
||||
@@ -208,6 +505,13 @@ public abstract class SkiaView : IDisposable
|
||||
|
||||
child._parent = this;
|
||||
_children.Add(child);
|
||||
|
||||
// Propagate binding context to new child
|
||||
if (BindingContext != null)
|
||||
{
|
||||
SetInheritedBindingContext(child, BindingContext);
|
||||
}
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
@@ -234,6 +538,13 @@ public abstract class SkiaView : IDisposable
|
||||
|
||||
child._parent = this;
|
||||
_children.Insert(index, child);
|
||||
|
||||
// Propagate binding context to new child
|
||||
if (BindingContext != null)
|
||||
{
|
||||
SetInheritedBindingContext(child, BindingContext);
|
||||
}
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
@@ -275,7 +586,9 @@ public abstract class SkiaView : IDisposable
|
||||
public void Draw(SKCanvas canvas)
|
||||
{
|
||||
if (!IsVisible || Opacity <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.Save();
|
||||
|
||||
@@ -338,8 +651,8 @@ public abstract class SkiaView : IDisposable
|
||||
/// </summary>
|
||||
protected virtual SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
var width = RequestedWidth >= 0 ? (float)RequestedWidth : 0;
|
||||
var height = RequestedHeight >= 0 ? (float)RequestedHeight : 0;
|
||||
var width = WidthRequest >= 0 ? (float)WidthRequest : 0;
|
||||
var height = HeightRequest >= 0 ? (float)HeightRequest : 0;
|
||||
return new SKSize(width, height);
|
||||
}
|
||||
|
||||
@@ -369,6 +682,7 @@ public abstract class SkiaView : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Performs hit testing to find the view at the given coordinates.
|
||||
/// Coordinates are in absolute window space, matching how Bounds are stored.
|
||||
/// </summary>
|
||||
public virtual SkiaView? HitTest(float x, float y)
|
||||
{
|
||||
@@ -379,11 +693,10 @@ public abstract class SkiaView : IDisposable
|
||||
return null;
|
||||
|
||||
// Check children in reverse order (top-most first)
|
||||
var localX = x - Bounds.Left;
|
||||
var localY = y - Bounds.Top;
|
||||
// Coordinates stay in absolute space since children have absolute Bounds
|
||||
for (int i = _children.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var hit = _children[i].HitTest(localX, localY);
|
||||
var hit = _children[i].HitTest(x, y);
|
||||
if (hit != null)
|
||||
return hit;
|
||||
}
|
||||
|
||||
216
Views/SkiaVisualStateManager.cs
Normal file
216
Views/SkiaVisualStateManager.cs
Normal file
@@ -0,0 +1,216 @@
|
||||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Visual State Manager for Skia-rendered controls.
|
||||
/// Provides state-based styling through XAML VisualStateGroups.
|
||||
/// </summary>
|
||||
public static class SkiaVisualStateManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Common visual state names.
|
||||
/// </summary>
|
||||
public static class CommonStates
|
||||
{
|
||||
public const string Normal = "Normal";
|
||||
public const string Disabled = "Disabled";
|
||||
public const string Focused = "Focused";
|
||||
public const string PointerOver = "PointerOver";
|
||||
public const string Pressed = "Pressed";
|
||||
public const string Selected = "Selected";
|
||||
public const string Checked = "Checked";
|
||||
public const string Unchecked = "Unchecked";
|
||||
public const string On = "On";
|
||||
public const string Off = "Off";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attached property for VisualStateGroups.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty VisualStateGroupsProperty =
|
||||
BindableProperty.CreateAttached(
|
||||
"VisualStateGroups",
|
||||
typeof(SkiaVisualStateGroupList),
|
||||
typeof(SkiaVisualStateManager),
|
||||
null,
|
||||
propertyChanged: OnVisualStateGroupsChanged);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the visual state groups for the specified view.
|
||||
/// </summary>
|
||||
public static SkiaVisualStateGroupList? GetVisualStateGroups(SkiaView view)
|
||||
{
|
||||
return (SkiaVisualStateGroupList?)view.GetValue(VisualStateGroupsProperty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the visual state groups for the specified view.
|
||||
/// </summary>
|
||||
public static void SetVisualStateGroups(SkiaView view, SkiaVisualStateGroupList? value)
|
||||
{
|
||||
view.SetValue(VisualStateGroupsProperty, value);
|
||||
}
|
||||
|
||||
private static void OnVisualStateGroupsChanged(BindableObject bindable, object? oldValue, object? newValue)
|
||||
{
|
||||
if (bindable is SkiaView view && newValue is SkiaVisualStateGroupList groups)
|
||||
{
|
||||
// Initialize to default state
|
||||
GoToState(view, CommonStates.Normal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transitions the view to the specified visual state.
|
||||
/// </summary>
|
||||
/// <param name="view">The view to transition.</param>
|
||||
/// <param name="stateName">The name of the state to transition to.</param>
|
||||
/// <returns>True if the state was found and applied, false otherwise.</returns>
|
||||
public static bool GoToState(SkiaView view, string stateName)
|
||||
{
|
||||
var groups = GetVisualStateGroups(view);
|
||||
if (groups == null || groups.Count == 0)
|
||||
return false;
|
||||
|
||||
bool stateFound = false;
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
// Find the state in this group
|
||||
SkiaVisualState? targetState = null;
|
||||
foreach (var state in group.States)
|
||||
{
|
||||
if (state.Name == stateName)
|
||||
{
|
||||
targetState = state;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetState != null)
|
||||
{
|
||||
// Unapply current state if different
|
||||
if (group.CurrentState != null && group.CurrentState != targetState)
|
||||
{
|
||||
UnapplyState(view, group.CurrentState);
|
||||
}
|
||||
|
||||
// Apply new state
|
||||
ApplyState(view, targetState);
|
||||
group.CurrentState = targetState;
|
||||
stateFound = true;
|
||||
}
|
||||
}
|
||||
|
||||
return stateFound;
|
||||
}
|
||||
|
||||
private static void ApplyState(SkiaView view, SkiaVisualState state)
|
||||
{
|
||||
foreach (var setter in state.Setters)
|
||||
{
|
||||
setter.Apply(view);
|
||||
}
|
||||
}
|
||||
|
||||
private static void UnapplyState(SkiaView view, SkiaVisualState state)
|
||||
{
|
||||
foreach (var setter in state.Setters)
|
||||
{
|
||||
setter.Unapply(view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A list of visual state groups.
|
||||
/// </summary>
|
||||
public class SkiaVisualStateGroupList : List<SkiaVisualStateGroup>
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A group of mutually exclusive visual states.
|
||||
/// </summary>
|
||||
public class SkiaVisualStateGroup
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the name of this group.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of states in this group.
|
||||
/// </summary>
|
||||
public List<SkiaVisualState> States { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the currently active state.
|
||||
/// </summary>
|
||||
public SkiaVisualState? CurrentState { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single visual state with its setters.
|
||||
/// </summary>
|
||||
public class SkiaVisualState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the name of this state.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of setters for this state.
|
||||
/// </summary>
|
||||
public List<SkiaVisualStateSetter> Setters { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a property value when a visual state is active.
|
||||
/// </summary>
|
||||
public class SkiaVisualStateSetter
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the property to set.
|
||||
/// </summary>
|
||||
public BindableProperty? Property { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the value to set.
|
||||
/// </summary>
|
||||
public object? Value { get; set; }
|
||||
|
||||
// Store original value for unapply
|
||||
private object? _originalValue;
|
||||
private bool _hasOriginalValue;
|
||||
|
||||
/// <summary>
|
||||
/// Applies this setter to the target view.
|
||||
/// </summary>
|
||||
public void Apply(SkiaView view)
|
||||
{
|
||||
if (Property == null) return;
|
||||
|
||||
// Store original value if not already stored
|
||||
if (!_hasOriginalValue)
|
||||
{
|
||||
_originalValue = view.GetValue(Property);
|
||||
_hasOriginalValue = true;
|
||||
}
|
||||
|
||||
view.SetValue(Property, Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unapplies this setter, restoring the original value.
|
||||
/// </summary>
|
||||
public void Unapply(SkiaView view)
|
||||
{
|
||||
if (Property == null || !_hasOriginalValue) return;
|
||||
|
||||
view.SetValue(Property, _originalValue);
|
||||
}
|
||||
}
|
||||
695
Views/SkiaWebView.cs
Normal file
695
Views/SkiaWebView.cs
Normal file
@@ -0,0 +1,695 @@
|
||||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// WebView implementation using WebKitGTK for Linux.
|
||||
/// Renders web content in a native GTK window and composites to Skia.
|
||||
/// </summary>
|
||||
public class SkiaWebView : SkiaView
|
||||
{
|
||||
#region Native Interop - GTK
|
||||
|
||||
private const string LibGtk4 = "libgtk-4.so.1";
|
||||
private const string LibGtk3 = "libgtk-3.so.0";
|
||||
private const string LibWebKit2Gtk4 = "libwebkitgtk-6.0.so.4";
|
||||
private const string LibWebKit2Gtk3 = "libwebkit2gtk-4.1.so.0";
|
||||
private const string LibGObject = "libgobject-2.0.so.0";
|
||||
private const string LibGLib = "libglib-2.0.so.0";
|
||||
|
||||
private static bool _useGtk4;
|
||||
private static bool _gtkInitialized;
|
||||
private static string _webkitLib = LibWebKit2Gtk3;
|
||||
|
||||
// GTK functions
|
||||
[DllImport(LibGtk4, EntryPoint = "gtk_init")]
|
||||
private static extern void gtk4_init();
|
||||
|
||||
[DllImport(LibGtk3, EntryPoint = "gtk_init_check")]
|
||||
private static extern bool gtk3_init_check(ref int argc, ref IntPtr argv);
|
||||
|
||||
[DllImport(LibGtk4, EntryPoint = "gtk_window_new")]
|
||||
private static extern IntPtr gtk4_window_new();
|
||||
|
||||
[DllImport(LibGtk3, EntryPoint = "gtk_window_new")]
|
||||
private static extern IntPtr gtk3_window_new(int type);
|
||||
|
||||
[DllImport(LibGtk4, EntryPoint = "gtk_window_set_default_size")]
|
||||
private static extern void gtk4_window_set_default_size(IntPtr window, int width, int height);
|
||||
|
||||
[DllImport(LibGtk3, EntryPoint = "gtk_window_set_default_size")]
|
||||
private static extern void gtk3_window_set_default_size(IntPtr window, int width, int height);
|
||||
|
||||
[DllImport(LibGtk4, EntryPoint = "gtk_window_set_child")]
|
||||
private static extern void gtk4_window_set_child(IntPtr window, IntPtr child);
|
||||
|
||||
[DllImport(LibGtk3, EntryPoint = "gtk_container_add")]
|
||||
private static extern void gtk3_container_add(IntPtr container, IntPtr widget);
|
||||
|
||||
[DllImport(LibGtk4, EntryPoint = "gtk_widget_show")]
|
||||
private static extern void gtk4_widget_show(IntPtr widget);
|
||||
|
||||
[DllImport(LibGtk3, EntryPoint = "gtk_widget_show_all")]
|
||||
private static extern void gtk3_widget_show_all(IntPtr widget);
|
||||
|
||||
[DllImport(LibGtk4, EntryPoint = "gtk_widget_hide")]
|
||||
private static extern void gtk4_widget_hide(IntPtr widget);
|
||||
|
||||
[DllImport(LibGtk3, EntryPoint = "gtk_widget_hide")]
|
||||
private static extern void gtk3_widget_hide(IntPtr widget);
|
||||
|
||||
[DllImport(LibGtk4, EntryPoint = "gtk_widget_get_width")]
|
||||
private static extern int gtk4_widget_get_width(IntPtr widget);
|
||||
|
||||
[DllImport(LibGtk4, EntryPoint = "gtk_widget_get_height")]
|
||||
private static extern int gtk4_widget_get_height(IntPtr widget);
|
||||
|
||||
// GObject
|
||||
[DllImport(LibGObject, EntryPoint = "g_object_unref")]
|
||||
private static extern void g_object_unref(IntPtr obj);
|
||||
|
||||
[DllImport(LibGObject, EntryPoint = "g_signal_connect_data")]
|
||||
private static extern ulong g_signal_connect_data(IntPtr instance,
|
||||
[MarshalAs(UnmanagedType.LPStr)] string signal,
|
||||
IntPtr handler, IntPtr data, IntPtr destroyData, int flags);
|
||||
|
||||
// GLib main loop (for event processing)
|
||||
[DllImport(LibGLib, EntryPoint = "g_main_context_iteration")]
|
||||
private static extern bool g_main_context_iteration(IntPtr context, bool mayBlock);
|
||||
|
||||
#endregion
|
||||
|
||||
#region WebKit Functions
|
||||
|
||||
// We'll load these dynamically based on available version
|
||||
private delegate IntPtr WebKitWebViewNewDelegate();
|
||||
private delegate void WebKitWebViewLoadUriDelegate(IntPtr webView, [MarshalAs(UnmanagedType.LPStr)] string uri);
|
||||
private delegate void WebKitWebViewLoadHtmlDelegate(IntPtr webView, [MarshalAs(UnmanagedType.LPStr)] string html, [MarshalAs(UnmanagedType.LPStr)] string? baseUri);
|
||||
private delegate IntPtr WebKitWebViewGetUriDelegate(IntPtr webView);
|
||||
private delegate IntPtr WebKitWebViewGetTitleDelegate(IntPtr webView);
|
||||
private delegate void WebKitWebViewGoBackDelegate(IntPtr webView);
|
||||
private delegate void WebKitWebViewGoForwardDelegate(IntPtr webView);
|
||||
private delegate bool WebKitWebViewCanGoBackDelegate(IntPtr webView);
|
||||
private delegate bool WebKitWebViewCanGoForwardDelegate(IntPtr webView);
|
||||
private delegate void WebKitWebViewReloadDelegate(IntPtr webView);
|
||||
private delegate void WebKitWebViewStopLoadingDelegate(IntPtr webView);
|
||||
private delegate double WebKitWebViewGetEstimatedLoadProgressDelegate(IntPtr webView);
|
||||
private delegate IntPtr WebKitWebViewGetSettingsDelegate(IntPtr webView);
|
||||
private delegate void WebKitSettingsSetEnableJavascriptDelegate(IntPtr settings, bool enabled);
|
||||
|
||||
private static WebKitWebViewNewDelegate? _webkitWebViewNew;
|
||||
private static WebKitWebViewLoadUriDelegate? _webkitLoadUri;
|
||||
private static WebKitWebViewLoadHtmlDelegate? _webkitLoadHtml;
|
||||
private static WebKitWebViewGetUriDelegate? _webkitGetUri;
|
||||
private static WebKitWebViewGetTitleDelegate? _webkitGetTitle;
|
||||
private static WebKitWebViewGoBackDelegate? _webkitGoBack;
|
||||
private static WebKitWebViewGoForwardDelegate? _webkitGoForward;
|
||||
private static WebKitWebViewCanGoBackDelegate? _webkitCanGoBack;
|
||||
private static WebKitWebViewCanGoForwardDelegate? _webkitCanGoForward;
|
||||
private static WebKitWebViewReloadDelegate? _webkitReload;
|
||||
private static WebKitWebViewStopLoadingDelegate? _webkitStopLoading;
|
||||
private static WebKitWebViewGetEstimatedLoadProgressDelegate? _webkitGetProgress;
|
||||
private static WebKitWebViewGetSettingsDelegate? _webkitGetSettings;
|
||||
private static WebKitSettingsSetEnableJavascriptDelegate? _webkitSetJavascript;
|
||||
|
||||
[DllImport("libdl.so.2")]
|
||||
private static extern IntPtr dlopen([MarshalAs(UnmanagedType.LPStr)] string? filename, int flags);
|
||||
|
||||
[DllImport("libdl.so.2")]
|
||||
private static extern IntPtr dlsym(IntPtr handle, [MarshalAs(UnmanagedType.LPStr)] string symbol);
|
||||
|
||||
[DllImport("libdl.so.2")]
|
||||
private static extern IntPtr dlerror();
|
||||
|
||||
private const int RTLD_NOW = 2;
|
||||
private const int RTLD_GLOBAL = 0x100;
|
||||
|
||||
private static IntPtr _webkitHandle;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
|
||||
private IntPtr _gtkWindow;
|
||||
private IntPtr _webView;
|
||||
private string _source = "";
|
||||
private string _html = "";
|
||||
private bool _isInitialized;
|
||||
private bool _javascriptEnabled = true;
|
||||
private double _loadProgress;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the URL to navigate to.
|
||||
/// </summary>
|
||||
public string Source
|
||||
{
|
||||
get => _source;
|
||||
set
|
||||
{
|
||||
if (_source != value)
|
||||
{
|
||||
_source = value;
|
||||
if (_isInitialized && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
LoadUrl(value);
|
||||
}
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the HTML content to display.
|
||||
/// </summary>
|
||||
public string Html
|
||||
{
|
||||
get => _html;
|
||||
set
|
||||
{
|
||||
if (_html != value)
|
||||
{
|
||||
_html = value;
|
||||
if (_isInitialized && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
LoadHtml(value);
|
||||
}
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the WebView can navigate back.
|
||||
/// </summary>
|
||||
public bool CanGoBack => _webView != IntPtr.Zero && _webkitCanGoBack?.Invoke(_webView) == true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the WebView can navigate forward.
|
||||
/// </summary>
|
||||
public bool CanGoForward => _webView != IntPtr.Zero && _webkitCanGoForward?.Invoke(_webView) == true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current URL.
|
||||
/// </summary>
|
||||
public string? CurrentUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_webView == IntPtr.Zero || _webkitGetUri == null) return null;
|
||||
var ptr = _webkitGetUri(_webView);
|
||||
return ptr != IntPtr.Zero ? Marshal.PtrToStringAnsi(ptr) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current page title.
|
||||
/// </summary>
|
||||
public string? Title
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_webView == IntPtr.Zero || _webkitGetTitle == null) return null;
|
||||
var ptr = _webkitGetTitle(_webView);
|
||||
return ptr != IntPtr.Zero ? Marshal.PtrToStringAnsi(ptr) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether JavaScript is enabled.
|
||||
/// </summary>
|
||||
public bool JavaScriptEnabled
|
||||
{
|
||||
get => _javascriptEnabled;
|
||||
set
|
||||
{
|
||||
_javascriptEnabled = value;
|
||||
UpdateJavaScriptSetting();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the load progress (0.0 to 1.0).
|
||||
/// </summary>
|
||||
public double LoadProgress => _loadProgress;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether WebKit is available on this system.
|
||||
/// </summary>
|
||||
public static bool IsSupported => InitializeWebKit();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Events
|
||||
|
||||
public event EventHandler<WebNavigatingEventArgs>? Navigating;
|
||||
public event EventHandler<WebNavigatedEventArgs>? Navigated;
|
||||
public event EventHandler<string>? TitleChanged;
|
||||
public event EventHandler<double>? LoadProgressChanged;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
|
||||
public SkiaWebView()
|
||||
{
|
||||
RequestedWidth = 400;
|
||||
RequestedHeight = 300;
|
||||
BackgroundColor = SKColors.White;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Initialization
|
||||
|
||||
private static bool InitializeWebKit()
|
||||
{
|
||||
if (_webkitHandle != IntPtr.Zero) return true;
|
||||
|
||||
// Try WebKitGTK 6.0 (GTK4) first
|
||||
_webkitHandle = dlopen(LibWebKit2Gtk4, RTLD_NOW | RTLD_GLOBAL);
|
||||
if (_webkitHandle != IntPtr.Zero)
|
||||
{
|
||||
_useGtk4 = true;
|
||||
_webkitLib = LibWebKit2Gtk4;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to WebKitGTK 4.1 (GTK3)
|
||||
_webkitHandle = dlopen(LibWebKit2Gtk3, RTLD_NOW | RTLD_GLOBAL);
|
||||
if (_webkitHandle != IntPtr.Zero)
|
||||
{
|
||||
_useGtk4 = false;
|
||||
_webkitLib = LibWebKit2Gtk3;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try older WebKitGTK 4.0
|
||||
_webkitHandle = dlopen("libwebkit2gtk-4.0.so.37", RTLD_NOW | RTLD_GLOBAL);
|
||||
if (_webkitHandle != IntPtr.Zero)
|
||||
{
|
||||
_useGtk4 = false;
|
||||
_webkitLib = "libwebkit2gtk-4.0.so.37";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_webkitHandle == IntPtr.Zero)
|
||||
{
|
||||
Console.WriteLine("[WebView] WebKitGTK not found. Install with: sudo apt install libwebkit2gtk-4.1-0");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load function pointers
|
||||
_webkitWebViewNew = LoadFunction<WebKitWebViewNewDelegate>("webkit_web_view_new");
|
||||
_webkitLoadUri = LoadFunction<WebKitWebViewLoadUriDelegate>("webkit_web_view_load_uri");
|
||||
_webkitLoadHtml = LoadFunction<WebKitWebViewLoadHtmlDelegate>("webkit_web_view_load_html");
|
||||
_webkitGetUri = LoadFunction<WebKitWebViewGetUriDelegate>("webkit_web_view_get_uri");
|
||||
_webkitGetTitle = LoadFunction<WebKitWebViewGetTitleDelegate>("webkit_web_view_get_title");
|
||||
_webkitGoBack = LoadFunction<WebKitWebViewGoBackDelegate>("webkit_web_view_go_back");
|
||||
_webkitGoForward = LoadFunction<WebKitWebViewGoForwardDelegate>("webkit_web_view_go_forward");
|
||||
_webkitCanGoBack = LoadFunction<WebKitWebViewCanGoBackDelegate>("webkit_web_view_can_go_back");
|
||||
_webkitCanGoForward = LoadFunction<WebKitWebViewCanGoForwardDelegate>("webkit_web_view_can_go_forward");
|
||||
_webkitReload = LoadFunction<WebKitWebViewReloadDelegate>("webkit_web_view_reload");
|
||||
_webkitStopLoading = LoadFunction<WebKitWebViewStopLoadingDelegate>("webkit_web_view_stop_loading");
|
||||
_webkitGetProgress = LoadFunction<WebKitWebViewGetEstimatedLoadProgressDelegate>("webkit_web_view_get_estimated_load_progress");
|
||||
_webkitGetSettings = LoadFunction<WebKitWebViewGetSettingsDelegate>("webkit_web_view_get_settings");
|
||||
_webkitSetJavascript = LoadFunction<WebKitSettingsSetEnableJavascriptDelegate>("webkit_settings_set_enable_javascript");
|
||||
|
||||
Console.WriteLine($"[WebView] Using {_webkitLib}");
|
||||
return _webkitWebViewNew != null;
|
||||
}
|
||||
|
||||
private static T? LoadFunction<T>(string name) where T : Delegate
|
||||
{
|
||||
var ptr = dlsym(_webkitHandle, name);
|
||||
if (ptr == IntPtr.Zero) return null;
|
||||
return Marshal.GetDelegateForFunctionPointer<T>(ptr);
|
||||
}
|
||||
|
||||
private void Initialize()
|
||||
{
|
||||
if (_isInitialized) return;
|
||||
if (!InitializeWebKit()) return;
|
||||
|
||||
try
|
||||
{
|
||||
// Initialize GTK if needed
|
||||
if (!_gtkInitialized)
|
||||
{
|
||||
if (_useGtk4)
|
||||
{
|
||||
gtk4_init();
|
||||
}
|
||||
else
|
||||
{
|
||||
int argc = 0;
|
||||
IntPtr argv = IntPtr.Zero;
|
||||
gtk3_init_check(ref argc, ref argv);
|
||||
}
|
||||
_gtkInitialized = true;
|
||||
}
|
||||
|
||||
// Create WebKit view
|
||||
_webView = _webkitWebViewNew!();
|
||||
if (_webView == IntPtr.Zero)
|
||||
{
|
||||
Console.WriteLine("[WebView] Failed to create WebKit view");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create GTK window to host the WebView
|
||||
if (_useGtk4)
|
||||
{
|
||||
_gtkWindow = gtk4_window_new();
|
||||
gtk4_window_set_default_size(_gtkWindow, (int)RequestedWidth, (int)RequestedHeight);
|
||||
gtk4_window_set_child(_gtkWindow, _webView);
|
||||
}
|
||||
else
|
||||
{
|
||||
_gtkWindow = gtk3_window_new(0); // GTK_WINDOW_TOPLEVEL
|
||||
gtk3_window_set_default_size(_gtkWindow, (int)RequestedWidth, (int)RequestedHeight);
|
||||
gtk3_container_add(_gtkWindow, _webView);
|
||||
}
|
||||
|
||||
UpdateJavaScriptSetting();
|
||||
_isInitialized = true;
|
||||
|
||||
// Load initial content
|
||||
if (!string.IsNullOrEmpty(_source))
|
||||
{
|
||||
LoadUrl(_source);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_html))
|
||||
{
|
||||
LoadHtml(_html);
|
||||
}
|
||||
|
||||
Console.WriteLine("[WebView] Initialized successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[WebView] Initialization failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Navigation
|
||||
|
||||
public void LoadUrl(string url)
|
||||
{
|
||||
if (!_isInitialized) Initialize();
|
||||
if (_webView == IntPtr.Zero || _webkitLoadUri == null) return;
|
||||
|
||||
Navigating?.Invoke(this, new WebNavigatingEventArgs(url));
|
||||
_webkitLoadUri(_webView, url);
|
||||
}
|
||||
|
||||
public void LoadHtml(string html, string? baseUrl = null)
|
||||
{
|
||||
if (!_isInitialized) Initialize();
|
||||
if (_webView == IntPtr.Zero || _webkitLoadHtml == null) return;
|
||||
|
||||
_webkitLoadHtml(_webView, html, baseUrl);
|
||||
}
|
||||
|
||||
public void GoBack()
|
||||
{
|
||||
if (_webView != IntPtr.Zero && CanGoBack)
|
||||
{
|
||||
_webkitGoBack?.Invoke(_webView);
|
||||
}
|
||||
}
|
||||
|
||||
public void GoForward()
|
||||
{
|
||||
if (_webView != IntPtr.Zero && CanGoForward)
|
||||
{
|
||||
_webkitGoForward?.Invoke(_webView);
|
||||
}
|
||||
}
|
||||
|
||||
public void Reload()
|
||||
{
|
||||
if (_webView != IntPtr.Zero)
|
||||
{
|
||||
_webkitReload?.Invoke(_webView);
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_webView != IntPtr.Zero)
|
||||
{
|
||||
_webkitStopLoading?.Invoke(_webView);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateJavaScriptSetting()
|
||||
{
|
||||
if (_webView == IntPtr.Zero || _webkitGetSettings == null || _webkitSetJavascript == null) return;
|
||||
|
||||
var settings = _webkitGetSettings(_webView);
|
||||
if (settings != IntPtr.Zero)
|
||||
{
|
||||
_webkitSetJavascript(settings, _javascriptEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Event Processing
|
||||
|
||||
/// <summary>
|
||||
/// Process pending GTK events. Call this from your main loop.
|
||||
/// </summary>
|
||||
public void ProcessEvents()
|
||||
{
|
||||
if (!_isInitialized) return;
|
||||
|
||||
// Process GTK events
|
||||
g_main_context_iteration(IntPtr.Zero, false);
|
||||
|
||||
// Update progress
|
||||
if (_webView != IntPtr.Zero && _webkitGetProgress != null)
|
||||
{
|
||||
var progress = _webkitGetProgress(_webView);
|
||||
if (Math.Abs(progress - _loadProgress) > 0.01)
|
||||
{
|
||||
_loadProgress = progress;
|
||||
LoadProgressChanged?.Invoke(this, progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Show the native WebView window (for testing/debugging).
|
||||
/// </summary>
|
||||
public void ShowNativeWindow()
|
||||
{
|
||||
if (!_isInitialized) Initialize();
|
||||
if (_gtkWindow == IntPtr.Zero) return;
|
||||
|
||||
if (_useGtk4)
|
||||
{
|
||||
gtk4_widget_show(_gtkWindow);
|
||||
}
|
||||
else
|
||||
{
|
||||
gtk3_widget_show_all(_gtkWindow);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hide the native WebView window.
|
||||
/// </summary>
|
||||
public void HideNativeWindow()
|
||||
{
|
||||
if (_gtkWindow == IntPtr.Zero) return;
|
||||
|
||||
if (_useGtk4)
|
||||
{
|
||||
gtk4_widget_hide(_gtkWindow);
|
||||
}
|
||||
else
|
||||
{
|
||||
gtk3_widget_hide(_gtkWindow);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rendering
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
base.OnDraw(canvas, bounds);
|
||||
|
||||
// Draw placeholder/loading state
|
||||
using var bgPaint = new SKPaint { Color = BackgroundColor, Style = SKPaintStyle.Fill };
|
||||
canvas.DrawRect(bounds, bgPaint);
|
||||
|
||||
// Draw border
|
||||
using var borderPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(200, 200, 200),
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 1
|
||||
};
|
||||
canvas.DrawRect(bounds, borderPaint);
|
||||
|
||||
// Draw web icon and status
|
||||
var centerX = bounds.MidX;
|
||||
var centerY = bounds.MidY;
|
||||
|
||||
// Globe icon
|
||||
using var iconPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(100, 100, 100),
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 2,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawCircle(centerX, centerY - 20, 25, iconPaint);
|
||||
canvas.DrawLine(centerX - 25, centerY - 20, centerX + 25, centerY - 20, iconPaint);
|
||||
canvas.DrawArc(new SKRect(centerX - 15, centerY - 45, centerX + 15, centerY + 5), 0, 180, false, iconPaint);
|
||||
|
||||
// Status text
|
||||
using var textPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(80, 80, 80),
|
||||
IsAntialias = true,
|
||||
TextSize = 14
|
||||
};
|
||||
|
||||
string statusText;
|
||||
if (!IsSupported)
|
||||
{
|
||||
statusText = "WebKitGTK not installed";
|
||||
}
|
||||
else if (_isInitialized)
|
||||
{
|
||||
statusText = string.IsNullOrEmpty(_source) ? "No URL loaded" : $"Loading: {_source}";
|
||||
if (_loadProgress > 0 && _loadProgress < 1)
|
||||
{
|
||||
statusText = $"Loading: {(int)(_loadProgress * 100)}%";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
statusText = "WebView (click to open)";
|
||||
}
|
||||
|
||||
var textWidth = textPaint.MeasureText(statusText);
|
||||
canvas.DrawText(statusText, centerX - textWidth / 2, centerY + 30, textPaint);
|
||||
|
||||
// Draw install hint if not supported
|
||||
if (!IsSupported)
|
||||
{
|
||||
using var hintPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(120, 120, 120),
|
||||
IsAntialias = true,
|
||||
TextSize = 11
|
||||
};
|
||||
var hint = "Install: sudo apt install libwebkit2gtk-4.1-0";
|
||||
var hintWidth = hintPaint.MeasureText(hint);
|
||||
canvas.DrawText(hint, centerX - hintWidth / 2, centerY + 50, hintPaint);
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
if (_loadProgress > 0 && _loadProgress < 1)
|
||||
{
|
||||
var progressRect = new SKRect(bounds.Left + 20, bounds.Bottom - 30, bounds.Right - 20, bounds.Bottom - 20);
|
||||
using var progressBgPaint = new SKPaint { Color = new SKColor(230, 230, 230), Style = SKPaintStyle.Fill };
|
||||
canvas.DrawRoundRect(new SKRoundRect(progressRect, 5), progressBgPaint);
|
||||
|
||||
var filledWidth = progressRect.Width * (float)_loadProgress;
|
||||
var filledRect = new SKRect(progressRect.Left, progressRect.Top, progressRect.Left + filledWidth, progressRect.Bottom);
|
||||
using var progressPaint = new SKPaint { Color = new SKColor(33, 150, 243), Style = SKPaintStyle.Fill };
|
||||
canvas.DrawRoundRect(new SKRoundRect(filledRect, 5), progressPaint);
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
base.OnPointerPressed(e);
|
||||
|
||||
if (!_isInitialized && IsSupported)
|
||||
{
|
||||
Initialize();
|
||||
ShowNativeWindow();
|
||||
}
|
||||
else if (_isInitialized)
|
||||
{
|
||||
ShowNativeWindow();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cleanup
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
if (_gtkWindow != IntPtr.Zero)
|
||||
{
|
||||
if (_useGtk4)
|
||||
{
|
||||
gtk4_widget_hide(_gtkWindow);
|
||||
}
|
||||
else
|
||||
{
|
||||
gtk3_widget_hide(_gtkWindow);
|
||||
}
|
||||
g_object_unref(_gtkWindow);
|
||||
_gtkWindow = IntPtr.Zero;
|
||||
}
|
||||
_webView = IntPtr.Zero;
|
||||
_isInitialized = false;
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Event Args
|
||||
|
||||
public class WebNavigatingEventArgs : EventArgs
|
||||
{
|
||||
public string Url { get; }
|
||||
public bool Cancel { get; set; }
|
||||
|
||||
public WebNavigatingEventArgs(string url)
|
||||
{
|
||||
Url = url;
|
||||
}
|
||||
}
|
||||
|
||||
public class WebNavigatedEventArgs : EventArgs
|
||||
{
|
||||
public string Url { get; }
|
||||
public bool Success { get; }
|
||||
public string? Error { get; }
|
||||
|
||||
public WebNavigatedEventArgs(string url, bool success, string? error = null)
|
||||
{
|
||||
Url = url;
|
||||
Success = success;
|
||||
Error = error;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
Reference in New Issue
Block a user