Open1

Swift: delegateで結果が返ってくるパターンをasync/awaitでラップする

kabeyakabeya

何かを実行すると、delegate呼び出しで結果が返ってくる、というようなパターンがあります。
Swift Concurrencyの例題には、CompletionHandlerのようなパターンはよく載っているのですが、delegateのパターンはあまり見かけません。

ちょっと試してみたので未来の自分に向けて覚え書きしておきます。

今回試したのは、CLLocationManagerCLLocationManagerDelegateで、現在地を取得するパターンです。
CLLocationManager.startUpdatingLocation()を呼ぶと、CLLocationManagerDelegate .locationManager(_:didUpdateLocations:)で現在地のCLLocationの配列が返ってきます。
(返ってくるというか、stopUpdatingLocation()を呼ぶまでdelegateが繰り返し呼ばれます)

考え方としては、delegateの呼び出しの中からCompletionHandlerを呼び出すようにして、CompletionHandler内でwithCheckedContinuation.resumeを呼ぶ、というような感じになります。

CurrentLocationManager.swift
import CoreLocation

class CurrentLocationManager: NSObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()
    private var completionHandler: ((CLLocationCoordinate2D) -> (Void))? = nil
    
    private static var s_locationManager: CurrentLocationManager? = nil
    
    var location: CLLocationCoordinate2D = CLLocationCoordinate2D()
    
    @MainActor
    static func shared() -> CurrentLocationManager {
        if let manager = s_locationManager {
            return manager
        }
        s_locationManager = CurrentLocationManager()
        return s_locationManager!
    }
    
    private override init() {
        super.init()
        manager.delegate = self
        manager.requestWhenInUseAuthorization()
        manager.desiredAccuracy = kCLLocationAccuracyBest
        manager.distanceFilter = 2.0
    }
    
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        DispatchQueue.main.async {
            // 1回取れればよいので、すぐに座標更新を止める
            manager.stopUpdatingLocation()
            locations.last.map {
                //渡ってきた座標を覚えておく
                self.location = $0.coordinate
            }
            if let handler = self.completionHandler {
                // stopUpdatingLocation()をしていても、handler呼び出し中にこのdelegate呼び出しが再度来る場合がある。
                // handler内でwithCheckedContinuation.resumeを呼んでいるが、resumeは1回しか呼べず2回以上resumeを(=handlerを)呼ぶとエラーになる。
                // このため、handler呼び出し前に次の呼び出しができないようにnilにしておく
                self.completionHandler = nil
                handler(self.location)
            }
        }
    }
    
    func startUpdatingLocation() {
        manager.startUpdatingLocation()
    }
    
    static func getCurrentLocation() async -> CLLocationCoordinate2D {
        let shared = await CurrentLocationManager.shared()
        return await withCheckedContinuation { continuation in
            // delegate呼び出しから呼ばれるCompletionHandler。
            // CompletionHandler内でresumeする。
            shared.completionHandler = { center in
                continuation.resume(returning: center)
            }
            // CompletionHandlerをセットしたうえで、startする(と、しばらくしてdelegateが呼び出される)。
            shared.startUpdatingLocation()
        }
    }
}

使い方は以下のような感じになります。

import SwiftUI
import CoreLocation

struct ContentView: View {
    @State var coordinate: CLLocationCoordinate2D = CLLocationCoordinate2D()
    var body: some View {
        VStack {
            Button(action: {
                Task {
                    self.coordinate = await CurrentLocationManager.getCurrentLocation()
                }
            }, label: {
                Text("現在地座標を取得")
            })
            .buttonStyle(.bordered)
            Text("Location: \(coordinate.latitude), \(coordinate.longitude)")
        }
        .padding()
    }
}