VesselWheel

Mapkit의 지도에 MKLocalSearch활용하여 검색된 annotation 노출하기 본문

Xcode Study

Mapkit의 지도에 MKLocalSearch활용하여 검색된 annotation 노출하기

JasonYang 2024. 3. 4. 22:10

들어가기 앞서, MapKit은 SwiftUI로 간편하게 구현할 수 있고, 커스텀하기도 편하다. 

https://developer.apple.com/documentation/mapkit/

 

MapKit | Apple Developer Documentation

Display map or satellite imagery within your app, call out points of interest, and determine placemark information for map coordinates.

developer.apple.com

공식문서를 통해서, WWDC 2023에서 발표한 영상과 설명에 따르면, 개발자가 SwiftUI에서 기능 구현을 자유롭게 할 수 있도록 다양한 매소드와 인스턴스를 제공한다. 

https://developer.apple.com/wwdc23/10043

 

Meet MapKit for SwiftUI - WWDC23 - Videos - Apple Developer

Discover how expanded SwiftUI support for MapKit has made it easier than ever for you to integrate Maps into your app. We'll show you how...

developer.apple.com

23분 정도의 영상인데, 듀토리얼이 간편하고 크게 부담없이 따라할 수 있다. 

개발의 시작은 모방이니, 개인적으로 영상을 보면서 따라해보니 크게 어려움없이 실행할 수 있었다. 


SwiftUI를 UIkit과 연결하면, SwiftUI의 Mapkit에서 지원하는 다양한 기능들을 사용할 수 있다. 

-> UIkit에 SwiftUI 연결하는 방법

하지만 MapViewController와 다른 ViewController 간의 데이터 전송을 위해 통일성을 위해서,

이번에는 기존에 UIKit으로 구현된 MapViewController를 발전시켜서 개발하고자한다.

(SwiftUI와 UIKit 간에도 코어데이터 연동이 가능하다.)

 

아래는 본격적으로 구현하기에 앞서, 유트브의 간단한 영상을 참고했다. 

https://www.youtube.com/watch?v=4zqEl1s2Vas

 

https://www.youtube.com/watch?v=xngSMYXmh5Q

 

https://www.youtube.com/watch?v=czmgGFkXzHE&t=20s


 

 

 

더보기

 

//
//  RunningViewController.swift
//  Running&Eat
//
//  Created by Jason Yang on 2/21/24.
//

import UIKit
import SnapKit
import MapKit
import CoreLocation

class RunningMapViewController: UIViewController, MKMapViewDelegate{
    
    //MARK: - UI Properties
    lazy var locationManager: CLLocationManager = {
        let manager = CLLocationManager()
        manager.desiredAccuracy = kCLLocationAccuracyBest
        manager.startUpdatingLocation() // startUpdate를 해야 didUpdateLocation 메서드가 호출됨.
        manager.delegate = self
        manager.allowsBackgroundLocationUpdates = true
        manager.showsBackgroundLocationIndicator = true
        return manager
    }()
    
    var mapView: MKMapView = {
        let mapView = MKMapView()
        mapView.showsUserLocation = true
        mapView.isZoomEnabled = true
        mapView.isRotateEnabled = true
        mapView.mapType = MKMapType.standard
        mapView.showsCompass = false
        return mapView
    }()
    
    lazy var compassButton: MKCompassButton = {
        let compassButton = MKCompassButton(mapView: self.mapView)
        compassButton.compassVisibility = .visible
        var config = UIButton.Configuration.filled()
        config.cornerStyle = .capsule
        compassButton.frame = CGRect(x: 0, y: 0, width: 50, height: 50)
        return compassButton
    }()
    
