Pokédex – Kleines Xamarin.Forms Projekt – Teil 3

Ich habe bereits zwei Teile dieser kleinen Xamarin.Forms Serie in meinem Blog veröffentlicht. Im ersten Beitrag haben wir begonnen unsere Pokédex-App zu erstellen und ein generelles Grundsetup durchgeführt. Im zweiten Beitrag ging es mit der Entwicklung weiter. So haben wir die ersten Services und auch die ersten Calls in Richtung der öffentliche API gemacht und diese Informationen in der App anzeigen. In diesem Beitrag soll es nun darum gehen die Pokémon-Details abzurufen und per inkrementellem Laden auf der Übersichtsseite anzuzeigen.

Den bisherigen Stand könnt ihr über folgenden Download-Button herunterladen.

Wie bereits angekündigt, wollen wir in diesem Beitrag nun die Details von einem Pokémon abrufen. Dafür werfen wir erst einmal einen Blick auf die öffentliche API, welche uns die Daten liefert. Unter der Url pokeapi.co kann man die einzelnen Endpunkte sehen und auch direkt testen.

Wir öffnen nun in unserem bisherigen Projekt die Datei Statics.cs und fügen zunächst die Url zum Abrufen der Pokémon-Details hinzu. Außerdem bereiten wir schon die notwendigen Daten zum Abspeichern in der Datenbank vor. Als vierte Variable speichern wir uns noch die Information, wie viele Pokémon-Details wir in einem Rutsch herunterladen wollen. Dies ist für das inkrementelle Laden notwendig. Damit fügen wir die folgenden vier Variablen hinzu.

public static string PokemonDetailsUri = "pokemon/{0}";

public static string PokemonId = nameof(PokemonId);
public static string PokemonCollectionName = nameof(PokemonCollectionName);

public static int DefaultLimit = 10;

Als nächsten wollen wir das Datenmodell anlegen. Hierzu erstellen wir eine neue Klasse PokemonDetail im Ordner Models. Wir benötigen nicht alle Properties, welche uns von der API zur Verfügung gestellt werden. Daher ist unser Datenmodell deutlich kleiner bzw. kürzer.

public class PokemonDetail
{
    [JsonProperty("id")]
    public int Id { get; set; }

    [JsonProperty("name")]
    public string Name { get; set; }

    [JsonProperty("height")]
    public int Height { get; set; }

    [JsonProperty("weight")]
    public int Weight { get; set; }

    [JsonProperty("sprites")]
    public OtherSprite Sprite { get; set; }

    [JsonProperty("types")]
    public IList<TypeDetails> Types { get; set; }
}

public class OtherSprite
{
    [JsonProperty("other")]
    public OfficialArtwork Image { get; set; }
}

public class OfficialArtwork
{
    [JsonProperty("official-artwork")]
    public Image Artwork { get; set; }
}

public class Image
{
    [JsonProperty("front_default")]
    public string ImagePath { get; set; }
}

public class TypeDetails
{
    [JsonProperty("type")]
    public PokemonType PokemonType { get; set; }
}

public class PokemonDetailDatabaseObject
{
    [BsonId]
    public string Id { get; } = Statics.PokemonId;
    public IList<PokemonDetail> Pokemons { get; set; }
}

Nun können wir uns auch schon um das Interface IPokemonService kümmern und dieses um eine weitere Methode ergänzen, welche uns eine Liste von PokemonDetails zurückliefert. Als Parameter verwenden wir hier ein Offset und ein Limit, so dass wir mit der Hilfe von diesen Parametern das inkrementelle Nachladen umsetzen können.

Task<IList<PokemonDetail>> GetPokemonDetailsAsync(int offset, int limit);

Damit können wir uns auch gleich um die Implementierung kümmern. Da wir nun eine zweite Methode dem Service hinzufügen, welche einen HttpClient benötigen, extrahieren wir die Erstellung des HttpClients in den Konstruktor und nutzen eine private Variable.

private readonly IDatabaseService _databaseService;
private readonly INetworkService _networkService;
private readonly IUriBuilderService _uriBuilderService;
private readonly HttpClient _httpClient;

