포스트

MapKit (33)

iOS 17 Update

이전에 다룬글이 너무 길어서 여기는 새롭게 작성

PhotoPicker

이전에 우리는 PhotoPicker라는 structure를 만들고 그걸 사용해서 Photo picker를 사용했다.

이제 이걸 SwiftUI가 새롭게 지원하는 PhotosPicker를 사용해 만들어본다.

사실 이것도 이전에 한번 해본적이 있긴하다. (확인해보니 PHPPicker 사용) 아마도 UIKit 할때 했던것 같다.

코드 변경

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
46
47
48
49
50
51
52
53
54
// before
struct PhotoPicker: UIViewControllerRepresentable {
    // 생략
}
// ProfileView
ProfileImageView(image: viewModel.avatar)
    .onTapGesture {
        viewModel.isShowingPhotoPicker = true
    }
fileprivate struct ProfileImageView: View {
    
    var image: UIImage
    
    var body: some View {
        ZStack {
            AvatarView(image: image, size: 84)
            
            Image(systemName: "square.and.pencil")
                .resizable()
                .scaledToFit()
                .frame(width: 14, height: 14)
                .foregroundColor(.white)
                .offset(y: 30)
        }
        // 생략
    }
}
// after
import PhotosUI

ProfileImageView(viewModel: viewModel)

fileprivate struct ProfileImageView: View {
    
    var viewModel: ProfileView.ProfileViewModel
    @State private var selectedImage: PhotosPickerItem?
    
    var body: some View {
        ZStack {
            AvatarView(image: viewModel.avatar, size: 84)
            
            PhotosPicker(selection: $selectedImage, matching: .images) {
                Image(systemName: "square.and.pencil")
                    .resizable()
                    .scaledToFit()
                    .frame(width: 14, height: 14)
                    .foregroundColor(.white)
                    .offset(y: 30)
            }
            
        }
        // 생략
    }
}

기존 방식

기존에는 어떻게 해서 Picker를 불러왔는지를 확인해보면

ProfileImageView를 탭했을때 isShowingPhotoPicker = true 가 되면서 작동을 한다.

1
2
3
.sheet(isPresented: $viewModel.isShowingPhotoPicker) {
    PhotoPicker(image: $viewModel.avatar)
}

처음엔 여기서 의문이 생겼다. “PhotoPicker가 선택한 이미지를 어떻게 viewModel로 넘겨주지?”

photoPicker.image = image 한 줄 외에 딱히 “보내는” 코드가 보이지 않았기 때문이다.

그런데 다시 코드를 보니, 핵심은 이미 이 한 줄에 있었다.

1
PhotoPicker(image: $viewModel.avatar)

$viewModel.avatar를 파라미터로 넘기는 그 순간, PhotoPicker@Binding var imageviewModel.avatar는 이미 같은 상태를 바라보는 연결이 완성된다.

이후 Coordinator에서 photoPicker.image = image를 쓰는 건 “보내는” 것이 아니라 이미 연결된 통로에 값을 쓰는 것이고, 그게 곧 viewModel.avatar에 쓰는 것과 동일하다.

Binding은 데이터를 복사해서 전달하는 게 아니라, 같은 상태를 공유하는 연결 통로다.


이후 방식

우선 새로운 Picker를 사용하기 위해선 반드시 import PhotosUI를 해주어야한다.

그리고 기존에 OntapGesture를 통해 true로 바꾸면서 Picker를 트리거 했던 방식 대신에

ProfileImageView에서 Image(systemName: "square.and.pencil")이걸 탭하는것 만으로도 작동을 하게끔 변경해주었다.

즉 기존처럼 sheet를 true / false로 제어하는 방식이 아니라, PhotosPicker 자체가 버튼처럼 동작하는 방식으로 바뀐 것이다.

내가 만든 Label에 일종의 Button처럼 그냥 누르기만 해도 나오게 끔 하는 방식으로 변경이 되었다는것.

그리고 image의 타입도 바뀌었다.

@State private var selectedImage: PhotosPickerItem? PhotosPicker의 selection: 파라미터가 Binding<PhotosPickerItem?>을 요구하기 때문에 @State로 선언해서 $selectedImage로 넘겨주는 것이다.

하지만 이때 .offset(y: 30) 이거때문에 내가 만든 버튼의 위치보다 더 위를 눌러야 하기에 이부분을 지워준다.

Image

1
2
3
4
5
6
7
8
9
10
ZStack(alignment: .bottom) {
    AvatarView(image: viewModel.avatar, size: 84)
    
    PhotosPicker(selection: $selectedImage, matching: .images) {
        Image(systemName: "square.and.pencil")
            // 생략
            .padding(.bottom, 6)
    }
    
}

패딩과 alignment를 아예 bottom으로 하는식으로해서 버튼이 눌러지게 바꿔주었다.

