diff --git a/README.md b/README.md
index 57f101aa1..73b174646 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,7 @@ There are two ways to add Toolkit to your project:
- [**OverviewMap**](https://esri.github.io/arcgis-toolkit-dotnet/overview-map.html): Displays an interactive inset map for a map or scene.
- **PopupViewer**: Display details and media, edit attributes, geometry and related records, and manage the attachments of features and graphics (popups are defined in the popup property of features and graphics).
- **ScaleLine**: Displays current scale reference.
+- **[SearchView](https://esri.github.io/arcgis-toolkit-dotnet/search-view.html)**: Enables searching using one or more locators, with support for suggestions, automatic zooming, and custom search sources.
- **SymbolDisplay**: Renders a symbol in a control.
- **TimeSlider**: Allows interactively defining a temporal range (i.e. time extent) and animating time moving forward or backward. Can be used to manipulate the time extent in a MapView or SceneView.
diff --git a/docs/controls.md b/docs/controls.md
index 369dcca28..b0f8b4702 100644
--- a/docs/controls.md
+++ b/docs/controls.md
@@ -66,6 +66,11 @@ Displays current scale reference.
![ScaleLine](https://user-images.githubusercontent.com/1378165/73390077-3debb900-428a-11ea-8b2f-dfd4914a637e.png)
+### SearchView
+
+Enables searching using one or more locators, with support for suggestions, automatic zooming, and custom search sources.
+
+![SearchView](https://user-images.githubusercontent.com/29742178/142301018-4bbeb0f2-3021-49a7-b5ec-f642c5700bd0.png)
### SymbolDisplay
@@ -94,6 +99,7 @@ Allows interactively defining a temporal range (i.e. time extent) and animating
|[OverviewMap](overview-map.md) | ✔ | ✔ | ❌ | ❌ | ✔ |
|PopupViewer | ✔ | ✔ | ✔ | ✔ | ✔ |
|ScaleLine | ✔ | ✔ | ✔ | ✔ | ✔ |
+|[SearchView](search-view.md) | ✔ | ✔ | ❌ | ❌ | ✔ |
|SignInForm | | Preview | | | |
|SymbolDisplay | ✔ | ✔ | ✔ | ✔ | ✔ |
|TableOfContents | N/A | Preview | N/A | N/A | N/A |
diff --git a/docs/search-view.md b/docs/search-view.md
new file mode 100644
index 000000000..c8ce91a18
--- /dev/null
+++ b/docs/search-view.md
@@ -0,0 +1,62 @@
+# Search View
+
+Search View enables searching using one or more locators, with support for suggestions, automatic zooming, and custom search sources.
+
+![image](https://user-images.githubusercontent.com/29742178/142301018-4bbeb0f2-3021-49a7-b5ec-f642c5700bd0.png)
+
+> **NOTE**: Search View uses metered ArcGIS services by default, so you will need to configure an API key. See [Security and authentication documentation](https://developers.arcgis.com/documentation/mapping-apis-and-services/security/#api-keys) for more information.
+
+
+## Features
+
+- Updates search suggestions as you type
+- Supports using the Esri world geocoder or any other ArcGIS locators
+- Supports searching using custom search sources
+- Supports searching multiple sources simultaneously
+- Allows for customization of the display of search results
+- Allows you to repeat a search within a defined area, and shows a button to enable that search when the view's viewpoint changes
+- Separates the behavior (`SearchViewModel`) and the display (`SearchView`) to allow you to create a custom UI if needed
+
+## Customization
+
+The following properties enable customization of the view:
+
+- `EnableAutomaticConfiguration` - Controls whether view is automatically configured for the attached GeoView's map or scene. By default, this will set up a single World Geocoder search source. In future releases, this behavior may be extended to support other web map configuration options.
+- `EnableRepeatSearchHereButton` - Controls whether a 'Repeat Search Here' button is shown when the user navigates the attached GeoView after a search is completed.
+- `EnableResultListView` - Controls whether a result list is displayed.
+- `EnableIndividualResultDisplay` - Controls whether the result list is shown when there is only one result.
+- `MutlipleResultZoomBuffer` - Controls the buffer distance around collection results when a GeoView is attached and a search has multiple results.
+
+## Usage - WPF
+
+```xaml
+
+
+
+
+```
+
+## Usage - UWP
+
+```xaml
+
+
+
+
+```
+
+## Usage - Xamarin.Forms
+
+SearchView shows results in a list on top of underlying content, so it is best to position the view near the top of the page, on top of the MapView or SceneView.
+
+```xaml
+
+
+
+
+
+
+
+
+
+```
\ No newline at end of file
diff --git a/src/Samples/Toolkit.SampleApp.UWP/Samples/SearchView/SearchViewCustomizationSample.xaml b/src/Samples/Toolkit.SampleApp.UWP/Samples/SearchView/SearchViewCustomizationSample.xaml
new file mode 100644
index 000000000..e1661762b
--- /dev/null
+++ b/src/Samples/Toolkit.SampleApp.UWP/Samples/SearchView/SearchViewCustomizationSample.xaml
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Samples/Toolkit.SampleApp.UWP/Samples/SearchView/SearchViewCustomizationSample.xaml.cs b/src/Samples/Toolkit.SampleApp.UWP/Samples/SearchView/SearchViewCustomizationSample.xaml.cs
new file mode 100644
index 000000000..e8ac4449d
--- /dev/null
+++ b/src/Samples/Toolkit.SampleApp.UWP/Samples/SearchView/SearchViewCustomizationSample.xaml.cs
@@ -0,0 +1,69 @@
+using Esri.ArcGISRuntime.Mapping;
+using Esri.ArcGISRuntime.Toolkit.UI.Controls;
+using System;
+using Windows.UI.Popups;
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+
+namespace Esri.ArcGISRuntime.Toolkit.SampleApp.Samples.SearchView
+{
+ public sealed partial class SearchViewCustomizationSample : Page
+ {
+ public SearchViewCustomizationSample()
+ {
+ InitializeComponent();
+ MyMapView.Map = new Map(BasemapStyle.ArcGISImagery);
+ MySearchView.GeoView = MyMapView;
+ }
+
+ private void GeoViewConnection_Checked(object sender, RoutedEventArgs e)
+ {
+ if (EnableGeoViewBindingCheck.IsChecked ?? false)
+ {
+ MySearchView.GeoView = MyMapView;
+ }
+ else
+ {
+ MySearchView.GeoView = null;
+ }
+ }
+
+ private async void AddDefaultLocator_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ var source = await LocatorSearchSource.CreateDefaultSourceAsync();
+ source.DisplayName = GeocoderNameTextBox.Text;
+ MySearchView.SearchViewModel.Sources.Add(source);
+ }
+ catch (Exception ex)
+ {
+ await new MessageDialog(ex.Message, "Error").ShowAsync();
+ }
+ }
+
+ private void RemoveLocator_Click(object sender, RoutedEventArgs e)
+ {
+ if (MySearchView.SearchViewModel?.Sources?.Count > 0)
+ {
+ MySearchView.SearchViewModel.Sources.RemoveAt(MySearchView.SearchViewModel.Sources.Count - 1);
+ }
+ }
+
+ private void ComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ switch (SearchModeCombo.SelectedIndex)
+ {
+ case 0:
+ MySearchView.SearchViewModel.SearchMode = SearchResultMode.Automatic;
+ break;
+ case 1:
+ MySearchView.SearchViewModel.SearchMode = SearchResultMode.Single;
+ break;
+ case 2:
+ MySearchView.SearchViewModel.SearchMode = SearchResultMode.Multiple;
+ break;
+ }
+ }
+ }
+}
diff --git a/src/Samples/Toolkit.SampleApp.UWP/Samples/SearchView/SearchViewMapSample.xaml b/src/Samples/Toolkit.SampleApp.UWP/Samples/SearchView/SearchViewMapSample.xaml
new file mode 100644
index 000000000..49899435d
--- /dev/null
+++ b/src/Samples/Toolkit.SampleApp.UWP/Samples/SearchView/SearchViewMapSample.xaml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
diff --git a/src/Samples/Toolkit.SampleApp.UWP/Samples/SearchView/SearchViewMapSample.xaml.cs b/src/Samples/Toolkit.SampleApp.UWP/Samples/SearchView/SearchViewMapSample.xaml.cs
new file mode 100644
index 000000000..3b1246f83
--- /dev/null
+++ b/src/Samples/Toolkit.SampleApp.UWP/Samples/SearchView/SearchViewMapSample.xaml.cs
@@ -0,0 +1,14 @@
+using Esri.ArcGISRuntime.Mapping;
+using Windows.UI.Xaml.Controls;
+
+namespace Esri.ArcGISRuntime.Toolkit.SampleApp.Samples.SearchView
+{
+ public sealed partial class SearchViewMapSample : Page
+ {
+ public SearchViewMapSample()
+ {
+ InitializeComponent();
+ MyMapView.Map = new Map(BasemapStyle.ArcGISImagery);
+ }
+ }
+}
diff --git a/src/Samples/Toolkit.SampleApp.UWP/Samples/SearchView/SearchViewSceneSample.xaml b/src/Samples/Toolkit.SampleApp.UWP/Samples/SearchView/SearchViewSceneSample.xaml
new file mode 100644
index 000000000..ce2d4b273
--- /dev/null
+++ b/src/Samples/Toolkit.SampleApp.UWP/Samples/SearchView/SearchViewSceneSample.xaml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
diff --git a/src/Samples/Toolkit.SampleApp.UWP/Samples/SearchView/SearchViewSceneSample.xaml.cs b/src/Samples/Toolkit.SampleApp.UWP/Samples/SearchView/SearchViewSceneSample.xaml.cs
new file mode 100644
index 000000000..8402b02a4
--- /dev/null
+++ b/src/Samples/Toolkit.SampleApp.UWP/Samples/SearchView/SearchViewSceneSample.xaml.cs
@@ -0,0 +1,14 @@
+using Esri.ArcGISRuntime.Mapping;
+using Windows.UI.Xaml.Controls;
+
+namespace Esri.ArcGISRuntime.Toolkit.SampleApp.Samples.SearchView
+{
+ public sealed partial class SearchViewSceneSample : Page
+ {
+ public SearchViewSceneSample()
+ {
+ InitializeComponent();
+ MySceneView.Scene = new Scene(BasemapStyle.ArcGISImagery);
+ }
+ }
+}
diff --git a/src/Samples/Toolkit.SampleApp.UWP/Toolkit.Samples.UWP.csproj b/src/Samples/Toolkit.SampleApp.UWP/Toolkit.Samples.UWP.csproj
index 877766a63..990dd5e58 100644
--- a/src/Samples/Toolkit.SampleApp.UWP/Toolkit.Samples.UWP.csproj
+++ b/src/Samples/Toolkit.SampleApp.UWP/Toolkit.Samples.UWP.csproj
@@ -163,6 +163,15 @@
PopupViewerSample.xaml
+
+ SearchViewMapSample.xaml
+
+
+ SearchViewSceneSample.xaml
+
+
+ SearchViewCustomizationSample.xaml
+
SymbolEditorSample.xaml
@@ -252,6 +261,18 @@
MSBuild:Compile
Designer
+
+ MSBuild:Compile
+ Designer
+
+
+ MSBuild:Compile
+ Designer
+
+
+ MSBuild:Compile
+ Designer
+
MSBuild:Compile
Designer
diff --git a/src/Samples/Toolkit.SampleApp.WPF_Net5/Samples/SearchView/SearchViewCustomizationSample.xaml b/src/Samples/Toolkit.SampleApp.WPF_Net5/Samples/SearchView/SearchViewCustomizationSample.xaml
new file mode 100644
index 000000000..443664002
--- /dev/null
+++ b/src/Samples/Toolkit.SampleApp.WPF_Net5/Samples/SearchView/SearchViewCustomizationSample.xaml
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Samples/Toolkit.SampleApp.WPF_Net5/Samples/SearchView/SearchViewCustomizationSample.xaml.cs b/src/Samples/Toolkit.SampleApp.WPF_Net5/Samples/SearchView/SearchViewCustomizationSample.xaml.cs
new file mode 100644
index 000000000..b27d9f90a
--- /dev/null
+++ b/src/Samples/Toolkit.SampleApp.WPF_Net5/Samples/SearchView/SearchViewCustomizationSample.xaml.cs
@@ -0,0 +1,94 @@
+using Esri.ArcGISRuntime.Mapping;
+using Esri.ArcGISRuntime.Tasks.Geocoding;
+using Esri.ArcGISRuntime.Toolkit.UI.Controls;
+using System;
+using System.IO;
+using System.Windows;
+using System.Windows.Controls;
+
+namespace Esri.ArcGISRuntime.Toolkit.Samples.SearchView
+{
+ public partial class SearchViewCustomizationSample : UserControl
+ {
+ public SearchViewCustomizationSample()
+ {
+ InitializeComponent();
+ MyMapView.Map = new Map(BasemapStyle.ArcGISImagery);
+ MySearchView.GeoView = MyMapView;
+ }
+
+ private void GeoViewConnection_Checked(object sender, System.Windows.RoutedEventArgs e)
+ {
+ if (EnableGeoViewBindingCheck.IsChecked ?? false)
+ {
+ MySearchView.GeoView = MyMapView;
+ }
+ else
+ {
+ MySearchView.GeoView = null;
+ }
+ }
+
+ private async void AddDefaultLocator_Click(object sender, System.Windows.RoutedEventArgs e)
+ {
+ try
+ {
+ var source = await LocatorSearchSource.CreateDefaultSourceAsync();
+ source.DisplayName = GeocoderNameTextBox.Text;
+ MySearchView.SearchViewModel.Sources.Add(source);
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show("Error", ex.Message);
+ }
+ }
+
+ private async void AddSMPLocator_Click(object sender, RoutedEventArgs e)
+ {
+ // NOTE: You can download a sample of StreetMap Premium for testing purposes by visiting the downloads section of the ArcGIS Developer dashboard.
+ string path = LocatorPathText.Text;
+ if (!File.Exists(path))
+ {
+ MessageBox.Show("Error", "Input Path Does Not Exist");
+ return;
+ }
+
+ try
+ {
+ MobileMapPackage mmpk = await MobileMapPackage.OpenAsync(path);
+ if (mmpk.LocatorTask is LocatorTask packagedLocator)
+ {
+ MySearchView.SearchViewModel.Sources.Add(new LocatorSearchSource(packagedLocator));
+ }
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show("Error", ex.Message);
+ }
+ }
+
+ private void RemoveLocator_Click(object sender, RoutedEventArgs e)
+ {
+ if (MySearchView.SearchViewModel?.Sources?.Count > 0)
+ {
+ MySearchView.SearchViewModel.Sources.RemoveAt(MySearchView.SearchViewModel.Sources.Count - 1);
+ }
+ }
+
+ private void ComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ switch (SearchModeCombo.SelectedIndex)
+ {
+ case 0:
+ MySearchView.SearchViewModel.SearchMode = SearchResultMode.Automatic;
+ break;
+ case 1:
+ MySearchView.SearchViewModel.SearchMode = SearchResultMode.Single;
+ break;
+ case 2:
+ MySearchView.SearchViewModel.SearchMode = SearchResultMode.Multiple;
+ break;
+ }
+ }
+ }
+}
diff --git a/src/Samples/Toolkit.SampleApp.WPF_Net5/Samples/SearchView/SearchViewMapSample.xaml b/src/Samples/Toolkit.SampleApp.WPF_Net5/Samples/SearchView/SearchViewMapSample.xaml
new file mode 100644
index 000000000..a0f9e5056
--- /dev/null
+++ b/src/Samples/Toolkit.SampleApp.WPF_Net5/Samples/SearchView/SearchViewMapSample.xaml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/src/Samples/Toolkit.SampleApp.WPF_Net5/Samples/SearchView/SearchViewMapSample.xaml.cs b/src/Samples/Toolkit.SampleApp.WPF_Net5/Samples/SearchView/SearchViewMapSample.xaml.cs
new file mode 100644
index 000000000..ae58c7ef0
--- /dev/null
+++ b/src/Samples/Toolkit.SampleApp.WPF_Net5/Samples/SearchView/SearchViewMapSample.xaml.cs
@@ -0,0 +1,14 @@
+using Esri.ArcGISRuntime.Mapping;
+using System.Windows.Controls;
+
+namespace Esri.ArcGISRuntime.Toolkit.Samples.SearchView
+{
+ public partial class SearchViewMapSample : UserControl
+ {
+ public SearchViewMapSample()
+ {
+ InitializeComponent();
+ MyMapView.Map = new Map(BasemapStyle.ArcGISImagery);
+ }
+ }
+}
diff --git a/src/Samples/Toolkit.SampleApp.WPF_Net5/Samples/SearchView/SearchViewSceneSample.xaml b/src/Samples/Toolkit.SampleApp.WPF_Net5/Samples/SearchView/SearchViewSceneSample.xaml
new file mode 100644
index 000000000..a586cd5fe
--- /dev/null
+++ b/src/Samples/Toolkit.SampleApp.WPF_Net5/Samples/SearchView/SearchViewSceneSample.xaml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/src/Samples/Toolkit.SampleApp.WPF_Net5/Samples/SearchView/SearchViewSceneSample.xaml.cs b/src/Samples/Toolkit.SampleApp.WPF_Net5/Samples/SearchView/SearchViewSceneSample.xaml.cs
new file mode 100644
index 000000000..3df61d704
--- /dev/null
+++ b/src/Samples/Toolkit.SampleApp.WPF_Net5/Samples/SearchView/SearchViewSceneSample.xaml.cs
@@ -0,0 +1,14 @@
+using Esri.ArcGISRuntime.Mapping;
+using System.Windows.Controls;
+
+namespace Esri.ArcGISRuntime.Toolkit.Samples.SearchView
+{
+ public partial class SearchViewSceneSample : UserControl
+ {
+ public SearchViewSceneSample()
+ {
+ InitializeComponent();
+ MySceneView.Scene = new Scene(BasemapStyle.ArcGISImagery);
+ }
+ }
+}
diff --git a/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Samples/SearchViewCustomizationSample.xaml b/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Samples/SearchViewCustomizationSample.xaml
new file mode 100644
index 000000000..cc30bb922
--- /dev/null
+++ b/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Samples/SearchViewCustomizationSample.xaml
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Automatic
+ Single
+ Multiple
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Samples/SearchViewCustomizationSample.xaml.cs b/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Samples/SearchViewCustomizationSample.xaml.cs
new file mode 100644
index 000000000..7f1ab6d72
--- /dev/null
+++ b/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Samples/SearchViewCustomizationSample.xaml.cs
@@ -0,0 +1,70 @@
+using Esri.ArcGISRuntime.Mapping;
+using Esri.ArcGISRuntime.Toolkit.UI.Controls;
+using System;
+using Xamarin.Forms;
+using Xamarin.Forms.Xaml;
+
+namespace Toolkit.Samples.Forms.Samples
+{
+ [XamlCompilation(XamlCompilationOptions.Compile)]
+ [SampleInfoAttribute(Category = "SearchView", Description = "Exercises bindings and advanced customization options.")]
+ public partial class SearchViewCustomizationSample : ContentPage
+ {
+ public SearchViewCustomizationSample()
+ {
+ InitializeComponent();
+ MyMapView.Map = new Map(BasemapStyle.ArcGISImagery);
+ MySearchView.GeoView = MyMapView;
+ }
+
+ private void GeoViewConnection_Checked(object sender, CheckedChangedEventArgs e)
+ {
+ if (e.Value)
+ {
+ MySearchView.GeoView = MyMapView;
+ }
+ else
+ {
+ MySearchView.GeoView = null;
+ }
+ }
+
+ private async void AddDefaultLocator_Click(object sender, EventArgs e)
+ {
+ try
+ {
+ var source = await LocatorSearchSource.CreateDefaultSourceAsync();
+ source.DisplayName = GeocoderNameEntry.Text;
+ MySearchView.SearchViewModel.Sources.Add(source);
+ }
+ catch (Exception ex)
+ {
+ await DisplayAlert("Error", ex.Message, "Ok");
+ }
+ }
+
+ private void RemoveLocator_Click(object sender, EventArgs e)
+ {
+ if (MySearchView.SearchViewModel?.Sources?.Count > 0)
+ {
+ MySearchView.SearchViewModel.Sources.RemoveAt(MySearchView.SearchViewModel.Sources.Count - 1);
+ }
+ }
+
+ private void SearchModePicker_SelectedIndexChanged(object sender, EventArgs e)
+ {
+ switch (SearchModePicker.SelectedIndex)
+ {
+ case 0:
+ MySearchView.SearchViewModel.SearchMode = SearchResultMode.Automatic;
+ break;
+ case 1:
+ MySearchView.SearchViewModel.SearchMode = SearchResultMode.Single;
+ break;
+ case 2:
+ MySearchView.SearchViewModel.SearchMode = SearchResultMode.Multiple;
+ break;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Samples/SearchViewSample.xaml b/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Samples/SearchViewSample.xaml
new file mode 100644
index 000000000..bec460f1c
--- /dev/null
+++ b/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Samples/SearchViewSample.xaml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Samples/SearchViewSample.xaml.cs b/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Samples/SearchViewSample.xaml.cs
new file mode 100644
index 000000000..74e507781
--- /dev/null
+++ b/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Samples/SearchViewSample.xaml.cs
@@ -0,0 +1,17 @@
+using Esri.ArcGISRuntime.Mapping;
+using Xamarin.Forms;
+using Xamarin.Forms.Xaml;
+
+namespace Toolkit.Samples.Forms.Samples
+{
+ [XamlCompilation(XamlCompilationOptions.Compile)]
+ [SampleInfoAttribute(Category = "SearchView", Description = "Demonstrates SearchView used with a map.")]
+ public partial class SearchViewSample : ContentPage
+ {
+ public SearchViewSample()
+ {
+ InitializeComponent();
+ MyMapView.Map = new Map(BasemapStyle.ArcGISImagery);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Samples/SearchViewSceneSample.xaml b/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Samples/SearchViewSceneSample.xaml
new file mode 100644
index 000000000..a9c59230f
--- /dev/null
+++ b/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Samples/SearchViewSceneSample.xaml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Samples/SearchViewSceneSample.xaml.cs b/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Samples/SearchViewSceneSample.xaml.cs
new file mode 100644
index 000000000..854760ecf
--- /dev/null
+++ b/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Samples/SearchViewSceneSample.xaml.cs
@@ -0,0 +1,17 @@
+using Esri.ArcGISRuntime.Mapping;
+using Xamarin.Forms;
+using Xamarin.Forms.Xaml;
+
+namespace Toolkit.Samples.Forms.Samples
+{
+ [XamlCompilation(XamlCompilationOptions.Compile)]
+ [SampleInfoAttribute(Category = "SearchView", Description = "Demonstrates SearchView used with a scene.")]
+ public partial class SearchViewSceneSample : ContentPage
+ {
+ public SearchViewSceneSample()
+ {
+ InitializeComponent();
+ MySceneView.Scene = new Scene(BasemapStyle.ArcGISImagery);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Toolkit.Samples.Forms.csproj b/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Toolkit.Samples.Forms.csproj
index c38f4f12d..75611e18e 100644
--- a/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Toolkit.Samples.Forms.csproj
+++ b/src/Samples/Toolkit.Samples.Forms/Toolkit.Samples.Forms/Toolkit.Samples.Forms.csproj
@@ -52,6 +52,9 @@
PopupViewerSample.xaml
+
+ SearchViewSample.xaml
+
SymbolEditorSample.xaml
@@ -79,6 +82,9 @@
MSBuild:UpdateDesignTimeXaml
+
+ MSBuild:UpdateDesignTimeXaml
+
MSBuild:UpdateDesignTimeXaml
diff --git a/src/Toolkit.Forms/Assets/caret-down-small.png b/src/Toolkit.Forms/Assets/caret-down-small.png
new file mode 100644
index 000000000..09e08449f
Binary files /dev/null and b/src/Toolkit.Forms/Assets/caret-down-small.png differ
diff --git a/src/Toolkit.Forms/Assets/caret-down.png b/src/Toolkit.Forms/Assets/caret-down.png
new file mode 100644
index 000000000..a11165120
Binary files /dev/null and b/src/Toolkit.Forms/Assets/caret-down.png differ
diff --git a/src/Toolkit.Forms/Assets/pin-tear-small.png b/src/Toolkit.Forms/Assets/pin-tear-small.png
new file mode 100644
index 000000000..59ad32144
Binary files /dev/null and b/src/Toolkit.Forms/Assets/pin-tear-small.png differ
diff --git a/src/Toolkit.Forms/Assets/pin-tear.png b/src/Toolkit.Forms/Assets/pin-tear.png
new file mode 100644
index 000000000..947a10274
Binary files /dev/null and b/src/Toolkit.Forms/Assets/pin-tear.png differ
diff --git a/src/Toolkit.Forms/Assets/search-small.png b/src/Toolkit.Forms/Assets/search-small.png
new file mode 100644
index 000000000..9bd8f49cb
Binary files /dev/null and b/src/Toolkit.Forms/Assets/search-small.png differ
diff --git a/src/Toolkit.Forms/Assets/search.png b/src/Toolkit.Forms/Assets/search.png
new file mode 100644
index 000000000..6358543c2
Binary files /dev/null and b/src/Toolkit.Forms/Assets/search.png differ
diff --git a/src/Toolkit.Forms/Assets/x-small.png b/src/Toolkit.Forms/Assets/x-small.png
new file mode 100644
index 000000000..3f9dc9b49
Binary files /dev/null and b/src/Toolkit.Forms/Assets/x-small.png differ
diff --git a/src/Toolkit.Forms/Assets/x.png b/src/Toolkit.Forms/Assets/x.png
new file mode 100644
index 000000000..0839a62b0
Binary files /dev/null and b/src/Toolkit.Forms/Assets/x.png differ
diff --git a/src/Toolkit.Forms/BasemapGallery/ByteArrayToImageSourceConverter.cs b/src/Toolkit.Forms/BasemapGallery/ByteArrayToImageSourceConverter.cs
index 876e96c0f..a3a9139a6 100644
--- a/src/Toolkit.Forms/BasemapGallery/ByteArrayToImageSourceConverter.cs
+++ b/src/Toolkit.Forms/BasemapGallery/ByteArrayToImageSourceConverter.cs
@@ -26,7 +26,7 @@ internal class ByteArrayToImageSourceConverter : IValueConverter
///
/// Converts a byte array to an image source for display in Xamarin.Forms.
///
- public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is byte[] rawBuffer)
{
diff --git a/src/Toolkit.Forms/Esri.ArcGISRuntime.Toolkit.Xamarin.Forms.csproj b/src/Toolkit.Forms/Esri.ArcGISRuntime.Toolkit.Xamarin.Forms.csproj
index 3c7f36a02..c3a06eb06 100644
--- a/src/Toolkit.Forms/Esri.ArcGISRuntime.Toolkit.Xamarin.Forms.csproj
+++ b/src/Toolkit.Forms/Esri.ArcGISRuntime.Toolkit.Xamarin.Forms.csproj
@@ -40,6 +40,14 @@
+
+
+
+
+
+
+
+
@@ -74,6 +82,14 @@
+
+
+
+
+
+
+
+
diff --git a/src/Toolkit.Forms/SearchView/BoolToCollectionIconImageConverter.cs b/src/Toolkit.Forms/SearchView/BoolToCollectionIconImageConverter.cs
new file mode 100644
index 000000000..bf1708612
--- /dev/null
+++ b/src/Toolkit.Forms/SearchView/BoolToCollectionIconImageConverter.cs
@@ -0,0 +1,56 @@
+// /*******************************************************************************
+// * Copyright 2012-2018 Esri
+// *
+// * Licensed under the Apache License, Version 2.0 (the "License");
+// * you may not use this file except in compliance with the License.
+// * You may obtain a copy of the License at
+// *
+// * http://www.apache.org/licenses/LICENSE-2.0
+// *
+// * Unless required by applicable law or agreed to in writing, software
+// * distributed under the License is distributed on an "AS IS" BASIS,
+// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// * See the License for the specific language governing permissions and
+// * limitations under the License.
+// ******************************************************************************/
+
+using System;
+using System.Globalization;
+using System.IO;
+using System.Reflection;
+using Xamarin.Forms;
+
+namespace Esri.ArcGISRuntime.Toolkit.Xamarin.Forms
+{
+ internal class BoolToCollectionIconImageConverter : IValueConverter
+ {
+ ///
+ /// Converts a bool to an icon representing either a search (true) or an individual result (false).
+ ///
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value is bool boolvalue && boolvalue)
+ {
+ if (Device.RuntimePlatform == Device.UWP)
+ {
+ return ImageSource.FromResource("Esri.ArcGISRuntime.Toolkit.Xamarin.Forms.Assets.search-small.png", Assembly.GetAssembly(typeof(BoolToCollectionIconImageConverter)));
+ }
+
+ return ImageSource.FromResource("Esri.ArcGISRuntime.Toolkit.Xamarin.Forms.Assets.search.png", Assembly.GetAssembly(typeof(BoolToCollectionIconImageConverter)));
+ }
+
+ if (Device.RuntimePlatform == Device.UWP)
+ {
+ return ImageSource.FromResource("Esri.ArcGISRuntime.Toolkit.Xamarin.Forms.Assets.pin-tear-small.png", Assembly.GetAssembly(typeof(BoolToCollectionIconImageConverter)));
+ }
+
+ return ImageSource.FromResource("Esri.ArcGISRuntime.Toolkit.Xamarin.Forms.Assets.pin-tear.png", Assembly.GetAssembly(typeof(BoolToCollectionIconImageConverter)));
+ }
+
+ ///
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
diff --git a/src/Toolkit.Forms/SearchView/EmptyStringToBoolConverter.cs b/src/Toolkit.Forms/SearchView/EmptyStringToBoolConverter.cs
new file mode 100644
index 000000000..9cc79f127
--- /dev/null
+++ b/src/Toolkit.Forms/SearchView/EmptyStringToBoolConverter.cs
@@ -0,0 +1,46 @@
+// /*******************************************************************************
+// * Copyright 2012-2018 Esri
+// *
+// * Licensed under the Apache License, Version 2.0 (the "License");
+// * you may not use this file except in compliance with the License.
+// * You may obtain a copy of the License at
+// *
+// * http://www.apache.org/licenses/LICENSE-2.0
+// *
+// * Unless required by applicable law or agreed to in writing, software
+// * distributed under the License is distributed on an "AS IS" BASIS,
+// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// * See the License for the specific language governing permissions and
+// * limitations under the License.
+// ******************************************************************************/
+
+using System;
+using System.Globalization;
+using System.IO;
+using System.Reflection;
+using Xamarin.Forms;
+
+namespace Esri.ArcGISRuntime.Toolkit.Xamarin.Forms
+{
+ internal class EmptyStringToBoolConverter : IValueConverter
+ {
+ ///
+ /// Converts a string to a bool, true if not empty, false otherwise.
+ ///
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value is string stringvalue && !string.IsNullOrEmpty(stringvalue))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
diff --git a/src/Toolkit.Forms/SearchView/SearchView.Appearance.cs b/src/Toolkit.Forms/SearchView/SearchView.Appearance.cs
new file mode 100644
index 000000000..9b677b89e
--- /dev/null
+++ b/src/Toolkit.Forms/SearchView/SearchView.Appearance.cs
@@ -0,0 +1,201 @@
+// /*******************************************************************************
+// * Copyright 2012-2018 Esri
+// *
+// * Licensed under the Apache License, Version 2.0 (the "License");
+// * you may not use this file except in compliance with the License.
+// * You may obtain a copy of the License at
+// *
+// * http://www.apache.org/licenses/LICENSE-2.0
+// *
+// * Unless required by applicable law or agreed to in writing, software
+// * distributed under the License is distributed on an "AS IS" BASIS,
+// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// * See the License for the specific language governing permissions and
+// * limitations under the License.
+// ******************************************************************************/
+using System.ComponentModel;
+using Esri.ArcGISRuntime.Toolkit.UI.Controls;
+using Xamarin.Forms;
+using Grid = Xamarin.Forms.Grid;
+using XForms = Xamarin.Forms.Xaml;
+
+namespace Esri.ArcGISRuntime.Toolkit.Xamarin.Forms
+{
+ public partial class SearchView : TemplatedView, INotifyPropertyChanged
+ {
+#pragma warning disable SA1306, SA1310, SX1309 // Field names should begin with lower-case letter
+ private Entry? PART_Entry;
+ private ImageButton? PART_CancelButton;
+ private ImageButton? PART_SearchButton;
+ private ImageButton? PART_SourceSelectButton;
+ private Label? PART_ResultLabel;
+ private ListView? PART_SuggestionsView;
+ private ListView? PART_ResultView;
+ private ListView? PART_SourcesView;
+ private Button? PART_RepeatButton;
+ private Grid? PART_ResultContainer;
+ private Grid? PART_RepeatButtonContainer;
+#pragma warning restore SA1306, SA1310, SX1309 // Field names should begin with lower-case letter
+
+ private static readonly DataTemplate DefaultResultTemplate;
+ private static readonly DataTemplate DefaultSuggestionTemplate;
+ private static readonly DataTemplate DefaultSuggestionGroupHeaderTemplate;
+ private static readonly ControlTemplate DefaultControlTemplate;
+ private static readonly ByteArrayToImageSourceConverter ImageSourceConverter;
+ private static readonly BoolToCollectionIconImageConverter CollectionIconConverter;
+ private static readonly EmptyStringToBoolConverter EmptyStringConverter;
+
+ static SearchView()
+ {
+ ImageSourceConverter = new ByteArrayToImageSourceConverter();
+ CollectionIconConverter = new BoolToCollectionIconImageConverter();
+ EmptyStringConverter = new EmptyStringToBoolConverter();
+ DefaultSuggestionGroupHeaderTemplate = new DataTemplate(() =>
+ {
+ var viewcell = new ViewCell();
+ Grid containingGrid = new Grid();
+ containingGrid.BackgroundColor = Color.FromHex("#4e4e4e");
+
+ Label textLabel = new Label();
+ textLabel.SetBinding(Label.TextProperty, "Key.DisplayName");
+ textLabel.Margin = new Thickness(4);
+ textLabel.TextColor = Color.White;
+ textLabel.FontSize = 14;
+ textLabel.VerticalTextAlignment = TextAlignment.Center;
+ containingGrid.Children.Add(textLabel);
+ viewcell.View = containingGrid;
+ return viewcell;
+ });
+ DefaultSuggestionTemplate = new DataTemplate(() =>
+ {
+ var viewCell = new ViewCell();
+
+ Grid containingGrid = new Grid();
+
+ containingGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ containingGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Star });
+ containingGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+
+ Grid textStack = new Grid();
+ textStack.VerticalOptions = LayoutOptions.Center;
+ textStack.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+ textStack.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+
+ Image imageView = new Image();
+ imageView.SetBinding(Image.SourceProperty, nameof(SearchSuggestion.IsCollection), converter: CollectionIconConverter);
+ imageView.WidthRequest = 16;
+ imageView.HeightRequest = 16;
+ imageView.Margin = new Thickness(4);
+ imageView.VerticalOptions = LayoutOptions.Center;
+
+ Label titleLabel = new Label();
+ titleLabel.SetBinding(Label.TextProperty, nameof(SearchSuggestion.DisplayTitle));
+ titleLabel.VerticalOptions = LayoutOptions.End;
+ titleLabel.VerticalTextAlignment = TextAlignment.End;
+ titleLabel.TextColor = Color.Black;
+
+ Label subtitleLabel = new Label();
+ subtitleLabel.SetBinding(Label.TextProperty, nameof(SearchSuggestion.DisplaySubtitle));
+ subtitleLabel.SetBinding(Label.IsVisibleProperty, nameof(SearchSuggestion.DisplaySubtitle), converter: EmptyStringConverter);
+ subtitleLabel.VerticalOptions = LayoutOptions.Start;
+ subtitleLabel.VerticalTextAlignment = TextAlignment.Start;
+ subtitleLabel.TextColor = Color.Black;
+
+ textStack.Children.Add(titleLabel);
+ textStack.Children.Add(subtitleLabel);
+ Grid.SetRow(titleLabel, 0);
+ Grid.SetRow(subtitleLabel, 1);
+
+ containingGrid.Children.Add(imageView);
+ containingGrid.Children.Add(textStack);
+
+ Grid.SetColumn(textStack, 1);
+ Grid.SetColumn(imageView, 0);
+
+ viewCell.View = containingGrid;
+ return viewCell;
+ });
+ DefaultResultTemplate = new DataTemplate(() =>
+ {
+ var viewCell = new ViewCell();
+
+ Grid containingGrid = new Grid();
+ containingGrid.Padding = new Thickness(2, 4, 2, 4);
+
+ containingGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ containingGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Star });
+ containingGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+
+ Grid textStack = new Grid();
+ textStack.VerticalOptions = LayoutOptions.Center;
+ textStack.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+ textStack.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+
+ Image imageView = new Image();
+ imageView.SetBinding(Image.SourceProperty, nameof(SearchResult.MarkerImageData), converter: ImageSourceConverter);
+ imageView.WidthRequest = 24;
+ imageView.HeightRequest = 24;
+ imageView.Margin = new Thickness(4);
+ imageView.VerticalOptions = LayoutOptions.Center;
+
+ Label titleLabel = new Label();
+ titleLabel.SetBinding(Label.TextProperty, nameof(SearchResult.DisplayTitle));
+ titleLabel.FontAttributes = FontAttributes.Bold;
+ titleLabel.VerticalOptions = LayoutOptions.End;
+ titleLabel.VerticalTextAlignment = TextAlignment.End;
+ titleLabel.TextColor = Color.Black;
+
+ Label subtitleLabel = new Label();
+ subtitleLabel.SetBinding(Label.TextProperty, nameof(SearchResult.DisplaySubtitle));
+ subtitleLabel.SetBinding(Label.IsVisibleProperty, nameof(SearchResult.DisplaySubtitle), converter: EmptyStringConverter);
+ subtitleLabel.TextColor = Color.Black;
+ subtitleLabel.VerticalOptions = LayoutOptions.Start;
+ subtitleLabel.VerticalTextAlignment = TextAlignment.Start;
+
+ textStack.Children.Add(titleLabel);
+ textStack.Children.Add(subtitleLabel);
+ Grid.SetRow(titleLabel, 0);
+ Grid.SetRow(subtitleLabel, 1);
+
+ containingGrid.Children.Add(imageView);
+ containingGrid.Children.Add(textStack);
+
+ Grid.SetColumn(textStack, 1);
+ Grid.SetColumn(imageView, 0);
+
+ viewCell.View = containingGrid;
+ return viewCell;
+ });
+
+ string template =
+$@"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+";
+ DefaultControlTemplate = XForms.Extensions.LoadFromXaml(new ControlTemplate(), template);
+ }
+ }
+}
diff --git a/src/Toolkit.Forms/SearchView/SearchView.cs b/src/Toolkit.Forms/SearchView/SearchView.cs
new file mode 100644
index 000000000..07cc52cd9
--- /dev/null
+++ b/src/Toolkit.Forms/SearchView/SearchView.cs
@@ -0,0 +1,951 @@
+// /*******************************************************************************
+// * Copyright 2012-2018 Esri
+// *
+// * Licensed under the Apache License, Version 2.0 (the "License");
+// * you may not use this file except in compliance with the License.
+// * You may obtain a copy of the License at
+// *
+// * http://www.apache.org/licenses/LICENSE-2.0
+// *
+// * Unless required by applicable law or agreed to in writing, software
+// * distributed under the License is distributed on an "AS IS" BASIS,
+// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// * See the License for the specific language governing permissions and
+// * limitations under the License.
+// ******************************************************************************/
+using System;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Linq;
+using System.Reflection;
+using System.Threading.Tasks;
+using System.Windows.Input;
+using Esri.ArcGISRuntime.Geometry;
+using Esri.ArcGISRuntime.Mapping;
+using Esri.ArcGISRuntime.Toolkit.Internal;
+using Esri.ArcGISRuntime.Toolkit.UI.Controls;
+using Esri.ArcGISRuntime.UI;
+using Esri.ArcGISRuntime.Xamarin.Forms;
+using Xamarin.Forms;
+using Grid = Xamarin.Forms.Grid;
+
+namespace Esri.ArcGISRuntime.Toolkit.Xamarin.Forms
+{
+ ///
+ /// View for searching with locators and custom search sources.
+ ///
+ public partial class SearchView : TemplatedView, INotifyPropertyChanged
+ {
+ // Controls how long the control waits after typing stops before looking for suggestions.
+ private const int TypingDelayMilliseconds = 75;
+ private GeoModel? _lastUsedGeomodel;
+ private readonly GraphicsOverlay _resultOverlay;
+
+ // Flag indicates whether control is waiting after user finished typing.
+ private bool _waitFlag;
+
+ private bool _configureMapFlag;
+
+ // Flag indicating that query text is changing as a result of selecting a suggestion; view should not request suggestions in response to the user suggesting a selection.
+ private bool _acceptingSuggestionFlag;
+
+ private bool _sourceSelectToggled;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public SearchView()
+ {
+ ResultTemplate = DefaultResultTemplate;
+ SuggestionTemplate = DefaultSuggestionTemplate;
+ ControlTemplate = DefaultControlTemplate;
+ SuggestionGroupHeaderTemplate = DefaultSuggestionGroupHeaderTemplate;
+
+ string suffix = Device.RuntimePlatform == Device.UWP ? "-small" : string.Empty;
+ if (GetTemplateChild(nameof(PART_SourceSelectButton)) is ImageButton newSourceButton)
+ {
+ newSourceButton.Source = ImageSource.FromResource($"Esri.ArcGISRuntime.Toolkit.Xamarin.Forms.Assets.caret-down{suffix}.png", Assembly.GetAssembly(typeof(SearchView)));
+ }
+
+ if (GetTemplateChild(nameof(PART_SearchButton)) is ImageButton newsearchButton)
+ {
+ newsearchButton.Source = ImageSource.FromResource($"Esri.ArcGISRuntime.Toolkit.Xamarin.Forms.Assets.search{suffix}.png", Assembly.GetAssembly(typeof(SearchView)));
+ }
+
+ if (GetTemplateChild(nameof(PART_CancelButton)) is ImageButton cancelButton)
+ {
+ cancelButton.Source = ImageSource.FromResource($"Esri.ArcGISRuntime.Toolkit.Xamarin.Forms.Assets.x{suffix}.png", Assembly.GetAssembly(typeof(SearchView)));
+ }
+
+ BindingContext = this;
+ SearchViewModel = new SearchViewModel();
+ NoResultMessage = "No Results";
+ AllSourcesSelectText = "All Sources";
+ RepeatSearchButtonText = "Repeat Search Here";
+ _resultOverlay = new GraphicsOverlay { Id = "SearchView_Result_Overlay" };
+ ClearCommand = new DelegateCommand(HandleClearSearchCommand);
+ SearchCommand = new DelegateCommand(HandleSearchCommand);
+ RepeatSearchHereCommand = new DelegateCommand(HandleRepeatSearchHereCommand);
+ }
+
+ ///
+ /// Gets a command that clears the current search.
+ ///
+ public ICommand ClearCommand { get; private set; }
+
+ ///
+ /// Gets a command that starts a search with current parameters.
+ ///
+ public ICommand SearchCommand { get; private set; }
+
+ ///
+ /// Gets a command that repeats the last search with new geometry.
+ ///
+ public ICommand RepeatSearchHereCommand { get; private set; }
+
+ ///
+ protected override void OnApplyTemplate()
+ {
+ if (PART_SourceSelectButton != null)
+ {
+ PART_SourceSelectButton.Clicked -= PART_SourceSelectButton_Clicked;
+ }
+
+ if (PART_Entry != null)
+ {
+ PART_Entry.TextChanged -= PART_Entry_TextChanged;
+ }
+
+ if (PART_CancelButton != null)
+ {
+ PART_CancelButton.Clicked -= PART_CancelButton_Clicked;
+ }
+
+ if (PART_SearchButton != null)
+ {
+ PART_SearchButton.Clicked -= PART_SearchButton_Clicked;
+ }
+
+ if (PART_SourcesView != null)
+ {
+ PART_SourcesView.ItemTapped -= PART_SourcesView_ItemTapped;
+ }
+
+ if (PART_SuggestionsView != null)
+ {
+ PART_SuggestionsView.ItemSelected -= PART_SuggestionsView_ItemSelected;
+ PART_SuggestionsView.ItemsSource = null;
+ }
+
+ if (PART_ResultView != null)
+ {
+ PART_ResultView.ItemSelected -= PART_ResultView_ItemSelected;
+ PART_ResultView.ItemsSource = null;
+ }
+
+ if (PART_RepeatButton != null)
+ {
+ PART_RepeatButton.Clicked -= PART_RepeatButton_Clicked;
+ }
+
+ base.OnApplyTemplate();
+
+ if (GetTemplateChild(nameof(PART_SourceSelectButton)) is ImageButton newSourceButton)
+ {
+ PART_SourceSelectButton = newSourceButton;
+ PART_SourceSelectButton.Clicked += PART_SourceSelectButton_Clicked;
+ }
+
+ if (GetTemplateChild(nameof(PART_Entry)) is Entry newEntry)
+ {
+ PART_Entry = newEntry;
+ PART_Entry.Text = SearchViewModel?.CurrentQuery;
+ PART_Entry.Placeholder = SearchViewModel?.ActivePlaceholder;
+ PART_Entry.TextChanged += PART_Entry_TextChanged;
+ }
+
+ if (GetTemplateChild(nameof(PART_CancelButton)) is ImageButton newCancel)
+ {
+ PART_CancelButton = newCancel;
+ PART_CancelButton.IsVisible = !string.IsNullOrEmpty(PART_Entry?.Text);
+ PART_CancelButton.Clicked += PART_CancelButton_Clicked;
+ }
+
+ if (GetTemplateChild(nameof(PART_SearchButton)) is ImageButton newSearch)
+ {
+ PART_SearchButton = newSearch;
+ PART_SearchButton.Clicked += PART_SearchButton_Clicked;
+ }
+
+ if (GetTemplateChild(nameof(PART_ResultLabel)) is Label newResultLabel)
+ {
+ PART_ResultLabel = newResultLabel;
+ PART_ResultLabel.Text = NoResultMessage;
+ }
+
+ if (GetTemplateChild(nameof(PART_ResultContainer)) is Grid newResultContainer)
+ {
+ PART_ResultContainer = newResultContainer;
+ }
+
+ if (GetTemplateChild(nameof(PART_SourcesView)) is ListView newSourceSelectView)
+ {
+ PART_SourcesView = newSourceSelectView;
+ PART_SourcesView.ItemTapped += PART_SourcesView_ItemTapped;
+ }
+
+ if (GetTemplateChild(nameof(PART_ResultView)) is ListView newResultList)
+ {
+ PART_ResultView = newResultList;
+ PART_ResultView.ItemTemplate = ResultTemplate;
+ PART_ResultView.ItemSelected += PART_ResultView_ItemSelected;
+ }
+
+ if (GetTemplateChild(nameof(PART_SuggestionsView)) is ListView newSuggestionList)
+ {
+ PART_SuggestionsView = newSuggestionList;
+ PART_SuggestionsView.ItemTemplate = SuggestionTemplate;
+ PART_SuggestionsView.ItemSelected += PART_SuggestionsView_ItemSelected;
+ PART_SuggestionsView.IsGroupingEnabled = SearchViewModel?.Sources?.Count > 1 && SearchViewModel?.ActiveSource == null;
+ }
+
+ if (GetTemplateChild(nameof(PART_RepeatButton)) is Button newRepeatButton)
+ {
+ PART_RepeatButton = newRepeatButton;
+ PART_RepeatButton.Text = RepeatSearchButtonText;
+ PART_RepeatButton.Clicked += PART_RepeatButton_Clicked;
+ }
+
+ if (GetTemplateChild(nameof(PART_RepeatButtonContainer)) is Grid newRepeatButtonContainer)
+ {
+ PART_RepeatButtonContainer = newRepeatButtonContainer;
+ }
+
+ UpdateVisibility();
+ }
+
+ private void HandleClearSearchCommand()
+ {
+ SearchViewModel?.CancelSearch();
+ SearchViewModel?.ClearSearch();
+ }
+
+ private void HandleSearchCommand()
+ {
+ SearchViewModel?.CommitSearch();
+ }
+
+ private void HandleRepeatSearchHereCommand()
+ {
+ SearchViewModel?.RepeatSearchHere();
+ }
+
+ private void PART_SourcesView_ItemTapped(object sender, ItemTappedEventArgs e)
+ {
+ if (SearchViewModel == null)
+ {
+ return;
+ }
+
+ if (e.ItemIndex == 0)
+ {
+ SearchViewModel.ActiveSource = null;
+ }
+ else
+ {
+ SearchViewModel.ActiveSource = SearchViewModel.Sources[e.ItemIndex - 1];
+ }
+
+ _sourceSelectToggled = false;
+ UpdateVisibility();
+ }
+
+ private void PART_RepeatButton_Clicked(object sender, EventArgs e) => SearchViewModel?.RepeatSearchHere();
+
+ private void PART_SuggestionsView_ItemSelected(object sender, SelectedItemChangedEventArgs e)
+ {
+ if (e.SelectedItem is SearchSuggestion suggestion)
+ {
+ PART_SuggestionsView?.SetValue(ListView.SelectedItemProperty, null);
+
+ _ = AcceptSuggestion(suggestion);
+ }
+ }
+
+ private void PART_ResultView_ItemSelected(object sender, SelectedItemChangedEventArgs e)
+ {
+ if (e.SelectedItemIndex != -1 && SearchViewModel is SearchViewModel vm)
+ {
+ vm.SelectedResult = vm.Results?.ElementAtOrDefault(e.SelectedItemIndex);
+ PART_ResultView?.SetValue(ListView.SelectedItemProperty, null);
+ }
+ }
+
+ private void PART_SourceSelectButton_Clicked(object sender, EventArgs e)
+ {
+ _sourceSelectToggled = !_sourceSelectToggled;
+
+ if (_sourceSelectToggled)
+ {
+ UpdateSearchSourceList();
+ }
+
+ UpdateVisibility();
+ }
+
+ private void PART_Entry_TextChanged(object sender, TextChangedEventArgs e)
+ {
+ if (SearchViewModel != null)
+ {
+ SearchViewModel.CurrentQuery = e.NewTextValue;
+ }
+ }
+
+ private void PART_CancelButton_Clicked(object sender, EventArgs e)
+ {
+ SearchViewModel?.CancelSearch();
+ SearchViewModel?.ClearSearch();
+ }
+
+ private void PART_SearchButton_Clicked(object sender, EventArgs e) => SearchViewModel?.CommitSearch();
+
+ private async Task ConfigureForCurrentConfiguration()
+ {
+ if (!EnableDefaultWorldGeocoder || _configureMapFlag)
+ {
+ return;
+ }
+
+ _configureMapFlag = true;
+
+ try
+ {
+ await (SearchViewModel?.ConfigureDefaultWorldGeocoder() ?? Task.CompletedTask);
+ }
+ catch (Exception)
+ {
+ // Ignore
+ }
+ finally
+ {
+ _configureMapFlag = false;
+ }
+ }
+
+ private async Task AcceptSuggestion(SearchSuggestion suggestion)
+ {
+ if (SearchViewModel == null || _acceptingSuggestionFlag)
+ {
+ return;
+ }
+
+ _acceptingSuggestionFlag = true;
+ try
+ {
+ await SearchViewModel.AcceptSuggestion(suggestion);
+ }
+ finally
+ {
+ _acceptingSuggestionFlag = false;
+ }
+ }
+
+ private void AddResultToGeoView(SearchResult result)
+ {
+ if (result?.GeoElement is Graphic graphic)
+ {
+ _resultOverlay.Graphics.Add(graphic);
+ }
+ }
+
+ #region State definitions
+
+ private bool ResultViewVisibility
+ {
+ get
+ {
+ if (!EnableResultListView)
+ {
+ return false;
+ }
+
+ if (!EnableIndividualResultDisplay && (SearchViewModel?.SearchMode == SearchResultMode.Single || SearchViewModel?.SelectedResult != null))
+ {
+ return false;
+ }
+
+ if (SearchViewModel?.Results?.Any() ?? false)
+ {
+ return true;
+ }
+
+ return false;
+ }
+ }
+
+ private bool SuggestionsViewVisibility => (SearchViewModel?.Suggestions?.Any() ?? false) && SearchViewModel?.Results == null;
+
+ private bool SourceSelectVisibility => SearchViewModel?.Sources?.Count > 1;
+
+ private bool ResultLabelVisibility => (SearchViewModel?.Suggestions != null && SearchViewModel?.Suggestions?.Count == 0) ||
+ (SearchViewModel?.Results != null && SearchViewModel?.Results?.Count == 0);
+
+ private bool RepeatSearchButtonVisibility => EnableRepeatSearchHereButton && (SearchViewModel?.IsEligibleForRequery ?? false);
+
+ private bool SourcePopupVisibility => _sourceSelectToggled && SearchViewModel?.Sources.Count > 1;
+
+ #endregion State definitions
+
+ #region events
+
+ private static void OnResultTemplateChanged(BindableObject sender, object? oldValue, object? newValue)
+ {
+ if (sender is SearchView originatingView && originatingView.PART_ResultView != null)
+ {
+ originatingView.PART_ResultView.ItemTemplate = newValue as DataTemplate;
+ }
+ }
+
+ private static void OnSuggestionTemplateChanged(BindableObject sender, object? oldValue, object? newValue)
+ {
+ if (sender is SearchView originatingView && originatingView.PART_SuggestionsView != null)
+ {
+ originatingView.PART_SuggestionsView.ItemTemplate = newValue as DataTemplate;
+ }
+ }
+
+ private static void OnSuggestionGroupHeaderTemplateChanged(BindableObject sender, object? oldValue, object? newValue)
+ {
+ if (sender is SearchView originatingview && originatingview.PART_SuggestionsView != null)
+ {
+ originatingview.PART_SuggestionsView.GroupHeaderTemplate = newValue as DataTemplate;
+ }
+ }
+
+ private static void OnGeoViewPropertyChanged(BindableObject sender, object? oldValue, object? newValue)
+ {
+ if (sender is SearchView sendingView)
+ {
+ if (oldValue is GeoView oldGeoView)
+ {
+ oldGeoView.DismissCallout();
+ oldGeoView.ViewpointChanged -= sendingView.GeoView_ViewpointChanged;
+ sendingView._lastUsedGeomodel = null;
+ (oldGeoView as INotifyPropertyChanged).PropertyChanged -= sendingView.HandleMapChange;
+ if (oldGeoView.GraphicsOverlays?.Contains(sendingView._resultOverlay) ?? false)
+ {
+ oldGeoView.GraphicsOverlays.Remove(sendingView._resultOverlay);
+ }
+ }
+
+ if (newValue is GeoView newGeoView)
+ {
+ (newGeoView as INotifyPropertyChanged).PropertyChanged += sendingView.HandleMapChange;
+ newGeoView.ViewpointChanged += sendingView.GeoView_ViewpointChanged;
+ newGeoView.GraphicsOverlays?.Add(sendingView._resultOverlay);
+ }
+
+ _ = sendingView.ConfigureForCurrentConfiguration();
+ }
+ }
+
+ private static void OnEnableDefaultWorldGeocoderPropertyChanged(BindableObject sender, object? oldValue, object? newValue)
+ {
+ if (sender is SearchView sendingView)
+ {
+ _ = sendingView.ConfigureForCurrentConfiguration();
+ }
+ }
+
+ private static void OnEnableRepeatSearchButtonChanged(BindableObject sender, object? oldValue, object? newValue) => (sender as SearchView)?.UpdateVisibility();
+
+ private static void OnViewModelChanged(BindableObject sender, object? oldValue, object? newValue)
+ {
+ if (sender is SearchView sendingView)
+ {
+ if (oldValue is SearchViewModel oldModel)
+ {
+ oldModel.PropertyChanged -= sendingView.SearchViewModel_PropertyChanged;
+ if (oldModel.Sources is INotifyCollectionChanged oldSources)
+ {
+ oldSources.CollectionChanged -= sendingView.Sources_CollectionChanged;
+ }
+ }
+
+ if (newValue is SearchViewModel newModel)
+ {
+ sendingView.PART_Entry?.SetValue(Entry.TextProperty, newModel.CurrentQuery);
+ sendingView.PART_Entry?.SetValue(Entry.PlaceholderProperty, newModel.ActivePlaceholder);
+ sendingView.PART_SuggestionsView?.SetValue(ListView.IsGroupingEnabledProperty, newModel.Sources?.Count > 1 && newModel.ActiveSource == null);
+ newModel.PropertyChanged += sendingView.SearchViewModel_PropertyChanged;
+ if (newModel.Sources is INotifyCollectionChanged newSources)
+ {
+ newSources.CollectionChanged += sendingView.Sources_CollectionChanged;
+ }
+ }
+ }
+ }
+
+ private static void OnEnableResultListViewChanged(BindableObject sender, object? oldValue, object? newValue) =>
+ (sender as SearchView)?.UpdateVisibility();
+
+ private static void OnRepeatSearchButtonTextChanged(BindableObject sender, object? oldValue, object? newValue) =>
+ (sender as SearchView)?.PART_RepeatButton?.SetValue(Button.TextProperty, newValue);
+
+ private static void OnNoResultMessagePropertyChanged(BindableObject sender, object? oldValue, object? newValue) =>
+ (sender as SearchView)?.PART_ResultLabel?.SetValue(Label.TextProperty, newValue);
+
+ private void Sources_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
+ {
+ UpdateSearchSourceList();
+ UpdateVisibility();
+ }
+
+ private void UpdateSearchSourceList()
+ {
+ if (PART_SourcesView == null || SearchViewModel == null)
+ {
+ return;
+ }
+
+ var sources = new[] { AllSourcesSelectText ?? "All" }.Concat(SearchViewModel.Sources.Select(source => source.DisplayName)).ToList();
+ PART_SourcesView.ItemsSource = sources;
+
+ if (SearchViewModel.ActiveSource == null)
+ {
+ PART_SourcesView.SelectedItem = sources[0];
+ }
+ else
+ {
+ PART_SourcesView.SelectedItem = sources[SearchViewModel.Sources.IndexOf(SearchViewModel.ActiveSource) + 1];
+ }
+ }
+
+ private void HandleMapChange(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(Map) || e.PropertyName == nameof(Scene))
+ {
+ _ = ConfigureForCurrentConfiguration();
+ return;
+ }
+
+ if (e.PropertyName == nameof(MapView.DrawStatus) && _lastUsedGeomodel == null)
+ {
+ if (GeoView is MapView mv && mv.Map is Map map)
+ {
+ _lastUsedGeomodel = map;
+ }
+ else if (GeoView is SceneView sv && sv.Scene is Scene scene)
+ {
+ _lastUsedGeomodel = scene;
+ }
+
+ _ = ConfigureForCurrentConfiguration();
+ }
+ }
+
+ private void SearchViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (SearchViewModel == null)
+ {
+ return;
+ }
+
+ switch (e.PropertyName)
+ {
+ case nameof(SearchViewModel.ActivePlaceholder):
+ PART_Entry?.SetValue(Entry.PlaceholderProperty, SearchViewModel.ActivePlaceholder);
+ UpdateVisibility();
+ break;
+ case nameof(SearchViewModel.Suggestions):
+ // Only group if there are multiple sources
+ bool groupingEnabled = SearchViewModel.Sources.Count > 1 && SearchViewModel.ActiveSource == null;
+ PART_SuggestionsView?.SetValue(ListView.IsGroupingEnabledProperty, groupingEnabled);
+ if (groupingEnabled)
+ {
+ var grouped = SearchViewModel.Suggestions?.GroupBy(item => item.OwningSource);
+
+ // IGrouping.Key is being linked away in release mode, breaking the group header display. This ugly block of code prevents that.
+ // https://docs.microsoft.com/en-us/xamarin/android/deploy-test/linker#falseflag
+ bool falseFlag = false;
+ if (falseFlag)
+ {
+ Console.WriteLine(grouped.First().Key);
+ }
+
+ PART_SuggestionsView?.SetValue(ListView.ItemsSourceProperty, grouped);
+ }
+ else
+ {
+ PART_SuggestionsView?.SetValue(ListView.ItemsSourceProperty, SearchViewModel.Suggestions);
+ }
+
+ UpdateVisibility();
+ break;
+ case nameof(SearchViewModel.Results):
+ PART_ResultView?.SetValue(ListView.ItemsSourceProperty, SearchViewModel.Results);
+ _ = HandleResultsCollectionChanged();
+ break;
+ case nameof(SearchViewModel.CurrentQuery):
+ PART_CancelButton?.SetValue(View.IsVisibleProperty, !string.IsNullOrEmpty(SearchViewModel.CurrentQuery));
+ PART_Entry?.SetValue(Entry.TextProperty, SearchViewModel.CurrentQuery);
+ _ = HandleQueryChanged();
+ break;
+ case nameof(SearchViewModel.SearchMode):
+ UpdateVisibility();
+ break;
+ case nameof(SearchViewModel.SelectedResult):
+ _ = HandleSelectedResultChanged();
+ break;
+ case nameof(SearchViewModel.IsEligibleForRequery):
+ UpdateVisibility();
+ break;
+ case nameof(SearchViewModel.Sources):
+ UpdateSearchSourceList();
+ UpdateVisibility();
+ break;
+ }
+ }
+
+ private void GeoView_ViewpointChanged(object? sender, EventArgs e) => HandleViewpointChanged();
+
+ ///
+ /// Updates with the current viewpoint.
+ ///
+ private void HandleViewpointChanged()
+ {
+ if (SearchViewModel == null || GeoView == null)
+ {
+ return;
+ }
+
+ if (GeoView.GetCurrentViewpoint(ViewpointType.BoundingGeometry)?.TargetGeometry is Envelope targetEnvelope)
+ {
+ SearchViewModel.QueryArea = targetEnvelope;
+ SearchViewModel.QueryCenter = targetEnvelope.GetCenter();
+ }
+ }
+
+ ///
+ /// Implements typing delay behavior; it is best to wait for user to finish typing before asking for suggestions.
+ ///
+ private async Task HandleQueryChanged()
+ {
+ if (_waitFlag || _acceptingSuggestionFlag || SearchViewModel == null)
+ {
+ return;
+ }
+
+ _waitFlag = true;
+ await Task.Delay(TypingDelayMilliseconds);
+ _waitFlag = false;
+
+ await SearchViewModel.UpdateSuggestions();
+ }
+
+ private async Task HandleSelectedResultChanged()
+ {
+ UpdateVisibility();
+
+ if (SearchViewModel?.SelectedResult is SearchResult selectedResult)
+ {
+ PART_ResultView?.SetValue(ListView.SelectedItemProperty, selectedResult);
+ _resultOverlay?.Graphics.Clear();
+ AddResultToGeoView(selectedResult);
+
+ if (GeoView != null && selectedResult.CalloutDefinition != null && selectedResult.GeoElement != null)
+ {
+ GeoView.ShowCalloutForGeoElement(selectedResult.GeoElement, new Point(0, 0), selectedResult.CalloutDefinition);
+ }
+
+ // Zoom to the feature
+ if (selectedResult.SelectionViewpoint != null && GeoView != null && SearchViewModel != null)
+ {
+ SearchViewModel.IgnoreAreaChangesFlag = true;
+ await GeoView.SetViewpointAsync(selectedResult.SelectionViewpoint);
+ await Task.Delay(1000);
+ SearchViewModel.IgnoreAreaChangesFlag = false;
+ }
+ }
+ else
+ {
+ PART_ResultView?.SetValue(ListView.SelectedItemProperty, null);
+ GeoView?.DismissCallout();
+ }
+ }
+
+ private async Task HandleResultsCollectionChanged()
+ {
+ if (SearchViewModel == null)
+ {
+ return;
+ }
+
+ UpdateVisibility();
+
+ if (SearchViewModel.Results == null)
+ {
+ _resultOverlay?.Graphics?.Clear();
+ }
+ else if (SearchViewModel.SelectedResult == null && GeoView != null)
+ {
+ _resultOverlay?.Graphics?.Clear();
+ foreach (var result in SearchViewModel.Results)
+ {
+ AddResultToGeoView(result);
+ }
+
+ var zoomableResults = SearchViewModel.Results
+ .Select(res => res.GeoElement?.Geometry).OfType().ToList();
+
+ if (zoomableResults != null && zoomableResults.Count > 1)
+ {
+ SearchViewModel.IgnoreAreaChangesFlag = true;
+ var newViewpoint = GeometryEngine.CombineExtents(zoomableResults);
+ if (GeoView is MapView mv)
+ {
+ await mv.SetViewpointGeometryAsync(newViewpoint, MultipleResultZoomBuffer);
+ }
+ else
+ {
+ await GeoView.SetViewpointAsync(new Viewpoint(newViewpoint));
+ }
+
+ await Task.Delay(1000);
+ SearchViewModel.IgnoreAreaChangesFlag = false;
+ }
+ }
+ }
+
+ private void UpdateVisibility()
+ {
+ PART_SuggestionsView?.SetValue(View.IsVisibleProperty, SuggestionsViewVisibility);
+ PART_ResultView?.SetValue(View.IsVisibleProperty, ResultViewVisibility);
+ PART_ResultContainer?.SetValue(View.IsVisibleProperty, ResultLabelVisibility);
+ PART_ResultLabel?.SetValue(View.IsVisibleProperty, ResultLabelVisibility);
+ PART_SourceSelectButton?.SetValue(View.IsVisibleProperty, SourceSelectVisibility);
+ PART_RepeatButton?.SetValue(View.IsVisibleProperty, RepeatSearchButtonVisibility);
+ PART_RepeatButtonContainer?.SetValue(View.IsVisibleProperty, RepeatSearchButtonVisibility);
+ PART_SourcesView?.SetValue(View.IsVisibleProperty, SourcePopupVisibility);
+ }
+
+ #endregion events
+
+ #region properties
+
+ ///
+ /// Gets or sets the template used to display suggestions.
+ ///
+ public DataTemplate? SuggestionTemplate
+ {
+ get => GetValue(SuggestionTemplateProperty) as DataTemplate;
+ set => SetValue(SuggestionTemplateProperty, value);
+ }
+
+ ///
+ /// Gets or sets the template used to display results.
+ ///
+ public DataTemplate? ResultTemplate
+ {
+ get => GetValue(ResultTemplateProperty) as DataTemplate;
+ set => SetValue(ResultTemplateProperty, value);
+ }
+
+ ///
+ /// Gets or sets the template used to display the header that groups suggestion results by source.
+ ///
+ public DataTemplate? SuggestionGroupHeaderTemplate
+ {
+ get => GetValue(SuggestionGroupHeaderTemplateProperty) as DataTemplate;
+ set => SetValue(SuggestionGroupHeaderTemplateProperty, value);
+ }
+
+ ///
+ /// Gets or sets the GeoView associated with this view.
+ ///
+ ///
+ /// If set, will add a graphics overlay for showing results, and will automatically navigate to show search results.
+ ///
+ public GeoView? GeoView
+ {
+ get => GetValue(GeoViewProperty) as GeoView;
+ set => SetValue(GeoViewProperty, value);
+ }
+
+ ///
+ /// Gets or sets a message to show when a search completes with no results.
+ ///
+ public string? NoResultMessage
+ {
+ get => GetValue(NoResultMessageProperty) as string;
+ set => SetValue(NoResultMessageProperty, value);
+ }
+
+ ///
+ /// Gets or sets the text to show in the button for selecting all search sources.
+ ///
+ public string? AllSourcesSelectText
+ {
+ get => GetValue(AllSourcesSelectTextProperty) as string;
+ set => SetValue(AllSourcesSelectTextProperty, value);
+ }
+
+ ///
+ /// Gets or sets the text to show in the 'Repeat search' button.
+ ///
+ public string? RepeatSearchButtonText
+ {
+ get => GetValue(RepeatSearchButtonTextProperty) as string;
+ set => SetValue(RepeatSearchButtonTextProperty, value);
+ }
+
+ ///
+ /// Gets or sets the viewmodel that implements core search behavior.
+ ///
+ public SearchViewModel? SearchViewModel
+ {
+ get => GetValue(SearchViewModelProperty) as SearchViewModel;
+ set => SetValue(SearchViewModelProperty, value);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether will include the Esri World Geocoder service by default.
+ ///
+ public bool EnableDefaultWorldGeocoder
+ {
+ get => (bool)GetValue(EnableDefaultWorldGeocoderProperty);
+ set => SetValue(EnableDefaultWorldGeocoderProperty, value);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether a 'Repeat Search' button will be displayed
+ /// when the user pans the map a sufficient amount after a search completes.
+ ///
+ ///
+ /// Some consumer applications will display this button in a separate area of the UI from the search bar, often centered over the map.
+ /// This property is intended to allow hiding the default button if using a custom 'Repeat Search' implementation.
+ /// See and to enable a custom button implementation.
+ ///
+ public bool EnableRepeatSearchHereButton
+ {
+ get => (bool)GetValue(EnableRepeatSearchHereButtonProperty);
+ set => SetValue(EnableRepeatSearchHereButtonProperty, value);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the view will show the selected result.
+ /// If false, the result list is hidden automatically when a result is selected.
+ ///
+ ///
+ /// See to display custom UI for the selected result.
+ ///
+ public bool EnableIndividualResultDisplay
+ {
+ get => (bool)GetValue(EnableIndividualResultDisplayProperty);
+ set => SetValue(EnableIndividualResultDisplayProperty, value);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the default result list view will be shown.
+ ///
+ ///
+ /// Set this value to false to enable a custom list presentation.
+ ///
+ public bool EnableResultListView
+ {
+ get => (bool)GetValue(EnableResultListViewProperty);
+ set => SetValue(EnableResultListViewProperty, value);
+ }
+
+ ///
+ /// Gets or sets the buffer used when zooming to a set of results.
+ ///
+ public double MultipleResultZoomBuffer
+ {
+ get => (double)GetValue(MultipleResultZoomBufferProperty);
+ set => SetValue(MultipleResultZoomBufferProperty, value);
+ }
+ #endregion properties
+
+ #region bindable properties
+
+ ///
+ /// Identifies the bindable property.
+ ///
+ public static readonly BindableProperty ResultTemplateProperty =
+ BindableProperty.Create(nameof(ResultTemplate), typeof(DataTemplate), typeof(SearchView), propertyChanged: OnResultTemplateChanged);
+
+ ///
+ /// Identifies the bindable property.
+ ///
+ public static readonly BindableProperty SuggestionTemplateProperty =
+ BindableProperty.Create(nameof(SuggestionTemplate), typeof(DataTemplate), typeof(SearchView), propertyChanged: OnSuggestionTemplateChanged);
+
+ ///
+ /// Identifies the bindable property.
+ ///
+ public static readonly BindableProperty SuggestionGroupHeaderTemplateProperty =
+ BindableProperty.Create(nameof(SuggestionGroupHeaderTemplate), typeof(DataTemplate), typeof(SearchView), propertyChanged: OnSuggestionGroupHeaderTemplateChanged);
+
+ ///
+ /// Identifies the bindable property.
+ ///
+ public static readonly BindableProperty NoResultMessageProperty =
+ BindableProperty.Create(nameof(NoResultMessage), typeof(string), typeof(SearchView), propertyChanged: OnNoResultMessagePropertyChanged);
+
+ ///
+ /// Identifies the bindable property.
+ ///
+ public static readonly BindableProperty GeoViewProperty =
+ BindableProperty.Create(nameof(GeoView), typeof(GeoView), typeof(SearchView), null, propertyChanged: OnGeoViewPropertyChanged);
+
+ ///
+ /// Identifies the bindable property.
+ ///
+ public static readonly BindableProperty EnableDefaultWorldGeocoderProperty =
+ BindableProperty.Create(nameof(EnableDefaultWorldGeocoder), typeof(bool), typeof(SearchView), true, propertyChanged: OnEnableDefaultWorldGeocoderPropertyChanged);
+
+ ///
+ /// Identifies the bindable proeprty.
+ ///
+ public static readonly BindableProperty EnableRepeatSearchHereButtonProperty =
+ BindableProperty.Create(nameof(EnableRepeatSearchHereButton), typeof(bool), typeof(SearchView), true, propertyChanged: OnEnableRepeatSearchButtonChanged);
+
+ ///
+ /// Identifies the bindable property.
+ ///
+ public static readonly BindableProperty SearchViewModelProperty =
+ BindableProperty.Create(nameof(SearchViewModel), typeof(SearchViewModel), typeof(SearchView), null, propertyChanged: OnViewModelChanged);
+
+ ///
+ /// Identifies the bindable property.
+ ///
+ public static readonly BindableProperty EnableResultListViewProperty =
+ BindableProperty.Create(nameof(EnableResultListView), typeof(bool), typeof(SearchView), true, propertyChanged: OnEnableResultListViewChanged);
+
+ ///
+ /// Identifies the bindable property.
+ ///
+ public static readonly BindableProperty EnableIndividualResultDisplayProperty =
+ BindableProperty.Create(nameof(EnableIndividualResultDisplay), typeof(bool), typeof(SearchView), false, propertyChanged: OnEnableResultListViewChanged);
+
+ ///
+ /// Identifies the bindable property.
+ ///
+ public static readonly BindableProperty MultipleResultZoomBufferProperty =
+ BindableProperty.Create(nameof(MultipleResultZoomBuffer), typeof(double), typeof(SearchView), 64.0);
+
+ ///
+ /// Identifies the bindable property.
+ ///
+ public static readonly BindableProperty AllSourcesSelectTextProperty =
+ BindableProperty.Create(nameof(AllSourcesSelectText), typeof(string), typeof(SearchView), null);
+
+ ///
+ /// Identifies the bindable property.
+ ///
+ public static readonly BindableProperty RepeatSearchButtonTextProperty =
+ BindableProperty.Create(nameof(RepeatSearchButtonText), typeof(string), typeof(SearchView), propertyChanged: OnRepeatSearchButtonTextChanged);
+ #endregion bindable properties
+ }
+}
diff --git a/src/Toolkit/Toolkit.UWP/Themes/Generic.xaml b/src/Toolkit/Toolkit.UWP/Themes/Generic.xaml
index e429901a2..33363ac8d 100644
--- a/src/Toolkit/Toolkit.UWP/Themes/Generic.xaml
+++ b/src/Toolkit/Toolkit.UWP/Themes/Generic.xaml
@@ -5,6 +5,7 @@
+
diff --git a/src/Toolkit/Toolkit.UWP/UI/Controls/SearchView/SearchView.Theme.xaml b/src/Toolkit/Toolkit.UWP/UI/Controls/SearchView/SearchView.Theme.xaml
new file mode 100644
index 000000000..3d5baaa24
--- /dev/null
+++ b/src/Toolkit/Toolkit.UWP/UI/Controls/SearchView/SearchView.Theme.xaml
@@ -0,0 +1,662 @@
+
+
+
+ White
+ #f3f3f3
+ #e2f1fb
+ #404040
+ #6e6e6e
+ #4e4e4e
+ #2e2e2e
+ #007AC2
+ #00619B
+ #004874
+ #007AC2
+ #6e6e6e
+ #6e6e6e
+ White
+ #4e4e4e
+ #4e6e6e6e
+
+
+ #242424
+ #303030
+ #000
+ White
+ #9f9f9f
+ #dddddd
+ White
+ #009AF2
+ #007AC2
+ #00619B
+ #009AF2
+ #505050
+ #101010
+ White
+ #181818
+ #4e101010
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Toolkit/Toolkit.WPF/Themes/Generic.xaml b/src/Toolkit/Toolkit.WPF/Themes/Generic.xaml
index e58426600..2f2cba822 100644
--- a/src/Toolkit/Toolkit.WPF/Themes/Generic.xaml
+++ b/src/Toolkit/Toolkit.WPF/Themes/Generic.xaml
@@ -9,6 +9,7 @@
+
diff --git a/src/Toolkit/Toolkit.WPF/UI/Controls/SearchView/SearchView.Theme.xaml b/src/Toolkit/Toolkit.WPF/UI/Controls/SearchView/SearchView.Theme.xaml
new file mode 100644
index 000000000..35ba6a297
--- /dev/null
+++ b/src/Toolkit/Toolkit.WPF/UI/Controls/SearchView/SearchView.Theme.xaml
@@ -0,0 +1,577 @@
+
+
+
+
+
+
+ #f3f3f3
+ #e2f1fb
+ #007AC2
+ #00619B
+ #004874
+ #6e6e6e
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Toolkit/Toolkit/EmbeddedResources/pin_red.png b/src/Toolkit/Toolkit/EmbeddedResources/pin_red.png
new file mode 100644
index 000000000..bce401d5d
Binary files /dev/null and b/src/Toolkit/Toolkit/EmbeddedResources/pin_red.png differ
diff --git a/src/Toolkit/Toolkit/Esri.ArcGISRuntime.Toolkit.csproj b/src/Toolkit/Toolkit/Esri.ArcGISRuntime.Toolkit.csproj
index 96ebc6b83..6aa1646d7 100644
--- a/src/Toolkit/Toolkit/Esri.ArcGISRuntime.Toolkit.csproj
+++ b/src/Toolkit/Toolkit/Esri.ArcGISRuntime.Toolkit.csproj
@@ -68,7 +68,15 @@
+
+
+
+
+
+
+
+
@@ -91,6 +99,14 @@
+
+
+
+
+
+
+
+
diff --git a/src/Toolkit/Toolkit/Internal/BoolToVisibilityConverter.cs b/src/Toolkit/Toolkit/Internal/BoolToVisibilityConverter.cs
new file mode 100644
index 000000000..111549cdc
--- /dev/null
+++ b/src/Toolkit/Toolkit/Internal/BoolToVisibilityConverter.cs
@@ -0,0 +1,63 @@
+// /*******************************************************************************
+// * Copyright 2012-2018 Esri
+// *
+// * Licensed under the Apache License, Version 2.0 (the "License");
+// * you may not use this file except in compliance with the License.
+// * You may obtain a copy of the License at
+// *
+// * http://www.apache.org/licenses/LICENSE-2.0
+// *
+// * Unless required by applicable law or agreed to in writing, software
+// * distributed under the License is distributed on an "AS IS" BASIS,
+// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// * See the License for the specific language governing permissions and
+// * limitations under the License.
+// ******************************************************************************/
+
+#if !XAMARIN
+using System;
+#if NETFX_CORE
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Data;
+using Culture = System.String;
+#else
+using System.Windows;
+using System.Windows.Data;
+using Culture = System.Globalization.CultureInfo;
+#endif
+
+namespace Esri.ArcGISRuntime.Toolkit.Internal
+{
+ ///
+ /// Converts a boolean value to a visibility. Specify 'Inverse' as parameter to invert result.
+ ///
+ [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
+ #if NETFX_CORE
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1121:Use built-in type alias", Justification = "Alias used to support UWP/WPF differences.")]
+ #endif
+ public class BoolToVisibilityConverter : IValueConverter
+ {
+ ///
+ public object Convert(object value, Type targetType, object parameter, Culture culture)
+ {
+ if (value is bool inputValue)
+ {
+ if ("Inverse".Equals(parameter))
+ {
+ return inputValue ? Visibility.Collapsed : Visibility.Visible;
+ }
+
+ return inputValue ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ return Visibility.Collapsed;
+ }
+
+ ///
+ public object ConvertBack(object value, Type targetType, object parameter, Culture culture)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
+#endif
diff --git a/src/Toolkit/Toolkit/Internal/CollectionIsEmptyToBoolConverter.cs b/src/Toolkit/Toolkit/Internal/CollectionIsEmptyToBoolConverter.cs
new file mode 100644
index 000000000..ee6168aec
--- /dev/null
+++ b/src/Toolkit/Toolkit/Internal/CollectionIsEmptyToBoolConverter.cs
@@ -0,0 +1,62 @@
+// /*******************************************************************************
+// * Copyright 2012-2018 Esri
+// *
+// * Licensed under the Apache License, Version 2.0 (the "License");
+// * you may not use this file except in compliance with the License.
+// * You may obtain a copy of the License at
+// *
+// * http://www.apache.org/licenses/LICENSE-2.0
+// *
+// * Unless required by applicable law or agreed to in writing, software
+// * distributed under the License is distributed on an "AS IS" BASIS,
+// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// * See the License for the specific language governing permissions and
+// * limitations under the License.
+// ******************************************************************************/
+
+#if !XAMARIN
+using System;
+#if NETFX_CORE
+using Windows.UI.Xaml.Data;
+using Culture = System.String;
+#else
+using System.Windows;
+using System.Windows.Data;
+using Culture = System.Globalization.CultureInfo;
+#endif
+
+namespace Esri.ArcGISRuntime.Toolkit.Internal
+{
+ ///
+ /// Takes a collection size and returns True if size is not 0, False otherwise. Specify "Empty" as parameter to invert.
+ ///
+ [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
+ #if NETFX_CORE
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1121:Use built-in type alias", Justification = "Alias used to support UWP/WPF differences.")]
+ #endif
+ public class CollectionIsEmptyToBoolConverter : IValueConverter
+ {
+ ///
+ public object Convert(object value, Type targetType, object parameter, Culture culture)
+ {
+ if (value is int collectionSize)
+ {
+ if (parameter is string stringparameter && stringparameter == "Empty")
+ {
+ return collectionSize == 0;
+ }
+
+ return collectionSize != 0;
+ }
+
+ return false;
+ }
+
+ ///
+ public object ConvertBack(object value, Type targetType, object parameter, Culture culture)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
+#endif
diff --git a/src/Toolkit/Toolkit/Internal/CollectionIsEmptyToVisibilityConverter.cs b/src/Toolkit/Toolkit/Internal/CollectionIsEmptyToVisibilityConverter.cs
new file mode 100644
index 000000000..51df9b4b7
--- /dev/null
+++ b/src/Toolkit/Toolkit/Internal/CollectionIsEmptyToVisibilityConverter.cs
@@ -0,0 +1,72 @@
+// /*******************************************************************************
+// * Copyright 2012-2018 Esri
+// *
+// * Licensed under the Apache License, Version 2.0 (the "License");
+// * you may not use this file except in compliance with the License.
+// * You may obtain a copy of the License at
+// *
+// * http://www.apache.org/licenses/LICENSE-2.0
+// *
+// * Unless required by applicable law or agreed to in writing, software
+// * distributed under the License is distributed on an "AS IS" BASIS,
+// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// * See the License for the specific language governing permissions and
+// * limitations under the License.
+// ******************************************************************************/
+
+#if !XAMARIN
+using System;
+#if NETFX_CORE
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Data;
+using Culture = System.String;
+#else
+using System.Windows;
+using System.Windows.Data;
+using Culture = System.Globalization.CultureInfo;
+#endif
+
+namespace Esri.ArcGISRuntime.Toolkit.Internal
+{
+ ///
+ /// Takes a collection size and returns Visibile if size is not 0, Collapsed otherwise. Specify "Empty" as parameter to invert.
+ ///
+ [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
+ #if NETFX_CORE
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1121:Use built-in type alias", Justification = "Alias used to support UWP/WPF differences.")]
+ #endif
+ public class CollectionIsEmptyToVisibilityConverter : IValueConverter
+ {
+ ///
+ public object Convert(object value, Type targetType, object parameter, Culture culture)
+ {
+ if (value is int collectionSize)
+ {
+ if (parameter is string stringparameter && stringparameter == "Empty")
+ {
+ return collectionSize == 0 ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ return collectionSize != 0 ? Visibility.Visible : Visibility.Collapsed;
+ }
+ else if (value == null)
+ {
+ if (parameter is string stringParameter && stringParameter == "Empty")
+ {
+ return Visibility.Visible;
+ }
+
+ return Visibility.Collapsed;
+ }
+
+ return Visibility.Collapsed;
+ }
+
+ ///
+ public object ConvertBack(object value, Type targetType, object parameter, Culture culture)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
+#endif
diff --git a/src/Toolkit/Toolkit/Internal/CollectionIsSingletonToBoolConverter.cs b/src/Toolkit/Toolkit/Internal/CollectionIsSingletonToBoolConverter.cs
new file mode 100644
index 000000000..7533aaa15
--- /dev/null
+++ b/src/Toolkit/Toolkit/Internal/CollectionIsSingletonToBoolConverter.cs
@@ -0,0 +1,62 @@
+// /*******************************************************************************
+// * Copyright 2012-2018 Esri
+// *
+// * Licensed under the Apache License, Version 2.0 (the "License");
+// * you may not use this file except in compliance with the License.
+// * You may obtain a copy of the License at
+// *
+// * http://www.apache.org/licenses/LICENSE-2.0
+// *
+// * Unless required by applicable law or agreed to in writing, software
+// * distributed under the License is distributed on an "AS IS" BASIS,
+// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// * See the License for the specific language governing permissions and
+// * limitations under the License.
+// ******************************************************************************/
+
+#if !XAMARIN
+using System;
+#if NETFX_CORE
+using Windows.UI.Xaml.Data;
+using Culture = System.String;
+#else
+using System.Windows;
+using System.Windows.Data;
+using Culture = System.Globalization.CultureInfo;
+#endif
+
+namespace Esri.ArcGISRuntime.Toolkit.Internal
+{
+ ///
+ /// Converts collection size to bool, returning true if value is 1, false otherwise. Specify 'Inverse' parameter to invert.
+ ///
+ [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
+ #if NETFX_CORE
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1121:Use built-in type alias", Justification = "Alias used to support UWP/WPF differences.")]
+ #endif
+ public class CollectionIsSingletonToBoolConverter : IValueConverter
+ {
+ ///
+ public object Convert(object value, Type targetType, object parameter, Culture culture)
+ {
+ if (value is int collectionSize)
+ {
+ if (parameter is string stringparameter && stringparameter == "Inverse")
+ {
+ return collectionSize != 1;
+ }
+
+ return collectionSize == 1;
+ }
+
+ return false;
+ }
+
+ ///
+ public object ConvertBack(object value, Type targetType, object parameter, Culture culture)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
+#endif
diff --git a/src/Toolkit/Toolkit/Internal/DelegateCommand.cs b/src/Toolkit/Toolkit/Internal/DelegateCommand.cs
new file mode 100644
index 000000000..c9a2259af
--- /dev/null
+++ b/src/Toolkit/Toolkit/Internal/DelegateCommand.cs
@@ -0,0 +1,49 @@
+// /*******************************************************************************
+// * Copyright 2012-2018 Esri
+// *
+// * Licensed under the Apache License, Version 2.0 (the "License");
+// * you may not use this file except in compliance with the License.
+// * You may obtain a copy of the License at
+// *
+// * http://www.apache.org/licenses/LICENSE-2.0
+// *
+// * Unless required by applicable law or agreed to in writing, software
+// * distributed under the License is distributed on an "AS IS" BASIS,
+// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// * See the License for the specific language governing permissions and
+// * limitations under the License.
+// ******************************************************************************/
+
+using System;
+using System.Windows.Input;
+
+namespace Esri.ArcGISRuntime.Toolkit.Internal
+{
+ ///
+ /// Simple command implementation.
+ ///
+ internal class DelegateCommand : ICommand
+ {
+ private readonly Action _action;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public DelegateCommand(Action inputAction) => _action = inputAction;
+
+ ///
+ public event EventHandler? CanExecuteChanged;
+
+ ///
+ public bool CanExecute(object? parameter)
+ {
+ return true;
+ }
+
+ ///
+ public void Execute(object? parameter)
+ {
+ _action.Invoke();
+ }
+ }
+}
diff --git a/src/Toolkit/Toolkit/Internal/EmptyStringToVisibilityConverter.cs b/src/Toolkit/Toolkit/Internal/EmptyStringToVisibilityConverter.cs
new file mode 100644
index 000000000..5f523d6ca
--- /dev/null
+++ b/src/Toolkit/Toolkit/Internal/EmptyStringToVisibilityConverter.cs
@@ -0,0 +1,63 @@
+// /*******************************************************************************
+// * Copyright 2012-2018 Esri
+// *
+// * Licensed under the Apache License, Version 2.0 (the "License");
+// * you may not use this file except in compliance with the License.
+// * You may obtain a copy of the License at
+// *
+// * http://www.apache.org/licenses/LICENSE-2.0
+// *
+// * Unless required by applicable law or agreed to in writing, software
+// * distributed under the License is distributed on an "AS IS" BASIS,
+// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// * See the License for the specific language governing permissions and
+// * limitations under the License.
+// ******************************************************************************/
+
+#if !XAMARIN
+using System;
+#if NETFX_CORE
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Data;
+using Culture = System.String;
+#else
+using System.Windows;
+using System.Windows.Data;
+using Culture = System.Globalization.CultureInfo;
+#endif
+
+namespace Esri.ArcGISRuntime.Toolkit.Internal
+{
+ ///
+ /// Converts string to visibility. Returns Visible if string is empty, collapsed otherwise. Specify 'NotEmpty' for parameter to invert.
+ ///
+ [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
+ #if NETFX_CORE
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1121:Use built-in type alias", Justification = "Alias used to support UWP/WPF differences.")]
+ #endif
+ public class EmptyStringToVisibilityConverter : IValueConverter
+ {
+ ///
+ public object Convert(object value, Type targetType, object parameter, Culture culture)
+ {
+ if (value is string valueString)
+ {
+ if ("NotEmpty".Equals(parameter))
+ {
+ return string.IsNullOrEmpty(valueString) ? Visibility.Collapsed : Visibility.Visible;
+ }
+
+ return string.IsNullOrEmpty(valueString) ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ return "NotEmpty".Equals(parameter) ? Visibility.Collapsed : Visibility.Visible;
+ }
+
+ ///
+ public object ConvertBack(object value, Type targetType, object parameter, Culture culture)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
+#endif
diff --git a/src/Toolkit/Toolkit/Internal/NullToBoolSelectionConverter.cs b/src/Toolkit/Toolkit/Internal/NullToBoolSelectionConverter.cs
new file mode 100644
index 000000000..c776f42a6
--- /dev/null
+++ b/src/Toolkit/Toolkit/Internal/NullToBoolSelectionConverter.cs
@@ -0,0 +1,52 @@
+// /*******************************************************************************
+// * Copyright 2012-2018 Esri
+// *
+// * Licensed under the Apache License, Version 2.0 (the "License");
+// * you may not use this file except in compliance with the License.
+// * You may obtain a copy of the License at
+// *
+// * http://www.apache.org/licenses/LICENSE-2.0
+// *
+// * Unless required by applicable law or agreed to in writing, software
+// * distributed under the License is distributed on an "AS IS" BASIS,
+// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// * See the License for the specific language governing permissions and
+// * limitations under the License.
+// ******************************************************************************/
+
+#if !XAMARIN
+using System;
+#if NETFX_CORE
+using Windows.UI.Xaml.Data;
+using Culture = System.String;
+#else
+using System.Windows;
+using System.Windows.Data;
+using Culture = System.Globalization.CultureInfo;
+#endif
+
+namespace Esri.ArcGISRuntime.Toolkit.Internal
+{
+ ///
+ /// Converter returns true if the supplied value is null, false otherwise.
+ ///
+ [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
+ #if NETFX_CORE
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1121:Use built-in type alias", Justification = "Alias used to support UWP/WPF differences.")]
+ #endif
+ public class NullToBoolSelectionConverter : IValueConverter
+ {
+ ///
+ public object Convert(object value, Type targetType, object parameter, Culture culture)
+ {
+ return value == null;
+ }
+
+ ///
+ public object? ConvertBack(object value, Type targetType, object parameter, Culture culture)
+ {
+ return null;
+ }
+ }
+}
+#endif
diff --git a/src/Toolkit/Toolkit/UI/Controls/SearchView/ISearchSource.cs b/src/Toolkit/Toolkit/UI/Controls/SearchView/ISearchSource.cs
new file mode 100644
index 000000000..e9ef852cc
--- /dev/null
+++ b/src/Toolkit/Toolkit/UI/Controls/SearchView/ISearchSource.cs
@@ -0,0 +1,126 @@
+// /*******************************************************************************
+// * Copyright 2012-2018 Esri
+// *
+// * Licensed under the Apache License, Version 2.0 (the "License");
+// * you may not use this file except in compliance with the License.
+// * You may obtain a copy of the License at
+// *
+// * http://www.apache.org/licenses/LICENSE-2.0
+// *
+// * Unless required by applicable law or agreed to in writing, software
+// * distributed under the License is distributed on an "AS IS" BASIS,
+// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// * See the License for the specific language governing permissions and
+// * limitations under the License.
+// ******************************************************************************/
+
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Esri.ArcGISRuntime.Geometry;
+using Esri.ArcGISRuntime.Mapping;
+using Esri.ArcGISRuntime.Symbology;
+using Esri.ArcGISRuntime.UI;
+
+namespace Esri.ArcGISRuntime.Toolkit.UI.Controls
+{
+ ///
+ /// Defines the API contract for sources of search results that can be used with .
+ ///
+ public interface ISearchSource
+ {
+ ///
+ /// Gets or sets the display name for the source, which may be displayed in the UI.
+ ///
+ string DisplayName { get; set; }
+
+ ///
+ /// Gets or sets the placeholder used for this source, which may be displaye in the UI.
+ ///
+ string? Placeholder { get; set; }
+
+ ///
+ /// Gets or sets the callout definition used for results by default.
+ ///
+ CalloutDefinition? DefaultCalloutDefinition { get; set; }
+
+ ///
+ /// Gets or sets the symbol to be used for results by default.
+ ///
+ Symbol? DefaultSymbol { get; set; }
+
+ ///
+ /// Gets or sets the default zoom scale to be used for results from this source.
+ /// This value should be ignored when the underlying provider (e.g. LocatorTask) provides a viewpoint.
+ /// Otherwise this zoom scale should be used to create the viewpoint for point results.
+ ///
+ double DefaultZoomScale { get; set; }
+
+ ///
+ /// Gets or sets the maximum number of results to return when completing a search.
+ ///
+ int MaximumResults { get; set; }
+
+ ///
+ /// Gets or sets the maximum number of results to return when requesting search suggestions.
+ ///
+ int MaximumSuggestions { get; set; }
+
+ ///
+ /// Gets or sets the area to be used as a constraint for searches and suggestions.
+ ///
+ Geometry.Geometry? SearchArea { get; set; }
+
+ ///
+ /// Gets or sets the point to be used as an input to searches and suggestions.
+ ///
+ Geometry.MapPoint? PreferredSearchLocation { get; set; }
+
+ ///
+ /// Gets a list of suggestions for the given query.
+ ///
+ /// Text of the query.
+ /// Token used to cancel requests (e.g. because the search text has changed).
+ /// Task returning a list of suggestions.
+ Task> SuggestAsync(string queryString, CancellationToken cancellationToken);
+
+ ///
+ /// Gets a list of search results for the given query.
+ ///
+ /// Text of the query.
+ /// Token used to cancel requests (e.g. because the search was changed or canceled).
+ /// Task returning list of search results.
+ Task> SearchAsync(string queryString, CancellationToken cancellationToken);
+
+ ///
+ /// Gets a list of search results for the given suggestions.
+ ///
+ /// Suggestion to use for the search.
+ /// Token used to cancel searches.
+ /// Task returning a list of results.
+ Task> SearchAsync(SearchSuggestion suggestion, CancellationToken cancellationToken);
+
+ ///
+ /// Repeats the last search, with results restricted to the current visible area.
+ ///
+ /// Text to be used for the query.
+ /// Extent used to limit the results.
+ /// Token used to cancel search.
+ /// Task returning a list of results.
+ Task> RepeatSearchAsync(string queryString, Envelope queryExtent, CancellationToken cancellationToken);
+
+ ///
+ /// Used to notify the source when the has selected a result.
+ ///
+ /// This can be used to implement custom selection behavior (e.g. when using a ).
+ /// The given search result.
+ void NotifySelected(SearchResult result);
+
+ ///
+ /// Used to notify the source when the has deselected a result, or all results if is null.
+ ///
+ /// This can be used to implement custom selection behavior (e.g. when using a ).
+ /// The result that has been deselected.
+ void NotifyDeselected(SearchResult? result);
+ }
+}
diff --git a/src/Toolkit/Toolkit/UI/Controls/SearchView/LocatorSearchSource.cs b/src/Toolkit/Toolkit/UI/Controls/SearchView/LocatorSearchSource.cs
new file mode 100644
index 000000000..fcf21b972
--- /dev/null
+++ b/src/Toolkit/Toolkit/UI/Controls/SearchView/LocatorSearchSource.cs
@@ -0,0 +1,299 @@
+// /*******************************************************************************
+// * Copyright 2012-2018 Esri
+// *
+// * Licensed under the Apache License, Version 2.0 (the "License");
+// * you may not use this file except in compliance with the License.
+// * You may obtain a copy of the License at
+// *
+// * http://www.apache.org/licenses/LICENSE-2.0
+// *
+// * Unless required by applicable law or agreed to in writing, software
+// * distributed under the License is distributed on an "AS IS" BASIS,
+// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// * See the License for the specific language governing permissions and
+// * limitations under the License.
+// ******************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using Esri.ArcGISRuntime.Geometry;
+using Esri.ArcGISRuntime.Symbology;
+using Esri.ArcGISRuntime.Tasks.Geocoding;
+using Esri.ArcGISRuntime.UI;
+
+namespace Esri.ArcGISRuntime.Toolkit.UI.Controls
+{
+ ///
+ /// Basic search source implementation for generic locators.
+ ///
+ public class LocatorSearchSource : ISearchSource
+ {
+ internal const string WorldGeocoderUriString = "https://geocode-api.arcgis.com/arcgis/rest/services/World/GeocodeServer";
+
+ private static LocatorTask? _worldGeocoderTask;
+
+ ///
+ /// Creates a configured for use with the Esri World Geocoder service.
+ ///
+ /// Token used for cancellation.
+ /// This method will re-use a static LocatorTask instance to improve performance.
+ public static async Task CreateDefaultSourceAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (_worldGeocoderTask == null)
+ {
+ _worldGeocoderTask = new LocatorTask(new Uri(WorldGeocoderUriString));
+ await _worldGeocoderTask.LoadAsync();
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ return new WorldGeocoderSearchSource(_worldGeocoderTask, null);
+ }
+
+ private readonly Task _loadTask;
+
+ ///
+ /// Gets the task used to perform initial locator setup.
+ ///
+ protected Task LoadTask => _loadTask;
+
+ ///
+ /// Gets or sets the name of the locator. Defaults to the locator's name, or "locator" if not set.
+ ///
+ public string DisplayName { get; set; } = "Locator";
+
+ ///
+ /// Gets or sets the maximum number of results to return for a search. Default is 6.
+ ///
+ public int MaximumResults { get; set; } = 6;
+
+ ///
+ /// Gets or sets the maximum number of suggestions to return. Default is 6.
+ ///
+ public int MaximumSuggestions { get; set; } = 6;
+
+ ///
+ /// Gets the geocode parameters, which can be used to configure search behavior.
+ ///
+ ///
+ /// , ,
+ /// and are set by automatically on search.
+ /// is set to "*" when the is loaded for the first time.
+ ///
+ public GeocodeParameters GeocodeParameters { get; } = new GeocodeParameters();
+
+ ///
+ /// Gets the suggestion parameters, which can be used to configure suggestion behavior.
+ ///
+ ///
+ /// and are
+ /// set automatically on search.
+ ///
+ public SuggestParameters SuggestParameters { get; } = new SuggestParameters();
+
+ ///
+ /// Gets the underlying locator.
+ ///
+ public LocatorTask Locator { get; }
+
+ ///
+ /// Gets or sets the placeholder to show when this search source is selected for use.
+ ///
+ public string? Placeholder { get; set; }
+
+ ///
+ /// Gets or sets the default callout definition to use with results.
+ ///
+ public CalloutDefinition? DefaultCalloutDefinition { get; set; }
+
+ ///
+ /// Gets or sets the default symbol to use when displaying results.
+ ///
+ public Symbol? DefaultSymbol { get; set; } = new SimpleMarkerSymbol(SimpleMarkerSymbolStyle.Circle, System.Drawing.Color.Red, 2);
+
+ ///
+ public double DefaultZoomScale { get; set; } = 100_000;
+
+ ///
+ public Geometry.Geometry? SearchArea { get; set; }
+
+ ///
+ public MapPoint? PreferredSearchLocation { get; set; }
+
+ ///
+ /// Gets or sets the attribute key to use as the subtitle when returning results. Key must be included in .
+ ///
+ public string? SubtitleAttributeKey { get; set; }
+
+ ///
+ /// Initializes a new instance of the class.
+ /// to create a source configured for use with the Esri World Geocoder.
+ ///
+ /// Locator to be used.
+ public LocatorSearchSource(LocatorTask locator)
+ {
+ Locator = locator;
+
+ _loadTask = EnsureLoaded();
+ }
+
+ private async Task EnsureLoaded()
+ {
+ await Locator.LoadAsync();
+
+ Stream? resourceStream = Assembly.GetAssembly(typeof(LocatorSearchSource))?.GetManifestResourceStream(
+ "Esri.ArcGISRuntime.Toolkit.EmbeddedResources.pin_red.png");
+
+ if (resourceStream != null)
+ {
+ PictureMarkerSymbol pinSymbol = await PictureMarkerSymbol.CreateAsync(resourceStream);
+ pinSymbol.Width = 33;
+ pinSymbol.Height = 33;
+ pinSymbol.LeaderOffsetX = 16.5;
+ pinSymbol.OffsetY = 16.5;
+ DefaultSymbol = pinSymbol;
+ }
+
+ if (DisplayName != Locator?.LocatorInfo?.Name && !string.IsNullOrWhiteSpace(Locator?.LocatorInfo?.Name))
+ {
+ DisplayName = Locator?.LocatorInfo?.Name ?? "Locator";
+ }
+
+ GeocodeParameters.ResultAttributeNames.Add("*");
+ }
+
+ ///
+ /// This search source does not track selection state.
+ ///
+ public virtual void NotifySelected(SearchResult result)
+ {
+ // This space intentionally left blank.
+ }
+
+ ///
+ /// This search source does not track selection state.
+ ///
+ public virtual void NotifyDeselected(SearchResult? result)
+ {
+ // This space intentionally left blank.
+ }
+
+ ///
+ public virtual async Task> SuggestAsync(string queryString, CancellationToken cancellationToken = default)
+ {
+ await _loadTask;
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ SuggestParameters.PreferredSearchLocation = PreferredSearchLocation;
+ SuggestParameters.MaxResults = MaximumSuggestions;
+
+ var results = await Locator.SuggestAsync(queryString, SuggestParameters, cancellationToken);
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ return SuggestionToSearchSuggestion(results);
+ }
+
+ ///
+ public virtual async Task> SearchAsync(SearchSuggestion suggestion, CancellationToken cancellationToken = default)
+ {
+ await _loadTask;
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var results = await Locator.GeocodeAsync(suggestion.UnderlyingObject as SuggestResult, cancellationToken);
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ return ResultToSearchResult(results);
+ }
+
+ ///
+ public virtual async Task> SearchAsync(string queryString, CancellationToken cancellationToken = default)
+ {
+ await _loadTask;
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Reset spatial parameters
+ GeocodeParameters.PreferredSearchLocation = PreferredSearchLocation;
+ GeocodeParameters.MaxResults = MaximumResults;
+
+ var results = await Locator.GeocodeAsync(queryString, GeocodeParameters, cancellationToken);
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ return ResultToSearchResult(results);
+ }
+
+ ///
+ public virtual async Task> RepeatSearchAsync(string queryString, Envelope queryExtent, CancellationToken cancellationToken = default)
+ {
+ await _loadTask;
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Reset spatial parameters
+ GeocodeParameters.PreferredSearchLocation = PreferredSearchLocation;
+ GeocodeParameters.SearchArea = queryExtent;
+ GeocodeParameters.MaxResults = MaximumResults;
+
+ var results = await Locator.GeocodeAsync(queryString, GeocodeParameters, cancellationToken);
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ return ResultToSearchResult(results);
+ }
+
+ ///
+ /// Converts geocode result list into list of results, applying result limits and calling necessary callbacks.
+ ///
+ private IList ResultToSearchResult(IReadOnlyList input)
+ {
+ var results = input.Select(i => GeocodeResultToSearchResult(i));
+
+ return results.Take(MaximumResults).ToList();
+ }
+
+ ///
+ /// Converts suggest result list into list of suggestions, applying result limits and calling necessary callbacks.
+ ///
+ private IList SuggestionToSearchSuggestion(IReadOnlyList input)
+ {
+ var results = input.Select(i => SuggestResultToSearchSuggestion(i));
+
+ return results.Take(MaximumResults).ToList();
+ }
+
+ ///
+ /// Creates a basic search result for the given geocode result.
+ ///
+ private SearchResult GeocodeResultToSearchResult(GeocodeResult r)
+ {
+ string? subtitle = null;
+ if (SubtitleAttributeKey != null && r.Attributes.ContainsKey(SubtitleAttributeKey))
+ {
+ subtitle = r.Attributes[SubtitleAttributeKey]?.ToString();
+ }
+
+ Mapping.Viewpoint? selectionViewpoint = r.Extent == null ? null : new Mapping.Viewpoint(r.Extent);
+ return new SearchResult(r.Label, subtitle, this, new Graphic(r.DisplayLocation, r.Attributes, DefaultSymbol), selectionViewpoint) { CalloutDefinition = DefaultCalloutDefinition };
+ }
+
+ ///
+ /// Creates a basic search suggestion for the given suggest result.
+ ///
+ private SearchSuggestion SuggestResultToSearchSuggestion(SuggestResult r)
+ {
+ return new SearchSuggestion(r.Label, this) { IsCollection = r.IsCollection, UnderlyingObject = r };
+ }
+ }
+}
diff --git a/src/Toolkit/Toolkit/UI/Controls/SearchView/SearchResult.cs b/src/Toolkit/Toolkit/UI/Controls/SearchView/SearchResult.cs
new file mode 100644
index 000000000..5fcc07314
--- /dev/null
+++ b/src/Toolkit/Toolkit/UI/Controls/SearchView/SearchResult.cs
@@ -0,0 +1,221 @@
+// /*******************************************************************************
+// * Copyright 2012-2018 Esri
+// *
+// * Licensed under the Apache License, Version 2.0 (the "License");
+// * you may not use this file except in compliance with the License.
+// * You may obtain a copy of the License at
+// *
+// * http://www.apache.org/licenses/LICENSE-2.0
+// *
+// * Unless required by applicable law or agreed to in writing, software
+// * distributed under the License is distributed on an "AS IS" BASIS,
+// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// * See the License for the specific language governing permissions and
+// * limitations under the License.
+// ******************************************************************************/
+
+using System;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using System.Threading.Tasks;
+using Esri.ArcGISRuntime.Data;
+using Esri.ArcGISRuntime.Mapping;
+using Esri.ArcGISRuntime.Symbology;
+using Esri.ArcGISRuntime.UI;
+#if WINDOWS_UWP
+using Windows.UI.Xaml.Media;
+#endif
+
+namespace Esri.ArcGISRuntime.Toolkit.UI.Controls
+{
+ ///
+ /// Wraps a search result for display.
+ ///
+ public class SearchResult : INotifyPropertyChanged
+ {
+#pragma warning disable SA1011 // Closing square brackets should be spaced correctly - catch-22
+ private byte[]? _markerData;
+#pragma warning restore SA1011 // Closing square brackets should be spaced correctly
+ private RuntimeImage? _markerImage;
+ #if WINDOWS_UWP
+ private ImageSource _markerImageSource;
+ #endif
+ private bool _imageRequestFlag;
+ private string _displayTitle;
+ private string? _displaySubtitle;
+ private ISearchSource _owningSource;
+ private GeoElement? _geoElement;
+ private Viewpoint? _selectionViewpoint;
+ private CalloutDefinition? _calloutDefinition;
+
+ ///
+ /// Gets or sets the title that should be shown whenever the result is displayed.
+ ///
+ public string DisplayTitle
+ {
+ get => _displayTitle;
+ set => SetPropertyChanged(value, ref _displayTitle);
+ }
+
+ ///
+ /// Gets or sets the subtitle that should be shown whenever the result is displayed.
+ ///
+ public string? DisplaySubtitle
+ {
+ get => _displaySubtitle;
+ set => SetPropertyChanged(value, ref _displaySubtitle);
+ }
+
+ ///
+ /// Gets the search source that created this result.
+ ///
+ public ISearchSource OwningSource
+ {
+ get => _owningSource;
+ private set => SetPropertyChanged(value, ref _owningSource);
+ }
+
+ ///
+ /// Gets or sets the optional GeoElement for the result. This could be a graphic for a locator search, a feature for a feature layer search, or null if there is nothing to display on the map.
+ ///
+ public GeoElement? GeoElement
+ {
+ get => _geoElement;
+ set
+ {
+ if (value != _geoElement)
+ {
+ _geoElement = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(GeoElement)));
+
+ // Start loading image
+ if (_geoElement is Graphic graphic && graphic.Symbol is Symbol symbol)
+ {
+ _ = LoadImage(symbol);
+ }
+ else if (_geoElement is Feature feature &&
+ feature.FeatureTable?.Layer is FeatureLayer featureLayer &&
+ featureLayer.Renderer?.GetSymbol(_geoElement) is Symbol featureSymbol)
+ {
+ _ = LoadImage(featureSymbol);
+ }
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets the selection viewpoint for the result. Some search sources will return a viewpoint that should be used when a result is selected.
+ ///
+ public Viewpoint? SelectionViewpoint
+ {
+ get => _selectionViewpoint;
+ set => SetPropertyChanged(value, ref _selectionViewpoint);
+ }
+
+ ///
+ /// Gets or sets the CalloutDefinition used to display the result.
+ ///
+ public CalloutDefinition? CalloutDefinition
+ {
+ get => _calloutDefinition;
+ set => SetPropertyChanged(value, ref _calloutDefinition);
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Sets the .
+ /// Sets the .
+ /// Sets the .
+ /// Sets the .
+ /// Sets the .
+ public SearchResult(string title, string? subtitle, ISearchSource owner, GeoElement? geoElement, Viewpoint? viewpoint)
+ {
+ _displayTitle = title;
+ DisplaySubtitle = subtitle;
+ _owningSource = owner;
+ GeoElement = geoElement;
+ SelectionViewpoint = viewpoint;
+ }
+
+ ///
+ /// Gets the image displayed for this result.
+ ///
+ public RuntimeImage? MarkerImage
+ {
+ get => _markerImage;
+ private set => SetPropertyChanged(value, ref _markerImage);
+ }
+
+ ///
+ /// Gets or sets the image displayed for this result, in a format useful for cross-platform scenarios.
+ ///
+#pragma warning disable SA1011 // Closing square brackets should be spaced correctly - catch-22
+ public byte[]? MarkerImageData
+#pragma warning restore SA1011 // Closing square brackets should be spaced correctly
+ {
+ get => _markerData;
+ set => SetPropertyChanged(value, ref _markerData);
+ }
+
+ #if WINDOWS_UWP
+
+ ///
+ /// Gets the image displayed for this result, in a format useful for UWP.
+ ///
+ public ImageSource? MarkerImageSource
+ {
+ get => _markerImageSource;
+ private set => SetPropertyChanged(value, ref _markerImageSource);
+ }
+#endif
+
+ private async Task LoadImage(Symbol symbol)
+ {
+ if (_imageRequestFlag)
+ {
+ return;
+ }
+
+ _imageRequestFlag = true;
+
+ try
+ {
+ MarkerImage = await symbol.CreateSwatchAsync();
+ #if WINDOWS_UWP
+ MarkerImageSource = await MarkerImage.ToImageSourceAsync();
+ #endif
+ var markerDataStream = await MarkerImage.GetEncodedBufferAsync();
+ var buffer = new byte[markerDataStream.Length];
+ await markerDataStream.ReadAsync(buffer, 0, (int)markerDataStream.Length);
+ MarkerImageData = buffer;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(MarkerImage)));
+ }
+ catch (Exception)
+ {
+ // Ignored
+ }
+ finally
+ {
+ _imageRequestFlag = false;
+ }
+ }
+
+ private void SetPropertyChanged(T value, ref T field, [CallerMemberName] string propertyName = "")
+ {
+ if (!Equals(value, field))
+ {
+ field = value;
+ OnPropertyChanged(propertyName);
+ }
+ }
+
+ private void OnPropertyChanged(string propertyName = "")
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+
+ ///
+ public event PropertyChangedEventHandler? PropertyChanged;
+ }
+}
diff --git a/src/Toolkit/Toolkit/UI/Controls/SearchView/SearchResultMode.cs b/src/Toolkit/Toolkit/UI/Controls/SearchView/SearchResultMode.cs
new file mode 100644
index 000000000..dff3a1430
--- /dev/null
+++ b/src/Toolkit/Toolkit/UI/Controls/SearchView/SearchResultMode.cs
@@ -0,0 +1,39 @@
+// /*******************************************************************************
+// * Copyright 2012-2018 Esri
+// *
+// * Licensed under the Apache License, Version 2.0 (the "License");
+// * you may not use this file except in compliance with the License.
+// * You may obtain a copy of the License at
+// *
+// * http://www.apache.org/licenses/LICENSE-2.0
+// *
+// * Unless required by applicable law or agreed to in writing, software
+// * distributed under the License is distributed on an "AS IS" BASIS,
+// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// * See the License for the specific language governing permissions and
+// * limitations under the License.
+// ******************************************************************************/
+
+namespace Esri.ArcGISRuntime.Toolkit.UI.Controls
+{
+ ///
+ /// Defines how many results should be returned by a search.
+ ///
+ public enum SearchResultMode
+ {
+ ///
+ /// Always try to return a single result.
+ ///
+ Single,
+
+ ///
+ /// Always try to return multiple results.
+ ///
+ Multiple,
+
+ ///
+ /// Try to make the right choice of single or multiple results based on context.
+ ///
+ Automatic,
+ }
+}
diff --git a/src/Toolkit/Toolkit/UI/Controls/SearchView/SearchSuggestion.cs b/src/Toolkit/Toolkit/UI/Controls/SearchView/SearchSuggestion.cs
new file mode 100644
index 000000000..367f9c7cf
--- /dev/null
+++ b/src/Toolkit/Toolkit/UI/Controls/SearchView/SearchSuggestion.cs
@@ -0,0 +1,65 @@
+// /*******************************************************************************
+// * Copyright 2012-2018 Esri
+// *
+// * Licensed under the Apache License, Version 2.0 (the "License");
+// * you may not use this file except in compliance with the License.
+// * You may obtain a copy of the License at
+// *
+// * http://www.apache.org/licenses/LICENSE-2.0
+// *
+// * Unless required by applicable law or agreed to in writing, software
+// * distributed under the License is distributed on an "AS IS" BASIS,
+// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// * See the License for the specific language governing permissions and
+// * limitations under the License.
+// ******************************************************************************/
+
+using Esri.ArcGISRuntime.Tasks.Geocoding;
+
+namespace Esri.ArcGISRuntime.Toolkit.UI.Controls
+{
+ ///
+ /// Wraps a search suggestion for display.
+ ///
+ public class SearchSuggestion
+ {
+ ///
+ /// Gets or sets the title to use when displaying a suggestion.
+ ///
+ public string DisplayTitle { get; set; }
+
+ ///
+ /// Gets or sets the optional subtitle that may be used when displaying a suggestion.
+ ///
+ public string? DisplaySubtitle { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether this result is for a collection (e.g. 'Coffee Shops') or a single result (e.g. 'Starbucks on Grand').
+ ///
+ public bool IsCollection { get; set; }
+
+ ///
+ /// Gets the that created this result.
+ ///
+ public ISearchSource OwningSource { get; }
+
+ ///
+ /// Gets or sets any underlying object for the suggestion.
+ ///
+ ///
+ /// This is helpful in cases where an underlying suggestion object is useful when completing a search. For example, when using a locator, the underlying should be used when accepting the suggestion for a search.
+ ///
+ public object? UnderlyingObject { get; set; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Sets .
+ /// Sets .
+ public SearchSuggestion(string title, ISearchSource owner)
+ {
+ DisplayTitle = title;
+ OwningSource = owner;
+ }
+ }
+}
diff --git a/src/Toolkit/Toolkit/UI/Controls/SearchView/SearchView.cs b/src/Toolkit/Toolkit/UI/Controls/SearchView/SearchView.cs
new file mode 100644
index 000000000..cf4a32765
--- /dev/null
+++ b/src/Toolkit/Toolkit/UI/Controls/SearchView/SearchView.cs
@@ -0,0 +1,812 @@
+// /*******************************************************************************
+// * Copyright 2012-2018 Esri
+// *
+// * Licensed under the Apache License, Version 2.0 (the "License");
+// * you may not use this file except in compliance with the License.
+// * You may obtain a copy of the License at
+// *
+// * http://www.apache.org/licenses/LICENSE-2.0
+// *
+// * Unless required by applicable law or agreed to in writing, software
+// * distributed under the License is distributed on an "AS IS" BASIS,
+// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// * See the License for the specific language governing permissions and
+// * limitations under the License.
+// ******************************************************************************/
+#if !XAMARIN
+using System;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows.Input;
+using Esri.ArcGISRuntime.Geometry;
+using Esri.ArcGISRuntime.Mapping;
+using Esri.ArcGISRuntime.Toolkit.Internal;
+using Esri.ArcGISRuntime.UI;
+using Esri.ArcGISRuntime.UI.Controls;
+#if !NETFX_CORE
+using System.Windows;
+using System.Windows.Controls;
+#else
+using System.Collections;
+using System.Collections.Generic;
+using Windows.Foundation;
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+using Windows.UI.Xaml.Controls.Primitives;
+#endif
+
+namespace Esri.ArcGISRuntime.Toolkit.UI.Controls
+{
+ ///
+ /// View for searching with locators or custom search sources.
+ ///
+#if NETFX_CORE
+ [TemplatePart(Name = "PART_SuggestionList", Type = typeof(ListView))]
+#endif
+ public partial class SearchView : Control, INotifyPropertyChanged
+ {
+ // Controls how long the control waits after typing stops before looking for suggestions.
+ private const int TypingDelayMilliseconds = 75;
+ private GeoModel? _lastUsedGeomodel;
+ private readonly GraphicsOverlay _resultOverlay;
+ private bool _isSourceSelectOpen;
+ private CancellationTokenSource? _configurationCancellationToken;
+
+ // Flag indicates whether control is waiting after user finished typing.
+ private bool _waitFlag;
+
+ // Flag indicating that query text is changing as a result of selecting a suggestion; view should not request suggestions in response to the user suggesting a selection.
+ private bool _acceptingSuggestionFlag;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public SearchView()
+ {
+ DefaultStyleKey = typeof(SearchView);
+ DataContext = this;
+ SearchViewModel = new SearchViewModel();
+ _resultOverlay = new GraphicsOverlay { Id = "SearchView_Result_Overlay" };
+ ClearCommand = new DelegateCommand(HandleClearSearchCommand);
+ SearchCommand = new DelegateCommand(HandleSearchCommand);
+ RepeatSearchHereCommand = new DelegateCommand(HandleRepeatSearchHereCommand);
+ }
+
+#if NETFX_CORE
+ private ListView _suggestionList;
+
+ // UWP listview automatically selects first item when doing grouping; using this flag to be able to ignore that first selection.
+ private bool _groupListSelectionFlag;
+
+ ///
+ protected override void OnApplyTemplate()
+ {
+ base.OnApplyTemplate();
+
+ if (_suggestionList != null)
+ {
+ _suggestionList.SelectionChanged -= SuggestionList_SelectionChanged;
+ _suggestionList = null;
+ }
+
+ var listview = GetTemplateChild("PART_SuggestionList");
+
+ if (listview is ListView newlistview)
+ {
+ _suggestionList = newlistview;
+ _suggestionList.SelectedIndex = -1;
+ _suggestionList.SelectionChanged += SuggestionList_SelectionChanged;
+ }
+ }
+
+ private void SuggestionList_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (_groupListSelectionFlag)
+ {
+ _suggestionList.SelectedIndex = -1;
+ return;
+ }
+
+ if (e.AddedItems.FirstOrDefault() is SearchSuggestion suggestion)
+ {
+ SearchViewModel?.AcceptSuggestion(suggestion);
+ }
+
+ if (_suggestionList != null)
+ {
+ _suggestionList.SelectedIndex = -1;
+ }
+ }
+#endif
+
+ private async Task ConfigureViewModel()
+ {
+ if (!EnableDefaultWorldGeocoder)
+ {
+ return;
+ }
+
+ if (_configurationCancellationToken != null)
+ {
+ _configurationCancellationToken.Cancel();
+ }
+
+ _configurationCancellationToken = new CancellationTokenSource();
+
+ try
+ {
+ await (SearchViewModel?.ConfigureDefaultWorldGeocoder(_configurationCancellationToken.Token) ?? Task.CompletedTask);
+ }
+ catch (Exception)
+ {
+ // Ignore
+ }
+ }
+
+ private void AddResultToGeoView(SearchResult result)
+ {
+ if (result?.GeoElement is Graphic graphic)
+ {
+ _resultOverlay.Graphics.Add(graphic);
+ }
+ }
+
+ #region Binding support
+
+ ///
+ /// Gets or sets the selected suggestion, triggering a search.
+ ///
+ public SearchSuggestion? SelectedSuggestion
+ {
+ get => null;
+ set
+ {
+ // ListView calls selecteditem binding with null when collection is cleared.
+ if (value is SearchSuggestion userSelection)
+ {
+ _acceptingSuggestionFlag = true;
+ _ = SearchViewModel?.AcceptSuggestion(userSelection)
+ .ContinueWith(tt => _acceptingSuggestionFlag = false, TaskScheduler.FromCurrentSynchronizationContext());
+ }
+ }
+ }
+
+ ///
+ /// Gets the visibility for the result list view.
+ ///
+ public Visibility ResultViewVisibility
+ {
+ get
+ {
+ if (!EnableResultListView)
+ {
+ return Visibility.Collapsed;
+ }
+
+ // Ensure no result message is visible
+ if ((SearchViewModel?.Results != null && SearchViewModel.Results.Count == 0) || (SearchViewModel?.Suggestions != null && SearchViewModel.Suggestions.Count == 0))
+ {
+ return Visibility.Visible;
+ }
+
+ if (!EnableIndividualResultDisplay && (SearchViewModel?.SearchMode == SearchResultMode.Single || SearchViewModel?.SelectedResult != null))
+ {
+ return Visibility.Collapsed;
+ }
+
+ if (SearchViewModel?.Results?.Any() ?? false)
+ {
+ return Visibility.Visible;
+ }
+
+ return Visibility.Collapsed;
+ }
+ }
+
+ ///
+ /// Gets the visibility for the source selection button.
+ ///
+ public Visibility SourceSelectVisibility
+ {
+ get
+ {
+ if (SearchViewModel?.Sources.Count > 1)
+ {
+ return Visibility.Visible;
+ }
+
+ return Visibility.Collapsed;
+ }
+ }
+
+ ///
+ /// Gets the visibility for the presentation of the .
+ ///
+ public Visibility ResultMessageVisibility
+ {
+ get
+ {
+ if (SearchViewModel?.Suggestions?.Count == 0 || SearchViewModel?.Results?.Count == 0)
+ {
+ return Visibility.Visible;
+ }
+
+ return Visibility.Collapsed;
+ }
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the source selection view is being displayed.
+ ///
+ public bool IsSourceSelectOpen
+ {
+ get => _isSourceSelectOpen;
+ set
+ {
+ if (value != _isSourceSelectOpen)
+ {
+ _isSourceSelectOpen = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsSourceSelectOpen)));
+ }
+ }
+ }
+
+ #endregion binding support
+
+ #region events
+
+ private static void OnGeoViewPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is SearchView sender)
+ {
+ if (e.OldValue is GeoView oldGeoView)
+ {
+ oldGeoView.DismissCallout();
+ oldGeoView.ViewpointChanged -= sender.GeoView_ViewpointChanged;
+ sender._lastUsedGeomodel = null;
+ (oldGeoView as INotifyPropertyChanged).PropertyChanged -= sender.HandleMapChange;
+ if (oldGeoView.GraphicsOverlays?.Contains(sender._resultOverlay) ?? false)
+ {
+ oldGeoView.GraphicsOverlays.Remove(sender._resultOverlay);
+ }
+ }
+
+ if (e.NewValue is GeoView newGeoView)
+ {
+ (newGeoView as INotifyPropertyChanged).PropertyChanged += sender.HandleMapChange;
+ newGeoView.ViewpointChanged += sender.GeoView_ViewpointChanged;
+ newGeoView.GraphicsOverlays?.Add(sender._resultOverlay);
+ }
+
+ _ = sender.ConfigureViewModel();
+ }
+ }
+
+ private static void OnEnableDefualtWorldGeocoderPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ _ = (d as SearchView)?.ConfigureViewModel();
+ }
+
+ private static void OnViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is SearchView sendingView)
+ {
+ if (e.OldValue is SearchViewModel oldModel)
+ {
+ oldModel.PropertyChanged -= sendingView.SearchViewModel_PropertyChanged;
+ if (oldModel.Sources is INotifyCollectionChanged oldSources)
+ {
+ oldSources.CollectionChanged -= sendingView.Sources_CollectionChanged;
+ }
+ }
+
+ if (e.NewValue is SearchViewModel newModel)
+ {
+ newModel.PropertyChanged += sendingView.SearchViewModel_PropertyChanged;
+ if (newModel.Sources is INotifyCollectionChanged newSources)
+ {
+ newSources.CollectionChanged += sendingView.Sources_CollectionChanged;
+ }
+ }
+ }
+ }
+
+ private void Sources_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
+ {
+ HandleSourcesChange();
+ }
+
+ private void HandleSourcesChange()
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SourceSelectVisibility)));
+ IsSourceSelectOpen = false;
+ }
+
+ private void HandleMapChange(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(Map) || e.PropertyName == nameof(Scene))
+ {
+ _ = ConfigureViewModel();
+ return;
+ }
+
+ // When binding, MapView is unreliable about notifying about map changes, especially when first connecting to the view
+ if (e.PropertyName == nameof(MapView.DrawStatus) && _lastUsedGeomodel == null)
+ {
+ if (GeoView is MapView mv && mv.Map is Map map)
+ {
+ _lastUsedGeomodel = map;
+ }
+ else if (GeoView is SceneView sv && sv.Scene is Scene scene)
+ {
+ _lastUsedGeomodel = scene;
+ }
+
+ _ = ConfigureViewModel();
+ }
+ }
+
+ private static void OnEnableResultListViewChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) => (d as SearchView)?.NotifyPropertyChange(nameof(ResultViewVisibility));
+
+ private void SearchViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ IsSourceSelectOpen = false;
+ switch (e.PropertyName)
+ {
+ case nameof(SearchViewModel.CurrentQuery):
+ _ = HandleQueryChanged();
+ break;
+ case nameof(SearchViewModel.SearchMode):
+ HandleSearchModeChanged();
+ break;
+ case nameof(SearchViewModel.Results):
+ _ = HandleResultsCollectionChanged();
+ break;
+ case nameof(SearchViewModel.SelectedResult):
+ _ = HandleSelectedResultChanged();
+ break;
+ case nameof(SearchViewModel.Suggestions):
+ HandleSuggestionsChanged();
+ break;
+ case nameof(SearchViewModel.Sources):
+ HandleSourcesChange();
+ break;
+ }
+ }
+
+ private void GeoView_ViewpointChanged(object? sender, EventArgs e) => HandleViewpointChanged();
+
+ ///
+ /// Updates with the current viewpoint.
+ ///
+ private void HandleViewpointChanged()
+ {
+ if (SearchViewModel == null || GeoView == null)
+ {
+ return;
+ }
+
+ if (GeoView.GetCurrentViewpoint(ViewpointType.BoundingGeometry)?.TargetGeometry is Envelope targetEnvelope)
+ {
+ SearchViewModel.QueryArea = targetEnvelope;
+ SearchViewModel.QueryCenter = targetEnvelope.GetCenter();
+ }
+ }
+
+ ///
+ /// Implements typing delay behavior; it is best to wait for user to finish typing before asking for suggestions.
+ ///
+ ///
+ /// The view XAML is expected to bind to the viewmodel property directly, in such a matter that the query updates every keystroke.
+ ///
+ private async Task HandleQueryChanged()
+ {
+ if (_waitFlag || _acceptingSuggestionFlag || SearchViewModel == null)
+ {
+ return;
+ }
+
+ _waitFlag = true;
+ await Task.Delay(TypingDelayMilliseconds);
+ _waitFlag = false;
+
+ await SearchViewModel.UpdateSuggestions();
+ }
+
+ private async Task HandleSelectedResultChanged()
+ {
+ NotifyPropertyChange(nameof(ResultViewVisibility));
+
+ if (SearchViewModel?.SelectedResult is SearchResult selectedResult)
+ {
+ _resultOverlay?.Graphics.Clear();
+ AddResultToGeoView(selectedResult);
+
+ if (GeoView != null && selectedResult.CalloutDefinition != null && selectedResult.GeoElement != null)
+ {
+ GeoView.ShowCalloutForGeoElement(selectedResult.GeoElement, new Point(0, 0), selectedResult.CalloutDefinition);
+ }
+
+ // Zoom to the feature
+ if (selectedResult.SelectionViewpoint != null && GeoView != null && SearchViewModel != null)
+ {
+ SearchViewModel.IgnoreAreaChangesFlag = true;
+ await GeoView.SetViewpointAsync(selectedResult.SelectionViewpoint);
+ await Task.Delay(1000);
+ SearchViewModel.IgnoreAreaChangesFlag = false;
+ }
+ }
+ else
+ {
+ GeoView?.DismissCallout();
+ }
+ }
+
+ private async Task HandleResultsCollectionChanged()
+ {
+ if (SearchViewModel == null)
+ {
+ return;
+ }
+
+ NotifyPropertyChange(nameof(ResultViewVisibility));
+ NotifyPropertyChange(nameof(ResultMessageVisibility));
+
+ if (SearchViewModel.Results == null)
+ {
+ _resultOverlay?.Graphics?.Clear();
+ }
+ else if (SearchViewModel.SelectedResult == null && GeoView != null)
+ {
+ _resultOverlay?.Graphics?.Clear();
+ foreach (var result in SearchViewModel.Results)
+ {
+ AddResultToGeoView(result);
+ }
+
+ var zoomableResults = SearchViewModel.Results
+ .Select(res => res.GeoElement?.Geometry).OfType().ToList();
+
+ if (zoomableResults != null && zoomableResults.Count > 1)
+ {
+ SearchViewModel.IgnoreAreaChangesFlag = true;
+ var newViewpoint = GeometryEngine.CombineExtents(zoomableResults);
+ if (GeoView is MapView mv)
+ {
+ await mv.SetViewpointGeometryAsync(newViewpoint, MultipleResultZoomBuffer);
+ }
+ else
+ {
+ await GeoView.SetViewpointAsync(new Viewpoint(newViewpoint));
+ }
+
+ await Task.Delay(1000);
+ SearchViewModel.IgnoreAreaChangesFlag = false;
+ }
+ }
+ }
+
+ private void HandleSearchModeChanged()
+ {
+ NotifyPropertyChange(nameof(ResultViewVisibility));
+ }
+
+ #endregion events
+
+ #region commands
+
+ ///
+ /// Gets a command that clears the current search.
+ ///
+ public ICommand ClearCommand { get; private set; }
+
+ ///
+ /// Gets a command that starts a search with current parameters.
+ ///
+ public ICommand SearchCommand { get; private set; }
+
+ ///
+ /// Gets a command that repeats the last search with new geometry.
+ ///
+ public ICommand RepeatSearchHereCommand { get; private set; }
+
+ private void HandleClearSearchCommand()
+ {
+ SearchViewModel?.CancelSearch();
+ SearchViewModel?.ClearSearch();
+ }
+
+ private void HandleSearchCommand()
+ {
+ SearchViewModel?.CommitSearch();
+ }
+
+ private void HandleRepeatSearchHereCommand()
+ {
+ SearchViewModel?.RepeatSearchHere();
+ }
+ #endregion commands
+
+ #region properties
+
+ ///
+ /// Gets or sets the GeoView associated with this view.
+ ///
+ ///
+ /// If set, will add a graphics overlay for showing results, and will automatically navigate to show search results.
+ ///
+ public GeoView? GeoView
+ {
+ get => GetValue(GeoViewProperty) as GeoView;
+ set => SetValue(GeoViewProperty, value);
+ }
+
+ ///
+ /// Gets or sets a message to show when a search completes with no results.
+ ///
+ public string? NoResultMessage
+ {
+ get => GetValue(NoResultMessageProperty) as string;
+ set => SetValue(NoResultMessageProperty, value);
+ }
+
+ ///
+ /// Gets or sets the viewmodel that implements core search behavior.
+ ///
+ public SearchViewModel? SearchViewModel
+ {
+ get => GetValue(SearchViewModelProperty) as SearchViewModel;
+ set => SetValue(SearchViewModelProperty, value);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether will include the Esri World Geocoder service by default.
+ ///
+ public bool EnableDefaultWorldGeocoder
+ {
+ get => (bool)GetValue(EnableDefaultWorldGeocoderProperty);
+ set => SetValue(EnableDefaultWorldGeocoderProperty, value);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether a 'Repeat Search' button will be displayed
+ /// when the user pans the map a sufficient amount after a search completes.
+ ///
+ ///
+ /// Some consumer applications will display this button in a separate area of the UI from the search bar, often centered over the map.
+ /// This property is intended to allow hiding the default button if using a custom 'Repeat Search' implementation.
+ /// See and to enable a custom button implementation.
+ ///
+ public bool EnableRepeatSearchHereButton
+ {
+ get => (bool)GetValue(EnableRepeatSearchHereButtonProperty);
+ set => SetValue(EnableRepeatSearchHereButtonProperty, value);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the view will show the selected result.
+ /// If false, the result list is hidden automatically when a result is selected.
+ ///
+ ///
+ /// See to display custom UI for the selected result.
+ ///
+ public bool EnableIndividualResultDisplay
+ {
+ get => (bool)GetValue(EnableIndividualResultDisplayProperty);
+ set => SetValue(EnableIndividualResultDisplayProperty, value);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the default result list view will be shown.
+ ///
+ ///
+ /// Set this value to false to enable a custom list presentation.
+ ///
+ public bool EnableResultListView
+ {
+ get => (bool)GetValue(EnableResultListViewProperty);
+ set => SetValue(EnableResultListViewProperty, value);
+ }
+
+ ///
+ /// Gets or sets the buffer used when zooming to a set of results.
+ ///
+ public double MultipleResultZoomBuffer
+ {
+ get => (double)GetValue(MultipleResultZoomBufferProperty);
+ set => SetValue(MultipleResultZoomBufferProperty, value);
+ }
+
+ ///
+ /// Gets or sets the text to display for the button used to select all search sources.
+ ///
+ public string? AllSourceSelectText
+ {
+ get => GetValue(AllSourceSelectTextProperty) as string;
+ set => SetValue(AllSourceSelectTextProperty, value);
+ }
+
+ ///
+ /// Gets or sets the tooltip text to display for the clear/cancel search button.
+ ///
+ public string? ClearSearchTooltipText
+ {
+ get => GetValue(ClearSearchTooltipTextProperty) as string;
+ set => SetValue(ClearSearchTooltipTextProperty, value);
+ }
+
+ ///
+ /// Gets or sets the tooltip text to display for the search button.
+ ///
+ public string? SearchTooltipText
+ {
+ get => GetValue(SearchTooltipTextProperty) as string;
+ set => SetValue(SearchTooltipTextProperty, value);
+ }
+
+ ///
+ /// Gets or sets the text to display in the 'Repeat Search' button.
+ ///
+ public string? RepeatSearchButtonText
+ {
+ get => GetValue(RepeatSearchButtonTextProperty) as string;
+ set => SetValue(RepeatSearchButtonTextProperty, value);
+ }
+
+ #endregion properties
+
+ #region dependency properties
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty NoResultMessageProperty =
+ DependencyProperty.Register(nameof(NoResultMessage), typeof(string), typeof(SearchView), new PropertyMetadata(null));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty GeoViewProperty =
+ DependencyProperty.Register(nameof(GeoView), typeof(GeoView), typeof(SearchView), new PropertyMetadata(null, OnGeoViewPropertyChanged));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty EnableDefaultWorldGeocoderProperty =
+ DependencyProperty.Register(nameof(EnableDefaultWorldGeocoder), typeof(bool), typeof(SearchView), new PropertyMetadata(false, OnEnableDefualtWorldGeocoderPropertyChanged));
+
+ ///
+ /// Identifies the dependency proeprty.
+ ///
+ public static readonly DependencyProperty EnableRepeatSearchHereButtonProperty =
+ DependencyProperty.Register(nameof(EnableRepeatSearchHereButton), typeof(bool), typeof(SearchView), new PropertyMetadata(true));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty SearchViewModelProperty =
+ DependencyProperty.Register(nameof(SearchViewModel), typeof(SearchViewModel), typeof(SearchView), new PropertyMetadata(null, OnViewModelChanged));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty EnableResultListViewProperty =
+ DependencyProperty.Register(nameof(EnableResultListView), typeof(bool), typeof(SearchView), new PropertyMetadata(true, OnEnableResultListViewChanged));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty EnableIndividualResultDisplayProperty =
+ DependencyProperty.Register(nameof(EnableIndividualResultDisplay), typeof(bool), typeof(SearchView), new PropertyMetadata(false, OnEnableResultListViewChanged));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty MultipleResultZoomBufferProperty =
+ DependencyProperty.Register(nameof(MultipleResultZoomBuffer), typeof(double), typeof(SearchView), new PropertyMetadata(64.0));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty AllSourceSelectTextProperty =
+ DependencyProperty.Register(nameof(AllSourceSelectText), typeof(string), typeof(SearchView), null);
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty ClearSearchTooltipTextProperty =
+ DependencyProperty.Register(nameof(ClearSearchTooltipText), typeof(string), typeof(SearchView), null);
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty SearchTooltipTextProperty =
+ DependencyProperty.Register(nameof(SearchTooltipText), typeof(string), typeof(SearchView), null);
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty RepeatSearchButtonTextProperty =
+ DependencyProperty.Register(nameof(RepeatSearchButtonText), typeof(string), typeof(SearchView), null);
+ #endregion dependency properties
+
+ ///
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private void NotifyPropertyChange(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+
+ private void HandleSuggestionsChanged()
+ {
+ NotifyPropertyChange(nameof(ResultViewVisibility));
+ NotifyPropertyChange(nameof(ResultMessageVisibility));
+ #if NETFX_CORE
+ UpdateGroupingForUWP();
+ #endif
+ }
+
+#if NETFX_CORE
+ private void UpdateGroupingForUWP()
+ {
+ _groupListSelectionFlag = true;
+ if (SearchViewModel?.Suggestions != null)
+ {
+ GroupedSuggestions = SearchViewModel.Suggestions.GroupBy(m => m.OwningSource, (key, list) => new SuggestionsGrouped(key, list)).ToList();
+ }
+ else
+ {
+ GroupedSuggestions = null;
+ }
+
+ _groupListSelectionFlag = false;
+ }
+
+ private List _groupedSuggestions;
+
+ ///
+ /// Gets the grouped list of suggestions.
+ ///
+ public List? GroupedSuggestions
+ {
+ get => _groupedSuggestions;
+ private set
+ {
+ if (value != _groupedSuggestions)
+ {
+ _groupedSuggestions = value;
+ NotifyPropertyChange(nameof(GroupedSuggestions));
+ }
+ }
+ }
+
+ ///
+ /// Class to support grouping suggestions on UWP.
+ ///
+ public class SuggestionsGrouped : IGrouping
+ {
+ private readonly List _suggestions;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ internal SuggestionsGrouped(ISearchSource owningSource, IEnumerable suggestions)
+ {
+ Key = owningSource;
+ _suggestions = suggestions.ToList();
+ }
+
+ ///
+ public ISearchSource Key { get; private set; }
+
+ ///
+ public IEnumerator GetEnumerator() => _suggestions.GetEnumerator();
+
+ ///
+ IEnumerator IEnumerable.GetEnumerator() => _suggestions.GetEnumerator();
+ }
+#endif
+ }
+}
+#endif
\ No newline at end of file
diff --git a/src/Toolkit/Toolkit/UI/Controls/SearchView/SearchViewModel.cs b/src/Toolkit/Toolkit/UI/Controls/SearchView/SearchViewModel.cs
new file mode 100644
index 000000000..2a354b98f
--- /dev/null
+++ b/src/Toolkit/Toolkit/UI/Controls/SearchView/SearchViewModel.cs
@@ -0,0 +1,544 @@
+// /*******************************************************************************
+// * Copyright 2012-2018 Esri
+// *
+// * Licensed under the Apache License, Version 2.0 (the "License");
+// * you may not use this file except in compliance with the License.
+// * You may obtain a copy of the License at
+// *
+// * http://www.apache.org/licenses/LICENSE-2.0
+// *
+// * Unless required by applicable law or agreed to in writing, software
+// * distributed under the License is distributed on an "AS IS" BASIS,
+// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// * See the License for the specific language governing permissions and
+// * limitations under the License.
+// ******************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Esri.ArcGISRuntime.Geometry;
+using Esri.ArcGISRuntime.Mapping;
+using Esri.ArcGISRuntime.Tasks.Geocoding;
+
+namespace Esri.ArcGISRuntime.Toolkit.UI.Controls
+{
+ ///
+ /// Backing controller for a search experience, intended for use with SearchView.
+ /// SearchView supports searching, with search-as-you-type for multiple search providers via .
+ ///
+ public class SearchViewModel : INotifyPropertyChanged
+ {
+ private const int QueryTimeoutMilliseconds = 2000;
+ private ISearchSource? _activeSource;
+ private SearchResult? _selectedResult;
+ private string? _currentQuery;
+ private string? _defaultPlaceholder = "Find a place or address";
+ private SearchResultMode _searchMode = SearchResultMode.Automatic;
+ private Geometry.Geometry? _queryArea;
+ private Geometry.Geometry? _lastSetArea;
+ private MapPoint? _queryCenter;
+ private IList? _results;
+ private IList? _suggestions;
+ private SearchSuggestion? _lastSuggestion;
+
+ private bool _searchInProgress;
+ private bool _suggestInProgress;
+
+ private CancellationTokenSource? _activeSearchCancellation;
+ private CancellationTokenSource? _activeSuggestCancellation;
+
+ ///
+ /// Gets or sets the active search source, if one is selected. If there is no selection, all sources will be used for the search.
+ ///
+ public ISearchSource? ActiveSource
+ {
+ get => _activeSource;
+ set
+ {
+ SetPropertyChanged(value, ref _activeSource, nameof(ActiveSource), nameof(ActivePlaceholder));
+ _ = UpdateSuggestions();
+ }
+ }
+
+ ///
+ /// Gets a value indicating whether a search operation is in progress.
+ ///
+ public bool IsSearchInProgress
+ {
+ get => _searchInProgress;
+ private set => SetPropertyChanged(value, ref _searchInProgress, nameof(IsSearchInProgress), nameof(IsWaiting));
+ }
+
+ ///
+ /// Gets a value indicating whether a suggestion request is in progress.
+ ///
+ public bool IsSuggestInProgress
+ {
+ get => _suggestInProgress;
+ private set => SetPropertyChanged(value, ref _suggestInProgress, nameof(IsSuggestInProgress), nameof(IsWaiting));
+ }
+
+ ///
+ /// Gets a value indicating whether a waiting operation (search, suggestion) is in progress.
+ ///
+ ///
+ /// This can be used to implement activity indicator or progress bar display.
+ ///
+ public bool IsWaiting
+ {
+ get => IsSearchInProgress || IsSuggestInProgress;
+ }
+
+ ///
+ /// Gets or sets the selected search result.
+ ///
+ public SearchResult? SelectedResult { get => _selectedResult; set => SetPropertyChanged(value, ref _selectedResult); }
+
+ ///
+ /// Gets or sets the current query.
+ ///
+ ///
+ /// Call to refresh suggestions after updating the query.
+ ///
+ public string? CurrentQuery { get => _currentQuery; set => SetPropertyChanged(value, ref _currentQuery); }
+
+ ///
+ /// Gets or sets the default placeholder to use when there is no or the does not have a placeholder defined.
+ /// Consumers should always display the in the UI, rather than accessing this property directly.
+ ///
+ public string? DefaultPlaceholder { get => _defaultPlaceholder; set => SetPropertyChanged(value, ref _defaultPlaceholder, nameof(DefaultPlaceholder), nameof(ActivePlaceholder)); }
+
+ ///
+ /// Gets the correct placeholder to display in the UI.
+ ///
+ public string? ActivePlaceholder
+ {
+ get
+ {
+ if (ActiveSource?.Placeholder is string placeholder)
+ {
+ return placeholder;
+ }
+
+ return DefaultPlaceholder;
+ }
+ }
+
+ ///
+ /// Gets or sets the search mode, which defaults to .
+ /// This mode controls how many results are displayed when a search is performed.
+ ///
+ public SearchResultMode SearchMode { get => _searchMode; set => SetPropertyChanged(value, ref _searchMode); }
+
+ ///
+ /// Gets or sets the query area to use when searching and getting suggestions.
+ /// When used in conjunction with a GeoView, this property should be set every time navigation completes,
+ /// to enable automatic update of the property.
+ ///
+ public Geometry.Geometry? QueryArea
+ {
+ get => _queryArea;
+ set
+ {
+ if (value != null)
+ {
+ value = GeometryEngine.NormalizeCentralMeridian(value);
+ }
+
+ SetPropertyChanged(value, ref _queryArea);
+
+ if (IgnoreAreaChangesFlag)
+ {
+ // Store set viewpoint for comparison.
+ _lastSetArea = value;
+ }
+ else if (Results != null && _lastSetArea?.Extent is Envelope oldView && value?.Extent is Envelope newView)
+ {
+ double avgSize = (oldView.Width + oldView.Height) / 2;
+ double threshold = avgSize / 4;
+ double distance = GeometryEngine.Distance(oldView.GetCenter(), newView.GetCenter());
+ double newAvgSize = (newView.Width + newView.Height) / 2;
+ IsEligibleForRequery = distance > threshold || newAvgSize > avgSize * 1.25 || newAvgSize < avgSize * 0.75;
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets the center point around which results should be returned.
+ ///
+ public MapPoint? QueryCenter
+ {
+ get => _queryCenter;
+ set
+ {
+ if (value != null)
+ {
+ value = GeometryEngine.NormalizeCentralMeridian(value) as MapPoint;
+ }
+
+ SetPropertyChanged(value, ref _queryCenter);
+ }
+ }
+
+ ///
+ /// Gets or sets a value indicating whether changes to the query area should be ignored. This is used to prevent becoming true because the view zoomed to a result.
+ ///
+ ///
+ /// Set this value to true when the GeoView is being navigated to show results, and set to false when the navigation completes.
+ ///
+ public bool IgnoreAreaChangesFlag { get; set; }
+
+ ///
+ /// Gets or sets the list of available search sources, which can be updated dynamically.
+ ///
+ /// See for a convenient method to populate this collection automatically.
+ public IList Sources { get; set; } = new ObservableCollection();
+
+ ///
+ /// Gets the list of search results for the most-recently completed query.
+ /// Clearing a search via will set this collection to null.
+ ///
+ public IList? Results { get => _results; private set => SetPropertyChanged(value, ref _results); }
+
+ ///
+ /// Gets the list of search suggestions. This value is set after calls to .
+ ///
+ public IList? Suggestions { get => _suggestions; private set => SetPropertyChanged(value, ref _suggestions); }
+
+ private bool _viewpointChangedSinceResultReturned;
+
+ ///
+ /// Gets a value indicating whether spatial parameters have changed enough to justify displaying a 'Repeat Search Here' button.
+ ///
+ ///
+ /// 'Repeat Search Here' is a common pattern in applications that return multiple search results.
+ ///
+ public bool IsEligibleForRequery
+ {
+ get => _viewpointChangedSinceResultReturned;
+ private set => SetPropertyChanged(value, ref _viewpointChangedSinceResultReturned);
+ }
+
+ ///
+ /// Submits the current query as a fresh search.
+ ///
+ ///
+ /// The search will return results from the or all if is null.
+ /// Search in progress can be cancelled by calling .
+ ///
+ public async Task CommitSearch()
+ {
+ if (_activeSearchCancellation != null)
+ {
+ _activeSearchCancellation.Cancel();
+ }
+
+ if (string.IsNullOrWhiteSpace(CurrentQuery))
+ {
+ return;
+ }
+
+ using var searchCancellation = new CancellationTokenSource(QueryTimeoutMilliseconds);
+ try
+ {
+ _activeSearchCancellation = searchCancellation;
+ PrepareForNewSearch();
+ _lastSuggestion = null;
+ var sourcesToSearch = SourcesToSearch();
+
+ foreach (var source in SourcesToSearch())
+ {
+ source.SearchArea = QueryArea;
+ source.PreferredSearchLocation = QueryCenter;
+ }
+
+ var allResults = await Task.WhenAll(sourcesToSearch.Select(s => s.SearchAsync(CurrentQuery!, _activeSearchCancellation.Token)));
+
+ ApplyNewResult(allResults.SelectMany(l => l).ToList(), null);
+ }
+ catch (TaskCanceledException)
+ {
+ ApplyNewResult(new List(0), null);
+ }
+ finally
+ {
+ _activeSearchCancellation = null;
+ IsSearchInProgress = false;
+ }
+ }
+
+ ///
+ /// Repeats the current search, with results confined to the area defined by .
+ ///
+ ///
+ /// This is used to allow users to narrow down search results using a geographic constraint.
+ /// This is especially useful when a search results in multiple results.
+ ///
+ public async Task RepeatSearchHere()
+ {
+ if (_activeSearchCancellation != null)
+ {
+ _activeSearchCancellation.Cancel();
+ }
+
+ if (string.IsNullOrWhiteSpace(CurrentQuery) || QueryArea?.Extent == null)
+ {
+ return;
+ }
+
+ using var searchCancellation = new CancellationTokenSource(QueryTimeoutMilliseconds);
+ try
+ {
+ _activeSearchCancellation = searchCancellation;
+ PrepareForNewSearch();
+ var sourcesToSearch = SourcesToSearch();
+ foreach (var source in sourcesToSearch)
+ {
+ source.SearchArea = QueryArea;
+ source.PreferredSearchLocation = QueryCenter;
+ }
+
+ var allResults = await Task.WhenAll(sourcesToSearch.Select(s => s.RepeatSearchAsync(CurrentQuery!, QueryArea.Extent, _activeSearchCancellation.Token)));
+
+ ApplyNewResult(allResults.SelectMany(l => l).ToList(), _lastSuggestion);
+ }
+ catch (TaskCanceledException)
+ {
+ ApplyNewResult(new List(0), _lastSuggestion);
+ }
+ finally
+ {
+ _activeSearchCancellation = null;
+ IsSearchInProgress = false;
+ }
+ }
+
+ ///
+ /// Updates for the current query.
+ ///
+ public async Task UpdateSuggestions()
+ {
+ if (_activeSuggestCancellation != null)
+ {
+ _activeSuggestCancellation.Cancel();
+ }
+
+ if (string.IsNullOrWhiteSpace(CurrentQuery))
+ {
+ Suggestions = null;
+ return;
+ }
+
+ using var suggestCancellation = new CancellationTokenSource(QueryTimeoutMilliseconds);
+ try
+ {
+ IsSuggestInProgress = true;
+ _activeSuggestCancellation = suggestCancellation;
+ Suggestions = null;
+
+ var sourcesToSearch = SourcesToSearch();
+ foreach (var source in sourcesToSearch)
+ {
+ source.SearchArea = QueryArea;
+ source.PreferredSearchLocation = QueryCenter;
+ }
+
+ var allSuggestions = await Task.WhenAll(sourcesToSearch.Select(s => s.SuggestAsync(CurrentQuery!, suggestCancellation.Token)));
+
+ Suggestions = allSuggestions.SelectMany(l => l).ToList();
+ }
+ catch (TaskCanceledException)
+ {
+ Suggestions = new List(0);
+ }
+ finally
+ {
+ _activeSuggestCancellation = null;
+ IsSuggestInProgress = false;
+ }
+ }
+
+ ///
+ /// Initiates a search using a suggestion as the query.
+ ///
+ /// A suggestion from to be used as basis for the search.
+ /// This will update to match the selected suggestion.
+ public async Task AcceptSuggestion(SearchSuggestion suggestion)
+ {
+ if (_activeSearchCancellation != null)
+ {
+ _activeSearchCancellation.Cancel();
+ }
+
+ using var searchCancellation = new CancellationTokenSource(QueryTimeoutMilliseconds);
+ try
+ {
+ _activeSearchCancellation = searchCancellation;
+ PrepareForNewSearch();
+
+ _lastSuggestion = suggestion;
+
+ // Update the UI just so it matches user expectation
+ CurrentQuery = suggestion.DisplayTitle;
+
+ var selectedSource = suggestion.OwningSource;
+ var results = await selectedSource.SearchAsync(suggestion, searchCancellation.Token);
+
+ ApplyNewResult(results, suggestion);
+ }
+ catch (TaskCanceledException)
+ {
+ ApplyNewResult(new List(0), suggestion);
+ }
+ finally
+ {
+ _activeSearchCancellation = null;
+ IsSearchInProgress = false;
+ }
+ }
+
+ ///
+ /// Cancels any previous operations, clears results, and gets ready for a new search.
+ ///
+ private void PrepareForNewSearch()
+ {
+ SelectedResult = null;
+ Suggestions = null;
+ Results = null;
+ IsEligibleForRequery = false;
+ IsSearchInProgress = true;
+ }
+
+ private void ApplyNewResult(IList results, SearchSuggestion? originatingSuggestion)
+ {
+ if (!results.Any())
+ {
+ Results = new List();
+ return;
+ }
+
+ if (results.Count == 1)
+ {
+ Results = results.ToList();
+ SelectedResult = results.First();
+ return;
+ }
+
+ switch (SearchMode)
+ {
+ case SearchResultMode.Single:
+ Results = new List { results.First() };
+ SelectedResult = Results.First();
+ break;
+ case SearchResultMode.Multiple:
+ Results = results.ToList();
+ break;
+ case SearchResultMode.Automatic:
+ if (originatingSuggestion?.IsCollection ?? false)
+ {
+ Results = results.ToList();
+ }
+ else
+ {
+ Results = new List() { results.First() };
+ }
+
+ break;
+ }
+
+ if (Results?.Count == 1)
+ {
+ SelectedResult = Results.First();
+ }
+ }
+
+ ///
+ /// Cancels any active search/suggest tasks, then clears all results and the current query.
+ ///
+ public void ClearSearch()
+ {
+ _activeSearchCancellation?.Cancel();
+ _activeSuggestCancellation?.Cancel();
+ SelectedResult = null;
+ Results = null;
+ Suggestions = null;
+ CurrentQuery = null;
+ IsEligibleForRequery = false;
+ _lastSuggestion = null;
+ }
+
+ ///
+ /// Cancels any active search task.
+ ///
+ public void CancelSearch()
+ {
+ _activeSearchCancellation?.Cancel();
+ }
+
+ ///
+ /// Cancels any active suggest task.
+ ///
+ public void CancelSuggestion()
+ {
+ _activeSuggestCancellation?.Cancel();
+ }
+
+ ///
+ /// Configures the viewmodel with a search source optimized for use with the Esri World Geocoder service.
+ ///
+ /// Token used for cancellation.
+ public async Task ConfigureDefaultWorldGeocoder(CancellationToken token = default)
+ {
+ Sources.Clear();
+ Sources.Add(await LocatorSearchSource.CreateDefaultSourceAsync(token));
+ }
+
+ private List SourcesToSearch()
+ {
+ var selectedSources = new List();
+ if (ActiveSource == null)
+ {
+ selectedSources.AddRange(Sources);
+ }
+ else
+ {
+ selectedSources.Add(ActiveSource);
+ }
+
+ return selectedSources;
+ }
+
+ private void SetPropertyChanged(T value, ref T field, [CallerMemberName] string propertyName = "")
+ {
+ if (!Equals(value, field))
+ {
+ field = value;
+ OnPropertyChanged(propertyName);
+ }
+ }
+
+ private void SetPropertyChanged(T value, ref T field, params string[] notifiedProperties)
+ {
+ if (!Equals(value, field))
+ {
+ field = value;
+
+ foreach (var property in notifiedProperties)
+ {
+ OnPropertyChanged(property);
+ }
+ }
+ }
+
+ private void OnPropertyChanged([CallerMemberName] string propertyName = "") => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+
+ ///
+ public event PropertyChangedEventHandler? PropertyChanged;
+ }
+}
diff --git a/src/Toolkit/Toolkit/UI/Controls/SearchView/WorldGeocoderSearchSource.cs b/src/Toolkit/Toolkit/UI/Controls/SearchView/WorldGeocoderSearchSource.cs
new file mode 100644
index 000000000..91e0160b8
--- /dev/null
+++ b/src/Toolkit/Toolkit/UI/Controls/SearchView/WorldGeocoderSearchSource.cs
@@ -0,0 +1,296 @@
+// /*******************************************************************************
+// * Copyright 2012-2018 Esri
+// *
+// * Licensed under the Apache License, Version 2.0 (the "License");
+// * you may not use this file except in compliance with the License.
+// * You may obtain a copy of the License at
+// *
+// * http://www.apache.org/licenses/LICENSE-2.0
+// *
+// * Unless required by applicable law or agreed to in writing, software
+// * distributed under the License is distributed on an "AS IS" BASIS,
+// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// * See the License for the specific language governing permissions and
+// * limitations under the License.
+// ******************************************************************************/
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Esri.ArcGISRuntime.Symbology;
+using Esri.ArcGISRuntime.Tasks.Geocoding;
+using Esri.ArcGISRuntime.UI;
+
+namespace Esri.ArcGISRuntime.Toolkit.UI.Controls
+{
+ ///
+ /// Search source intended for use with the World Geocode Service and similarly-configured geocode services.
+ ///
+ internal class WorldGeocoderSearchSource : LocatorSearchSource
+ {
+ private const string AddressAttributeKey = "Place_addr";
+
+ // Attribute used to identify the type of result coming from the locaotr.
+ private const string LocatorIconAttributeKey = "Type";
+
+ private readonly Task _additionalLoadTask;
+
+ ///
+ /// Gets or sets the minimum number of results to attempt to return.
+ /// If there are too few results, the search is repeated with loosened parameters until enough results are accumulated.
+ ///
+ ///
+ /// If no search is successful, it is still possible to have a total number of results less than this threshold.
+ /// Does not apply to repeated search with area constraint.
+ /// Set to zero to disable search repeat behavior. Defaults to 1.
+ ///
+ public int RepeatSearchResultThreshold { get; set; } = 0;
+
+ ///
+ /// Gets or sets the minimum number of suggestions to attempt to return.
+ /// If there are too few suggestions, request is repeated with loosened constraints until enough suggestions are accumulated.
+ ///
+ ///
+ /// If no search is successful, it is still possible to have a total number of results less than this threshold.
+ /// Does not apply to repeated search with area constraint.
+ /// Set to zero to disable search repeat behavior. Defaults to 6.
+ ///
+ public int RepeatSuggestResultThreshold { get; set; } = 6;
+
+ ///
+ /// Gets or sets the web style used to find symbols for results.
+ /// When set, symbols are found for results based on the result's `Type` field, if available.
+ ///
+ ///
+ /// Defaults to the style identified by the name "Esri2DPointSymbolsStyle".
+ /// The default Esri 2D point symbol has good results for many of the types returned by the world geocode service.
+ /// You can use this property to customize result icons by publishing a web style, taking care to ensure that symbol keys match the `Type` attribute returned by the locator.
+ ///
+ public SymbolStyle? ResultSymbolStyle { get; set; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Locator to use.
+ /// Symbol style to use to find results.
+ public WorldGeocoderSearchSource(LocatorTask locator, SymbolStyle? style)
+ : base(locator)
+ {
+ SubtitleAttributeKey = AddressAttributeKey;
+ if (style != null)
+ {
+ ResultSymbolStyle = style;
+ }
+
+ _additionalLoadTask = EnsureLoaded();
+ }
+
+ private async Task EnsureLoaded()
+ {
+ await LoadTask;
+
+ if (Locator.LocatorInfo is LocatorInfo info)
+ {
+ // Locators from online services have descriptions but not names.
+ if (!string.IsNullOrWhiteSpace(info.Name) && info.Name != Locator.Uri?.ToString())
+ {
+ DisplayName = info.Name;
+ }
+ else if (!string.IsNullOrWhiteSpace(info.Description))
+ {
+ DisplayName = info.Description;
+ }
+ }
+
+ // Add attributes expected from the World Geocoder Service if present, otherwise default to all attributes.
+ if (Locator.Uri?.ToString() == WorldGeocoderUriString &&
+ (Locator.LocatorInfo?.ResultAttributes?.Any() ?? false))
+ {
+ var desiredAttributes = new[] { AddressAttributeKey, LocatorIconAttributeKey };
+ foreach (var attr in desiredAttributes.OfType())
+ {
+ if (Locator.LocatorInfo.ResultAttributes.Where(at => at.Name == attr).Any())
+ {
+ GeocodeParameters.ResultAttributeNames.Add(attr);
+ }
+ }
+ }
+ else
+ {
+ GeocodeParameters.ResultAttributeNames.Add("*");
+ }
+ }
+
+ ///
+ /// Converts suggest result list into list of suggestions, applying result limits and calling necessary callbacks.
+ ///
+ private IList SuggestionToSearchSuggestion(IReadOnlyList input)
+ {
+ var results = input.Select(i => SuggestResultToSearchSuggestion(i));
+
+ return results.Take(MaximumResults).ToList();
+ }
+
+ ///
+ public override async Task> SearchAsync(SearchSuggestion suggestion, CancellationToken cancellationToken = default)
+ {
+ await _additionalLoadTask;
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var tempParams = new GeocodeParameters();
+ foreach (var attribute in GeocodeParameters.ResultAttributeNames)
+ {
+ tempParams.ResultAttributeNames.Add(attribute);
+ }
+
+ var results = await Locator.GeocodeAsync(suggestion.UnderlyingObject as SuggestResult, tempParams, cancellationToken);
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ return await ResultToSearchResult(results);
+ }
+
+ ///
+ /// Creates a basic search suggestion for the given suggest result.
+ ///
+ private SearchSuggestion SuggestResultToSearchSuggestion(SuggestResult r)
+ {
+ return new SearchSuggestion(r.Label, this) { IsCollection = r.IsCollection, UnderlyingObject = r };
+ }
+
+ ///
+ public override async Task> SuggestAsync(string queryString, CancellationToken cancellationToken = default)
+ {
+ await _additionalLoadTask;
+ cancellationToken.ThrowIfCancellationRequested();
+
+ SuggestParameters.PreferredSearchLocation = PreferredSearchLocation;
+ SuggestParameters.MaxResults = MaximumSuggestions;
+ SuggestParameters.SearchArea = SearchArea;
+
+ var results = await Locator.SuggestAsync(queryString, SuggestParameters, cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (RepeatSuggestResultThreshold > 0 && results.Count < RepeatSuggestResultThreshold)
+ {
+ SuggestParameters.SearchArea = null;
+ results = await Locator.SuggestAsync(queryString, SuggestParameters, cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+
+ if (RepeatSuggestResultThreshold > 0 && results.Count < RepeatSuggestResultThreshold)
+ {
+ SuggestParameters.PreferredSearchLocation = null;
+ results = await Locator.SuggestAsync(queryString, SuggestParameters, cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+
+ return SuggestionToSearchSuggestion(results);
+ }
+
+ ///
+ public override async Task> SearchAsync(string queryString, CancellationToken cancellationToken = default)
+ {
+ await _additionalLoadTask;
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Reset spatial parameters
+ GeocodeParameters.PreferredSearchLocation = PreferredSearchLocation;
+ GeocodeParameters.SearchArea = SearchArea;
+ GeocodeParameters.MaxResults = MaximumResults;
+
+ var results = await Locator.GeocodeAsync(queryString, GeocodeParameters, cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (RepeatSearchResultThreshold > 0 && results.Count < RepeatSearchResultThreshold)
+ {
+ GeocodeParameters.SearchArea = null;
+ results = await Locator.GeocodeAsync(queryString, GeocodeParameters, cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+
+ if (RepeatSearchResultThreshold > 0 && results.Count < RepeatSearchResultThreshold)
+ {
+ GeocodeParameters.PreferredSearchLocation = null;
+ results = await Locator.GeocodeAsync(queryString, GeocodeParameters, cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+
+ return await ResultToSearchResult(results);
+ }
+
+ ///
+ ///
+ ///
+ public override async Task> RepeatSearchAsync(string queryString, Geometry.Envelope queryArea, CancellationToken cancellationToken = default)
+ {
+ await _additionalLoadTask;
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Reset spatial parameters
+ GeocodeParameters.SearchArea = queryArea;
+ GeocodeParameters.MaxResults = MaximumResults;
+
+ var results = await Locator.GeocodeAsync(queryString, GeocodeParameters, cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ return await ResultToSearchResult(results);
+ }
+
+ private async Task GeocodeResultToSearchResult(GeocodeResult r)
+ {
+ var symbol = await SymbolForResult(r);
+ string? subtitle = null;
+ if (SubtitleAttributeKey != null && r.Attributes.ContainsKey(SubtitleAttributeKey) && r.Attributes[SubtitleAttributeKey] is string subtitleString)
+ {
+ subtitle = subtitleString;
+ }
+
+ var viewpoint = r.Extent == null ? null : new Mapping.Viewpoint(r.Extent);
+
+ var graphic = new Graphic(r.DisplayLocation, r.Attributes, symbol);
+
+ CalloutDefinition callout = new CalloutDefinition(graphic) { Text = r.Label, DetailText = subtitle };
+
+ return new SearchResult(r.Label, subtitle, this, graphic, viewpoint) { CalloutDefinition = callout };
+ }
+
+ private async Task SymbolForResult(GeocodeResult r)
+ {
+ if (ResultSymbolStyle != null && r.Attributes.ContainsKey(LocatorIconAttributeKey) && r.Attributes[LocatorIconAttributeKey] is string typeAttrs)
+ {
+ if (Locator.Uri?.ToString() == WorldGeocoderUriString && ResultSymbolStyle.StyleName == "Esri2DPointSymbolsStyle")
+ {
+ var firstResult = await ResultSymbolStyle.GetSymbolAsync(new[] { typeAttrs.ToString().Replace(' ', '-').ToLower() });
+ if (firstResult != null)
+ {
+ return firstResult;
+ }
+ }
+
+ var symbParams = new SymbolStyleSearchParameters();
+ symbParams.Names.Add(typeAttrs.ToString());
+ symbParams.NamesStrictlyMatch = false;
+ var symbolResult = await ResultSymbolStyle.SearchSymbolsAsync(symbParams);
+
+ if (symbolResult.Any())
+ {
+ return await symbolResult.First().GetSymbolAsync();
+ }
+ }
+
+ return DefaultSymbol;
+ }
+
+ ///
+ /// Converts geocode result list into list of results, applying result limits and calling necessary callbacks.
+ ///
+ private async Task> ResultToSearchResult(IReadOnlyList input)
+ {
+ IEnumerable results = await Task.WhenAll(input.Select(i => GeocodeResultToSearchResult(i)));
+
+ return results.Take(MaximumResults).ToList();
+ }
+ }
+}
diff --git a/src/Toolkit/Toolkit/VisualStudioToolsManifest.xml b/src/Toolkit/Toolkit/VisualStudioToolsManifest.xml
index d56e30859..809f32f71 100644
--- a/src/Toolkit/Toolkit/VisualStudioToolsManifest.xml
+++ b/src/Toolkit/Toolkit/VisualStudioToolsManifest.xml
@@ -7,6 +7,7 @@
+