Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,4 +415,5 @@ Work directly for:
| Error Handling | `documentation/dev/error-handling.md` |
| Debugging | `documentation/dev/debugging-methodology.md` |
| NuGet Packages | `documentation/dev/packages.md` |
| Blazor Server/Hybrid Rendering | `documentation/dev/blazor-server-hybrid-rendering.md` |
| Release Notes & API Diffs | `documentation/dev/release-notes-and-api-diffs.md` |
1 change: 1 addition & 0 deletions documentation/dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ C# Wrapper (binding/SkiaSharp/) → P/Invoke → C API (externals/skia/src/c
| [architecture.md](architecture.md) | Three layers, type mappings, call flow, threading |
| [memory-management.md](memory-management.md) | Pointer types, ownership, lifecycle |
| [error-handling.md](error-handling.md) | Error patterns across layers |
| [blazor-server-hybrid-rendering.md](blazor-server-hybrid-rendering.md) | Multi-host Blazor rendering: WASM/Server/Hybrid/Auto, present strategies, JS module structure |

### Contributing
| Document | Description |
Expand Down
444 changes: 444 additions & 0 deletions documentation/dev/blazor-server-hybrid-rendering.md

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions samples/Basic/BlazorHybrid/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# SkiaSharp on Blazor Hybrid (.NET MAUI)

This is the **same application** as the [Blazor WebAssembly](../BlazorWebAssembly) and
[Blazor Server](../BlazorServer) samples, packaged as a **.NET MAUI Blazor Hybrid** mobile app.
The pages, layout, styles, MudBlazor theme and SkiaSharp drawing code are identical — only the
host is different (a native `BlazorWebView` instead of a browser or a server).