Image

하지만 여기서 끝이 아니다. 지금은 사진만 선택되게만 했기에 최종적으로 적용이 되게끔 해줘야한다.

1
2
3
4
5
6
7
8
9
.onChange(of: selectedImage) { _, _ in
    Task {
        if let pickerItem = selectedImage, let data = try? await pickerItem.loadTransferable(type: Data.self) {
            if let image = UIImage(data: data) {
                viewModel.avatar = image
            }
        }
    }
}

우선 onChange의 경우 말그대로 변화가 생겼을때 작동하는데 picker를 통해 이미지를 선택하게되면 selectedImage에 말그대로 사진이 들어가게 되고, 그걸 Data로 변환하는 과정이 필요하다 왜냐면 SelectedImage의 타입이 PhotosPickerItem이다.

혼동하면 안되는게 우리가 사진을 골랐다고해서 SelectedImage의 타입이 UIImage가 아니라는 것.

그래서 이걸 UIImage로 다시 Type을 변환을 해주기 위해 loadTransferable 썼고 이게 비동기 함수라서 try/await, Task가 사용된 것이다.

이후

1
2
3
if let image = UIImage(data: data) {
    viewModel.avatar = image
}

그 data를 UIImage에 다시 넣어주면서 선택 한 이미지가 UIImage 타입으로 재가공 된것.

이렇게 해주면 이제 작동이 잘된다.

혹시 몰라 여기만 최종코드를 적어둔다. (Modifier 일부 생략)

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
fileprivate struct ProfileImageView: View {
    
    var viewModel: ProfileView.ProfileViewModel
    @State private var selectedImage: PhotosPickerItem?
    
    var body: some View {
        ZStack(alignment: .bottom) {
            AvatarView(image: viewModel.avatar, size: 84)
            
            PhotosPicker(selection: $selectedImage, matching: .images) {
                Image(systemName: "square.and.pencil")
                    // 생략
                    .padding(.bottom, 6)
            }
            
        }
        // 생략
        .onChange(of: selectedImage) { _, _ in
            Task {
                if let pickerItem = selectedImage, let data = try? await pickerItem.loadTransferable(type: Data.self) {
                    if let image = UIImage(data: data) {
                        viewModel.avatar = image
                    }
                }
            }
        }
    }
}

간단하게 정리

기존: Binding으로 연결 → 쓰기만 하면 자동 반영
이후: onChange로 감지 → 직접 변환 → 직접 할당

Map

WWDC 2023 Meet MapKit for SwiftUI에서 새롭게 MapKit을 선보였다.

이전에 한번 해본 기억이 있는데, 이전글참조

그래도 늘 그렇듯, 이전에 했다고해서 완벽하게 습득이 되는건 아니고, 또 여기서도 새로운게 나올수 있기에 적어보도록 한다.

코드 변경

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// Before
Map(coordinateRegion: $viewModel.region, showsUserLocation: true, annotationItems: locationManager.locations) { location in
    MapAnnotation(coordinate: location.location.coordinate, anchorPoint: CGPoint(x: 0.5, y: 0.75)) {
        DDGAnnotation(location: location, number: viewModel.checkedInProfiles[location.id, default: 0])
            .onTapGesture {
                locationManager.selectedLocation = location
                viewModel.isShowingDetailView = true
            }
    }
}
.tint(Color.pink)
.ignoresSafeArea()
// After
Map(initialPosition: viewModel.cameraPosition) {
    ForEach(locationManager.locations) { location in
        Annotation(location.name, coordinate: location.location.coordinate) {
            DDGAnnotation(location: location, number: viewModel.checkedInProfiles[location.id, default: 0])
            .onTapGesture {
                locationManager.selectedLocation = location
                viewModel.isShowingDetailView = true
            }
        }
        .annotationTitles(.hidden)
    }
}
// MapVM
// Before
var region = MKCoordinateRegion(
    center: CLLocationCoordinate2D(
        latitude: 37.331516,
        longitude: -121.891054
    ),
    span: MKCoordinateSpan(
        latitudeDelta: 0.01,
        longitudeDelta: 0.01
    )
)
// After
var cameraPosition: MapCameraPosition = .region(
    .init(
        center: CLLocationCoordinate2D(
            latitude: 37.331516,
            longitude: -121.891054
        ),
        latitudinalMeters: 1200,
        longitudinalMeters: 1200
    )
)
// Before
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    guard let currentLocation = locations.last else { return }
    
    withAnimation {
        region = MKCoordinateRegion(center: currentLocation.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01))
    }
}
// After
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    guard let currentLocation = locations.last else { return }
    
    withAnimation {
        cameraPosition = .region(.init(center: currentLocation.coordinate, latitudinalMeters: 1200, longitudinalMeters: 1200))
    }
}

