지하철 1~8호선의 역사 내 공중화장실 데이터를 가져와 지도에 띄워줍니다
https://www.data.go.kr/data/15044453/fileData.do
데이터 분석
데이터 시트를 열어보면 아래 사진처럼 여러개의 데이터들이 존재합니다, 하지만 여기서 남녀공용화장실 여부라던지 관리기관명 같은 데이터들은 사용자한테 별로 유용한 정보가 아닙니다.
데이터 필터링
추가로 데이터를 분석해보면 145번째 행에 위치는 서울이지만 위도가 36인 outlier 가 존재합니다. 해당 데이터를 37로 수정해줍니다.
데이터를 이용하여 마커로 지도에 띄우기
다음으로는 데이터를 보여주기 위한 뷰를 만들어줍니다. 먼저 네이버 지도를 사용하기 위한 NMapsMap를 불러옵니다. 다음으로는 네이버 지도는 UIKit 인 UIView로 만들어졌기 때문에 SwiftUI에서 사용하려면 UIViewRepresentable를 이용하여 뷰를 만들어줍니다.
import SwiftUI
import NMapsMap
struct ContentView: View {
@EnvironmentObject var coordinator: Coordinator
var body: some View {
NaverMapView()
}
}
struct NaverMapView: UIViewRepresentable {
@EnvironmentObject var coordinator: Coordinator
func makeCoordinator() -> Coordinator {
return coordinator
}
func makeUIView(context: Context) -> NMFNaverMapView {
return coordinator.view
}
func updateUIView(_ uiView: NMFNaverMapView, context: Context) {
uiView.mapView.mapType = coordinator.mapType
}
}
클러스터링
이제 마커를 이용하여 사용자한테 화장실에 위치,정보를 알려주는 코드를 작성해보겠습니다. 아래 코드는 클러스터링 작업을 수행하여 마커를 표시하는 코드입니다.
func makeClusterer() {
let builder = NMCComplexBuilder<ItemKey>()
builder.minClusteringZoom = 9
builder.maxClusteringZoom = 16
builder.maxScreenDistance = 200
builder.clusterMarkerUpdater = self
builder.leafMarkerUpdater = self
builder.markerManager = MarkerManager()
self.clusterer = builder.build()
var keyTagMap = [ItemKey: ItemData]()
do {
if let path = Bundle.main.path(forResource: CSV_ASSET_NAME, ofType: "csv") {
let contents = try String(contentsOfFile: path, encoding: .utf8)
let lines = contents.components(separatedBy: .newlines)
for (i, line) in lines.enumerated() {
let split = line.components(separatedBy: ",")
guard split.count >= 30 else {
continue
}
let key = ItemKey(cases: i, position: NMGLatLng(lat: Double(split[21]) ?? 0.0, lng: Double(split[22]) ?? 0.0))
let itemData = ItemData(serialNumber: split[0],
category: split[1],
line: Int(split[2]) ?? 0,
toiletName: split[3],
roadAddress: split[4],
landLotAddress: split[5],
maleToiletCount: Int(split[7]) ?? 0,
maleUrinalCount: Int(split[8]) ?? 0,
maleDisabledToiletCount: Int(split[9]) ?? 0,
maleDisabledUrinalCount: Int(split[10]) ?? 0,
maleChildrenToiletCount: Int(split[11]) ?? 0,
maleChildrenUrinalCount: Int(split[12]) ?? 0,
femaleToiletCount: Int(split[13]) ?? 0,
femaleDisabledToiletCount: Int(split[14]) ?? 0,
femaleChildrenToiletCount: Int(split[15]) ?? 0,
managementAgency: split[16],
phoneNumber: split[17],
openHours: split[18],
toiletDetailedLocation: split[19],
toiletDetailedLocationInGate: split[20],
latitude: Double(split[21]) ?? 0.0,
longitude: Double(split[22]) ?? 0.0,
toiletInstallationPlaceType: split[24],
emergencyBellInstallation: (split[26] == "Y"),
entranceCCTVInstallation: (split[27] == "Y"),
diaperChangingTableInstallationMaleToilet: (split[28] == "Y"),
diaperChangingTableInstallationMaleDisabledToilet: (split[29] == "Y"),
diaperChangingTableInstallationFemaleToilet: (split[30] == "Y"),
diaperChangingTableInstallationFemaleDisabledToilet: (split[31] == "Y"),
remodelingYearMonth: split[32],
dataStandardDate: split[33])
print(itemData.serialNumber)
keyTagMap[key] = itemData
}
}
} catch {
}
self.clusterer?.addAll(keyTagMap)
self.clusterer?.mapView = self.view.mapView
}
로컬 파일에 데이터 불러오기
위에 코드에서 중요한 것은 아래 코드입니다.
- 현재 로컬에 저장되어 있는 파일의 이름을 forResource 에 입력하고 ofType 으로 파일의 확장자를 입력합니다.
- 다음으로 파일을 String으로 담아 새로운 줄을 기준으로 데이터를 lines에 나눠 담습니다. lines는 행 전체를 하나의 String
- 담겨진 lines를 enumerated()를 사용하여 for 문으로 돌려 line(String)의 각 요소들을 ","를 기준으로 split([String])으로 만들어주며 만약 split의 개수가 30개 미만이라면 잘못된 데이터이기 때문에 생략합니다.
if let path = Bundle.main.path(forResource: CSV_ASSET_NAME, ofType: "csv") {
let contents = try String(contentsOfFile: path, encoding: .utf8)
let lines = contents.components(separatedBy: .newlines)
for (i, line) in lines.enumerated() {
let split = line.components(separatedBy: ",")
guard split.count >= 30 else {
continue
}
조금 더 나아가 마커를 탭했을 때 sheet를 통해서 화장실의 상세 정보를 보여주는 기능을 추가할 수 있습니다.
마커 탭했을 때 동작
마커를 탭 했을 때 사용자한테 보여주기 위한 코드를 touchHandler 안에 담아줍니다.
- moveCamera 메서드를 사용하여 탭한 위치로 카메라를 이동시킵니다
- 탭한 위치의 정보를 tappedMarkerInfo와 tappedMarkerTag로 담아줍니다
- 탭하였음을 ContentView에 알려 sheet를 보여주게 합니다.
func updateLeafMarker(_ info: NMCLeafMarkerInfo, _ marker: NMFMarker) {
marker.captionText = "\(info.position)"
marker.iconImage = NMF_MARKER_IMAGE_GREEN
marker.touchHandler = { [weak self] (overlay: NMFOverlay) -> Bool in
let cameraUpdate = NMFCameraUpdate(scrollTo: info.position)
self?.view.mapView.moveCamera(cameraUpdate)
self?.tappedMarkerInfo = info
self?.tappedMarkerTag = info.tag as? ItemData
self?.markerTapped = true
return true
}
}
하지만 Custom Binding을 이용하면 코드를 다음과 같이 한줄 줄일 수 있습니다.
func updateLeafMarker(_ info: NMCLeafMarkerInfo, _ marker: NMFMarker) {
marker.captionText = "\(info.position)"
marker.iconImage = NMF_MARKER_IMAGE_GREEN
marker.touchHandler = { [weak self] (overlay: NMFOverlay) -> Bool in
let cameraUpdate = NMFCameraUpdate(scrollTo: info.position)
self?.view.mapView.moveCamera(cameraUpdate)
self?.tappedMarkerInfo = info
self?.tappedMarkerTag = info.tag as? ItemData
return true
}
}
대신 코드가 다음과 같이 수정됩니다, 간단하게 설명하면 tappedMarkerTag의 값이 nil이 아니게 되면 트리거 되고 sheet를 없애게 되면 tappedMarkerTag의 값도 nil로 만들어줍니다.
extension Binding where Value == Bool {
init<T>(value: Binding<T?>) {
self.init {
value.wrappedValue != nil
} set: { newValue in
if !newValue {
value.wrappedValue = nil
}
}
}
}
struct ContentView: View {
@EnvironmentObject var coordinator: Coordinator
var body: some View {
NaverMapView()
.ignoresSafeArea()
.sheet(isPresented: Binding(value: $coordinator.tappedMarkerTag)/*$coordinator.markerTapped*/, content: {
ToiletInfoView()
.presentationDetents([.fraction(0.35), .large])
.presentationBackgroundInteraction(.enabled(upThrough: .fraction(0.35)))
})
}
}
다음은 sheet의 content로 들어갈 뷰로 화장실의 상세 정보를 나타내는 뷰입니다.
import SwiftUI
struct ToiletInfoView: View {
@EnvironmentObject var coordinator: Coordinator
var body: some View {
ScrollView {
VStack {
if let tag = coordinator.tappedMarkerTag {
HStackTextView(text: "\(tag.line)호선")
HStackTextView(text: "화장실이름: " + tag.toiletName)
HStackTextView(text: "화장실 상세 위치: " + tag.toiletDetailedLocation)
HStackTextView(text: "게이트 내외부: " + tag.toiletDetailedLocationInGate)
HStackTextView(text: "운영시간: " + tag.openHours)
HStackPhoneCallVeiw(text: "전화번호: ", phoneNumber: tag.phoneNumber)
HStackTextView(text: "리모델링: " + tag.remodelingYearMonth)
HStackTextView(text: "기저귀교환대설치유무-남자화장실: " + transformXY(value: tag.diaperChangingTableInstallationMaleToilet))
HStackTextView(text: "기저귀교환대설치유무-여자화장실: " + transformXY(value: tag.diaperChangingTableInstallationFemaleToilet))
HStackTextView(text: "기저귀교환대설치유무-남자장애인화장실: " + transformXY(value: tag.diaperChangingTableInstallationMaleDisabledToilet))
HStackTextView(text: "기저귀교환대설치유무-여자장인화장실: " + transformXY(value: tag.diaperChangingTableInstallationFemaleDisabledToilet))
}
}
}
.padding()
}
func transformXY(value: Bool) -> String {
return value ? "있음" : "없음"
}
}
extension ToiletInfoView {
func HStackTextView(text: String) -> some View {
HStack {
Text(text)
Spacer()
}
.padding(3)
}
func HStackPhoneCallVeiw(text: String , phoneNumber: String) -> some View {
HStack {
Text(text+phoneNumber)
Button(action: {
callNumber(phoneNumber: phoneNumber)
}, label: {
Image(systemName: "phone.fill")
.foregroundStyle(Color.green)
})
Spacer()
}
}
private func callNumber(phoneNumber:String) {
if let phoneCallURL = URL(string: "tel://\(phoneNumber)") {
let application:UIApplication = UIApplication.shared
if (application.canOpenURL(phoneCallURL)) {
application.open(phoneCallURL, options: [:], completionHandler: nil)
}
}
}
}
다음은 위에서 만든 뷰를 담아주는 sheet입니다.
struct ContentView: View {
@EnvironmentObject var coordinator: Coordinator
var body: some View {
NaverMapView()
.ignoresSafeArea()
.sheet(isPresented: $coordinator.markerTapped, content: {
ToiletInfoView()
.presentationDetents([.fraction(0.35), .large])
.presentationBackgroundInteraction(.enabled(upThrough: .fraction(0.35)))
})
}
}
길찾기 기능 추가
이제 길을 찾을 수 있도록 네비게이션을 실행하는 코드를 작성합니다. 아래 블로그를 참조하였습니다.
https://develop-const.tistory.com/33
func NaverMap(lat: Double, lng: Double) {
// URL Scheme을 사용하여 네이버맵 앱을 열고 자동차 경로를 생성합니다.
guard let url = URL(string: "nmap://route/car?dlat=\(lat)&dlng=\(lng)&appname=kr.co.kepco.ElectricCar") else { return }
// 앱 스토어 URL을 설정합니다.
guard let appStoreURL = URL(string: "http://itunes.apple.com/app/id311867728?mt=8") else { return }
if UIApplication.shared.canOpenURL(url) {
// 네이버맵 앱이 설치되어 있는 경우 앱을 엽니다.
UIApplication.shared.open(url)
} else {
// 네이버맵 앱이 설치되어 있지 않은 경우 앱 스토어로 이동합니다.
UIApplication.shared.open(appStoreURL)
}
}
func KaKaoMap(lat: Double, lng: Double) {
// URL Scheme을 사용하여 kakaomap 앱 열고 경로 생성합니다
guard let url = URL(string: "kakaomap://route?ep=\(lat),\(lng)&by=CAR") else { return }
// Kakaomap 앱의 App Store URL 생성
guard let appStoreUrl = URL(string: "itms-apps://itunes.apple.com/app/id304608425") else { return }
let urlString = "kakaomap://open"
// Kakaomap 앱이 설치되어 있는지 확인하고 URL 열기
if let appUrl = URL(string: urlString) {
if UIApplication.shared.canOpenURL(appUrl) {
UIApplication.shared.open(url)
} else {
// Kakaomap 앱이 설치되어 있지 않은 경우 App Store URL 열기
UIApplication.shared.open(appStoreUrl)
}
}
}
func TMap(lat:Double, lng:Double) {
// URL Scheme을 사용하여 티맵 앱을 열고 자동차 경로를 생성합니다.
let urlStr = "tmap://route?rGoName=목적지&rGoX=\(lng)&rGoY=\(lat)"
// URL 문자열을 인코딩하여 올바른 형식으로 변환합니다.
guard let encodedStr = urlStr.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return }
// 인코딩된 URL 문자열을 URL 객체로 변환합니다.
guard let url = URL(string: encodedStr) else { return }
// TMap 앱이 설치되어 있는지 확인합니다.
if UIApplication.shared.canOpenURL(url) {
// TMap 앱을 엽니다.
UIApplication.shared.open(url)
} else {
// TMap 앱이 설치되어 있지 않은 경우 앱 스토어로 이동합니다.
guard let appStoreURL = URL(string: "http://itunes.apple.com/app/id431589174") else { return }
UIApplication.shared.open(appStoreURL)
}
}
이제 위에 함수를 호출하는 버튼들을 만들어줍니다.
HStack {
Text("길찾기")
Button(action: {
NaverMap(lat: coordinator.tappedMarkerTag?.latitude ?? 0.0, lng: coordinator.tappedMarkerTag?.longitude ?? 0.0)
}, label: {
Image("navermap")
.resizable()
.frame(width: 30, height: 30)
})
Button {
KaKaoMap(lat: coordinator.tappedMarkerTag?.latitude ?? 0.0, lng: coordinator.tappedMarkerTag?.longitude ?? 0.0)
} label: {
Image("kakaomap")
.resizable()
.frame(width: 30, height: 30)
}
Button {
TMap(lat: coordinator.tappedMarkerTag?.latitude ?? 0.0, lng: coordinator.tappedMarkerTag?.longitude ?? 0.0)
} label: {
Image("tmap")
.resizable()
.frame(width: 30, height: 30)
}
Spacer()
}
그러면 이제 다음과 같이 작동합니다.
https://github.com/iOS-Developer-KR/WhereIsTheRestroom
'SwiftUI' 카테고리의 다른 글
async let 으로 비동기 작업들을 동시에 수행하기 (1) (0) | 2024.05.17 |
---|---|
Chat GPT API 사용하기 (3) | 2024.05.15 |
custom error (0) | 2024.05.11 |
custom binding (0) | 2024.05.10 |
지도에서 내 현재 위치 가져오기 (네이버 지도) (0) | 2024.05.09 |