Files
MapLibreParticleLab/platform/ios/src/MGLUserLocationAnnotationView.m
Minh Nguyễn 97fc2120c4 [ios] Fixed entering tracking mode at launch
Entering user tracking mode at launch now zooms in and shows the user dot or user puck. The user dot’s heading indicator now points in the correct direction during the animation to the initial location. Course changes are reflected immediately even in the absence of location changes.

Fixes #1145.
2016-01-26 18:48:56 -08:00

511 lines
17 KiB
Objective-C

#import "MGLUserLocationAnnotationView.h"
#import "MGLUserLocation.h"
#import "MGLUserLocation_Private.h"
#import "MGLAnnotation.h"
#import "MGLMapView.h"
const CGFloat MGLUserLocationAnnotationDotSize = 22.0;
const CGFloat MGLUserLocationAnnotationHaloSize = 115.0;
const CGFloat MGLUserLocationAnnotationPuckSize = 45.0;
const CGFloat MGLUserLocationAnnotationArrowSize = MGLUserLocationAnnotationPuckSize * 0.6;
@interface MGLUserLocationAnnotationView ()
@property (nonatomic, readwrite) CALayer *haloLayer;
@end
#pragma mark -
@implementation MGLUserLocationAnnotationView
{
BOOL _puckModeActivated;
CALayer *_puckDot;
CAShapeLayer *_puckArrow;
CALayer *_headingIndicatorLayer;
CAShapeLayer *_headingIndicatorMaskLayer;
CALayer *_accuracyRingLayer;
CALayer *_dotBorderLayer;
CALayer *_dotLayer;
double _oldHeadingAccuracy;
CLLocationAccuracy _oldHorizontalAccuracy;
double _oldZoom;
double _oldPitch;
}
- (instancetype)initWithFrame:(CGRect)frame
{
NSAssert(NO, @"No containing map view specified. Call -initInMapView: instead.");
return self = [self init];
}
- (instancetype)initInMapView:(MGLMapView *)mapView
{
CGFloat frameSize = (mapView.userTrackingMode == MGLUserTrackingModeFollowWithCourse) ? MGLUserLocationAnnotationPuckSize : MGLUserLocationAnnotationDotSize;
if (self = [super initWithFrame:CGRectMake(0, 0, frameSize, frameSize)])
{
self.annotation = [[MGLUserLocation alloc] initWithMapView:mapView];
_mapView = mapView;
[self setupLayers];
self.accessibilityLabel = @"User location";
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)decoder
{
MGLMapView *mapView = [decoder valueForKey:@"mapView"];
return [self initInMapView:mapView];
}
- (void)setTintColor:(UIColor *)tintColor
{
if (_puckModeActivated)
{
_puckArrow.fillColor = [tintColor CGColor];
}
else
{
if (_accuracyRingLayer)
{
_accuracyRingLayer.backgroundColor = [tintColor CGColor];
}
_haloLayer.backgroundColor = [tintColor CGColor];
_dotLayer.backgroundColor = [tintColor CGColor];
_headingIndicatorLayer.contents = (__bridge id)[[self headingIndicatorTintedGradientImage] CGImage];
}
}
- (void)setupLayers
{
if (CLLocationCoordinate2DIsValid(self.annotation.coordinate))
{
(_mapView.userTrackingMode == MGLUserTrackingModeFollowWithCourse) ? [self drawPuck] : [self drawDot];
[self updatePitch];
}
}
- (void)updatePitch
{
if (self.mapView.camera.pitch != _oldPitch)
{
CATransform3D t = CATransform3DRotate(CATransform3DIdentity, MGLRadiansFromDegrees(self.mapView.camera.pitch), 1.0, 0, 0);
self.layer.sublayerTransform = t;
[self updateFaux3DEffect];
_oldPitch = self.mapView.camera.pitch;
}
}
- (void)updateFaux3DEffect
{
CGFloat pitch = MGLRadiansFromDegrees(self.mapView.camera.pitch);
if (_puckDot)
{
_puckDot.shadowOffset = CGSizeMake(0, fmaxf(pitch * 10.f, 1.f));
_puckDot.shadowRadius = fmaxf(pitch * 5.f, 0.75f);
}
if (_dotBorderLayer)
{
_dotBorderLayer.shadowOffset = CGSizeMake(0.f, pitch * 10.f);
_dotBorderLayer.shadowRadius = fmaxf(pitch * 5.f, 3.f);
}
if (_dotLayer)
{
_dotLayer.zPosition = pitch * 2.f;
}
}
- (void)drawPuck
{
if ( ! _puckModeActivated)
{
self.layer.sublayers = nil;
_headingIndicatorLayer = nil;
_headingIndicatorMaskLayer = nil;
_accuracyRingLayer = nil;
_haloLayer = nil;
_dotBorderLayer = nil;
_dotLayer = nil;
}
// background dot (white with black shadow)
//
if ( ! _puckDot)
{
_puckDot = [self circleLayerWithSize:MGLUserLocationAnnotationPuckSize];
_puckDot.backgroundColor = [[UIColor whiteColor] CGColor];
_puckDot.shadowColor = [[UIColor blackColor] CGColor];
_puckDot.shadowOpacity = 0.25;
if (self.mapView.camera.pitch)
{
[self updateFaux3DEffect];
}
else
{
_puckDot.shadowOffset = CGSizeMake(0, 1);
_puckDot.shadowRadius = 0.75;
}
[self.layer addSublayer:_puckDot];
}
// arrow
//
if ( ! _puckArrow)
{
_puckArrow = [CAShapeLayer layer];
_puckArrow.path = [[self puckArrow] CGPath];
_puckArrow.fillColor = [_mapView.tintColor CGColor];
_puckArrow.bounds = CGRectMake(0, 0, MGLUserLocationAnnotationArrowSize, MGLUserLocationAnnotationArrowSize);
_puckArrow.position = CGPointMake(super.bounds.size.width / 2.0, super.bounds.size.height / 2.0);
_puckArrow.shouldRasterize = YES;
_puckArrow.rasterizationScale = [UIScreen mainScreen].scale;
_puckArrow.drawsAsynchronously = YES;
[self.layer addSublayer:_puckArrow];
}
if (self.annotation.location.course >= 0)
{
_puckArrow.affineTransform = CGAffineTransformRotate(CGAffineTransformIdentity, -MGLRadiansFromDegrees(self.mapView.direction - self.annotation.location.course));
}
if ( ! _puckModeActivated)
{
_puckModeActivated = YES;
[self updateFaux3DEffect];
}
}
- (UIBezierPath *)puckArrow
{
CGFloat max = MGLUserLocationAnnotationArrowSize;
UIBezierPath *bezierPath = UIBezierPath.bezierPath;
[bezierPath moveToPoint: CGPointMake(max * 0.5, 0)];
[bezierPath addLineToPoint: CGPointMake(max * 0.1, max)];
[bezierPath addLineToPoint: CGPointMake(max * 0.5, max * 0.65)];
[bezierPath addLineToPoint: CGPointMake(max * 0.9, max)];
[bezierPath addLineToPoint: CGPointMake(max * 0.5, 0)];
[bezierPath closePath];
return bezierPath;
}
- (void)drawDot
{
if (_puckModeActivated)
{
self.layer.sublayers = nil;
_puckDot = nil;
_puckArrow = nil;
}
BOOL showHeadingIndicator = _mapView.userTrackingMode == MGLUserTrackingModeFollowWithHeading;
// update heading indicator
//
if (showHeadingIndicator)
{
_headingIndicatorLayer.hidden = NO;
// heading indicator (tinted, semi-circle)
//
if ( ! _headingIndicatorLayer && self.annotation.heading.headingAccuracy)
{
CGFloat headingIndicatorSize = MGLUserLocationAnnotationHaloSize;
_headingIndicatorLayer = [CALayer layer];
_headingIndicatorLayer.bounds = CGRectMake(0, 0, headingIndicatorSize, headingIndicatorSize);
_headingIndicatorLayer.position = CGPointMake(super.bounds.size.width / 2.0, super.bounds.size.height / 2.0);
_headingIndicatorLayer.contents = (__bridge id)[[self headingIndicatorTintedGradientImage] CGImage];
_headingIndicatorLayer.contentsGravity = kCAGravityBottom;
_headingIndicatorLayer.contentsScale = [UIScreen mainScreen].scale;
_headingIndicatorLayer.opacity = 0.4;
_headingIndicatorLayer.shouldRasterize = YES;
_headingIndicatorLayer.rasterizationScale = [UIScreen mainScreen].scale;
_headingIndicatorLayer.drawsAsynchronously = YES;
[self.layer insertSublayer:_headingIndicatorLayer below:_dotBorderLayer];
}
// heading indicator accuracy mask (fan-shaped)
//
if ( ! _headingIndicatorMaskLayer && self.annotation.heading.headingAccuracy)
{
_headingIndicatorMaskLayer = [CAShapeLayer layer];
_headingIndicatorMaskLayer.frame = _headingIndicatorLayer.bounds;
_headingIndicatorMaskLayer.path = [[self headingIndicatorClippingMask] CGPath];
// apply the mask to the halo-radius-sized gradient layer
_headingIndicatorLayer.mask = _headingIndicatorMaskLayer;
_oldHeadingAccuracy = self.annotation.heading.headingAccuracy;
}
else if (_oldHeadingAccuracy != self.annotation.heading.headingAccuracy)
{
// recalculate the clipping mask based on updated accuracy
_headingIndicatorMaskLayer.path = [[self headingIndicatorClippingMask] CGPath];
_oldHeadingAccuracy = self.annotation.heading.headingAccuracy;
}
if (self.annotation.heading.trueHeading >= 0)
{
_headingIndicatorLayer.affineTransform = CGAffineTransformRotate(CGAffineTransformIdentity, -MGLRadiansFromDegrees(self.mapView.direction - self.annotation.heading.trueHeading));
}
}
else
{
[_headingIndicatorLayer removeFromSuperlayer];
[_headingIndicatorMaskLayer removeFromSuperlayer];
_headingIndicatorLayer = nil;
_headingIndicatorMaskLayer = nil;
}
// update accuracy ring (if zoom or horizontal accuracy have changed)
//
if (_accuracyRingLayer && (_oldZoom != self.mapView.zoomLevel || _oldHorizontalAccuracy != self.annotation.location.horizontalAccuracy))
{
CGFloat accuracyRingSize = [self calculateAccuracyRingSize];
// only show the accuracy ring if it won't be obscured by the location dot
if (accuracyRingSize > MGLUserLocationAnnotationDotSize + 15)
{
_accuracyRingLayer.hidden = NO;
_accuracyRingLayer.bounds = CGRectMake(0, 0, accuracyRingSize, accuracyRingSize);
_accuracyRingLayer.cornerRadius = accuracyRingSize / 2;
// match the halo to the accuracy ring
_haloLayer.bounds = _accuracyRingLayer.bounds;
_haloLayer.cornerRadius = _accuracyRingLayer.cornerRadius;
_haloLayer.shouldRasterize = NO;
}
else
{
_accuracyRingLayer.hidden = YES;
_haloLayer.bounds = CGRectMake(0, 0, MGLUserLocationAnnotationHaloSize, MGLUserLocationAnnotationHaloSize);
_haloLayer.cornerRadius = MGLUserLocationAnnotationHaloSize / 2.0;
_haloLayer.shouldRasterize = YES;
_haloLayer.rasterizationScale = [UIScreen mainScreen].scale;
}
// store accuracy and zoom so we're not redrawing unchanged location updates
_oldHorizontalAccuracy = self.annotation.location.horizontalAccuracy;
_oldZoom = self.mapView.zoomLevel;
}
// accuracy ring (circular, tinted, mostly-transparent)
//
if ( ! _accuracyRingLayer && self.annotation.location.horizontalAccuracy)
{
CGFloat accuracyRingSize = [self calculateAccuracyRingSize];
_accuracyRingLayer = [self circleLayerWithSize:accuracyRingSize];
_accuracyRingLayer.backgroundColor = [_mapView.tintColor CGColor];
_accuracyRingLayer.opacity = 0.1;
_accuracyRingLayer.shouldRasterize = NO;
_accuracyRingLayer.allowsGroupOpacity = NO;
[self.layer addSublayer:_accuracyRingLayer];
}
// expanding sonar-like pulse (circular, tinted, fades out)
//
if ( ! _haloLayer)
{
_haloLayer = [self circleLayerWithSize:MGLUserLocationAnnotationHaloSize];
_haloLayer.backgroundColor = [_mapView.tintColor CGColor];
_haloLayer.allowsGroupOpacity = NO;
_haloLayer.zPosition = -0.1f;
// set defaults for the animations
CAAnimationGroup *animationGroup = [self loopingAnimationGroupWithDuration:3.0];
// scale out radially with initial acceleration
CAKeyframeAnimation *boundsAnimation = [CAKeyframeAnimation animationWithKeyPath:@"transform.scale.xy"];
boundsAnimation.values = @[@0, @0.35, @1];
boundsAnimation.keyTimes = @[@0, @0.2, @1];
// go transparent as scaled out, start semi-opaque
CAKeyframeAnimation *opacityAnimation = [CAKeyframeAnimation animationWithKeyPath:@"opacity"];
opacityAnimation.values = @[@0.4, @0.4, @0];
opacityAnimation.keyTimes = @[@0, @0.2, @1];
animationGroup.animations = @[boundsAnimation, opacityAnimation];
[_haloLayer addAnimation:animationGroup forKey:@"animateTransformAndOpacity"];
[self.layer addSublayer:_haloLayer];
}
// background dot (white with black shadow)
//
if ( ! _dotBorderLayer)
{
_dotBorderLayer = [self circleLayerWithSize:MGLUserLocationAnnotationDotSize];
_dotBorderLayer.backgroundColor = [[UIColor whiteColor] CGColor];
_dotBorderLayer.shadowColor = [[UIColor blackColor] CGColor];
_dotBorderLayer.shadowOpacity = 0.25;
if (self.mapView.camera.pitch)
{
[self updateFaux3DEffect];
}
else
{
_dotBorderLayer.shadowOffset = CGSizeMake(0, 0);
_dotBorderLayer.shadowRadius = 3;
}
[self.layer addSublayer:_dotBorderLayer];
}
// inner dot (pulsing, tinted)
//
if ( ! _dotLayer)
{
_dotLayer = [self circleLayerWithSize:MGLUserLocationAnnotationDotSize * 0.75];
_dotLayer.backgroundColor = [_mapView.tintColor CGColor];
_dotLayer.shouldRasterize = NO;
// set defaults for the animations
CAAnimationGroup *animationGroup = [self loopingAnimationGroupWithDuration:1.5];
animationGroup.autoreverses = YES;
animationGroup.fillMode = kCAFillModeBoth;
// scale the dot up and down
CABasicAnimation *pulseAnimation = [CABasicAnimation animationWithKeyPath:@"transform.scale.xy"];
pulseAnimation.fromValue = @0.8;
pulseAnimation.toValue = @1;
// fade opacity in and out, subtly
CABasicAnimation *opacityAnimation = [CABasicAnimation animationWithKeyPath:@"opacity"];
opacityAnimation.fromValue = @0.8;
opacityAnimation.toValue = @1;
animationGroup.animations = @[pulseAnimation, opacityAnimation];
[_dotLayer addAnimation:animationGroup forKey:@"animateTransformAndOpacity"];
[self.layer addSublayer:_dotLayer];
}
if (_puckModeActivated)
{
_puckModeActivated = NO;
[self updateFaux3DEffect];
}
}
- (CALayer *)circleLayerWithSize:(CGFloat)layerSize
{
CALayer *circleLayer = [CALayer layer];
circleLayer.bounds = CGRectMake(0, 0, layerSize, layerSize);
circleLayer.position = CGPointMake(super.bounds.size.width / 2.0, super.bounds.size.height / 2.0);
circleLayer.cornerRadius = layerSize / 2.0;
circleLayer.shouldRasterize = YES;
circleLayer.rasterizationScale = [UIScreen mainScreen].scale;
circleLayer.drawsAsynchronously = YES;
return circleLayer;
}
- (CAAnimationGroup *)loopingAnimationGroupWithDuration:(CGFloat)animationDuration
{
CAAnimationGroup *animationGroup = [CAAnimationGroup animation];
animationGroup.duration = animationDuration;
animationGroup.repeatCount = INFINITY;
animationGroup.removedOnCompletion = NO;
animationGroup.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionDefault];
return animationGroup;
}
- (CGFloat)calculateAccuracyRingSize
{
CGFloat latRadians = self.annotation.coordinate.latitude * M_PI / 180.0f;
CGFloat pixelRadius = self.annotation.location.horizontalAccuracy / cos(latRadians) / [self.mapView metersPerPointAtLatitude:self.annotation.coordinate.latitude];
return pixelRadius * 2;
}
- (UIImage *)headingIndicatorTintedGradientImage
{
UIImage *image;
CGFloat haloRadius = MGLUserLocationAnnotationHaloSize / 2.0;
UIGraphicsBeginImageContextWithOptions(CGSizeMake(MGLUserLocationAnnotationHaloSize, haloRadius), NO, 0);
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
CGContextRef context = UIGraphicsGetCurrentContext();
// gradient from the tint color to no-alpha tint color
CGFloat gradientLocations[] = {0.0, 1.0};
CGGradientRef gradient = CGGradientCreateWithColors(
colorSpace, (__bridge CFArrayRef)@[(id)[_mapView.tintColor CGColor],
(id)[[_mapView.tintColor colorWithAlphaComponent:0] CGColor]], gradientLocations);
// draw the gradient from the center point to the edge (full halo radius)
CGPoint centerPoint = CGPointMake(haloRadius, haloRadius);
CGContextDrawRadialGradient(context, gradient,
centerPoint, 0.0,
centerPoint, haloRadius,
nil);
image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
CGGradientRelease(gradient);
CGColorSpaceRelease(colorSpace);
return image;
}
- (UIBezierPath *)headingIndicatorClippingMask
{
CGFloat accuracy = self.annotation.heading.headingAccuracy;
// size the mask using exagerated accuracy, but keep within a good display range
CGFloat clippingDegrees = 90 - (accuracy * 1.5);
clippingDegrees = fmin(clippingDegrees, 55);
clippingDegrees = fmax(clippingDegrees, 10);
CGRect ovalRect = CGRectMake(0, 0, MGLUserLocationAnnotationHaloSize, MGLUserLocationAnnotationHaloSize);
UIBezierPath *ovalPath = UIBezierPath.bezierPath;
// clip the oval to ± incoming accuracy degrees (converted to radians), from the top
[ovalPath addArcWithCenter:CGPointMake(CGRectGetMidX(ovalRect), CGRectGetMidY(ovalRect))
radius:CGRectGetWidth(ovalRect) / 2.0
startAngle:(-180 + clippingDegrees) * M_PI / 180
endAngle:-clippingDegrees * M_PI / 180
clockwise:YES];
[ovalPath addLineToPoint:CGPointMake(CGRectGetMidX(ovalRect), CGRectGetMidY(ovalRect))];
[ovalPath closePath];
return ovalPath;
}
@end