정리

우선 Map이 리뉴얼 되면서 중요한 요소중에 하나라면 바로 MapCameraPosition이다.

이전에는 CameraPosition과 비슷했던게 바로 Region 이었다.

사실 둘은 꽤 비슷한 성격을 가진다고 생각한다. 초기 좌표값과 지도를 어느 범위(높이)로 보여줄지 결정하는 역할이기 때문이다.

기존에는 latitudeDelta / longitudeDelta 기반이었다.

하지만 거리 감각이 직관적이지 않다 보니, Apple도 meter 기반으로 방향을 바꾼 느낌이다. (개인적인 생각)


이제 cameraPosition가 기존의 region을 대체할것이므로, MapVM에 있는 함수에도 변경을 해주었다.

Map의 기본 형태 (Marker 포함)

여기는 코드를 같이 가져왔다.

1
2
3
4
5
Map(initialPosition: viewModel.cameraPosition) {
    ForEach(locationManager.locations) { location in
        Marker(location.name, coordinate: location.location.coordinate)
    }
}

Image

우리가 만들었던 CustomAnnotation 대신 기본으로 제공하는 Marker를 사용한 상태.

이게 Map의 Default 코드라고 봐도 될것같다 (Marker 포함)

기능 복구 및 추가

이후 우리가 이전에 구현했던 기능들을 다시 살리기 위해서

다음과 같이 Modifer 및 Method를 추가했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Map(initialPosition: viewModel.cameraPosition) {
    ForEach(locationManager.locations) { location in
        // 생략
        .onTapGesture {
            생략
        }
        .contextMenu {
            Button("Look Around", systemImage: "eyes") {
                
            }
            Button("Get Directions", systemImage: "arrow.triangle.turn.up.right.circle") {
                //
            }
        }
    }
    UserAnnotation()
}
.mapStyle(.standard)
.mapControls {
    MapCompass()
    MapUserLocationButton()
    MapPitchToggle()
    MapScaleView()
}
  • UserAnnotation
    • User의 현 위치 표시

Image


  • MapStyle
    • 지도의 Style 변경 (아래 사진은 imagery)

Image

MapStyle Docs참고


  • MapControls
    • 우측 상단에 버튼 추가
    • 이미 버튼 기능 자체가 구현되어 있어서 쓰기만 하면 됨

Image


  • ContextMenu
    • Annotation을 꾹 눌렀을때 뜨는 Menu

Image


LookAround

현재는 ContextMenu만 만들어 둔 상태이며, 아직 실제 기능은 구현하지 않은 상태이다.

1
2
3
4
5
6
7
8
.contextMenu {
    Button("Look Around", systemImage: "eyes") {
        
    }
    Button("Get Directions", systemImage: "arrow.triangle.turn.up.right.circle") {
        //
    }
}

이제 여기에 기능을 구현해보려 한다.

먼저 할건 첫번째 버튼인 LookAround 이다.

LookAroundPreview Docs참고

코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// MapVM
var isShowingLookAround = false
var lookAroundScene: MKLookAroundScene? {
    didSet {
        if let _ = lookAroundScene {
            isShowingLookAround = true
        }
    }
}
@MainActor
func getLookAroundScene(for location: DDGLocation) {
    Task {
        let request = MKLookAroundSceneRequest(coordinate: location.location.coordinate)
        lookAroundScene = try? await request.scene
    }
}
// MapView
Button("Look Around", systemImage: "eyes") {
    viewModel.getLookAroundScene(for: location)
}

.lookAroundViewer(isPresented: $viewModel.isShowingLookAround, initialScene: viewModel.lookAroundScene)
.mapStyle(.standard)
정리

우선 이 코드를 짜기전에

