Custom Map View in Swift – XSTutorials

Custom Map View in Swift

In this tutorial, we’ll build a small app for you to see how to customize and use the Map View with the MapKit framework in iOS.

The App Design

Let’s start by creating a Single View App in Xcode, name it CustomMap and save your project in your Desktop. Set its Deployment Target to 9.0 in the General tab, then select Main.storyboard from the left menu and open the Object library.

Search for MapView and drag it in the Controller of the Storyboard. Embed that ViewController into a NavigationController by clicking Editor -> Embed in -> NavigationController. Then add the following Views:

  • A Button to the top-right corner of the Controller – give it a title like “SET
  • A TextField over the MapView
  • Two Buttons on the bottom-right, make their size 44×44 and assign them a satellite and an arrow image – you can download the Xcode project at the end of this article and get images from the Assets.xcassets folder
  • A UIView on the very bottom of the Controller, which must include a Label and a Slider.

The ViewController should look like this:

Home screen

Now drag a new ViewController from the Object library to the Storyboard, create a new Cocoa Touch Class file and name it LocationDetails, select the Controller again and assign it the Class name and Storyboard ID form the Identity inspector – of course, it’s LocationDetails. Then do the following:

  • Drag a Label to the top of the Controller – for the screen’s title
  • Add a Button to the top-left corner and give it a title like “Back”
  • Drag a Label in the middle, set its height to 150 – we’ll use it to display the full address of the selected location

When you’re done, the Storyboard area should look like this:

Complete Storyboard design

Select the first ViewController, expand the Assistant editor and start declaring your Views by connecting the TextField to the ViewController.swift file and naming it as “locationTxt”. Then, connect the MapView and name it “aMap”, the bottom Label – name it “distanceLabel” – and the Slider – name it “distanceSlider”.

Declare the 3 IBActions for the Buttons – the “SET”, satellite and location ones – and the 2 Slider‘s one.

@IBOutlet weak var aMap: MKMapView!
@IBOutlet weak var distanceLabel: UILabel!
@IBOutlet weak var distanceSlider: UISlider!
@IBOutlet weak var locationTxt: UITextField!

@IBAction func distanceChanged(_ sender: UISlider) {}
@IBAction func sliderEndDrag(_ sender: UISlider) {}
@IBAction func satelliteButt(_ sender: Any) {}
@IBAction func currentLocationButt(_ sender: Any) {}
@IBAction func setLocationButt(_ sender: Any) {}

Select the LocationDetails Controller and connect the Label to its relative Swift file, name it “addressLabel”. Declare the IBAction for the Back Button too.

@IBOutlet weak var addressLabel: UILabel!

@IBAction func backButt(_ sender: Any) {}

The Code

It’s time to code, so select the ViewController.swift file and click the Standard editor button in Xcode. The very first thing we have to do is to import the necessary Frameworks to the top of the file:

import UIKit
import MapKit
import CoreLocation

We’ll have to also add a global variable that will set a default CLLocation value in case the app won’t detect your current location. So paste this line of code above the Class declaration, right below the imports:

let DEFAULT_LOCATION = CLLocation(latitude: 40.7143528, longitude: -74.0059731)

As you can see by the float numbers of the above variable, we’ve set New York’s GPS coordinates. You can change those coordinates as you wish.

TIP: Use this tool to quickly get GPS coordinates for your project: https://www.mapcoordinates.net/en

Let’s not add the necessary delegate protocols to the ViewController’s class:

