Explore the SpaceX GraphQL API by building a Xamarin Forms app

mariusmuntean

Marius Muntean

Posted on March 2, 2021

Explore the SpaceX GraphQL API by building a Xamarin Forms app

What's this about

We're going to build a Xamarin.Forms app that connects to the SpaceX GraphQL API and displays some cool rocket launch pictures. You'll see how to generate C# classes for typesafe coding, send queries with the GraphQLHttpClient, build an elegant UI in XAML and use a Xamarin.Forms Behavior.

3
2
1
šŸš€

Generate types from the GraphQl schema

Here's the endpoint where you can get the schema from https://api.spacex.land/graphql

If you have your own way of generating the types, you can skip this section but this is how I did it.

In a folder of your choice, initialize a new yarn project

yarn init
Enter fullscreen mode Exit fullscreen mode

Next, install these dev dependencies

yarn add -D graphql @graphql-codegen/c-sharp @graphql-codegen/cli @graphql-codegen/introspection @graphql-codegen/typescript
Enter fullscreen mode Exit fullscreen mode

Then add the following script property to your package.json

  "scripts": {
    "generate": "graphql-codegen --config codegen.yml"
  }
Enter fullscreen mode Exit fullscreen mode

Now we're done with the packages.json and we can move on to configuring the node module that will take care of generating the classes.

Here's how that YAML file needs to look like

overwrite: true
schema: "https://api.spacex.land/graphql"
#documents: "src/**/*.graphql"
generates:
  gen/Models.cs:
    plugins:
      - "c-sharp"
    config: 
      namespaceName: SpaceXGraphQL.SpaceX.gen
      scalars:
        timestamptz: DateTime
        uuid: Guid
  ./graphql.schema.json:
    plugins:
      - "introspection"
Enter fullscreen mode Exit fullscreen mode

Let me translate: get the schema from https://api.spacex.land/graphql, using the "c-sharp" plugin generate classes with the namespace SpaceXGraphQL.SpaceX.gen into the file (relative to self) gen/Models.cs.

The scalars property is a mapping between custom types from the GraphQL schema and matching C# types. It looks like they use Postgres under the hood. timestamptz stores date and time info including the time zone and uuid is just a 128 -bit quantity for identifying entities.