    var currentLocationButton: UIButton = {
        let button = UIButton()
        var config = UIButton.Configuration.filled()
        config.baseBackgroundColor = .systemCyan
        config.cornerStyle = .capsule
        config.image = UIImage(systemName: "location")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))
        button.configuration = config
        button.layer.cornerRadius = 25
        button.addTarget(self, action: #selector(currentLocationButtonAction), for: .touchUpInside)
        return button
    }()
    
    var storeButton: UIButton = {
        let button = UIButton()
        var config = UIButton.Configuration.filled()
        config.baseBackgroundColor = .systemIndigo
        config.cornerStyle = .capsule
        config.image = UIImage(systemName: "plus")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))
        button.configuration = config
        button.layer.cornerRadius = 25
        button.layer.shadowRadius = 10
        button.layer.shadowOpacity = 0.3
        button.addTarget(self, action: #selector(didTapFloatingButton), for: .touchUpInside)
        return button
    }()
    
    var convenienceStoreButton: UIButton = {
        let button = UIButton()
        var config = UIButton.Configuration.filled()
        config.baseBackgroundColor = .systemPink
        config.cornerStyle = .capsule
        config.image = UIImage(systemName: "storefront")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))
        button.configuration = config
        button.layer.shadowRadius = 10
        button.layer.shadowOpacity = 0.3
        button.alpha = 0.0
        button.addTarget(self, action: #selector(storeHalfModalButtonAction), for: .touchUpInside)
        return button
    }()
    
    private var isActive: Bool = false {
        didSet {
            showActionButtons()
        }
    }
    
    private var animation: UIViewPropertyAnimator? //회전 버튼 프로퍼티
    
    lazy var startRunningButton: UIButton = {
        let button = UIButton()
        button.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
        button.tintColor = .white
        let configuration = UIImage.SymbolConfiguration(pointSize: 50)
        if let image = UIImage(systemName: "figure.run", withConfiguration: configuration) {
            button.setImage(image, for: .normal)
        }
        button.backgroundColor = .systemIndigo
        button.layer.cornerRadius = 45
        button.clipsToBounds = true
        
        button.addTarget(self, action: #selector(TappedstartRunningButton), for: .touchUpInside)
        return button
    }()
    
    var routeLine: MKPolyline?
    
    lazy var restaurantsearch: MKLocalSearch = {
        let request = MKLocalSearch.Request()
        request.naturalLanguageQuery = "Restaurant"
        request.region = self.mapView.region

        let filter = MKPointOfInterestFilter(including: [.restaurant])
        request.pointOfInterestFilter = filter

        return MKLocalSearch(request: request)
    }()

    
    lazy var foodMarketsearch: MKLocalSearch = {
        let request = MKLocalSearch.Request()
        request.naturalLanguageQuery = "FoodMarket"
        request.region = self.mapView.region

        let filter = MKPointOfInterestFilter(including: [.foodMarket])
        request.pointOfInterestFilter = filter

        return MKLocalSearch(request: request)
    }()
    
    lazy var Cafesearch: MKLocalSearch = {
        let request = MKLocalSearch.Request()
        request.naturalLanguageQuery = "Cafe"
        request.region = self.mapView.region

        let filter = MKPointOfInterestFilter(including: [.cafe])
        request.pointOfInterestFilter = filter

        return MKLocalSearch(request: request)
    }()

    
    //MARK: - LifeCycle
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        addSubview()
        setLayout()
        getLocationUsagePermission()

    }
    
    override func viewWillDisappear(_ animated: Bool) {
        self.locationManager.stopUpdatingLocation()
    }
    
    //MARK: - @objc functions
    @objc private func TappedstartRunningButton() {
        print("TappedstartRunningButton()")
        let startRunningViewController =  StartRunningViewController()
        startRunningViewController.modalPresentationStyle = .fullScreen
        self.present(startRunningViewController, animated: true)

    }
    
    @objc func currentLocationButtonAction() {
        locationManager.requestWhenInUseAuthorization()
        mapView.showsUserLocation = true
        mapView.setUserTrackingMode(.follow, animated: true)
        print("확인")
    }
    
    @objc private func didTapFloatingButton() {
        isActive.toggle() //
    }
    
    @objc func storeHalfModalButtonAction() {
//        getConvenienceStoreLocation()
//        let storeViewController = StoreViewController()
//        showMyViewControllerInACustomizedSheet(storeViewController)
    }
    

}

//MARK: - Button setup
extension RunningMapViewController {

    
//    private func getConvenienceStoreLocation() {
//        guard let userLocation = locationManager.location else { return } // 사용자의 현재 위치 가져오기
//        
//        // 사용자의 현재 위치에서 500m 반경 내의 영역 설정
//        let region = MKCoordinateRegion(center: userLocation.coordinate, latitudinalMeters: 500, longitudinalMeters: 500)
//        
//        let search = createFoodMarketSearch(in: region)
//        search.start { (response, error) in
//            guard let response = response else { return }
//            
//            for item in response.mapItems {
//                let annotation = MKPointAnnotation()
//                annotation.coordinate = item.placemark.coordinate
//                annotation.title = item.name
//                self.mapView.addAnnotation(annotation)
//            }
//        }
//    }

//    // 'lazy var' 대신 'func' 사용
//    private func createFoodMarketSearch(in region: MKCoordinateRegion) -> MKLocalSearch {
//        let request = MKLocalSearch.Request()
//        request.naturalLanguageQuery = "FoodMarket"
//        request.region = region
//
//        let filter = MKPointOfInterestFilter(including: [.foodMarket])
//        request.pointOfInterestFilter = filter
//
//        return MKLocalSearch(request: request)
//    }



    
    private func showActionButtons() {
        popButtons()
        rotateFloatingButton()
    }
    
