Map Floorpan
Overview
Use SAP Fiori Map Floorplan to extend Apple’s MapKit
and Esri’s ArcGIS
frameworks. The floorplan supports native map APIs and adds additional components to enhance the map experience. Maps are built upon layers of geometries (points, polylines, and polygons). Floorplans provide foundation for functionality including, showing search results, geometry details, and geometry creation. Floorplans currently only support iPad devices.
Components
Display Geometries
1. Layers
The map view may consist of multiple display layers. Each layer is represented by an FUIGeometryLayer
object, which has the displayName
property. By assigning geometries to distinct layers, geometries can be organized by their business context and then consumed by the map display. The state or behavior of all geometries can then be easily controlled layer by layer. For example, developer can show or hide all geometries on a specified layer using setLayerHidden(_:hidden:)
method.
- Initialization of
FUIGeometryLayer
s on map view
An FUIGeometryLayer
object can be initialized by setting its displayName
. In the example App, there are three geometry layers defined: special zones, NYC ferry, and NYC MTA.
enum Layer {
static let zones = FUIGeometryLayer(displayName: "Special Zones")
static let stops = FUIGeometryLayer(displayName: "NYC Ferry")
static let mtaStops = FUIGeometryLayer(displayName: "NYC MTA")
}
2. Display Objects
In the example App, we added annotations and overlays to the FUIMKMapView
. By default FUIMKMapView
has its own data source implementation and all added annotations and overlays will be automatically managed when calling reloadData()
.
- Add annotation objects to the internally managed map data source
Developer can add annotations to the data source of FUIMKMapView
directly by calling reloadData()
. In such way, the added annotations will also be automatically appended to the corresponding geometry layer. Below code snippet demonstrates adding an array of stop annotations to the data source.
var stopAnnotations: [MKPointAnnotation] = [] {
didSet {
reloadData()
}
}
- Add overlay objects to the internally managed map data source
Likewise, developer can add overlays to the data source of FUIMKMapView
directly by calling reloadData()
. In such way, the added overlays will also be automatically appended to the corresponding geometry layer. Below code snippet demonstrates adding an array of zone overlays to the data source.
var zonesOverlays: [MKPolygon] = [] {
didSet {
reloadData()
}
}
- Working with native
MKMapView
instance methods
Developer may still use native MKMapView
methods to manage annotations and overlays on the map view. Native methods like addAnnotation(_:)
, addOverlay(_:)
works the same on FUIMKMapView
. However, for annotation and overlay objects added without geometry layer information, they cannot be configured using methods specialized for wrapper display geometries.
Additionally, developer should manage the display of their own annotations and overlays objects. This can be achieved by implementing your own mapView:viewForAnnotation:
and renderer(for:)
delegate methods. Below code snippet demonstrates the implementation of custom annotation view for MTA stops.
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
...
if mtaStopAnnotations.contains(pointAnnotation) {
let view = mapView.dequeueReusableAnnotationView(withIdentifier: "FUIMarkerAnnotationView", for: annotation) as! FUIMarkerAnnotationView
view.markerTintColor = UIColor.preferredFioriColor(forStyle: .map6)
view.glyphImage = FUIIconLibrary.map.marker.bus
view.clusteringIdentifier = nil
return view
} else if stopAnnotations.contains(pointAnnotation) {
return nil
} else if editAnnotations.contains(where: { return $0 as? MKPointAnnotation != nil }) {
let view = mapView.dequeueReusableAnnotationView(withIdentifier: "FUIMarkerAnnotationView", for: annotation) as! FUIMarkerAnnotationView
view.markerTintColor = UIColor.preferredFioriColor(forStyle: .map5)
view.glyphImage = FUIIconLibrary.map.marker.venue
view.clusteringIdentifier = nil
return view
}
return nil
}
3. Clustering
A clustering annotation groups two or more distinct annotations into a single entity. FUIMKMapView
utilize the same mechanism from MapKit
to generate clustered annotations. Depends on the global setting of property isClusteringEnabled
on the floorplan view controller as well as the specific setting of property clusteringIdentifier
for each annotation view, the map view automatically creates cluster annotations when two or more annotation views become grouped too closely together on the map surface.
In the example App, only stop annotations are set to enable clustering. For MTA stop annotations, clustering is disable. The screenshot below illustrates their different behavior.
By default, clustering is enable for your map view controller once it is inherited from FUIMapFloorplanViewController
. Therefore, in default state all annotations added to the map view will be clustered automatically depending on the zoom level. Two ways of customizations on specific annotation view can be made on the map view:
- Determine if an annotation view should be clustered or not:
If clusteringIdentifier
is set to nil, the annotation view will not be clustered. You may also define distinct clusteringIdentifier
value for annotation views that should not be clustered. The settings of clusteringIdentifier
is commonly done by implementing the delegate method mapView(_:viewFor:)
.
public func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
...
guard let _ = annotation as? FUIAnnotation else {
let view = mapView.dequeueReusableAnnotationView(withIdentifier: "FUIMarkerAnnotationView", for: annotation) as! FUIMarkerAnnotationView
view.markerTintColor = UIColor.preferredFioriColor(forStyle: .map6)
view.glyphImage = FUIIconLibrary.map.marker.bus
view.clusteringIdentifier = nil
return view
}
return nil
}
- Customize the cluster annotation:
To customize the cluster annotation for the specified set of annotations, implement the mapView(_:clusterAnnotationForMemberAnnotations:)
method in your map’s delegate. This delegate will not be called if the clusteringIdentifier
for the annotation view is set to nil.
Map Interactions
1. Overlay Selection and Deselection
Native MapKit provides some handy property and methods to manage interaction with annotations: selectedAnnotations
to get an array of selected annotations, selectAnnotation(_:animated:)
to select the specified annotation and displays a callout view for it, and deselectAnnotation(_:animated:)
to
deselect the specified annotation and hides its callout view.
Likewise, FUIMKMapView
provides equivalent property and methods to manage interaction with overlays:
selectedOverlays
: get an array of selected overlays.
selectOverlay(_:animated:)
: select the specified overlay and change its display to selected state.
deselectOverlay(_:animated:)
: deselect the specified overlay and restore its display to default state.
- Map view delegate setting
Before using the overlay selection functionality, developer should make sure their own map view controller has been sub-classed from FUIMKMapFloorplanViewController
. Also developer should set map view’s delegate to their own map view delegate implementation:
let mapDelegate = MKMapViewDelegateImpl()
mapView.delegate = mapDelegate
- Selection/Deselection behavior of overlay on map view
At this point, once the custom map view’s delegate is correctly set, the interaction with overlays through tap gesture is supported right out of box. Please note that at one time only one overlay object on map can be selected. Also if an annotation call out is highlighted, all other selected overlay reset to default state.
Overlay selection and deselection can also be implemented using the detail panel as below.
- Link selection behavior of overlay to detail panel
Detail panel is implemented by a table view. So to define the selection behavior from the detail panel, developer has to implement the tableView(_didSelectRowAt:)
method for the detail panel.
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
...
if let zone = feature.zone {
self.pushContent(for: zone)
}
...
}
After that, the pushContent(for:)
method needs to be implemented to trigger the selection behavior for the overlay. Developer needs to get selected overlay through table view index value, and then fire selectOverlay(_:animated:)
to enable selection state on the overlay.
func pushContent(for zone: Feature<BetaNYC.Zone>) {
...
if let index = zonesFeatureCollection.features.firstIndex(where: { $0.properties.id == zone.properties.id }) {
self.mapView.selectOverlay(zonesOverlays[index], animated: true)
}
...
}
- Link deselection behavior of overlay to detail panel
Deselection behavior from detail panel may be implemented through the completion handler on the close button: detailPanel?.content.closeButton.didSelectHandler
. Developer may get a list of selected overlays from the selectedOverlays
property, and then fire deselectOverlay(_:animated:)
to unselect all.
self.detailPanel?.content.closeButton.didSelectHandler = { [unowned self] _ in
...
for selectedOverlay in self.mapView.selectedOverlays {
self.mapView.deselectOverlay(selectedOverlay, animated: true)
}
...
}
Map Components
Default Toolbar
The toolbar
contains buttons for common map functionalities. By default the toolbar has button for presenting settings, showing user location, presenting a map legend, and showing all annotations. The view is anchored to the right top right side of the map.
Settings
The settingsController
is a UIViewController
for the developer to set additional configurations for his application. The controller is presented modally as a formsheet over the map. Tapping on the close
button will dismiss the controller and return to the map. In the example project, the settings acts as a placeholder and does not serve a functional purpose.
- Settings Data Binding
Settings is set below in viewDidLoad()
:
// Components: Settings
self.settingsController = SettingsTableViewController.shared
User Location
This button zooms the map to center on the coordinates of the user’s location.
Privacy settings must be changed in the Info.plist
file to request permission to track location. Modify the file by adding the Privacy – Location When In Use Usage Description key. Check if permissions have been authorized in the viewDidAppear(_:)
method.
Legend
The legend
shows additional information about the MKAnnotationView
s, MKPolyline
s, and MKPolygon
s that appear on the map. The legend is presented as a table in a UIPopoverPresentationController
. The popover itself is anchored to the legend button within the toolbar
. In the example, legend information about the Ferry Stops & Special Zones is provided.
- Legend Items
The legend
contains a list of FUIMapLegendItem
s. Items contain icon
, line
, or fillItem
, to represent the MKAnnotationView
, MKPolyline
, or MKPolygon
. In the example app the legend items are defined according to their layer. The icon
and fillItem
variants are shown.
enum Layer {
...
static var zonesLegendItem: FUIMapLegendItem = {
var item = FUIMapLegendItem(title: Layer.zones.displayName)
item.fillItem = FUIMapLegendFillItem()
item.fillItem?.backgroundColor = UIColor.preferredFioriColor(forStyle: .map1)
item.fillItem?.borderColor = UIColor.preferredFioriColor(forStyle: .map1)
return item
}()
static var stopsLegendItem: FUIMapLegendItem = {
var item = FUIMapLegendItem(title: Layer.stops.displayName)
let image = FUIAttributedImage(image: FUIIconLibrary.map.marker.bus.withRenderingMode(.alwaysTemplate))
image.tintColor = .white
item.icon = FUIMapLegendIcon(glyphImage: image) //FUIMapLegendIcon(glyphImage: image)
item.backgroundColor = UIColor.preferredFioriColor(forStyle: .map6)
return item
}()
}
- Legend Title
A title to the legend by accessing the headerTextView
and settings it’s text. Titles are truncated after reaching the legend maximum width.
- Legend passthrough views
Passthrough views allow for the legend to remain open while tapping separate views. By default both the detailPanel
and toolbar
are passthrough views. Add additional passthrough views by appending to the list,
- Legend Data Binding
Legend configurations are set in viewDidLoad()
:
// Components: Legend
self.legend.headerTextView.text = "New York Legend"
self.legend.items = [Layer.zonesLegendItem, Layer.stopsLegendItem]
self.legend.passThroughViews.append(mapView)
Zoom Extents
This button zooms the map to the region containing all annotations.
Note: Zoom Extents does not include the user’s location
Detail Panel
The detailPanel
is a view that shows searchResults of map features and additional details about map components. The panel resizes to fit its content based on the controllers preferredContentSize
and is anchored to the top left of the map.
Search Results
The searchResults
control allows the user to filter map information when typing in the searchbar
.
- Enable Search
Search Results is enabled by setting the isSearchEnabled
Boolean value.
- SearchResults tableView dataSource
Setting the dataSource
to populates the searchResults tableView. The tableView can also be configured by registering cells, adding estimated row heights, and enabling automatic dimensions.
- SearchResults tableView delegate
The developer can set the delegate
to respond to events. In the example project, row selection will push the content controller. (See Content Section below)
- SearchResults searchbar delegate
The developer can set the delegate
to respond to events. In the example project, row selection will push the content controller. (See Content Section below)
- SearchResults Data Binding
In the example project, the ViewController
implements the UITableViewDataSource
, UITableViewDelegate
, and UISearchBarDelegate
.
class ViewController: FUIMKMapFloorplanViewController, UITableViewDataSource, UITableViewDelegate, UISearchBarDelegate, EditingGeometryProviding {
...
// MARK: UITableViewDataSource
func numberOfSections(in tableView: UITableView) -> Int { ... }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { ... }
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { ... }
// MARK: UITableViewDelegate
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { ... }
// MARK: UISearchBarDelegate
func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { ... }
func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { ... }
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { ... }
}
SearchResults configurations are set in the viewDidLoad()
.
// Components: Detail Panel - SearchResults
self.detailPanel.isSearchEnabled = true
self.detailPanel.searchResults.tableView.dataSource = self
self.detailPanel.searchResults.tableView.delegate = self
self.detailPanel.searchResults.tableView.register(FUIObjectTableViewCell.self, forCellReuseIdentifier: FUIObjectTableViewCell.reuseIdentifier)
self.detailPanel.searchResults.tableView.estimatedRowHeight = 60
self.detailPanel.searchResults.tableView.rowHeight = UITableView.automaticDimension
self.detailPanel.searchResults.searchBar.delegate = self
Content
The content
control presents additional information about a map component (MKAnnotationView
, MKPolyline
, MKPolygon
, etc.). This controller is presented and dismissed by using the pushChildViewController()
and popChildViewController()
API respectively.
- Content Headline and Subheadline
The headlineText
provides the title of the content
and the subheadlineText
provides additional title information. The headline is unscrollable and will always remain visible while the subheadline will be hidden while scrolling. A didSelectTitleHandler
is that is called when the headlineText
is tapped.
- Content DataSource and Delegate
Configure the tableView and set the dataSource
and delegate
to the tableView similar to the searchResults
.
- Content Present
Calling pushChildViewController()
will transition the content
into view. It is up to the developer to determine when to present the content. In the example project, the controller is presented when a tableView row is selected. Update the controller properties and reloadData immediately prior to calling pushChildViewController()
.
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
...
self.pushContent(for: stop)
}
func pushContent(for stop: Feature<TransitLand.Stop>) {
...
detailPanel.content.headlineText = stop.properties.name
detailPanel.content.subheadlineText = stop.properties.operators.first?.values.first
detailPanel.content.didSelectTitleHandler = {
print(self.detailPanel.content.headlineText)
}
detailPanel.content.tableView.reloadData()
detailPanel.pushChildViewController()
}
Only one `content` can be displayed at a time. To update a contentViewController, reload the table then resize the panel by calling the `fitToContent()` method.
- Content Dismiss
Calling popChildViewController()
dismisses the content
and returns to searchResults
.
This controller has a closeButton
intended to dismiss itself. The developer can make additional changes prior to calling popChildViewController()
.
- Content Data Binding
As stated in searchResults
, example project’s ViewController
already implements UITableViewDataSource
and UITableViewDelegate
Initial content configurations are set in the viewDidLoad()
.
// Components: Detail Panel - Content
self.detailPanel.content.tableView.dataSource = self
self.detailPanel.content.tableView.delegate = self
self.detailPanel.content.tableView.register(FUIObjectTableViewCell.self, forCellReuseIdentifier: FUIObjectTableViewCell.reuseIdentifier)
self.detailPanel.content.tableView.register(FUIMapDetailTagObjectTableViewCell.self, forCellReuseIdentifier: FUIMapDetailTagObjectTableViewCell.reuseIdentifier)
self.detailPanel.content.tableView.estimatedRowHeight = 44
self.detailPanel.content.tableView.rowHeight = UITableView.automaticDimension
self.detailPanel.content.closeButton.didSelectHandler = { [unowned self] _ in
...
self.detailPanel.popChildViewController()
}
Editing
The floorplan provides an editing interface to add geometries. Users can draw directly onto the map or input addresses to create their shape (point, polyline, or polygon)
- Set Editable Floorplan
To allow editing of the panel set the isEditable
property to true
.
- Set Create Geometry Items
The developer chooses what types of items can be created and placed on the map. In the example project, the user can create items that appear in the legend.
Tapping the +
right bar button presents a popover to select the create geometry item. Once selected, the view controller will transition to an editing state by setting all other annotations to disabled and uninteractable, showing editing toolbar buttons, and presenting the editing panel. In the example project, the item that can be created is an editLegendItem
.
- Set Creation Results Controller
This controller is available for the user to make additional changes to the workorder. In the example project, a placeholder controller is provided but does not have additional functionality.
- Save the Geometry
The developer is responsible for updating the model with the returned geometry. Use the didSaveResults
closure to update the model. The first argument is the editing shape and the second argument is the type of workorder created. This closure is called after tapping the save bar button item on the createGeometryResultsController
.
The Floorplan sets geometry creation and editing in viewDidLoad()
.
// Components: Editing Panel
self.isEditable = true
self.editingPanel.createGeometryItems = [Layer.editLegendItem]
self.editingPanel.createGeometryResultsController = CreateGeometryResultsController(provider: self)
self.editingPanel.didSaveResults = { [unowned self] shape, createObject in
if let point = shape as? MKPointAnnotation {
self.editAnnotations.append(point)
} else if let polyline = shape as? MKPolyline {
self.editAnnotations.append(polyline)
} else if let polygon = shape as? MKPolygon {
self.editAnnotations.append(polygon)
}
}
Editing Toolbar
The editing toolbar shows buttons to assist in geometry creation.
User Location Button
When tapped, the map will zoom to the user’s location. This button has the same functionality as the Defaut Toolbar User Location Button.
Zoom Extents Button
When tapped, the map will zoom to show all map annotations. This button has the same functionality as the Defaut Toolbar Zoom Extents Button.
Add Button
When selected, tapping on the map will add points to the map.
Delete Button
When selected, tapping on points of the current editing shape will delete the points.
Undo Button
Add and delete actions can be undone by tapping on the undo button.
Redo Button
Undo actions can be redone by tapping on the redo button.
Branch Button
To create a branch off of a polyline or polygon, select the branch button.
Note: The branch button is disabled when creating a point and when the delete button is selected.
Editing Panel
The Editing Panel shows editing configurations while creating a geometry. A user can select the type of geometry to create (point, polyline, or polygon) as well as add addresses directly to the shape.
Geometry Segmented control
A user can switch between the geometries (point, polyline, or polygon) by tapping on the segment.
Note: Switching from polyline or polygon to point geometries will clear the stored points. The user will be prompted with an alert if they choose to continue.
Clear All Button
A user can clear all points by tapping the clear all button. The user will be prompted with an alert if they wish to continue.
Add New Point Field
This field allows the user to add addresses directly to the editing shape. Tapping the field will launch the keyboard.
Save Button
The save button will prepare to commit the changes and launch a create results controller. (See Save Geometries)
Drawing Interaction
Tap on the +
button to open the create geometry popover. The example project allows the user to create Edit Annotations
. Tap the cell to start.
Select the polyline image to create a polyline.
When the add button is selected points are added when the map is tapped
To add a point on a segment, tap directly on the line.
To move the point, long press and drag on the point to a new location.
To delete a point first select the delete button. The add button will become deselected.
Tap on a point to delete.
To undo the delete, tap the undo button.
To redo the delete, tap the redo button.
To add a branch select the add button,
then select the branch button.
Select a point on the segment then tap away.
Edit Panel Interaction
A user can directly add an address by tapping the Add New Point field. This launches the keyboard and shows suggestions.
Typing into the field provides suggestions for addresses.
Selecting the cell adds the address to the model.
Cells can be rearranged by dragging the cell with the hamburger icon.
To delete a cell from the panel, tap the red circle to show the delete button.
Accept the prompt to delete the point.
Switching between geometries is possible by changing the segmented control. First a prompt will appear.
Accept the prompt to change to polygon.
Changing from a polyline or polygon to a point will delete all the points. This action cannot be undone!
Points can be wiped by tapping the clear all button. This action cannot be undone!
Cancel the alert and return to the editing line. Tap the save button in the panel to launch the create results page.
The user can tap the cancel button to return back to editing. Tap the save bar button item to return back to the map and the editing geometry will be saved.