public PokemonService(IDatabaseService databaseService,
    INetworkService networkService, IUriBuilderService uriBuilderService)
{
    _databaseService = databaseService;
    _networkService = networkService;
    _uriBuilderService = uriBuilderService;

    _httpClient = new HttpClient();
}

Die API liefert leider keine Option, um mehrere Details verschiedener Pokémons abrufen zu können. Daher nutzen wir eine For-Schleife und rufen die Details per Pokémon-ID einzeln ab. Zunächst überprüfen wir, ob wir die gewünschten Informationen bereits in der Datenbank vorliegen haben. Wenn dies der Fall ist, so geben wir diese zurück. Ansonsten führen wir nun die verschiedenen Calls durch und speichern diese Informationen in einer temporären Liste. Anschließend fügen wir die neuen Pokémons der lokalen Datenbank-Liste hinzu und wir speichern diese in der Datenbank ab. Zu guter Letzt geben wir die neue Liste an Pokémons zurück.

public async Task<IList<PokemonDetail>> GetPokemonDetailsAsync(int offset, int limit = 10)
{
    try
    {
        var localData = _databaseService.
            GetById<PokemonDetailDatabaseObject>(Statics.PokemonId,
                Statics.PokemonCollectionName);
        if (localData != null)
        {
            if (localData.Pokemons.Count > offset + limit)
                return localData.Pokemons.Skip(offset).Take(limit).ToList();
        }

        if (!_networkService.HasInternetAccess)
            return null;

        var pokemons = new List<PokemonDetail>();

        for (int i = 1; i <= limit; i++)
        {
            var uri = _uriBuilderService.GetPokemonDetailUri(offset + i);

            var response = await _httpClient.GetAsync(uri);

            if (response.IsSuccessStatusCode)
            {
                var jsonResponse = await response.Content.ReadAsStringAsync();
                var pokemonDetail = 
                    JsonConvert.DeserializeObject<PokemonDetail>(jsonResponse);

                pokemons.Add(pokemonDetail);
            }
        }

        var newPokemons = new List<PokemonDetail>(pokemons);

        if (localData != null)
            pokemons.AddRange(localData.Pokemons);

        _databaseService.Upsert(new PokemonDetailDatabaseObject
            {
                Pokemons = pokemons.OrderBy(p => p.Id).ToList()
            }, Statics.PokemonCollectionName);

        return newPokemons;
    }
    catch (Exception ex)
    {
        Debug.WriteLine($"{GetType().Name} | {nameof(GetPokemonDetailsAsync)} | {ex}");
    }

    return null;
}

Nun können wir schon unser OverviewPageViewModel öffnen und ergänzen zunächst eine Property für IsLoadingData und eine weitere Property für die Liste der Pokémon-Details.

private bool _isLoadingData;
public bool IsLoadingData
{
    get => _isLoadingData;
    set => SetProperty(ref _isLoadingData, value);
}


private ObservableCollection<PokemonDetail> _pokemons;
public ObservableCollection<PokemonDetail> Pokemons
{
    get => _pokemons;
    set => SetProperty(ref _pokemons, value);
}

Außerdem benötigen wir nun noch zwei Commands. Der eine Command dient zum Abrufen der Initialen-Liste und der zweite Command ist zum Nachladen der Daten.

private IAsyncCommand _loadPokemonsCommand;
public IAsyncCommand LoadPokemonsCommand => 
            _loadPokemonsCommand 
            ?? (_loadPokemonsCommand = 
                new AsyncCommand(LoadPokemonsAsync, allowsMultipleExecutions: false));

private IAsyncCommand _loadMorePokemonsCommand;
public IAsyncCommand LoadMorePokemonsCommand => 
            _loadMorePokemonsCommand 
            ?? (_loadMorePokemonsCommand = 
                new AsyncCommand(LoadMorePokemonsAsync, allowsMultipleExecutions: false));

Nun müssen wir noch die beiden Methoden implementieren. Beide nutzen hierfür die neue Methode aus dem PokemonService.

