エッジジャスチャーで地図をぬるぬるズーム

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/dd5a4175-953b-09e1-f908-0c35b7dc7f77.png)

画面端からのスワイプジェスチャーでMapKitの地図表示をズームさせるサンプルプログラムです。

# つくったもの
画面左右の端からのスワイプ量に応じて地図がぬるぬるっとズームイン・ズームアウトします。現在地を表示するカスタムピンも実装してみました。

![IMG_8690.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/92d4b6dd-6067-19b2-7853-6fdac4418d43.png)

## 環境
- Xcode 15.2
- iOS 17.2.1

# 実装ポイント

## MapKitのMKMapViewのタップジャスチャーをインターセプト
MKMapViewのタップイベントをトリガーにズームするとMapKitの挙動と干渉してしまって、ズームがカクついてしまいます。代わりにタップジャスチャーを上位Viewでインターセプトすることでぬぬるぬる動作するズームが実装できました。

```swift: ViewController.swift
class ViewController: UIViewController {
    var mapView: MKMapView!
    var locationManager: CLLocationManager!
    var tapAreaView: TapAreaView!
    var isLoadingMap = false
    var isRenderingMap = false
    var lastTapPoint = CGPoint(x: 0, y: 0)
    var lastEventTimestamp: TimeInterval = 0.0
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupMapKit()
        setupZoomArea(rect: view.bounds)
    }
    
    private func setupMapKit() {
        // mapViewの生成
        mapView = MKMapView(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height))
        mapView.delegate = self
        mapView.showsBuildings = true
        mapView.showsScale = true
        mapView.showsCompass = true
        //mapView.showsUserLocation = true
        self.view.addSubview(mapView)
        
        // ロケーションマネージャーのセットアップ
        locationManager = CLLocationManager()
        locationManager.delegate = self
        locationManager.requestWhenInUseAuthorization()
        
        // 東京駅に照準を合わす
        let span = MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01)
        let coordinate = CLLocationCoordinate2DMake(35.681236, 139.767125)
        let region = MKCoordinateRegion(center: coordinate, span: span)
        mapView.region = region
        
        // pinに表示する画像(吹き出し画像で切り抜く)
        let sourceImage = UIImage(named: "profile1")
        let maskImage = UIImage(named: "balloon_mask")
        let image = sourceImage?.masking(maskImage: maskImage)
        
        // pin生成
        let pin = PlaceAnnotation(index: 1, name: "name1", image: image!, latitude: 35.681236, longitude: 139.767125)
        pin.coordinate = coordinate
        mapView.addAnnotation(pin)
        
        // ドラッグ検知
        let tapInterceptor = GlobalGestureRecognizer(target: nil, action: nil)
        tapInterceptor.touchesBeganCallback = { touches, event in
            let touchPoint = touches.first?.location(in: self.view) ?? CGPoint()
            self.lastTapPoint = touchPoint

            var x = self.view.bounds.width * 0.2
            let hitLeftRect = CGRect(x: 0, y: 0, width: x, height: self.view.bounds.height)

            x = self.view.bounds.width * 0.8
            let hitRightRect = CGRect(x: x, y: 0, width: self.view.bounds.width - x, height: self.view.bounds.height)
            
            if hitLeftRect.contains(touchPoint) || hitRightRect.contains(touchPoint){
                self.mapView.isScrollEnabled = false
                self.tapAreaView.showArea(touchPoint: touchPoint)
            }
        }
        tapInterceptor.touchesMovedCallback = { touches, event in
            if !self.tapAreaView.isHidden {
                self.tapAreaView.moveAreaWithCallback(touches: touches, event: event)
            }
        }
        tapInterceptor.touchesEndedCallback = { touches, event in
            if !self.tapAreaView.isHidden {
                self.mapView.isScrollEnabled = true
                self.tapAreaView.hideArea()
            }
        }
        mapView.addGestureRecognizer(tapInterceptor)
    }

    private func setupZoomArea(rect: CGRect) {
        let drawView = TapAreaView(frame: rect)
        drawView.hideArea()
        self.view.addSubview(drawView)
        self.tapAreaView = drawView

        drawView.touchesBeganCallback = { touches, event in
            self.mapView.isScrollEnabled = false
            self.lastTapPoint = touches.first?.location(in: self.mapView) ?? CGPoint()
        }
        drawView.touchesEndedCallback = { touches, event in
            self.mapView.isScrollEnabled = true
        }
        drawView.touchesMovedCallback = { touches, event in
            if self.isLoadingMap || self.isRenderingMap {
                return
            }
            if self.mapView.isScrollEnabled {
                return
            }
            if event.timestamp - self.lastEventTimestamp < 0.1 {
                return
            }
            self.lastEventTimestamp = event.timestamp
            
            let tapPoint = touches.first?.location(in: self.mapView) ?? CGPoint()
            let deltaX = self.lastTapPoint.x - tapPoint.x
            let deltaY = self.lastTapPoint.y - tapPoint.y
            self.lastTapPoint = tapPoint
            
            var zoomValue = 2.0
            if abs(deltaX) > abs(deltaY) {
                if abs(deltaX) < 3 {
                    return
                }
                if deltaX < 0 {
                    zoomValue *= -1
                }
            } else {
                if abs(deltaY) < 3 {
                    return
                }
                if deltaY < 0 {
                    zoomValue *= -1
                }
            }
            
            self.zoomMap(zoomValue: zoomValue)
        }
    }
    
    private func zoomMap(zoomValue: Double) {
        var region: MKCoordinateRegion = self.mapView.region
        if zoomValue < 0 {
            region.span.latitudeDelta /= abs(zoomValue)
            region.span.longitudeDelta /= abs(zoomValue)
        } else {
            region.span.latitudeDelta *= zoomValue
            region.span.longitudeDelta *= zoomValue
        }
        
        if region.span.latitudeDelta < 0 || region.span.latitudeDelta > 30 {
            return
        }
        if region.span.longitudeDelta < 0 || region.span.longitudeDelta > 30 {
            return
        }
        
        self.isRenderingMap = true
        self.mapView.setRegion(region, animated: true)
    }
}
```

