Foto-Galerie App mit Xamarin.Forms

Ich habe hier im Blog bereits das NuGet-Package PixabaySharp von meinem Kollegen Thomas Pentenrieder und mir vorgestellt. Nun dachte ich mir, dass es doch schön wäre, wenn man auch gleich ein Beispielprojekt hätte, was diese Library einmal in Aktion zeigt. Wie der Titel schon verrät, habe ich mich natürlich für Xamarin.Forms entschieden und möchte jetzt in diesem Beitrag zeigen, wie ihr selbst mit wenig Aufwand eine kleine Foto-Galerie App schreiben könnt. Dabei werden ich nicht jeden einzelnen Code-Block hier im Blog vorstellen, aber am Ende des Beitrags findet ihr den Link zu einem GitHub-Repository, wo ihr den vollständigen Code herunterladen könnt.

Ausgangslage bildet ein leeres Xamarin.Forms Projekt in Visual Studio. Ich habe mich entschieden neben iOS und Android auch UWP zu unterstützen. Anschließend müssen ein paar NuGet-Pakete installiert werden. Dazu gehören PixabaySharp, Unity.Container, und Unity.ServiceLocation. Diese drei Packages müssen nur im .NET Standard Projekt installiert werden. Außerdem installieren wir noch Xamarin.Forms.Visual.Material in das Android- und das iOS-Projekt und initialisieren dies entsprechend. Alle bereits installierten NuGet-Packages können wir gleich auch kurz aktualisieren, so dass wir mit dem letzten Software-Stand arbeiten können.

Beginnen wir zunächst mit dem Erstellen eines ImageServices, welcher die Kommunikation mit der Pixabay-API übernimmt. Dafür erstellen wir einen Ordner Utils und darin eine Datei Statics. Diese Klasse beinhaltet eine Property, welche den API-Key für Pixabay bereitstellt und den folgenden Aufbau hat:

public static class Statics
{
    public static string ApiKey = "YOUR API KEY";
}

Dabei ist zu beachten, dass ihr euren eigenen API-Key benötigt, welchen ihr auf dieser Webseite erhalten könnt.

Damit können wir jetzt einen neuen Ordner Interfaces erstellen und darin das Interface IImageService anlegen. Dieses stelle für unsere kleine Anwendung nur eine Methode bereit, nämlich GetImagesAsync.

public interface IImageService
{
    Task<List<string>> GetImagesAsync(string searchQuery, 
        int page = 0, int perPage = 20);
}

Nun können wir das Interface bereits implementieren. Dafür erstellen wir zunächst einen Ordner Services und darin die Klasse ImageService.

public class ImageService : IImageService
{
    private readonly PixabaySharpClient _pixabaySharpClient;

    public ImageService()
    {
        _pixabaySharpClient = new PixabaySharpClient(Statics.ApiKey);
    }

    public async Task<List<string>> GetImagesAsync(string searchQuery, 
        int page = 0, int perPage = 20)
    {
        try
        {
            page++;

            var imageQueryBuilder = new ImageQueryBuilder 
            { 
                Query = searchQuery, Page = page, PerPage = perPage 
            };

            var imageResult = await _pixabaySharpClient
                    .QueryImagesAsync(imageQueryBuilder);

            return imageResult.Images.Select(i => i.PreviewURL).ToList();
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"{GetType().Name} | {nameof(GetImagesAsync)} | {ex}");
        }

        return new List<string>();
    }
}

Wir nutzen hier den PixabaySharpClient aus dem NuGet-Package und übergeben den hinterlegten API-Key. Zum Abrufen der Bilder nutzen wir die Methode QueryImagesAsync. Hier übergeben wir den eingegebenen Suchparameter und nutzen für das Paging noch die Properties Page und PerPage, so dass wir später automatisch Bilder nachladen können. Die Methode liefert als Ergebnis einfach eine Liste von Strings, welche die URLs für ein Vorschaubild beinhaltet. Diese URLs wollen wir dann später im Frontend anzeigen.

Im nächsten Schritt erstellen wir die notwendigen ViewModel. Hierfür habe ich im Ordner Utils eine Klasse AsyncRelayCommand erstellt, welches eine Implementierung des ICommand-Interfaces darstellt und die ausführende Methode asynchron macht. Den dazugehörigen Code findet ihr im GitHub-Repository. Im Anschluss können wir den Ordner ViewModels erstellen und darin das BaseViewModel anlegen. Dieses stellt eine Basis-Klasse für die anderen ViewModels dar und implementiert das notwendige Interface INotifyPropertyChanged und stellt die beiden Properties IsLoading und IsRefreshing bereit.

public class BaseViewModel : INotifyPropertyChanged
{
    private bool _isLoading;
    public bool IsLoading
    {
        get => _isLoading;
        set { _isLoading = value; OnPropertyChanged(); }
    }

