Table of Contents

DrawnUI Fluent C# Extensions - Developer Guide ๐Ÿš€

version 1.5a

Welcome to the world of fluent C# UI development! ๐ŸŽ‰ This guide covers the essential patterns of DrawnUI fluent extensions for drawn controls, making your UI code more readable, maintainable, and fun to write.

Not all methods are listed here, as extensions are evolving.

WARNING: this API could become obsolete as i recently discovered UNO platform fluent syntax and thinking much about replicating it.

Table of Contents

  1. Core Philosophy
  2. Actions and References
  3. Property Observation ๐ŸŽฏ Start here for reactive UI
  4. Advanced Property Observation
  5. Common Patterns
  6. Layout Extensions
  7. Gesture Handling
  8. Control Helpers
  9. Best Practices
  10. Troubleshooting

Core Philosophy

Use these extensions to build your UI with C# using fluent chaining for clean, readable code.

  • Fluent chaining - Readable, declarative UI code
  • Performance first - Runs efficiently, does not require UI thread
  • Automatic cleanup - No memory leaks, subscriptions auto-dispose
  • Framework-independent - Multi-way property observations without framework bindings

.NET MAUI notice:

  1. While you can still create bindings in code, these extensions allow you to use MVVM without traditional bindings.
  2. Drawn controls do not require their properties to be accessed on UI-thread.
new SkiaLabel()
{
    UseCache = SkiaCacheType.Operations
}
.ObserveProperty(()=>Model, nameof(Title), me =>
{
    me.Text = Model.Title;
})
.OnTapped(me =>
{
    Model.CommandAddRequest.Execute(null);
})

Actions and References

You can execute conditional code during construction and initialization of controls, and access controls by assigned references.

Execute simple code within the fluent chain:

new SkiaMauiEditor()
{
    MaxLines = 1,
    HeightRequest = 32,
    Placeholder = "...",
    Padding = new Thickness(0, 2, 0, 4),
}
.Initialize(me =>
{
    if (_multiline)
    {
        me.MaxLines = -1;
        me.HeightRequest = 180;
    }
})

Assign References

You can declare a variable holding a reference to a control and assign it during control creation.

//declared in some scope
SkiaLabel labelText;

//assign variable during control creation
new SkiaLabel("Hello World!")
    .WithFontSize(24)
    .Assign(out labelText)

Getting References During Construction

The variable you set with Assign will be available after the fluent chain has been completely built. If you need to access them for initialization, use the Initialize method.
For observing variables that are still null at the time of UI construction use access by action inside the Observe, same goes for ObserveProperty, ObserveProperties:

SkiaLabel statusLabel;
SkiaButton button;
int counter = 0;

var layout = new SkiaStack
{
    Children =  
    {           
        new SkiaLabel("0")
            .Assign(out statusLabel),

        new SkiaLabel()             
        .Observe(() => statusLabel, (me, prop) => //notice access by action!
        {
            if (prop.IsEither(nameof(BindingContext), nameof(Text)))
            {
                me.Text = $"Label text changed to: {statusLabel.Text}";
            }
        }),

        new SkiaButton("Click Me")
        {
            BackgroundColor = Colors.Grey
        }
        .Assign(out button) //  <--- assign
        .OnTapped(me => { statusLabel.Text = $"{++counter}"; })
    }
}                   
.Initialize(me =>
{
    //assigned variables <--- access
    button.BackgroundColor = Colors.Green;
});

Parent Assignment

You would normally include children like this:

new SkiaStack
{
    Children = 
    {           
        new SkiaLabel("0"),
        new SkiaButton("Click Me")
    }
}    

Or you might prefer this approach:

new SkiaStack()
    .WithChildren(
        new SkiaLabel("0"),
        new SkiaButton("Click Me")
    )

In case you need to assign a single control to a parent properly:

var child = new SkiaLabel("I'm a child")
    .AssignParent(parentLayout)  // Adds to parent automatically
    .CenterX();

or

var child = new SkiaLabel("I'm a child");
parentLayout.AddSubView(child);

To properly remove children by code:

layout.Children.RemoveAt(0); //remove the first one

layout.RemoveSubView(child); //remove child

layout.ClearChildren(); //clear them all

Property Observation

