[Swift Project] Smoking/Nonsmoking Area

4 minute read

1. Requirements

Project Objectives

Produce an iOS application that display 🚬smoking areas for smokers and 🚭 non-smoking areas for non-smokers in South Korea.

Specification

version 1.0.0

  • Mark smoking areas in the map. (YongSan-Gu)
  • Mark non-smoking areas in the map.
  • The map screen can be zoomed.
  • Can move to the current position on the map screen.

Next version

  • Precise way to mark ‘near’ positioin in non-smoking area. (current: same administrative & locality)
  • Mark other localities’ smoking area API. (current: smoking area 1, non-smoking area: all South Korea except API error area.)
  • Show information of the selected area.
  • Optional: people density



2. Structure/Function

version 1.0.0

: one view, one controller, one model.

(1) View(main storyboard)

  • Apple MapView
  • two switch(smoking area, nonsmoking area on/off) with each label → stack view
  • stepper(zoom)
  • button(current location)
storyboard
Smoking Area v1.0.0 storyboard with constraints

(2) Main Controller

  • moveToCurrentLocation(): move to current location.
  • getCoordinateToAddress(): transfer coordinate(latitude & longitude) to Korean address.
  • addAnnotations(): add/remove multiple Annotations(markers) in the Apple MapView.
  • mapScaleStepper(): zoom in/out.
  • callJsonApi(): call public API in JSON format.
  • callXmlApi(): get public API in XML format.
  • smokingAreaSwitchAction(): smoking area annotations on/off.
  • nonsmokingAreaSwitchAction(): nonsmoking area annotations on/off.

(3) API Model

  • nonsmoking area API model
  • smoking area API model
  • address model

Next version

  • Applying design patterns.



3. Implementation: Focus on the Problem-Solving

: Among the implementations, parts that take a long time or still have a room for improvement are summarized with the source.

version 1.0.0

In version 1.0.0, there are main 4 features.

➀ Move to current location. ➁ Zoom in/out map. ➂ Display the non-smoking area data near the user on the map. ➃ Display the smoking area data of Yonsan-Gu on the map.

The codes below are the main functions needed to implement these four features.

Main problems

  • call XML/JSON API
  • synchronize with/without loop

Functions

➀ Move to current locations

  • basic settings in viewDidLoad()
locationManager = CLLocationManager()
locationManager.delegate = self

locationManager.requestWhenInUseAuthorization()

locationManager.desiredAccuracy = kCLLocationAccuracyBest
locationManager.startUpdatingLocation()

moveToCurrentLocation()
currentDisplayCenter = mapView.centerCoordinate

mapView.showsUserLocation = true
self.mapView.delegate = self
  • moveToCurrentLocation()
private func moveToCurrentLocation() {
    if let userLocation = locationManager.location?.coordinate {
        let viewRegion = MKCoordinateRegion(center: userLocation, latitudinalMeters: 500, longitudinalMeters: 500)
        mapView.setRegion(viewRegion, animated: false)
    }
}

➁ Zoom map with Stepper

  • basic settings in viewDidLoad()
// zoom in/out stepper setup

mapScaleStepperOulet.wraps = true
mapScaleStepperOulet.autorepeat = true
mapScaleStepperOulet.minimumValue = -1000
mapScaleStepperOulet.maximumValue = 1000
  • mapScaleStepper(_ sender: UIStepper)
@IBAction func mapScaleStepper(_ sender: UIStepper) {
  var region = mapView.region
  
  if sender.value > mapScaleValue {
      region.span.latitudeDelta /= 2.0
      region.span.longitudeDelta /= 2.0
      mapView.setRegion(region, animated: true)
  } else  {
      region.span.latitudeDelta *= 2.0
region.span.longitudeDelta*= 2.0
      mapView.setRegion(region, animated: true)

  }
  mapScaleValue = sender.value
}

➂ Add/Remove multiple Annotations in the map

: Area is the structure implemented in the Model.

  • Add
private func addAnnotations(areas: [Area]){
    let annotations = areas.map { area -> MKAnnotation in
        let annotation = MKPointAnnotation()
        annotation.title = area.placeName
        annotation.coordinate = area.coordinate
        annotation.subtitle = area.placeSubtitle.rawValue

        return annotation
    }
    mapView.addAnnotations(annotations)
}
  • Remove
@IBAction func nonsmokingAreaSwitchAction(_ sender: UISwitch) {
    if sender.isOn {
        callXmlApi(baseUrl: URLs.nonsmokingAreaBaseURL.rawValue, endPage: 4) { nonsmokingAreas in
            self.addAnnotations(areas: nonsmokingAreas)
        }
    } else {
        // Remove the anotations that have 'Nonsmoking' subtitle.

        for annotation in self.mapView.annotations {
            if let subtitle = annotation.subtitle, subtitle == Area.PlaceSubtitle.Nonsmoking.rawValue {
                mapView.removeAnnotation(annotation)
            }
        }
    }
}

