2
0
記事投稿キャンペーン 「2024年!初アウトプットをしよう」

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

Posted at

image.png

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

つくったもの

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

IMG_8690.PNG

環境

  • Xcode 15.2
  • iOS 17.2.1

実装ポイント

MapKitのMKMapViewのタップジャスチャーをインターセプト

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

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?」を参考にジェスチャーイベントをグローバル化するコードを実装しました。

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とするベジェ曲線を描画し、画面端との閉域をグレー色で塗ります。

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

GitHub

参考

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0