Modify Views and Use the Reader/Proxy Pattern With ArcGIS Maps SDK for Swift
Welcome to the final post in this series! In previous posts, we have covered the basic mapping app structure, state and data flow, and using concurrency in your app. Finally, we will revisit the SwiftUI view to discuss the view modifiers as well as the view reader/proxy pattern. In addition, we will also lightly touch on a few other topics to give you a head start in your journey with the new ArcGIS Maps SDK for Swift.
Topics In This Post
Apply view modifiers to customize the characteristics of a view
Explore the commonly used view modifiers in SwiftUI and Swift Maps SDK
Access map view operations using theMapViewReaderandMapViewProxypattern
Beyond this blog series
View Modifiers
With UIKit, we used to control a view's appearance, behavior, gesture recognition, and operations, by using a variety of properties and methods. With SwiftUI, we now control this with two different categories of methods. A new genre of declarative methods called view modifiershandle the configuration of a view, and proxy view methods handle the operations performed on the content of a view. We will explore this reader/proxy coding pattern in a later section.
A view modifier takes a view as input, and returns a modified view as output. You can even chain together multiple modifiers to achieve complex effects. There are many types of view modifiers in SwiftUI that can be roughly grouped by their purpose into the following categories:
Customize the view's appearance, such as color, style, effect, and shape.
Change layout by adding an overlay to a view, or padding between views, for example.
Handle events such as, detecting gestures on a view, or responding to the text field submission.
Configure the environment by using the environment object modifier to pass a data model to subviews.
Miscellaneous modifiers that control the navigation behavior, showing an alert and other supporting views, setting animation on a view, setting disabled or hidden states, and for accessibility, and many more.
In the following sections, we will examine some of the SwiftUI and ArcGIS view modifiers using a code example.
View Modifiers in SwiftUI
Take another look at the view's body we've written so far.
Xcode library showing different types of view modifiersIn the code above, a number of SwiftUI view modifiers are used to customize the map view. The .toolbar modifier adds a toolbar control to the bottom on the view, the .task modifier performs some asynchronous operations as the view is created, and the .onDisappear stops the location data source as the view disappears from the interface. In addition, the overlay modifier layers a Text subview in front of the map view.
Here are a few examples of other SwiftUI view modifiers. In terms of the layout, you can also change the frame of a view, aspect ratio, z-index, views alignment, and padding between views. In terms of the map view, the .alert, presentation .sheet, and .popover view modifiers allow you to present UI choices and interactions in your app. They are very versatile when customizing a view and work seamlessly with the Swift Maps SDK view modifiers.
Note: To browse the other view modifiers in SwiftUI, check out the Xcode library by clicking the Library button (+) in Xcode's toolbar on the right.
View Modifiers in Swift Maps SDK
The Swift Maps SDK also provides view modifiers for various mapping workflows. In the code example above, the.locationDisplay modifier is used to pass a location display instance to the map view. It replaces the mapView.locationDisplay property found in the iOS Runtime SDK. Typically, if a map view property configures the look of the view, or provides information about the view, it will now be exposed as a view modifier that takes the value of the property. For example, you can set the background grid on a map view, the atmosphere effect on a scene view, or a geometry editor instance on a map view using their respective modifiers.
The .onViewpointChanged modifier sets a closure to perform an action when the viewpoint changes. This pattern represents the modifiers that respond to the changes on the map view. Whenever the event generates a new value, it calls the provided closure with the value. The names of these modifiers often starts with "on" and ends with "Changed", to indicate the action will be performed whenever the value-of-interest changes. Similar examples are .onRotationChanged, .onVisibleAreaChanged, .onDrawStatusChanged, and various gesture modifiers such as .onSingleTapGesture, which will be further demonstrated below.
Note: The callout modifier is a special case that doesn't belong to the 2 patterns above. It resembles a control modifier in SwiftUI, which you can specify its placement and add subviews to it.
For a comprehensive list of the Swift Maps SDK instance methods that you can set as view modifiers, check out the individual API Reference pages for GeoView, MapView, and SceneView.
Reader/Proxy Pattern
As SwiftUI views, MapView and SceneView do not expose any properties, and all their instance methods are view modifiers. View modifiers allow you to customize a view's appearance or behavior. There are some methods such as identify, set view point with animation, export image and set bookmarks, however, that do not set the view's appearance and behavior.
These types of methods perform a specific function on the view itself and are handled using a proxy, MapViewProxy, and a reader view, MapViewReader. They are used to expose the properties and methods that aren't suitable for an initializer parameter or a modifier.
SwiftUI uses the same reader patterns in the ScrollViewReader and the GeometryReader. These 2 container views provide a content proxy to access the content view's size, coordinate space, read-only properties, and methods that operate on the map or scene view itself.
The most prominent examples of the reader/proxy pattern are the identify and setViewpoint operations. The identify operation allows a user to discover features at a geographic location while the setViewpoint operation allows the map view to change to a viewpoint in an animated fashion.
Let's add these 2 abilities to the code example. First, add 2 new state properties to the ContentView, to hold the tapping points from the gesture.
+ // 1. Wrap the map view in a `MapViewReader`.
+ MapViewReader { mapViewProxy in
MapView(map: model.map, viewpoint: viewpoint)
.onViewpointChanged(kind: .centerAndScale) { viewpoint = $0 }
.locationDisplay(model.locationDisplay)
+ // 2. Add a single tap gesture modifier.
+ .onSingleTapGesture { screenPoint, mapPoint in
+ identifyPoint = screenPoint
+ tapLocation = mapPoint
+ }
+ // 3. Add a task modifier to run the async operations.
+ .task(id: identifyPoint) {
+ guard let identifyPoint else { return }
+ model.featureLayer.clearSelection()
+ let results = try? await mapViewProxy.identify(
+ on: model.featureLayer,
+ screenPoint: identifyPoint,
+ tolerance: 12,
+ maximumResults: 1
+ )
+ if let feature = results?.geoElements.first as? Feature,
+ let geometry = feature.geometry {
+ model.featureLayer.selectFeature(feature)
+ await mapViewProxy.setViewpointGeometry(geometry)
+ }
+ }
// ... Code below collapsed
+ }
Feature layer identify result highlightedAt the top of the changes, we wrap the map view in the map view reader at 1. This allows us to get access to the map view through its proxy, to call the MapViewProxy.identify(on:screenPoint:tolerance:returnPopupsOnly:maximumResults:) and MapViewProxy.setViewpointGeometry(_:padding:) methods.
Next, add the Swift Maps SDK .onSingleTapGesture modifier at 2, to receive the tap point and location when the user taps on the screen. These values are stored in the state properties, called identifyPoint and tapLocation, that we created above.
Finally, let's add a .task modifier at 3 that will run when the value of identifyPoint changes. This task block runs the asynchronous operations of identify and set the viewpoint with animation. The identify method on the map view proxy, uses the identifyPoint, and waits for the identify result. If the result is not empty, we then call the setViewpointGeometry method on the proxy to pan the map view to the selected feature with animation.
Build and run the app. Tap on any city feature in the map view. The feature will be selected, and the map view will pan to the feature.
This reader/proxy coding pattern is also adopted by the SceneView. Refer to the MapViewProxy and the SceneViewProxy API Reference documentation for a list of their methods. Once you get familiar with this pattern, you will find it quite intuitive to use, just like calling the methods on the map view itself in the iOS Runtime SDK.
Next Steps
Woo-Hoo! 🥳 We are at the finish line of the series. View modifiers are an essential part of the whole SwiftUI ecosystem. As you get more familiar with the new SDK, you will get used to the great ease of modifying views using many modifiers together. At Esri, we strive to give you a developer experience that is on par with the first-party native frameworks.
Due to limited space, there are a few things that we didn't discuss in detail in the series. To learn more about the new SDK, there are many resources for you to explore.
Finally, below you will find the full code we've completed through this blog series. It marks the first milestone of your journey with ArcGIS Maps SDK for Swift.With this solid foundation to build upon, we hope to see your powerful and modern mapping apps on App Store soon!:mobile_phone:
Last but not least, I would like to express my gratitude to…
@MaryHarvey , my co-writer, whose dedication brought this series to life. Her insightful editing and unwavering commitment to the blog series have been invaluable. 🙇
@MarkDostal , for his thorough technical reviews which enhanced the quality of our code examples. 👨🔬
@Rachael_Ellen , the developer outreach representative, for her enthusiastic efforts in promoting and advocating the series to our target audience. 📣
@SuganyaBaskaran1 , for her coordinating the collaboration progress. ️✍️
@DiveshGoyal , for his vision in shaping the direction of the series. 🚀
Together, their efforts have made this series see the light 💡, and I am truly grateful for their contributions. 🎉
';
}
}
}
catch(e){
}
}
}
if (newSub.getAttribute("slang").toLowerCase() != code_l.toLowerCase()) {
if (trLabelsHtml != "") {
var labelSname = "";
if(labelEle[i].querySelector("ul li:nth-child(1)").getAttribute("aria-hidden")){
labelSname = labelEle[i].querySelector("ul li:nth-child(1)").outerHTML;
}
labelEle[i].innerHTML = "";
labelEle[i].innerHTML = labelSname + trLabelsHtml;
}
}
}
}
}
catch(e){
}
}
}
/* V 2.0:3 = Store not translated reply id */
if(lingoRSXML.snapshotLength == 0){
if($scope.falseReplyID == "") {
$scope.falseReplyID = value;
}
}
/* Get translated Body of Replies/Comments */
var lingoRBXML = doc.evaluate(lingoRBExp, doc, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
for(var i=0;i 0) {
var attachDiv = rootElement.querySelector('div.lia-quilt-row-main').querySelector('div.custom-attachments');
if (attachDiv) {
attachDiv = attachDiv.outerHTML;
}
else if(rootElement.querySelector('div.lia-quilt-row-main').querySelectorAll('#attachments').length > 0){
if ("BlogArticlePage" == "BlogArticlePage") {
attachDiv = rootElement.querySelector('div.lia-quilt-row-main .lia-message-body-content').querySelector('#attachments');
if (attachDiv) {
attachDiv = attachDiv.outerHTML;
}
else{
attachDiv = "";
}
}else{
attachDiv = rootElement.querySelector('div.lia-quilt-row-main').querySelector('#attachments').outerHTML;
}
}
else {
attachDiv = "";
}
/* Feedback Div */
var feedbackDiv = "";
var feedbackDivs = rootElement.querySelector('div.lia-quilt-row-main').querySelectorAll('div.lia-panel-feedback-banner-safe');
if (feedbackDivs.length > 0) {
for (var k = 0; k < feedbackDivs.length; k++) {
feedbackDiv = feedbackDiv + feedbackDivs[k].outerHTML;
}
}
}
else {
var attachDiv = rootElement.querySelector('div.lia-message-body-content').querySelector('div.Attachments.preview-attachments');
if (attachDiv) {
attachDiv = attachDiv.outerHTML;
} else {
attachDiv = "";
}
/* Everyone tags links */
if (document.querySelectorAll("div.TagList").length > 0){
var everyoneTagslink = document.querySelector('div.lia-quilt-row-main').querySelector(".MessageTagsTaplet .TagList");
if ((everyoneTagslink != null)||(everyoneTagslink != undefined)){
everyoneTagslink = everyoneTagslink.outerHTML;
}
else{
everyoneTagslink = "";
}
}
/* Feedback Div */
var feedbackDiv = "";
var feedbackDivs = rootElement.querySelector('div.lia-message-body-content').querySelectorAll('div.lia-panel-feedback-banner-safe');
if (feedbackDivs.length > 0) {
for (var m = 0; m < feedbackDivs.length; m++) {
feedbackDiv = feedbackDiv + feedbackDivs[m].outerHTML;
}
}
}
}
} catch (e) {
}
if (body_L == "") {
/* V 2.0:7 Replacing translated video data with source video data */
var newBodyVideoData = newBody.querySelectorAll('div[class*="video-embed"]');
angular.forEach($scope.videoData[value], function (sourceVideoElement, index) {
if (index <= (newBodyVideoData.length - 1)) {
newBodyVideoData[index].outerHTML = sourceVideoElement.outerHTML
}
});
/* V 2.0:7 = Replacing translated image data with source data */
var newBodyImageData = newBody.querySelectorAll('[class*="lia-image"]');
angular.forEach($scope.imageData[value], function (sourceImgElement, index) {
if (index <= (newBodyImageData.length - 1)) {
newBodyImageData[index].outerHTML = sourceImgElement.outerHTML;
}
});
/* V 2.0:7 = Replacing translated pre tag data with source data */
var newBodyPreTagData = newBody.querySelectorAll('pre');
angular.forEach($scope.preTagData[value], function (sourcePreTagElement, index) {
if (index <= (newBodyPreTagData.length - 1)) {
newBodyPreTagData[index].outerHTML = sourcePreTagElement.outerHTML;
}
});
}
var copyBodySubject = false;
if (body_L == "") {
copyBodySubject = true;
body_L = newBody.innerHTML;
}
/* This code is written as part of video fix by iTalent */
/* try{
var iframeHTMLText = body_L;
var searchIframeText = "<IFRAME";
var foundiFrameTag;
if (iframeHTMLText.indexOf(searchIframeText) > -1) {
foundiFrameTag = decodeHTMLEntities(iframeHTMLText);
foundiFrameTag = foundiFrameTag.split('src="')[1];
body_L = foundiFrameTag;
}
}
catch(e){
} */
/* This code is placed to remove the extra meta tag adding in the UI*/
try{
body_L = body_L.replace('<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />','');
}
catch(e){
}
/** We should not replace the source content if user profile language and selected target language matches with source language **/
if(showTrContent) {
var compiled = false;
rootElement.querySelectorAll('div.lia-message-body-content')[0].innerHTML = null
if("BlogArticlePage"=="IdeaPage"){
// var customAttachDiv = '';
rootElement.querySelectorAll('div.lia-message-body-content')[0].innerHTML = body_L + feedbackDiv ;
$compile(rootElement.querySelectorAll('div.lia-message-body-content')[0])($scope);
compiled = true;
/* Attach atttach div */
// document.querySelector("div.translation-attachments-"+value).innerHTML = attachDiv;
rootElement.querySelectorAll('div.lia-message-body-content')[0].insertAdjacentHTML('afterend',attachDiv);
if(rootElement.querySelectorAll('div.lia-quilt-idea-message .lia-message-body .lia-attachments-message').length > 1){
rootElement.querySelectorAll('div.lia-quilt-idea-message .lia-message-body .lia-attachments-message')[1].remove();
}
} else {
if("BlogArticlePage"=="TkbArticlePage"){
rootElement.querySelectorAll('div.lia-message-body-content')[0].innerHTML = body_L + feedbackDiv ;
}else{
rootElement.querySelectorAll('div.lia-message-body-content')[0].innerHTML = body_L + feedbackDiv + attachDiv;
compiled = true;
}
}
/* Destroy and recreate OOyala player videos to restore the videos in target languages which is written by iTalent as part of iTrack LILICON-79 */ /* Destroy and recreate OOyala player videos */
try{
// $scope.videoData[value][0].querySelector("div").getAttribute("id");
for(var vidIndex=0; vidIndex<$scope.videoData[value].length; vidIndex++){
if( $scope.videoData[value][vidIndex].querySelector("div") != null){
var containerId = LITHIUM.OOYALA.players[$scope.videoData[value][vidIndex].querySelector("div").getAttribute("id")].containerId;
videoId = LITHIUM.OOYALA.players[$scope.videoData[value][vidIndex].querySelector("div").getAttribute("id")].videoId;
/** Get the Video object */
vid = OO.Player.create(containerId,videoId);
/** Destroy the video **/
vid.destroy();
/** recreate in the same position */
var vid = OO.Player.create(containerId,videoId);
}
}
}
catch(e){
}
try{
for(var vidIndex=0; vidIndex<($scope.videoData[value].length); vidIndex++){
if($scope.videoData[value][vidIndex].querySelector('video-js') != null){
var data_id = $scope.videoData[value][vidIndex].querySelector('video-js').getAttribute('data-video-id');
var data_account = $scope.videoData[value][vidIndex].querySelector('video-js').getAttribute('data-account');
var data_palyer = $scope.videoData[value][vidIndex].querySelector('video-js').getAttribute('data-player');
var div = document.createElement('div');
div.id = "brightcove";
div.class = "brightcove-player";
div.innerHTML =
'(view in my videos)'
var data = div.getElementsByClassName("video-js");
var script = document.createElement('script');
script.src = "https://players.brightcove.net/" + data_account + "/" + data_palyer + "_default/index.min.js";
for(var i=0;i< data.length;i++){
videodata.push(data[i]);
}
}
}
for(var i=0;i< videodata.length;i++){
document.getElementsByClassName('lia-vid-container')[i].innerHTML = videodata[i].outerHTML;
document.body.appendChild(script);
}
}
catch(e){
}
if(!compiled){
/* Re compile html */
$compile(rootElement.querySelectorAll('div.lia-message-body-content')[0])($scope);
}
}
if (code_l.toLowerCase() != newBody.getAttribute("slang").toLowerCase()) {
/* Adding Translation flag */
var tr_obj = $filter('filter')($scope.sourceLangList, function (obj_l) {
return obj_l.code.toLowerCase() === newBody.getAttribute("slang").toLowerCase()
});
if (tr_obj.length > 0) {
tr_text = "Esri may utilize third parties to translate your data and/or imagery to facilitate communication across different languages.".replace(/lilicon-trans-text/g, tr_obj[0].title);
try {
if ($scope.wootMessages[$rootScope.profLang] != undefined) {
tr_text = $scope.wootMessages[$rootScope.profLang].replace(/lilicon-trans-text/g, tr_obj[0].title);
}
} catch (e) {
}
} else {
//tr_text = "This message was translated for your convenience!";
tr_text = "Esri may utilize third parties to translate your data and/or imagery to facilitate communication across different languages.";
}
try {
if (!document.getElementById("tr-msz-" + value)) {
var tr_para = document.createElement("P");
tr_para.setAttribute("id", "tr-msz-" + value);
tr_para.setAttribute("class", "tr-msz");
tr_para.style.textAlign = 'justify';
var tr_fTag = document.createElement("IMG");
tr_fTag.setAttribute("class", "tFlag");
tr_fTag.setAttribute("src", "/html/assets/langTrFlag.PNG");
tr_fTag.style.marginRight = "5px";
tr_fTag.style.height = "14px";
tr_para.appendChild(tr_fTag);
var tr_textNode = document.createTextNode(tr_text);
tr_para.appendChild(tr_textNode);
/* Woot message only for multi source */
if(rootElement.querySelector(".lia-quilt-forum-message")){
rootElement.querySelector(".lia-quilt-forum-message").appendChild(tr_para);
} else if(rootElement.querySelector(".lia-message-view-blog-topic-message")) {
rootElement.querySelector(".lia-message-view-blog-topic-message").appendChild(tr_para);
} else if(rootElement.querySelector(".lia-quilt-blog-reply-message")){
rootElement.querySelector(".lia-quilt-blog-reply-message").appendChild(tr_para);
} else if(rootElement.querySelector(".lia-quilt-tkb-message")){
rootElement.querySelector(".lia-quilt-tkb-message").appendChild(tr_para);
} else if(rootElement.querySelector(".lia-quilt-tkb-reply-message")){
rootElement.querySelector(".lia-quilt-tkb-reply-message").insertBefore(tr_para,rootElement.querySelector(".lia-quilt-row.lia-quilt-row-footer"));
} else if(rootElement.querySelector(".lia-quilt-idea-message")){
rootElement.querySelector(".lia-quilt-idea-message").appendChild(tr_para);
} else if(rootElement.querySelector('.lia-quilt-occasion-message')){
rootElement.querySelector('.lia-quilt-occasion-message').appendChild(tr_para);
}
else {
if (rootElement.querySelectorAll('div.lia-quilt-row-footer').length > 0) {
rootElement.querySelectorAll('div.lia-quilt-row-footer')[0].appendChild(tr_para);
} else {
rootElement.querySelectorAll('div.lia-quilt-column-message-footer')[0].appendChild(tr_para);
}
}
}
} catch (e) {
}
}
} else {
/* Do not display button for same language */
// syncList.remove(value);
var index = $scope.syncList.indexOf(value);
if (index > -1) {
$scope.syncList.splice(index, 1);
}
}
}
}
});
});
/* V 1.1:2 = Reply Sync button for multi source translation */
} catch(e){
console.log(e);
}
};
if((rContent != undefined) && (rContent != "")) {
drawCanvas(decodeURIComponent(rContent));
/** Update variable with selected language code **/
$scope.previousSelCode = code_l;
}
};
/**
* @function manageTranslation
* @description Managess the translation of given language for the thread
* @param {string} langCode - Language Code
* @param {string} tid - Thread ID
*/
$scope.manageTranslation = function (langCode, tid) {
//debugger;
$scope.showTrText = false;
/* V 2.0:5 = actualStatus variable introduced to indicate detailed connector status on UI. This variable holds the actual translation percentage */
$scope.transPercent = "";
$scope.actualStatus = "";
if (tid != "") {
var bulkTranslation = lithiumPlugin.bulkTranslation(langCode, tid);
bulkTranslation.then(function (trContent) {
if(trContent.body != "") {
$scope.showPreview(trContent.body, $scope.mszList, langCode);
if(langCode != "en-US") {
$scope.showTrText = true;
}
}
if((trContent.status != "NA") && trContent.status != null) {
// $scope.transPercent = String(trContent.status);
$scope.actualStatus = String(trContent.status);
} else {
// $rootScope.errorMsg = "Translation is in progress. Please check again a few minutes."
$rootScope.errorMsg = "Translation is in progress. Please retry in a few minutes."
}
$scope.workbench = trContent.wb;
/* V 2.0:4 = Trigger uncalled or delayed callbacks (documnet uploaded/translation completed from lithium).*/
if(trContent.callback == 'true') {
var trCompletCallback = lithiumPlugin.trCompletCallback(langCode, trContent.docID);
trCompletCallback.then(function (callback){
// $rootScope.errorMsg = "Downloading Translated content in " + langCode + " now. Please check again in a few minutes."
$rootScope.errorMsg = "Uploading content to translate. Please check again in a few minutes."
});
} else if (trContent.callback == 'upload') {
var trCompletUpload = lithiumPlugin.trCompletUpload(langCode, trContent.docID);
trCompletUpload.then(function (callback) {
//$rootScope.errorMsg = "Uploading content to translate. Please check again in a few minutes."
$rootScope.errorMsg = "Uploading content to translate. Please check again in a few minutes."
});
} else if ("many" == "one") {
$scope.updateOOS();
} else if("SmartConx" == "SmartConx"){
if ("many" == "many"){
$scope.updateOOS();
}
}else if ((trContent.status != null) && trContent.status.includes("100")) {
/* If everything fine then only check Out of Sync status */
$scope.updateOOS();
} else {
/* If translation perccent is less than 100 then show the percentage on UI */
$scope.transPercent = $scope.actualStatus;
}
});
}
}
/**
* @function selectThisLang
* @description Called on select dropdown.
* @param {string} lang - Language code
*
*/
$scope.selectThisLang = function (lang, anonymousFlag) {
/* 1.4:3 Update Analytics on language selection */
try {
lingoThreadLangSelected(lang, '1294852');
} catch (e) {
}
/** Display Translated content **/
var getTranslation = lithiumPlugin.getTranslation(lang, "1294852");
getTranslation.then(function (trContent) {
if (trContent.body != "") {
$scope.showPreview(trContent.body, $scope.mszList, lang);
} else {
//$rootScope.errorMsg = "Translation is in progress. Please check again in a few minutes."
$rootScope.errorMsg = "Translation is in progress. Please retry in a few minutes."
}
});
};
var decodeEntities = (function() {
// this prevents any overhead from creating the object each time
var element = document.createElement('div');
function decodeHTMLEntities (str) {
if(str && typeof str === 'string') {
// strip script/html tags
str = str.replace(/