private async Task LoadPokemonsAsync()
{
    IsLoading = true;

    var pokemons = await _pokemonService
                .GetPokemonDetailsAsync(0, Statics.DefaultLimit);

    if (pokemons != null)
    {
        if (Pokemons == null)
            Pokemons = new ObservableRangeCollection<PokemonDetail>(pokemons);
        else
            Pokemons.AddRange(pokemons);
    }

    IsLoading = false;
}

private async Task LoadMorePokemonsAsync()
{
    if (Pokemons == null)
        return;

    IsLoadingData = true;

    var pokemons = await _pokemonService
                .GetPokemonDetailsAsync(Pokemons.Count, Statics.DefaultLimit);
    if (pokemons != null)
        Pokemons.AddRange(pokemons);

    IsLoadingData = false;
}

Ihr werdet merken, dass die AddRange-Methode noch nicht zur Verfügung steht. Dafür müssen wir im Ordner Common ein neue Klasse ObservableCollectionExtensions anlegen und die Methode AddRange selbst implementiert.

public static void AddRange<T>(this ObservableCollection<T> observableCollection, 
    IEnumerable<T> collection)
{
    if (observableCollection == null)
        throw new ArgumentNullException(nameof(observableCollection));

    foreach (var i in collection)
        observableCollection.Add(i);
}

Nun erstellen wir einen neuen Ordner Controls und darin eine ContentView mit dem Namen LoadingControl. Dieses soll einfach nur einen ActivityIndicator beinhalten, welcher über die gesamte Seite angezeigt wird.

<?xml version="1.0" encoding="UTF-8"?>
<ContentView xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="Pokedex.Controls.LoadingControl">

    <ContentView.Content>
        <Grid BackgroundColor="{StaticResource DefaultPageBackgroundColor}"
              VerticalOptions="FillAndExpand"
              HorizontalOptions="FillAndExpand">
            <ActivityIndicator IsRunning="True" />
        </Grid>
    </ContentView.Content>

</ContentView>

Außerdem erstellen wir noch eine weitere ContentView im Ordner Controls. Dieses bekommt den Namen PokemonOverviewControl und soll unser Control zum Anzeigen des Pokémons auf der Übersichtsseite sein.

<?xml version="1.0" encoding="UTF-8"?>
<ContentView xmlns="http://xamarin.com/schemas/2014/forms" 
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="Pokedex.Controls.PokemonOverviewControl"
             x:Name="root">
    
  <ContentView.Content>
        <Frame Style="{StaticResource PokemonFrameStyle}">
            <Grid RowDefinitions="Auto,Auto,Auto">
                <Label Text="{Binding PokemonDetail.Id, 
                    StringFormat='#{0:D3}', Source={x:Reference root}}"
                       Style="{StaticResource PokemonIdLabelStyle}"
                       Grid.Row="0" />

                <Image Source="{Binding PokemonDetail.Sprite.Image.Artwork.ImagePath, 
                    Source={x:Reference root}}"
                       WidthRequest="75"
                       HeightRequest="75"
                       Aspect="AspectFit"
                       Grid.Row="1" />

                <Label Text="{Binding PokemonDetail.Name, 
                    Source={x:Reference root}}"
                       Style="{StaticResource PokemonNameStyle}"
                       Grid.Row="2" />
            </Grid>
        </Frame>
    </ContentView.Content>
</ContentView>

Wir wechseln nun noch in die Code-Behind Datei und fügen noch eine BindableProperty hinzu, so dass wir unser PokemonDetail auch innerhalb des Controls verwenden können.

public static readonly BindableProperty PokemonDetailProperty
    = BindableProperty.Create(nameof(PokemonDetail), typeof(PokemonDetail), 
        typeof(PokemonOverviewControl));

public PokemonDetail PokemonDetail
{
    get => (PokemonDetail)GetValue(PokemonDetailProperty);
    set => SetValue(PokemonDetailProperty, value);
}

Ihr habt euch sicherlich gefragt, wo die Styles herkommen, welche ich in dem Control verwendet habe. Diese müssen wir natürlich jetzt noch in unseren Styles hinzufügen bzw. anpassen.