These methods replace traditional MAUI bindings with a thread-safe approach that works seamlessly with ViewModels and controls without requiring UI-thread.

Key Benefits:

  • ๐Ÿš€ Thread-safe - No UI thread requirements for property access
  • ๐Ÿงน Auto-cleanup - Subscriptions automatically dispose when control is disposed
  • ๐ŸŽฏ Type-safe - Full IntelliSense support with nameof()
  • โšก Performance - Direct property observation without binding overhead
  • ๐Ÿ”„ Reactive - UI updates automatically when properties change

Essential Methods (start here):

  • ObserveProperty() - Single property observation
  • ObserveProperties() - Multiple properties observation

Key Points:

  • Observe properties of any INotifyPropertyChanged source
  • Always check for nameof(BindingContext) for initial default value setup
  • Extension will automatically unsubscribe/cleanup when control is disposed
  • Can use propertyName.IsEither(prop1, prop2) for multiple properties

.ObserveProperty(target, propertyName, callback) - Single Property

The simplest way to observe a single property change. Perfect for basic scenarios:

new SkiaLabel()
.ObserveProperty(Model, nameof(Model.Title), me =>
{
    me.Text = Model.Title;
})

Real-world example from GameButton tutorial:

new SkiaRichLabel()
{
    Text = this.Text,
    UseCache = SkiaCacheType.Operations,
    HorizontalTextAlignment = DrawTextAlignment.Center,
    VerticalOptions = LayoutOptions.Center,
    FontSize = 16,
    FontAttributes = FontAttributes.Bold,
    TextColor = Colors.White,
}
.ObserveProperty(this, nameof(Text), me =>
{
    me.Text = this.Text;
})

.ObserveProperties(target, propertyNames, callback) - Multiple Properties

Observes multiple specific properties on a source. BindingContext is automatically included:

new SkiaButton("Submit")
.ObserveProperties(viewModel,
    [nameof(viewModel.CanSubmit), nameof(viewModel.IsLoading)],
    me =>
    {
        if (viewModel.CanSubmit && !viewModel.IsLoading)
        {
            me.IsEnabled = true;
            me.Opacity = 1.0;
        }
        else
        {
            me.IsEnabled = false;
            me.Opacity = 0.5;
        }
    });

Real-world example from FirstApp tutorial:

SkiaButton btnClickMe;

// Create button with assignment
new SkiaButton("Click Me!")
{
    UseCache = SkiaCacheType.Image,
    BackgroundColor = Colors.CornflowerBlue,
    TextColor = Colors.White,
    CornerRadius = 8,
    HorizontalOptions = LayoutOptions.Center,
}
.Assign(out btnClickMe)
.OnTapped(async me =>
{
    clickCount++;
    me.Text = $"Clicked {clickCount} times! ๐ŸŽ‰";
    await me.ScaleToAsync(1.1, 1.1, 100);
    await me.ScaleToAsync(1, 1, 100);
}),

// Observer label that watches button properties
new SkiaRichLabel()
{
    UseCache = SkiaCacheType.Operations,
    FontSize = 14,
    TextColor = Colors.Green,
    HorizontalOptions = LayoutOptions.Center,
}
.ObserveProperties(() => btnClickMe,
    [nameof(SkiaButton.Text), nameof(SkiaButton.IsPressed)],
    me =>
    {
        me.Text = $"Observing button: \"..{btnClickMe.Text.Right(12)}\", is pressed: {btnClickMe.IsPressed}";
    })

.Observe(vm, callback) - Basic Pattern

Observes property changes on any INotifyPropertyChanged source:

//BindingMode.OneWay alternative
new SkiaLabel()
.Observe(Model, (label, prop) => {
    if (prop.IsEither(nameof(BindingContext), nameof(Model.DisplayName)))
    {
        //get value from viewmodel
        label.Text = Model.DisplayName;
    }
});

.ObserveSelf(callback) - Self Observation

Observes the control's own property changes:

//BindingMode.OneWayToSource alternative
wheelPicker
    .ObserveSelf((me, prop) => {
        if (prop.IsEither(nameof(BindingContext), nameof(me.SelectedIndex)))
        {
            //set viewmodel property
            viewModel.CurrentIndex = me.SelectedIndex;
        }
    });

.ObserveBindingContext<TControl, TViewModel>(callback) - Typed ViewModel

