Migrating MaterialFrame to MAUI Handlers: A StackBlur Story

HI THERE AGAIN! Sharpnado strikes again my dudes. I have been kind of busy, some deep diving into MaterialFrame internals, and I must say: what started as a simple handler migration turned into a complete Android blur rewrite.

0:00
/0:35

Remember when MaterialFrame had that sweet blur effect using RenderScript? Yeah that was nice... until Android 15 came along with its 16KB page size and decided to completely break it. Like REALLY GOOGLE???

But rejoice! This forced rewrite turned into something way better. Let me tell you about it.

The Handler Migration

So first things first: renderers are dead, long live handlers! I know, we all procrastinated on this migration because the compatibility layer was working just fine thank you very much. But since MAUI 9.0, it's very nice! Time to bite the bullet.

Remember the old renderer pattern? It looked something like this:

protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e)
{
    base.OnElementPropertyChanged(sender, e);
    
    if (e.PropertyName == nameof(MaterialFrame.CornerRadius))
        UpdateCornerRadius();
    else if (e.PropertyName == nameof(MaterialFrame.Elevation))
        UpdateElevation();
    // ... 50 more else ifs
}

Yeah, not fun. Now with handlers it's all nice and declarative:

public static PropertyMapper<MaterialFrame, AndroidMaterialFrameHandler> MaterialFrameMapper = new(Mapper)
{
    [nameof(MaterialFrame.CornerRadius)] = (handler, view) => handler.UpdateCornerRadius(),
    [nameof(MaterialFrame.Elevation)] = (handler, view) => handler.UpdateElevation(),
    [nameof(MaterialFrame.MaterialTheme)] = (handler, view) => handler.UpdateMaterialTheme(),
};

Much better! You can see everything at a glance, it's type-safe, and MAUI can optimize this under the hood.

