This article demonstrates a way of dynamic UI generation when you don't know all types of controls upfront. This is a well-known challenge for extendable content management systems or dynamic forms frameworks when embedding of new controls to page generation logic is restricted.
Microsoft Blazor - Dynamic Content
Introduction
Good day! Welcome to the continuation of my previous blog "Microsoft Blazor - Dynamic content". In this post, I would like to demonstrate how to create a True dynamic page that can generate and bind controls that the page is unaware of. This is an important feature because, as I described in my previous blog post, the dynamic content is generated using a switch
statement where all available controls should be added.
You may notice that sometimes I use "controls" and sometimes "components" in my blog posts. Please do not be confused – the terms are interchangeable and they are absolutely the same things. All it means is UI control and both terms are used by the developer community.
User Story #2: A Dynamic Generation With Custom Controls
- Add support of custom controls to dynamic UI generation
- Custom controls can be located in a separate assembly for dynamic assembly loading
- Custom controls should accept two mandatory parameters:
ControlDetails
Control and Dictionary<string, string>
Values
Implementation - Razor
I will take my previous solution to start with, I copied all the code to a new folder and renamed the solution file, so the final resulting code is stored separately from code from the previous blog (story #1). Again, you can download code from my GitHub page.
Let's start with changes to the Counter.razor file, we will need to add a case where Type
of control is unknown and generate this control:
default:
var customComponent = GetCustomComponent(control.Type);
RenderFragment renderFragment = (builder) =>
{
builder.OpenComponent(0, customComponent);
builder.AddAttribute(0, "Control", control);
builder.AddAttribute(0, "Values", Values);
builder.CloseComponent();
};
<div>
@renderFragment
</div>
break;
This code uses the RenderTreeBuilder
class to do the custom rendering. We are expected to supply the component type - not the text name of the component but a real .NET type, and then we supply as many component parameters as we want. Because user story #2 specifies 2 mandatory parameters, we supply only them.
Now we will need to implement a new method: GetCustomComponent
that should find the .NET type of the rendered control (component) by name somehow. Of course, we will use dependency injection for that, but before coding it, we need to think about the possibility to store custom controls in a separate library.
If we store the controls in a separate library, we will probably need to implement the type-resolution logic in the same library (to have access to control's .NET types), and if we do it in the most elegant way, we will use an interface for that (let's name it IComponentTypeResolver
), putting the type-resolution logic to a service that implements this interface. So IComponentTypeResolver
interface should be visible to the type-resolution service.
At the same time, IComponentTypeResolver
should be visible from our dynamic page to be able to consume it, and when we want to consume an interface from two different assemblies that do not have explicit dependencies - we need to create a shared assembly and put the interface there.
Implementation - Libraries
So let's create a Razor
component library first:
By default, it will create the library using .NET Standard 2.0, so change it to version 2.1:
I believe that Microsoft uses .NET Standard instead of .NET Core for purpose because Blazor WebAssembly
can be built only on .NET Standard and if you want to reuse your controls in WebAssembly
in the future, it is better to use the .NET Standard framework.
Now, we need to create a shared assembly and it should be usable from the .NET Standard library that we just created:
Don't forget to change the framework from .NET Standard 2.0 to version 2.1 and add project references from the main application and from the Razor
library to the Shared library.
Now we can implement the interface IComponentTypeResolver
, let's add new items to the Shared library:
using System;
namespace DemoShared
{
public interface IComponentTypeResolver
{
Type GetComponentTypeByName(string name);
}
}
Now we can use this interface from the dynamic razor page to find the control type by name, and we need to inject IComponentTypeResolver
at the top of the file:
...
@inject DemoShared.IComponentTypeResolver _componentResolverService
...
private Type GetCustomComponent(string name)
{
return _componentResolverService.GetComponentTypeByName(name);
}
...
Thus, the resulting code of Counter.razor
page will look like:
@page "/counter"
@inject ControlService _controlService
@inject DemoShared.IComponentTypeResolver _componentResolverService
@foreach (var control in ControlList)
{
@if (control.IsRequired)
{
<div>@(control.Label)*</div>
}
else
{
<div>@control.Label</div>
}
@switch (control.Type)
{
case "TextEdit":
<input @bind-value="@Values[control.Label]" required="@control.IsRequired" />
break;
case "DateEdit":
<input type="date" value="@Values[control.Label]"
@onchange="@(a => ValueChanged(a, control.Label))"
required="@control.IsRequired" />
break;
default:
var customComponent = GetCustomComponent(control.Type);
RenderFragment renderFragment = (builder) =>
{
builder.OpenComponent(0, customComponent);
builder.AddAttribute(0, "Control", control);
builder.AddAttribute(0, "Values", Values);
builder.CloseComponent();
};
<div>
@renderFragment
</div>
break;
}
}
<br />
<button @onclick="OnClick">Submit</button>
@code
{
private List<ControlDetails> ControlList;
private Dictionary<string, string> Values;
protected override async Task OnInitializedAsync()
{
ControlList = _controlService.GetControls();
Values = ControlList.ToDictionary(c => c.Label, c => "");
}
void ValueChanged(ChangeEventArgs a, string label)
{
Values[label] = a.Value.ToString();
}
string GetValue(string label)
{
return Values[label];
}
private void OnClick(MouseEventArgs e)
{
// send your Values
}
private Type GetCustomComponent(string name)
{
return _componentResolverService.GetComponentTypeByName(name);
}
}
Now the solution can be compiled and run, but it will throw an exception complaining that _componentResolverService
cannot be resolved, because it is not registered in dependency injection. We will register the type-resolution service at the final step.
Implementation - Custom Controls
Now let's create custom control, but before doing that, we will need to move ControlDetails.cs to the Shared library because this class should be accessible from the Razor
library too.
The control code will look like:
@namespace DemoRazorClassLibrary
<div class="my-component">
This Blazor component is defined in the <strong>DemoRazorClassLibrary</strong> package.
<input @bind-value="@Values[Control.Label]" required="@Control.IsRequired" />
</div>
@code
{
[Parameter]
public DemoDynamicContent.ControlDetails Control { get; set; }
[Parameter]
public Dictionary<string, string> Values { get; set; }
}
I used @namespace
to explicitly specify the full name of the control type - now it will be DemoRazorClassLibrary.Component1
independently in which folder you will move it, and now we can create a ComponentResolverService
class that will register the created control type in a Dictionary
to be able to quickly find its type by name, whenever the Blazor engine wants to re-render the page.
The control input parameters marked by Parameter
attributes and their names are the same names that we supplied in the dynamic page rendering code.
The last bit is the resolver, it will look like:
using DemoShared;
using System;
using System.Collections.Generic;
using System.Text;
namespace DemoRazorClassLibrary
{
public class ComponentResolverService : IComponentTypeResolver
{
private readonly Dictionary<string, Type> _types = new Dictionary<string, Type>();
public ComponentResolverService()
{
_types["Component1"] = typeof(DemoRazorClassLibrary.Component1);
}
public Type GetComponentTypeByName(string name)
{
return _types[name];
}
}
}
Now if we want to register another custom control, we just need to add a new control Razor file and register its type in the ComponentResolverService
constructor.
Implementation - Running
If we run our solution right now, it will not work because we forgot to register ComponentResolverService
in Dependency Injection. We need to open Startup.cs and add the registration line of code, so the ConfigureServices
method will look like:
public void ConfigureServices(IServiceCollection services)
{
services.AddRazorPages();
services.AddServerSideBlazor();
services.AddSingleton<WeatherForecastService>();
services.AddSingleton<ControlService>();
services.AddSingleton<DemoShared.IComponentTypeResolver,
DemoRazorClassLibrary.ComponentResolverService>();
}
but this is not enough! We also need to add a project reference from the main project to the Razor
library – though we tried to avoid making this reference.
However, we can try moving the Counter.Razor
page to a new assembly, and then it will not be any dependencies between the dynamic page and the custom control.
Alternatively, Dependency Injection registration of ComponentResolverService
can be done by loading assemblies at run time to AppDomain
finding the required type using reflection and registering it. We don't do that now only for simplification.
Here at Pro Coders, we use reflection a lot and maybe in the next blog posts, I will show you how to load components dynamically from an assembly that is not referenced - it is a well-known plug-in practice.
We modify the main project ControlService
stub class to use created custom control Component1
:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace DemoDynamicContent
{
public class ControlService
{
public List<ControlDetails> GetControls()
{
var result = new List<ControlDetails>();
result.Add(new ControlDetails { Type = "TextEdit",
Label = "First Name", IsRequired = true });
result.Add(new ControlDetails { Type = "TextEdit",
Label = "Last Name", IsRequired = true });
result.Add(new ControlDetails { Type = "DateEdit",
Label = "Birth Date", IsRequired = false });
result.Add(new ControlDetails { Type = "Component1",
Label = "Custom1", IsRequired = false });
return result;
}
}
}
All done! Now let's run and see the results:
After filling in controls, I clicked the Submit button, let's see our Values
Dictionary in debug:
As you can see, all the entered values are stored in the Dictionary
and we can save it to a database if needed.
User Story #2 was completed.
Summary
This article demonstrated a way of dynamic UI generation when you don't know all types of controls upfront. This is a well-known challenge for extendable content management systems or dynamic forms frameworks when embedding of new controls to page generation logic is restricted.
Thanks to Microsoft Blazor developers who provided an elegant way for a custom rendering with RenderTreeBuilder
class.
See you next time and thank you for reading.
History
- 16th October, 2020: Initial version