## グローバルなジェスチャーイベント処理
Stack Overflowに投稿されていた「[How to intercept touches events on a MKMapView or UIWebView objects?](https://stackoverflow.com/questions/1049889/how-to-intercept-touches-events-on-a-mkmapview-or-uiwebview-objects)」を参考にジェスチャーイベントをグローバル化するコードを実装しました。

```swift: GlobalGestureRecognizer.swift
import UIKit

class GlobalGestureRecognizer: UIGestureRecognizer {
    var touchesBeganCallback: ((Set<UITouch>, UIEvent) -> Void)?
    var touchesEndedCallback: ((Set<UITouch>, UIEvent) -> Void)?
    var touchesMovedCallback: ((Set<UITouch>, UIEvent) -> Void)?

    override init(target: Any?, action: Selector?) {
        super.init(target: target, action: action)
        self.cancelsTouchesInView = false
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesBegan(touches, with: event)
        
        if let touchesBeganCallback {
            touchesBeganCallback(touches, event)
        }
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesEnded(touches, with: event)

        if let touchesEndedCallback {
            touchesEndedCallback(touches, event)
        }
    }
    
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesMoved(touches, with: event)

        if let touchesMovedCallback {
            touchesMovedCallback(touches, event)
        }
    }

    override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool {
        return false
    }

    override func canBePrevented(by preventingGestureRecognizer: UIGestureRecognizer) -> Bool {
        return false
    }
}
```

## ズーム量をUIフィードバック
タップ位置とズーム量が視覚的にわかるようにTapAreaViewをオーバーレイさせます。タッチ座標をcontrolPointとするベジェ曲線を描画し、画面端との閉域をグレー色で塗ります。

```swift:TapAreaView.swift
import UIKit

class TapAreaView: UIView {
    var touchesBeganCallback: ((Set<UITouch>, UIEvent) -> Void)?
    var touchesEndedCallback: ((Set<UITouch>, UIEvent) -> Void)?
    var touchesMovedCallback: ((Set<UITouch>, UIEvent) -> Void)?
    var isLeftArea = true
    
    private var parentRect: CGRect!
    private var drawRect: CGRect!
    private var touchPoint: CGPoint = CGPoint()
    
    override init(frame: CGRect) {
        parentRect = frame
        drawRect = frame
        
        super.init(frame: frame);
        self.backgroundColor = UIColor.clear;
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func draw(_ rect: CGRect) {
        var startPoint = CGPoint()
        var endPoint = CGPoint()
        var controlPoint = CGPoint()

        if isLeftArea {
            startPoint = CGPoint(x: 0, y: 0)
            endPoint = CGPoint(x: 0, y: parentRect.height)
            
            let deltaX = touchPoint.x
            controlPoint = touchPoint
            controlPoint.x += deltaX
        } else {
            startPoint = CGPoint(x: parentRect.width, y: 0)
            endPoint = CGPoint(x: parentRect.width, y: parentRect.height)
            
            let deltaX = parentRect.width - touchPoint.x
            controlPoint = touchPoint
            controlPoint.x -= deltaX
        }
        
        let path = UIBezierPath()
        path.move(to: startPoint)
        path.addQuadCurve(to: endPoint,
                          controlPoint: CGPoint(x: controlPoint.x, y: controlPoint.y))

        UIColor.black.withAlphaComponent(0.3).setFill()
        path.fill()
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        let touch = touches.first!
        let location = touch.location(in: self)
        showArea(touchPoint: location)
        
        if let touchesBeganCallback {
            touchesBeganCallback(touches, event!)
        }
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        hideArea()
        
        if let touchesEndedCallback {
            touchesEndedCallback(touches, event!)
        }
    }
    
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        let touch = touches.first!
        let location = touch.location(in: self)
        moveArea(touchPoint: location)
        
        if let touchesMovedCallback {
            touchesMovedCallback(touches, event!)
        }
    }
    
    func showArea(touchPoint: CGPoint) {
        let x = touchPoint.x
        self.drawRect = CGRect(x: x, y: parentRect.origin.y, width: parentRect.width - x, height: parentRect.height)
        self.touchPoint = touchPoint
        
        if x < (parentRect.width / 2) {
            isLeftArea = true
        } else {
            isLeftArea = false
        }
        
        setNeedsDisplay()
        layer.opacity = 0.9
    }
    
    func hideArea() {
        layer.opacity = 0.0
    }
    
    func moveArea(touchPoint: CGPoint) {
        let x = touchPoint.x
        self.drawRect = CGRect(x: x, y: parentRect.origin.y, width: parentRect.width - x, height: parentRect.height)
        self.touchPoint = touchPoint
        setNeedsDisplay()
    }
    
    func moveAreaWithCallback(touches: Set<UITouch>, event: UIEvent?) {
        let touch = touches.first!
        let location = touch.location(in: self)
        let x = location.x
        self.drawRect = CGRect(x: x, y: parentRect.origin.y, width: parentRect.width - x, height: parentRect.height)
        self.touchPoint = location
        setNeedsDisplay()
        
        if let touchesMovedCallback {
            touchesMovedCallback(touches, event!)
        }
    }
}

```

## 完成！
![sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/f4e8795d-4704-e77d-bf2d-e2a96a2018f9.gif)

## GitHub
https://github.com/yuppejp/MapEffectSample/

# 参考
- [How to intercept touches events on a MKMapView or UIWebView objects?](https://stackoverflow.com/questions/1049889/how-to-intercept-touches-events-on-a-mkmapview-or-uiwebview-objects)
- [MKMapView：ピンに画像やCustomViewを設定する](https://blog.shota-ito.com/2021/06/21/mapview-annotation/)