<!-- Colors -->
<Color x:Key="DefaultTextColor">#DE000000</Color>
<Color x:Key="InfoTextColor">#99000000</Color>

<Color x:Key="FrameBackgroundColor">#F5F0E1</Color>

<Color x:Key="AccentColor">#FF6E40</Color>

<!-- Styles -->
<Style x:Key="PokemonFrameStyle" 
       TargetType="Frame">
    <Setter Property="BackgroundColor"
            Value="{StaticResource FrameBackgroundColor}" />
    <Setter Property="Padding"
            Value="8" />
    <Setter Property="CornerRadius"
            Value="8" />
</Style>

<Style x:Key="PokemonIdLabelStyle"
       TargetType="Label">
    <Setter Property="TextColor"
            Value="{StaticResource InfoTextColor}" />
    <Setter Property="HorizontalTextAlignment"
            Value="End" />
    <Setter Property="FontAttributes"
            Value="Italic" />
    <Setter Property="FontSize"
            Value="Micro" />
</Style>

<Style x:Key="PokemonNameStyle"
       TargetType="Label">
    <Setter Property="TextColor"
            Value="{StaticResource DefaultTextColor}" />
    <Setter Property="HorizontalTextAlignment"
            Value="Center" />
    <Setter Property="FontSize"
            Value="Caption" />
    <Setter Property="TextTransform"
            Value="Uppercase" />
</Style>

<Style TargetType="ActivityIndicator">
    <Setter Property="Color"
            Value="{StaticResource AccentColor}" />
    <Setter Property="WidthRequest"
            Value="50" />
    <Setter Property="HeightRequest"
            Value="50" />
</Style>

Nun passen wir noch die OverviewPage entsprechend an. Anstatt dem Abrufen der verschiedenen Typen, rufen wir nun die Details der Pokémons ab. Und wir verwenden unsere neuen Controls, um die Informationen übersichtlich darzustellen.

<ContentPage.Content>
    <Grid>
        <Grid RowDefinitions="Auto,*"
                  RowSpacing="12">
                <Button Text="Get Pokémons"
                        Command="{Binding LoadPokemonsCommand}"
                        Grid.Row="0" />

                <CollectionView ItemsSource="{Binding Pokemons}"
                                RemainingItemsThreshold="1"
                                RemainingItemsThresholdReachedCommand="{Binding LoadMorePokemonsCommand}"
                                Grid.Row="1">
                    <CollectionView.ItemsLayout>
                        <GridItemsLayout HorizontalItemSpacing="12"
                                         VerticalItemSpacing="12"
                                         Orientation="Vertical"
                                         Span="2" />
                    </CollectionView.ItemsLayout>

                    <CollectionView.ItemTemplate>
                        <DataTemplate>
                            <controls:PokemonOverviewControl PokemonDetail="{Binding .}" />
                        </DataTemplate>
                    </CollectionView.ItemTemplate>
                </CollectionView>
            </Grid>

        <controls:LoadingControl IsVisible="{Binding IsLoading}" />
    </Grid>
</ContentPage.Content>

Hier nun der aktuelle Stand auf einem Android-Telefon. Wenn man jetzt nach unten scrollt werden die nächsten Pokémons nachgeladen und unten an die Liste angefügt.

Es funktioniert natürlich auch unter UWP, wie der folgende Screenshot zeigt.

Damit haben wir nun einen weiteren Schritt bei der Entwicklung unseres Pokédex geschafft. Wir können nun bereits Daten für die einzelnen Pokémons abrufen, welche dann auch in der Datenbank abgelegt und stehen bei einem Neustart der App zur Verfügung.

Wie immer könnt ihr euch den Code aus diesem Beitrag über den folgenden Button herunterladen.

Nächste Woche folgt dann bereits der nächste Teil dieser kleinen Serie, in dem wir nun weitere Details in einem Dialog zu einem Pokémon anzeigen wollen und noch weitere Anpassungen am Styling durchführen.

Demo: Xamarin.Forms Material Visual Eigene Schriftarten in Xamarin.Forms Apps Mac fernsteuern mit VNC