In a previous article, I talked about how to show a route based on two location points and simulate tracking. This time I will be extending that sample to support searching places so that we can specify the origin and destination for our route, for that will use Google Place API.
The result will look like this:
Let’s code
Before we start, if you haven’t read the previous article, I recommend you to check it out, because in the first steps I explain how to integrate Google Maps, create a service and consume it. Knowing that we can get started:
1-Integrate the Google Place API.
To get places will be using two endpoints:
- api/place/autocomplete (More info): this endpoint gets a list of places based on a text query.
- api/place/details (More info): this endpoint gets the place details based on a PlaceId obtained using the previous endpoint.
With this will implement an autocomplete which will show a list of places based on the text query, then get the place details when tapping in a specific place of the list.
Let’s define an interface to consume this API service.
using System.Threading.Tasks; | |
using TrackingSample.Models; | |
namespace TrackingSample.Services | |
{ | |
public interface IGoogleMapsApiService | |
{ | |
Task<GooglePlaceAutoCompleteResult> GetPlaces(string text); | |
Task<GooglePlace> GetPlaceDetails(string placeId); | |
} | |
} |
In the GetPlaces method will pass as parameter the text query typed by the user. The method returns a GooglePlaceAutoCompleteResult. (Copy code here).
In the GetPlaceDetails method, will pass the PlaceId and it returns a GooglePlace. (Copy code here).
Now let’s implement the IGoogleMapsApiService to consume the Google Maps Place API.
using System; | |
using System.Net.Http; | |
using System.Net.Http.Headers; | |
using System.Threading.Tasks; | |
using Newtonsoft.Json; | |
using Newtonsoft.Json.Linq; | |
using TrackingSample.Models; | |
namespace TrackingSample.Services | |
{ | |
public class GoogleMapsApiService : IGoogleMapsApiService | |
{ | |
static string _googleMapsKey; | |
private const string ApiBaseAddress = "https://maps.googleapis.com/maps/"; | |
private HttpClient CreateClient() | |
{ | |
var httpClient = new HttpClient | |
{ | |
BaseAddress = new Uri(ApiBaseAddress) | |
}; | |
httpClient.DefaultRequestHeaders.Accept.Clear(); | |
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); | |
return httpClient; | |
} | |
public static void Initialize(string googleMapsKey) | |
{ | |
_googleMapsKey = googleMapsKey; | |
} | |
public async Task<GooglePlaceAutoCompleteResult> GetPlaces(string text) | |
{ | |
GooglePlaceAutoCompleteResult results = null; | |
using (var httpClient = CreateClient()) | |
{ | |
var response = await httpClient.GetAsync($"api/place/autocomplete/json?input={Uri.EscapeUriString(text)}&key={_googleMapsKey}").ConfigureAwait(false); | |
if (response.IsSuccessStatusCode) | |
{ | |
var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); | |
if (!string.IsNullOrWhiteSpace(json) && json != "ERROR") | |
{ | |
results = await Task.Run(() => | |
JsonConvert.DeserializeObject<GooglePlaceAutoCompleteResult>(json) | |
).ConfigureAwait(false); | |
} | |
} | |
} | |
return results; | |
} | |
public async Task<GooglePlace> GetPlaceDetails(string placeId) | |
{ | |
GooglePlace result = null; | |
using (var httpClient = CreateClient()) | |
{ | |
var response = await httpClient.GetAsync($"api/place/details/json?placeid={Uri.EscapeUriString(placeId)}&key={_googleMapsKey}").ConfigureAwait(false); | |
if (response.IsSuccessStatusCode) | |
{ | |
var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); | |
if (!string.IsNullOrWhiteSpace(json) && json != "ERROR") | |
{ | |
result = new GooglePlace(JObject.Parse(json)); | |
} | |
} | |
} | |
return result; | |
} | |
} | |
} |
2-Create a ViewModel to call the GoogleMapsApiService
In the ViewModel, we will have two methods to consume each endpoint and handle the place search logic.
To handle the origin and destination we will use two string properties bound to each entry so that each time one changes will do the GetPlaces request and populate the list of the places to show the results.
using System; | |
using System.Collections.Generic; | |
using System.Collections.ObjectModel; | |
using System.ComponentModel; | |
using System.Linq; | |
using System.Threading.Tasks; | |
using System.Windows.Input; | |
using TrackingSample.Helpers; | |
using TrackingSample.Models; | |
using TrackingSample.Services; | |
using Xamarin.Forms; | |
namespace TrackingSample.ViewModels | |
{ | |
public class MainViewModel: INotifyPropertyChanged | |
{ | |
public ICommand CalculateRouteCommand { get; set; } | |
public ICommand UpdatePositionCommand { get; set; } | |
public ICommand LoadRouteCommand { get; set; } | |
public ICommand StopRouteCommand { get; set; } | |
IGoogleMapsApiService googleMapsApi = new GoogleMapsApiService(); | |
bool _hasRouteRunning; | |
string _originLatitud; | |
string _originLongitud; | |
string _destinationLatitud; | |
string _destinationLongitud; | |
GooglePlaceAutoCompletePrediction _placeSelected; | |
public GooglePlaceAutoCompletePrediction PlaceSelected { get | |
{ | |
return _placeSelected; | |
} | |
set | |
{ | |
_placeSelected= value; | |
if (_placeSelected != null) | |
GetPlaceDetailCommand.Execute(_placeSelected); | |
} | |
} | |
public ICommand FocusOriginCommand { get; set; } | |
public ICommand GetPlacesCommand { get; set; } | |
public ICommand GetPlaceDetailCommand { get; set; } | |
public ObservableCollection<GooglePlaceAutoCompletePrediction> Places { get; set; } | |
public ObservableCollection<GooglePlaceAutoCompletePrediction> RecentPlaces { get; set; } = new ObservableCollection<GooglePlaceAutoCompletePrediction>(); | |
public bool ShowRecentPlaces { get; set; } | |
bool _isPickupFocused = true; | |
string _pickupText; | |
public string PickupText | |
{ | |
get | |
{ | |
return _pickupText; | |
} | |
set | |
{ | |
_pickupText = value; | |
if (!string.IsNullOrEmpty(_pickupText)) | |
{ | |
_isPickupFocused = true; | |
GetPlacesCommand.Execute(_pickupText); | |
} | |
} | |
} | |
string _originText; | |
public string OriginText | |
{ | |
get | |
{ | |
return _originText; | |
} | |
set | |
{ | |
_originText = value; | |
if (!string.IsNullOrEmpty(_originText)) | |
{ | |
_isPickupFocused = false; | |
GetPlacesCommand.Execute(_originText); | |
} | |
} | |
} | |
public MainViewModel() | |
{ | |
LoadRouteCommand = new Command(async () => await LoadRoute()); | |
StopRouteCommand = new Command(StopRoute); | |
GetPlacesCommand = new Command<string>(async (param) => await GetPlacesByName(param)); | |
GetPlaceDetailCommand = new Command<GooglePlaceAutoCompletePrediction>(async (param) => await GetPlacesDetail(param)); | |
} | |
public async Task LoadRoute() | |
{ | |
var positionIndex = 1; | |
var googleDirection = await googleMapsApi.GetDirections(_originLatitud, _originLongitud, _destinationLatitud, _destinationLongitud); | |
if(googleDirection.Routes!=null && googleDirection.Routes.Count>0) | |
{ | |
var positions = (Enumerable.ToList(PolylineHelper.Decode(googleDirection.Routes.First().OverviewPolyline.Points))); | |
CalculateRouteCommand.Execute(positions); | |
_hasRouteRunning = true; | |
//Location tracking simulation | |
Device.StartTimer(TimeSpan.FromSeconds(1),() => | |
{ | |
if(positions.Count>positionIndex && _hasRouteRunning) | |
{ | |
UpdatePositionCommand.Execute(positions[positionIndex]); | |
positionIndex++; | |
return true; | |
} | |
else | |
{ | |
return false; | |
} | |
}); | |
} | |
else | |
{ | |
await App.Current.MainPage.DisplayAlert(":(", "No route found", "Ok"); | |
} | |
} | |
public void StopRoute() | |
{ | |
_hasRouteRunning = false; | |
} | |
public async Task GetPlacesByName(string placeText) | |
{ | |
var places = await googleMapsApi.GetPlaces(placeText); | |
var placeResult= places.AutoCompletePlaces; | |
if (placeResult != null && placeResult.Count > 0) | |
{ | |
Places = new ObservableCollection<GooglePlaceAutoCompletePrediction>(placeResult); | |
} | |
ShowRecentPlaces = (placeResult == null || placeResult.Count ==0); | |
} | |
public async Task GetPlacesDetail(GooglePlaceAutoCompletePrediction placeA) | |
{ | |
var place = await googleMapsApi.GetPlaceDetails(placeA.PlaceId); | |
if (place != null) | |
{ | |
if (_isPickupFocused) | |
{ | |
PickupText = place.Name; | |
_originLatitud = $"{place.Latitude}"; | |
_originLongitud = $"{place.Longitude}"; | |
_isPickupFocused = false; | |
FocusOriginCommand.Execute(null); | |
} | |
else | |
{ | |
_destinationLatitud = $"{place.Latitude}"; | |
_destinationLongitud = $"{place.Longitude}"; | |
RecentPlaces.Add(placeA); | |
if (_originLatitud == _destinationLatitud && _originLongitud == _destinationLongitud) | |
{ | |
await App.Current.MainPage.DisplayAlert("Error", "Origin route should be different than destination route", "Ok"); | |
} | |
else | |
{ | |
LoadRouteCommand.Execute(null); | |
await App.Current.MainPage.Navigation.PopAsync(false); | |
CleanFields(); | |
} | |
} | |
} | |
} | |
void CleanFields() | |
{ | |
PickupText = OriginText = string.Empty; | |
ShowRecentPlaces = true; | |
PlaceSelected = null; | |
} | |
public event PropertyChangedEventHandler PropertyChanged; | |
} | |
} |
3-Add a Search in the Map Page
In our actual map page, we will add a view to search place, when tapping on it will navigate to a new page called SearchPlacePage in which we will search for places to set our route.
Also, will add a Stop button, so that when it is tracking will show it and hide the search view. When tapping on this button it will stop the tracking, hide this button and make the search view visible again.
<?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:vm="clr-namespace:TrackingSample.ViewModels" | |
xmlns:maps="clr-namespace:Xamarin.Forms.GoogleMaps;assembly=Xamarin.Forms.GoogleMaps" | |
x:Class="TrackingSample.MainPage" | |
NavigationPage.BackButtonTitle="" | |
NavigationPage.HasNavigationBar="false" | |
CalculateCommand="{Binding CalculateRouteCommand}" | |
UpdateCommand="{Binding UpdatePositionCommand}"> | |
<ContentPage.BindingContext> | |
<vm:MainViewModel/> | |
</ContentPage.BindingContext> | |
<Grid> | |
<maps:Map x:Name="map" | |
InitialCameraUpdate="40.77, -73.93, 13, 30, 60" | |
VerticalOptions="FillAndExpand" | |
HorizontalOptions="FillAndExpand"/> | |
<StackLayout BackgroundColor="White" | |
Orientation="Horizontal" | |
VerticalOptions="Start" | |
Padding="10" | |
x:Name="searchLayout" | |
Margin="30,50,30,0"> | |
<Image Source="ic_search" | |
HeightRequest="15" | |
WidthRequest="15"/> | |
<Label HorizontalOptions="FillAndExpand" | |
LineBreakMode="TailTruncation" | |
FontAttributes="Italic" | |
Text="Search Place" | |
TextColor="Gray"/> | |
<StackLayout.GestureRecognizers> | |
<TapGestureRecognizer | |
Tapped="OnEnterAddressTapped" /> | |
</StackLayout.GestureRecognizers> | |
</StackLayout> | |
<Button Text="Stop" | |
Margin="40" | |
Clicked="Handle_Stop_Clicked" | |
Command="{Binding StopRouteCommand}" | |
IsVisible="false" | |
x:Name="stopRouteButton" | |
BackgroundColor="Red" | |
TextColor="White" | |
FontSize="17" | |
VerticalOptions="EndAndExpand" | |
HorizontalOptions="FillAndExpand"/> | |
</Grid> | |
</ContentPage> |
using System; | |
using System.Collections.Generic; | |
using System.Linq; | |
using System.Windows.Input; | |
using TrackingSample.Views; | |
using Xamarin.Forms; | |
using Xamarin.Forms.GoogleMaps; | |
namespace TrackingSample | |
{ | |
public partial class MainPage : ContentPage | |
{ | |
public static readonly BindableProperty CalculateCommandProperty = | |
BindableProperty.Create(nameof(CalculateCommand), typeof(ICommand), typeof(MainPage), null, BindingMode.TwoWay); | |
public ICommand CalculateCommand | |
{ | |
get { return (ICommand)GetValue(CalculateCommandProperty); } | |
set { SetValue(CalculateCommandProperty, value); } | |
} | |
public static readonly BindableProperty UpdateCommandProperty = | |
BindableProperty.Create(nameof(UpdateCommand), typeof(ICommand), typeof(MainPage), null, BindingMode.TwoWay); | |
public ICommand UpdateCommand | |
{ | |
get { return (ICommand)GetValue(UpdateCommandProperty); } | |
set { SetValue(UpdateCommandProperty, value); } | |
} | |
public MainPage() | |
{ | |
InitializeComponent(); | |
CalculateCommand = new Command<List<Xamarin.Forms.GoogleMaps.Position>>(Calculate); | |
UpdateCommand = new Command<Xamarin.Forms.GoogleMaps.Position>(Update); | |
} | |
async void Update(Xamarin.Forms.GoogleMaps.Position position) | |
{ | |
if (map.Pins.Count == 1 && map.Polylines!=null&& map.Polylines?.Count>1) | |
return; | |
var cPin = map.Pins.FirstOrDefault(); | |
if (cPin != null) | |
{ | |
cPin.Position = new Position(position.Latitude, position.Longitude); | |
cPin.Icon = BitmapDescriptorFactory.FromView(new Image() { Source = "ic_taxi.png", WidthRequest = 25, HeightRequest = 25 }); | |
await map.MoveCamera(CameraUpdateFactory.NewPosition(new Position(position.Latitude, position.Longitude))); | |
var previousPosition = map?.Polylines?.FirstOrDefault()?.Positions?.FirstOrDefault(); | |
map.Polylines?.FirstOrDefault()?.Positions?.Remove(previousPosition.Value); | |
} | |
else | |
{ | |
//END TRIP | |
map.Polylines?.FirstOrDefault()?.Positions?.Clear(); | |
} | |
} | |
void Calculate(List<Xamarin.Forms.GoogleMaps.Position> list) | |
{ | |
searchLayout.IsVisible = false; | |
stopRouteButton.IsVisible = true; | |
map.Polylines.Clear(); | |
var polyline = new Xamarin.Forms.GoogleMaps.Polyline(); | |
foreach (var p in list) | |
{ | |
polyline.Positions.Add(p); | |
} | |
map.Polylines.Add(polyline); | |
map.MoveToRegion(MapSpan.FromCenterAndRadius(new Position(polyline.Positions[0].Latitude, polyline.Positions[0].Longitude), Xamarin.Forms.GoogleMaps.Distance.FromMiles(0.50f))); | |
var pin = new Xamarin.Forms.GoogleMaps.Pin | |
{ | |
Type = PinType.Place, | |
Position = new Position(polyline.Positions.First().Latitude, polyline.Positions.First().Longitude), | |
Label = "First", | |
Address = "First", | |
Tag = string.Empty, | |
Icon = BitmapDescriptorFactory.FromView(new Image() { Source = "ic_taxi.png", WidthRequest = 25, HeightRequest = 25 }) | |
}; | |
map.Pins.Add(pin); | |
var pin1 = new Xamarin.Forms.GoogleMaps.Pin | |
{ | |
Type = PinType.Place, | |
Position = new Position(polyline.Positions.Last().Latitude, polyline.Positions.Last().Longitude), | |
Label = "Last", | |
Address = "Last", | |
Tag = string.Empty | |
}; | |
map.Pins.Add(pin1); | |
} | |
public async void OnEnterAddressTapped(object sender, EventArgs e) | |
{ | |
await Navigation.PushAsync(new SearchPlacePage() { BindingContext = this.BindingContext }, false); | |
} | |
public void Handle_Stop_Clicked(object sender, EventArgs e) | |
{ | |
searchLayout.IsVisible = true; | |
stopRouteButton.IsVisible = false; | |
map.Polylines.Clear(); | |
map.Pins.Clear(); | |
} | |
} | |
} |
NOTE: If you don’t want to graph and track the route is not necessary to use the CalculateCommand and UpdateCommand.
4-Add the search place AutoComplete UI
As shown at the GIF at the beginning of this post, we will have two entries, one to specify the Origin and another one for the Destination.
Also, will have ListView to show the place search results.
<?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:ios="clr-namespace:Xamarin.Forms.PlatformConfiguration.iOSSpecific;assembly=Xamarin.Forms.Core" | |
ios:Page.UseSafeArea="true" | |
x:Class="TrackingSample.Views.SearchPlacePage" | |
FocusOriginCommand="{Binding FocusOriginCommand}"> | |
<Grid VerticalOptions="FillAndExpand" | |
BackgroundColor="White" | |
RowSpacing="0" | |
ColumnSpacing="0"> | |
<Grid.RowDefinitions> | |
<RowDefinition Height="Auto"/> | |
<RowDefinition Height="Auto"/> | |
<RowDefinition Height="Auto"/> | |
</Grid.RowDefinitions> | |
<Grid.ColumnDefinitions> | |
<ColumnDefinition Width="*"/> | |
</Grid.ColumnDefinitions> | |
<StackLayout Grid.Row="0" | |
Padding="10" | |
BackgroundColor="LightGray" | |
Orientation="Horizontal"> | |
<Image Source="ic_search" | |
HeightRequest="20" | |
WidthRequest="20"/> | |
<Entry Placeholder="Enter Pickup" | |
Text="{Binding PickupText}" | |
FontSize="18" | |
x:Name="originEntry" | |
ClassId="origin" | |
FontAttributes="Italic" | |
ReturnType="Search" | |
HorizontalOptions="FillAndExpand" /> | |
</StackLayout> | |
<StackLayout Grid.Row="1" | |
Padding="10" | |
BackgroundColor="LightGray" | |
Orientation="Horizontal"> | |
<Image Source="ic_search" | |
HeightRequest="20" | |
WidthRequest="20"/> | |
<Entry Placeholder="Where to?" | |
Text="{Binding OriginText}" | |
FontSize="18" | |
x:Name="destinationEntry" | |
ClassId="destination" | |
FontAttributes="Italic" | |
ReturnType="Search" | |
HorizontalOptions="FillAndExpand" /> | |
</StackLayout> | |
<ListView VerticalOptions="FillAndExpand" | |
BackgroundColor="Transparent" | |
Grid.Row="2" | |
Footer="" | |
SelectedItem="{Binding PlaceSelected, Mode=TwoWay}" | |
x:Name="list" | |
ItemsSource="{Binding Places}" | |
HasUnevenRows="true" | |
SeparatorColor="Gray" | |
ios:ListView.SeparatorStyle="FullWidth"> | |
<ListView.Triggers> | |
<DataTrigger TargetType="ListView" | |
Binding="{Binding ShowRecentPlaces}" | |
Value="True"> | |
<Setter Property="ItemsSource" Value="{Binding RecentPlaces}" /> | |
</DataTrigger> | |
<DataTrigger TargetType="ListView" | |
Binding="{Binding ShowRecentPlaces}" | |
Value="False"> | |
<Setter Property="ItemsSource" Value="{Binding Places}" /> | |
</DataTrigger> | |
</ListView.Triggers> | |
<ListView.Header> | |
<StackLayout x:Name="recentSearchText" | |
IsVisible="{Binding ShowRecentPlaces}"> | |
<Label LineBreakMode="WordWrap" | |
FontAttributes="Bold" | |
Margin="20,10" | |
x:Name="recentSearch" | |
Text="History"/> | |
</StackLayout> | |
</ListView.Header> | |
<ListView.ItemTemplate> | |
<DataTemplate> | |
<ViewCell> | |
<Grid Padding="15" | |
RowSpacing="2" | |
ColumnSpacing="15"> | |
<Grid.RowDefinitions> | |
<RowDefinition Height="Auto"/> | |
<RowDefinition Height="Auto"/> | |
</Grid.RowDefinitions> | |
<Grid.ColumnDefinitions> | |
<ColumnDefinition Width="Auto"/> | |
<ColumnDefinition Width="*"/> | |
</Grid.ColumnDefinitions> | |
<Image Source="ic_location" | |
HeightRequest="20" | |
WidthRequest="20" | |
VerticalOptions="Start" | |
Grid.Row="0" | |
Grid.Column="0" | |
Grid.RowSpan="2"/> | |
<Label LineBreakMode="MiddleTruncation" | |
Text="{Binding StructuredFormatting.MainText}" | |
Grid.Row="0" | |
Grid.Column="1"/> | |
<Label LineBreakMode="MiddleTruncation" | |
Text="{Binding StructuredFormatting.SecondaryText}" | |
TextColor="Gray" | |
Grid.Row="1" | |
Grid.Column="1"/> | |
</Grid> | |
</ViewCell> | |
</DataTemplate> | |
</ListView.ItemTemplate> | |
</ListView> | |
</Grid> | |
</ContentPage> |
In the code behind, will add a Command property called FocusOriginCommand that will focus the destination entry. It will be called from the ViewModel after origin place is set.
using System.Windows.Input; | |
using Xamarin.Forms; | |
namespace TrackingSample.Views | |
{ | |
public partial class SearchPlacePage : ContentPage | |
{ | |
public static readonly BindableProperty FocusOriginCommandProperty = | |
BindableProperty.Create(nameof(FocusOriginCommand), typeof(ICommand), typeof(SearchPlacePage), null, BindingMode.TwoWay); | |
public ICommand FocusOriginCommand | |
{ | |
get { return (ICommand)GetValue(FocusOriginCommandProperty); } | |
set { SetValue(FocusOriginCommandProperty, value); } | |
} | |
public SearchPlacePage() | |
{ | |
InitializeComponent(); | |
} | |
protected override void OnBindingContextChanged() | |
{ | |
base.OnBindingContextChanged(); | |
if (BindingContext != null) | |
{ | |
FocusOriginCommand = new Command(OnOriginFocus); | |
} | |
} | |
void OnOriginFocus() | |
{ | |
destinationEntry.Focus(); | |
} | |
} | |
} |
That’s it!
I will be publishing more content about using google maps in Xamarin Forms (How to add a CSS, how to choose a pickup point, how to handle a re-routing logic, etc), so keep in touch :).
You can check the full source code here.
Happy place searching!