# 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? 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? 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 { public static IPropertyMapper PropertyMapper = new PropertyMapper(ViewMapper) { [nameof(RatingControl.Value)] = MapValue, [nameof(RatingControl.MaxValue)] = MapMaxValue, [nameof(RatingControl.StarColor)] = MapStarColor, [nameof(RatingControl.EmptyStarColor)] = MapEmptyStarColor, }; public static CommandMapper 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() .UseOpenMauiLinux() .ConfigureMauiHandlers(handlers => { handlers.AddHandler(); }); return builder.Build(); } } ``` ### Step 5: Use in XAML ```xml ``` ## 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 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.