Skip to content

Latest commit

 

History

History
664 lines (519 loc) · 28.8 KB

README.md

File metadata and controls

664 lines (519 loc) · 28.8 KB

Contents


Description

The Maps App suite showcases best practices in building cross platform applications with the ArcGIS Runtime SDK for .NET. The suite is built using Xamarin Forms and WPF. Comprised of four applications for four separate platforms, it is designed with maximized code sharing in mind.

Platforms supported

  • iOS (iPhone and iPad)
  • Android (phones and tablets)
  • WPF (devices running Windows 7 and above)
  • UWP (devices running Windows 10)

Get your organization's authoritative map data into the hands of your workers with this suite of applications. The applications can be easily customized to include a web map from your ArcGIS Online organization or you can use the Living Atlas as a starting place. The Maps App also includes examples of place searching and routing capabilities using either ArcGIS Online's powerful services or your own services. It can also leverage your organization's configured basemaps to allow users to switch to the basemap that makes the most sense for them.

Functionality showcased

  • Place Search
  • Geocode addresses
  • Reverse Geocode
  • Turn-by-turn Directions
  • Dynamically switch basemaps
  • Open Web Maps
  • Work with ArcGIS Online or an on-premise ArcGIS Portal
  • OAuth2 authentication

Configure this app for your organization or use it to learn how to integrate similar capabilities into your own app.

Overview

Disclaimer: For screen real estate purposes, only Android and WPF screenshots are posted. Since the app was built with Xamarin Forms, iOS and UWP user interfaces are very similar to the Android interface, with exception of the iconography and platform specific controls.

When the app starts, you will be presented with the default empty map. In the initial run of the app, you may be prompted to accept that the app wants to use your current location.

Android WPF

The default map is created inside the MapViewModel.cs by initializing the Map with a new Topographic Vector basemap.

private Map _map = new Map(Basemap.CreateTopographicVector());

/// <summary>
/// Gets or sets the map
/// </summary>
public Map Map
{
    get
    {
        return _map;
    }

    set
    {
        _map = value;
        OnPropertyChanged();
    }
}

Inside the view (XAML), a MapView control is created and its Map property is bound to the Map property on the MapViewModel

Xamarin & WPF:

<esri:MapView x:Name="MapView" Map="{Binding Map, Source={StaticResource MapViewModel}}"/>

Place search & geocoding

At the top of the screen, there is a menu button and a search bar. The search bar provides the geocoding functionality. Geocoding lets you transform an address or a place name to a specific geographic location. Reverse geocoding lets you use a geographic location to find a description of the location, like a postal address or place name.

In the solution, the logic for geocoding is contained inside the shared GeocodeViewModel. First, a LocatorTask is defined to use the ArcGIS World Geocoding Service. Before using the LocatorTask, it must be loaded. The loadable pattern is described here.

/// <summary>
/// Gets the locator
/// </summary>
private LocatorTask Locator { get; set; }

// Load locator
Locator = await LocatorTask.CreateAsync(new Uri(Configuration.GeocodeUrl));

The search box is bound to the SearchText property inside the GeocodeViewModel. When the text inside the search box is changed the SearchText property is updated and begins a new location suggestion search.

Xamarin:

<SearchBar x:Name="AddressSearchBar" Placeholder="Address or Place" Text="{Binding SearchText, Mode=TwoWay}"
    BindingContext="{StaticResource GeocodeViewModel}" SearchCommand="{Binding SearchCommand}" />

WPF:

<TextBox x:Name="SearchTextBox" Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}" />
<Button Command="{Binding SearchCommand}" CommandParameter="{Binding SearchText}" />
/// <summary>
/// Gets or sets the search text the user has entered
/// </summary>
public string SearchText
{
    get
    {
        return _searchText;
    }

    set
    {
        if (_searchText != value)
        {
            _searchText = value;
            OnPropertyChanged();

            if (!string.IsNullOrEmpty(_searchText))
            {
                // Call method to get location suggestions
                GetLocationSuggestionsAsync(_searchText).ContinueWith((t) =>
                {
                    if (t.Status == TaskStatus.RanToCompletion)
                        SuggestionsList = t.Result;
                });
            }
            else
            {
                SuggestionsList = null;
            }
        }
    }
}

Place suggestions

Typing the first few letters of a place into the Maps App search box (e.g. “West End Bikes”) shows a number of suggestions near the device’s location.

Android WPF

This is handled inside the GeocodeViewModel. When the search text property is updated the GetLocationSuggestionsAsync method is called. A check is first performed to ensure the locator supports suggestions. When creating your own locator, you can enable suggestions and thus be able to take advantage of this functionality.

