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
Next, install these dev dependencies
yarn add -D graphql @graphql-codegen/c-sharp @graphql-codegen/cli @graphql-codegen/introspection @graphql-codegen/typescript
Then add the following script property to your package.json
"scripts": {
"generate": "graphql-codegen --config codegen.yml"
}
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"
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.
Now install the referenced dependencies with
yarn install
And run the script
yarn generate
Our classes were generated! Yay! 🎉
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" />
Next, add the generated classes to your shared project. I actually added the whole yarn project folder
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>
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));
}
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();
}
}
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);
}
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);
}
}
}
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>
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;
}
}
}
}
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);
}
}
}
We're done with the first page and the result looks like this
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; }
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;
}
}
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>
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;
}
}
}
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();
}
}
}
We're done with the second and last page and the result looks like this
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
Top comments (2)
Great article!
Thanks!