Type-safe observation of the control's BindingContext:

new SkiaLabel()
.ObserveBindingContext<SkiaLabel, ChatViewModel>((me, vm, prop) => {
    if (prop.IsEither(nameof(BindingContext), nameof(vm.MessageCount)))
    {
        me.Text = $"Messages: {vm.MessageCount}";
    }
});

Advanced Property Observation

For complex scenarios where targets change dynamically or you need to observe nested properties:

.ObservePropertyOn(parent, targetSelector, parentPropertyName, callback) - Dynamic Target

Observes a dynamically resolved target object using a function selector. When the parent's properties change, re-evaluates the selector and automatically unsubscribes from old target and subscribes to new one:

new SkiaLabel()
.ObservePropertyOn(
    this,
    () => CurrentTimer,
    nameof(CurrentTimer),
    (me, prop) =>
    {
        if (prop.IsEither(nameof(BindingContext), nameof(RunningTimer.Time)))
        {
            me.Text = $"{CurrentTimer.Time:mm\\:ss}";
        }
    }
)

.ObservePropertiesOn(parent, targetSelector, parentPropertyName, propertyNames, callback) - Dynamic Target Multiple Properties

Similar to ObservePropertyOn but observes multiple specific properties on the dynamically resolved target:

new SkiaLabel()
.ObservePropertiesOn(
    parentViewModel,
    () => parentViewModel.CurrentUser,
    nameof(ParentViewModel.CurrentUser),
    [nameof(User.Name), nameof(User.Status)],
    me =>
    {
        var user = parentViewModel.CurrentUser;
        me.Text = user != null ? $"{user.Name} - {user.Status}" : "No user";
    }
)

.ObserveBindingContextOn<TControl, TTarget, TViewModel>(target, callback) - Another Control's BindingContext

Watches for property changes on another control's BindingContext:

new SkiaLabel()
.ObserveBindingContextOn<SkiaLabel, SkiaEntry, MyViewModel>(
    entryControl,
    (me, target, vm, prop) =>
    {
        if (prop.IsEither(nameof(BindingContext), nameof(vm.ValidationError)))
        {
            me.Text = vm.ValidationError ?? "";
            me.IsVisible = !string.IsNullOrEmpty(vm.ValidationError);
        }
    }
)

.ObserveOn<T, TParent, TTarget>(parent, targetSelector, parentPropertyName, callback, propertyFilter) - Core Dynamic Observation

The foundational method for observing dynamically resolved target objects. When the parent's properties change, re-evaluates the selector and automatically unsubscribes from old target and subscribes to new one. This is AOT-compatible:

new SkiaLabel()
.ObserveOn(
    parentViewModel,
    () => parentViewModel.CurrentTimer,
    nameof(parentViewModel.CurrentTimer),
    (me, prop) => {
        if (prop.IsEither(nameof(BindingContext), nameof(Timer.RemainingTime)))
        {
            me.Text = $"Time: {parentViewModel.CurrentTimer?.RemainingTime ?? 0}";
        }
    },
    [nameof(BindingContext), nameof(Timer.RemainingTime)]
);

.Observe<T, TSource>(sourceFetcher, callback, propertyFilter) - Delayed Assignment Observation

Observes a control that will be assigned later in the initialization process using a function selector:

new SkiaLabel()
.Observe(() => statusLabel, (me, prop) => {
    if (prop.IsEither(nameof(BindingContext), nameof(SkiaLabel.Text)))
    {
        me.TextColor = statusLabel.Text.Contains("Error") ? Colors.Red : Colors.Green;
    }
});

Common Patterns

Observe injected ViewModel

When you inject your ViewModel in the page/screen constructor you can observe a fixed reference:

public class MyScreen : AppScreen //subclassed custom SkiaLayout
{
    public readonly InjectedViewModel Model;

    public ScreenChat(InjectedViewModel vm)
    {
        Model = vm;
        BindingContext = Model;

        CreateContent();
    }
}