GraphiQL (https://api.spacex.land/graphql/)
uuid

timestamp


Now install the referenced dependencies with

yarn install
Enter fullscreen mode Exit fullscreen mode

And run the script

yarn generate
Enter fullscreen mode Exit fullscreen mode

Our classes were generated! Yay! šŸŽ‰

Generated models

Thats it! We now have types to work with.

Write the Xamarin.Forms App

Create a new Xamarin.Forms app and add the following NuGet packages

<PackageReference Include="GraphQL.Client" Version="3.2.1" />
<PackageReference Include="GraphQL.Client.Serializer.Newtonsoft" Version="3.2.1" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="Xamarin.Essentials" Version="1.6.1" />
<PackageReference Include="GraphQL.Client.Serializer.Newtonsoft" Version="3.2.2" />
Enter fullscreen mode Exit fullscreen mode

Next, add the generated classes to your shared project. I actually added the whole yarn project folder
generated models

Now let's have fun!

Set up this global style

<?xml version="1.0" encoding="utf-8" ?>
<Application xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="SpaceXGraphQL.App">
    <Application.Resources>

        <!-- Styles -->
        <Style TargetType="NavigationPage">
            <Setter Property="BarBackgroundColor" Value="#384355"/>
            <Setter Property="BarTextColor" Value="#FF543D"/>
        </Style>
        <!-- Styles -->

    </Application.Resources>
</Application>
Enter fullscreen mode Exit fullscreen mode

The app will have two pages.
The first page shows a list of rocket launches, uses infinite scrolling and shows a pulsing indicator for "upcoming" launches.

To keep things simple I didn't use any third party UI library nor did I integrate Prism or MvvmCross, though I'm a big fan of those.

Start with the view model, I named mine LaunchesPageViewModel. It implements INotifyPropertyChanged like so

 public event PropertyChangedEventHandler PropertyChanged;

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

It needs a few properties to hold the rocket launches, to indicate that it is fetching data and a command to load the next batch/page of launches

        public Command LoadMoreCommand
        {
            get => _loadMoreCommand;
            set
            {
                _loadMoreCommand = value;
                OnPropertyChanged();
            }
        }

        public bool IsLoading
        {
            get => _isLoading;
            set
            {
                _isLoading = value;
                OnPropertyChanged();
            }
        }

        public ObservableCollection<Types.Launch> Launches
        {
            get => _launches;
            set
            {
                _launches = value;
                OnPropertyChanged();
            }
        }
Enter fullscreen mode Exit fullscreen mode

Next, in the constructor I'm doing a bit of init work. The LoadMoreCommand fetches rocket launches and, in case that it is executed again while the previous fetching isn't done, it just returns. When I'm done with the init, I start fetching.

 public LaunchesPageViewModel()
        {
            _client = new GraphQLHttpClient("https://api.spacex.land/graphql", new NewtonsoftJsonSerializer());
            Launches = new ObservableCollection<Types.Launch>();

            LoadMoreCommand = new Command(async () =>
            {
                if (_isFetchingMore)
                {
                    return;
                }

                try
                {
                    _isFetchingMore = true;
                    await GetLaunches();
                }
                finally
                {
                    _isFetchingMore = false;
                }
            });

            IsLoading = true;
            GetLaunches().ContinueWith((_, __) => IsLoading = false, null);
        }
Enter fullscreen mode Exit fullscreen mode

And now for the last piece, the actual GraphQL query. We're making use of the "launches" query, which allows use to tell it how many launch events to return and how many to skip, effectively offering a paginated api.
Notice that I'm using the amount of launches that I currently have as offset.

        private async Task GetLaunches()
        {
            var launchesRequest = new GraphQLRequest
            {
                Query = @"query getLaunches($limit: Int, $offset: Int)
                                {
                                  launches(limit: $limit, offset: $offset) {
                                    id
                                    is_tentative
                                    upcoming                                    
                                    mission_name
                                    links {
                                      article_link
                                      video_link
                                      flickr_images
                                      mission_patch
                                    }
                                    launch_date_utc
                                    details
                                  }
                                }",
                Variables = new
                {
                    limit = 15,
                    offset = Launches.Count
                }
            };

            var response = await _client.SendQueryAsync<Types.Query>(launchesRequest);
            if (!(response.Errors ?? Array.Empty<GraphQLError>()).Any())
            {
                foreach (var launch in response.Data.launches)
                {
                    Launches.Add(launch);
                }
            }
        }
Enter fullscreen mode Exit fullscreen mode

And that's all for the LaunchesPageViewModel.
The LaunchesPage, is implemented exclusively in XAML, except for a selection handler.

Basically I'm making use of a CollectionView (for its virtualization) to show the launches. Notice the properties RemainingItemsThreshold and RemainingItemsThresholdReachedCommand. Whenever theres 5 items or fewer to show, the CollectionView calls the LoadMoreCommand, which sends a query to the GraphQL API to fetch the next batch of launches.

LaunchesPage.xaml
<!-- Loading -->
        <ActivityIndicator IsVisible="{Binding IsLoading}"
                           IsRunning="True"
                           Color="#FF543D"
                           VerticalOptions="Center" HorizontalOptions="Center"
                           WidthRequest="120" HeightRequest="120" />

        <!-- Launches -->
        <CollectionView ItemsSource="{Binding Launches}"
                        ItemSizingStrategy="MeasureFirstItem"
                        SelectionMode="Single"
                        SelectionChanged="OnLaunchSelected"
                        RemainingItemsThreshold="5"
                        RemainingItemsThresholdReachedCommand="{Binding LoadMoreCommand}"
                        Margin="10">
            <CollectionView.ItemsLayout>
                <LinearItemsLayout Orientation="Vertical"
                                   ItemSpacing="5.0" />
            </CollectionView.ItemsLayout>

            <!-- Launch Template -->
            <CollectionView.ItemTemplate>
                <DataTemplate>
                    <Frame CornerRadius="15"
                           Padding="0" Margin="5,0,5,0"
                           HasShadow="False"
                           BackgroundColor="#041727"
                           IsClippedToBounds="True">
                        <Grid RowDefinitions="*,*"
                              ColumnDefinitions="Auto,*"
                              HeightRequest="80">

                            <!-- Mission patch -->
                            <Image HeightRequest="60" WidthRequest="60"
                                   Margin="10"
                                   Grid.Row="0" Grid.Column="0" Grid.RowSpan="2"
                                   Aspect="AspectFit"
                                   HorizontalOptions="CenterAndExpand"
                                   VerticalOptions="CenterAndExpand"
                                   Source="{Binding links.mission_patch}" />
                            <Label Grid.Row="0" Grid.Column="1"
                                   VerticalOptions="End"
                                   TextColor="White"
                                   FontAttributes="Bold"
                                   Text="{Binding mission_name}" />
                            <Label Grid.Row="1" Grid.Column="1"
                                   FontSize="Small"
                                   TextColor="#FF543D"
                                   VerticalOptions="Start"
                                   Text="{Binding launch_date_utc}" />

                            <!-- Upcoming indicator -->
                            <BoxView Grid.Row="0" Grid.RowSpan="2" Grid.Column="1"
                                     HeightRequest="10" WidthRequest="10"
                                     Margin="20"
                                     CornerRadius="5"
                                     HorizontalOptions="End" VerticalOptions="Center"
                                     Color="#FF543D"
                                     IsVisible="False">
                                <BoxView.Behaviors>
                                    <behaviors:PulseBehavior />
                                </BoxView.Behaviors>
                                <BoxView.Triggers>
                                    <DataTrigger TargetType="BoxView"
                                                 Binding="{Binding Path=upcoming}"
                                                 Value="True">
                                        <DataTrigger.Setters>
                                            <Setter Property="IsVisible" Value="True" />
                                        </DataTrigger.Setters>
                                    </DataTrigger>
                                </BoxView.Triggers>
                            </BoxView>
                        </Grid>
                    </Frame>
                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>
Enter fullscreen mode Exit fullscreen mode

LaunchesPage.xaml.cs
    [XamlCompilation(XamlCompilationOptions.Compile)]
    public partial class LaunchesPage : ContentPage
    {
        public LaunchesPage()
        {
            InitializeComponent();
            BindingContext = new LaunchesPageViewModel();
        }

        private async void OnLaunchSelected(object sender, SelectionChangedEventArgs e)
        {
            if (e.CurrentSelection.Any() && e.CurrentSelection.First() is Types.Launch launch)
            {
                await Navigation.PushAsync(new LaunchPage(new LaunchPageViewModel(launch.id)));

                if (sender is CollectionView cw)
                {
                    cw.SelectedItem = null;
                }
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

PulseBehavior.cs
using Xamarin.Forms;
using Xamarin.Forms.Internals;

namespace SpaceXGraphQL.Behaviors
{
    public class PulseBehavior : Behavior<View>
    {
        private const string PulsingAnimation = "Pulsing";
        private bool _running = false;
        private bool _isReversed = false;

        protected override void OnAttachedTo(View bindable)
        {
            base.OnAttachedTo(bindable);

            _running = true;
            bindable.Animate(PulsingAnimation,
                d =>
                {
                    var newScale = _isReversed ? 1.0d - d : d;
                    bindable.Scale = newScale.Clamp(0.3, 1.0);
                },
                length: 1000,
                easing: Easing.CubicInOut,
                repeat: () =>
                {
                    _isReversed = !_isReversed;
                    return _running;
                });
        }

        protected override void OnDetachingFrom(View bindable)
        {
            base.OnDetachingFrom(bindable);

            _running = false;
            bindable.AbortAnimation(PulsingAnimation);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We're done with the first page and the result looks like this
Launches overview

Now for the second page. It is going to show some launch details like the coolest images of the launch, the launch name and patch, a link to the original article and a way of sharing the images.

We're starting with the view model which I named LaunchPageViewModel. It uses the exact same INotifyPropertyChanged implementation, so I'm skipping over it here.
This view model needs properties to hold the data of a single launch and commands to open the original article link and to share a launch image.

        public Types.Launch Launch
        {
            get => _launch;
            set
            {
                _launch = value;
                OnPropertyChanged();
            }
        }

        public Command ArticleLinkTappedCommand { get; set; }
        public Command ImageTappedCommand { get; set; }
Enter fullscreen mode Exit fullscreen mode

In the constructor, the view model initializes its members and the two commands, making use of Xamarin Essentials for opening links and sharing data.
You'll notice that the constructor take as argument a launch id. That's the id of the launch whose data we're querying and showing.

Finally, here's how the GetLaunch() method looks like

private async Task GetLaunch()
        {
            Launch = null;
            var launchRequest = new GraphQLRequest
            {
                Query = @"query getLaunch($id: ID!) {
                                      launch(id: $id) {
                                        id
                                        is_tentative
                                        upcoming
                                        mission_name
                                        links {
                                          article_link
                                          video_link
                                          flickr_images
                                          mission_patch
                                        }
                                        launch_date_utc
                                        details
                                      }
                                    }",
                Variables = new
                {
                    id = _launchId,
                }
            };

            var response = await _client.SendQueryAsync<Types.Query>(launchRequest);
            if (!(response.Errors ?? Array.Empty<GraphQLError>()).Any())
            {
                Launch = response.Data.launch;
            }
        }
Enter fullscreen mode Exit fullscreen mode

Again, the page is almost exclusively implemented in XAML.

LaunchPage.xaml
<?xml version="1.0" encoding="utf-8"?>

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:converters="clr-namespace:SpaceXGraphQL.Converters;assembly=SpaceXGraphQL"
             x:Class="SpaceXGraphQL.LaunchPage"
             BackgroundColor="#041727"
             x:Name="ParentPage">

    <ContentPage.Resources>
        <converters:GetLastItemConverter x:Key="GetLastItemConverter" />
    </ContentPage.Resources>

    <ScrollView Padding="20, 0">
        <StackLayout>
            <!-- Top image -->
            <Grid VerticalOptions="Start">
                <Frame CornerRadius="20"
                       Padding="0"
                       HasShadow="True"
                       IsClippedToBounds="True">
                    <Image Source="{Binding Launch.links.flickr_images, Converter={StaticResource GetLastItemConverter}}"
                           HorizontalOptions="FillAndExpand"
                           BackgroundColor="#384355"
                           Aspect="AspectFill"
                           HeightRequest="250" />
                </Frame>

                <!-- Patch, name and date -->
                <StackLayout Orientation="Horizontal"
                             HorizontalOptions="Start" VerticalOptions="Start"
                             Margin="20">
                    <Image Source="{Binding Launch.links.mission_patch}"
                           Aspect="AspectFit"
                           HeightRequest="40" WidthRequest="40" />
                    <StackLayout>

                        <Label Text="{Binding Launch.mission_name}"
                               TextColor="White"
                               FontAttributes="Bold" FontSize="Large"
                               HorizontalOptions="Start" VerticalOptions="Center" />
                        <Label Text="{Binding Launch.launch_date_utc}"
                               TextColor="LightGray"
                               FontAttributes="Bold" FontSize="Micro"
                               HorizontalOptions="Start" VerticalOptions="Center" />
                    </StackLayout>
                </StackLayout>
            </Grid>

            <!-- Article link -->
            <Label VerticalOptions="End" HorizontalOptions="End"
                   Margin="0, 5, 20,0">
                <Label.FormattedText>
                    <FormattedString>
                        <Span Text="Article Link" TextColor="RoyalBlue"
                              FontSize="Small" FontAttributes="Bold">
                            <Span.GestureRecognizers>
                                <TapGestureRecognizer Command="{Binding ArticleLinkTappedCommand}" />
                            </Span.GestureRecognizers>
                        </Span>
                    </FormattedString>
                </Label.FormattedText>
            </Label>

            <!-- Description -->
            <Label Text="Description" TextColor="#FF543D" FontSize="Large"
                   FontAttributes="Bold"
                   Margin="0, 20,0,5" />

            <Label Text="{Binding Launch.details}"
                   FontSize="Body"
                   TextColor="White" />

            <!-- Media -->
            <Label Text="Media" TextColor="#FF543D"
                   FontSize="Large" FontAttributes="Bold" Margin="0, 20,0,5" />
            <StackLayout BindableLayout.ItemsSource="{Binding Launch.links.flickr_images}">
                <BindableLayout.EmptyView>
                    <Label Text="Loading ..." TextColor="White" FontSize="Small"
                           HorizontalOptions="Center"/>
                </BindableLayout.EmptyView>

                <!-- Images -->
                <BindableLayout.ItemTemplate>
                    <DataTemplate>
                        <SwipeView>
                            <SwipeView.RightItems>
                                <SwipeItems>
                                    <SwipeItem Text="Share"
                                               BackgroundColor="#FF543D"
                                               Command="{Binding Source={x:Reference ParentPage}, Path=BindingContext.ImageTappedCommand}"
                                               CommandParameter="{Binding .}" />
                                </SwipeItems>
                            </SwipeView.RightItems>
                            <Frame CornerRadius="20"
                                   Padding="0" Margin="0,0,0,5"
                                   HasShadow="True"
                                   IsClippedToBounds="True">
                                <Image Source="{Binding .}"
                                       HorizontalOptions="FillAndExpand"
                                       BackgroundColor="#384355"
                                       Aspect="AspectFill"
                                       HeightRequest="250" />
                            </Frame>
                        </SwipeView>

                    </DataTemplate>
                </BindableLayout.ItemTemplate>
            </StackLayout>
        </StackLayout>
    </ScrollView>
</ContentPage>
Enter fullscreen mode Exit fullscreen mode

LaunchPage.xaml.cs
using Xamarin.Forms;
using Xamarin.Forms.Xaml;

namespace SpaceXGraphQL
{
    [XamlCompilation(XamlCompilationOptions.Compile)]
    public partial class LaunchPage : ContentPage
    {

        public LaunchPage(LaunchPageViewModel viewModel)
        {
            InitializeComponent();
            BindingContext = viewModel;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

GetLastItemConverter.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Xamarin.Forms;

namespace SpaceXGraphQL.Converters
{
    public class GetLastItemConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            if (value is IEnumerable<object> enumerable)
            {
                return new List<object>(enumerable).Last();
            }

            return value;
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We're done with the second and last page and the result looks like this
Launch details

Conclusion

Except for the hiccup while generating the types, connecting to the GraphQL API was pretty easy. Kudos to them for providing the data for free!
Let me know if you're using another tool for code generation which can handle SpaceX's GraphQL schema.

I have to say that implementing the app in two afternoons was a lot of fun. I was pleasantly surprised by how good the Xamarin.Forms hot reload has become. Last time I checked, which was in the summer of 2020, it was much less stable. This time around I could actually use it to prototype my app.

If you've made it this far, I got a bonus for you! Here's the link to my GitHub repo with all the code: https://github.com/mariusmuntean/SpaceXGraphQL

šŸ’– šŸ’Ŗ šŸ™… šŸš©
mariusmuntean
Marius Muntean

Posted on March 2, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related