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.
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 ContentPage
s 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;
}
}
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);
}
}
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);
}
}
}
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).
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();
}
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;
}
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");
}
}
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
}
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);
}
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;
}
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");
}
}
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;
}
}
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();
}
Plugin.Maui.DebugOverlay
Plugin.Maui.DebugOverlay
provides a simple ribbon to indicate the app is running in Debug mode.
Install Plugin
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)
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
…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!
Top comments (1)
Hi David Ortinau,
Top, very nice and helpful !
Thanks for sharing.