/// <summary>
/// Gets list of suggested locations from the locator based on user input
/// </summary>
private async Task GetLocationSuggestionsAsync(string userInput)
{
    // make sure input is defined and suggestions are supported
    if (Locator?.LocatorInfo?.SupportsSuggestions ?? false && !string.IsNullOrEmpty(userInput))
    {
        // restrict the search to return no more than 10 suggestions
        // set preferred search location to location around current map center
        var suggestParams = new SuggestParameters { MaxResults = 10, PreferredSearchLocation = AreaOfInterest?.TargetGeometry as MapPoint, };

        // get suggestions for the text provided by the user
        var suggestions = await Locator.SuggestAsync(userInput, suggestParams);
        var s = new ObservableCollection<string>();
        foreach (var suggestion in suggestions)
        {
            s.Add(suggestion.Label);
        }
        SuggestionsList = s;
    }
}

Searching from a suggestion

Once a suggestion in the list has been selected by the user, the suggested address is geocoded using the LocatorTask.GeocodeAsync function. Along with the address, specific geocoding parameters can be set to tune the results. For example, in the Maps App, we set the PreferredSearchLocation property to prioritize results closer to the center of the map.

/// <summary>
/// Get location searched by user from the locator
/// </summary>
private async Task<GeocodeResult> GetSearchedLocationAsync(string geocodeAddress)
{
    // Locate the searched feature
    var geocodeParameters = new GeocodeParameters
    {
        MaxResults = 1,
        PreferredSearchLocation = AreaOfInterest?.TargetGeometry as MapPoint,
    };

    // return the first match
    var matches = await Locator.GeocodeAsync(geocodeAddress, geocodeParameters);
    return matches.FirstOrDefault();
}
Android WPF

Routing

Getting routing directions in the Maps App is easy with the Runtime SDK and the ArcGIS World Routing Service. You can also customize your routing service for your organization.

Navigating from point to point in the Maps App is enabled by first geocoding or reverse geocoding a location. You can then get directions to that location from the current GPS location (or if GPS is disabled, from a location of your choice). In the Maps App, routing requires you to provide credentials to your Portal or ArcGIS Online organization. See the Authentication section for more details.

Routing in the app is handled inside the shared RouteViewModel which creates and loads a RouteTask.

/// <summary>
/// Gets the router for the map
/// </summary>
internal RouteTask Router { get; set; }

private async Task CreateRouteTask()
{
    ...
    Router = await RouteTask.CreateAsync(new Uri(Configuration.RouteUrl));
    ...
}

The RouteParameters object is used to specify input parameters such as start point and end point for the route. You can instantiate a new RouteParameters object by using the CreateDefaultParametersAsync function on the RouteTask instance. This retrieves the appropriate default settings for the route service. Then add the stops and request route directions. After setting the parameters, call SolveRouteAsync to request the route from the server.

/// <summary>
/// Generates route from the geocoded locations
/// </summary>
private async Task GetRouteAsync()
{
    // set the route parameters
    var routeParams = await Router.CreateDefaultParametersAsync();
    routeParams.ReturnDirections = true;
    routeParams.ReturnRoutes = true;

    // add route stops as parameters
        routeParams.SetStops(new List<Stop>() { new Stop(FromPlace),
                                                new Stop(ToPlace) });
        Route = await Router.SolveRouteAsync(routeParams);
    }
}
Android WPF

Turn-by-turn directions (direction maneuvers)

After a RouteResult has been retrieved, the list of direction maneuvers is available as a list on each Route. In WPF, the direction maneuvers are displayed on the same page. Due to the smaller screen size in Android, iOS, and UWP the turn-by-turn directions are shown on the TurnByTurnDirections view when you tap the Direction Maneuvers icon. In both cases, the direction maneuvers are set in the shared RouteViewModel.

Android WPF
/// <summary>
/// Gets or sets the turn-by-turn directions for the returned route
/// </summary>
public IReadOnlyList<DirectionManeuver> DirectionManeuvers
{
    get
    {
        return _directionManeuvers;
    }
    set
    {
        if (_directionManeuvers != value)
        {
            _directionManeuvers = value;
            OnPropertyChanged();
        }
    }
}

private async Task GetRouteAsync()
{
    ...
    // Set turn-by-turn directions
    DirectionManeuvers = Route.Routes.FirstOrDefault()?.DirectionManeuvers;
    ...
}

