iOS Naver Map CustomMarker [2부]

dev_will_d·2023년 4월 20일
1

이 글은 Naver Map을 구현했다는 것을 전제로 작성하였습니다.

iOS Naver Map CustomMarker [1부]에서는 CustomMarker을 어떻게 구현하는가에 대해서 설명했다.

실제 이 생각 가지고 어떻게 코드로 구현했는지 살펴보겠다.

구현

1. 자료구조

enum MarkerType {
    case _static
    case human
    case information
}

/*
 Marker의 기본이 되는 데이터
 */
protocol MarKerProtocol {
    var type : MarkerType { get set }
    var id : Int { get set }
    var lat : Double { get set }
    var lng : Double { get set }
}


/*
 크기와 모양이 정해진 정적인 마커 데이터
 */
struct StaticMarker : MarKerProtocol {
    var type: MarkerType
    
    var id: Int
    
    var lat: Double
    
    var lng: Double
    
    
    let imgUrl : String
}


/*
 사용자를 표현하는 마커 데이터
 */
struct HumanMarker : MarKerProtocol {
    var type: MarkerType
    
    var id: Int
    
    var lat: Double
    
    var lng: Double
    
    let imgUrl : String
    
    let decorateColor : String
}


/*
 매물 수 등과 같은 정보를 표현하기 위한 마커 데이터
 */
struct InformationMarker : MarKerProtocol {
    var type: MarkerType
    
    var id: Int
    
    var lat: Double
    
    var lng: Double
    
    let imgUrl : String
    
    let docorateColor : String
    
    let count : Int
}

Marker을 표현하기 위해서는 기본적으로 lat, lng가 있어야 한다. 또한 Marker 탭했을때 DetailPage를 띄운다던가 이후에 작업이 있어야 하기 때문에 id도 필요하다. 나는 여러 Marker에 대해서 표현 할것이기 때문에 type도 정해줬다. 결과적으로 MarkerProtocol은 위의 코드와 같다.

2. HumanMarkerView

import Foundation
import UIKit
import SnapKit
import Then
import Kingfisher
import UIColor_Hex_Swift

class HumanMarkerView : UIView {
    
    lazy var imgView = UIImageView(frame: .init(x: 0, y: 0, width: 44, height: 44)).then {
        $0.clipsToBounds = true
        $0.layer.cornerRadius = 12
        $0.layer.borderWidth = 3
        $0.layer.borderColor = UIColor.clear.cgColor
    }
    
    lazy var decorateView = UIView(frame: .init(x: 44 / 2 - 10 / 2, y: 44 + 8, width: 10, height: 10)).then {
        $0.backgroundColor = .clear
        $0.layer.cornerRadius = 5
    }
    
    override var intrinsicContentSize: CGSize {
        let imgSize = 44
        let decorationHeight = 10 + 8
        return CGSize(width: imgSize, height: imgSize + decorationHeight)
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        layout()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    /*
     HumanMarkerView 설정
     */
    func configure(
        _ data : HumanMarker,
        _ completion : @escaping (UIImage?) -> Void) {
            /*
            Color 설정
            */
            imgView.layer.borderColor = UIColor(data.decorateColor).cgColor
            decorateView.backgroundColor = UIColor(data.decorateColor)
            let url = URL(string: data.imgUrl)
            let resource = ImageResource(downloadURL: url!)
            
            /*
             서버에서 가져오는 Image를 처리하는 부분
             */
            KingfisherManager.shared.retrieveImage(with: resource, options: nil, progressBlock: nil) { result in
                switch result {
                case .success(let value):
                    self.imgView.image = value.image
                    
                    /*
                     CustomMarkerView에 대해서 Snapshot을 하는 부분
                     */
                    let img = self.toImage()
                    
                    
                    completion(img)
                case .failure(let e):
                    print("HumanMarker Image Render Error : \(e.localizedDescription)")
                    completion(nil)
                }
            }
        }
    
    private func layout() {
        [
            imgView,
            decorateView,
        ].forEach {
            addSubview($0)
        }
    }
}

extension UIView {
    
