Introduction
Blazor was initially developed to build interactive web applications using C#, but can now be used in many platforms when used together with .NET MAUI.
In this post, I'll show how to use Blazor Hybrid to develop a Who's that Pokémon game for Android.
What is MAUI?
MAUI (Multi-platform App UI) is a cross-platform framework to create native mobile apps and desktop apps with .NET.
Using MAUI, we can create apps that run on multiple platforms (Windows, MacOS, Android and iOS) with the same codebase (and some specific code for each platform, if necessary).
What is Blazor Hybrid?
Blazor Hybrid allows us to run Blazor components natively in mobile and desktop apps using a Web View with full access to the devices capabilities. There is no need for a server to render the components and WebAssembly isn't involved.
Installing MAUI
MAUI doesn't come with Visual Studio or the .NET SDK by default. We need to install its workload manually using the dotnet workload install
command:
dotnet workload install maui
ℹ️ The installation process may take some time to complete.
After installing, the MAUI templates will show in Visual Studio. We'll use the .NET MAUI Blazor Hybrid App.
The components
The app is composed mainly of the four components marked in the image and described below.
- WhosThatPokemon: Renders the other components + logic to select a random pokemon and check if Pokémon selected by the user is correct;
- WhosThatPokemonData: Renders the Pokémon image and name;
- PokemonSelector: Component to search and select a Pokémon;
- WhosThatPokemonResult: Show if the selected Pokémon was the right one.
Creating a service to store the Pokémon information
First, I created a service to get the list of Pokémon from a public API (PokéAPI) using Refit and cache it locally to be used by any component. This information is static, so there is no need to go to the API every time.
using BlazorHybridApp.Components.Domain;
using BlazorHybridApp.Components.Interfaces.APIEndpoints;
using Microsoft.Extensions.Configuration;
using Refit;
namespace BlazorHybridApp.Components.Services;
public class PokemonDataService
{
private List<PokemonInfo>? _pokemonList;
private readonly IPokeApi _pokeApi;
public PokemonDataService(IConfiguration configuration)
{
_pokeApi ??= RestService.For<IPokeApi>(configuration!["PokeApiBaseUrl"]!);
}
public async Task<List<PokemonInfo>> GetPokemonListAsync()
{
if (_pokemonList == null)
{
var species = await _pokeApi.GetAllPokemonSpecies();
_pokemonList = species.Results
.Select(pokemonSpecie => new PokemonInfo()
{
Id = GetPokemonIdFromUrl(pokemonSpecie.Url),
Name = pokemonSpecie.Name.ToUpperInvariant()
})
.ToList();
}
return _pokemonList;
}
public async Task<PokemonInfo> GetPokemonByIdAsync(int id)
{
_pokemonList ??= await GetPokemonListAsync();
return _pokemonList.Single(a => a.Id == id);
}
public async Task<int> GetPokemonCountAsync()
{
_pokemonList ??= await GetPokemonListAsync();
return _pokemonList.Count;
}
private static int GetPokemonIdFromUrl(string url)
{
return int.Parse(new Uri(url).Segments.LastOrDefault()?.Trim('/') ?? "0");
}
}
And added it as a singleton in the dependency injection container:
builder.Services.AddSingleton<PokemonDataService>();
Installing MudBlazor components
The app uses the MudBlazor library, a free open-source component library for Blazor build entirely with C# (not Javascript wrapped components). The list of components and how to use them can be seen here.
Instructions on how to install may change for newer versions, so I'll link the official docs.
Configuring the app layout
The Blazor Hybrid app template comes with a MAUI app rendering an Index.razor component page.
Let's edit it to render the WhosThatPokemon component we'll create:
@page "/"
<WhosThatPokemon></WhosThatPokemon>
Then, let's modify the MainLayout.razor removing the menu:
@inherits LayoutComponentBase
<MudThemeProvider />
<div class="page">
<main>
@Body
</main>
</div>
And add some customizations in the app.css:
body {
overflow-y: scroll;
background-color: red;
}
...
Creating the components
Now, let's create the components in a Components folder.
WhosThatPokemonData
WhosThatPokemonData.razor
Here we check the Show
variable to decide if we should show the pokemon image and name.
<div class="container">
@if (!Show)
{
<div>
<img src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/@(PokemonId).png"
class="pokemon-image pokemon-image-hidden" />
</div>
<div class="pokemon-name">
<p>?????</p>
</div>
}
else
{
<div>
<img src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/@(PokemonId).png"
class="pokemon-image" />
</div>
<div class="pokemon-name">
<p>@PokemonName (#@PokemonId)</p>
</div>
}
</div>
WhosThatPokemonData.razor.css
Here we have the "magic" to hide the Pokémon image using filter: brightness(0)
.
.container {
font-family: Verdana;
color: black;
}
.pokemon-image {
width: 200px;
height: 200px;
filter: drop-shadow(2px 4px 6px black);
margin: auto;
display: block;
}
.pokemon-image-hidden {
filter: brightness(0) drop-shadow(2px 4px 6px black)
}
.pokemon-name {
font-size: large;
}
WhosThatPokemonData.razor.cs
No logic, just parameters used to render the component.
using Microsoft.AspNetCore.Components;
namespace BlazorHybridApp.Components;
public partial class WhosThatPokemonData
{
[Parameter]
public int PokemonId { get; init; }
[Parameter]
public required string PokemonName { get; init; }
[Parameter]
public bool Show { get; init; }
}
PokemonSelector
PokemonSelector.razor
I used the MudAutocomplete
component from MudBlazor. The value selected is binded to the SelectedPokemon
property.
@using BlazorHybridApp.Components.Domain;
<div class="container">
@if (_pokemon == null)
{
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Width="100%" Height="30px" />
}
else
{
<MudAutocomplete @ref="_selectedPokemon"
T="PokemonInfo" @bind-Value="SelectedPokemon"
ResetValueOnEmptyText="true"
Label="Who's that pokémon?"
ToStringFunc="@(e => e?.Name)"
SearchFunc="@Search" />
}
</div>
PokemonSelector.razor.cs
using BlazorHybridApp.Components.Domain;
using BlazorHybridApp.Components.Services;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace BlazorHybridApp.Components;
public partial class PokemonSelector
{
[Inject]
private PokemonDataService PokemonDataService { get; init; } = default!;
private MudAutocomplete<PokemonInfo> _selectedPokemon = default!;
private List<PokemonInfo>? _pokemon;
public PokemonInfo? SelectedPokemon { get; set; }
public void Clear()
{
_selectedPokemon.Clear();
}
protected override async Task OnInitializedAsync()
{
await GetPokemonListAsync();
}
private Task<IEnumerable<PokemonInfo>?> Search(string value)
{
// if text is null or empty, show complete list
if (string.IsNullOrEmpty(value))
return Task.FromResult(_pokemon?.Take(10));
return Task.FromResult(_pokemon?
.Where(pokemon => pokemon.Name.Contains(value, StringComparison.InvariantCultureIgnoreCase))
.Take(10));
}
protected async Task GetPokemonListAsync()
{
_pokemon = await PokemonDataService.GetPokemonListAsync();
}
}
WhosThatPokemonResult
WhosThatPokemonResult.razor
Nothing fancy here, this component just shows the result after selecting a Pokémon.
<div class="container">
@if (Show)
{
@if (Correct)
{
<div class="game-result-correct">
That's right!
</div>
}
else
{
<div class="game-result-wrong">
Sorry, it's not
</div>
}
}
</div>
WhosThatPokemonResult.razor.cs
using Microsoft.AspNetCore.Components;
namespace BlazorHybridApp.Components;
public partial class WhosThatPokemonResult
{
[Parameter]
public bool Show { get; init; }
[Parameter]
public bool Correct { get; init; }
}
WhosThatPokemon
WhosThatPokemon.razor
This component uses all the other components, orchestrating their parameter values and events:
<div class="container">
@if (_loading)
{
<Loader></Loader>
}
else
{
<div class="title">
Who's that Pokémon?!
</div>
<WhosThatPokemonData PokemonId="_pokemonData.Id" PokemonName="@_pokemonData.Name" Show="_showResult"></WhosThatPokemonData>
<PokemonSelector @ref="_pokemonSelector"></PokemonSelector>
<MudButton @onclick="Confirm" class="confirm-button"
Variant="Variant.Filled">Confirm</MudButton>
<WhosThatPokemonResult Correct="_correct" Show="_showResult"></WhosThatPokemonResult>
<MudButton class="confirm-button" Variant="Variant.Filled" @onclick="ShowNextAsync">Show Next</MudButton>
}
</div>
WhosThatPokemon.razor.css
Here we have to use ::deep
to set the css properties for the MudButton
. This is necessary because Blazor CSS isolation only works for elements directly below its component hierarchy in the DOM.
.container {
border-radius: 3px;
background-color: rgba(255, 255, 255, 0.9);
margin: 15px;
padding: 10px;
text-align: center;
max-width: 280px;
margin-left: auto;
margin-right: auto;
height: calc(100vh - 30px);
}
::deep button.confirm-button {
margin-top: 10px;
background-color: red;
color: white;
margin: 5px;
}
.title {
font-size: large;
}
WhosThatPokemon.razor.cs
In the OnInitializedAsync
and ShowNextAsync
method, we select a random Pokémon that is passed to the other components.
In the Confirm
method, we check if the Pokémon selected is the right one and pass the result to the WhosThatPokemonResult
component.
using BlazorHybridApp.Components.Domain;
using BlazorHybridApp.Components.Services;
using Microsoft.AspNetCore.Components;
namespace BlazorHybridApp.Components;
public partial class WhosThatPokemon
{
[Inject]
private PokemonDataService PokemonDataService { get; init; } = default!;
private PokemonInfo _pokemonData = default!;
private PokemonSelector _pokemonSelector = default!;
private bool _correct;
private bool _showResult;
private bool _loading;
protected override async Task OnInitializedAsync()
{
_pokemonData = await GetRandomPokemon();
}
private async Task<PokemonInfo> GetRandomPokemon()
{
try
{
_loading = true;
var pokemonCount = await PokemonDataService.GetPokemonCountAsync();
Random r = new Random();
var pokemonId = r.Next(1, pokemonCount);
return await PokemonDataService.GetPokemonByIdAsync(pokemonId);
}
finally
{
_loading = false;
}
}
protected void Confirm()
{
_correct = _pokemonSelector?.SelectedPokemon?.Id == _pokemonData?.Id;
_showResult = true;
}
protected async Task ShowNextAsync()
{
_showResult = false;
_pokemonSelector.Clear();
_pokemonData = await GetRandomPokemon();
}
}
Other customizations
Status bar color
To change the status bar color for the app, we have to create a style
tag and define the android:statusBarColor
and android:navigationBarColor
elements in the Platforms/Android/Resources/values/colors.xml
file:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#FF0000</color>
<color name="colorPrimaryDark">#DD0000</color>
<color name="colorAccent">#BB0000</color>
<style name="MainTheme" parent="@style/Maui.SplashTheme">
<item name="android:statusBarColor">#FF0000</item>
<item name="android:navigationBarColor">#FF0000</item>
</style>
</resources>
Then, set the Theme
property of the Activity
attribute to the style name (@style/MainTheme
, in this example) in the MainActivity.cs
file:
[Activity(Theme = "@style/MainTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
}
Running the app
Since MAUI applications run on multiple platforms, we can run the app on Windows to debug or do quicker tests. Just select Windows Machine
from the Run menu:
💡 I recommend using an Android physical device when running/debugging on Android, as it is many times faster than running on an emulator. Just connect the device to the computer using an USB cable and the ADB drivers installed, and it will show up in the Run menu:
Full source code
Related
Liked this post?
I post extra content in my personal blog. Click here to see.