1
.lookAroundViewer(isPresented: <#T##Binding<Bool>#>, initialScene: <#T##MKLookAroundScene?#>)

이 Modifier를 보고 생각을 먼저 해보는게 좋다.

isPresented, initialScene 두개의 파라미터가 있고, type은 Binding, MKLookAroundScene 이렇다고 한다.

즉 우리는 이전처럼 true / false를 통해 이게 트리거가 된다고 보면 되고, MKLookAroundScene type으로 된 변수가 필요하다는걸 알 수가 있다.

그래서 isShowingLookAround, lookAroundScene 변수가 만들어졌다. (평상시에는 아직 Look Around 정보가 존재하지 않기 때문에 lookAroundScene는 Optional로 선언했다.)

lookAroundScene에 didSet을 사용한 이유는, 선택한 가게의 좌표를 기반으로 MKLookAroundSceneRequest를 실행한 뒤, 그 결과값이 lookAroundScene에 저장되는 순간 자동으로 isShowingLookAround = true를 작동시키기 위해서이다.

즉, lookAroundScene의 값 변경 자체를 트리거로 사용한 것이다.

결과적으로 lookAroundViewerisShowingLookAround 값의 변화를 감지하여 화면을 표시하게 된다.

즉, 순서를 정리해보면

  1. Look Around Button을 누른다.
  2. viewModel.getLookAroundScene이 작동 하며 그 가게의 정보값이 전해짐
  3. 그 함수 내부에서 MKLookAroundSceneRequest이 작동하고, 그 결과값이 lookAroundScene에 담김
  4. lookAroundScene에 값이 바뀌자마자 didSet에 의해 isShowingLookAround 가 true로 값이 바뀜
  5. lookAroundViewerisShowingLookAround 값의 변화를 감지 하여 LookAround 기능이 작동.

실행하면 아래와 같이 Look Around 화면이 표시된다.

Image


Directions on Map

다음으로 두번째 버튼인 Get Directions이다.

말그대로 흔히 아는 경로를 나타내는 기능이다. MKDirection Docs참고

코드
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
// MapVM
var route: MKRoute?

@MainActor
func getDirections(to location: DDGLocation) {
    guard let userLocation = deviceLocationManager.location?.coordinate else {
        return
    }
    let destination = location.location.coordinate
    
    let request = MKDirections.Request()
    request.source = MKMapItem(placemark: .init(coordinate: userLocation))
    request.destination = MKMapItem(placemark: .init(coordinate: destination))
    request.transportType = .walking
    
    Task {
        let directions = try? await MKDirections(request: request).calculate()
        route = directions?.routes.first
    }
}
// MapView
Button("Get Directions", systemImage: "arrow.triangle.turn.up.right.circle") {
    viewModel.getDirections(to: location)
}

ForEach {
    //생략
}
UserAnnotation()
                
if let route = viewModel.route {
    MapPolyline(route)
        .stroke(.brandPrimary, lineWidth: 8)
}
정리

경로는 보통 MKPolyline을 통해 그려진다.

그래서 만약에 경로가 있다면 경로를 그려라 라는 코드가 핵심이다.

이걸 기준으로 코드를 전개하면 그래도 방향성이 그려진다.

경로를 그리기 위해서는 우선 경로 데이터 자체가 필요하다. 물론 평상시엔 경로가 그려지지 않을것이니 옵셔널로 해두는 것. (평상시에는 아직 경로가 존재하지 않기 때문에 route는 Optional로 선언했다.)

경로를 구하는 함수인 getDirections부분이 코드가 길어서 처음에는 어려워 보일지 몰라도 하나하나 뜯어보면 별거 없다.

나의 위치에서 선택한 가게 까지의 경로를 구한다. 이게 핵심이다.

userLocation은 당연히 현재 사용자의 위치를 나타내는것이고. destination도 당연히 목적지의 위치를 나타내는것이다.

이후 이전에 CloudKit에서 쓰이는 operation 처럼 (아니면 URLSession에서 쓰이는 operation처럼) request라는걸 만들어서 request를 통해 값을 만든다.

이때 request.~~로 시작하는건 이렇게 생각하면 쉽다.

지도에 핀을 두개를 박을 건데 하나는 시작점 (source), 다른하나는 도착점 (destination)이다. 그리고 움직이는 방식은 .walking 즉 걷는걸로 했다.

이후 비동기 작업을 거치는데 MKDirections를 통해 시작 지점과 도착 지점의 경로를 계산한다.

여기서 중요한 점은 calculate()의 결과가 단일 경로가 아니라 [MKRoute] 배열이라는 것이다.

즉 Apple Maps처럼 여러 후보 경로를 반환할 수 있으며, 현재 코드는 그중 첫 번째 추천 경로(routes.first)만 가져와 route에 저장한 뒤 지도에 표시하는 방식이다.

즉, 순서를 정리해보면

  1. Get Directions Button을 누른다.
  2. viewModel.getDirections가 작동 하며 그 가게의 정보값이 전해짐
  3. 그 함수 내부에서 유저의 실시간 위치와, 선택한 가게의 위치의 좌표값을 가져와서 MKDirections를 통해 경로를 계산한다.
  4. 계산을하고 첫번째 값을 route에 넣는다.
  5. route가 평상시엔 optional이서 경로가 보이지 않았으나 route에 값이 생기면서 if let route 블럭이 실행되고, MapPolyline이 지도 위에 경로를 그리게 된다.

실행하면 이렇게 경로가 뜨는걸 알 수 있다.

Image


iOS 17 까지 적용하면서 길고 긴 여정이 끝났다.

물론 중간에 다시 시작하는데 긴 공백이 있긴했으나, 이번 강의 자체는 글도 꽤나 많이 쓴만큼 내용도 그렇고 상당히 도움이 많이 되었던것같다.


Github: Dub-Dub-Grub Repository

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