All platforms migrated:

  • ✅ Android: ContentViewHandler
  • ✅ iOS: ContentViewHandler
  • ✅ MacCatalyst: ContentViewHandler (I just copied the iOS code, don't @ me)
  • ✅ Windows: ViewHandler<MaterialFrame, Grid>

Windows was special because we render to a Grid directly with all the composition shadow stuff. But it works \o/


The Android Drama: RenderScript Is Dead

Here's where it gets interesting. The original Android blur was using RenderScript. You know, that thing Google told us to use and then deprecated? Yeah that one.

It was working great! Fast, smooth, beautiful blurs... and then Pixel 8 users started reporting crashes. Turns out on devices with 16KB page size (Android 15+), RenderScript just... doesn't work anymore. At all. Complete breakage.

So I had a choice:

  1. Wait for a fix that's never coming (deprecated remember?)
  2. Find another way

Guess which one I picked? 🙃


StackBlur to the Rescue

After some research, I found StackBlur by Mario Klingemann. It's an elegant CPU-based blur algorithm that's surprisingly fast.

The beauty of StackBlur? It's pure C#. No native libs, no GPU drivers, no Android version drama. Just good old-fashioned sliding window arithmetic.

private void StackBlurHorizontal(int[] pixels, int w, int h, int radius)
{
    int div = radius + radius + 1;
    
    for (int y = 0; y < h; y++)
    {
        int sumR = 0, sumG = 0, sumB = 0;
        
        // Build initial stack
        for (int x = -radius; x <= radius; x++)
        {
            int pixel = pixels[y * w + Math.Clamp(x, 0, w - 1)];
            sumR += (pixel >> 16) & 0xFF;
            sumG += (pixel >> 8) & 0xFF;
            sumB += pixel & 0xFF;
        }
        
        // Slide the window
        for (int x = 0; x < w; x++)
        {
            pixels[y * w + x] = (sumR / div << 16) | (sumG / div << 8) | (sumB / div);
            // ... update sums
        }
    }
}

Performance? About 15-25ms for a 500x500px image. Not bad for pure C#!

But that's not all...

Making It Buttery Smooth: Double Buffering

15-25ms is not bad, but that's still way too much to run on the UI thread. The app would stutter like crazy during scrolling.

The solution? Move it off-thread! But naive async won't cut it - you need proper buffering or you get tearing and race conditions.

So double buffering:

private Bitmap? _frontBuffer;  // What's displayed right now
private Bitmap? _backBuffer;   // What we're working on
private volatile bool _isProcessing;

public void ProcessBlurAsync(Bitmap source)
{
    if (_isProcessing) return; // One job at a time!
    
    _isProcessing = true;
    
    Task.Run(() =>
    {
        // Process in background
        ApplyStackBlur(_backBuffer, source, radius: 25);
        
        // Swap on UI thread
        MainThread.BeginInvokeOnMainThread(() =>
        {
            (_frontBuffer, _backBuffer) = (_backBuffer, _frontBuffer);
            InvalidateBlurLayer();
            _isProcessing = false;
        });
    });
}

Results: UI thread blocking went from 22ms to 3ms. Frame drops? Gone. Smooth 60 FPS scrolling? Check! ✨

The Lazy Optimization: Change Detection

I was watching the profiler and noticed something: when the user stops scrolling, we're still blurring like crazy. Every frame. For no reason.

The background didn't change, so why reprocess it?

So the world's simplest change detection:

private int ComputeContentHash(Bitmap bitmap)
{
    const int samplePoints = 16; // Sample a 4x4 grid
    int hash = 17;
    
    int stepX = bitmap.Width / 4;
    int stepY = bitmap.Height / 4;
    
    for (int y = 0; y < 4; y++)
    {
        for (int x = 0; x < 4; x++)
        {
            int pixel = bitmap.GetPixel(x * stepX, y * stepY);
            hash = hash * 31 + pixel;
        }
    }
    
    return hash;
}

We sample 16 pixels in a grid pattern, hash them, and compare. If the hash matches the previous frame? Skip the blur entirely!

Results: When content is static, CPU usage drops to 0%. Battery life? Way better. Thermal throttling? Reduced.

iOS and Mac: The Easy Ones

iOS and MacCatalyst were pretty straightforward. Migrated to ContentViewHandler, kept using UIVisualEffectView:

private void EnableBlur()
{
    var blurEffect = UIBlurEffect.FromStyle(GetBlurStyle());
    _blurView = new UIVisualEffectView(blurEffect);
    PlatformView.Layer.InsertSublayer(_blurView.Layer, 0);
}

Native blur is hardware-accelerated, respects system settings, and just works™. Apple got this right.

MacCatalyst? I copy-pasted the iOS handler into the MacCatalyst folder. Don't @ me. 😎

Windows: AcrylicBrush FTW

Windows migration was smooth. Went from ViewRenderer to ViewHandler<MaterialFrame, Grid>:

private void UpdateBlur()
{
    var acrylicBrush = new AcrylicBrush
    {
        TintColor = GetTintColor(),
        FallbackColor = GetFallbackColor(),
    };
    
    _acrylicGrid.Background = acrylicBrush;
}

Windows acrylic just... works. It's performant, it's pretty, and it has fallback for older systems. Thanks Microsoft!

The Numbers (Because We Love Numbers)

So what did we actually achieve here?

Metric Before After Change
Android 15 compatibility 💥 Broken ✅ Works ∞% better
UI thread time 22ms 3ms 86% faster
Static content CPU 100% 0% 100% reduction
Frame rate 30-45 FPS 60 FPS Smooth AF
Blur quality Great Great Still great

Not bad for a "forced rewrite" eh?

What I Learned

Sometimes Breaking Changes Are Good

RenderScript breaking forced me to find a better solution. StackBlur is simpler, more portable, and performs better in practice.

Async + Buffering = Magic

Never block the UI thread. With double buffering, background work becomes seamless.

Don't Compute What You Don't Need

Change detection is such a simple idea but the impact is huge. Always measure first though!

The Handler Pattern Rocks

I was skeptical at first, but the new handler pattern is really nice. More maintainable, more performant, cleaner code.

Installation

Sharpnado.MaterialFrame 2.0 is out with all this goodness:

dotnet add package Sharpnado.MaterialFrame.Maui --version 2.0.0
public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();
    builder
        .UseMauiApp<App>()
        .UseSharpnadoMaterialFrame(loggerEnable: false);
}
<sh:MaterialFrame MaterialTheme="AcrylicBlur"
                  MaterialBlurStyle="Light"
                  CornerRadius="10"
                  Elevation="8">
    <Label Text="Smooth like butter 🧈" />
</sh:MaterialFrame>

BOOM You just achieved smooth 60 FPS blur on all platforms.

Wrapping Up

Migrating to handlers turned into a complete Android blur rewrite, which turned into a performance optimization journey. Sometimes the universe forces you to improve your code.

If you're still using renderers in your MAUI libs, now's the time. MAUI 9 is solid, the handler pattern is mature, your future self will thank you.

Questions or ideas? github.com/roubachof/Sharpnado.MaterialFrame

Happy blurring! ✨