Featured image of post Criando um jogo Quem é esse Pokémon para Android com .NET MAUI e Blazor Hybrid
Picture by Daniel Genezini

Criando um jogo Quem é esse Pokémon para Android com .NET MAUI e Blazor Hybrid

Usando componentes Blazor para construir um app mobile Android

Introdução Link to this section

O Blazor foi inicialmente desenvolvido para criar aplicações web interativas usando C#, mas agora pode ser usado em muitas plataformas quando combinado com o .NET MAUI.

Neste post, mostrarei como usar o Blazor Hybrid para desenvolver um jogo Quem é esse Pokémon para Android.

Jogo Quem é esse Pokémon

O que é MAUI? Link to this section

O MAUI (Multi-platform App UI) é um framework multiplataforma para criar apps nativos para mobile e desktop com .NET.

Usando o MAUI, podemos criar apps que rodam em múltiplas plataformas (Windows, MacOS, Android e iOS) com a mesma base de código (e algum código específico para cada plataforma, se necessário).

O que é Blazor Hybrid? Link to this section

O Blazor Hybrid permite executar componentes Blazor nativamente em apps para mobile e desktop usando uma Web View com acesso total às capacidades do dispositivo. Não é necessário um servidor para renderizar os componentes e o WebAssembly não é envolvido.

Instalando o MAUI Link to this section

O MAUI não vem com o Visual Studio ou o .NET SDK por padrão. Precisamos instalar sua workload manualmente usando o comando dotnet workload install:

dotnet workload install maui
ℹ️
O processo de instalação pode levar algum tempo para completar.

Após a instalação, os templates do MAUI aparecerão no Visual Studio. Usaremos o .NET MAUI Blazor Hybrid App.

Template .NET MAUI Blazor Hybrid App

Os componentes Link to this section

O app é composto principalmente pelos quatro componentes marcados na imagem e descritos abaixo.

Componentes do app

  • WhosThatPokemon: Renderiza os outros componentes + lógica para selecionar um Pokémon aleatório e verificar se o Pokémon selecionado pelo usuário está correto;
  • WhosThatPokemonData: Renderiza a imagem e o nome do Pokémon;
  • PokemonSelector: Componente para buscar e selecionar um Pokémon;
  • WhosThatPokemonResult: Mostra se o Pokémon selecionado era o correto.

Criando um serviço para armazenar as informações do Pokémon Link to this section

Primeiro, criei um serviço para obter a lista de Pokémon de uma API pública (PokéAPI) usando o Refit e armazená-la em cache localmente para ser usada por qualquer componente. Essas informações são estáticas, então não há necessidade de acessar a API toda vez.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
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");
    }
}

E adicionei-o como singleton no container de injeção de dependência:

builder.Services.AddSingleton<PokemonDataService>();

Instalando componentes MudBlazor Link to this section

O app usa a biblioteca MudBlazor, uma biblioteca de componentes open-source gratuita para Blazor construída inteiramente com C# (não são componentes wrappers de JavaScript). A lista de componentes e como usá-los pode ser vista aqui.

As instruções de instalação podem mudar para versões mais recentes, então linkarei a documentação oficial.

Configurando o layout do app Link to this section

O template de app Blazor Hybrid vem com um app MAUI renderizando uma página de componente Index.razor.

Vamos editá-lo para renderizar o componente WhosThatPokemon que criaremos:

1
2
3
@page "/"

<WhosThatPokemon></WhosThatPokemon>

Então, vamos modificar o MainLayout.razor removendo o menu:

1
2
3
4
5
6
7
8
9
@inherits LayoutComponentBase

<MudThemeProvider />

<div class="page">
    <main>
        @Body
    </main>
</div>

E adicionar algumas personalizações no app.css:

1
2
3
4
5
body {
    overflow-y: scroll;
    background-color: red;
}
...

Criando os componentes Link to this section

Agora, vamos criar os componentes em uma pasta Components.

WhosThatPokemonData Link to this section

