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

Ich habe bereits drei 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. Im dritten Beitrag haben wir begonnen die Pokémon-Details abzurufen und per inkrementellem Laden auf der Übersichtsseite anzuzeigen. Im nun vorliegenden Beitrag wollen wir ein kleines Popup entwickeln, welches uns weitere Details anzeigt, sofern der Nutzer einen Eintrag in der Übersicht angeklickt hat.

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

Zunächst müssen wir ein paar Einträge in die AppResources aufnehmen. Diese dienen bereits zur Vorbereitung auf das Popup, welches wir im Laufe dieses Beitrags umsetzen wollen.

PokemonDetailHeightLabelHEIGHT
PokemonDetailHeightUnitLabelcm
PokemonDetailIdLabelID
PokemonDetailNameLabelNAME
PokemonDetailTypesLabelTYPES
PokemonDetailWeightLabelWEIGHT
PokemonDetailWeightUnitLabelkg

Außerdem benötigen wir noch ein paar Styles, welche ich euch hier ebenfalls in gekürzter Version zeige. Die vollständige Styles.xaml-Datei könnt ihr euch am Ende des Beitrags herunterladen.

<Color x:Key="LightTextColor">#FFFFFF</Color>

<Style x:Key="TypeChipLabelStyle"
       TargetType="Label">
    <Setter Property="TextColor"
            Value="{StaticResource LightTextColor}" />
    <Setter Property="TextTransform"
            Value="Uppercase" />
    <Setter Property="MaxLines"
            Value="1" />
</Style>

<Style x:Key="TypeChipFrameStyle"
       TargetType="Frame">
    <Setter Property="Margin"
            Value="0" />
    <Setter Property="Padding"
            Value="8" />
    <Setter Property="BorderColor"
            Value="#000000" />
</Style>

<Style x:Key="PokemonDetailTitleLabelStyle"
       TargetType="Label">
    <Setter Property="TextTransform"
            Value="Uppercase" />
    <Setter Property="FontAttributes"
            Value="Italic" />
    <Setter Property="HorizontalTextAlignment"
            Value="Center" />
    <Setter Property="VerticalTextAlignment"
            Value="Center" />
</Style>

<Style x:Key="PokemonDetailLabelStyle"
       TargetType="Label">
    <Setter Property="TextTransform"
            Value="Uppercase" />
    <Setter Property="FontAttributes"
            Value="Bold" />
    <Setter Property="HorizontalTextAlignment"
            Value="Center" />
    <Setter Property="VerticalTextAlignment"
            Value="Center" />
</Style>

<Style x:Key="PokemonDetailImageStyle"
       TargetType="Image">
    <Setter Property="WidthRequest"
            Value="150" />
    <Setter Property="HeightRequest"
            Value="150" />
    <Setter Property="Aspect"
            Value="AspectFit" />
    <Setter Property="VerticalOptions"
            Value="Center" />
    <Setter Property="HorizontalOptions"
            Value="Center" />
</Style>

Wir erstellen nun ein neues Control im Ordner Controls. Dieses trägt den Namen TypeChipControl und nutzt eine ContentView als Template. Dieses Control wollen wir verwenden, um den Pokémon-Typ übersichtlich darzustellen.

<?xml version="1.0" encoding="UTF-8"?>
<Frame xmlns="http://xamarin.com/schemas/2014/forms"
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       x:Class="Pokedex.Controls.TypeChipControl"
       Style="{StaticResource TypeChipFrameStyle}">

    <Frame.Content>
        <Label x:Name="ChipLabel"
               Style="{StaticResource TypeChipLabelStyle}" />
    </Frame.Content>
</Frame>

Nun öffnen wir die Code-Behind Datei und fügen zunächst eine BindableProperty Text unserem Control hinzu. Außerdem erstellen wir noch eine Methode SetBackgroundColor, welche in Abhängigkeit des übergebenen Typs die Hintergrundfarbe des Chips anpasst. So kann man nämlich noch schneller den jeweiligen Typen feststellen.

[XamlCompilation(XamlCompilationOptions.Compile)]
public partial class TypeChipControl : Frame
{
    public static BindableProperty TextProperty =
       BindableProperty.Create(nameof(Text), typeof(string), 
           typeof(TypeChipControl), string.Empty, 
           propertyChanged: (bindable, oldVal, newVal) =>
       {
           var view = (TypeChipControl)bindable;
           view.ChipLabel.Text = (string)newVal;
           view.SetBackgroundColor((string)newVal);
       });