    private bool _isRefreshing;
    public bool IsRefreshing
    {
        get => _isRefreshing;
        set { _isRefreshing = value; OnPropertyChanged(); }
    }


    public event PropertyChangedEventHandler PropertyChanged;

    protected virtual void OnPropertyChanged([CallerMemberName] 
        string propertyName = null)
    {
        PropertyChanged?.Invoke(this, 
            new PropertyChangedEventArgs(propertyName));
    }
}

Damit ist der Grundstein für den ImageViewModel gelegt, welches wir ebenfalls im Ordner ViewModels ablegen wollen. Dieses ViewModel stellt eine Property für die Suchanfrage und die Bilder bereit. Darüber hinaus gibt es noch einen Command für das initiale Laden und einen Command für das Nachladen der Bilder.

public class ImageViewModel : BaseViewModel
{
    private readonly IImageService _imageService;

    private int _currentPage = -1;

    private string _searchQuery;
    public string SearchQuery
    {
        get => _searchQuery;
        set { _searchQuery = value; OnPropertyChanged(); 
                LoadImagesAsyncCommand.RaiseCanExecuteChange(); }
    }

    private ObservableCollection<string> _images = 
            new ObservableCollection<string>();
    public ObservableCollection<string> Images
    {
        get => _images;
        set { _images = value; OnPropertyChanged(); }
    }

    private bool _isIncrementalLoading;
    public bool IsIncrementalLoading
    {
        get => _isIncrementalLoading;
        set { _isIncrementalLoading = value; OnPropertyChanged(); }
    }


    private AsyncRelayCommand _loadImagesAsyncCommand;
    public AsyncRelayCommand LoadImagesAsyncCommand 
            => _loadImagesAsyncCommand 
            ?? (_loadImagesAsyncCommand 
                = new AsyncRelayCommand(LoadImagesAsync, CanLoadImages));
    
    private AsyncRelayCommand _loadMoreImagesAsyncCommand;
    public AsyncRelayCommand LoadMoreImagesAsyncCommand 
            => _loadMoreImagesAsyncCommand 
            ?? (_loadMoreImagesAsyncCommand 
                = new AsyncRelayCommand(LoadMoreImagesAsync, CanLoadImages));

    private bool CanLoadImages() 
            => !string.IsNullOrEmpty(SearchQuery);


    public ImageViewModel(IImageService imageService)
    {
        _imageService = imageService;
    }


    private async Task LoadImagesAsync()
    {
        try
        {
            IsRefreshing = true;

            _currentPage = 0;

            Images.Clear();

            var images = await _imageService
                    .GetImagesAsync(SearchQuery, _currentPage);

            Images.AddRange(images);
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"{GetType().Name} | {nameof(LoadImagesAsync)} | {ex}");
        }
        finally
        {
            IsRefreshing = false;
        }
    }

    private async Task LoadMoreImagesAsync()
    {
        if (IsIncrementalLoading)
            return;

        try
        {
            IsIncrementalLoading = true;

            _currentPage++;

            var images = await _imageService
                    .GetImagesAsync(SearchQuery, _currentPage, 20);

            if (!images.Any())
                return;

            Images.AddRange(images);
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"{GetType().Name} | {nameof(LoadMoreImagesAsync)} | {ex}");
        }
        finally
        {
            IsIncrementalLoading = false;
        }
    }
}

Wir ihr sehen könnt, wird der ImageService benutzt, welchen wir vorher angelegt haben. Zu guter Letzt fehlt uns jetzt nur noch eine UI. Hierfür legen wir einen neuen Ordner Views an und erstellen eine neue ContentPage mit dem Namen ImagesPage. Der Aufbau der Seite besteht aus einem Grid, welches in der ersten Zeile ein Entry für den Suchbegriff bereitstellt, in der  zweiten Zeile einen Button zum Starten der Suchanfrage und in der dritten Zeile verwenden wir eine CollectionView in Verbindung mit einer RefreshView zum Anzeigen der Bilder.

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="XFImageGallery.Views.ImagesPage"
             Title="XF Image Gallery"
             Padding="16"
             Visual="Material">

    <ContentPage.Content>
        <Grid RowDefinitions="Auto,Auto,*">
            <Entry Placeholder="Search Query"
                   Text="{Binding SearchQuery}"
                   Grid.Row="0" />

            <Button Text="Search"
                    Command="{Binding LoadImagesAsyncCommand}"
                    Grid.Row="1" />

            <RefreshView Command="{Binding LoadImagesAsyncCommand}"
                         IsRefreshing="{Binding IsRefreshing}"
                         Grid.Row="2">
                <CollectionView x:Name="ImagesCollectionView"
                                ItemsSource="{Binding Images}"
                                RemainingItemsThresholdReachedCommand
                                    ="{Binding LoadMoreImagesAsyncCommand}"
                                RemainingItemsThreshold="4"
                                Scrolled="ImagesCollectionViewOnScrolled">
                    <CollectionView.ItemTemplate>
                        <DataTemplate>
                            <Image Source="{Binding .}"
                                   Aspect="AspectFill"
                                   WidthRequest="200"
                                   HeightRequest="200"
                                   VerticalOptions="Center"
                                   HorizontalOptions="Center" />
                        </DataTemplate>
                    </CollectionView.ItemTemplate>
                </CollectionView>
            </RefreshView>
        </Grid>
    </ContentPage.Content>
