개발을 시작하는 이야기

Annotation 클릭 이벤트 본문

개발 이야기/우리동네 문화유산 :: JHeritage

Annotation 클릭 이벤트

Teiresias 2022. 4. 7. 18:01

앱을 구동하면 데이터를 로딩하다가 알림 창과 함께 강제 종료가 되는 오류를 해결하려고 다시금 프로젝트를 열고 뒤적이다 원론적인 오류는 해결하지 못하고 Uncategirized 오류만 해결한채로 오류의 원인을 찾으며, 지도 탭을 업데이트하기로 했다.

 

Uncategirized 오류 해결 : [Error]Uncategorized

 

이미 문화유산에 대한 Annotation들은 사용하고 있지만 Annotation을 선택하면 아무 효과가 없었다. 초기 Storyboard로 작성했을 때는 containerView 효과를 주려고 했었으나, 이당시 PickerView를 containerView로 작성을 해주어서 하나의 Storyboard에 두 개의 ContainerView를 컨트롤하기가 힘들어서 잠시 중단을 했었다.

 

후에 멘토님이 앱을 살펴보며 짧게 리뷰해주실 기회가 있었는데, PickerView는 ContainerView가 아닌 Textfield의 키패드 영역에 사용하는게 보편적이라고 하셔서 Codebase로 리팩터링 하며 변경해주었는데, ContainerView를 코드로 작성하는 것은 아직은 어려워 정보를 찾다가 잠시 중단했던 상태였다. 

 

Annotation을 선택을 추적하는 메서드는 선택을 추적하는 didSelect와 해제를 추적하는 didDeselect가 있다. 이 두 메서는MKMapViewDelegate 에서 실행되게 된다.

extension MapViewController: MKMapViewDelegate {
    func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
    }
    
    func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
    }
    
    func mapView(_ mapView: MKMapView, didDeselect view: MKAnnotationView) {
    }
}

mapViewDidChangeVisibleRegion은 뷰의 가시 영역이 변경되는 것을 추적하는 메서드로, 보고 있는 영역의 중앙의 위치를 표시해줄 수 있게 된다. 이때 보이는 영역의 위치를 실시간으로 가져오기보다는 TimeInterval을 설정해서 지도가 움직임을 멈추고 일정 시간이 지난 후에 위치 정보를 가져오도록 했다. 

 

var runTimeInterval: TimeInterval?
let mTimer: Selector = #selector(checkMapGeocoder)

위 두 가지 변수는 전역 변수로 설정해두었다.

func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
    runTimeInterval = Date().timeIntervalSinceReferenceDate
}

@objc func checkMapGeocoder() {
    guard let timeInterval = runTimeInterval else { return }
    let interval = Date().timeIntervalSinceReferenceDate - timeInterval
    if interval < 0.5 { return }
    let coordinate = mainView.mapView.centerCoordinate
    let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)

    // 지정된 위치의 지오 코드 요청
    CLGeocoder().reverseGeocodeLocation(location) { (placemarks, error) in
        if let pm: CLPlacemark = placemarks?.first {
            let address: String = "\(pm.country ?? "") \(pm.administrativeArea ?? "") \(pm.locality ?? "") \(pm.subLocality ?? "") \(pm.name ?? "")"
            print("address", address)
        } else {
            print("checkMapGeocoder, Error")
        }
    }
    runTimeInterval = nil
}

mapViewDidChangeVisibleRegion에서는 runTimeInterval에 지금 시간을 저장하는 역할을 해준다. 실질적인 지도의 위치를 가져오는 것은 checkMapGeocoder()가 담당을 하게 되는데, checkMapGeocoder를 실행하는 것은 아래 코드로 실행을 하게 된다.

Timer.scheduledTimer(timeInterval: 0.5, target: self, selector: mTimer, userInfo: nil, repeats: true)

checkMapGeocoder가 실행되면 실행된 지금 시간을 interval이라는 지역 변수에 저장을 해서, 기존에 저장했던 runTimeInterval과 비교하여 그 차이가 0.5보다 작다면 return을 , 그렇지 않으면 지도의 중앙 좌표를 가져오게 된다.

 

func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
    DispatchQueue.main.async {
        self.mainView.heritageView.snp.updateConstraints { make in
            make.height.equalTo(60)
        }
    }
    mainView.heritageTitle.text = view.annotation?.title ?? "이름을 불러올수 없습니다."
    viewModel.targetHeritageNo.value = view.annotation?.subtitle! ?? ""
    mainView.heritageButton.setTitle("자세히 보기", for: .normal)
}

