Source: https://maps.vietmap.vn/docs/vi/sdk-mobile/sdk-ios/navigation/ # SDK dẫn đường iOS (phiên bản 2.0) ![SDK iOS của VIETMAP](https://maps.vietmap.vn/docs/sdk-mobile/sdk-ios/sdkmobile.png) ## Chuẩn bị môi trường Cài Homebrew Kiểm tra brew ``` $ brew --version ``` Nếu chưa cài Homebrew, bạn sẽ thấy: ``` $ zsh: command not found: brew ``` Chưa cài Homebrew thì cài bằng lệnh: ``` $ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" ``` Cài CocoaPods ``` $ brew install cocoapods ``` ## Tạo dự án dẫn đường bằng Xcode Tạo dự án dùng Swift và Storyboard: mở Xcode, chọn File > New > Project ![Tạo dự án trong Xcode](https://maps.vietmap.vn/docs/sdk-mobile/sdk-ios/image-10.png) Chạy thử dự án. Nếu chạy được, bạn sẽ thấy giao diện như hình dưới. ![Giao diện dự án sau khi chạy](https://maps.vietmap.vn/docs/sdk-mobile/sdk-ios/image-11.png) **Thêm thư viện bằng Podfile** 1. Mở Terminal và chuyển vào thư mục dự án: ``` cd NavigationSample ``` 2. Trong thư mục dự án, chạy lệnh sau để tạo Podfile: ``` pod init ``` 3. Mở Podfile và thêm nội dung sau: ``` pod 'VietMap', '~> 1.2.2' pod 'VietMapNavigation', '~> 2.1.8' pod 'VietMapCoreNavigation', '~> 2.1.6' ``` 4. Cài thư viện ``` pod install --repo-update ``` 5. Mở dự án bằng file `.xcworkspace` (bấm đúp) ## Sự kiện **addGestureRecognizer**: đăng ký nhận cử chỉ trên mapView **Directions**: gọi API tìm đường **NavigationViewController**: bắt đầu dẫn đường Lắng nghe sự kiện của bộ quản lý tuyến đường (route controller) **routeControllerProgressDidChange** **routeControllerDidReroute** Lắng nghe sự kiện bản đồ qua `NavigationMapViewDelegate` **didSelect**: điểm dừng (waypoint) **didSelect**: tuyến đường Lắng nghe sự kiện dẫn đường qua `NavigationViewControllerDelegate` **didArriveAt** **byCanceling** ## Build dự án Thêm cấu hình vào `Info.plist` Xin quyền truy cập vị trí thiết bị. ```plist NSLocationAlwaysAndWhenInUseUsageDescription Your request location description NSLocationAlwaysUsageDescription Your request location description NSLocationWhenInUseUsageDescription Your request location description UIBackgroundModes audio location ``` Thêm URL style, URL server và API key. ```plist VietMapURL https://maps.vietmap.vn/api/maps/light/styles.json?apikey=YOUR_API_KEY_HERE VietMapAPIBaseURL https://maps.vietmap.vn/api/navigations/route/ VietMapAccessToken YOUR_API_KEY_HERE ``` Muốn dùng màu và icon riêng, tạo bản đồ trong [Map Editor](https://tools.vietmap.vn/map-editor/) rồi thay URL style ở trên bằng link style của bản đồ đó. Tạo giao diện trong `Main.storyboard` như hình dưới, gồm: UIView: `mapView`, hiển thị bản đồ UIButton: `clearMarker`, xoá marker điểm đến UIButton: `startButton`, bắt đầu dẫn đường Có thể xem hoặc chép giao diện từ `NavigationSample/Main.storyboard` ![Giao diện Main.storyboard của app dẫn đường mẫu](https://maps.vietmap.vn/docs/sdk-mobile/sdk-ios/image-15.png) ### Khởi tạo mapView ```swift var mapView: NavigationMapView? { didSet { oldValue?.removeFromSuperview() if let mapView = mapView { configureMapView(mapView) view.insertSubview(mapView, belowSubview: longPressHintView) } } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) startMapView() } func startMapView() { self.routes = nil self.waypoints = [] self.mapView = NavigationMapView(frame: view.bounds,styleURL: URL(string: styleView)) // Reset the navigation styling to the defaults if we are returning from a presentation. if (presentedViewController != nil) { DayStyle().apply() } Locale.localeVoice = "vi" } ``` ### Đăng ký cử chỉ cho mapView Tạo sự kiện `didLongPress` để người dùng nhấn giữ chọn điểm đến trên bản đồ. ```swift let longTap = UILongPressGestureRecognizer(target: self, action: #selector(didLongPress(tap:))) mapView.gestureRecognizers?.filter({ $0 is UILongPressGestureRecognizer }).forEach(longTap.require(toFail:)) mapView.addGestureRecognizer(longTap) ``` Trong `didLongPress`, lấy toạ độ và đặt marker tại điểm đến. ```swift @objc func didLongPress(tap: UILongPressGestureRecognizer) { guard let mapView = mapView, tap.state == .began else { return } if let annotation = mapView.annotations?.last, waypoints.count > 2 { mapView.removeAnnotation(annotation) } if waypoints.count > 1 { waypoints = Array(waypoints.suffix(1)) } let coordinates = mapView.convert(tap.location(in: mapView), toCoordinateFrom: mapView) // Note: The destination name can be modified. The value is used in the top banner when arriving at a destination. let waypoint = Waypoint(coordinate: coordinates, name: "Dropped Pin #\(waypoints.endIndex + 1)") waypoints.append(waypoint) requestRoute() } ``` ### Gọi API tìm đường Tìm đường từ vị trí hiện tại tới điểm vừa nhấn giữ. ```swift func requestRoute() { guard waypoints.count > 0 else { return } guard let mapView = mapView else { return } let userWaypoint = Waypoint(location: mapView.userLocation!.location!, heading: mapView.userLocation?.heading, name: "User location") waypoints.insert(userWaypoint, at: 0) let routeOptions = NavigationRouteOptions(waypoints: waypoints) requestRoute(with: routeOptions, success: defaultSuccess, failure: defaultFailure) } fileprivate func requestRoute(with options: RouteOptions, success: @escaping RouteRequestSuccess, failure: RouteRequestFailure?) { let handler: Directions.RouteCompletionHandler = {(waypoints, potentialRoutes, potentialError) in if let error = potentialError, let fail = failure { return fail(error) } guard let routes = potentialRoutes else { return } return success(routes) } Directions.shared.calculate(options, completionHandler: handler) } ``` ### Bắt đầu dẫn đường Bắt đầu dẫn đường: ẩn mapView hiện tại và thay bằng navigationView. ```swift func startStyledNavigation() { guard let route = self.routes?.first else { return } navigationViewController = NavigationViewController( for: route, styles: [NightStyle()], locationManager: NavigationLocationManager() ) navigationViewController.delegate = self customStyleMap() configureMapView() addListenerMap() present(navigationViewController, animated: true) { self.mapView?.removeFromSuperview() self.mapView = nil } } ``` ### Lắng nghe sự kiện ProgressDidChange và ReRoute ```swift private func addListenerMap() { NotificationCenter.default.addObserver(self, selector: #selector(progressDidChange(_ :)), name: .routeControllerProgressDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(progressDidReroute(_ :)), name: .routeControllerDidReroute, object: nil) } ``` `progressDidChange` trả về dữ liệu tuyến và vị trí trong lúc di chuyển, dùng để điều khiển camera và hiển thị dẫn đường. Ví dụ dưới lưu vị trí hiện tại và vẽ mũi tên chỉ hướng cho lần rẽ tiếp theo. ```swift @objc func progressDidChange(_ notification: NSNotification ) { let routeProgress = notification.userInfo![RouteControllerNotificationUserInfoKey.routeProgressKey] as! RouteProgress let location = notification.userInfo![RouteControllerNotificationUserInfoKey.locationKey] as! CLLocation currentLocation = location setCenterIsFirst(location) addManeuverArrow(routeProgress) } ``` `progressDidReroute` trả về tuyến mới khi người dùng đi lệch khỏi tuyến ban đầu. ```swift @objc func progressDidReroute(_ notification: Notification) { if let userInfo = notification.object as? RouteController { navigationViewController.mapView?.showRoutes([userInfo.routeProgress.route]) centerMap(userInfo.locationManager.location!) } } ``` ### Lắng nghe sự kiện chọn waypoint và chọn tuyến ```swift extension ViewController: NavigationMapViewDelegate { func navigationMapView(_ mapView: NavigationMapView, didSelect waypoint: Waypoint) { guard let routeOptions = routes?.first?.routeOptions else { return } let modifiedOptions = routeOptions.without(waypoint: waypoint) presentWaypointRemovalActionSheet { _ in self.requestRoute(with:modifiedOptions, success: self.defaultSuccess, failure: self.defaultFailure) } } func navigationMapView(_ mapView: NavigationMapView, didSelect route: Route) { guard let routes = routes else { return } guard let index = routes.firstIndex(where: { $0 == route }) else { return } self.routes!.remove(at: index) self.routes!.insert(route, at: 0) } private func presentWaypointRemovalActionSheet(completionHandler approve: @escaping ((UIAlertAction) -> Void)) { let title = NSLocalizedString("Remove Waypoint?", comment: "Waypoint Removal Action Sheet Title") let message = NSLocalizedString("Would you like to remove this waypoint?", comment: "Waypoint Removal Action Sheet Message") let removeTitle = NSLocalizedString("Remove Waypoint", comment: "Waypoint Removal Action Item Title") let cancelTitle = NSLocalizedString("Cancel", comment: "Waypoint Removal Action Sheet Cancel Item Title") let actionSheet = UIAlertController(title: title, message: message, preferredStyle: .actionSheet) let remove = UIAlertAction(title: removeTitle, style: .destructive, handler: approve) let cancel = UIAlertAction(title: cancelTitle, style: .cancel, handler: nil) [remove, cancel].forEach(actionSheet.addAction(_:)) self.present(actionSheet, animated: true, completion: nil) } } ``` ### Lắng nghe sự kiện didArriveAt và byCanceling `didArriveAt` phát ra khi người dùng tới điểm đến; `byCanceling` phát ra khi người dùng huỷ dẫn đường. ```swift extension ViewController: NavigationViewControllerDelegate { // By default, when the user arrives at a waypoint, the next leg starts immediately. // If you implement this method, return true to preserve this behavior. // Return false to remain on the current leg, for example to allow the user to provide input. // If you return false, you must manually advance to the next leg. See the example above in `confirmationControllerDidConfirm(_:)`. public func navigationViewController(_ navigationViewController: NavigationViewController, didArriveAt waypoint: Waypoint) -> Bool { cancelListener() return true } // Called when the user hits the exit button. // If implemented, you are responsible for also dismissing the UI. public func navigationViewControllerDidDismiss(_ navigationViewController: NavigationViewController, byCanceling canceled: Bool) { cancelListener() self.navigationViewController.dismiss(animated: true) { self.startMapView() } } } ``` ### Code mẫu ```swift import UIKit import VietMapCoreNavigation import VietMapNavigation import VietMapDirections import UserNotifications private typealias RouteRequestSuccess = (([Route]) -> Void) private typealias RouteRequestFailure = ((NSError) -> Void) class ViewController: UIViewController, MGLMapViewDelegate { // MARK: - IBOutlets @IBOutlet weak var longPressHintView: UIView! @IBOutlet weak var startButton: UIButton! @IBOutlet weak var bottomBar: UIView! @IBOutlet weak var bottomBarBackground: UIView! @IBOutlet weak var clearMarker: UIButton! var navigationViewController: NavigationViewController! var mapboxRouteController: RouteController? var currentLocation: CLLocation! var isFirstRender: Bool = false var styleView = Bundle.main.object(forInfoDictionaryKey: "VietMapURL") as! String // MARK: Properties var mapView: NavigationMapView? { didSet { oldValue?.removeFromSuperview() if let mapView = mapView { configureMapView(mapView) view.insertSubview(mapView, belowSubview: longPressHintView) } } } var waypoints: [Waypoint] = [] { didSet { waypoints.forEach { $0.coordinateAccuracy = -1 } } } var routes: [Route]? { didSet { startButton.isEnabled = (routes?.count ?? 0 > 0) guard let routes = routes, let current = routes.first else { mapView?.removeRoutes(); return } mapView?.showRoutes(routes) mapView?.showWaypoints(current) } } // MARK: Directions Request Handlers fileprivate lazy var defaultSuccess: RouteRequestSuccess = { [weak self] (routes) in guard let current = routes.first else { return } self?.clearMarker.isEnabled = true self?.mapView?.removeWaypoints() self?.routes = routes self?.waypoints = current.routeOptions.waypoints self?.longPressHintView.isHidden = true } fileprivate lazy var defaultFailure: RouteRequestFailure = { [weak self] (error) in self?.routes = nil //clear routes from the map print(error.localizedDescription) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) startMapView() } override func viewDidLoad() { super.viewDidLoad() if #available(iOS 10.0, *) { UNUserNotificationCenter.current().requestAuthorization(options: [.badge, .alert, .sound]) { _,_ in DispatchQueue.main.async { CLLocationManager().requestWhenInUseAuthorization() } } } } func startMapView() { self.routes = nil self.waypoints = [] self.mapView = NavigationMapView(frame: view.bounds,styleURL: URL(string: styleView)) // Reset the navigation styling to the defaults if we are returning from a presentation. if (presentedViewController != nil) { DayStyle().apply() } Locale.localeVoice = "vi" } func configureMapView(_ mapView: NavigationMapView) { mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight] mapView.delegate = self mapView.navigationMapDelegate = self mapView.routeLineColor = UIColor.yellow mapView.userTrackingMode = .follow mapView.showsUserHeadingIndicator = true let singleTap = UILongPressGestureRecognizer(target: self, action: #selector(didLongPress(tap:))) mapView.gestureRecognizers?.filter({ $0 is UILongPressGestureRecognizer }).forEach(singleTap.require(toFail:)) mapView.addGestureRecognizer(singleTap) } // MARK: Gesture Recognizer Handlers @objc func didLongPress(tap: UILongPressGestureRecognizer) { guard let mapView = mapView, tap.state == .began else { return } if let annotation = mapView.annotations?.last, waypoints.count > 2 { mapView.removeAnnotation(annotation) } if waypoints.count > 1 { waypoints = Array(waypoints.suffix(1)) } let coordinates = mapView.convert(tap.location(in: mapView), toCoordinateFrom: mapView) // Note: The destination name can be modified. The value is used in the top banner when arriving at a destination. let waypoint = Waypoint(coordinate: coordinates, name: "Dropped Pin #\(waypoints.endIndex + 1)") waypoints.append(waypoint) requestRoute() } @IBAction func startButtonPressed(_ sender: Any) { startStyledNavigation() } @IBAction func clearMarker(_ sender: Any) { self.clearMarker.isEnabled = false self.startButton.isEnabled = false mapView?.removeRoutes() mapView?.removeWaypoints() waypoints.removeAll() longPressHintView.isHidden = false } // MARK: - Public Methods // MARK: Route Requests func requestRoute() { guard waypoints.count > 0 else { return } guard let mapView = mapView else { return } let userWaypoint = Waypoint(location: mapView.userLocation!.location!, heading: mapView.userLocation?.heading, name: "User location") waypoints.insert(userWaypoint, at: 0) let routeOptions = NavigationRouteOptions(waypoints: waypoints) requestRoute(with: routeOptions, success: defaultSuccess, failure: defaultFailure) } fileprivate func requestRoute(with options: RouteOptions, success: @escaping RouteRequestSuccess, failure: RouteRequestFailure?) { let handler: Directions.RouteCompletionHandler = {(waypoints, potentialRoutes, potentialError) in if let error = potentialError, let fail = failure { return fail(error) } guard let routes = potentialRoutes else { return } return success(routes) } Directions.shared.calculate(options, completionHandler: handler) } func startStyledNavigation() { guard let route = self.routes?.first else { return } navigationViewController = NavigationViewController( for: route, styles: [NightStyle()], locationManager: NavigationLocationManager() ) navigationViewController.delegate = self customStyleMap() configureMapView() addListenerMap() present(navigationViewController, animated: true) { self.mapView?.removeFromSuperview() self.mapView = nil } } private func customStyleMap() { navigationViewController.mapView?.styleURL = URL(string: styleView); navigationViewController.mapView?.routeLineColor = UIColor.yellow navigationViewController.mapView?.userTrackingMode = .follow navigationViewController.mapView?.showsUserHeadingIndicator = true } private func configureMapView() { navigationViewController.mapView?.autoresizingMask = [.flexibleWidth, .flexibleHeight] navigationViewController.routeController.reroutesProactively = true } @objc func progressDidReroute(_ notification: Notification) { if let userInfo = notification.object as? RouteController { navigationViewController.mapView?.showRoutes([userInfo.routeProgress.route]) } } @objc func progressDidChange(_ notification: NSNotification ) { let routeProgress = notification.userInfo![RouteControllerNotificationUserInfoKey.routeProgressKey] as! RouteProgress let location = notification.userInfo![RouteControllerNotificationUserInfoKey.locationKey] as! CLLocation currentLocation = location setCenterIsFirst(location) addManeuverArrow(routeProgress) } private func setCenterIsFirst(_ location: CLLocation) { if !isFirstRender { DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { let camera = MGLMapCamera( lookingAtCenter: location.coordinate, acrossDistance: 500, pitch: 75, heading: location.course ) self.navigationViewController.mapView?.setCamera(camera, animated: true) } isFirstRender = true } } private func addManeuverArrow(_ routeProgress: RouteProgress) { if routeProgress.currentLegProgress.followOnStep != nil { navigationViewController.mapView?.addArrow(route: routeProgress.route, legIndex: routeProgress.legIndex, stepIndex: routeProgress.currentLegProgress.stepIndex + 1) } else { navigationViewController.mapView?.removeArrow() } } private func addListenerMap() { NotificationCenter.default.addObserver(self, selector: #selector(progressDidChange(_ :)), name: .routeControllerProgressDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(progressDidReroute(_ :)), name: .routeControllerDidReroute, object: nil) } public func cancelListener() { NotificationCenter.default.removeObserver(self, name: .routeControllerDidReroute, object: nil) NotificationCenter.default.removeObserver(self, name: .routeControllerProgressDidChange, object: nil) } } // MARK: - NavigationMapViewDelegate extension ViewController: NavigationMapViewDelegate { func navigationMapView(_ mapView: NavigationMapView, didSelect waypoint: Waypoint) { guard let routeOptions = routes?.first?.routeOptions else { return } let modifiedOptions = routeOptions.without(waypoint: waypoint) presentWaypointRemovalActionSheet { _ in self.requestRoute(with:modifiedOptions, success: self.defaultSuccess, failure: self.defaultFailure) } } func navigationMapView(_ mapView: NavigationMapView, didSelect route: Route) { guard let routes = routes else { return } guard let index = routes.firstIndex(where: { $0 == route }) else { return } self.routes!.remove(at: index) self.routes!.insert(route, at: 0) } private func presentWaypointRemovalActionSheet(completionHandler approve: @escaping ((UIAlertAction) -> Void)) { let title = NSLocalizedString("Remove Waypoint?", comment: "Waypoint Removal Action Sheet Title") let message = NSLocalizedString("Would you like to remove this waypoint?", comment: "Waypoint Removal Action Sheet Message") let removeTitle = NSLocalizedString("Remove Waypoint", comment: "Waypoint Removal Action Item Title") let cancelTitle = NSLocalizedString("Cancel", comment: "Waypoint Removal Action Sheet Cancel Item Title") let actionSheet = UIAlertController(title: title, message: message, preferredStyle: .actionSheet) let remove = UIAlertAction(title: removeTitle, style: .destructive, handler: approve) let cancel = UIAlertAction(title: cancelTitle, style: .cancel, handler: nil) [remove, cancel].forEach(actionSheet.addAction(_:)) self.present(actionSheet, animated: true, completion: nil) } } // MARK: - NavigationViewControllerDelegate extension ViewController: NavigationViewControllerDelegate { // By default, when the user arrives at a waypoint, the next leg starts immediately. // If you implement this method, return true to preserve this behavior. // Return false to remain on the current leg, for example to allow the user to provide input. // If you return false, you must manually advance to the next leg. See the example above in `confirmationControllerDidConfirm(_:)`. public func navigationViewController(_ navigationViewController: NavigationViewController, didArriveAt waypoint: Waypoint) -> Bool { cancelListener() return true } // Called when the user hits the exit button. // If implemented, you are responsible for also dismissing the UI. public func navigationViewControllerDidDismiss(_ navigationViewController: NavigationViewController, byCanceling canceled: Bool) { cancelListener() self.navigationViewController.dismiss(animated: true) { self.startMapView() } } } ```