// 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;
///
/// Base class for all Skia-rendered views on Linux.
/// Inherits from BindableObject to enable XAML styling, data binding, and Visual State Manager.
///
public abstract class SkiaView : BindableObject, IDisposable
{
// Popup overlay system for dropdowns, calendars, etc.
private static readonly List<(SkiaView Owner, Action Draw)> _popupOverlays = new();
public static void RegisterPopupOverlay(SkiaView owner, Action drawAction)
{
_popupOverlays.RemoveAll(p => p.Owner == owner);
_popupOverlays.Add((owner, drawAction));
}
public static void UnregisterPopupOverlay(SkiaView owner)
{
_popupOverlays.RemoveAll(p => p.Owner == owner);
}
public static void DrawPopupOverlays(SKCanvas canvas)
{
// Restore canvas to clean state for overlay drawing
// Save count tells us how many unmatched Saves there are
while (canvas.SaveCount > 1)
{
canvas.Restore();
}
foreach (var (_, draw) in _popupOverlays)
{
canvas.Save();
draw(canvas);
canvas.Restore();
}
}
///
/// Gets the popup owner that should receive pointer events at the given coordinates.
/// This allows popups to receive events even outside their normal bounds.
///
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;
}
///
/// Checks if there are any active popup overlays.
///
public static bool HasActivePopup => _popupOverlays.Count > 0;
///
/// Override this to define the popup area for hit testing.
///
protected virtual bool HitTestPopupArea(float x, float y)
{
// Default: no popup area beyond normal bounds
return Bounds.Contains(x, y);
}
#region BindableProperties
///
/// Bindable property for IsVisible.
///
public static readonly BindableProperty IsVisibleProperty =
BindableProperty.Create(
nameof(IsVisible),
typeof(bool),
typeof(SkiaView),
true,
propertyChanged: (b, o, n) => ((SkiaView)b).OnVisibilityChanged());
///
/// Bindable property for IsEnabled.
///
public static readonly BindableProperty IsEnabledProperty =
BindableProperty.Create(
nameof(IsEnabled),
typeof(bool),
typeof(SkiaView),
true,
propertyChanged: (b, o, n) => ((SkiaView)b).OnEnabledChanged());
///
/// Bindable property for Opacity.
///
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());
///
/// Bindable property for BackgroundColor.
///
public static readonly BindableProperty BackgroundColorProperty =
BindableProperty.Create(
nameof(BackgroundColor),
typeof(SKColor),
typeof(SkiaView),
SKColors.Transparent,
propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate());
///
/// Bindable property for WidthRequest.
///
public static readonly BindableProperty WidthRequestProperty =
BindableProperty.Create(
nameof(WidthRequest),
typeof(double),
typeof(SkiaView),
-1.0,
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
///
/// Bindable property for HeightRequest.
///
public static readonly BindableProperty HeightRequestProperty =
BindableProperty.Create(
nameof(HeightRequest),
typeof(double),
typeof(SkiaView),
-1.0,
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
///
/// Bindable property for MinimumWidthRequest.
///
public static readonly BindableProperty MinimumWidthRequestProperty =
BindableProperty.Create(
nameof(MinimumWidthRequest),
typeof(double),
typeof(SkiaView),
0.0,
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
///
/// Bindable property for MinimumHeightRequest.
///
public static readonly BindableProperty MinimumHeightRequestProperty =
BindableProperty.Create(
nameof(MinimumHeightRequest),
typeof(double),
typeof(SkiaView),
0.0,
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
///
/// Bindable property for IsFocusable.
///
public static readonly BindableProperty IsFocusableProperty =
BindableProperty.Create(
nameof(IsFocusable),
typeof(bool),
typeof(SkiaView),
false);
///
/// Bindable property for Margin.
///
public static readonly BindableProperty MarginProperty =
BindableProperty.Create(
nameof(Margin),
typeof(Thickness),
typeof(SkiaView),
default(Thickness),
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
///
/// Bindable property for HorizontalOptions.
///
public static readonly BindableProperty HorizontalOptionsProperty =
BindableProperty.Create(
nameof(HorizontalOptions),
typeof(LayoutOptions),
typeof(SkiaView),
LayoutOptions.Fill,
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
///
/// Bindable property for VerticalOptions.
///
public static readonly BindableProperty VerticalOptionsProperty =
BindableProperty.Create(
nameof(VerticalOptions),
typeof(LayoutOptions),
typeof(SkiaView),
LayoutOptions.Fill,
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
///
/// Bindable property for Name (used for template child lookup).
///
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 _children = new();
///
/// Gets the absolute bounds of this view in screen coordinates.
///
public SKRect GetAbsoluteBounds()
{
var bounds = Bounds;
var current = Parent;
while (current != null)
{
// Adjust for scroll offset if parent is a ScrollView
if (current is SkiaScrollView scrollView)
{
bounds = new SKRect(
bounds.Left - scrollView.ScrollX,
bounds.Top - scrollView.ScrollY,
bounds.Right - scrollView.ScrollX,
bounds.Bottom - scrollView.ScrollY);
}
current = current.Parent;
}
return bounds;
}
///
/// Gets or sets the bounds of this view in parent coordinates.
///
public SKRect Bounds
{
get => _bounds;
set
{
if (_bounds != value)
{
_bounds = value;
OnBoundsChanged();
}
}
}
///
/// Gets or sets whether this view is visible.
///
public bool IsVisible
{
get => (bool)GetValue(IsVisibleProperty);
set => SetValue(IsVisibleProperty, value);
}
///
/// Gets or sets whether this view is enabled for interaction.
///
public bool IsEnabled
{
get => (bool)GetValue(IsEnabledProperty);
set => SetValue(IsEnabledProperty, value);
}
///
/// Gets or sets the opacity of this view (0.0 to 1.0).
///
public float Opacity
{
get => (float)GetValue(OpacityProperty);
set => SetValue(OpacityProperty, value);
}
///
/// Gets or sets the background color.
///
private SKColor _backgroundColor = SKColors.Transparent;
public SKColor BackgroundColor
{
get => _backgroundColor;
set
{
if (_backgroundColor != value)
{
_backgroundColor = value;
SetValue(BackgroundColorProperty, value); // Keep BindableProperty in sync for bindings
Invalidate();
}
}
}
///
/// Gets or sets the requested width.
///
public double WidthRequest
{
get => (double)GetValue(WidthRequestProperty);
set => SetValue(WidthRequestProperty, value);
}
///
/// Gets or sets the requested height.
///
public double HeightRequest
{
get => (double)GetValue(HeightRequestProperty);
set => SetValue(HeightRequestProperty, value);
}
///
/// Gets or sets the minimum width request.
///
public double MinimumWidthRequest
{
get => (double)GetValue(MinimumWidthRequestProperty);
set => SetValue(MinimumWidthRequestProperty, value);
}
///
/// Gets or sets the minimum height request.
///
public double MinimumHeightRequest
{
get => (double)GetValue(MinimumHeightRequestProperty);
set => SetValue(MinimumHeightRequestProperty, value);
}
///
/// Gets or sets the requested width (backwards compatibility alias).
///
public double RequestedWidth
{
get => WidthRequest;
set => WidthRequest = value;
}
///
/// Gets or sets the requested height (backwards compatibility alias).
///
public double RequestedHeight
{
get => HeightRequest;
set => HeightRequest = value;
}
///
/// Gets or sets whether this view can receive keyboard focus.
///
public bool IsFocusable
{
get => (bool)GetValue(IsFocusableProperty);
set => SetValue(IsFocusableProperty, value);
}
///
/// Gets or sets the margin around this view.
///
public Thickness Margin
{
get => (Thickness)GetValue(MarginProperty);
set => SetValue(MarginProperty, value);
}
///
/// Gets or sets the horizontal layout options.
///
public LayoutOptions HorizontalOptions
{
get => (LayoutOptions)GetValue(HorizontalOptionsProperty);
set => SetValue(HorizontalOptionsProperty, value);
}
///
/// Gets or sets the vertical layout options.
///
public LayoutOptions VerticalOptions
{
get => (LayoutOptions)GetValue(VerticalOptionsProperty);
set => SetValue(VerticalOptionsProperty, value);
}
///
/// Gets or sets the name of this view (used for template child lookup).
///
public string Name
{
get => (string)GetValue(NameProperty);
set => SetValue(NameProperty, value);
}
///
/// Gets or sets whether this view currently has keyboard focus.
///
public bool IsFocused { get; internal set; }
///
/// Gets or sets the parent view.
///
public SkiaView? Parent
{
get => _parent;
internal set => _parent = value;
}
///
/// Gets the bounds of this view in screen coordinates (accounting for scroll offsets).
///
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;
}
}
///
/// Gets the desired size calculated during measure.
///
public SKSize DesiredSize { get; protected set; }
///
/// Gets the child views.
///
public IReadOnlyList Children => _children;
///
/// Event raised when this view needs to be redrawn.
///
public event EventHandler? Invalidated;
///
/// Called when visibility changes.
///
protected virtual void OnVisibilityChanged()
{
Invalidate();
}
///
/// Called when enabled state changes.
///
protected virtual void OnEnabledChanged()
{
Invalidate();
}
///
/// Called when binding context changes. Propagates to children.
///
protected override void OnBindingContextChanged()
{
base.OnBindingContextChanged();
// Propagate binding context to children
foreach (var child in _children)
{
SetInheritedBindingContext(child, BindingContext);
}
}
///
/// Adds a child view.
///
public void AddChild(SkiaView child)
{
if (child._parent != null)
throw new InvalidOperationException("View already has a parent");
child._parent = this;
_children.Add(child);
// Propagate binding context to new child
if (BindingContext != null)
{
SetInheritedBindingContext(child, BindingContext);
}
Invalidate();
}
///
/// Removes a child view.
///
public void RemoveChild(SkiaView child)
{
if (child._parent != this)
return;
child._parent = null;
_children.Remove(child);
Invalidate();
}
///
/// Inserts a child view at the specified index.
///
public void InsertChild(int index, SkiaView child)
{
if (child._parent != null)
throw new InvalidOperationException("View already has a parent");
child._parent = this;
_children.Insert(index, child);
// Propagate binding context to new child
if (BindingContext != null)
{
SetInheritedBindingContext(child, BindingContext);
}
Invalidate();
}
///
/// Removes all child views.
///
public void ClearChildren()
{
foreach (var child in _children)
{
child._parent = null;
}
_children.Clear();
Invalidate();
}
///
/// Requests that this view be redrawn.
///
public void Invalidate()
{
Invalidated?.Invoke(this, EventArgs.Empty);
_parent?.Invalidate();
}
///
/// Invalidates the cached measurement.
///
public void InvalidateMeasure()
{
DesiredSize = SKSize.Empty;
_parent?.InvalidateMeasure();
Invalidate();
}
///
/// Draws this view and its children to the canvas.
///
public void Draw(SKCanvas canvas)
{
if (!IsVisible || Opacity <= 0)
{
return;
}
canvas.Save();
// Apply opacity
if (Opacity < 1.0f)
{
canvas.SaveLayer(new SKPaint { Color = SKColors.White.WithAlpha((byte)(Opacity * 255)) });
}
// Draw background at absolute bounds
if (BackgroundColor != SKColors.Transparent)
{
using var paint = new SKPaint { Color = BackgroundColor };
canvas.DrawRect(Bounds, paint);
}
// Draw content at absolute bounds
OnDraw(canvas, Bounds);
// Draw children - they draw at their own absolute bounds
foreach (var child in _children)
{
child.Draw(canvas);
}
if (Opacity < 1.0f)
{
canvas.Restore();
}
canvas.Restore();
}
///
/// Override to draw custom content.
///
protected virtual void OnDraw(SKCanvas canvas, SKRect bounds)
{
}
///
/// Called when the bounds change.
///
protected virtual void OnBoundsChanged()
{
Invalidate();
}
///
/// Measures the desired size of this view.
///
public SKSize Measure(SKSize availableSize)
{
DesiredSize = MeasureOverride(availableSize);
return DesiredSize;
}
///
/// Override to provide custom measurement.
///
protected virtual SKSize MeasureOverride(SKSize availableSize)
{
var width = WidthRequest >= 0 ? (float)WidthRequest : 0;
var height = HeightRequest >= 0 ? (float)HeightRequest : 0;
return new SKSize(width, height);
}
///
/// Arranges this view within the given bounds.
///
public void Arrange(SKRect bounds)
{
Bounds = ArrangeOverride(bounds);
}
///
/// Override to customize arrangement within the given bounds.
///
protected virtual SKRect ArrangeOverride(SKRect bounds)
{
return bounds;
}
///
/// Performs hit testing to find the view at the given point.
///
public virtual SkiaView? HitTest(SKPoint point)
{
return HitTest(point.X, point.Y);
}
///
/// Performs hit testing to find the view at the given coordinates.
/// Coordinates are in absolute window space, matching how Bounds are stored.
///
public virtual SkiaView? HitTest(float x, float y)
{
if (!IsVisible || !IsEnabled)
return null;
if (!Bounds.Contains(x, y))
return null;
// Check children in reverse order (top-most first)
// Coordinates stay in absolute space since children have absolute Bounds
for (int i = _children.Count - 1; i >= 0; i--)
{
var hit = _children[i].HitTest(x, y);
if (hit != null)
return hit;
}
return this;
}
#region Input Events
public virtual void OnPointerEntered(PointerEventArgs e) { }
public virtual void OnPointerExited(PointerEventArgs e) { }
public virtual void OnPointerMoved(PointerEventArgs e) { }
public virtual void OnPointerPressed(PointerEventArgs e) { }
public virtual void OnPointerReleased(PointerEventArgs e) { }
public virtual void OnScroll(ScrollEventArgs e) { }
public virtual void OnKeyDown(KeyEventArgs e) { }
public virtual void OnKeyUp(KeyEventArgs e) { }
public virtual void OnTextInput(TextInputEventArgs e) { }
public virtual void OnFocusGained()
{
IsFocused = true;
Invalidate();
}
public virtual void OnFocusLost()
{
IsFocused = false;
Invalidate();
}
#endregion
#region IDisposable
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
foreach (var child in _children)
{
child.Dispose();
}
_children.Clear();
}
_disposed = true;
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
#endregion
}
///
/// Event args for pointer events.
///
public class PointerEventArgs : EventArgs
{
public float X { get; }
public float Y { get; }
public PointerButton Button { get; }
public bool Handled { get; set; }
public PointerEventArgs(float x, float y, PointerButton button = PointerButton.None)
{
X = x;
Y = y;
Button = button;
}
}
///
/// Mouse button flags.
///
[Flags]
public enum PointerButton
{
None = 0,
Left = 1,
Middle = 2,
Right = 4,
XButton1 = 8,
XButton2 = 16
}
///
/// Event args for scroll events.
///
public class ScrollEventArgs : EventArgs
{
public float X { get; }
public float Y { get; }
public float DeltaX { get; }
public float DeltaY { get; }
public bool Handled { get; set; }
public ScrollEventArgs(float x, float y, float deltaX, float deltaY)
{
X = x;
Y = y;
DeltaX = deltaX;
DeltaY = deltaY;
}
}
///
/// Event args for keyboard events.
///
public class KeyEventArgs : EventArgs
{
public Key Key { get; }
public KeyModifiers Modifiers { get; }
public bool Handled { get; set; }
public KeyEventArgs(Key key, KeyModifiers modifiers = KeyModifiers.None)
{
Key = key;
Modifiers = modifiers;
}
}
///
/// Event args for text input events.
///
public class TextInputEventArgs : EventArgs
{
public string Text { get; }
public bool Handled { get; set; }
public TextInputEventArgs(string text)
{
Text = text;
}
}
///
/// Keyboard modifier flags.
///
[Flags]
public enum KeyModifiers
{
None = 0,
Shift = 1,
Control = 2,
Alt = 4,
Super = 8,
CapsLock = 16,
NumLock = 32
}