App is the Root Component in the Blazor UI. This article shows how to add Dynamic Routing, Layouts and RouteViews to App.
Overview
App
is the Blazor UI root component. This article looks at how it works and demonstrates how to:
- add Dynamic Layouts - change the default layout at runtime
- add Dynamic Routes - add and remove extra routes at runtime
- add Dynamic RouteViews - change the
RouteView
component directly without Routing
Code and Examples
The repository for this project is here, and is based on my Blazor AllInOne Template.
You can view a demo of the components running on my Blazor.Database
site at https://cec-blazor-database.azurewebsites.net/ from the highlighted links.
The Blazor Application
App
is normally defined in App.razor. The same component is used in both Web Assembly and Server contexts.
In the Web Assembly context, the SPA startup page contains an element placeholder which is replaced when Program
starts in the Web Assembly context.
....
<body>
<div id="app">Loading...</div>
...
</body>
The code line that defines the replacement in Program
is:
builder.RootComponents.Add<App>("#app");
In the Server context, App
is declared directly as a Razor component in the Razor markup. It gets pre-rendered by the server and then updated by the Blazor Server client in the browser.
...
<body>
<component type="typeof(Blazor.App)" render-mode="ServerPrerendered" />
...
</body>
The App Component
The App
code is shown below. It's a standard Razor component, inheriting from ComponentBase
.
Router
is the local root component and sets AppAssembly
to the assembly containing Program
. On initialization, it trawls Assembly
for all classes with a Route
attribute and registers with the NavigationChanged
event on the NavigationManager
Service. On a navigation event, it tries to match the navigation Url to a route. If it finds one, it renders the Found
render fragment, otherwise it renders NotFound
.
<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
RouteView
is declared within Found
. RouteData
is set to the router's current routeData
object and DefaultLayout
set to an application Layout Type
. RouteView
renders an instance of RouteData.Type
as a component within either a page specific layout or the default layout, and applies any parameters in RouteData.RouteValues
.
NotFound
contains a LayoutView
component, specifying a layout to render any child content in.
RouteViewService
RouteViewService
is the state management service for the new components. It's registered in the WASM and Server Services. The Server version can be either a Singleton or Scoped, depending on the application needs. You could have two separate services to manage application and user contexts separately.
public class RouteViewService
{
....
}
In the Server, it's added to Startup
in ConfigServices
.
services.AddSingleton<RouteViewService>();
In the Web Assembly context, it's added to Program
.
builder.Services.AddScoped<RouteViewService>();
RouteViewManager
RouteViewManager
replaces RouteView
.
It's implements RouteView
's functionality. It's too large to show in its entirety so we'll look at the key functionality in sections.
When a routing event occurs, RouteViewManager.RouteData
is updated and Router
re-rendered. The Renderer
calls SetParametersAsync
on RouteViewManager
, passing the updated Parameters. SetParametersAsync
checks it has a valid RouteData
, sets _ViewData
to null
and renders the component. _ViewData
is set to null
to ensure the component loads the route. A valid ViewData
object has precedence over a valid RouteData
object in the render process.
public await Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
if (RouteData == null)
{
throw new InvalidOperationException($"The {nameof(RouteView)}
component requires a non-null value for the parameter {nameof(RouteData)}.");
}
this._ViewData = null;
await this.RenderAsync();
}
Render
uses InvokeAsync
to ensure the render
event is run on the correct thread context. _RenderEventQueued
ensures there's only only one render
event in the Renderer
's queue.
public async Task RenderAsync() => await InvokeAsync(() =>
{
if (!this._RenderEventQueued)
{
this._RenderEventQueued = true;
_renderHandle.Render(_renderDelegate);
}
}
);
For those curious, InvokeAsync
looks like this:
protected Task InvokeAsync(Action workItem) => _renderHandle.Dispatcher.InvokeAsync(workItem);
RouteViewManager
s content is built as a set of components, each defined within a RenderFragment
.
_renderDelegate
defines the local root component, cascading itself and adding the _layoutViewFragment
fragment as it's ChildContent
.
private RenderFragment _renderDelegate => builder =>
{
_RenderEventQueued = false;
builder.OpenComponent<CascadingValue<RouteViewManager>>(0);
builder.AddAttribute(1, "Value", this);
builder.AddAttribute(2, "ChildContent", this._layoutViewFragment);
builder.CloseComponent();
};
_layoutViewFragment
selects the layout, adds it and sets _renderComponentWithParameters
as its ChildContent
.
private RenderFragment _layoutViewFragment => builder =>
{
Type _pageLayoutType =
RouteData?.PageType.GetCustomAttribute<LayoutAttribute>()?.LayoutType
?? RouteViewService.Layout
?? DefaultLayout;
builder.OpenComponent<LayoutView>(0);
builder.AddAttribute(1, nameof(LayoutView.Layout), _pageLayoutType);
builder.AddAttribute(2, nameof(LayoutView.ChildContent), _renderComponentWithParameters);
builder.CloseComponent();
};
_renderComponentWithParameters
selects the view/route component to render and adds it with the supplied parameters. A valid view takes precedence over a valid route.
private RenderFragment _renderComponentWithParameters => builder =>
{
Type componentType = null;
IReadOnlyDictionary<string, object> parameters = new Dictionary<string, object>();
if (_ViewData != null)
{
componentType = _ViewData.ViewType;
parameters = _ViewData.ViewParameters;
}
else if (RouteData != null)
{
componentType = RouteData.PageType;
parameters = RouteData.RouteValues;
}
if (componentType != null)
{
builder.OpenComponent(0, componentType);
foreach (var kvp in parameters)
{
builder.AddAttribute(1, kvp.Key, kvp.Value);
}
builder.CloseComponent();
}
else
{
builder.OpenElement(0, "div");
builder.AddContent(1, "No Route or View Configured to Display");
builder.CloseElement();
}
};
Dynamic Layouts
Out-of-the-box, Blazor layouts are defined and fixed at compile time. @Layout
is Razor talk that gets transposed when the Razor is pre-compiled to:
[Microsoft.AspNetCore.Components.LayoutAttribute(typeof(MainLayout))]
[Microsoft.AspNetCore.Components.RouteAttribute("/")]
[Microsoft.AspNetCore.Components.RouteAttribute("/index")]
public partial class Index : Microsoft.AspNetCore.Components.ComponentBase
....
To change Layouts dynamically, we use RouteViewService
to store the layout. It can be set from any component that injects the service.
public class RouteViewService
{
public Type Layout { get; set; }
....
}
_layoutViewFragment
in RouteViewManager
chooses the layout - RouteViewService.Layout
is set above the default layout in precedence.
private RenderFragment _layoutViewFragment => builder =>
{
Type _pageLayoutType =
RouteData?.PageType.GetCustomAttribute<LayoutAttribute>()?.LayoutType
?? RouteViewService.Layout
?? DefaultLayout;
builder.OpenComponent<LayoutView>(0);
builder.AddAttribute(1, nameof(LayoutView.Layout), _pageLayoutType);
builder.AddAttribute(2, nameof(LayoutView.ChildContent), _renderComponentWithParameters);
builder.CloseComponent();
};
Changing in the layout is demonstrated in the demo pages.
Dynamic Routing
Dynamic Routing is a little more complicated. Router
is a sealed box, so it's take it or re-write it. Unless you must, don't re-write it. We're not looking to change existing routes, just add and remove new dynamic routes.
Routes are defined at compile time and are used internally within the Router
Component.
RouteView
Razor Pages are labelled like this:
@page "/"
@page "/index"
This is Razor talk, and gets transposed into the following in the C# class when pre-compiled.
[Microsoft.AspNetCore.Components.RouteAttribute("/")]
[Microsoft.AspNetCore.Components.RouteAttribute("/index")]
public partial class Index : Microsoft.AspNetCore.Components.ComponentBase
.....
When Router
initializes, it trawls any assemblies provided and builds a route dictionary of component/route pairs.
You can get a list of route attribute components like this:
static public IEnumerable<Type>
GetTypeListWithCustomAttribute(Assembly assembly, Type attribute)
=> assembly.GetTypes().Where(item =>
(item.GetCustomAttributes(attribute, true).Length > 0));
On initial render, the Router
registers a delegate with the NavigationManager.LocationChanged
event. This delegate looks up routes and triggers render events on the Router
. If it finds a route, it renders Found
which renders our new RouteViewManager
. RouteViewManager
builds out the Layout and adds a new instance of the component defined in RouteData
.
When it doesn't find a route, what happens depends on the IsNavigationIntercepted
property of the LocationChangedEventArgs
provided by the event:
True
if it intercepts navigation in the DOM - anchors, etc. True
if a UI component calls its NavigateTo
method and sets ForceLoad
False
if a UI component calls its NavigateTo
method and sets ForceLoad
If we can avoid causing a hard navigation event in Router
, we can add a component in NotFound
to handle additional dynamic routing. Not too difficult, it is our code! There's an enhanced NavLink
control to help control navigation - covered later. In the event of a hard navigation event, routing will still work, but the application reloads. Any rogue navigation events should be detected and fixed during testing.
CustomRouteData
CustomRouteData
holds the information needed to make routing decisions. The class looks like this with inline detailed explanations.
public class CustomRouteData
{
public RouteData RouteData { get; private set; }
public Type PageType { get; set; }
public string RouteMatch { get; set; }
public SortedDictionary<string, object> ComponentParameters
{ get; set; } = new SortedDictionary<string, object>();
public bool IsMatch(string url)
{
var match = Regex.Match(url, this.RouteMatch,RegexOptions.IgnoreCase);
if (match.Success)
{
var dict = new Dictionary<string, object>();
if (match.Groups.Count >= ComponentParameters.Count)
{
var i = 1;
foreach (var pars in ComponentParameters)
{
string matchValue = string.Empty;
if (i < match.Groups.Count)
matchValue = match.Groups[i].Value;
var ts = new TypeSwitch()
.Case((int x) =>
{
if (int.TryParse(matchValue, out int value))
dict.Add(pars.Key, value);
else
dict.Add(pars.Key, pars.Value);
})
.Case((float x) =>
{
if (float.TryParse(matchValue, out float value))
dict.Add(pars.Key, value);
else
dict.Add(pars.Key, pars.Value);
})
.Case((decimal x) =>
{
if (decimal.TryParse(matchValue, out decimal value))
dict.Add(pars.Key, value);
else
dict.Add(pars.Key, pars.Value);
})
.Case((string x) =>
{
dict.Add(pars.Key, matchValue);
});
ts.Switch(pars.Value);
i++;
}
}
this.RouteData = new RouteData(this.PageType, dict);
}
return match.Success;
}
public bool IsMatch(string url, out RouteData routeData)
{
routeData = this.RouteData;
return IsMatch(url);
}
}
For those interested, TypeSwitch
looks like this (thanks to cdiggins on StackOverflow for the code):
public class TypeSwitch
{
public TypeSwitch Case<T>(Action<T> action) { matches.Add(typeof(T),
(x) => action((T)x)); return this; }
private Dictionary<Type, Action<object>> matches =
new Dictionary<Type, Action<object>>();
public void Switch(object x) { matches[x.GetType()](x); }
}
Updates to the RouteViewService
The updated sections in RouteViewService
are shown below. Routes
holds the list of custom routes - it's deliberately left open for customization.
public List<CustomRouteData> Routes { get; private set; } = new List<CustomRouteData>();
public bool GetRouteMatch(string url, out RouteData routeData)
{
var route = Routes?.FirstOrDefault(item => item.IsMatch(url)) ?? null;
routeData = route?.RouteData ?? null;
return route != null;
}
The RouteNotFoundManager Component
RouteNotFoundManager
is a simple version of RouteViewManager
.
SetParametersAsync
is called when the component loads. It gets the local Url, calls GetRouteMatch
on RouteViewService
, and renders the component. If there's no layout, it just renders the ChildContent
.
public Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
var url = $"/{NavManager.Uri.Replace(NavManager.BaseUri, "")}";
if (RouteViewService.GetRouteMatch(url, out var routedata))
_routeData = routedata;
if (_pageLayoutType == null)
_renderHandle.Render(ChildContent);
else
_renderHandle.Render(_ViewFragment);
return Task.CompletedTask;
}
_ViewFragment
either renders a RouteViewManager
, setting RouteData
if it finds a custom route, or the contents of RouteNotFoundManager
.
private RenderFragment _ViewFragment => builder =>
{
if (_routeData != null)
{
builder.OpenComponent<RouteViewManager>(0);
builder.AddAttribute(1, nameof(RouteViewManager.DefaultLayout), _pageLayoutType);
builder.AddAttribute(1, nameof(RouteViewManager.RouteData), _routeData);
builder.CloseComponent();
}
else
{
builder.OpenComponent<LayoutView>(0);
builder.AddAttribute(1, nameof(LayoutView.Layout), _pageLayoutType);
builder.AddAttribute(2, nameof(LayoutView.ChildContent), this.ChildContent);
builder.CloseComponent();
}
};
Switching the RouteView Without Routing
Switching the RouteView
without routing has several applications. These are some I've used:
- Hide direct access to a page. It can only be accessed within the application.
- Multipart forms/processes with a single entry point. The state of the saved form/process dictates which form gets loaded.
- Context dependant forms or information. Login/logout/signup is a good example. The same Url but with a different routeviews loaded depending on the context.
ViewData
The equivalent to RouteData
.
public class ViewData
{
public Type ViewType { get; set; }
public Type LayoutType { get; set; }
public Dictionary<string, object>
ViewParameters { get; private set; } = new Dictionary<string, object>();
public ViewData(Type viewType, Dictionary<string, object> viewValues = null)
{
if (viewType == null) throw new ArgumentNullException(nameof(viewType));
this.ViewType = viewType;
if (viewValues != null) this.ViewParameters = viewValues;
}
}
All functionality is implemented in RouteViewManager
.
RouteViewManager
First some properties and fields.
[Parameter] public int ViewHistorySize { get; set; } = 10;
public ViewData ViewData
{
get => this._ViewData;
protected set
{
this.AddViewToHistory(this._ViewData);
this._ViewData = value;
}
}
public SortedList<DateTime, ViewData> ViewHistory { get; private set; } =
new SortedList<DateTime, ViewData>();
public ViewData LastViewData
{
get
{
var newest = ViewHistory.Max(item => item.Key);
if (newest != default) return ViewHistory[newest];
else return null;
}
}
public bool IsCurrentView(Type view) => this.ViewData?.ViewType == view;
public bool HasView => this._ViewData?.ViewType != null;
private ViewData _ViewData { get; set; }
Next, a set of LoadViewAsync
methods to provide a variety of ways to load a new view. The main method sets the internal viewData
field and calls Render
to re-render the component.
public await Task LoadViewAsync(ViewData viewData = null)
{
if (viewData != null) this.ViewData = viewData;
if (ViewData == null)
{
throw new InvalidOperationException($"The {nameof(RouteViewManager)}
component requires a non-null value for the parameter {nameof(ViewData)}.");
}
await this.RenderAsync();
}
public async Task LoadViewAsync(Type viewtype)
=> await this.LoadViewAsync(new ViewData(viewtype, new Dictionary<string, object>()));
public async Task LoadViewAsync<TView>(Dictionary<string, object> data = null)
=> await this.LoadViewAsync(new ViewData(typeof(TView), data));
We have already seen _renderComponentWithParameters
. With a valid _ViewData
object, it renders the component using _ViewData
.
private RenderFragment _renderComponentWithParameters => builder =>
{
Type componentType = null;
IReadOnlyDictionary<string, object> parameters = new Dictionary<string, object>();
if (_ViewData != null)
{
componentType = _ViewData.ViewType;
parameters = _ViewData.ViewParameters;
}
else if (RouteData != null)
{
componentType = RouteData.PageType;
parameters = RouteData.RouteValues;
}
if (componentType != null)
{
builder.OpenComponent(0, componentType);
foreach (var kvp in parameters)
{
builder.AddAttribute(1, kvp.Key, kvp.Value);
}
builder.CloseComponent();
}
else
{
builder.OpenElement(0, "div");
builder.AddContent(1, "No Route or View Configured to Display");
builder.CloseElement();
}
};
RouteNavLink
RouteNavLink
is an enhanced NavLink
control. The code is a direct copy with a small amount of added code. It doesn't inherit because NavLink
is a black box. It ensures navigation is through the NavigationManager
rather than Html anchor links and provides direct access to RouteView
loading. The code is in the Repo - it's too long to reproduce here.
Example Pages
The application has RouteViews/Pages to demonstrate the new components. You can review the source code in the Repo. You can also see the pages on the Demo Site.
RouteViewer.razor
https://cec-blazor-database.azurewebsites.net/routeviewer
This demonstrates:
- Adding routes dynamically to the Application. Choose a page to add a custom route for, add a route name and click Go To Route.
- Loading a
RouteView
without navigation. Choose a Page and click on Go To View. The page is displayed, but the Url doesn't change! Confusing, but it demos the principle. - Changing the default Layout. Click on Red Layout and the layout will change to red. Basic
FetchData
has a specific layout defined so it will use the original layout. Click on Normal Layout to change back.
Form.Razor
https://cec-blazor-database.azurewebsites.net/form
This demonstrates a multipart form. There are four forms:
Form.Razor
the base and first form Form2.Razor
the second form - inherits from the first form Form3.Razor
the third form - inherits from the first form Form4.Razor
the result form - inherits from the first form
The forms link to data in the WeathForecastService
which maintains the form state. Try leaving the form part way through and then returning. State
is preserved while the SPA session is maintained.
Wrap Up
Hopefully, I've demonstrated the principles you can use to build the extra functionality into the core Blazor framework. None of the components are finished articles. Use them and develop them as you wish.
If you're reading this article a long time into the future, check here for the latest version.
History
- 13th April, 2021: Initial version