The answer from @simon-mourier was the key for me in finally getting this done.
I created a TiledContentControl that has a ContentControl in front of the tiled background, and which reloads its bitmap image when the TileUriString property is changed (e.g. due to a binding).
There are also properties TileWidth, TileHeight to control the drawn size of the tile bitmap, as well as AlignRight and AlignBottom to make the bitmap align with the right edge or bottom edge instead of the left or top edge. The alignment parameters are useful to get a seamless continuity between two TiledContentControls that are right next to each other.
I am providing this back to the community with thanks for all of the help I've gotten on various coding challenges in the past. Note: I have done some basic testing but not extensive testing.
The key nuget packages used are Microsoft.Graphics.Win2D 1.0.4 and Microsoft.WindowsAppSDK 1.2. There are some interesting coding challenges that I discuss in a comment in the code. For example the need to prevent memory leakage when subscribing to Win2D C++ events from WinUI3 C# code.
Here is TiledContentControl.xaml:
<UserControl
x:Class="Z.Framework.TiledContentControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:win2d="using:Microsoft.Graphics.Canvas.UI.Xaml"
mc:Ignorable="d"
Padding="0"
>
<Grid
RowDefinitions="*"
ColumnDefinitions="*"
>
<win2d:CanvasControl
x:Name="CanvasControl"
Grid.Row="0"
Grid.Column="0"
>
</win2d:CanvasControl>
<ContentPresenter
Name="ContentPresenter"
Grid.Row="0"
Grid.Column="0"
Background="Transparent"
Foreground="{x:Bind Foreground, Mode=OneWay}"
HorizontalContentAlignment="{x:Bind HorizontalContentAlignment, Mode=OneWay}"
VerticalContentAlignment="{x:Bind VerticalContentAlignment, Mode=OneWay}"
Padding="{x:Bind Padding, Mode=OneWay}"
Content="{x:Bind Content, Mode=OneWay}"
>
</ContentPresenter>
</Grid>
</UserControl>
Here is TiledContentControl.xaml.cs:
using Microsoft.Graphics.Canvas;
using Microsoft.Graphics.Canvas.Brushes;
using Microsoft.Graphics.Canvas.UI;
using Microsoft.Graphics.Canvas.UI.Xaml;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Markup;
using System;
using System.Diagnostics;
using System.Numerics;
using System.Threading.Tasks;
using Windows.Foundation;
namespace Z.Framework
{
/// <summary>
/// A control that has a tiled (repeating) bitmap background behind a content control.
///
/// Setting the TileUriString will change the tiled bitmap. Setting the drawing parameters
/// (TileWidth, TileHeight, AlignRight, AlignBottom) will scale the bitmap or offset it so
/// that it is right or bottom aligned.
/// </summary>
[ContentProperty(Name="Content")]
public sealed partial class TiledContentControl : UserControl
{
#region Discussion
// There are a number of necessary objectives to achieve the Win2D tiling with post-Load updates.
// Goal: to trigger an async load-resources when a resource-related property of the control
// changes. This is accomplished by calling StartLoadingResources when the TileUriString changes.
// Goal: cancel any resource loads that are in progress when the new load is requested.
// This is done in StartNewLoadResourcesTaskAndCleanupOldTaskAsync.
// To do it, one must store the resource-loading task (LoadResourcesTask).
// Goal: to store the resources that have been loaded, and dispose them timely.
// The LoadResourcesTask contains the loaded resources in the Result property.
// They are kept around indefinitely, except if we start a new resource load task
// then any resources in the old load task are disposed. Also, when loading several
// resources, if one of the resource loads fails then we dispose of the others.
// The CanvasResourcesRecord and LoadResourcesAsync provide a generalizable way of
// storing resources in the task result.
// Goal: make sure that any exceptions from resource creation are thrown to Win2D, so that
// Win2D can handle device-lost events (which includes Win2D triggering a new CreateResources).
// It is accomplished by only throwing load-resource exceptions from the Win2d draw handler.
// Goal: prevent Draw from being called before resources are loaded. Resource loads that are
// triggered by Win2D go through the CreateResources event handler, allowing the use of
// CanvasCreateResourcesEventArgs.TrackAsyncAction which will postpone the Draw call -- not
// until the resources are loaded but at least while the load task is started. A Draw
// callback may then occur before the load completes, but then when the load completes
// it will invalidate the CanvasControl and another Draw callback will occur.
// It does not appear to be necessary from a Win2D perspective to prevent Draw calls
// while subsequent (post-CreateResources) resource loads are being done.
// Goal: to prevent memory leaks due to .NET not being able to detect the reference cycle
// between the main control and the CanvasControl. This is accomplished by only subscribing
// to CanvasControl events while the main control is loaded.
// References:
// https://microsoft.github.io/Win2D/WinUI2/html/M_Microsoft_Graphics_Canvas_UI_CanvasCreateResourcesEventArgs_TrackAsyncAction.htm
// https://stackoverflow.com/questions/74527783/repeating-brush-or-tile-of-image-in-winui-3-composition-api
// https://microsoft.github.io/Win2D/WinUI2/html/RefCycles.htm
// https://english.r2d2rigo.es/
// https://microsoft.github.io/Win2D/WinUI3/html/M_Microsoft_Graphics_Canvas_UI_CanvasCreateResourcesEventArgs_TrackAsyncAction.htm
// https://learn.microsoft.com/en-us/windows/win32/direct2d/tile
#endregion
#region ctor
public TiledContentControl()
{
this.InitializeComponent();
this.Loaded += this.TiledContentControl_Loaded; // OK, same lifetime
this.Unloaded += this.TiledContentControl_Unloaded; // OK, same lifetime
}
private void TiledContentControl_Loaded(object sender, RoutedEventArgs e)
{
this.CanvasControl.Draw += this.CanvasControl_Draw; // OK, matched in Unloaded
this.CanvasControl.CreateResources += this.CanvasControl_CreateResources;
}
private void TiledContentControl_Unloaded(object sender, RoutedEventArgs e)
{
this.CanvasControl.Draw -= this.CanvasControl_Draw;
this.CanvasControl.CreateResources -= this.CanvasControl_CreateResources;
}
#endregion
#region CanvasResourcesRecord, LoadResourcesAsync, LoadResourcesTask
private record class CanvasResourcesRecord(
CanvasBitmap TileBitmap,
CanvasImageBrush TileBrush
): IDisposable
{
public void Dispose()
{
this.TileBitmap.Dispose();
this.TileBrush.Dispose();
}
}
static private async Task<CanvasResourcesRecord> LoadResourcesAsync(CanvasControl canvasControl, string tileUriString)
{
object[] resources = new object[2];
try {
Uri tileUri = new Uri(tileUriString);
Task<CanvasBitmap> loadTileBitmap = CanvasBitmap.LoadAsync(canvasControl, tileUri).AsTask();
CanvasBitmap tileBitmap = await loadTileBitmap;
resources[0] = tileBitmap;
CanvasImageBrush tileBrush = new CanvasImageBrush(canvasControl, tileBitmap);
tileBrush.ExtendX = CanvasEdgeBehavior.Wrap;
tileBrush.ExtendY = CanvasEdgeBehavior.Wrap;
resources[1] = tileBrush;
} catch {
// Cleanup from partial/incomplete creation
foreach (object? resource in resources) {
(resource as IDisposable)?.Dispose();
}
throw;
}
canvasControl.Invalidate(); // now that resources are loaded, we trigger an async Draw.
return new CanvasResourcesRecord(
TileBitmap: (CanvasBitmap)resources[0],
TileBrush: (CanvasImageBrush)resources[1]
);
}
private Task<CanvasResourcesRecord>? LoadResourcesTask {
get { return this._loadResourcesTask; }
set { this._loadResourcesTask = value; }
}
private Task<CanvasResourcesRecord>? _loadResourcesTask;
#endregion
#region CanvasControl_CreateResources
private void CanvasControl_CreateResources(CanvasControl sender, CanvasCreateResourcesEventArgs args)
{
Debug.Assert(sender == this.CanvasControl);
args.TrackAsyncAction(this.StartNewLoadResourcesTaskAndCleanupOldTaskAsync().AsAsyncAction());
}
#endregion
#region StartLoadingResources, StartNewLoadResourcesTaskAndCleanupOldTaskAsync
private void StartLoadingResources()
{
if (this.CanvasControl.IsLoaded) {
Task _ = this.StartNewLoadResourcesTaskAndCleanupOldTaskAsync();
}
}
private async Task StartNewLoadResourcesTaskAndCleanupOldTaskAsync()
{
// Start new task, if the necessary input properties are available.
string? tileUriString = this.TileUriString;
Task<CanvasResourcesRecord>? oldTask = this.LoadResourcesTask;
if (tileUriString != null) {
this.LoadResourcesTask = LoadResourcesAsync(this.CanvasControl, tileUriString);
} else {
this.LoadResourcesTask = null;
}
// Cleanup old task.
if (oldTask != null) {
oldTask.AsAsyncAction().Cancel();
try {
await oldTask;
} catch {
// ignore exceptions from the cancelled task
} finally {
if (oldTask.IsCompletedSuccessfully) {
oldTask.Result.Dispose();
}
}
}
}
#endregion
#region CanvasControl_Draw, ActuallyDraw
private void CanvasControl_Draw(CanvasControl sender, CanvasDrawEventArgs args)
{
Debug.Assert(sender == this.CanvasControl);
if (!this.DrawingParameters.AreFullyDefined) { return; }
if (!this.DrawingParameters.AreValid) { throw new InvalidOperationException($"Invalid drawing parameters (typically width or height)."); }
Task<CanvasResourcesRecord>? loadResourcesTask = this.LoadResourcesTask;
if (loadResourcesTask == null) { return; }
if (loadResourcesTask.IsCompletedSuccessfully) {
CanvasResourcesRecord canvasResources = loadResourcesTask.Result;
this.ActuallyDraw( args, canvasResources);
} else if (loadResourcesTask.IsFaulted) {
// Throw exceptions to Win2D, for example DeviceLostException resulting in new CreateResoures event
loadResourcesTask.Exception?.Handle(e => throw e);
} else {
return;
}
}
private void ActuallyDraw( CanvasDrawEventArgs args, CanvasResourcesRecord canvasResources)
{
Debug.Assert(this.DrawingParameters.AreFullyDefined && this.DrawingParameters.AreValid);
Debug.Assert(this.DrawingParameters.AlignRight != null && this.DrawingParameters.AlignBottom != null);
CanvasControl canvasControl = this.CanvasControl;
float scaleX = (float)(this.DrawingParameters.TileWidth / canvasResources.TileBitmap.Bounds.Width);
float scaleY = (float)(this.DrawingParameters.TileHeight / canvasResources.TileBitmap.Bounds.Height);
float translateX = ((bool)this.DrawingParameters.AlignRight) ? (float)((canvasControl.RenderSize.Width % this.DrawingParameters.TileWidth) - this.DrawingParameters.TileWidth) : (float)0;
float translateY = ((bool)this.DrawingParameters.AlignBottom) ? (float)((canvasControl.RenderSize.Height % this.DrawingParameters.TileHeight) - this.DrawingParameters.TileHeight) : (float)0;
Matrix3x2 transform = Matrix3x2.CreateScale( scaleX, scaleY);
transform.Translation = new Vector2(translateX, translateY);
canvasResources.TileBrush.Transform = transform;
Rect rectangle = new Rect(new Point(), canvasControl.RenderSize);
args.DrawingSession.FillRectangle(rectangle, canvasResources.TileBrush);
}
#endregion
#region Content
new public UIElement? Content {
get { return (UIElement?)this.GetValue(ContentProperty); }
set { this.SetValue(ContentProperty, value); }
}
new public static DependencyProperty ContentProperty { get; } = DependencyProperty.Register(nameof(TiledContentControl.Content), typeof(UIElement), typeof(TiledContentControl), new PropertyMetadata(default(UIElement)));
#endregion
#region TileUriString
public string? TileUriString {
get { return (string?)this.GetValue(TileUriStringProperty); }
set { this.SetValue(TileUriStringProperty, value); }
}
public static readonly DependencyProperty TileUriStringProperty = DependencyProperty.Register(nameof(TiledContentControl.TileUriString), typeof(string), typeof(TiledContentControl), new PropertyMetadata(default(string), new PropertyChangedCallback(OnTileUriStringChanged)));
private static void OnTileUriStringChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
{
TiledContentControl @this = (TiledContentControl)sender;
@this.StartLoadingResources();
}
#endregion
#region TileWidth, TileHeight, AlignRight, AlignBottom; OnDrawingParameterChanged, DrawingParametersRecord, DrawingParameters
public double TileWidth {
get { return (double)this.GetValue(TileWidthProperty); }
set { this.SetValue(TileWidthProperty, value); }
}
public static readonly DependencyProperty TileWidthProperty = DependencyProperty.Register(nameof(TileWidth), typeof(double), typeof(TiledContentControl), new PropertyMetadata(double.NaN, new PropertyChangedCallback(OnDrawingParameterChanged)));
public double TileHeight {
get { return (double)this.GetValue(TileHeightProperty); }
set { this.SetValue(TileHeightProperty, value); }
}
public static readonly DependencyProperty TileHeightProperty = DependencyProperty.Register(nameof(TileHeight), typeof(double), typeof(TiledContentControl), new PropertyMetadata(double.NaN, new PropertyChangedCallback(OnDrawingParameterChanged)));
public bool? AlignRight {
get { return (bool?)this.GetValue(AlignRightProperty); }
set { this.SetValue(AlignRightProperty, value); }
}
public static readonly DependencyProperty AlignRightProperty = DependencyProperty.Register(nameof(AlignRight), typeof(bool?), typeof(TiledContentControl), new PropertyMetadata(default(bool?), new PropertyChangedCallback(OnDrawingParameterChanged)));
public bool? AlignBottom {
get { return (bool?)this.GetValue(AlignBottomProperty); }
set { this.SetValue(AlignBottomProperty, value); }
}
public static readonly DependencyProperty AlignBottomProperty = DependencyProperty.Register(nameof(AlignBottom), typeof(bool?), typeof(TiledContentControl), new PropertyMetadata(default(bool?), new PropertyChangedCallback(OnDrawingParameterChanged)));
private static void OnDrawingParameterChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
{
TiledContentControl @this = (TiledContentControl)sender;
@this.DrawingParameters = new DrawingParametersRecord(@this.TileWidth, @this.TileHeight, @this.AlignRight, @this.AlignBottom);
@this.CanvasControl.Invalidate(); // trigger an async redraw using the new parameters.
}
private record struct DrawingParametersRecord(
double TileWidth,
double TileHeight,
bool? AlignRight,
bool? AlignBottom
)
{
public bool AreFullyDefined => !double.IsNaN(this.TileWidth) && !double.IsNaN(this.TileHeight) && this.AlignBottom != null && this.AlignRight != null;
public bool AreValid => this.TileWidth > 0 && this.TileHeight > 0;
}
private DrawingParametersRecord DrawingParameters { get; set; }
#endregion
}
}