➃ Exchange Latitude & Longitude to Address

: This function is used in the nonsmokingAreaSwitchAction() function with the flow shown below.

Get current location’s latitude & longitude → exchange latitude & longitude to Korean Address → Extract the specific factor (ex. administrativeArea, locality of Address) → get current location’s non-smoking areas’ API using the factor

private func getCoordinateToAddress(coordinate: CLLocationCoordinate2D) {
    let coordinateLocation = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
    let geocoder = CLGeocoder()
    let locale = Locale(identifier: "Ko-kr")

    geocoder.reverseGeocodeLocation(coordinateLocation, preferredLocale: locale, completionHandler: {(placemarks, error) in
        if let fullAddress: [CLPlacemark] = placemarks {
            if let locality = fullAddress.last?.locality, let administrativeArea = fullAddress.last?.administrativeArea {
                self.currentDisplayAddress = Address(administrativeArea: administrativeArea, locality: locality)
            }
        }
    })
}

➄ API: XML format

: In this project, the Alamofire, CFRunLoop and completion is used to handle the XML type multiple API in the loop.

Calling API with NSXMLParserDelegate, handling its data and other ways to synchronize it will be delt with in another post.

private func callXmlApi(baseUrl: String, endPage: Int, completion: @escaping ([Area])->()){
  var nonsmokingAreas = [Area]()
  var isEndPage = false
  let runLoop = CFRunLoopGetCurrent()

  for pageNo in 0...endPage {
    if isEndPage { break }

    Alamofire.request(url, method: .get).validate().responseString { response in
      CFRunLoopStop(runLoop)
      switch response.result {
      case .success:
        if let responseData = response.data {
          let responseDataEncoding = NSString(data: responseData, encoding: String.Encoding.utf8.rawValue)
          let xml = try! XML.parse(String(responseDataEncoding!))
          
          if let endPageCode = xml["response"]["header"]["code"].text, let endPageCodeName = xml["response"]["header"]["codeNm"].text {
              isEndPage = true
              print("EndCode: \(endPageCode), EndCodeName: \(endPageCodeName)", isEndPage)
              break
          }
          
          for element in xml["response"]["body"]["items"]["item"]{
              if let placeName = element["prhsmkNm"].text, let latitude = element["latitude"].text, let longitude = element["longitude"].text {
                  let latitudeDoube: Double = Double(latitude)!
                  let longitudeDoube: Double = Double(longitude)!

                  nonsmokingAreas.append(Area(placeSubtitle: Area.PlaceSubtitle.Nonsmoking , placeName: placeName, coordinate: CLLocationCoordinate2D(latitude: latitudeDoube, longitude: longitudeDoube)))
              }
          }
      }

      case .failure(_):
      print("fail to get response from \(url)")
      }
    }
    CFRunLoopRun()
  }
  completion(nonsmokingAreas)
}

➅ API: JSON format

private func callJsonApi(urlStr: String, completion: @escaping ([Area])->()) {
    var smokingAreas = [Area]()
    guard let url = URL(string: urlStr) else {  return  }
    let session = URLSession(configuration: URLSessionConfiguration.default)
    let task = session.dataTask(with: url, completionHandler: {(data, response, error) in
        guard error == nil else {
            print(" > Error: \(error!)")
            return
        }
        guard let resData = data else { return }
        do {
            let dataDecode = try JSONDecoder().decode(SmokingArea.self, from: resData)
            for row in dataDecode.data {
                if let latitudeDouble: Double = Double(row.latitude),let longitudeDouble: Double = Double(row.longitude) {
                    smokingAreas.append(Area(placeSubtitle: Area.PlaceSubtitle.Smoking, placeName: row.installationLocation, coordinate: CLLocationCoordinate2D(latitude: latitudeDouble, longitude: longitudeDouble)))
                }
            }
            completion(smokingAreas)
        } catch let error {
            print("JSONDecoder Error: \(error)")
        }
    })
    task.resume()
}

Further Improvement

Refactoring

  • guard
  • log(current: print) → warning/error/info/debug
  • Other ways to syncßhronize the API function.
  • Unit Test
  • (UI Test?)

Calling multiple APIs FAST without CPU, Momory overloading

: In version 1.0.0, the application is quite slow especially when calling API.



4. Result

version 1.0.0

  • period: Aug 9, 2021 ~ Sep 5, 2021

Comments