protected void CreateContent()
{
    HorizontalOptions = LayoutOptions.Fill;
    VerticalOptions = LayoutOptions.Fill;
    Type = LayoutType.Column;
    Spacing = 0;
    Padding = 16;
    Children =
    {
        new SkiaLabel()
        .Observe(Model, (me, prop) => //observe Model reference directly
        {
            bool attached = prop == nameof(BindingContext);
            if (attached || prop == nameof(Model.Title))
            {
                me.Text = Model.Title;
            }
            if (attached || prop == nameof(Model.Error))
            {
                me.TextColor = Model.Error ? Colors.Red : Colors.Black;
            }
        }),
    };
}

Observe another control property

You have several options depending on your scenario:

Simple static reference - when the target is not likely to change:

new SkiaLabel()
.ObserveProperty(Model, nameof(Model.Title), me =>
{
    me.Text = Model.Title;
})

Dynamic reference - when Model is likely to change and implements INotifyPropertyChanged:

new SkiaLabel()
.ObservePropertyOn(this, () => Model, nameof(Model), (me, propertyName) =>
{
    me.Text = Model.Title;
})

Advanced cross-control observation - observing one control from another:

SkiaLabel labelTitle;

new SkiaLabel()
.Observe(Model, (me, prop) =>
{
    bool attached = prop == nameof(BindingContext);
    if (attached || prop == nameof(Model.Title))
    {
        me.Text = Model.Title;
    }
})
.Assign(out labelTitle),

new SkiaLabel()
.Observe(() => labelTitle, (me, prop) =>
{
    bool attached = prop == nameof(BindingContext);
    if (attached || prop == nameof(Text))
    {
        me.Text = $"The title was: {labelTitle.Text}";
    }
})

Two-Way bindings

new WheelPicker()
.ObserveSelf((me, prop) =>
{
    if (prop.IsEither(nameof(BindingContext), nameof(WheelPicker.SelectedIndex)))
    {
        IndexIso = me.SelectedIndex; //update local property from control
    }
})
.Observe(this, (me, prop) =>
{
    if (prop.IsEither(nameof(BindingContext), nameof(IndexIso)))
    {
        me.SelectedIndex = IndexIso; //update control property from local
    }
}),

Reactive Button States

var submitButton = new SkiaButton("Submit")
    .ObserveBindingContext<SkiaButton, MyViewModel>((btn, vm, prop) => {
        bool attached = prop == nameof(BindingContext);
        if (attached || prop == nameof(vm.CanSubmit))
        {
            btn.IsEnabled = vm.CanSubmit;
            btn.Opacity = vm.CanSubmit ? 1.0 : 0.5;
        }
        if (attached || prop == nameof(vm.IsReadOnly))
        {
            btn.IsVisible = !vm.IsReadOnly;
        }
    })
    .OnTapped(me => { viewModel.SubmitCommand.Execute(null); });

Conditional Visibility

var errorView = new SkiaLabel()
    .ObserveBindingContext<SkiaLabel, MyViewModel>((lbl, vm, prop) => {
        bool attached = prop == nameof(BindingContext);
        if (attached || prop == nameof(vm.HasError))
        {
            lbl.IsVisible = vm.HasError;
            lbl.Text = vm.ErrorMessage;
        }
    });

Loading States

var loadingIndicator = new ActivityIndicator()
    .ObserveBindingContext<ActivityIndicator, MyViewModel>((indicator, vm, prop) => {
        bool attached = prop == nameof(BindingContext);
        if (attached || prop == nameof(vm.IsLoading))
        {
            indicator.IsVisible = vm.IsLoading;
            indicator.IsRunning = vm.IsLoading;
        }
    });

List Content Management

var listView = new CellsStack()
    .ObserveBindingContext<CellsStack, MyViewModel>((list, vm, prop) => {
        bool attached = prop == nameof(BindingContext);

        if (attached || prop == nameof(vm.HasData))
        {
            list.ItemsSource = vm.HasData ? vm.Items : null;
        }

        if (attached || prop == nameof(vm.HasError))
        {
            if (vm.HasError)
                list.ItemsSource = null;
        }
    });

Two-Way Property Synchronization

// Sync slider value with viewModel
var slider = new SkiaSlider()
    .ObserveBindingContext<SkiaSlider, MyViewModel>((sld, vm, prop) => {
        bool attached = prop == nameof(BindingContext);
        if (attached || prop == nameof(vm.Volume))
        {
            if (Math.Abs(sld.Value - vm.Volume) > 0.01) // Prevent loops
                sld.Value = vm.Volume;
        }
    })
    .ObserveSelf((sld, prop) => {
        if (prop == nameof(sld.Value))
        {
            if (BindingContext is MyViewModel vm)
                vm.Volume = sld.Value;
        }
    });

