Recently, I've been playing a bit with Blazor and Microsoft Teams apps. I had hard time finding clear explanation how to handle theme changes in MS Teams. As, after spending some time, I figured out the way, I'll share my approach.
The problem
The problem is very simple - when user changes their theme settings in Microsoft Teams, we want to react to that instantly; without app reload. In my case, I want to change BaseLayerLuminance
of FluentDesignSystemProvider
in App.razor
, so all the Fluent UI components have their look updated automatically.
The tricky part is that we want to do that using Blazor.
JavaScript approach
Solution with JavaScript is incredibly simple, found at the bottom of the docs:
// You can register your app to be informed if the theme changes by calling
microsoftTeams.app.registerOnThemeChangeHandler(function(theme) { /* ... */ }).
In Blazor we also have something similar to microsoftTeams
object when we inject @inject MicrosoftTeams MicrosoftTeams
in the component, so that should be pretty simple, right?
No.
Blazor approach
Turns out, Interop.TeamsSDK.MicrosoftTeams
class doesn't expose any app
or registerOnThemeChangeHandler
. After searching a bit it gets even better - there doesn't seem to be any C# SDK doing that. This means we are stuck with JavaScript and JS Interop.
Conceptually, it's rather simple:
- Create .js file which has a function calling
microsoftTeams.app.registerOnThemeChangeHandler
- Import and call the function from
App.razor
- On theme change, the JS function calls C# code
- Update the variable bound to
BaseLayerLuminance
and rerender the component
However, there are some technicalities which make it a bit more complicated than it looks.
Starting code
Code at the beginning looks like that.
-
_luminance
field is used to setBaseLayerLuminance
- after the first render, the theme is grabbed from the context and used to determine
_luminance
-
UpdateLuminance
has this weird async implementation as it's required later by the JS interop
// App.razor
@inject MicrosoftTeams MicrosoftTeams
<FluentDesignSystemProvider AccentBaseColor="#6264A7" BaseLayerLuminance="_luminance">
<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>
</FluentDesignSystemProvider>
@code
{
private float _luminance = 1.0f;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if(firstRender)
{
await MicrosoftTeams.InitializeAsync();
var context = await MicrosoftTeams.GetTeamsContextAsync();
await UpdateLuminance(context.Theme);
}
}
public async Task UpdateLuminance(string theme = "default")
{
await Task.Run(() =>
{
_luminance = theme switch
{
"default" => 1.0f,
"dark" => 0.0f,
"contrast" => 0.5f, // it doesn't make sense, but let's pretend it does
_ => 1.0f
};
});
StateHasChanged();
}
}
Calling JS from Blazor component
That is described pretty accurately in the docs. The examples usually show the functions attached to the window
object. I just prefer to have them exported from the .js
file. Thus, that's my ThemeChange.js
:
// ./wwwroot/js/ThemeChange.js
export function onTeamsThemeChange() {
microsoftTeams.app.registerOnThemeChangeHandler(function (theme) {
console.log('Theme changed to:', theme);
});
}
As I want to update the theme of the whole app, I'll be modifying App.razor
.
First of all, it needs IJSRuntime
to be injected:
@inject IJSRuntime JS
// Rest of the HTML/Code
Then, in the @code
block, we declare IJSObjectReference
field to hold the loaded module and call the function inside it. We load all of that after the first render:
private float _luminance = 1.0f;
private IJSObjectReference? _module;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if(firstRender)
{
await MicrosoftTeams.InitializeAsync();
var context = await MicrosoftTeams.GetTeamsContextAsync();
await UpdateLuminance(context.Theme);
_module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/ThemeChange.js");
await _module.InvokeVoidAsync("onTeamsThemeChange");
}
}
Now, after changing the theme in Teams settings, console should log which theme is currently selected.
Calling C# from JS
At this point, we just want to call UpdateLuminance
from inside the callback of our JS function. Details are described in the docs. We have to follow the instance method way, as we are calling non-static StatusHasChanged
method, so it can't be called from a static context.
Let's start with updating JS:
// ./wwwroot/js/ThemeChange.js
export function onTeamsThemeChange(dotNetHelper) {
microsoftTeams.app.registerOnThemeChangeHandler(function (theme) {
dotNetHelper.invokeMethodAsync('UpdateLuminance', theme);
});
}
Details are described in the docs, but in short, Blazor is going to pass some object which will be used to call the C# method. The second argument to invokeMethodAsync
is just an argument to UpdateLuminance
.
Code in App.razor
will require a bit more changes:
-
Starting a bit bacwards, but we will have to clean up after all the JS Interop fun, so declare that the component is disposable:
@implements IDisposable @inject IJSRuntime JS @inject MicrosoftTeams MicrosoftTeams
-
Then, we have to declare a field to hold reference to the
App
in JavaScript:
private float _luminance = 1.0f; private IJSObjectReference? _module; private DotNetObjectReference<App>? _objRef;
-
This reference is created in overriden
OnInitialized
method:
protected override void OnInitialized() { _objRef = DotNetObjectReference.Create(this); }
-
As our JS function has a parameter now, we need to pass it:
if(firstRender) { await MicrosoftTeams.InitializeAsync(); var context = await MicrosoftTeams.GetTeamsContextAsync(); await UpdateLuminance(context.Theme); _module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/ThemeChange.js"); await _module.InvokeVoidAsync("onTeamsThemeChange", _objRef); }
-
Minor but important thing - our
UpdateLuminance
method has to be decorated withJSInvokable
attribute:
[JSInvokable] public async Task UpdateLuminance(string theme = "default")
-
To finish up, don't forget about disposing:
public void Dispose() { _objRef?.Dispose(); }
Et voilà! Now theme of your Fluent UI controls should change instantly after user changes their settings 🙂
Full code:
// App.razor
@implements IDisposable
@inject IJSRuntime JS
@inject MicrosoftTeams MicrosoftTeams
<FluentDesignSystemProvider AccentBaseColor="#6264A7" BaseLayerLuminance="_luminance">
<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>
</FluentDesignSystemProvider>
@code{
private float _luminance = 1.0f;
private IJSObjectReference? _module;
private DotNetObjectReference<App>? _objRef;
protected override void OnInitialized()
{
_objRef = DotNetObjectReference.Create(this);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if(firstRender)
{
await MicrosoftTeams.InitializeAsync();
var context = await MicrosoftTeams.GetTeamsContextAsync();
await UpdateLuminance(context.Theme);
_module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/ThemeChange.js");
await _module.InvokeVoidAsync("onTeamsThemeChange", _objRef);
}
}
[JSInvokable]
public async Task UpdateLuminance(string theme = "default")
{
await Task.Run(() =>
{
_luminance = theme switch
{
"default" => 1.0f,
"dark" => 0.0f,
"contrast" => 0.5f,
_ => 1.0f
};
});
StateHasChanged();
}
public void Dispose()
{
_objRef?.Dispose();
}
}
// ./wwwroot/js/ThemeChange.js
export function onTeamsThemeChange(dotNetHelper) {
microsoftTeams.app.registerOnThemeChangeHandler(function (theme) {
dotNetHelper.invokeMethodAsync('UpdateLuminance', theme);
});
}