    public string Text
    {
        get => (string)GetValue(TextProperty);
        set => SetValue(TextProperty, value);
    }

    public TypeChipControl()
    {
        InitializeComponent();
    }

    private void SetBackgroundColor(string type)
    {
        switch (type)
        {
            case "normal":
                BackgroundColor = Color.FromHex("#A8A87D");
                break;
            case "fire":
                BackgroundColor = Color.FromHex("#E38444");
                break;
            case "water":
                BackgroundColor = Color.FromHex("#708EE9");
                break;
            case "grass":
                BackgroundColor = Color.FromHex("#88C760");
                break;
            case "electric":
                BackgroundColor = Color.FromHex("#F2D154");
                break;
            case "ice":
                BackgroundColor = Color.FromHex("#A4D7D7");
                break;
            case "fighting":
                BackgroundColor = Color.FromHex("#B33831");
                break;
            case "poison":
                BackgroundColor = Color.FromHex("#96439C");
                break;
            case "ground":
                BackgroundColor = Color.FromHex("#DBC175");
                break;
            case "flying":
                BackgroundColor = Color.FromHex("#A590EA");
                break;
            case "psychic":
                BackgroundColor = Color.FromHex("#E85F88");
                break;
            case "bug":
                BackgroundColor = Color.FromHex("#ABB842");
                break;
            case "rock":
                BackgroundColor = Color.FromHex("#B5A14B");
                break;
            case "ghost":
                BackgroundColor = Color.FromHex("#6D5894");
                break;
            case "dark":
                BackgroundColor = Color.FromHex("#6D594A");
                break;
            case "dragon":
                BackgroundColor = Color.FromHex("#6A36EF");
                break;
            case "steel":
                BackgroundColor = Color.FromHex("#B8B8CE");
                break;
            case "fairy":
                BackgroundColor = Color.FromHex("#E8B7BD");
                break;
            case "shadow":
                BackgroundColor = Color.FromHex("#6D5894");
                break;
            default:
                BackgroundColor = Color.FromHex("#76A497");
                break;
        }
    }
}

Bevor wir nun unseren Dialog erstellen, müssen wir uns ein Icon aus der Material Icons Galerie herunterladen. Wir suchen hier nach dem Keyword Close und laden uns das passende Icon herunter.

Nun erstellen wir uns einen neuen Ordner Assets und fügen hier die größte Auflösung hinzu, welche wir vorher noch in close.png umbenannt haben. Nun klicken wir mit der rechten Maustaste auf die hinzugefügte Datei in Visual Studio und wählen Properties. In Build Action wählen wir nun den Eintrag Embedded Resource aus.

Damit wir dieses Bild nun auch im XAML verwenden können, benötigen wir eine eigene MarkupExtension, welche das Bild anzeigt. Dazu legen wir eine neue Klasse ImageResourceExtension im Ordner Common hinzu. Diese Klasse implementiert nun das Interface IMarkupExtension und muss daher die Methode ProvideValue implementieren. Hier nutzen wir die FromResource-Methode der ImageSource-Klasse, um das Bild aus dem Assets-Ordner zu laden.

[ContentProperty(nameof(Source))]
public class ImageResourceExtension : IMarkupExtension
{
    public string Source { get; set; }

    public object ProvideValue(IServiceProvider serviceProvider)
    {
        if (Source == null)
            return null;

        return ImageSource.FromResource($"Pokedex.Assets.{Source}");
    }
}

Nun legen wir eine neue ContentPage im Ordner Views an. Wir wollen diese Page PokemonDetailDialogPage nennen. Allerdings passen wir den Typen an und ersetzen die ContentPage mit einer PopupPage aus dem Rg.Plugins.Popup-Package. Als Content können wir nun ganz nach belieben unseren Inhalt erstellen.