Layout Extensions

Positioning and Sizing

new SkiaLabel("Hello")
    .Center()           // Centers both X and Y
    .CenterX()          // Centers horizontally only
    .CenterY()          // Centers vertically only
    .Fill()             // Fills both directions
    .FillX()            // Fills horizontally
    .FillY()            // Fills vertically
    .StartX()           // Aligns to start horizontally
    .StartY()           // Aligns to start vertically
    .EndX()             // Aligns to end horizontally
    .EndY()             // Aligns to end vertically
    .WithHeight(100)    // Sets height (HeightRequest)
    .WithWidth(200)     // Sets width (WidthRequest)
    .WithMargin(16)     // Uniform margin
    .WithMargin(16, 8)  // Horizontal, vertical
    .WithMargin(16, 8, 16, 8); // Left, top, right, bottom

Layout-Specific Extensions

new SkiaLayout()
    .WithPadding(16)              // Uniform padding
    .WithPadding(16, 8)           // Horizontal, vertical
    .WithChildren(child1, child2) // Add multiple children
    .WithContent(singleChild);    // For IWithContent containers

Additional UI Extensions

new SkiaLabel("Hello")
    .WithCache(SkiaCacheType.Operations)    // Set cache type
    .WithBackgroundColor(Colors.Blue)       // Set background color
    .WithHorizontalOptions(LayoutOptions.Center) // Set horizontal options
    .WithVerticalOptions(LayoutOptions.End) // Set vertical options
    .WithHeight(100)                        // Set height request
    .WithWidth(200)                         // Set width request
    .WithMargin(new Thickness(16))          // Set margin with Thickness
    .WithVisibility(true)                   // Set visibility
    .WithTag("MyLabel");                    // Set tag

Shape Extensions

new SkiaShape()
    .WithShapeType(ShapeType.Rectangle)     // Set shape type
    .Shape(ShapeType.Circle);               // Shorter alias

Image Extensions

new SkiaImage()
    .WithAspect(TransformAspect.Fill);      // Set image aspect

Label Extensions

new SkiaLabel("Text")
    .WithFontSize(16)                       // Set font size
    .WithTextColor(Colors.Red)              // Set text color
    .WithHorizontalTextAlignment(DrawTextAlignment.Center); // Set text alignment

Entry Extensions

new SkiaMauiEntry()
    .OnTextChanged((entry, text) =>
    {
        // Handle text changes
        Console.WriteLine($"Text changed to: {text}");
    });

new SkiaMauiEditor()
    .OnTextChanged((editor, text) =>
    {
        // Handle editor text changes
    });

new SkiaLabel()
    .OnTextChanged((label, text) =>
    {
        // Handle label text changes via PropertyChanged
    });

Gesture Handling

Basic Gestures

Can add gesture handling effects to any control:

anyControl
.OnTapped(btn =>
{
    viewModel.CommandExecute(null);
})
.OnLongPressing(btn =>
{
    ShowContextMenu();
});

Advanced Gesture Handling

Controls that implement ISkiaGestureListener (deriving from SkiaLayout etc) can use this extension. Technically, this calls a delegate OnGestures action before executing the base.ProcessGestures code. The same logic can be implemented by subclassing a control and overriding ProcessGestures. Return this control reference if you consumed a gesture, return null if not. The UP gesture should be marked as consumed ONLY for specific scenarios; please return null if unsure.

layout.WithGestures((me, args, apply) => {
    ISkiaGestureListener consumed = null;

    //your logic
    if (args.Type == TouchActionResult.Panning)
    {
        // Handle panning
        consumed = this; //we consumed this one
    }

    //return consumed state
    if (consumed != null && args.Type != TouchActionResult.Up)
    {
        return consumed; //do not let others use this gesture anymore
    }
    return null; //will send this gesture to other controls
});

Control Helpers

You might want to create helpers to be reused within your app, for example:

//define once
public class AppButton : SkiaButton
{
    public AppButton(string caption)
    {
        UseCache = SkiaCacheType.Image;
        HorizontalOptions = LayoutOptions.Center;
        WidthRequest = 250;
        HeightRequest = 44;
        Text = caption;
    }
}