이제 Annotation을 선택하면 실행되는 didSelect 메서드를 작성을 해주었는데, mainView에 heritageView를 미리 생성해두고 height를 '0'으로 숨겨두었다가 메서드가 실행되면 DispatchQueue.main.async를 활용하여 height를 재설정해주며 ContainerView와 비슷한 효과를 주게 되었다. Annotation을 선택하면 mainView의 Label과 Button에 텍스트를 채워주고, viewModel에 Annotation의 subTitle인 sn의 값을 저장해주었다.

 

그러고 나서 이제 heritageView의 Button 액션을 설정해서 '자세히 보기'를 누르면 DetailViewController로 연결시켜 주면 되었다.

@objc func heritageButtonClicked() {
    if let filter = viewModel.tasks {
        let filtered = filter.filter("sn = '\(viewModel.targetHeritageNo.value)'")
        viewModel.items = filtered[0]
        let vc = ListDetailViewController()
        vc.viewModel = viewModel
        self.navigationController?.pushViewController(vc, animated: true)
    } else {
        print("filter is nil")
    }
}

버튼을 클릭하면 일단 if let 구문을 통해 viewModel.tasks의 optional을 해제해주도록 한다. viewModel의 tasks에는 지도에 표시되는 Annotation들만 필터링되어 들어가 있게 된다. 

 

참조 : 사용자의 위치를 확인하고, 해당 지역으로 필터링 설정하기

 

지도에서 Annotation을 선택했다는 것은 이미 해당 목록이 tasks안에 포함되어 있다는 의미이고, 전체 목록에서 필터링을 할 필요 없이 이 목록에서만 필터링을 하는 것이 훨씬 간결하고, 혹시나 모를 중복 문제에 대해서 좀 더 안전할 수 있다.

출처 : 문화재청 공식 홈페이지

사실 나는 필터를 설정하는 파라미터 값을 sn(문화유산 순번)이 아닌 no(문화유산 고유 키값)을 넣어주려고 했는데, no를 넣어 필터링을 하게 되면 아래와 같은 오류를 만들고 앱이 종료된다. 

"Predicate expressions must compare a keypath and another keypath or a constant value"

Xcode Error Message

그래서 가장 처음에는 내가 잘못 설정을 해둔 건가 싶어 HeritageListModel 등을 다시 되짚어 살펴보았지만 큰 문제를 찾을 수 없었다. 그래서 혹시나 no라는 단어가 예약어 중 하나라서 사용이 안 되는 건가 싶어서 `no`를 붙여서 사용을 해보았지만 결과는 아래와 같았다.

'Unable to parse the format string "`no` = '10079'"'

Realm의 filter에서는 ``가 적용이 되지 않았다. 그래서 결국 no대신 사용할 파라미터로 sn을 사용하게 되었고, 여러 번 테스트를 통해 살펴본 결과 다행히 중복 값은 없는 듯했다. 그렇게 sn으로 목록을 필터링해서 상세 화면으로 넘어갈 데이터를 줄 수 있었고, 혹시 모를 중복을 대비하여 필터 값의 첫 번째 요소만 가져오도록 했다. 

 

그러고 나서 이제 Annotation의 선택을 해지했을 때의 값인 didDeselect를 설정해 주어야 했는데, 이것은 didSelect에서 DispatchQueue.main.async를 연결해 View를 조정할 때 자동으로 선택이 해제되며 didDeselect가 호출이 되었다. 그래서 didSelect에 view를 다시 내리는 함수를 작성하니 view가 올라오기도 전에 내려가는 문제가 발생해서 이 함수는 사용하지 못했고, 현재는 view가 올라오면 다시 내려가지 않게 되고 있다. 

 

사실 이렇게 View를 감췄다가 올리는 방식은 좋은 방식은 아니라고 일전에 멘토님이 이야기해주신 적이 있다. 그리고 지금 같이 개인 앱의 간단한 구동이 아닌 실무적으로 복잡한 기업의 앱에서 연동을 하게 된다고 하면 상당히 골차 아픈 일이 발생할 수 있다고 생각한다. view의 높이를 조절해서 눈속임하는 임시방편이 아닌 정석으로 사용하는 ContainerView를 좀 더 연구해서 코드로 작성해서 리팩터링 해주어야겠다.