Building a Who's that Pokémon game for Android with .NET MAUI and Blazor Hybrid

Daniel Genezini - Jul 12 '23 - - Dev Community

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.

Who's that Pokémon game

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
Enter fullscreen mode Exit fullscreen mode

ℹ️ 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.

.NET MAUI Blazor Hybrid App Template

The components

The app is composed mainly of the four components marked in the image and described below.

App components

  • 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");
    }
}
Enter fullscreen mode Exit fullscreen mode

And added it as a singleton in the dependency injection container:

builder.Services.AddSingleton<PokemonDataService>();
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Then, let's modify the MainLayout.razor removing the menu:

@inherits LayoutComponentBase

<MudThemeProvider />

<div class="page">
    <main>
        @Body
    </main>
</div>
Enter fullscreen mode Exit fullscreen mode

And add some customizations in the app.css:

body {
    overflow-y: scroll;
    background-color: red;
}
...
Enter fullscreen mode Exit fullscreen mode

Creating the components

Now, let's create the components in a Components folder.

WhosThatPokemonData

WhosThatPokemonData Component

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

PokemonSelector

PokemonSelector Component

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>
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

WhosThatPokemonResult

WhosThatPokemonResult Component

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>
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

WhosThatPokemon

WhosThatPokemon Component

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
    }    
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
{
}
Enter fullscreen mode Exit fullscreen mode

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:

Running on Windows

💡 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:

Run on Android Device

Full source code

GitHub repository

Related

Real-time charts with Blazor, SignalR and ApexCharts

Streaming currency prices with SignalR

blog.genezini.com

Liked this post?

I post extra content in my personal blog. Click here to see.

Follow me

References and Links

. . . . . . . . . . . . . . . . . . .