//Use everywhere
new AppButton("Click Me")

For convenience, some helpers come out of the box:

  • SkiaLayer - absolute layout, children will be super-positioned, create layers and anything. This is a SkiaLayout with horizontal Fill by default.
  • SkiaStack - Vertical stack, like MAUI VerticalStackLayout. This is a SkiaLayout type Column with horizontal Fill by default.
  • SkiaRow - Horizontal stack, like MAUI HorizontalStackLayout. This is a SkiaLayout type Row.
  • SkiaWrap - A powerful flexible control that arranges children in a responsive way according to available size. This is a SkiaLayout type Wrap with horizontal Fill by default.
  • SkiaGrid - MAUI Grid alternative to use rows and columns at will. If you are used to a MAUI grid with a single row/col just to position items one over the other, please use SkiaLayer instead!

Best Practices

Always Check for BindingContext

// โœ… CORRECT
.ObserveBindingContext<Control, ViewModel>((ctrl, vm, prop) => {
    bool attached = prop == nameof(BindingContext);
    if (attached || prop == nameof(vm.MyProperty))
    {
        // Handle both initial setup and property changes
    }
});

// โŒ WRONG - misses initial setup
.ObserveBindingContext<Control, ViewModel>((ctrl, vm, prop) => {
    if (prop == nameof(vm.MyProperty)) // Only triggers on changes
    {
        // Will miss initial value!
    }
});

Use IsEither for Multiple Properties

// โœ… CORRECT
if (prop.IsEither(nameof(BindingContext), nameof(vm.Prop1), nameof(vm.Prop2)))

// โŒ VERBOSE
if (prop == nameof(BindingContext) || prop == nameof(vm.Prop1) || prop == nameof(vm.Prop2))

Prevent Circular Updates

// โœ… CORRECT - prevents infinite loops
.ObserveSelf((control, prop) => {
    if (prop == nameof(control.Value))
    {
        if (Math.Abs(viewModel.Value - control.Value) > 0.01)
            viewModel.Value = control.Value;
    }
});
new SkiaButton("Save")
    .WithHeight(44)
    .CenterX()
    .WithMargin(16, 8)
    .ObserveBindingContext<SkiaButton, MyViewModel>((btn, vm, prop) => {
        // Reactive logic
    })
    .OnTapped(me =>
    {
        // Action logic
    });

Troubleshooting

Problem: Observer Not Triggering

Symptoms: UI doesn't update when ViewModel properties change

Solutions:

  1. Ensure ViewModel implements INotifyPropertyChanged
  2. Check that property either has a static bindable property or calls OnPropertyChanged() in the setter.
// โœ… Make sure ViewModel raises PropertyChanged
public class MyViewModel : INotifyPropertyChanged
{
    private string _name;
    public string Name
    {
        get => _name;
        set
        {
            if (value == _name)
                return;
            _name = value;
            OnPropertyChanged(); // Must call this!
        }
    }
}
  1. Check that property names match exactly; use nameof().
  2. Ensure all your overrides, if any, of void OnPropertyChanged([CallerMemberName] string propertyName = null) have a [CallerMemberName] attribute.
  3. Verify you're checking for nameof(BindingContext) for initial setup.

Problem: Null Reference Exceptions

Symptoms: Crashes when accessing ViewModel properties

Solutions:

  1. Make sure you are not accessing an assigned control reference from Adapt; use Initialize instead.
  2. Check that the viewmodel was created and set.

Problem: Performance Issues

Symptoms: UI stuttering or slow updates

Solutions:

  1. Always use cache for layers of controls:
    • Do NOT cache scrolls/heavily animated controls and above
    • UseCache = SkiaCacheType.Operations for labels and svg
    • UseCache = SkiaCacheType.Image for complex layouts, buttons etc
    • UseCache = SkiaCacheType.ImageComposite for complex layouts where a region changes while others remain static, like a stack with different user-handled controls.
    • UseCache = SkiaCacheType.ImageDoubleBuffered for equally sized recycled cells. Will show old cache while preparing new one in background.
    • UseCache = SkiaCacheType.GPU for small static overlays like headers, navbars.
  2. Check that you do not have logs spamming the console on every rendering frame.