</ContentPage>

Wir wechseln nun noch in die Code-Behind Datei der ImagesPage, da hier auch einige Anpassungen notwendig sind. Zum einen wollen wir berechnen, wie viele Bilder nebeneinander in einer Reihe angezeigt werden können und zum anderen müssen wir einen kleinen Workaround für UWP implementieren, da ansonsten unter UWP nicht das automatische Nachladen der Bilder getriggert wird.

[XamlCompilation(XamlCompilationOptions.Compile)]
public partial class ImagesPage : ContentPage
{
    public ImagesPage()
    {
        InitializeComponent();

        BindingContext = ServiceLocator.Current
            .GetInstance<ImageViewModel>();

        SetLayout();
        SizeChanged += OnSizeChanged;
    }

    private void OnSizeChanged(object sender, EventArgs e)
    {
        if (Device.Idiom == TargetIdiom.Tablet
                || Device.Idiom == TargetIdiom.Desktop
                || Device.Idiom == TargetIdiom.TV)
        {
            var itemsPerRow = (int)(Width / 212);
            ((GridItemsLayout)ImagesCollectionView.ItemsLayout)
                .Span = itemsPerRow;
        }
    }

    private void SetLayout()
    {
        switch (Device.Idiom)
        {
            case TargetIdiom.Unsupported:
            case TargetIdiom.Watch:
                ImagesCollectionView.ItemsLayout
                        = new GridItemsLayout(ItemsLayoutOrientation.Vertical)
                        {
                            Span = 1,
                            HorizontalItemSpacing = 12,
                            VerticalItemSpacing = 12
                        };
                break;
            case TargetIdiom.Phone:
                ImagesCollectionView.ItemsLayout
                    = new GridItemsLayout(ItemsLayoutOrientation.Vertical)
                    {
                        Span = 2,
                        HorizontalItemSpacing = 12,
                        VerticalItemSpacing = 12
                    };
                break;
            case TargetIdiom.Tablet:
            case TargetIdiom.Desktop:
            case TargetIdiom.TV:
                ImagesCollectionView.ItemsLayout
                    = new GridItemsLayout(ItemsLayoutOrientation.Vertical)
                    {
                        Span = 4,
                        HorizontalItemSpacing = 12,
                        VerticalItemSpacing = 12
                    };
                break;
        }
    }

    // Workaround for UWP bug 
    // => https://github.com/xamarin/Xamarin.Forms/issues/9013
    private void ImagesCollectionViewOnScrolled(object sender, 
        ItemsViewScrolledEventArgs e)
    {
        if (Device.RuntimePlatform != Device.UWP)
            return;

        if (sender is CollectionView collectionView 
            && collectionView is IElementController elementController)
        {
            var count = elementController.LogicalChildren.Count;
            if (e.LastVisibleItemIndex + 1 - count 
                + collectionView.RemainingItemsThreshold >= 0)
            {
                if (collectionView
                    .RemainingItemsThresholdReachedCommand.CanExecute(null))
                    collectionView
                        .RemainingItemsThresholdReachedCommand.Execute(null);
            }
        }
    }
}

Damit wir den BindingContext setzen können, müssen wir noch einen Bootstrapper implementieren, welchen ihr aber dem GitHub-Repository entnehmen könnt. Der folgende Screenshot zeigt das Ergebnis einmal unter Windows. Wenn man jetzt die Größe des Fensters ändert, dann passt sich die Anzahl der Bilder in einer Reihe entsprechend dem vorliegenden Platz an.

Hier auch noch einmal ein Screenshot der Android-Variante.

In diesem Beitrag habe ich euch zum einen aufgezeigt, wie man mit wenig Aufwand Bilder von Pixabay abrufen kann. Außerdem habe ich euch gezeigt, wie man ein automatisches Nachladen von Daten in Abhängigkeit des Scrollens der Liste implementiert. Den gesamten Quellcode findet ihr in folgendem GitHub-Repository.

Deutschlands Kennzeichen: Meine erste iOS-App What3Words Sample-App Android Archive Erstellung schlägt fehl