    func toImage() -> UIImage {
        let renderer = UIGraphicsImageRenderer(bounds: bounds)
        return renderer.image { rendererContext in
            layer.render(in: rendererContext.cgContext)
        }
    }
}

Configure 함수에서는 HumanMarkerView을 설정한다.
이 함수에서는 서버에서 가져오는 Image를 처리하고 Snapshot을 찍어서 completion Delegate에게 전달하는 코드가 포함되어 있다.

3. InformationMarkerView

import Foundation
import UIKit
import Kingfisher

class InformationMarkerView : UIView {
    
    let imgView = UIImageView(frame: .init(x: 0, y: 0, width: 24, height: 24)).then {
        $0.clipsToBounds = true
        $0.layer.cornerRadius = 12
    }
    
    let informationLabel = UILabel(frame: .init(x: 24 + 8, y: 24 / 2 - 16 / 2, width: 16, height: 16)).then {
        $0.font = .systemFont(ofSize: 16)
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        attribute()
        layout()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func configure(
        _ data : InformationMarker,
        _ completin : @escaping (UIImage?) -> Void) {
            /*
             Color 설정
             */
            informationLabel.text = "\(data.count)"
            informationLabel.textColor = UIColor(data.docorateColor)
            self.layer.borderColor = UIColor(data.docorateColor).cgColor
            
            /*
             img 설정
             */
            let url = URL(string: data.imgUrl)
            let resource = ImageResource(downloadURL: url!)
            KingfisherManager.shared.retrieveImage(with: resource, options: nil, progressBlock: nil) { result in
                switch result {
                case .success(let value):
                    self.imgView.image = value.image
                    
                    let img = self.toImage()
                    completin(img)
                case .failure(let e):
                    print("Information Image Render Error : \(e.localizedDescription)")
                    completin(nil)
                }
            }
        }
    
    private func attribute() {
        self.layer.cornerRadius = 4
        self.layer.borderWidth = 2
        self.layer.borderColor = UIColor.clear.cgColor
    }
    
    private func layout() {
        [
            imgView,
            informationLabel,
        ].forEach {
            addSubview($0)
        }
    }
}

InformationMarkerView도 위의 HumanMarkerView와 동일하게 처리했다.

4. MainViewModel

import Foundation
import RxSwift
import RxCocoa

struct MainViewModel {
    
    
    var staticMarkerList : Driver<[StaticMarker]>! = nil
    
    
    var humanMarkerList : Driver<[HumanMarker]>! = nil
    
    var informationMarkerList : Driver<[InformationMarker]>! = nil
    
    init() {
        
        staticMarkerList = Driver.just([
            .init(type: ._static, id: 0, lat: 37.356136344215585, lng: 127.10234768837722, imgUrl: "https://cdn-icons-png.flaticon.com/512/3658/3658773.png"),
            .init(type: ._static, id: 1, lat: 37.3561477148818, lng: 127.10529454232739, imgUrl: "https://cdn-icons-png.flaticon.com/512/1250/1250683.png"),
            .init(type: ._static, id: 2, lat: 37.356284164825, lng: 127.10819848027683, imgUrl: "https://cdn-icons-png.flaticon.com/512/307/307325.png")
        ])
        
        humanMarkerList = Driver.just([
            .init(type: .human, id: 3, lat: 37.358455959863576, lng: 127.10274823138474, imgUrl: "https://cdn.pixabay.com/photo/2022/02/23/17/08/planets-7031048__480.jpg", decorateColor: "#3B7B0D"),
            .init(type: .human, id: 4, lat: 37.35854692389589, lng: 127.10692532546011, imgUrl: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRb4WbK-3RFO7_c7cP1fZ0AEK1sVBvEqwFNDx6emzQIm5wN49FKbxo3WECcpKrBeOXbmxM&usqp=CAU", decorateColor: "#D8E337"),
            .init(type: .human, id: 5, lat: 37.35842184838813, lng: 127.11020119662356, imgUrl: "https://upload.wikimedia.org/wikipedia/commons/b/b4/Lionel-Messi-Argentina-2022-FIFA-World-Cup_%28cropped%29.jpg", decorateColor: "#9113C3")
        ])
        
        
        informationMarkerList = Driver.just([
            .init(type: .information, id: 6, lat: 37.36184428899243, lng: 127.102276162583, imgUrl: "https://cdn-icons-png.flaticon.com/512/1515/1515636.png", docorateColor: "#3E7D47", count: 3),
            .init(type: .information, id: 7, lat: 37.36196935879643, lng: 127.10566647488639, imgUrl: "https://cdn-icons-png.flaticon.com/512/5050/5050019.png", docorateColor: "#6B26B0", count: 5),
            .init(type: .information, id: 8, lat: 37.36199209888029, lng: 127.10944302529919, imgUrl: "https://cdn-icons-png.flaticon.com/512/6570/6570907.png", docorateColor: "#2C1C77", count: 8),
        ])
    }
}

MainViewModel에서는 앞서 구현한 자료구조를 기반으로 ItemList 데이터를 가진다.

5. MainViewController

class MainViewController : UIViewController {
    let disposeBag = DisposeBag()
    