class ViewController: UIViewController, MKMapViewDelegate, UITextFieldDelegate, CLLocationManagerDelegate {

The MKMapViewDelegate is needed to use some functions from the MapKit framework. We’ve declared the UITextFieldDelegate to make the locationTxt TextField search for an address after hitting the Search button in the keyboard. Lastly, CLLocationManagerDelegate is needed to perform the location’s functions.

In order to make our app work, we have to declare a few variables:

var distance = 50.0
var location = CLLocation()
var locationManager: CLLocationManager!
var mapIsSatellite = false

The distance variable is the region’s range – in Km – of the circle that will be shown around the map pin. The location and locationManager variables are needed to handle your current location detection, while the mapIsSatellite boolean will be used to switch the MapView’s look.

Inside the viewDidLoad() function, let’s paste this code:

// Setup the mapView's delegate and type
aMap.mapType = .standard
aMap.delegate = self

// Set initial default distance around the MapView's Pin
let formattedDistance = String(format: "%.0f", distance)
distanceLabel.text = "(formattedDistance) Km around your location"
        
// Set the value of the distance Slider accordingly
distanceSlider.value = Float(distance)
        
// Get current Location
currentLocationButt(self)

Comments are pretty self-explanatory: We first set out Map with the Standard look and attach the MKMapViewDelegate to itself. Then we format the distance to have no more than 2 digits after the separator (.) and show its value in the distanceLabel.

The distanceSlider‘s value will be set based on the distance value, and the currentLocationButt IBAction will be called.

Let’s add a TextField delegate function that will check it the Return button gets tapped:

func textFieldShouldReturn(_ textField: UITextField) -> Bool {
    if textField.text != "" {
        // Get the address string you've typed in the TextField
        let address = textField.text!
        textField.resignFirstResponder()
        print("ADDRESS: (address)")
            
        // Launch Geocoder to retrieve GPS coordinates form the address string
        let geocoder = CLGeocoder()
        geocoder.geocodeAddressString(address, completionHandler: { (placemarks, error) in
            if let placemark = placemarks?.first {
                let coords = placemark.location!.coordinate
                    
                // Set Location
                self.location = CLLocation(latitude: coords.latitude, longitude: coords.longitude)
                self.addPinOnMap(self.location)
                    
            } else { self.simpleAlert("Location not found. Try a new search.") }
        })
            
    } else { simpleAlert("Please type somehting!") }
        
return true
}

The code above basically makes the CLGeocoder get GPS coordinates from a typed address and call a function that will place a custom pin on the MapView.

Inside the currentLocationButt() function, place this code:

locationManager = CLLocationManager()
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyThreeKilometers
if locationManager.responds(to: #selector(CLLocationManager.requestWhenInUseAuthorization)) {
    locationManager.requestAlwaysAuthorization()
}
locationManager.startUpdatingLocation()
// Reset the locationTxt
locationTxt.text = ""

This method retrieves your current location and sets the locationTxt text to an empty string.

Below this function, let’s now add two delegate functions that belong to the CoreLocation framework and will help us to get your current location or the default one – in case of GPS signal’s absence.

func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
    simpleAlert("Failed to get your location. Please go into Settings and enable Location service for this app.")
        
    // Set the default currentLocation
    location = DEFAULT_LOCATION
        
    // Add pin on the map
    addPinOnMap(location)
}

func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    locationManager.stopUpdatingLocation()
        
    location = locations.last!
    locationManager = nil
        
    // Add pin on the map
    addPinOnMap(location)
}

We have now to create a function that will add a pin on the Map based on the found location:

func addPinOnMap(_ location: CLLocation) {
    aMap.delegate = self
    aMap.removeOverlays(aMap.overlays)
        
    if aMap.annotations.count != 0 {
        let annotation = aMap.annotations[0]
        aMap.removeAnnotation(annotation)
    }

    // Add PointAnnonation text and a Pin to the Map
    let pointAnnotation = MKPointAnnotation()
        
    // Set pin title and subtitle
    pointAnnotation.title = "Hey, I'm here!"
    pointAnnotation.subtitle = "Such a cool place"
        
    pointAnnotation.coordinate = CLLocationCoordinate2D(latitude: location.coordinate.latitude,
                                                            longitude:location.coordinate.longitude)
    let pinView = MKPinAnnotationView(annotation: pointAnnotation, reuseIdentifier: nil)
        
    aMap.centerCoordinate = pointAnnotation.coordinate
    aMap.addAnnotation(pinView.annotation!)
        
    // Zoom the Map to the location
    let region = MKCoordinateRegion.init(center: pointAnnotation.coordinate, latitudinalMeters: distance*4000, longitudinalMeters: distance*4000);
    aMap.setRegion(region, animated: true)
    aMap.regionThatFits(region)
    aMap.reloadInputViews()
            
    // Add a circle around the location
    addRadiusCircle(location)
}

At the end of the above method, we call another function that adds a colored circle around the pin:

func addRadiusCircle(_ location: CLLocation) {
    let circle = MKCircle(center: location.coordinate, radius: distance*1609 as CLLocationDistance)
    aMap.addOverlay(circle)
}

func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { 
    if overlay is MKCircle {
        let circle = MKCircleRenderer(overlay: overlay)
        circle.strokeColor = UIColor.yellow
        circle.fillColor = UIColor(red: 255, green: 255, blue: 0, alpha: 0.1)
        circle.lineWidth = 1
        return circle
    }
        
    return MKOverlayRenderer()
}

In the mapView: rendererFor() delegate function we set the RGBA values of the circle, as well as its stroke width and color.

We need to add another MapKit‘s delegate method that will set a custom pin image and a Right Callout Accessory View – the popup that shows up when you tap a pin:

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
        if annotation.isKind(of: MKPointAnnotation.self) {
            
            // Try to dequeue an existing pin view first.
            let reuseID = "CustomPinAnnotationView"
            var annotView = mapView.dequeueReusableAnnotationView(withIdentifier: reuseID)
            
            if annotView == nil {
                annotView = MKAnnotationView(annotation: annotation, reuseIdentifier: reuseID)
                annotView!.canShowCallout = true
                
                // Custom Pin image
                let imageView = UIImageView(frame: CGRect(x:0, y:0, width:44, height: 44))
                imageView.image =  UIImage(named: "map_pin")
                imageView.center = annotView!.center
                imageView.contentMode = .scaleAspectFill
                annotView!.addSubview(imageView)
                
                // RIGHT Callout Accessory
                let rightButton = UIButton(type: .custom)
                rightButton.frame = CGRect(x:0, y:0, width:32, height: 32)
                rightButton.layer.cornerRadius = rightButton.bounds.size.width/2
                rightButton.clipsToBounds = true
                rightButton.setImage(UIImage(named: "directions_butt"), for: .normal)
                annotView!.rightCalloutAccessoryView = rightButton
            }
            return annotView
            
        }
        return nil
    }
    

