Chasing Curiosity

davidortinau

David Ortinau

Posted on July 29, 2024

Chasing Curiosity

android ios mac and windows screenshots of the result of this blog

This blog is part of the .NET MAUI UI July 2024 series with a new post every day of the month. See the full schedule for more.

Today marks my 50th birthday. Like many of you who have made a career out of software, I wrote my first line of code before I was a teenager. I recall sitting in a windowless classroom with PCs on long folding tables lined against the walls leaving a no-mans-land in the middle. The only light came from the dim glow of the dark monitors with white text and lines. I performed exercises in BASIC or similar trying to coax the machine into drawing boxes and other shapes to match the instructions of poorly copied lesson sheets. My primary recollection from this quarter-length class was how quickly others completed their tasks and how that left me thinking I must not be any good.

This feeling of futility was later reinforced when my parents gifted me a programming book with which to continue feeding my curiousity. As classic underperforming student when it came to grades at that time, I was generally well above average at most things...well, except coding apparently.

Btw, this is what 50 looks like for me -- hanging out with my granddaughter and playing in my wife's newly (and very pinkly) decorated home office.

Opa and Harmony

Graphics

.NET MAUI introduced a cross-platform drawing abstraction via the gracious contribution of Microsoft.Maui.Graphics from Jon Lipsky. When using this library, the platform-specific graphics engine is used. By comparison SkiaSharp provides its own engine.

When I saw Roman Jasek's contribution to this blog series about creating a ".NET MAUI Environment Ribbon", I became curious: could I achieve the same using Microsoft.Maui.Graphics and the IWindowOverlay in .NET MAUI? What I wanted was a ribbon that told me I was running a Debug build (not Release).

IWindowOverlay is a graphics canvas that caught some attention early in the development of .NET MAUI, but we intentionally don't promote it. This canvas sits above all the Shell and ContentPages of the app at the Window level. Yes, every .NET MAUI app has at least 1 window. The primary use of the IWindowOverlay is for Visual Studio tooling to draw the adorners you see in the Visual Studio XAML Live Preview, and not really for use by everyone. If your app or library uses it, but another library also uses it, and one of you removes all overlays from the window...you can see how this might not go well.

Despite the dangers, I remained curious. The primary benefits of using this approach are that the UI can appear anywhere in the Window and be maintained in a single place.

After some digging, I started asking around about how I could use IWindowOverlay. I didn't get an immediate response, so I just started trying it myself.

Here's some unsolicited advice:

  • Do your research: you don't want to receive a RTFM, right? Read the docs, the source code, the source code tests, and look for samples and articles.
  • Ask questions: if you must...
  • Don't delay trying things yourself: this is the big one IMO. The time it takes you to create/clone a project and try the thing yourself will likely be shorter than waiting for a response to your questions. I always learn something along the way too.

Creating an Overlay

My first step was to simply make an overlay and add a circle to it.

To get the Window I could override the CreateWindow method in the App, or get the Window from a ContentPage.



public partial class App : Application
{
    public App()
    {
        InitializeComponent();

        MainPage = new MainPage();
    }

    protected override Window CreateWindow(IActivationState? activationState)
    {
        var window = base.CreateWindow(activationState);

        // add an overlay
        window.AddOverlay(new WindowOverlay(window));

        return window;
    }
}


Enter fullscreen mode Exit fullscreen mode

To add my circle to the overlay, I need to create an IWindowOverlayElement. Within that class is a Draw method where I could draw my circle. This interface requires I implement that method as well as a Contains method for hit detection (more on that later). Although I spent more than a decade writing drawing code in ActionScript, I leaned on my Copilot to provide the code. :)

"Copilot, draw a 300 purple circle in the middle of the canvas using Microsoft.Maui.Graphics", I pleaded. Immediately I received something like this:



public class CircleElement : IWindowOverlayElement
{
    public bool Contains(Point point)
    {
        return false;
    }

    public void Draw(ICanvas canvas, RectF dirtyRect)
    {
        canvas.FillColor = Colors.Purple;
        canvas.FillCircle(dirtyRect.Width / 2, dirtyRect.Height / 2, 150);
    }
}


Enter fullscreen mode Exit fullscreen mode

Voila! In truth, it didn't all work my first try, and IIRC I hadn't added the window to the overlay, or the overlay to the window...something like that. In the end I prevailed, and even integrated it into my .NET Hot Reload handler where I'd been pursuing other curiosities. I have blogged previously about tapping into the MetaDataUpdateHandler, and you can learn more about the ICommunityToolkitHotReloadHandler I use below in the Learn documentation.