It demonstrates [#1194](https://github.com/mono/SkiaSharp/issues/1194): the same `SKCanvasView`
and `SKGLView` components run under Blazor Hybrid because the views detect their host at runtime
(a WebView) and render on the .NET side in-process, presenting frames into the WebView's
`<canvas>`.

## Run

```bash
# Mac (Mac Catalyst)
dotnet build SkiaSharpSample -t:Run -f net10.0-maccatalyst

# Android (device or emulator running)
dotnet build SkiaSharpSample -t:Run -f net10.0-android

# iOS (simulator or device)
dotnet build SkiaSharpSample -t:Run -f net10.0-ios
```

You should see the same three pages as the other samples, via the drawer:

- **CPU** — `SKCanvasView` with gradients, shapes and text.
- **GPU** — `SKGLView` with a continuously animated SkSL shader (on a WebGL-backed canvas).
- **Drawing** — `SKCanvasView` with pointer input and on-demand rendering.

## What differs from the WebAssembly / Server samples

Only the host wiring — the shared pages, layout, scoped CSS, `FpsCounter`, `SamplePage` and
`wwwroot/css/app.css` are byte-identical.

- Scaffolded from the `maui-blazor` template, so all the platform projects (`Platforms/`),
`MauiProgram.cs`, `App.xaml`, `MainPage.xaml` (the `BlazorWebView` hosting `Routes` at `#app`)
and `Resources/` are standard MAUI.
- `MauiProgram.cs` registers `AddMauiBlazorWebView()` and `AddMudServices()`.
- `wwwroot/index.html` is the WebView host page (loads `blazor.webview.js`, MudBlazor and
`css/app.css`).
- **Font loading:** `Home.razor` loads `NotoSans-Regular.ttf` as a **raw app-package asset** via
`FileSystem.OpenAppPackageFileAsync` (no `HttpClient`), since a native WebView has no server to
fetch from. The font is included both as a `Resources/Raw` asset (for the C# canvas text) and
in `wwwroot/fonts` (for the CSS `@font-face`). This is the only line that differs from the
WebAssembly/Server `Home.razor`.
- `Program.DefaultPage` (used by the shared `MainLayout`) is replaced with `App.DefaultPage`
in `App.xaml.cs` — the idiomatic MAUI place and the same approach as the native
[`samples/Basic/Maui`](../Maui) sample. This is the one line that differs in the Hybrid
`MainLayout.razor` from the WebAssembly/Server samples.
- `ValidateXcodeVersion=false` lets the sample build with a newer Xcode than the Apple SDK's exact
recommended version (as the repo's test projects do).

See [documentation/dev/blazor-server-hybrid-rendering.md](../../../documentation/dev/blazor-server-hybrid-rendering.md)
for the design.
16 changes: 16 additions & 0 deletions samples/Basic/BlazorHybrid/SkiaSharpSample/App.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:SkiaSharpSample"
x:Class="SkiaSharpSample.App">
<Application.Resources>
<ResourceDictionary>

<!--
For information about styling .NET MAUI pages
please refer to the documentation:
https://go.microsoft.com/fwlink/?linkid=2282329
-->

</ResourceDictionary>
</Application.Resources>
</Application>
17 changes: 17 additions & 0 deletions samples/Basic/BlazorHybrid/SkiaSharpSample/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace SkiaSharpSample;

public partial class App : Application
{
/// <summary>The page the sample starts on (mirrors the native MAUI sample).</summary>
public static SamplePage DefaultPage { get; set; } = SamplePage.Cpu;

public App()
{
InitializeComponent();
}

protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(new MainPage()) { Title = "SkiaSharpSample" };
}
}
50 changes: 50 additions & 0 deletions samples/Basic/BlazorHybrid/SkiaSharpSample/FpsCounter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using System.Diagnostics;

namespace SkiaSharpSample;

/// <summary>
/// Lightweight frame-rate counter. Call <see cref="Tick"/> once per frame;
/// it returns the measured FPS each time the sampling interval elapses.
/// Also exposes <see cref="ElapsedSeconds"/> so GPU pages can feed shader time
/// from the same clock without a separate <see cref="Stopwatch"/>.
/// </summary>
public sealed class FpsCounter
{
private readonly Stopwatch stopwatch = new();

private int frameCount;
private double lastSampleTime;

/// <summary>
/// Seconds between FPS samples (default 0.5 s).
/// </summary>
public double Interval { get; set; } = 0.5;

/// <summary>
/// Wall-clock seconds since <see cref="Start"/> was called.
/// Useful as a shader iTime uniform.
/// </summary>
public float ElapsedSeconds => (float)stopwatch.Elapsed.TotalSeconds;

public void Start() => stopwatch.Start();

public void Stop() => stopwatch.Stop();

/// <summary>
/// Record one frame. Returns the measured FPS when the sampling
/// interval has elapsed, or <c>null</c> otherwise.
/// </summary>
public double? Tick()
{
frameCount++;
var now = stopwatch.Elapsed.TotalSeconds;
var delta = now - lastSampleTime;
if (delta < Interval)
return null;

var fps = frameCount / delta;
frameCount = 0;
lastSampleTime = now;
return fps;
}
}
133 changes: 133 additions & 0 deletions samples/Basic/BlazorHybrid/SkiaSharpSample/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
@inherits LayoutComponentBase
@inject NavigationManager Navigation

<MudThemeProvider @ref="_themeProvider" @bind-IsDarkMode="_isDarkMode" Theme="_theme" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />

<MudLayout>
<MudAppBar Elevation="1">
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
OnClick="ToggleDrawer" />
<MudText Typo="Typo.h6" Class="ml-2">SkiaSharp</MudText>
<MudSpacer />
<MudTooltip Text="@_themeTooltip">
<MudIconButton Icon="@_themeIcon" Color="Color.Inherit" OnClick="CycleTheme" />
</MudTooltip>
</MudAppBar>
<MudDrawer @bind-Open="_drawerOpen" ClipMode="DrawerClipMode.Always"
Breakpoint="Breakpoint.Md" Elevation="2" Variant="@DrawerVariant.Responsive">
<MudNavMenu Rounded="true" Margin="Margin.Dense" Color="Color.Primary" Class="pa-3">
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Computer">CPU</MudNavLink>
<MudNavLink Href="/gpu" Icon="@Icons.Material.Filled.DeveloperBoard">GPU</MudNavLink>
<MudNavLink Href="/drawing" Icon="@Icons.Material.Filled.Brush">Drawing</MudNavLink>
</MudNavMenu>
</MudDrawer>
<MudMainContent Class="d-flex flex-column" Style="height: 100vh; overflow: hidden;">
<CascadingValue Value="_isDarkMode" Name="IsDarkMode">
@Body
</CascadingValue>
</MudMainContent>
</MudLayout>

@code {
bool _drawerOpen = true;
bool _isDarkMode;
ThemeMode _themeMode = ThemeMode.System;
MudThemeProvider _themeProvider = null!;

readonly MudTheme _theme = new()
{
PaletteLight = new PaletteLight
{
Primary = "#594AE2",
AppbarBackground = "#594AE2",
DrawerBackground = "#f0eef6",
DrawerText = "#424242",
DrawerIcon = "#594AE2",
Background = "#fafafa",
Surface = "#ffffff",
},
PaletteDark = new PaletteDark
{
Primary = "#776be7",
AppbarBackground = "#1e1e2d",
DrawerBackground = "#1a1a27",
Background = "#121218",
Surface = "#1e1e2d",
},
LayoutProperties = new LayoutProperties
{
DefaultBorderRadius = "12px",
},
};

string _themeIcon => _themeMode switch
{
ThemeMode.Light => Icons.Material.Filled.LightMode,
ThemeMode.Dark => Icons.Material.Filled.DarkMode,
_ => Icons.Material.Filled.SettingsBrightness,
};

string _themeTooltip => _themeMode switch
{
ThemeMode.Light => "Light mode (click for dark)",
ThemeMode.Dark => "Dark mode (click for system)",
_ => "System theme (click for light)",
};

protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_isDarkMode = await _themeProvider.GetSystemDarkModeAsync();
await _themeProvider.WatchSystemDarkModeAsync(OnSystemPreferenceChanged);

var route = App.DefaultPage switch
{
SamplePage.Gpu => "/gpu",
SamplePage.Drawing => "/drawing",
_ => null,
};
if (route is not null)
Navigation.NavigateTo(route);

StateHasChanged();
}
}

Task OnSystemPreferenceChanged(bool isDark)
{
if (_themeMode == ThemeMode.System)
{
_isDarkMode = isDark;
StateHasChanged();
}
return Task.CompletedTask;
}

async Task CycleTheme()
{
_themeMode = _themeMode switch
{
ThemeMode.System => ThemeMode.Light,
ThemeMode.Light => ThemeMode.Dark,
ThemeMode.Dark => ThemeMode.System,
_ => ThemeMode.System,
};

_isDarkMode = _themeMode switch
{
ThemeMode.Light => false,
ThemeMode.Dark => true,
_ => await _themeProvider.GetSystemDarkModeAsync(),
};

StateHasChanged();
}

void ToggleDrawer() => _drawerOpen = !_drawerOpen;

enum ThemeMode { System, Light, Dark }
}
12 changes: 12 additions & 0 deletions samples/Basic/BlazorHybrid/SkiaSharpSample/MainPage.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:SkiaSharpSample"
x:Class="SkiaSharpSample.MainPage">

<BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html">
<BlazorWebView.RootComponents>
<RootComponent Selector="#app" ComponentType="{x:Type local:Routes}" />
</BlazorWebView.RootComponents>
</BlazorWebView>

</ContentPage>
9 changes: 9 additions & 0 deletions samples/Basic/BlazorHybrid/SkiaSharpSample/MainPage.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace SkiaSharpSample;

public partial class MainPage : ContentPage
{
public MainPage()
{
InitializeComponent();
}
}
28 changes: 28 additions & 0 deletions samples/Basic/BlazorHybrid/SkiaSharpSample/MauiProgram.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using Microsoft.Extensions.Logging;
using MudBlazor.Services;

namespace SkiaSharpSample;

public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});

builder.Services.AddMauiBlazorWebView();
builder.Services.AddMudServices();

#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
builder.Logging.AddDebug();
#endif

return builder.Build();
}
}
Loading
Loading