포스트

MapKit (3)

Alert 만들기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct AlertItem: Identifiable {
    let id = UUID()
    let title: Text
    let message: Text
    let dismissButton: Alert.Button
}

struct AlertContext {
    
    // MARK: - MapView Errors
    static let unableToGetLocations = AlertItem(title: Text("Locations Error"),
                                                message: Text("Unable to retrieve locations at this time.\nPlease try again."),
                                                dismissButton: .default(Text("OK"))
    )
}

Alert를 체계적으로 관리하기 위해 위와같이 구성을 해준다.

그리고 다음과 같이 ListView에 적용을 해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@State private var alertItem: AlertItem?

.alert(item: $alertItem, content: { alertItem in
    Alert(title: alertItem.title, message: alertItem.message, dismissButton: alertItem.dismissButton)
})
.onAppear {
    CloudKitManager.getLocation { result in
        switch result {
            case .success(let locations):
            print(locations)
        case .failure(let error):
            alertItem = AlertContext.unableToGetLocations
        }
    }
}

하지만 이 방법도 Deprecated 된 상태, 추후 수정 예정

에러가 잘 발생하는지 확인해보자.

let query = CKQuery(recordType: "ddg", predicate: NSPredicate(value: true)) 여기에서 recordType의 String을 틀리게 해본다.

simulator_screenshot_B7AB47BD-531F-4B63-AC5A-8F8BAF5B3098

에러가 잘 뜨는걸 알 수 있다.

MVVM Pattern 적용하기

크게 언급할 부분은 없어보이고

MapView에서 @State로 선언했던 변수들을 ViewModel에 옮기고 @Published로 바꿔주었다.

그리고 onappear에 있던 호출도 옮겨주었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
final class LocationMapViewModel: ObservableObject {
    
    @Published var region = MKCoordinateRegion(
        center: CLLocationCoordinate2D(latitude: 37.331516, longitude: -121.891054),
        span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01))
    
    @Published var alertItem: AlertItem?
    @Published var locations: [DDGLocation] = []
    
    func getLocations() {
        CloudKitManager.getLocation { [self] result in
            switch result {
                case .success(let locations):
                self.locations = locations
            case .failure(_):
                alertItem = AlertContext.unableToGetLocations
            }
        }
    }    
}

struct LocationMapView: View {
    
    @StateObject private var viewModel = LocationMapViewModel()
    
    var body: some View {
        ZStack {
            Map(coordinateRegion: $viewModel.region)
                .ignoresSafeArea()
            
            VStack {
                LogoView()
                    .shadow(radius: 10)
                
                Spacer()
            }
        }
        .alert(item: $viewModel.alertItem, content: { alertItem in
            Alert(title: alertItem.title, message: alertItem.message, dismissButton: alertItem.dismissButton)
        })
        .onAppear {
            viewModel.getLocations()
        }
    }
}

그리고 View에 적용을 해준다.

지금은 아직 의존성 주입은 하지 않은 상태.

Mapkit 사용하기

이전글에서는 그냥 import하고 Map을 통해 지도를 가져온것 밖에 하지 않아서 사용했다고 하기도 뭐한 수준이었다.

물론 예전글에서 언급을 해본적이 있기에, 크게 어렵지는 않을것 같다.

물론 버전의 차이가 있어서 이후에 별도의 새로운글로 version update에 따른 methods 최신화는 그때 일괄적으로 처리하는걸로…

MapKit

CoreLocation

우선 새롭게 LocationManager를 만들어 주었다.

1
2
3
4
5
final class LocationManager: ObservableObject {
    
    @Published var locations: [DDGLocation] = []
        
}

현재는 기본틀만 구현해둔상태

의존성 주입

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct DubDubGrubApp: App {
    
    let locationManager = LocationManager() // new
    
    var body: some Scene {
        WindowGroup {
            AppTabView()
                .environmentObject(locationManager) // new
        }
    }
}

struct LocationMapView: View {
    
    @EnvironmentObject private var locationManager: LocationManager
    @StateObject private var viewModel = LocationMapViewModel()

    // 생략
}

여기서 LocationManager만 의존성 주입을하고 ViewModel에 대해선 인스턴스를 생성했다.

그이유는 ViewModel과 locationManager가 앱에서 어느 범위 안에서 쓰이냐를 생각하면 된다.

  • 의존성 주입:
    • 앱 전역으로 공유되는 리소스일때
  • Instance:
    • 특정 뷰에 국한된 상태
  • 정리:
    • 의존성 주입 여부는 객체의 사용 범위와 재사용 필요성에 따라 결정된다.