Each of the items in the DirectionManeuvers property inside the RouteViewModel is shown as an item in a ListView. An ItemTemplate is defined to show the image and text of each direction maneuver.

Xamarin:

 <ListView x:Name="DirectionsListView" ItemsSource="{Binding DirectionManeuvers}" CachingStrategy="RecycleElement">
    <ListView.ItemTemplate>
        <DataTemplate>
            <ImageCell ImageSource="{Binding ManeuverType, Converter={StaticResource DirectionManeuverToImagePathConverter}}" Text="{Binding DirectionText}" />
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

WPF:

<ListView ItemsSource="{Binding DirectionManeuvers}">
    <ListView.ItemTemplate>
        <DataTemplate>
            <StackPanel Orientation="Horizontal" >
                <Image Source="{Binding ManeuverType, Converter={StaticResource DirectionManeuverToImagePathConverter}}" />
                <TextBlock  Text="{Binding DirectionText}" />
            </StackPanel>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

Authentication

The Maps App leverages the ArcGIS identity model to provide access to resources via the the named user login pattern. During the routing workflow, or if clicking the Sign In button in the menu, the app prompts you for your organization’s ArcGIS Online credentials. The ArcGIS Runtime SDKs provide a simple to use API for dealing with ArcGIS logins.

The process of accessing token secured services with a challenge handler is illustrated in the following diagram.

  1. A request is made to a secured resource.
  2. The portal responds with an unauthorized access error.
  3. A challenge handler associated with the authentication manager is asked to provide a credential for the portal.
  4. A UI displays and the user is prompted to enter a user name and password.
  5. If the user is successfully authenticated, a credential (token) is included in requests to the secured service.
  6. The authentication manager stores the credential for this portal and all requests for secured content include the token in the request.

For an application to use this pattern, follow these guides to register your app. The AuthenticationManager provided by the ArcGIS Runtime SDK abstracts much of the authentication logic for you. In the Maps App, the AuthenticationManager is configured to prompt the user for credentials.

Android WPF

To set up the AuthenticationManager for the Maps App, we register the server info with the AuthenticationManager and set up the ChallengeHandler.

/// <summary>
/// Set up singleton instance of Authentication manager
/// </summary>
private void UpdateAuthenticationManager()
{
    // Define the server information for ArcGIS Online
    var portalServerInfo = new ServerInfo
    {
        ServerUri = new Uri(Configuration.ArcGISOnlineUrl),
        TokenAuthenticationType = TokenAuthenticationType.OAuthImplicit,
        OAuthClientInfo = new OAuthClientInfo
        {
            ClientId = Configuration.AppClientID,
            RedirectUri = new Uri(Configuration.RedirectURL)
        },
    };

    // Register the ArcGIS Online server information with the AuthenticationManager
    AuthenticationManager.Current.RegisterServer(portalServerInfo);

#if WPF
    // In WPF, use the OAuthAuthorize class to create a new web view to show the sign-in UI
    AuthenticationManager.Current.OAuthAuthorizeHandler = new WPF.Views.OAuthAuthorize();
#endif

    // Create a new ChallengeHandler that uses a method in this class to challenge for credentials
    AuthenticationManager.Current.ChallengeHandler = new ChallengeHandler(CreateCredentialAsync);
}

When a challenge is issued, such as when the user has hit the Sign In button or is attempting a Route, CreateCredentialsAsync is called. The user is prompted to enter username and password, and if the authentication is successful, the credential is stored inside the AuthenticationManager. The AuthenticatedUser is then stored in a separate bindable property.

/// <summary>
/// ChallengeHandler function that will be called whenever access to a secured resource is attempted
/// </summary>
private async Task<Credential> CreateCredentialAsync(CredentialRequestInfo info)
{
    // IOAuthAuthorizeHandler will challenge the user for OAuth credentials
    var credential = await AuthenticationManager.Current.GenerateCredentialAsync(new Uri(Configuration.ArcGISOnlineUrl));
    AuthenticationManager.Current.AddCredential(credential);

    // Create connection to Portal and provide credential
    var portal = await ArcGISPortal.CreateAsync(new Uri(Configuration.ArcGISOnlineUrl), credential);
    AuthenticatedUser = portal.User;
    return credential;
}

A note of caution on Authentication. The implementation used in this and other open source apps is simplified and generalized to apply to an array of apps and scenarios. You should research and carefully consider their own security implementation.

Switching basemaps

As an administrator for your ArcGIS Online Organization, you can create and publish basemaps that your users can switch between. We retrieve the basemaps inside the shared BasemapsViewModel and then display them in the view.