class HotReloadHandler : ICommunityToolkitHotReloadHandler
{
    public async void OnHotReload(IReadOnlyList<Type> types)
    {   
        if (Application.Current?.Windows is null)
        {
            return;
        }

        foreach (var window in Application.Current.Windows)
        {
            if (window.Page is not Page currentPage)
            {
                return; 
            }

            await AlertReloadViaWindowOverlay(window);

            // I do other things here like calling a Build method on a ContentPage
        }
    }

    private async Task AlertReloadViaWindowOverlay(Window window)
    {
        var overlay = window.Overlays.FirstOrDefault();

        if (overlay is null)
        {
            overlay = new WindowOverlay(window);
        }

        await window.Dispatcher.DispatchAsync(() =>
        {
            overlay.AddWindowElement(new HotReloadElement());
            window.AddOverlay(overlay);
        });

        await Task.Delay(5000);

        await window.Dispatcher.DispatchAsync(() =>
        {
            overlay.RemoveWindowElements();
            window.RemoveOverlay(overlay);
        });
    }

    public class HotReloadElement : IWindowOverlayElement
    {
        public bool Contains(Point point) => true;

        public void Draw(ICanvas canvas, RectF dirtyRect)
        {
            canvas.FillColor = Colors.Purple;
            canvas.FillCircle(dirtyRect.Width / 2, dirtyRect.Height / 2, 150);
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

Wrapping with a Ribbon

Once I had confidence I could draw something on the screen, it was time to make that something a ribbon with text. Once again I leaned on my Copilot. While I got a rectangle filled with a color and text above it, it was not at all in the right location. After a few zillion iterations and even doing some math on paper, I got the result I was after.

early mistakes of drawing the ribbon

Why a few zillion iterations? Cuz coordinate systems are tricky between global and local, and then adding in translation of the position and rotation things got Doctor Strange. This took me right back to being in my early 30s making some funky Flash animation at 3am hoping I'd be done before the sun came up (my lifetime record was about 50/50 on that score).



public void Draw(ICanvas canvas, RectF dirtyRect)
{
    Debug.WriteLine("Drawing DebugOverlay");
    Debug.WriteLine($"Width: {dirtyRect.Width}, Height: {dirtyRect.Height}");

    // Define the dimensions of the ribbon
    float ribbonWidth = 130;
    float ribbonHeight = 25;

    // Calculate the position of the ribbon in the lower right corner
    float ribbonX = dirtyRect.Right - (ribbonWidth * 0.25f);
    float ribbonY = dirtyRect.Bottom - (ribbonHeight + (ribbonHeight * 0.05f));

    // Translate the canvas to the start point of the ribbon
    canvas.Translate(ribbonX, ribbonY);

    // Save the current state of the canvas
    canvas.SaveState();

    // Rotate the canvas 45 degrees
    canvas.Rotate(-45);

    // Draw the ribbon background
    canvas.FillColor = _ribbonColor;
    PathF ribbonPath = new PathF();
    ribbonPath.MoveTo(-ribbonWidth / 2, -ribbonHeight / 2);
    ribbonPath.LineTo(ribbonWidth / 2, -ribbonHeight / 2);
    ribbonPath.LineTo(ribbonWidth / 2, ribbonHeight / 2);
    ribbonPath.LineTo(-ribbonWidth / 2, ribbonHeight / 2);
    ribbonPath.Close();
    canvas.FillPath(ribbonPath);

    // Draw the text
    canvas.FontColor = Colors.White;
    canvas.FontSize = 12;
    canvas.Font = new Microsoft.Maui.Graphics.Font("ArialMT", 800, FontStyleType.Normal);
    canvas.DrawString("DEBUG", 
        new RectF(
            (-ribbonWidth / 2), 
            (-ribbonHeight / 2) + 2, 
            ribbonWidth, 
            ribbonHeight), 
        HorizontalAlignment.Center, VerticalAlignment.Center);


    // Restore the canvas state
    canvas.RestoreState();
}


Enter fullscreen mode Exit fullscreen mode

Success or Success?

Around the time I was in 7th grade, my parents' VCR in their bedroom stopped working. Before they could throw it out I convinced them to let me try to fix it. I had zero confidence and even less knowledge of electronics to do the job, but I had curiosity. Opening the case on the floor of their bedroom, I closely inspected the circuit board and various inner workings to get an idea of how this thing worked. I hoped I might spot where things were going wrong. I don't recall what other tools I had with me, but I certainly know I had a screw driver. Why? Because I learned that a screwdriver touching the circuit board of a 1980's VCR that was PLUGGED IN would short and scare the crap out of me as I fell back from the smoking box. Gone was any chance of fixing the beast. A definitive failure, this short adventure still taught me plenty: a success.

Dare I Share?

I was still undecided as I was noodling on this curiosity about what I would blog for my birthday installment of #MauiUIJuly. If I was to include the ribbon example, I thought I should make it a bit more interesting and useful. I came up with a few more requirements:

  • the developer should be able to choose the background color
  • a user should be able to tap the ribbon and toggle between DEBUG and the version of .NET MAUI with which the app was built
  • the developer should be able to consume this as a NuGet and configure this in the MauiProgram

Ribbon background color

Introducing a background color customization was pretty easily accomplished by adding a parameter to the constructor of the DebugRibbonElement. I wanted make this optional though, so I tried setting a default color of purple to it. The compiler complained I shouldn't be allowed to code, or something similar...I don't recall the exact error message, so I turned to my Copilot for an answer. By making the default null and then providing the default within the constructor, I was once more on good terms with the compiler.



public DebugRibbonElement(WindowOverlay overlay, Color ribbonColor = null)
{
    _overlay = overlay;
    _ribbonColor = ribbonColor ?? Colors.MediumPurple;
}


Enter fullscreen mode Exit fullscreen mode

Tap, toggle, profit

In "graphics land" there is no such thing as a gesture recognizer or click events on controls to handle. This is the wild wild west of points and hit testing. Thankfully, I discovered that WindowOverlay provides a Tapped event I could handle.



private void DebugOverlay_Tapped(object? sender, WindowOverlayTappedEventArgs e)
{
    Debug.WriteLine("Tapped");
    if (_debugRibbonElement.Contains(e.Point))
    {
        // do things
    }
    else
    {
        // ignore things
        Debug.WriteLine("Tapped outside of _debugRibbonElement");
    }

}


Enter fullscreen mode Exit fullscreen mode

Now the tricky part was implementing the Contains method on the overlay element. The tricky part was translating the local coordinates to global so I was comparing the same system. Additionally, the rotation of the ribbon canvas threw off the "hit area" I wanted. To solve this I used an old Flash solution where I would make the hit area an invisible rectangle that was more generous than the visible art.

Before drawing on the canvas and translating and rotating, I made a RectF to store the location of the ribbon using the global coordinates.



private RectF _backgroundRect;

public void Draw(ICanvas canvas, RectF dirtyRect)
{
    Debug.WriteLine("Drawing DebugOverlay");

    // Define the dimensions of the ribbon
    float ribbonWidth = 130;
    float ribbonHeight = 25;

    // Calculate the position of the ribbon in the lower right corner
    float ribbonX = dirtyRect.Right - (ribbonWidth * 0.25f);
    float ribbonY = dirtyRect.Bottom - (ribbonHeight + (ribbonHeight * 0.05f));

    _backgroundRect = new RectF((dirtyRect.Right - 100), (dirtyRect.Bottom - 80), 100, 80);
    // canvas.FillColor = Colors.Black;
    // canvas.FillRectangle(_backgroundRect);

    // do the drawing
}


Enter fullscreen mode Exit fullscreen mode

You'll note that I had some different numbers in the rect than you might expect given the width and height of the ribbon. The explanation is that I don't really care to exactly match the size of it, I only care to specify the correct area to hit test. To complete this more easily, I drew the rectangle on the canvas, nudged it around, and then commented it out (see the code above).

To complete the Contains method was then very simple:



public bool Contains(Point point) 
{
    return _backgroundRect.Contains(point);
}


Enter fullscreen mode Exit fullscreen mode

To toggle between "DEBUG" and the .NET MAUI version, such as "8.0.70", I needed to supply the text and use it in the drawing. This isn't like a control where I can use data binding, so I chose to do what often is done in graphics drawing - redraw it. The easiest way to do that is to remove and add a new element.



private string _labelText;

public string LabelText{
    get { return _labelText; }
}

public DebugRibbonElement(WindowOverlay overlay, string labelText = "DEBUG", Color ribbonColor = null)
{
    _overlay = overlay;
    _ribbonColor = ribbonColor ?? Colors.MediumPurple;
    _backgroundRect = new RectF();
    _labelText = labelText;    
}


Enter fullscreen mode Exit fullscreen mode

Back in the overlay, I could complete the tap handler for when I have a hit.



private void DebugOverlay_Tapped(object? sender, WindowOverlayTappedEventArgs e)
{
    if (_debugRibbonElement.Contains(e.Point))
    {
        bool debugMode = _debugRibbonElement.LabelText.Contains("DEBUG");

        Debug.WriteLine($"Tapped on _debugRibbonElement {debugMode}");
        this.RemoveWindowElement(_debugRibbonElement);

        _debugRibbonElement = new DebugRibbonElement(this, 
            labelText: (debugMode) ? _mauiVersion : "DEBUG",
            ribbonColor:_ribbonColor);

        this.AddWindowElement(_debugRibbonElement);
    }
    else
    {
        // The tap is not on the _debugRibbonElement
        Debug.WriteLine("Tapped outside of _debugRibbonElement");
    }
}


Enter fullscreen mode Exit fullscreen mode

Consume and configure

To share this, I packaged it using the plugin template our dear Gerald provides. I nuked all the classes in the project, and just added what I needed, including the builder extension for the developer to configure the ribbon. In the extension I append to the mapping of the WindowHandler. Since this is all cross-platform code here, it works for all platforms.



public static class MauiProgramExtensions
{
    public static MauiAppBuilder UseDebugRibbon(this MauiAppBuilder builder, Color ribbonColor = null)
    {
        builder.ConfigureMauiHandlers(handlers =>
        {
            #if DEBUG
            WindowHandler.Mapper.AppendToMapping("AddDebugOverlay", (handler, view) =>
            {
                Debug.WriteLine("Adding DebugOverlay");
                var overlay = new DebugOverlay(handler.VirtualView, ribbonColor);
                handler.VirtualView.AddOverlay(overlay);
            });
            #endif          
        });       

        return builder;
    }
}


Enter fullscreen mode Exit fullscreen mode


public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();
    builder
        .UseMauiApp<App>()
        .UseDebugRibbon(Color.FromArgb("#FF3300"))
        .ConfigureFonts(fonts =>
        {
            fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
        });

    return builder.Build();
}


Enter fullscreen mode Exit fullscreen mode

Plugin.Maui.DebugOverlay

Plugin.Maui.DebugOverlay provides a simple ribbon to indicate the app is running in Debug mode.

screenshot of a mac app with the ribbon

Install Plugin

NuGet

Available on NuGet.

Install with the dotnet CLI: dotnet add package Plugin.Maui.DebugOverlay, or through the NuGet Package Manager in Visual Studio.

Supported Platforms


























Platform Minimum Version Supported
iOS 11+
macOS 10.15+
Android 5.0 (API 21)
Windows 11 and 10 version 1809+

Usage

Enable the plugin in your MauiProgram.cs and provide your preferred color.

.UseDebugRibbon(Colors.Orange)
Enter fullscreen mode Exit fullscreen mode

For exmaple:

public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .UseDebugRibbon(Colors.Orange)
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
            });
        builder.Services
Enter fullscreen mode Exit fullscreen mode

Oops and What?!

It took me a few times publishing the NuGet to get a working version, which ended up being that all my code was inside the conditional compile DEBUG, yet the NuGet was shipping a RELEASE build. Hahaha.

I tested with Windows to make sure I hadn't interferred with the XAML Live Preview adorners, and that appears to be fine. But I noticed that resizing the Window on Windows loses the ribbon, and it won't come back. Oops.

On Android, I tested 4 different apps and it did not appear in 2 of them. Why? No idea yet.

On iOS and macOS it works consistently. Yay!

Chase your curiosity

From writing apps for companies you'll never have heard of, to others that are household names (hmm, do those NDAs ever expire...), and now working as a Product Manager on the .NET team at Microsoft I've come to embrace curiosity and the perpetual learning that it fuels.

When I start to wonder about something, I try (more often) to indulge my curiosity rather than ignoring it, discovering where it leads. Going down dark alleys and off beaten paths is one of my favorite things to do when traveling, and the same applies to coding. I really enjoy getting lost, since it just might lead to my next adventure.

Chase your curiosity and let me know where it leads!

💖 💪 🙅 🚩
davidortinau
David Ortinau

Posted on July 29, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related