ViewModel에서 만들었던 locations는 위에서 locationManager를 통해 관리하므로 그에 맞게 ViewModel의 코드에도 변화를 준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func getLocations(for locationManager: LocationManager) { // modfiied
    CloudKitManager.getLocation { [self] result in
        switch result {
            case .success(let locations):
            locationManager.locations = locations // modified
        case .failure(_):
            alertItem = AlertContext.unableToGetLocations
        }
    }
}

// mapview
.onAppear {
    viewModel.getLocations(for: locationManager)
}

기존에는 VM에 있던 Locations에 값을 넣어주었다면 이제는 locationManager를 파라미터로 받아서 값을 넣는다.

MapView 수정

1
2
3
4
5
6
7
8
9
10
11
struct DDGLocation: Identifiable { // modified
    let id: CKRecord.ID // modified
    //생략
    init(record: CKRecord) {
        id  = record.recordID // modified
    //생략
    }
}
Map(coordinateRegion: $viewModel.region, annotationItems: locationManager.locations) { location in
    MapPin(coordinate: location.location.coordinate, tint: .brandPrimary)
}

simulator_screenshot_9DB8436D-5AD4-43AC-B895-F45AD0767761simulator_screenshot_395529C0-BB34-4CB6-85F2-3CDBEBFE372D

MapPin, MapMarker를 적용 한 상태를 위 사진으로 나타내었다.

물론 지금은 둘다 Deprecated 되었다.

LocationListView 수정

1
2
3
@State private var locations: [DDGLocation] = [DDGLocation(record: MockData.location)]

@EnvironmentObject private var locationManager: LocationManager

이젠 이것도 의존성 주입을 하기위해 바꿔준다.

simulator_screenshot_6DBA4CB0-5F56-480A-9DF2-D47B5D76FBB5

그러면 이제 Cloudkit에 저장했던 값이 로드가 된다.

DetailView

Image를 enum으로 Handling

지금 DetailView나 Cell을 보면 이미지의 값을 하드 코딩하여 직접 할당을 하고 있다.

1
BannerImageView(imageName: "default-banner-asset")

이런식으로 값을 직접 할당하다보면 오타로 인하여 제대로 값이 불러와지지 않을 수 있다.

이를 방지하기위해 ImageAsset에 있는 값 즉 디폴트 값에 대하여

enum을 통해 설정을 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import UIKit

enum PlaceholderImage {
    static let avatar = UIImage(named: "default-avatar")!
    static let square = UIImage(named: "default-square-asset")!
    static let banner = UIImage(named: "default-banner-asset")!
}


enum ImageDimension {
    case square, banner
    
    static func getPlaceholder(for dimension: ImageDimension) -> UIImage {
        return dimension == .square ? PlaceholderImage.square : PlaceholderImage.banner
    }
}

코드는 위와 같다.

지금은 ! 를 사용하여 강제 언래핑을 했는데, 이유는 우리가 Asset에 있는 파일을 그대로 사용했기 때문, 물론 해당 케이스는 Asset에 무조건 이미지 파일이 있다는 가정하게 진행을 하기에, 값이 변경되거나 삭제될경우 App Crash를 유발하기에 주의 하자.

extension 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
extension CKAsset {
    func convertToUIImage(in dimension: ImageDimension) -> UIImage {
        let placeholder = ImageDimension.getPlaceholder(for: dimension)
        
        guard let fileUrl = self.fileURL else {
            return placeholder
        }
        
        do {
            let data = try Data(contentsOf: fileUrl)
            return UIImage(data: data) ?? placeholder
        } catch {
            return placeholder
        }
    }
}

이젠 CloudKit에서 이미지를 저장해둔 Asset을 가져오기 위해 Extension을 만들어준다.

placeholder 는 이미지가 없을 경우에 대신할 이미지로 Alternative라고 생각을 하면 될것같다.

Cell 수정

하드코딩으로 값을 넣어주던것을 위에서 설정한 enum, extension을 통해 바꿔주었다.

Image(uiImage: location.squareAsset.convertToUIImage(in: .square))

DDGLocation에 함수 추가

위의 방법으로 해도 되지만 이것 마저도 함수를 통해 변환을 해준다.

  • DDGLocation에서 자체 처리를 하기 위함.
1
2
3
4
5
6
7
8
9
func createSquareImage() -> UIImage {
    guard let asset = squareAsset else { return PlaceholderImage.square }
    return asset.convertToUIImage(in: .square)
}

func createBannerImage() -> UIImage {
    guard let asset = squareAsset else { return PlaceholderImage.square }
    return asset.convertToUIImage(in: .square)
}

Image(uiImage: location.createSquareImage())

이젠 이렇게 처리가 가능하다.

DetailView 적용하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 기타 코드는 생략

struct BannerImageView: View {
    
    // before
    var imageName: String
        Image(imageName)

    // after
    var image: UIImage
        Image(uiImage: image) 
}

// before
BannerImageView(imageName: "default-banner-asset")