    private func popButtons() {
        if isActive {
            convenienceStoreButton.layer.transform = CATransform3DMakeScale(0.4, 0.4, 1)
            UIView.animate(withDuration: 0.3, delay: 0.2, usingSpringWithDamping: 0.55, initialSpringVelocity: 0.3, options: [.curveEaseInOut], animations: { [weak self] in
                guard let self = self else { return }
                self.convenienceStoreButton.layer.transform = CATransform3DIdentity
                self.convenienceStoreButton.alpha = 1.0
            })
        } else {
            UIView.animate(withDuration: 0.15, delay: 0.2, options: []) { [weak self] in
                guard let self = self else { return }
                self.convenienceStoreButton.layer.transform = CATransform3DMakeScale(0.4, 0.4, 0.1)
                self.convenienceStoreButton.alpha = 0.0
            }
        }
    }
        
    private func rotateFloatingButton() {
        let animation = CABasicAnimation(keyPath: "transform.rotation.z")
        let fromValue = isActive ? 0 : CGFloat.pi / 4
        let toValue = isActive ? CGFloat.pi / 4 : 0
        animation.fromValue = fromValue
        animation.toValue = toValue
        animation.duration = 0.3
        animation.fillMode = .forwards
        animation.isRemovedOnCompletion = false
        storeButton.layer.add(animation, forKey: nil)
    }
}

//MARK: - Layout setup
extension RunningMapViewController {
    private func addSubview() {
        view.addSubview(mapView)
        view.addSubview(compassButton)
        view.addSubview(startRunningButton)
        view.addSubview(currentLocationButton)
        view.addSubview(storeButton)
        view.addSubview(convenienceStoreButton)
    }
    
    private func setLayout() {
        mapView.snp.makeConstraints {
            $0.top.leading.trailing.equalToSuperview()
            $0.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom)
        }
        
        compassButton.snp.makeConstraints {
            $0.width.height.equalTo(50)
            $0.trailing.equalToSuperview().offset(-20)
            $0.top.equalToSuperview().offset(120)
        }
        
        currentLocationButton.snp.makeConstraints {
            $0.width.height.equalTo(50)
            $0.trailing.equalToSuperview().offset(-20)
            $0.top.equalTo(compassButton.snp.bottom).offset(20)
        }
        storeButton.snp.makeConstraints {
            $0.width.height.equalTo(50)
            $0.trailing.equalToSuperview().offset(-20)
            $0.top.equalTo(currentLocationButton.snp.bottom).offset(20)
        }
        
        convenienceStoreButton.snp.makeConstraints {
            $0.top.equalTo(storeButton.snp.bottom).offset(20)
            $0.centerX.equalTo(storeButton)
        }
        
        startRunningButton.snp.makeConstraints {
            $0.width.height.equalTo(90)
            $0.centerX.equalToSuperview()
            $0.bottom.equalToSuperview().offset(-120)
        }
    }
    
    
} // extension

//MARK: - CLLocationManagerDelegate
extension RunningMapViewController: CLLocationManagerDelegate {
    // 사용자에게 위치 관한 요청하기
    func getLocationUsagePermission() {
        self.locationManager.requestWhenInUseAuthorization()
    }
    // 위치 정보 업데이트 받기
    func startLocationUpdates() {
        locationManager.startUpdatingLocation()
    }
    
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        if let location = locations.last {
            print("Latitude: \(location.coordinate.latitude), Longitude: \(location.coordinate.longitude)")
            let userLocation = location.coordinate
            
            calculateAndShowRoute(from: userLocation)
        }
    }
    // 사용자의 현재 위치를 기반으로 경로를 계산하고 지도에 표시하는 메서드
    func calculateAndShowRoute(from userLocation: CLLocationCoordinate2D) {
        // 예를 들어, 사용자의 현재 위치로부터 목적지(서울역)까지의 경로를 계산
        let destinationCoordinate = CLLocationCoordinate2D(latitude: 37.554722, longitude: 126.970833)
        
        // MKDirectionsRequest를 사용하여 경로를 요청
        let request = MKDirections.Request()
        request.source = MKMapItem(placemark: MKPlacemark(coordinate: userLocation))
        request.destination = MKMapItem(placemark: MKPlacemark(coordinate: destinationCoordinate))
        request.transportType = .walking // 적절한 이동 수단을 선택
        
        // MKDirections를 사용하여 경로를 추적
        let directions = MKDirections(request: request)
        directions.calculate { response, error in
            guard let route = response?.routes.first else {
                if let error = error {
                    print("Error calculating route: \(error.localizedDescription)")
                }
                return
            }
            
            // 경로를 지도에 추가합니다.
            self.mapView.addOverlay(route.polyline)
            
            // 경로가 모두 표시되도록 지도를 조정합니다.
            let region = MKCoordinateRegion(route.polyline.boundingMapRect)
            self.mapView.setRegion(region, animated: true)
        }
    }
    
    // 지도에 경로를 표시하기 위해 MKMapViewDelegate에서 MKPolylineRenderer를 설정
    func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
        if overlay is MKPolyline {
            let renderer = MKPolylineRenderer(overlay: overlay)
            renderer.strokeColor = UIColor.systemIndigo // 경로의 색상을 설정
            renderer.lineWidth = 3 // 경로의 두께를 설정합니다.
            return renderer
        }
        return MKOverlayRenderer(overlay: overlay)
    }

    
    
    // 오류 처리
    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print("Location Error: \(error)")
    }
    // 위치 관한 상태 감지
    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        switch status {
        case .authorizedAlways, .authorizedWhenInUse:
            startLocationUpdates()
            print("GPS 권한 설정됨")
        case .restricted, .notDetermined:
            print("GPS 권한 설정되지 않음")
            DispatchQueue.main.async {
                self.getLocationUsagePermission()
            }
        case .denied:
            print("GPS 권한 요청 거부됨")
            DispatchQueue.main.async {
                self.getLocationUsagePermission()
            }
        default:
            print("GPS: Default")
        }
    }
    

}