<?xml version="1.0" encoding="UTF-8"?>
<pages:PopupPage xmlns="http://xamarin.com/schemas/2014/forms"
                 xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
                 xmlns:pages="clr-namespace:Rg.Plugins.Popup.Pages;assembly=Rg.Plugins.Popup"
                 xmlns:animations="clr-namespace:Rg.Plugins.Popup.Animations;assembly=Rg.Plugins.Popup"
                 xmlns:controls="clr-namespace:Pokedex.Controls"
                 xmlns:common="clr-namespace:Pokedex.Common"
                 xmlns:extensions="http://xamarin.com/schemas/2020/toolkit"
                 x:Class="Pokedex.Views.PokemonDetailDialogPage">

    <pages:PopupPage.Animation>
        <animations:ScaleAnimation PositionIn="Center"
                                   PositionOut="Center"
                                   ScaleIn="1.2"
                                   ScaleOut="0.8"
                                   DurationIn="250"
                                   DurationOut="250"
                                   EasingIn="SinOut"
                                   EasingOut="SinIn"
                                   HasBackgroundAnimation="True" />
    </pages:PopupPage.Animation>

    <pages:PopupPage.Content>
        <Frame HorizontalOptions="{OnPlatform Default=Fill, UWP=CenterAndExpand}"
               VerticalOptions="CenterAndExpand"
               BackgroundColor="{StaticResource FrameBackgroundColor}"
               WidthRequest="480"
               Padding="12">
            <Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto"
                  RowSpacing="12"
                  ColumnDefinitions="*,*">

                <Image Source="{common:ImageResource close.png}"
                       WidthRequest="24"
                       HeightRequest="24"
                       HorizontalOptions="End"
                       VerticalOptions="Center"
                       Grid.Row="0"
                       Grid.Column="1">
                    <Image.GestureRecognizers>
                        <TapGestureRecognizer Tapped="CloseImageOnTapped" />
                    </Image.GestureRecognizers>
                </Image>

                <StackLayout Grid.Row="1"
                             Grid.Column="0">
                    <Label Text="{extensions:Translate PokemonDetailIdLabel}"
                           Style="{StaticResource PokemonDetailTitleLabelStyle}" />

                    <Label x:Name="PokemonDetailId"
                           Style="{StaticResource PokemonDetailLabelStyle}" />
                </StackLayout>

                <StackLayout Grid.Row="1"
                             Grid.Column="1">
                    <Label Text="{extensions:Translate PokemonDetailNameLabel}"
                           Style="{StaticResource PokemonDetailTitleLabelStyle}" />

                    <Label x:Name="PokemonDetailName"
                           Style="{StaticResource PokemonDetailLabelStyle}" />
                </StackLayout>

                <Image x:Name="PokemonDetailSprite"
                       Style="{StaticResource PokemonDetailImageStyle}"
                       Grid.Row="2"
                       Grid.Column="0"
                       Grid.ColumnSpan="2" />

                <StackLayout Grid.Row="3"
                             Grid.Column="0">
                    <Label Text="{extensions:Translate PokemonDetailHeightLabel}"
                           Style="{StaticResource PokemonDetailTitleLabelStyle}" />

                    <Label x:Name="PokemonDetailHeight"
                           Style="{StaticResource PokemonDetailLabelStyle}" />
                </StackLayout>

                <StackLayout Grid.Row="3"
                             Grid.Column="1">
                    <Label Text="{extensions:Translate PokemonDetailWeightLabel}"
                           Style="{StaticResource PokemonDetailTitleLabelStyle}" />

                    <Label x:Name="PokemonDetailWeight"
                           Style="{StaticResource PokemonDetailLabelStyle}" />
                </StackLayout>

                <StackLayout Grid.Row="4"
                             Grid.Column="0"
                             Grid.ColumnSpan="2">
                    <Label Text="{extensions:Translate PokemonDetailTypesLabel}"
                           Style="{StaticResource PokemonDetailTitleLabelStyle}" />

                    <StackLayout x:Name="PokemonDetailTypes"
                                 Orientation="Horizontal"
                                 HorizontalOptions="Center">
                        <BindableLayout.ItemTemplate>
                            <DataTemplate>
                                <controls:TypeChipControl Text="{Binding PokemonType.Name}" />
                            </DataTemplate>
                        </BindableLayout.ItemTemplate>
                    </StackLayout>
                </StackLayout>
            </Grid>
        </Frame>
    </pages:PopupPage.Content>
</pages:PopupPage>

Wir wechseln nun noch in die Code-Behind Datei und passen zunächst den Konstruktor an. Als Parameter nutzen wir ein PokemonDetail-Modell, welches wir dann nutzen, um die notwendigen Labels mit den passenden Daten zu befüllen.