The last MapKit‘s delegate function we’re gonna add is the one that will open the native Maps application after clicking the button in the Right Callout Accessory View:

func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) {
    let annotation = view.annotation!
    let coordinate = annotation.coordinate
    let placemark = MKPlacemark(coordinate: coordinate, addressDictionary: nil)
    let mapitem = MKMapItem(placemark: placemark)
    mapitem.name = annotation.title!
    mapitem.openInMaps(launchOptions: nil)
}

Inside the setLocationButt() IBAction, paste this code:

if location.coordinate.latitude != 0.0 {
    let geoCoder = CLGeocoder()
    geoCoder.reverseGeocodeLocation(location) { (placemarks, error) in
        if error == nil {
            let placeArray:[CLPlacemark] = placemarks!
            var placemark: CLPlacemark!
            placemark = placeArray[0]
                    
            // Address data
            let street = placemark.addressDictionary?["Name"] as? String ?? ""
            let city = placemark.addressDictionary?["City"] as? String ?? ""
            let zip = placemark.addressDictionary?["ZIP"] as? String ?? ""
            let country = placemark.addressDictionary?["Country"] as? String ?? ""
            let state = placemark.addressDictionary?["State"] as? String ?? ""
                    
            // Get Address
            let address = "ADDRESS:n(street) - (zip), (city) - (country) - (state)"
                    
            // Show info to the next screen
            let vc = self.storyboard?.instantiateViewController(withIdentifier: "LocationDetails") as! LocationDetails
            vc.address = address
            vc.location = self.location
            self.navigationController?.pushViewController(vc, animated: true)
                    
        // error
        } else { self.simpleAlert("Geolocation not found! Try a new search.") }
                
     }// ./ geoCoder
}// ./ If

We have previously declared two functions for the distanceSlider. Fill them as it follows:

@IBAction func distanceChanged(_ sender: UISlider) {
    distance = Double(sender.value)
    let formattedDistance = String(format: "%.0f", distance)
    distanceLabel.text = "(formattedDistance) Km around your location"
}
    
@IBAction func sliderEndDrag(_ sender: UISlider) {
    // Refresh the MapView
    addPinOnMap(location)
}

Those methods will recall the appPinOnMap() function when you’ll stop dragging the distanceSlider and set the new distanceLabel‘s text and distance value.

Now place this code inside the satelliteButt() function:

mapIsSatellite = !mapIsSatellite

if mapIsSatellite { aMap.mapType = .satellite
} else { aMap.mapType = .standard }

Very simple code, it just switches the mapIsSatellite boolean variable from true to false and sets the MapView‘s look from the Standard look to the Satellite one – and vice-versa.

We have one last function to create in the ViewController.swift file. If you paid attention to the code shown so far, some lines had the simpleAlert prefix. Let’s create such method:

func simpleAlert(_ mess:String) {
    let alert = UIAlertController(title: "Custom Map",
        message: mess, preferredStyle: .alert)
    let ok = UIAlertAction(title: "OK", style: .default, handler: { (action) -> Void in })
    alert.addAction(ok)
    present(alert, animated: true, completion: nil)
}

Cool, right? Instead of using the code above all the times we have to fire an UIAlertController, we’ll simply call that function by setting the message string we need to show.

We’re done with this Swift file, let’s not switch to the LocationDetails.swift file and import the CoreLocation framework.

import CoreLocation

Declare 2 variable above the Class declaration:

var address = ""
var location = CLLocation()

Inside the viewDidLoad() function, paste this line:

addressLabel.text = address

The app will get the address string that comes from the previous ViewController and show it to the addressLabel.

Lastly, inside the backButt() method, paste this code:

 _ = navigationController?.popViewController(animated: true)

Good news: we’re done, our app is ready! Make sure to run it on a real device, it’s always the best thing to do, as the Apple Guidelines strongly suggest too.

This is how our Custom Map application looks like:

Allow Location permission
Search for an address
Satellite Map look
Full Address found

Conclusion

That’s all for this tutorial, you have learned how to create custom Map View in Xcode.

Hope you enjoyed this article, feel free to post comments about it. You can also download the full Xcode project of this tutorial, just click the link below:

Download the Xcode project

Buy me a coffee - XScoder - thanks for your support
Your support will be highly appreciated 😉