Componente WhosThatPokemonData

WhosThatPokemonData.razor Link to this section

Aqui verificamos a variável Show para decidir se devemos mostrar a imagem e o nome do Pokémon.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<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 Link to this section

Aqui temos a “mágica” para esconder a imagem do Pokémon usando filter: brightness(0).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
.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 Link to this section

Sem lógica, apenas parâmetros usados para renderizar o componente.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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 Link to this section

Componente PokemonSelector

PokemonSelector.razor Link to this section

Usei o componente MudAutocomplete do MudBlazor. O valor selecionado é vinculado à propriedade SelectedPokemon.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@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 Link to this section

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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)
    {
        // se o texto for nulo ou vazio, mostra a lista completa
        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 Link to this section

Componente WhosThatPokemonResult

WhosThatPokemonResult.razor Link to this section

Nada de especial aqui, este componente apenas mostra o resultado após selecionar um Pokémon.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<div class="container">
    @if (Show)
    {
        @if (Correct)
        {
            <div class="game-result-correct">
                Correto!
            </div>
        }
        else
        {
            <div class="game-result-wrong">
                Desculpe, não é
            </div>
        }
    }
</div>

WhosThatPokemonResult.razor.cs Link to this section

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
using Microsoft.AspNetCore.Components;

namespace BlazorHybridApp.Components;

public partial class WhosThatPokemonResult
{
    [Parameter]
    public bool Show { get; init; }
    [Parameter]
    public bool Correct { get; init; }
}

WhosThatPokemon Link to this section

Componente WhosThatPokemon

WhosThatPokemon.razor Link to this section

Este componente usa todos os outros componentes, orquestrando seus valores de parâmetros e eventos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<div class="container">
    @if (_loading)
    {
        <Loader></Loader>
    }
    else
    {
        <div class="title">
            Quem é esse 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">Confirmar</MudButton>

        <WhosThatPokemonResult Correct="_correct" Show="_showResult"></WhosThatPokemonResult>

        <MudButton class="confirm-button" Variant="Variant.Filled" @onclick="ShowNextAsync">Próximo</MudButton>
    }
</div>

WhosThatPokemon.razor.css Link to this section

Aqui temos que usar ::deep para definir as propriedades CSS para o MudButton. Isso é necessário porque o isolamento de CSS do Blazor só funciona para elementos diretamente abaixo da hierarquia de componentes no DOM.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
.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 Link to this section

Nos métodos OnInitializedAsync e ShowNextAsync, selecionamos um Pokémon aleatório que é passado para os outros componentes.

No método Confirm, verificamos se o Pokémon selecionado é o correto e passamos o resultado para o componente WhosThatPokemonResult.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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();
    }    
}

Outras personalizações Link to this section

Cor da barra de status Link to this section

Para alterar a cor da barra de status do app, precisamos criar uma tag style e definir os elementos android:statusBarColor e android:navigationBarColor no arquivo Platforms/Android/Resources/values/colors.xml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<?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>

Então, defina a propriedade Theme do atributo Activity para o nome do estilo (@style/MainTheme, neste exemplo) no arquivo MainActivity.cs:

1
2
3
4
[Activity(Theme = "@style/MainTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
}

Executando o app Link to this section

Como as aplicações MAUI rodam em múltiplas plataformas, podemos executar o app no Windows para depurar ou fazer testes mais rápidos. Basta selecionar Windows Machine no menu Executar:

Executando no Windows

💡
Recomendo usar um dispositivo Android físico ao executar/depurar no Android, pois é muito mais rápido que rodar em um emulador. Basta conectar o dispositivo ao computador usando um cabo USB e os drivers ADB instalados, e ele aparecerá no menu Executar:

Executar em dispositivo Android

Código fonte completo Link to this section

Repositório GitHub

💬 Like or have something to add? Leave a comment below.
Ko-fi
GitHub Sponsor
Licensed under CC BY-NC-SA 4.0
Built with Hugo
Theme Stack designed by Jimmy