Docs added
This commit is contained in:
689
docs/CUSTOM_CONTROLS.md
Normal file
689
docs/CUSTOM_CONTROLS.md
Normal file
@@ -0,0 +1,689 @@
|
||||
# Creating Custom Controls for OpenMaui Linux
|
||||
|
||||
This guide explains how to create custom controls that integrate with the OpenMaui Linux platform.
|
||||
|
||||
## Overview
|
||||
|
||||
OpenMaui Linux uses a layered architecture:
|
||||
|
||||
1. **MAUI Virtual Views** - Standard .NET MAUI controls (Button, Label, etc.)
|
||||
2. **Handlers** - Bridge between MAUI and platform views
|
||||
3. **Platform Views** - SkiaSharp-rendered controls
|
||||
|
||||
When creating custom controls, you can either:
|
||||
- Create a MAUI control with a custom handler (recommended for reusable controls)
|
||||
- Create a platform-specific SkiaView directly (for Linux-only functionality)
|
||||
|
||||
## Creating a MAUI Control with Handler
|
||||
|
||||
### Step 1: Define the MAUI Control
|
||||
|
||||
Create a standard MAUI control that inherits from `View`:
|
||||
|
||||
```csharp
|
||||
// Controls/RatingControl.cs
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Graphics;
|
||||
|
||||
namespace MyApp.Controls;
|
||||
|
||||
public class RatingControl : View
|
||||
{
|
||||
public static readonly BindableProperty ValueProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Value),
|
||||
typeof(int),
|
||||
typeof(RatingControl),
|
||||
0,
|
||||
BindingMode.TwoWay);
|
||||
|
||||
public static readonly BindableProperty MaxValueProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(MaxValue),
|
||||
typeof(int),
|
||||
typeof(RatingControl),
|
||||
5);
|
||||
|
||||
public static readonly BindableProperty StarColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(StarColor),
|
||||
typeof(Color),
|
||||
typeof(RatingControl),
|
||||
Colors.Gold);
|
||||
|
||||
public static readonly BindableProperty EmptyStarColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(EmptyStarColor),
|
||||
typeof(Color),
|
||||
typeof(RatingControl),
|
||||
Colors.Gray);
|
||||
|
||||
public int Value
|
||||
{
|
||||
get => (int)GetValue(ValueProperty);
|
||||
set => SetValue(ValueProperty, value);
|
||||
}
|
||||
|
||||
public int MaxValue
|
||||
{
|
||||
get => (int)GetValue(MaxValueProperty);
|
||||
set => SetValue(MaxValueProperty, value);
|
||||
}
|
||||
|
||||
public Color StarColor
|
||||
{
|
||||
get => (Color)GetValue(StarColorProperty);
|
||||
set => SetValue(StarColorProperty, value);
|
||||
}
|
||||
|
||||
public Color EmptyStarColor
|
||||
{
|
||||
get => (Color)GetValue(EmptyStarColorProperty);
|
||||
set => SetValue(EmptyStarColorProperty, value);
|
||||
}
|
||||
|
||||
public event EventHandler<int>? ValueChanged;
|
||||
|
||||
internal void SendValueChanged(int value)
|
||||
{
|
||||
ValueChanged?.Invoke(this, value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Create the Platform View
|
||||
|
||||
Create a SkiaView that renders your control:
|
||||
|
||||
```csharp
|
||||
// Platforms/Linux/SkiaRatingControl.cs
|
||||
using Microsoft.Maui.Graphics;
|
||||
using Microsoft.Maui.Platform;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace MyApp.Platforms.Linux;
|
||||
|
||||
public class SkiaRatingControl : SkiaView
|
||||
{
|
||||
private int _value;
|
||||
private int _maxValue = 5;
|
||||
private Color _starColor = Colors.Gold;
|
||||
private Color _emptyStarColor = Colors.Gray;
|
||||
private const float StarSize = 24f;
|
||||
private const float StarSpacing = 4f;
|
||||
|
||||
public int Value
|
||||
{
|
||||
get => _value;
|
||||
set
|
||||
{
|
||||
if (_value != value)
|
||||
{
|
||||
_value = Math.Clamp(value, 0, MaxValue);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int MaxValue
|
||||
{
|
||||
get => _maxValue;
|
||||
set
|
||||
{
|
||||
if (_maxValue != value)
|
||||
{
|
||||
_maxValue = Math.Max(1, value);
|
||||
InvalidateMeasure();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Color StarColor
|
||||
{
|
||||
get => _starColor;
|
||||
set
|
||||
{
|
||||
if (_starColor != value)
|
||||
{
|
||||
_starColor = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Color EmptyStarColor
|
||||
{
|
||||
get => _emptyStarColor;
|
||||
set
|
||||
{
|
||||
if (_emptyStarColor != value)
|
||||
{
|
||||
_emptyStarColor = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public event EventHandler<int>? ValueChanged;
|
||||
|
||||
protected override Size MeasureOverride(Size availableSize)
|
||||
{
|
||||
var width = MaxValue * StarSize + (MaxValue - 1) * StarSpacing;
|
||||
return new Size(width, StarSize);
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
for (int i = 0; i < MaxValue; i++)
|
||||
{
|
||||
var x = bounds.Left + i * (StarSize + StarSpacing);
|
||||
var y = bounds.Top;
|
||||
var color = i < Value ? StarColor : EmptyStarColor;
|
||||
|
||||
DrawStar(canvas, x, y, StarSize, color);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawStar(SKCanvas canvas, float x, float y, float size, Color color)
|
||||
{
|
||||
using var paint = new SKPaint
|
||||
{
|
||||
Color = color.ToSKColor(),
|
||||
IsAntialias = true,
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
|
||||
var path = CreateStarPath(x + size / 2, y + size / 2, size / 2, size / 4);
|
||||
canvas.DrawPath(path, paint);
|
||||
}
|
||||
|
||||
private SKPath CreateStarPath(float cx, float cy, float outerRadius, float innerRadius)
|
||||
{
|
||||
var path = new SKPath();
|
||||
var angle = -Math.PI / 2;
|
||||
var step = Math.PI / 5;
|
||||
|
||||
path.MoveTo(
|
||||
cx + (float)(outerRadius * Math.Cos(angle)),
|
||||
cy + (float)(outerRadius * Math.Sin(angle)));
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
angle += step;
|
||||
var radius = i % 2 == 0 ? innerRadius : outerRadius;
|
||||
path.LineTo(
|
||||
cx + (float)(radius * Math.Cos(angle)),
|
||||
cy + (float)(radius * Math.Sin(angle)));
|
||||
}
|
||||
|
||||
path.Close();
|
||||
return path;
|
||||
}
|
||||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
|
||||
var starIndex = (int)((e.X - Bounds.Left) / (StarSize + StarSpacing));
|
||||
var newValue = Math.Clamp(starIndex + 1, 0, MaxValue);
|
||||
|
||||
if (newValue != Value)
|
||||
{
|
||||
Value = newValue;
|
||||
ValueChanged?.Invoke(this, Value);
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Create the Handler
|
||||
|
||||
Connect the MAUI control to the platform view:
|
||||
|
||||
```csharp
|
||||
// Handlers/RatingControlHandler.cs
|
||||
using Microsoft.Maui.Handlers;
|
||||
using MyApp.Controls;
|
||||
using MyApp.Platforms.Linux;
|
||||
|
||||
namespace MyApp.Handlers;
|
||||
|
||||
public partial class RatingControlHandler : ViewHandler<RatingControl, SkiaRatingControl>
|
||||
{
|
||||
public static IPropertyMapper<RatingControl, RatingControlHandler> PropertyMapper =
|
||||
new PropertyMapper<RatingControl, RatingControlHandler>(ViewMapper)
|
||||
{
|
||||
[nameof(RatingControl.Value)] = MapValue,
|
||||
[nameof(RatingControl.MaxValue)] = MapMaxValue,
|
||||
[nameof(RatingControl.StarColor)] = MapStarColor,
|
||||
[nameof(RatingControl.EmptyStarColor)] = MapEmptyStarColor,
|
||||
};
|
||||
|
||||
public static CommandMapper<RatingControl, RatingControlHandler> CommandMapper =
|
||||
new(ViewCommandMapper);
|
||||
|
||||
public RatingControlHandler() : base(PropertyMapper, CommandMapper)
|
||||
{
|
||||
}
|
||||
|
||||
protected override SkiaRatingControl CreatePlatformView()
|
||||
{
|
||||
return new SkiaRatingControl();
|
||||
}
|
||||
|
||||
protected override void ConnectHandler(SkiaRatingControl platformView)
|
||||
{
|
||||
base.ConnectHandler(platformView);
|
||||
platformView.ValueChanged += OnPlatformValueChanged;
|
||||
}
|
||||
|
||||
protected override void DisconnectHandler(SkiaRatingControl platformView)
|
||||
{
|
||||
platformView.ValueChanged -= OnPlatformValueChanged;
|
||||
base.DisconnectHandler(platformView);
|
||||
}
|
||||
|
||||
private void OnPlatformValueChanged(object? sender, int value)
|
||||
{
|
||||
VirtualView.Value = value;
|
||||
VirtualView.SendValueChanged(value);
|
||||
}
|
||||
|
||||
private static void MapValue(RatingControlHandler handler, RatingControl control)
|
||||
{
|
||||
handler.PlatformView.Value = control.Value;
|
||||
}
|
||||
|
||||
private static void MapMaxValue(RatingControlHandler handler, RatingControl control)
|
||||
{
|
||||
handler.PlatformView.MaxValue = control.MaxValue;
|
||||
}
|
||||
|
||||
private static void MapStarColor(RatingControlHandler handler, RatingControl control)
|
||||
{
|
||||
handler.PlatformView.StarColor = control.StarColor;
|
||||
}
|
||||
|
||||
private static void MapEmptyStarColor(RatingControlHandler handler, RatingControl control)
|
||||
{
|
||||
handler.PlatformView.EmptyStarColor = control.EmptyStarColor;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Register the Handler
|
||||
|
||||
Register your handler in `MauiProgram.cs`:
|
||||
|
||||
```csharp
|
||||
using Microsoft.Maui.Hosting;
|
||||
using MyApp.Controls;
|
||||
using MyApp.Handlers;
|
||||
|
||||
public static class MauiProgram
|
||||
{
|
||||
public static MauiApp CreateMauiApp()
|
||||
{
|
||||
var builder = MauiApp.CreateBuilder();
|
||||
builder
|
||||
.UseMauiApp<App>()
|
||||
.UseOpenMauiLinux()
|
||||
.ConfigureMauiHandlers(handlers =>
|
||||
{
|
||||
handlers.AddHandler<RatingControl, RatingControlHandler>();
|
||||
});
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Use in XAML
|
||||
|
||||
```xml
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:controls="clr-namespace:MyApp.Controls"
|
||||
x:Class="MyApp.MainPage">
|
||||
|
||||
<VerticalStackLayout>
|
||||
<Label Text="Rate this product:" />
|
||||
<controls:RatingControl
|
||||
Value="{Binding Rating}"
|
||||
MaxValue="5"
|
||||
StarColor="Gold"
|
||||
EmptyStarColor="LightGray"
|
||||
ValueChanged="OnRatingChanged" />
|
||||
</VerticalStackLayout>
|
||||
</ContentPage>
|
||||
```
|
||||
|
||||
## Creating a Direct SkiaView
|
||||
|
||||
For Linux-only controls or simpler use cases, inherit directly from `SkiaView`:
|
||||
|
||||
```csharp
|
||||
using Microsoft.Maui.Graphics;
|
||||
using Microsoft.Maui.Platform;
|
||||
using SkiaSharp;
|
||||
|
||||
public class CustomGauge : SkiaView
|
||||
{
|
||||
private double _value;
|
||||
private double _minimum;
|
||||
private double _maximum = 100;
|
||||
|
||||
public double Value
|
||||
{
|
||||
get => _value;
|
||||
set
|
||||
{
|
||||
_value = Math.Clamp(value, Minimum, Maximum);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public double Minimum
|
||||
{
|
||||
get => _minimum;
|
||||
set { _minimum = value; Invalidate(); }
|
||||
}
|
||||
|
||||
public double Maximum
|
||||
{
|
||||
get => _maximum;
|
||||
set { _maximum = value; Invalidate(); }
|
||||
}
|
||||
|
||||
protected override Size MeasureOverride(Size availableSize)
|
||||
{
|
||||
return new Size(100, 100); // Fixed size gauge
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
var center = new SKPoint(bounds.MidX, bounds.MidY);
|
||||
var radius = Math.Min(bounds.Width, bounds.Height) / 2 - 10;
|
||||
|
||||
// Draw background arc
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = SKColors.LightGray,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 10,
|
||||
IsAntialias = true,
|
||||
StrokeCap = SKStrokeCap.Round
|
||||
};
|
||||
|
||||
canvas.DrawArc(
|
||||
new SKRect(center.X - radius, center.Y - radius, center.X + radius, center.Y + radius),
|
||||
135, 270, false, bgPaint);
|
||||
|
||||
// Draw value arc
|
||||
var percentage = (Value - Minimum) / (Maximum - Minimum);
|
||||
var sweepAngle = (float)(270 * percentage);
|
||||
|
||||
using var valuePaint = new SKPaint
|
||||
{
|
||||
Color = SKColors.Blue,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 10,
|
||||
IsAntialias = true,
|
||||
StrokeCap = SKStrokeCap.Round
|
||||
};
|
||||
|
||||
canvas.DrawArc(
|
||||
new SKRect(center.X - radius, center.Y - radius, center.X + radius, center.Y + radius),
|
||||
135, sweepAngle, false, valuePaint);
|
||||
|
||||
// Draw value text
|
||||
using var textPaint = new SKPaint
|
||||
{
|
||||
Color = SKColors.Black,
|
||||
TextSize = 24,
|
||||
TextAlign = SKTextAlign.Center,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
canvas.DrawText(
|
||||
$"{Value:F0}",
|
||||
center.X, center.Y + 8,
|
||||
textPaint);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use MAUI Types for Public APIs
|
||||
|
||||
Always use MAUI types (Color, Rect, Size, Thickness, double) in public APIs:
|
||||
|
||||
```csharp
|
||||
// Good - MAUI types
|
||||
public Color ForegroundColor { get; set; }
|
||||
public double BorderWidth { get; set; }
|
||||
public Thickness Padding { get; set; }
|
||||
|
||||
// Bad - SkiaSharp types (internal only)
|
||||
// public SKColor ForegroundColor { get; set; } // Don't expose
|
||||
// public float BorderWidth { get; set; } // Don't expose
|
||||
```
|
||||
|
||||
### 2. Implement Visual States
|
||||
|
||||
Support visual state changes for interactive controls:
|
||||
|
||||
```csharp
|
||||
public override void OnPointerEntered(PointerEventArgs e)
|
||||
{
|
||||
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.PointerOver);
|
||||
base.OnPointerEntered(e);
|
||||
}
|
||||
|
||||
public override void OnPointerExited(PointerEventArgs e)
|
||||
{
|
||||
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Normal);
|
||||
base.OnPointerExited(e);
|
||||
}
|
||||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Pressed);
|
||||
base.OnPointerPressed(e);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Support Accessibility
|
||||
|
||||
Override accessibility methods for screen reader support:
|
||||
|
||||
```csharp
|
||||
protected override string GetDefaultAccessibleName()
|
||||
{
|
||||
return $"Rating: {Value} of {MaxValue} stars";
|
||||
}
|
||||
|
||||
protected override AccessibleRole GetAccessibleRole()
|
||||
{
|
||||
return AccessibleRole.Slider;
|
||||
}
|
||||
|
||||
protected override IReadOnlyList<AccessibleAction> GetAccessibleActions()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new AccessibleAction("Increment", "Increase rating"),
|
||||
new AccessibleAction("Decrement", "Decrease rating")
|
||||
};
|
||||
}
|
||||
|
||||
protected override bool DoAccessibleAction(string actionName)
|
||||
{
|
||||
switch (actionName)
|
||||
{
|
||||
case "Increment":
|
||||
Value = Math.Min(Value + 1, MaxValue);
|
||||
return true;
|
||||
case "Decrement":
|
||||
Value = Math.Max(Value - 1, 0);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Implement Keyboard Navigation
|
||||
|
||||
Support keyboard input for accessibility:
|
||||
|
||||
```csharp
|
||||
public CustomControl()
|
||||
{
|
||||
IsFocusable = true;
|
||||
}
|
||||
|
||||
public override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Left:
|
||||
case Key.Down:
|
||||
Value--;
|
||||
e.Handled = true;
|
||||
break;
|
||||
case Key.Right:
|
||||
case Key.Up:
|
||||
Value++;
|
||||
e.Handled = true;
|
||||
break;
|
||||
case Key.Home:
|
||||
Value = Minimum;
|
||||
e.Handled = true;
|
||||
break;
|
||||
case Key.End:
|
||||
Value = Maximum;
|
||||
e.Handled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Handle Focus Visuals
|
||||
|
||||
Draw focus indicators when the control is focused:
|
||||
|
||||
```csharp
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Draw control content...
|
||||
|
||||
// Draw focus ring
|
||||
if (IsFocused)
|
||||
{
|
||||
using var focusPaint = new SKPaint
|
||||
{
|
||||
Color = SkiaTheme.PrimarySK.WithAlpha(100),
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 2,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
canvas.DrawRoundRect(bounds.Inflate(2, 2), 4, 4, focusPaint);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Optimize Rendering
|
||||
|
||||
Use `Invalidate()` sparingly and implement dirty region tracking:
|
||||
|
||||
```csharp
|
||||
// Only invalidate what changed
|
||||
public int Value
|
||||
{
|
||||
get => _value;
|
||||
set
|
||||
{
|
||||
if (_value != value)
|
||||
{
|
||||
_value = value;
|
||||
Invalidate(); // Request redraw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use InvalidateMeasure() when size changes
|
||||
public int MaxValue
|
||||
{
|
||||
get => _maxValue;
|
||||
set
|
||||
{
|
||||
if (_maxValue != value)
|
||||
{
|
||||
_maxValue = value;
|
||||
InvalidateMeasure(); // Size changed, remeasure needed
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Custom Controls
|
||||
|
||||
Create unit tests for your controls:
|
||||
|
||||
```csharp
|
||||
using FluentAssertions;
|
||||
using Microsoft.Maui.Graphics;
|
||||
using Xunit;
|
||||
|
||||
public class SkiaRatingControlTests
|
||||
{
|
||||
[Fact]
|
||||
public void Value_ClampedToMaxValue()
|
||||
{
|
||||
var control = new SkiaRatingControl { MaxValue = 5 };
|
||||
|
||||
control.Value = 10;
|
||||
|
||||
control.Value.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Value_ClampedToZero()
|
||||
{
|
||||
var control = new SkiaRatingControl();
|
||||
|
||||
control.Value = -5;
|
||||
|
||||
control.Value.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Measure_ReturnsCorrectSize()
|
||||
{
|
||||
var control = new SkiaRatingControl { MaxValue = 5 };
|
||||
|
||||
var size = control.Measure(new Size(500, 500));
|
||||
|
||||
size.Width.Should().BeGreaterThan(0);
|
||||
size.Height.Should().Be(24); // StarSize
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
Creating custom controls for OpenMaui Linux follows these patterns:
|
||||
|
||||
1. **Define MAUI control** with BindableProperties and events
|
||||
2. **Create platform view** inheriting from SkiaView
|
||||
3. **Create handler** to connect MAUI and platform views
|
||||
4. **Register handler** in MauiProgram.cs
|
||||
5. **Follow best practices** for accessibility, visual states, and performance
|
||||
|
||||
For more examples, see the existing controls in the `Views/` directory.
|
||||
Reference in New Issue
Block a user