我知道可以通过相反的地图相机方向来旋转符号。 我知道可以在用户方向发生变化时被通知以旋转注释。
我的问题是,我找不到一种方法来追踪由用户交互旋转引起的地图旋转。
我可以追踪地图区域更改的开始和结束,但不能追踪两者之间的变化。 我尝试使用相机的方位角进行KVO,但什么也没得到。 我尝试寻找系统发送的通知,但同样什么也没有。
有人有任何关于如何可靠地追踪当前地图旋转的建议吗?
很遗憾,MapKit本身没有提供跟踪旋转变化的解决方案。它只在旋转开始和结束时提供事件。更重要的是,在旋转地图时,它不会更新相机的航向值。
我有同样的需求,并在Swift中创建了自己的解决方案,对我很有效。
1. 子类化MKMapView以处理其数据
最简单的部分:
class MyMap : MKMapView {
}
2. 寻找可靠的具有实际地图旋转值的对象
MKMapView
是一种 UIView 容器,其中包含一个画布,在该画布内呈现和转换地图。我在运行时研究了 MKMapView
并探索了其子视图。该画布的类名为 MKScrollContainerView
。
您需要控制该实例,因此您需要:
MKMapView
内的该对象代码如下:
class MyMap : MKMapView {
var mapContainerView : UIView?
init() {
...
self.mapContainerView = self.findViewOfType("MKScrollContainerView", inView: self)
...
}
func findViewOfType(type: String, inView view: UIView) -> UIView? {
// function scans subviews recursively and returns reference to the found one of a type
if view.subviews.count > 0 {
for v in view.subviews {
if v.dynamicType.description() == type {
return v
}
if let inSubviews = self.findViewOfType(type, inView: v) {
return inSubviews
}
}
return nil
} else {
return nil
}
}
}
3. 计算旋转
MKScrollContainerView
的旋转是通过更改其 transform
属性来实现的。用于此目的的旋转矩阵在苹果文档中有详细描述:
https://developer.apple.com/library/mac/documentation/GraphicsImaging/Reference/CGAffineTransform/#//apple_ref/c/func/CGAffineTransformMakeRotation
它看起来像这样:
cosA sinA 0
-sinA cosA 0
0 0 1
基于该矩阵计算旋转的函数如下:
class MyMap : MKMapView {
...
func getRotation() -> Double? {
// function gets current map rotation based on the transform values of MKScrollContainerView
if self.mapContainerView != nil {
var rotation = fabs(180 * asin(Double(self.mapContainerView!.transform.b)) / M_PI)
if self.mapContainerView!.transform.b <= 0 {
if self.mapContainerView!.transform.a >= 0 {
// do nothing
} else {
rotation = 180 - rotation
}
} else {
if self.mapContainerView!.transform.a <= 0 {
rotation = rotation + 180
} else {
rotation = 360 - rotation
}
}
return rotation
} else {
return nil
}
}
...
}
4. 持续跟踪旋转
我发现实现这个功能的唯一方法是设置一个无限循环,每次循环调用时检查旋转值。 要实现这个功能,您需要:
以下是我的实现:
@objc protocol MyMapListener {
optional func onRotationChanged(rotation rotation: Double)
// message is sent when map rotation is changed
}
class MyMap : MKMapView {
...
var changesTimer : NSTimer? // timer to track map changes; nil when changes are not tracked
var listener : MyMapListener?
var rotation : Double = 0 // value to track rotation changes
...
func trackChanges() {
// function detects map changes and processes it
if let rotation = self.getRotation() {
if rotation != self.rotation {
self.rotation = rotation
self.listener?.onRotationChanged(rotation: rotation)
}
}
}
func startTrackingChanges() {
// function starts tracking map changes
if self.changesTimer == nil {
self.changesTimer = NSTimer(timeInterval: 0.1, target: self, selector: #selector(MyMap.trackChanges), userInfo: nil, repeats: true)
NSRunLoop.currentRunLoop().addTimer(self.changesTimer!, forMode: NSRunLoopCommonModes)
}
}
func stopTrackingChanges() {
// function stops tracking map changes
if self.changesTimer != nil {
self.changesTimer!.invalidate()
self.changesTimer = nil
}
}
}
这就是全部内容了 ;)
你可以在我的代码库中下载示例项目:https://github.com/d-babych/mapkit-wrap
protocol MyMapViewRotationDelegate:class {
func myMapView(mapView: MyMapView, didRotateAtAngle angle:CGFloat)
}
class MyMapView: MKMapView {
private var mapContainerView:UIView?
weak var rotationDelegate:MyMapViewRotationDelegate?
}
2. 如果您使用storyboard,请在init()
或awakeFromNib()
中查找MKScrollContainerView,并向其添加KVO观察器以观察层。
private func findViewOfType(type: String, inView view: UIView) -> UIView? {
// function scans subviews recursively and returns reference to the found one of a type
if view.subviews.count > 0 {
for v in view.subviews {
if v.classForCoder == NSClassFromString("MKScrollContainerView") {
return v
}
if let inSubviews = self.findViewOfType(type: type, inView: v) {
return inSubviews
}
}
return nil
} else {
return nil
}
}
override func awakeFromNib() {
super.awakeFromNib()
if let scrollContainerView = findViewOfType(type: "MKScrollContainerView", inView: self) {
mapContainerView = scrollContainerView
mapContainerView!.layer.addObserver(self, forKeyPath: #keyPath(transform), options: [.new, .old], context: nil)
}
}
3. 在MyMapView类中重写observeValue函数
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
guard keyPath == #keyPath(transform) else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
return
}
guard let layer = object as? CALayer else {
return
}
if let rotationRadians = layer.value(forKeyPath: "transform.rotation.z") as? CGFloat {
var angle = rotationRadians / .pi * 180 //convert to degrees
if angle < 0 {
angle = 360 + angle
}
if let rotationDelegate = rotationDelegate {
rotationDelegate.myMapView(mapView: self, didRotateAtAngle: angle)
}
}
}
4. 不要忘记在 MyMapView 将被释放时移除观察者
deinit {
mapContainerView?.layer.removeObserver(self, forKeyPath: #keyPath(transform))
}
看起来确实没有办法在旋转地图时简单地跟踪当前的方向。由于我刚刚实现了一个随地图旋转的指南针视图,我想与你分享我的知识。
我特别邀请你来完善这个答案。由于我有一个截止日期,现在对此感到满意(在此之前,指南针只在地图停止旋转时设置),但还有改进和微调的空间。
我在这里上传了一个示例项目:地图旋转示例项目
好的,让我们开始吧。由于我假设你们现在都使用Storyboards,将一些手势识别器拖到地图上。(那些不知道如何将这些步骤转化为代码的人肯定知道如何将其转化为书面形式。)
为了检测地图的旋转、缩放和3D角度,我们需要一个旋转、一个平移和一个捏合手势识别器。
将所有3个手势识别器的引用输出集合拖动到地图视图上,并选择“gestureRecognizers”。
现在,将旋转手势识别器Ctrl拖动到实现中,作为Outlet,就像这样:
@IBOutlet var rotationGestureRecognizer: UIRotationGestureRecognizer!
并将所有3个识别器作为IBAction:
@IBAction func handleRotation(sender: UIRotationGestureRecognizer) {
...
}
@IBAction func handleSwipe(sender: UIPanGestureRecognizer) {
...
}
@IBAction func pinchGestureRecognizer(sender: UIPinchGestureRecognizer) {
...
}
是的,我把平移手势命名为“handleSwype”。下面会有解释。:)
以下是完整的控制器代码,当然还必须实现MKMapViewDelegate协议。 我在注释中尽可能详细地说明了。
// compassView is the container View,
// arrowImageView is the arrow which will be rotated
@IBOutlet weak var compassView: UIView!
var arrowImageView = UIImageView(image: UIImage(named: "Compass")!)
override func viewDidLoad() {
super.viewDidLoad()
compassView.addSubview(arrowImageView)
}
// ******************************************************************************************
// *
// Helper: Detect when the MapView changes *
private func mapViewRegionDidChangeFromUserInteraction() -> Bool {
let view = mapView!.subviews[0]
// Look through gesture recognizers to determine whether this region
// change is from user interaction
if let gestureRecognizers = view.gestureRecognizers {
for recognizer in gestureRecognizers {
if( recognizer.state == UIGestureRecognizerState.Began ||
recognizer.state == UIGestureRecognizerState.Ended ) {
return true
}
}
}
return false
}
// *
// ******************************************************************************************
// ******************************************************************************************
// *
// Helper: Needed to be allowed to recognize gestures simultaneously to the MapView ones. *
func gestureRecognizer(_: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWithGestureRecognizer:UIGestureRecognizer) -> Bool {
return true
}
// *
// ******************************************************************************************
// ******************************************************************************************
// *
// Helper: Use CADisplayLink to fire a selector at screen refreshes to sync with each *
// frame of MapKit's animation
private var displayLink : CADisplayLink!
func setUpDisplayLink() {
displayLink = CADisplayLink(target: self, selector: "refreshCompassHeading:")
displayLink.addToRunLoop(NSRunLoop.currentRunLoop(), forMode: NSRunLoopCommonModes)
}
// *
// ******************************************************************************************
// ******************************************************************************************
// *
// Detect if the user starts to interact with the map... *
private var mapChangedFromUserInteraction = false
func mapView(mapView: MKMapView, regionWillChangeAnimated animated: Bool) {
mapChangedFromUserInteraction = mapViewRegionDidChangeFromUserInteraction()
if (mapChangedFromUserInteraction) {
// Map interaction. Set up a CADisplayLink.
setUpDisplayLink()
}
}
// *
// ******************************************************************************************
// *
// ... and when he stops. *
func mapView(mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
if mapChangedFromUserInteraction {
// Final transform.
// If all calculations would be correct, then this shouldn't be needed do nothing.
// However, if something went wrong, with this final transformation the compass
// always points to the right direction after the interaction is finished.
// Making it a 500 ms animation provides elasticity und prevents hard transitions.
UIView.animateWithDuration(0.5, animations: {
self.arrowImageView.transform =
CGAffineTransformMakeRotation(CGFloat(M_PI * -mapView.camera.heading) / 180.0)
})
// You may want this here to work on a better rotate out equation. :)
let stoptime = NSDate.timeIntervalSinceReferenceDate()
print("Needed time to rotate out:", stoptime - startRotateOut, "with velocity",
remainingVelocityAfterUserInteractionEnded, ".")
print("Velocity decrease per sec:", (Double(remainingVelocityAfterUserInteractionEnded)
/ (stoptime - startRotateOut)))
// Clean up for the next rotation.
remainingVelocityAfterUserInteractionEnded = 0
initialMapGestureModeIsRotation = nil
if let _ = displayLink {
displayLink.invalidate()
}
}
}
// *
// ******************************************************************************************
// ******************************************************************************************
// *
// This is our main function. The display link calls it once every display frame. *
// The moment the user let go of the map.
var startRotateOut = NSTimeInterval(0)
// After that, if there is still momentum left, the velocity is > 0.
// The velocity of the rotation gesture in radians per second.
private var remainingVelocityAfterUserInteractionEnded = CGFloat(0)
// We need some values from the last frame
private var prevHeading = CLLocationDirection()
private var prevRotationInRadian = CGFloat(0)
private var prevTime = NSTimeInterval(0)
// The momentum gets slower ower time
private var currentlyRemainingVelocity = CGFloat(0)
func refreshCompassHeading(sender: AnyObject) {
// If the gesture mode is not determinated or user is adjusting pitch
// we do obviously nothing here. :)
if initialMapGestureModeIsRotation == nil || !initialMapGestureModeIsRotation! {
return
}
let rotationInRadian : CGFloat
if remainingVelocityAfterUserInteractionEnded == 0 {
// This is the normal case, when the map is beeing rotated.
rotationInRadian = rotationGestureRecognizer.rotation
} else {
// velocity is > 0 or < 0.
// This is the case when the user ended the gesture and there is
// still some momentum left.
let currentTime = NSDate.timeIntervalSinceReferenceDate()
let deltaTime = currentTime - prevTime
// Calculate new remaining velocity here.
// This is only very empiric and leaves room for improvement.
// For instance I noticed that in the middle of the translation
// the needle rotates a bid faster than the map.
let SLOW_DOWN_FACTOR : CGFloat = 1.87
let elapsedTime = currentTime - startRotateOut
// Mathematicians, the next line is for you to play.
currentlyRemainingVelocity -=
currentlyRemainingVelocity * CGFloat(elapsedTime)/SLOW_DOWN_FACTOR
let rotationInRadianSinceLastFrame =
currentlyRemainingVelocity * CGFloat(deltaTime)
rotationInRadian = prevRotationInRadian + rotationInRadianSinceLastFrame
// Remember for the next frame.
prevRotationInRadian = rotationInRadian
prevTime = currentTime
}
// Convert radian to degree and get our long-desired new heading.
let rotationInDegrees = Double(rotationInRadian * (180 / CGFloat(M_PI)))
let newHeading = -mapView!.camera.heading + rotationInDegrees
// No real difference? No expensive transform then.
let difference = abs(newHeading - prevHeading)
if difference < 0.001 {
return
}
// Finally rotate the compass.
arrowImageView.transform =
CGAffineTransformMakeRotation(CGFloat(M_PI * newHeading) / 180.0)
// Remember for the next frame.
prevHeading = newHeading
}
// *
// ******************************************************************************************
// As soon as this optional is set the initial mode is determined.
// If it's true than the map is in rotation mode,
// if false, the map is in 3D position adjust mode.
private var initialMapGestureModeIsRotation : Bool?
// ******************************************************************************************
// *
// UIRotationGestureRecognizer *
@IBAction func handleRotation(sender: UIRotationGestureRecognizer) {
if (initialMapGestureModeIsRotation == nil) {
initialMapGestureModeIsRotation = true
} else if !initialMapGestureModeIsRotation! {
// User is not in rotation mode.
return
}
if sender.state == .Ended {
if sender.velocity != 0 {
// Velocity left after ending rotation gesture. Decelerate from remaining
// momentum. This block is only called once.
remainingVelocityAfterUserInteractionEnded = sender.velocity
currentlyRemainingVelocity = remainingVelocityAfterUserInteractionEnded
startRotateOut = NSDate.timeIntervalSinceReferenceDate()
prevTime = startRotateOut
prevRotationInRadian = rotationGestureRecognizer.rotation
}
}
}
// *
// ******************************************************************************************
// *
// Yes, there is also a SwypeGestureRecognizer, but the length for being recognized as *
// is far too long. Recognizing a 2 finger swype up or down with a PanGestureRecognizer
// yields better results.
@IBAction func handleSwipe(sender: UIPanGestureRecognizer) {
// After a certain altitude is reached, there is no pitch possible.
// In this case the 3D perspective change does not work and the rotation is initialized.
// Play with this one.
let MAX_PITCH_ALTITUDE : Double = 100000
// Play with this one for best results detecting a swype. The 3D perspective change is
// recognized quite quickly, thats the reason a swype recognizer here is of no use.
let SWYPE_SENSITIVITY : CGFloat = 0.5 // play with this one
if let _ = initialMapGestureModeIsRotation {
// Gesture mode is already determined.
// Swypes don't care us anymore.
return
}
if mapView?.camera.altitude > MAX_PITCH_ALTITUDE {
// Altitude is too high to adjust pitch.
return
}
let panned = sender.translationInView(mapView)
if fabs(panned.y) > SWYPE_SENSITIVITY {
// Initial swype up or down.
// Map gesture is most likely a 3D perspective correction.
initialMapGestureModeIsRotation = false
}
}
// *
// ******************************************************************************************
// *
@IBAction func pinchGestureRecognizer(sender: UIPinchGestureRecognizer) {
// pinch is zoom. this always enables rotation mode.
if (initialMapGestureModeIsRotation == nil) {
initialMapGestureModeIsRotation = true
// Initial pinch detected. This is normally a zoom
// which goes in hand with a rotation.
}
}
// *
// ******************************************************************************************
CADisplayLink
,它会在屏幕刷新时触发一个选择器,这足以与 MapKit 动画的每一帧同步。在每次循环中,检查方向值并更新你的注释视图。