    let naverMap = NMFNaverMapView()
    
    var staticMarkerList : [NMFMarker] = []
    var humanMarkerList : [NMFMarker] = []
    var informationMarkerList : [NMFMarker] = []
    
    
    /*
     Marker버튼 탭에 대해서 정의
     */
    lazy var touchHandler =  { (overlay : NMFOverlay) -> Bool in
        let userInfo = overlay.userInfo
        let type = userInfo["type"] as! MarkerType
        switch type {
        case ._static:
            let data = userInfo["data"] as! StaticMarker
            print("Static Marker ID : \(data.id)")
        case .human:
            let data = userInfo["data"] as! HumanMarker
            print("Human Marker ID : \(data.id)")
        case .information:
            let data = userInfo["data"] as! InformationMarker
            print("Information Marker ID : \(data.id)")
        }
        return true
    }
    
    override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
        attribute()
        layout()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func bind(_ viewModel : MainViewModel) {
        
        viewModel.staticMarkerList
            .map {
                $0.map { [weak self] data in
                    guard let self = self else { return NMFMarker() }
                    let marker = NMFMarker()
                    marker.width = 24
                    marker.height = 24
                    marker.position = .init(lat: data.lat, lng: data.lng)
                    marker.touchHandler = self.touchHandler
                    marker.userInfo = [
                        "type" : data.type,
                        "data" : data
                    ]
                    
                    /*
                     서버에서 가져오는 이미지 처리하는 부분
                     */
                    let url = URL(string: data.imgUrl)
                    let resource = ImageResource(downloadURL: url!)
                    KingfisherManager.shared.retrieveImage(with: resource, options: nil, progressBlock: nil) { result in
                        switch result {
                        case.success(let value):
                            marker.iconImage = NMFOverlayImage(image: value.image)
                        case.failure(let e):
                            print("StaticMarker Image Render Error : \(e.localizedDescription)")
                        }
                    }
                    return marker
                }
            }
            .drive(onNext : { [weak self] in
                guard let self = self else { return }
                /*
                 맵에서 삭제
                 */
                self.staticMarkerList.forEach {
                    $0.mapView = nil
                }
                
                /*
                 변경된 값 Update
                 */
                self.staticMarkerList.removeAll()
                $0.forEach {
                    self.staticMarkerList.append($0)
                }
                
                /*
                 맵에 표시
                 */
                self.staticMarkerList.forEach {
                    if $0.mapView == nil {
                        $0.mapView = self.naverMap.mapView
                    }
                }
            })
            .disposed(by: disposeBag)
        
        
        
    
        viewModel.humanMarkerList
            .map {
                $0.map { [weak self] data in
                    guard let self = self else { return NMFMarker() }
                    let marker = NMFMarker()
                    marker.width = 44
                    marker.height = 64
                    marker.position = .init(lat: data.lat, lng: data.lng)
                    marker.touchHandler = self.touchHandler
                    marker.userInfo = [
                        "type" : data.type,
                        "data" : data
                    ]
                    
                    /*
                     SnapShot을 찍은 Image를 통해 Marker에 설정하는 부분
                     */
                    let customView = HumanMarkerView(frame: .init(x: 0, y: 0, width: 44, height: 64))
                    customView.configure(data) {
                        marker.iconImage = NMFOverlayImage(image: $0!)
                    }
                    return marker
                }
            }
            .drive(onNext : { [weak self] in
                guard let self = self else { return }
                /*
                 맵에서 삭제
                 */
                self.humanMarkerList.forEach {
                    $0.mapView = nil
                }
                
                /*
                 변경된 값 Update
                 */
                self.humanMarkerList.removeAll()
                $0.forEach {
                    self.humanMarkerList.append($0)
                }
                
                /*
                 맵에 표시
                 */
                self.humanMarkerList.forEach {
                    if $0.mapView == nil {
                        $0.mapView = self.naverMap.mapView
                    }
                }
            })
            .disposed(by: disposeBag)

        
        viewModel.informationMarkerList
            .map {
                $0.map { [weak self] data in
                    guard let self = self else { return NMFMarker() }
                    let marker = NMFMarker()
                    marker.position = .init(lat: data.lat, lng: data.lng)
                    marker.touchHandler = self.touchHandler
                    marker.userInfo = [
                        "type" : data.type,
                        "data" : data
                    ]
                    /*
                     
                     SnapShot을 찍은 Image를 통해 Marker에 설정하는 부분
                     */
                    let customView = InformationMarkerView(frame: .init(x: 0, y: 0, width: 64, height: 24))
                    customView.configure(data) {
                        marker.iconImage = NMFOverlayImage(image: $0!)
                    }
                    return marker
                }
            }
            .drive(onNext : { [weak self] in
                guard let self = self else { return }
                /*
                 맵에서 삭제
                 */
                self.informationMarkerList.forEach {
                    $0.mapView = nil
                }
                
                /*
                 변경된 값 Update
                 */
                self.informationMarkerList.removeAll()
                $0.forEach {
                    self.informationMarkerList.append($0)
                }
                
                /*
                 맵에 표시
                 */
                self.informationMarkerList.forEach {
                    if $0.mapView == nil {
                        $0.mapView = self.naverMap.mapView
                    }
                }
            })
            .disposed(by: disposeBag)
    }
    
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        
    }
    