public PokemonDetailDialogPage(PokemonDetail pokemonDetail)
{
    InitializeComponent();

    PokemonDetailId.Text 
        = $"#{pokemonDetail.Id:D3}";
    PokemonDetailName.Text 
        = pokemonDetail.Name;
    PokemonDetailWeight.Text 
        = $"{pokemonDetail.Weight / 10.0} {AppResources.PokemonDetailWeightUnitLabel}";
    PokemonDetailHeight.Text 
        = $"{pokemonDetail.Height / 10.0} {AppResources.PokemonDetailHeightUnitLabel}";
    PokemonDetailSprite.Source 
        = pokemonDetail.Sprite.Image.Artwork.ImagePath;
    BindableLayout.SetItemsSource(PokemonDetailTypes, pokemonDetail.Types);
}

Wir erstellen im Ordner Interfaces nun ein neues Interface mit dem Namen IDialogService. Dieses Interface stellt uns zwei Methoden bereit. Zum einen eine Methode zum Öffnen des Dialogs und eine zweite Methode zum Schließen von diesem.

public interface IDialogService
{
    Task OpenPokemonDetailsDialogAsync(PokemonDetail pokemonDetail);

    Task CloseDialogAsync();
}

Dann wollen wir jetzt den Service auch gleich implementieren. Hierfür erstellen wir eine neue Klasse DialogService im Ordner Services, welche dann das neue Interface implementiert. Wir machen hier Gebrauch von dem INavigation-Objekt von der MainPage, welche entweder den Dialog öffnet bzw. wieder schließt.

public class DialogService : IDialogService
{
    public async Task CloseDialogAsync()
    {
        await Application.Current.MainPage
            .Navigation.PopPopupAsync();
    }

    public async Task OpenPokemonDetailsDialogAsync(PokemonDetail pokemonDetail)
    {
        await Application.Current.MainPage
            .Navigation.PushPopupAsync(new PokemonDetailDialogPage(pokemonDetail));
    }
}

Nun müssen wir noch den Service im Bootstrapper registrieren, so dass wir entsprechend darauf zugreifen können.

services.AddSingleton<IDialogService, DialogService>();

Da wir nun eine Methode zum Schließen des Dialogs zur Verfügung haben, kehren wir zurück in unsere PokemonDetailDialogPage-Codedatei. Hier brauchen wir nämlich noch eine Methode, welche aufgerufen wird, wenn der Nutzer auf das Close-Icon klickt.

private async void CloseImageOnTapped(object sender, EventArgs e)
{
    var view = sender as View;
    if (view == null)
        return;

    // press effect
    var scale = view.Scale;
    await view.ScaleTo(scale * 0.8, 100);
    await view.ScaleTo(scale, 100);

    await Bootstrapper.ServiceProvider
        .GetService<IDialogService>().CloseDialogAsync();
}

Jetzt haben wir bereits den Dialog soweit fertig. Was noch fehlt ist die Möglichkeit nun nach dem Klicken auf eine der Kacheln in der Übersicht diesen Dialog zu öffnen. Dazu öffnen wir unser PokemonOverviewControl und fügen einen GestureRecognizer hinzu.

<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.GestureRecognizers>
        <TapGestureRecognizer Tapped="OnTapped" />
    </Frame.GestureRecognizers>
</Frame>

Nun implementieren wir noch kurz das Event. Hier wollen wir eine kleine Press-Animation nutzen und anschließend über den DialogService den Dialog öffnen.

private async void OnTapped(object sender, System.EventArgs e)
{
    var view = sender as View;
    if (view == null)
        return;

    // press effect
    var scale = view.Scale;
    await view.ScaleTo(scale * 0.95, 100);
    await view.ScaleTo(scale, 100); 

    await Bootstrapper.ServiceProvider
        .GetService<IDialogService>().OpenPokemonDetailsDialogAsync(PokemonDetail);
}

Abschließend wollen wir jetzt noch einen Blick auf die UWP- und die Android-Version werfen. Beginnen wir zunächst mit der UWP-Version.

Und hier noch der gleiche Screen unter Android.

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

Damit sind wir auch schon wieder am Ende vom vierten Beitrag dieser kleinen Serie. Es wird nun noch ein fünfter Beitrag in der kommenden Woche folgen, welcher ein bisschen Cleanup und die letzten kleineren Anpassungen beinhaltet.

Xamarin.Forms: TextToSpeech What3Words Sample-App NuGet Package: ICanHazDadJokeSharp