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.
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.
publicpartialclassApp:Application{publicApp(){InitializeComponent();MainPage=newMainPage();}protectedoverrideWindowCreateWindow(IActivationState?activationState){varwindow=base.CreateWindow(activationState);// add an overlaywindow.AddOverlay(newWindowOverlay(window));returnwindow;}}
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:
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.
classHotReloadHandler:ICommunityToolkitHotReloadHandler{publicasyncvoidOnHotReload(IReadOnlyList<Type>types){if(Application.Current?.Windowsisnull){return;}foreach(varwindowinApplication.Current.Windows){if(window.PageisnotPagecurrentPage){return;}awaitAlertReloadViaWindowOverlay(window);// I do other things here like calling a Build method on a ContentPage}}privateasyncTaskAlertReloadViaWindowOverlay(Windowwindow){varoverlay=window.Overlays.FirstOrDefault();if(overlayisnull){overlay=newWindowOverlay(window);}awaitwindow.Dispatcher.DispatchAsync(()=>{overlay.AddWindowElement(newHotReloadElement());window.AddOverlay(overlay);});awaitTask.Delay(5000);awaitwindow.Dispatcher.DispatchAsync(()=>{overlay.RemoveWindowElements();window.RemoveOverlay(overlay);});}publicclassHotReloadElement:IWindowOverlayElement{publicboolContains(Pointpoint)=>true;publicvoidDraw(ICanvascanvas,RectFdirtyRect){canvas.FillColor=Colors.Purple;canvas.FillCircle(dirtyRect.Width/2,dirtyRect.Height/2,150);}}}
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.
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).
publicvoidDraw(ICanvascanvas,RectFdirtyRect){Debug.WriteLine("Drawing DebugOverlay");Debug.WriteLine($"Width: {dirtyRect.Width}, Height: {dirtyRect.Height}");// Define the dimensions of the ribbonfloatribbonWidth=130;floatribbonHeight=25;// Calculate the position of the ribbon in the lower right cornerfloatribbonX=dirtyRect.Right-(ribbonWidth*0.25f);floatribbonY=dirtyRect.Bottom-(ribbonHeight+(ribbonHeight*0.05f));// Translate the canvas to the start point of the ribboncanvas.Translate(ribbonX,ribbonY);// Save the current state of the canvascanvas.SaveState();// Rotate the canvas 45 degreescanvas.Rotate(-45);// Draw the ribbon backgroundcanvas.FillColor=_ribbonColor;PathFribbonPath=newPathF();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 textcanvas.FontColor=Colors.White;canvas.FontSize=12;canvas.Font=newMicrosoft.Maui.Graphics.Font("ArialMT",800,FontStyleType.Normal);canvas.DrawString("DEBUG",newRectF((-ribbonWidth/2),(-ribbonHeight/2)+2,ribbonWidth,ribbonHeight),HorizontalAlignment.Center,VerticalAlignment.Center);// Restore the canvas statecanvas.RestoreState();}
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.
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.
privatevoidDebugOverlay_Tapped(object?sender,WindowOverlayTappedEventArgse){Debug.WriteLine("Tapped");if(_debugRibbonElement.Contains(e.Point)){// do things}else{// ignore thingsDebug.WriteLine("Tapped outside of _debugRibbonElement");}}
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.
privateRectF_backgroundRect;publicvoidDraw(ICanvascanvas,RectFdirtyRect){Debug.WriteLine("Drawing DebugOverlay");// Define the dimensions of the ribbonfloatribbonWidth=130;floatribbonHeight=25;// Calculate the position of the ribbon in the lower right cornerfloatribbonX=dirtyRect.Right-(ribbonWidth*0.25f);floatribbonY=dirtyRect.Bottom-(ribbonHeight+(ribbonHeight*0.05f));_backgroundRect=newRectF((dirtyRect.Right-100),(dirtyRect.Bottom-80),100,80);// canvas.FillColor = Colors.Black;// canvas.FillRectangle(_backgroundRect);// do the drawing}
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:
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.
Back in the overlay, I could complete the tap handler for when I have a hit.
privatevoidDebugOverlay_Tapped(object?sender,WindowOverlayTappedEventArgse){if(_debugRibbonElement.Contains(e.Point)){booldebugMode=_debugRibbonElement.LabelText.Contains("DEBUG");Debug.WriteLine($"Tapped on _debugRibbonElement {debugMode}");this.RemoveWindowElement(_debugRibbonElement);_debugRibbonElement=newDebugRibbonElement(this,labelText:(debugMode)?_mauiVersion:"DEBUG",ribbonColor:_ribbonColor);this.AddWindowElement(_debugRibbonElement);}else{// The tap is not on the _debugRibbonElementDebug.WriteLine("Tapped outside of _debugRibbonElement");}}
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.
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!