Due to sufficient screen real estate in WPF, the basemap and user map selector can be displayed in the same view. In the Xamarin apps, the basemap and user map selectors are on separate views.

var portal = await ArcGISPortal.CreateAsync();
var items = await portal.GetBasemapsAsync();
Basemaps = items?.Select(b => b.Item).OfType<PortalItem>();

To use you own organization's default basemaps, pass the URL to your organization to CreateAsync.

Each of the items in the Basemaps property inside the BasemapsViewModel is shown as an item in a ListView. An ItemTemplate is defined to show the thumbnail and title of each basemap.

Xamarin:

<!-- List of basemaps for the user to select from -->
<ListView x:Name="BasemapListView" ItemsSource="{Binding Basemaps}" SelectedItem="{Binding SelectedBasemap}" ItemTapped="ListView_ItemTapped" >
    <ListView.ItemTemplate>
        <DataTemplate>
            <ImageCell ImageSource="{Binding ThumbnailUri}" Text="{Binding Title}" Detail="{Binding Snippet}"/>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

WPF:

<ListView  DataContext="{StaticResource BasemapsViewModel}"  ItemsSource="{Binding Basemaps}" SelectedItem="{Binding SelectedBasemap}">
    <ListView.ItemTemplate>
        <DataTemplate>
            <StackPanel Orientation="Vertical" >
                <Image Stretch="UniformToFill"  Source="{Binding ThumbnailUri}" />
                <TextBlock VerticalAlignment="Center" Text="{Binding Title}" />
            </StackPanel>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>
Android WPF

Using web maps

Loading and switching web maps is similar to switching basemaps in Maps App.

You can author your own web maps from ArcGIS Online or ArcGIS Pro and share them in your app via your ArcGIS Online organization. Building an app which uses a web map allows the cartography and map configuration to be completed in ArcGIS Online rather than in code. This then allows the map to change over time, without any code changes or app updates. Learn more about the benefits of developing with web maps here. Also, learn about authoring web maps in ArcGIS Online and ArcGIS Pro.

Once authenticated the shared UserItemsViewModel retrieves the user's web maps from Portal and displays them together with descriptions and thumbnails. When the user chooses a web map, the Map property on the MapViewModel is replaced with a new Map object created from the web map's URL.

// Set the item types you want the user to be able to select from.
// This does not have to be limited to web maps
private static readonly ICollection<PortalItemType> _validUserItemTypes = new PortalItemType[] { PortalItemType.WebMap};

/// <summary>
/// Loads user maps from Portal
/// </summary>
public async Task LoadUserItems()
{
    var portalUser = AuthViewModel.Instance.AuthenticatedUser?.Portal?.User as PortalUser;

    ...
    var userContent = await portalUser.GetContentAsync();

    UserItems = new ObservableCollection<PortalItem>();
    foreach (var item in userContent.Items)
    {
        if (_validUserItemTypes.Contains(item.Type))
            UserItems.Add(item);
    }
    ...
}
Android WPF

Architecture

Solution overview

The four applications that comprise the Maps App suite are all contained inside one solution. The diagram below represents how the solution is structured to make best use of shared logic between the apps:

The MapsApp.iOS, MapsApp.Android and MapsApp.UWP projects belong to the Xamarin Forms part of the solution. The code contained inside the MapsApp.Xamarin.Shared project is common to the three applications. The three apps share UI components and some Xamarin specific logic.

The MapsApp.WPF project has its own UI which is independent of the Xamarin Forms UI.

All four apps share the logic contained inside the MapsApp.Shared project.

Model-View-ViewModel pattern

The app uses the MVVM (Model-View-ViewModel) pattern. It makes heavy use of data binding to separate the presentation layer (the view) from the logic of the application (the view-model and the model), thus facilitating code sharing. Generally speaking, in .NET, business logic is commonly cross-platform compatible, whereas the presentation layer is often not. Learn more about MVVM here.

The application is structured to demonstrate separation of concerns using the Model-View-ViewModel (MVVM) architecture. The views have minimal logic in the code behind file and it is all view related. According to MVVM principles, the model should not know about the view model and the view model should not know about the view. The way the view model communicates with the view is through bindable properties. A good example of this is the Map property on the MapView control.

/// <summary>
/// Gets or sets the map
/// </summary>
public Map Map
{
    get
    {
        return _map;
    }

    set
    {
        _map = value;
        OnPropertyChanged();
    }
}

The map is bound to the Map property on the MapView control in XAML

Xamarin & WPF:

<esri:MapView x:Name="MapView" Map="{Binding Map, Source={StaticResource MapViewModel}}"/>