//extension RunningMapViewController: UISheetPresentationControllerDelegate {
//    func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) {
//        //크기 변경 됐을 경우
//        print(sheetPresentationController.selectedDetentIdentifier == .large ? "large" : "medium")
//    }
//}

https://developer.apple.com/documentation/mapkit/mklocalsearch

 

MKLocalSearch | Apple Developer Documentation

A utility object for initiating map-based searches and processing the results.

developer.apple.com

https://developer.apple.com/documentation/mapkit/mklocalsearch/request

 

MKLocalSearch.Request | Apple Developer Documentation

The parameters to use when searching for points of interest on the map.

developer.apple.com

 

-> MKLocalSearch.Request() 클래스를 활용하여, 자연어로 "coffee"를 검색했을 때, 

 


    private func removeAnnotationsFromMap() {
        // mapView.annotations 배열에서 MKUserLocation 인스턴스를 제외하고 모두 제거
        let annotationsToRemove = mapView.annotations.filter { $0 !== mapView.userLocation }
        mapView.removeAnnotations(annotationsToRemove)
    }

    
    private func getAnnotationLocation() {
        guard let currentLocation = self.mapView.userLocation.location else {
            print("Failed to get user location")
            return
        }
        
        let request = MKLocalSearch.Request()
        request.naturalLanguageQuery = "GS25" // 원하는 POI 유형을 검색어로 지정
        //        request.region = mapView.region // 검색 범위를 지정
        request.region = MKCoordinateRegion(center: currentLocation.coordinate, latitudinalMeters: 150, longitudinalMeters: 150) // 150m 범위를 지정
        
        let search = MKLocalSearch(request: request)
        search.start { (response, error) in
            guard let response = response else {
                print("Search error: \(error?.localizedDescription ?? "Unknown error")")
                return
            }
            
            for item in response.mapItems {
                // 검색한 POI를 지도에 추가
                let annotation = MKPointAnnotation()
                annotation.coordinate = item.placemark.coordinate
                annotation.title = item.name
                self.mapView.addAnnotation(annotation)
            }
        }
    }

-> 동일한 패턴으로 GS25를 자연어로 검색하고, 검색결과인 response의 mapItems를 MKPointAnnotation() 클래스를 활용하여 위경도좌표에 placemark로 mapView란 지도에 GS25 검색결과의 annotation을 추가했다. 

 

 

 

https://developer.apple.com/documentation/mapkit/mklocalsearch/request/1452353-naturallanguagequery

 

naturalLanguageQuery | Apple Developer Documentation

A string containing the desired search item.

developer.apple.com

https://developer.apple.com/documentation/mapkit/mkmapview/1452069-addannotation

 

addAnnotation(_:) | Apple Developer Documentation

Adds the specified annotation to the map view.

developer.apple.com

https://developer.apple.com/documentation/mapkit/mkmapview/1452130-removeannotations

 

removeAnnotations(_:) | Apple Developer Documentation

Removes an array of annotation objects from the map view.

developer.apple.com

https://developer.apple.com/documentation/mapkit/mkpointannotation

 

MKPointAnnotation | Apple Developer Documentation

A string-based piece of location-specific data that you apply to a specific point on a map.

developer.apple.com

https://developer.apple.com/documentation/mapkit/mkmapitem

 

MKMapItem | Apple Developer Documentation

A point of interest on the map.

developer.apple.com

https://developer.apple.com/documentation/mapkit/mkmapitem/1452134-placemark

 

placemark | Apple Developer Documentation

The placemark object containing the location information.

developer.apple.com