// after
BannerImageView(image: location.createBannerImage())

simulator_screenshot_25D4F06C-D445-4AC0-A72D-3815E9A196E4

적용 완료.

CLLocationManager를 사용한 위치 가져오기

CLLocationManager를 사용하여 사용자의 위치를 가져와보려한다.

지금은 Simulator로 처리하기에 시뮬레이터에서 좌표를 설정하여 그 위치가 맵에 보일것이지만, 실제 기기에 사용하면 유져의 위치를 가져올 수 있다.

Docs를 읽어보자.

1
2
3
Map(coordinateRegion: $viewModel.region, showsUserLocation: true, annotationItems: locationManager.locations) { location in
    MapMarker(coordinate: location.location.coordinate, tint: .brandPrimary)
}

showsUserLocation를 추가해 주었다.

ViewModel 수정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
var deviceLocationManager: CLLocationManager?

func checkIfLocationServiceIsEnabled() {
    if CLLocationManager.locationServicesEnabled() {
        deviceLocationManager = CLLocationManager()
        //deviceLocationManager?.desiredAccuracy = kCLLocationAccuracyBest // default
    } else {
        // show alert
        
    }
}

func checkLocationAuthorization() {
    guard let deviceLocationManager = deviceLocationManager else { return }
    
    switch deviceLocationManager.authorizationStatus {
        case .notDetermined:
            deviceLocationManager.requestWhenInUseAuthorization()
        case .restricted:
            // show alert
        case .denied:
            // show alert
        case .authorizedAlways, .authorizedWhenInUse:
            break
        @unknown default:
            break
    }
}

CLLocationManager를 사용하여 서술예정

show alert라고 주석을 잡은 부분은 말그대로 Alert를 띄울것이다.

AlertItem 추가

그러기 위해서는 우선 AlertItem을 추가해준다.

1
2
3
4
5
6
7
8
9
10
11
static let locationRestricted   = AlertItem(title: Text("Locations Restricted"),
    message: Text("You location is restricted. This may be due to parental controls."),
    dismissButton: .default(Text("Ok")))

static let locationDenied       = AlertItem(title: Text("Locations Denied"),
    message: Text("Dub Dub Grub does not have permission to access your location. To change that go to your phone's Settings > Dub Dub Grub > Location"),
    dismissButton: .default(Text("Ok")))

static let locationDisabled     = AlertItem(title: Text("Locations Service Disabled"),
    message: Text("Your phone's location services are disabled. To change that go to your phone's Settings > Privacy > Location Services"),
    dismissButton: .default(Text("Ok")))

그리고 ViewModel에 적용을 해주자.

이부분은 생략

CLLocationManagerDelegate 적용하기

viewModel에 Extension으로 적용을 한다.

이때

1
extension LocationMapViewModel: CLLocationManagerDelegate

이렇게만쓰면 missing 에러가 나는데 그러면 여러 값들을 추가하라고 한다.

그걸 방지하기 위해

1
final class LocationMapViewModel: NSObject, ObservableObject

ViewModel이 NSObject 프로토콜을 따른다고 반드시 명시해주자.

1
2
3
4
5
extension LocationMapViewModel: CLLocationManagerDelegate {
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        checkLocationAuthorization()
    }
}

locationManagerDidChangeAuthorization

Docs에 보면

1
2
3
Important

Core Location always calls locationManagerDidChangeAuthorization(_:) when the user’s action results in an authorization status change, and when your app creates an instance of CLLocationManager, whether your app runs in the foreground or in the background.

이렇게 나와있는데, 해당 메서드는 사용자가 위치 권한을 변경할 때, 앱이 새로운 CLLocationManager 인스턴스를 생성할 때 호출된다고 한다.

즉, 여기서 인스턴스를 생성하면 위의 메서드가 자동으로 실행이 된다는 것

1
2
3
4
5
6
7
8
9
func checkIfLocationServiceIsEnabled() {
    if CLLocationManager.locationServicesEnabled() {
        deviceLocationManager = CLLocationManager()
        //deviceLocationManager?.desiredAccuracy = kCLLocationAccuracyBest // default
    } else {
        // show alert
        
    }
}

그래서 checkLocationAuthorization 메서드는 인스턴스를 만들때 자연스럽게 체크하도록 해당 메서드 안에서 실행하게 해준다. `

info.plist 추가

사용자에게 권한에 대한 동의를 받기위해 추가를 해준다.

이전에도 언급했지만 사용자의 동의를 구하지 않으면 앱을 출시할때 무조건 리젝을 먹기에 반드시 등록을 해주자.

CleanShot 2024-12-21 at 14 00 06

simulator_screenshot_402F39A5-5826-49FB-A584-EF0455334252


Github: Dub-Dub-Grub Repository

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.