There are cases however when the properties that need to be set are not bindable. A good example in the Maps App is the Graphic element. It has a Geometry and Symbol, but they are not bindable because they are not dependency properties. But they are UI elements, so setting them inside the view model would not be appropriate as it would cause the view logic to "bleed" into the view model. So setting these properties is done in the code behind of the view.

geocodeViewModel.PropertyChanged += (o, e) =>
{
    switch (e.PropertyName)
    {
        case nameof(GeocodeViewModel.Place):
            {
                var graphicsOverlay = MapView.GraphicsOverlays["PlacesOverlay"];
                graphicsOverlay?.Graphics.Clear();

                GeocodeResult place = geocodeViewModel.Place;

                var graphic = new Graphic(geocodeViewModel.Place.DisplayLocation, new PictureMarkerSymbol(new RuntimeImage(imageData)));
                graphicsOverlay?.Graphics.Add(graphic);

                break;
            }
    }
}

Another option when setting non-bindable properties is through the use of attached properties. In Maps App, we have created an extension to the GeoView which contains a HoldingLocation attached property. The implementation code can be found below. When the GeoViewHolding event fires, the attached property is set and can be bound to from the view model.

/// <summary>
/// Creates a HoldingLocation property
/// </summary>
public static readonly DependencyProperty HoldingLocationProperty = BindingFramework.DependencyProperty.Register("HoldingLocation", typeof(MapPoint), typeof(HoldingLocationController),null);

/// <summary>
/// Invoked when the GeoViewHolding event fires
/// </summary>
private void GeoView_GeoViewHolding(object sender, GeoViewInputEventArgs e)
{
    if (!_isOnHoldingLocationChangedExecuting)
    {
        _isGeoViewHoldingEventFiring = true;
        // get the Location the user is holding from the event args
        HoldingLocation = e.Location;
        _isGeoViewHoldingEventFiring = false;
    }
}

Xamarin & WPF:

<utils:GeoViewExtensions.HoldingLocationController>
    <utils:HoldingLocationController HoldingLocation="{Binding ReverseGeocodeInputLocation, Mode=TwoWay, Source={StaticResource GeocodeViewModel}}"/>
</utils:GeoViewExtensions.HoldingLocationController>

Xamarin and WPF - platform differences

There are significant differences between Xamarin Forms and WPF and not just in the XAML syntax. A detailed explanation of the differences can be found here. In the Maps App, one way we overcome such differences is by using shims.

For example, we register a Xamarin BindableProperty as a DependencyProperty and provide the equivalent methods and properties.

/// <summary>
/// Provides members for registering BindableProperty instances
/// </summary>
internal class DependencyProperty
{
    /// <summary>
    /// Registers a BindableProperty for the specified type
    /// </summary>
    internal static BindableProperty Register(string propertyName, Type returnType, Type declaringType, PropertyMetadata metadata)
    {
        BindableProperty prop = null;

        if (metadata != null)
        {
            prop = BindableProperty.Create(propertyName, returnType, declaringType,
              metadata.DefaultValue, BindingMode.OneWay, null, metadata.BindablePropertyChanged);
            metadata.Property = prop;
        }
        else
        {
            prop = BindableProperty.Create(propertyName, returnType, declaringType,
              GetDefaultValue(returnType), BindingMode.OneWay, null, null);
        }

        return prop;
    }
}

Then in the shared code, we define the BindableProperty using aliases

#if __IOS__ || __ANDROID__ || NETFX_CORE
using DependencyProperty = Xamarin.Forms.BindableProperty;
#endif

Configuration and customization

The Maps App can be easily configured. The Configuration file contains all the information you need to modify the app to work with your organization's data.

// URL of the server to authenticate with (ArcGIS Online)
public const string ArcGISOnlineUrl = @"https://www.arcgis.com/sharing/rest";

// Client ID for the app registered with the server
public const string AppClientID = "YourClientID";

// Redirect URL after a successful authorization (configured for the Maps App application)
public const string RedirectURL = @"https://developers.arcgis.com";

// Url used for the geocode service
public const string GeocodeUrl = @"https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer";

// Url used for the routing service
public const string RouteUrl = @"https://route.arcgis.com/arcgis/rest/services/World/Route/NAServer/Route_World";

If changing the above mentioned values does not provide you with all the needed functionality, you are welcome to customize the app by modifying the source code and making the app your own. And if you happen to fix a bug, or add an enhancement that you think others may want, please submit a pull request and we will happily review it and incorporate it into the main app.