    func setMarkerImage(_ marker : NMFMarker) {
        
    }
    
    private func attribute() {
        naverMap.mapView.touchDelegate = self
    }
    
    private func layout() {
        [
            naverMap
        ].forEach {
            view.addSubview($0)
        }
        
        naverMap.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
    }
}


extension MainViewController : NMFMapViewTouchDelegate {
    func mapView(_ mapView: NMFMapView, didTapMap latlng: NMGLatLng, point: CGPoint) {
        print("lat : \(latlng.lat) /// lng : \(latlng.lng)")
    }
}

MainViewController에서는 MainViewModel이 가지고 있는 Maker ItemList를 Binding해서 지도에 표시하는 작업을 해줬다. map 함수 부분에서는 marker 객체 생성 및 설정을 함으로서 MakerList 리스트를 리턴한다. (설정하는 부분에서 UIView를 Snapshot을 찍어 Marker Image에 설정하는 부분이 포함된다 주석을 참고해주시면 감사하겠다.) 이후에 binding하는 함수에서는 이후에 Marker 객체를 제어할 경우도 생각해서 객체를 업데이트 및 저장하는 코드도 작성해 봤다. 간단히 참고 바란다.

결과

초록색 : InforMationMarker (Icon 및 Count가 모두 다른게 특징)
파란색 : HumanMarker(Profile이 모두 다른게 특징)
빨간색 : StaticMarker


마무리

오늘은 CustomMarker을 네이버 지도에 어떻게 표시하는가에 대해서 코드로 작성을 해봤다.

그러나 코드를 보면서 이상한 점을 못느꼈는가?
첫째, 나는 처음에 HumanMakerView와 InformationMakerView를 만들때 View 배치를 AutoLayout으로 배치하지 않았다.
둘째, 나는 현재 정적으로 View의 크기를 정하고 있다. 이는 매물 수 (Label의 길이)의 수가 길어지면 정보를 표시하는데 제약이 생긴다.
물론 위와 같은 코드를 작성하는것도 Naver Map에서 CustomMarker를 구현하는 하나의 방법이기는 하다. 그러나 부족하다. 다음글에서는 이러한 문제를 어떻게 해결했는지에 대해서 작성하도록 하겠다. 문제 전체코드

profile
질문의 질이 답의 질을 결정한다.

0개의 댓글