API로부터 공공시설에 관한 위치와 정보를 받을 수 있으면 더욱 좋기 때문에 그것을 할 것이다.
첫번째로 할 것은 API로부터 받아오기 위한 DTO (Data Transfer Object) 를 만드는 것이다.
- DTO란 Data Transfer Object의 약자로, 계층 간 데이터 전송을 위해 도메인 모델 대신 사용되는 객체이다
- EndPoint란 데이터를 가져오기 위한 주소
import Foundation
import CoreLocation
struct Restroom: Decodable {
let id: Int
let name: String
let street: String
let city: String
let state: String
let comment: String?
let accessible: Bool
let unisex: Bool
let changingTable: Bool // changing_Table 이지만 자동으로 _없이 매핑된다
let latitude: Double
let longitude: Double
var address: String {
"\(street), \(city) \(state)"
}
var coordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
}
enum CodingKeys: String, CodingKey, Decodable {
case id
case name
case street
case city
case state
case comment
case accessible
case unisex
case changingTable = "changing_table"
case latitude
case longitude
}
}
DTO 을 만들었으면 이제 데이터를 가져오는 작업을 해야한다.
import Foundation
struct RestrommClient {
private enum RestroomClientError: Error {
case invalidResponse
case networkError(Error)
}
func fetchRestrooms(url: URL) async throws -> [Restroom] {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse, // 응답이 유효한지 아닌지
httpResponse.statusCode == 200 else {// 화장실을 만드는 것이 아니기 때문에 항상 성공(200)
throw RestroomClientError.invalidResponse
}
do {
return try JSONDecoder().decode([Restroom].self, from: data)
} catch {
throw RestroomClientError.networkError(error)
}
}
}
enum RestroomClientError: Error 를 사용하여 특정 에러를 미리 지정하여 사용할 수 있게 열거형으로 정의한다
fectRestroom(url: URL) 함수를 만들어 화장실에 대한 정보들을 가져올 수 있게 한다.
- async인 이유는 네트워크를 통해 값을 가져오기 위해선 비동기적으로 값을 가져와야 하기 때문
let (data, response) = try await URLSession.shared.data(from: url): url로부터 받은 데이터와 응답을 반환하여 해당 값을 이용
만든 RestroomClient 를 사용한다. 해당 Client는 모든 View에서 사용할 수 있도록 Environment Value 로 만든다.
아래 처럼 만들수도 있겠지만 Environment로 만드는 것이 유지보수, 테스트하는데 쉽다.
import SwiftUI
struct ContentView: View {
let restroomClient = RestrommClient()
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}
}
#Preview {
ContentView()
}
하단이 EnvironmentValues extension을 사용하여 만든 httpClient와 Environment를 이용해서 만든 ContentView
import Foundation
import SwiftUI
private struct HTTPClientKey: EnvironmentKey {
static var defaultValue = RestroomClient()
}
extension EnvironmentValues {
var httpClient: RestroomClient {
get { self[HTTPClientKey.self] }
set { self[HTTPClientKey.self] = newValue }
}
}
EnvironmentValues를 extension으로 값을 확장하여 값을 지정하면 아래에 .environment(\.httpClient, 처럼 사용할 수 있다.
- get 값을 가져올때는 HTTPClientKey 값을 가져오고 set 으로 값을 지정할때는 HTTPClientKey 값을 newValue로 지정한다.
import SwiftUI
struct ContentView: View {
@Environment(\.httpClient) private var restroomClient
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}
}
#Preview {
ContentView()
// httpClient는 keyPath, RestroomClient()는 keyPath의 value
.environment(\.httpClient, RestroomClient())
}
.environment(\.httpClient, RestroomClient()): 원리는 httpClient를 EnvironmentValues에 변수로 포함시켰기때문에 가능
- key의 타입과 value의 타입은 같아야한다
아래 사진처럼 내 주변에 있는 공공기관 화장실 (이번 포스팅에서 다루는 내용은 미국사 API다)
API 링크, 엔드포인트 주소를 만든다.
import Foundation
struct Constants {
struct Urls {
static func restroomsByLocation(latitude: Double, longitude: Double) -> URL {
return URL(string: "https://www.refugerestrooms.org/api/v1/restrooms/by_location?lat=\(latitude)&lng=\(longitude)")!
}
}
}
다음으로는 만든 주소링크를 사용하여 화장실 값들을 가져온다
import SwiftUI
import MapKit
struct ContentView: View {
@Environment(\.httpClient) private var restroomClient
@State private var locationManager = LocationManager.shared
@State private var restrooms: [Restroom] = []
private func loadRestrooms() async {
guard let region = locationManager.region else { return }
let coordinate = region.center
do {
restrooms = try await restroomClient.fetchRestrooms(url: Constants.Urls.restroomsByLocation(latitude: coordinate.latitude, longitude: coordinate.longitude))
} catch {
print(error.localizedDescription)
}
}
var body: some View {
ZStack {
Map {
ForEach(restrooms) { restroom in
Marker(restroom.name, coordinate: restroom.coordinate)
}
UserAnnotation()
}
}.task(id: locationManager.region) { // region이 바뀌게 되면 실행된다.
await loadRestrooms()
}
}
}
#Preview {
ContentView()
// httpClient는 keyPath, RestroomClient()는 keyPath의 value
.environment(\.httpClient, RestroomClient())
}
다음으로 할 것은 매우 중요하다, ContentView에 특정 값이 변할때마다 loadRestrooms 함수가 매번 실행되어 불필요한 작업을 수행하게 된다.
Stub은 테스트 중에 만들어진 호출에 미리 준비된 답변을 제공하며 일반적으로 테스트를 위해 프로그래밍된 것 외에는 전혀 응답하지 않습니다
Mock 은 예상되는 기대값으로 미리 프로그래밍 객체입니다.
이제 반복되는 불필요한 서버에 요청을 없애기 위해서 Stub을 만든다.
우선 다음처럼 API로 받아온 예시 데이터를 restrooms.json이라는 파일을 만들어서 넣는다.
[
{
"id": 48724,
"name": "Happy Lemon",
"street": "10963 N Wolfe Road",
"city": "Cupertino ",
"state": "California",
"accessible": true,
"unisex": false,
"directions": "",
"comment": "for paying customers, ask for key at register. gender neutral single stall",
"latitude": 37.3362519,
"longitude": -122.0152063,
"created_at": "2019-04-01T02:48:48.783Z",
"updated_at": "2019-04-01T02:48:48.882Z",
"downvote": 0,
"upvote": 0,
"country": "US",
"changing_table": false,
"edit_id": 48724,
"approved": true,
"distance": 0.34763470708584443,
"bearing": "261.600082989893"
},
{
위에 데이터를 가져올 수 있는 PreviewData를 만든다.
struct PreviewData {
static func load<T: Decodable>(resourceName: String) -> T {
guard let path = Bundle.main.path(forResource: resourceName, ofType: "json") else {
fatalError("Resource \(resourceName) does not exists.")
}
let data = try! Data(contentsOf: URL(filePath: path))
return try! JSONDecoder().decode(T.self, from: data)
}
}
가져올 수 있는 함수가 포함된 PreviewData를 이용하여 화장실들의 정보를 가져오는 함수를 만든다.
import Foundation
struct MockRestroomClient: HTTPClient {
// HTTPClient 프로토콜을 준수하는 mock restroom client를 만들었다
func fetchRestrooms(url: URL) async throws -> [Restroom] {
return PreviewData.load(resourceName: "restrooms")
}
}
다음으로는 ContentView에 Preview 부분을 다음과 같이 수정하여 Preview를 다룰 경우 같은 API를 불필요하게 호출하는 것을 막는다.
#Preview {
ContentView()
// httpClient는 keyPath, RestroomClient()는 keyPath의 value
.environment(\.httpClient, MockRestroomClient())
}
다음으로는 화장실 이모티콘을 사용하여 Mark 대신 Anotation으로 꾸미고 클릭했을때 애니메이션을 추가하였다.
import SwiftUI
import MapKit
struct ContentView: View {
@Environment(\.httpClient) private var restroomClient
@State private var locationManager = LocationManager.shared
@State private var restrooms: [Restroom] = []
@State private var selectedRestroom: Restroom?
private func loadRestrooms() async {
guard let region = locationManager.region else { return }
let coordinate = region.center
do {
restrooms = try await restroomClient.fetchRestrooms(url: Constants.Urls.restroomsByLocation(latitude: coordinate.latitude, longitude: coordinate.longitude))
} catch {
print(error.localizedDescription)
}
}
var body: some View {
ZStack {
Map {
ForEach(restrooms) { restroom in
Annotation(restroom.name, coordinate: restroom.coordinate) {
Text("🚻")
.scaleEffect(selectedRestroom == restroom ? 2.0 : 1.0)
.font(.title)
.onTapGesture {
withAnimation {
selectedRestroom = restroom
}
}
.animation(.spring(duration: 0.25), value: selectedRestroom)
}
}
UserAnnotation()
}
}.task(id: locationManager.region) { // region이 바뀌게 되면 실행된다.
await loadRestrooms()
}
}
}
#Preview {
ContentView()
// httpClient는 keyPath, RestroomClient()는 keyPath의 value
.environment(\.httpClient, MockRestroomClient())
}
다음으로는 바뀌는 위치에 대해서 새로운 정보들을 받을 수 있게 만드는 것이다.
- 좌측 상단에 버튼을 클릭하면 새로고침을 수행하는 기능을 추가
import SwiftUI
import MapKit
struct ContentView: View {
@Environment(\.httpClient) private var restroomClient
@State private var locationManager = LocationManager.shared
@State private var restrooms: [Restroom] = []
@State private var selectedRestroom: Restroom?
@State private var visibleRegion: MKCoordinateRegion?
// 카메라의 시점을 결정하는 변수다
@State private var position: MapCameraPosition = .userLocation(fallback: .automatic)
private func loadRestrooms() async {
guard let region = visibleRegion else { return }
let coordinate = region.center
do {
restrooms = try await restroomClient.fetchRestrooms(url: Constants.Urls.restroomsByLocation(latitude: coordinate.latitude, longitude: coordinate.longitude))
} catch {
print(error.localizedDescription)
}
}
var body: some View {
ZStack {
Map(position: $position) { // 카메라의 시점을 결정
ForEach(restrooms) { restroom in
Annotation(restroom.name, coordinate: restroom.coordinate) {
Text("🚻")
.scaleEffect(selectedRestroom == restroom ? 2.0 : 1.0)
.font(.title)
.onTapGesture {
withAnimation {
selectedRestroom = restroom
}
}
.animation(.spring(duration: 0.25), value: selectedRestroom)
}
}
UserAnnotation()
}
}.task(id: locationManager.region) { // region이 바뀌게 되면 실행된다.
await loadRestrooms()
}
.onMapCameraChange({ context in
visibleRegion = context.region
})
.overlay(alignment: .topLeading) {
Button {
Task {
await loadRestrooms()
}
} label: {
Image(systemName: "arrow.clockwise.circle.fill")
.font(.largeTitle)
.foregroundStyle(.white, .blue) // 위에 투명으로 뚫려 있는것을 막아준다
}
}
}
}
#Preview {
ContentView()
// httpClient는 keyPath, RestroomClient()는 keyPath의 value
.environment(\.httpClient, RestroomClient())
}
MapCameraPosition: 지도에서 카메라의 시선이 어디로 가야하는지를 지정한다.
다음으로는 선택한 화장실의 정보를 확인하기 위한 기능을 추가한다.
import SwiftUI
struct RestroomDetailView: View {
let restroom: Restroom
var body: some View {
VStack(alignment: .leading, content: {
Text(restroom.name)
.font(.title3)
Text(restroom.address)
if let comment = restroom.comment { // 만약 comment가 있다면
Text(comment)
.font(.caption)
}
})
}
}
#Preview {
let restrooms: [Restroom] = PreviewData.load(resourceName: "restrooms")
return RestroomDetailView(restroom: restrooms.first!)
}
//
// ContentView.swift
// RestroomFinder
//
// Created by Mohammad Azam on 8/25/23.
//
import SwiftUI
import MapKit
struct ContentView: View {
@Environment(\.httpClient) private var restroomClient
@State private var locationManager = LocationManager.shared
@State private var restrooms: [Restroom] = []
@State private var selectedRestroom: Restroom?
@State private var visibleRegion: MKCoordinateRegion?
// 카메라의 시점을 결정하는 변수다
@State private var position: MapCameraPosition = .userLocation(fallback: .automatic)
private func loadRestrooms() async {
guard let region = visibleRegion else { return }
let coordinate = region.center
do {
restrooms = try await restroomClient.fetchRestrooms(url: Constants.Urls.restroomsByLocation(latitude: coordinate.latitude, longitude: coordinate.longitude))
} catch {
print(error.localizedDescription)
}
}
var body: some View {
ZStack {
Map(position: $position) { // 카메라의 시점을 결정
ForEach(restrooms) { restroom in
Annotation(restroom.name, coordinate: restroom.coordinate) {
Text("🚻")
.scaleEffect(selectedRestroom == restroom ? 2.0 : 1.0)
.font(.title)
.onTapGesture {
withAnimation {
selectedRestroom = restroom
}
}
.animation(.spring(duration: 0.25), value: selectedRestroom)
}
}
UserAnnotation()
}
}.task(id: locationManager.region) { // region이 바뀌게 되면 실행된다.
print("region changed")
if let region = locationManager.region {
visibleRegion = region
await loadRestrooms()
}
}
.onMapCameraChange({ context in
visibleRegion = context.region
})
.sheet(item: $selectedRestroom, content: { restroom in
RestroomDetailView(restroom: restroom)
.presentationDetents([.fraction(0.25)])
})
.overlay(alignment: .topLeading) {
Button {
Task {
await loadRestrooms()
}
} label: {
Image(systemName: "arrow.clockwise.circle.fill")
.font(.largeTitle)
.foregroundStyle(.white, .blue) // 위에 투명으로 뚫려 있는것을 막아준다
}
}
}
}
#Preview {
ContentView()
// httpClient는 keyPath, RestroomClient()는 keyPath의 value
.environment(\.httpClient, RestroomClient())
}
다음은 시설에 대한 구체적인 정보를 넣는 것이다(장애인 사용 가능 여부, 남여공용 여부, 기저귀 갈 수 있는지 여부)
import Foundation
import SwiftUI
struct AmenitiesView: View {
let restroom: Restroom
var body: some View {
HStack(spacing: 12) {
AmenityView(symbol: "♿️", isEnabled: restroom.accessible)
AmenityView(symbol: "🚻", isEnabled: restroom.unisex)
AmenityView(symbol: "🚼", isEnabled: restroom.changingTable)
}
}
}
struct AmenityView: View {
let symbol: String
let isEnabled: Bool
var body: some View {
if isEnabled {
Text(symbol)
}
}
}
struct RestroomClient: HTTPClient {
private enum RestroomClientError: Error {
case invalidResponse
case networkError(Error)
}
func fetchRestrooms(url: URL) async throws -> [Restroom] {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {// 화장실을 만드는 것이 아니기 때문에 항상 성공(200)
throw RestroomClientError.invalidResponse
}
do {
return try JSONDecoder().decode([Restroom].self, from: data)
} catch {
throw RestroomClientError.networkError(error)
}
}
}
import SwiftUI
struct RestroomDetailView: View {
let restroom: Restroom
var body: some View {
VStack(alignment: .leading) {
Text(restroom.name)
.font(.title3)
Text(restroom.address)
if let comment = restroom.comment {
Text(comment)
.font(.caption)
}
AmenitiesView(restroom: restroom)
}.frame(maxWidth: .infinity, alignment: .leading)
}
}
#Preview {
let restrooms: [Restroom] = PreviewData.load(resourceName: "restrooms")
return RestroomDetailView(restroom: restrooms[6])
}
해당 화장실에 가는 경로를 나타내는 작업이다.
extension Restroom {
var mapItem: MKMapItem {
var addressDictionary: [String: Any] = [
CNPostalAddressStreetKey: self.street,
CNPostalAddressCityKey: self.city,
CNPostalAddressStateKey: self.state
]
let placemark = MKPlacemark(coordinate: coordinate, addressDictionary: addressDictionary)
let mapItem = MKMapItem(placemark: placemark)
mapItem.name = name
return mapItem
}
}
import SwiftUI
struct RestroomDetailView: View {
let restroom: Restroom
var body: some View {
VStack(alignment: .leading) {
Text(restroom.name)
.font(.title3)
Text(restroom.address)
if let comment = restroom.comment {
Text(comment)
.font(.caption)
}
AmenitiesView(restroom: restroom)
ActionButtons(mapItem: restroom.mapItem)
}.frame(maxWidth: .infinity, alignment: .leading)
}
}
#Preview {
let restrooms: [Restroom] = PreviewData.load(resourceName: "restrooms")
return RestroomDetailView(restroom: restrooms[6])
}
'swiftUI' 카테고리의 다른 글
MVVM 디자인 패턴으로 영화 API 앱 만들기 (0) | 2023.12.15 |
---|---|
SwiftUI - JWT 를 사용하여 서버와 연결하기 (0) | 2023.11.10 |
SwiftUI - MapKit을 활용한 지도 앱 만들기 (0) | 2023.10.23 |
SwiftUI - App principles (기초) (0) | 2023.10.12 |
SwiftUI - Observable macro (1) | 2023.10.11 |