Refactor user location annotation

Adds:
- Accuracy ring
- Heading indicator

Fixes:
- Pulse sync
- Visual appearance (removes slight aliasing)

Closes #1222 (squashed)
This commit is contained in:
Jason Wray
2015-04-06 21:37:58 -07:00
parent 0a527f623e
commit 26d300b4f4
3 changed files with 242 additions and 103 deletions

View File

@@ -53,3 +53,13 @@ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
===========================================================================
Mapbox GL uses portions of SVPulsingAnnotationView.
Copyright (c) 2013, Sam Vermette <hello@samvermette.com>
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

View File

@@ -737,6 +737,7 @@ std::chrono::steady_clock::duration secondsAsDuration(float duration)
{
[self updateHeadingForDeviceOrientation];
[self updateCompass];
[self updateUserLocationAnnotationView];
}
}

View File

@@ -5,7 +5,8 @@
#import "MGLAnnotation.h"
#import "MGLMapView.h"
const CGFloat MGLTrackingDotRingWidth = 24.0;
const CGFloat MGLUserLocationAnnotationDotSize = 22.0;
const CGFloat MGLUserLocationAnnotationHaloSize = 115.0;
@interface MGLUserLocationAnnotationView ()
@@ -17,9 +18,15 @@ const CGFloat MGLTrackingDotRingWidth = 24.0;
@implementation MGLUserLocationAnnotationView
{
CALayer *_headingIndicatorLayer;
CAShapeLayer *_headingIndicatorMaskLayer;
CALayer *_accuracyRingLayer;
CALayer *_dotBorderLayer;
CALayer *_dotLayer;
double _oldHeadingAccuracy;
CLLocationAccuracy _oldHorizontalAccuracy;
double _oldZoom;
}
- (instancetype)initWithFrame:(CGRect)frame
@@ -30,7 +37,7 @@ const CGFloat MGLTrackingDotRingWidth = 24.0;
- (instancetype)initInMapView:(MGLMapView *)mapView
{
if (self = [super initWithFrame:CGRectMake(0, 0, MGLTrackingDotRingWidth, MGLTrackingDotRingWidth)])
if (self = [super initWithFrame:CGRectMake(0, 0, MGLUserLocationAnnotationDotSize, MGLUserLocationAnnotationDotSize)])
{
self.annotation = [[MGLUserLocation alloc] initWithMapView:mapView];
_mapView = mapView;
@@ -48,164 +55,285 @@ const CGFloat MGLTrackingDotRingWidth = 24.0;
- (void)setTintColor:(UIColor *)tintColor
{
UIImage *trackingDotHaloImage = [self trackingDotHaloImage];
_haloLayer.bounds = CGRectMake(0, 0, trackingDotHaloImage.size.width, trackingDotHaloImage.size.height);
_haloLayer.contents = (__bridge id)[trackingDotHaloImage CGImage];
if (_accuracyRingLayer)
{
_accuracyRingLayer.backgroundColor = [tintColor CGColor];
}
UIImage *dotImage = [self dotImage];
_dotLayer.bounds = CGRectMake(0, 0, dotImage.size.width, dotImage.size.height);
_dotLayer.contents = (__bridge id)[dotImage CGImage];
_haloLayer.backgroundColor = [tintColor CGColor];
_dotLayer.backgroundColor = [tintColor CGColor];
_headingIndicatorLayer.contents = (__bridge id)[[self headingIndicatorTintedGradientImage] CGImage];
}
- (void)setupLayers
{
if (CLLocationCoordinate2DIsValid(self.annotation.coordinate))
{
// update heading indicator
//
if (_headingIndicatorLayer)
{
_headingIndicatorLayer.hidden = (_mapView.userTrackingMode == MGLUserTrackingModeFollowWithHeading) ? NO : YES;
if (_oldHeadingAccuracy != self.annotation.heading.headingAccuracy)
{
// recalculate the clipping mask based on updated accuracy
_headingIndicatorMaskLayer.path = [[self headingIndicatorClippingMask] CGPath];
_oldHeadingAccuracy = self.annotation.heading.headingAccuracy;
}
}
// 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;
}
// update accuracy ring
//
if (_accuracyRingLayer)
{
// FIX: This stops EVERYTHING... and that isn't necessarily necessary, now is it?
if (_oldZoom == self.mapView.zoomLevel && _oldHorizontalAccuracy == self.annotation.location.horizontalAccuracy)
{
return;
}
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)
{
UIImage *accuracyRingImage = [self accuracyRingImage];
_accuracyRingLayer = [CALayer layer];
_haloLayer.bounds = CGRectMake(0, 0, accuracyRingImage.size.width, accuracyRingImage.size.height);
_haloLayer.contents = (__bridge id)[accuracyRingImage CGImage];
_haloLayer.position = CGPointMake(super.layer.bounds.size.width / 2.0, super.layer.bounds.size.height / 2.0);
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)
{
UIImage *haloImage = [self trackingDotHaloImage];
_haloLayer = [CALayer layer];
_haloLayer.bounds = CGRectMake(0, 0, haloImage.size.width, haloImage.size.height);
_haloLayer.contents = (__bridge id)[haloImage CGImage];
_haloLayer.position = CGPointMake(super.layer.bounds.size.width / 2.0, super.layer.bounds.size.height / 2.0);
_haloLayer = [self circleLayerWithSize:MGLUserLocationAnnotationHaloSize];
_haloLayer.backgroundColor = [_mapView.tintColor CGColor];
_haloLayer.allowsGroupOpacity = NO;
[CATransaction begin];
// set defaults for the animations
CAAnimationGroup *animationGroup = [self loopingAnimationGroupWithDuration:3.0];
[CATransaction setAnimationDuration:3.5];
[CATransaction setAnimationTimingFunction:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseOut]];
// 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];
// scale out radially
//
CABasicAnimation *boundsAnimation = [CABasicAnimation animationWithKeyPath:@"transform"];
boundsAnimation.repeatCount = MAXFLOAT;
boundsAnimation.fromValue = [NSValue valueWithCATransform3D:CATransform3DMakeScale(0.1, 0.1, 1.0)];
boundsAnimation.toValue = [NSValue valueWithCATransform3D:CATransform3DMakeScale(2.0, 2.0, 1.0)];
boundsAnimation.removedOnCompletion = NO;
// 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];
[_haloLayer addAnimation:boundsAnimation forKey:@"animateScale"];
animationGroup.animations = @[boundsAnimation, opacityAnimation];
// go transparent as scaled out
//
CABasicAnimation *opacityAnimation = [CABasicAnimation animationWithKeyPath:@"opacity"];
opacityAnimation.repeatCount = MAXFLOAT;
opacityAnimation.fromValue = [NSNumber numberWithFloat:1.0];
opacityAnimation.toValue = [NSNumber numberWithFloat:-1.0];
opacityAnimation.removedOnCompletion = NO;
[_haloLayer addAnimation:opacityAnimation forKey:@"animateOpacity"];
[CATransaction commit];
[_haloLayer addAnimation:animationGroup forKey:@"animateTransformAndOpacity"];
[self.layer addSublayer:_haloLayer];
}
// white dot background with shadow
// background dot (white with black shadow)
//
if ( ! _dotBorderLayer)
{
CGRect rect = CGRectMake(0, 0, MGLTrackingDotRingWidth * 1.5, MGLTrackingDotRingWidth * 1.5);
_dotBorderLayer = [self circleLayerWithSize:MGLUserLocationAnnotationDotSize];
_dotBorderLayer.backgroundColor = [[UIColor whiteColor] CGColor];
_dotBorderLayer.shadowColor = [[UIColor blackColor] CGColor];
_dotBorderLayer.shadowOffset = CGSizeMake(0, 0);
_dotBorderLayer.shadowRadius = 3;
_dotBorderLayer.shadowOpacity = 0.25;
UIGraphicsBeginImageContextWithOptions(rect.size, NO, [[UIScreen mainScreen] scale]);
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSetShadow(context, CGSizeMake(0, 0), MGLTrackingDotRingWidth / 4.0);
CGContextSetFillColorWithColor(context, [[UIColor whiteColor] CGColor]);
CGContextFillEllipseInRect(context, CGRectMake((rect.size.width - MGLTrackingDotRingWidth) / 2.0, (rect.size.height - MGLTrackingDotRingWidth) / 2.0, MGLTrackingDotRingWidth, MGLTrackingDotRingWidth));
UIImage *whiteBackground = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
_dotBorderLayer = [CALayer layer];
_dotBorderLayer.bounds = CGRectMake(0, 0, whiteBackground.size.width, whiteBackground.size.height);
_dotBorderLayer.contents = (__bridge id)[whiteBackground CGImage];
_dotBorderLayer.position = CGPointMake(super.layer.bounds.size.width / 2.0, super.layer.bounds.size.height / 2.0);
[self.layer addSublayer:_dotBorderLayer];
}
// pulsing, tinted dot sublayer
// inner dot (pulsing, tinted)
//
if ( ! _dotLayer)
{
UIImage *dotImage = [self dotImage];
_dotLayer = [CALayer layer];
_dotLayer.bounds = CGRectMake(0, 0, dotImage.size.width, dotImage.size.height);
_dotLayer.contents = (__bridge id)[dotImage CGImage];
_dotLayer.position = CGPointMake(super.layer.bounds.size.width / 2.0, super.layer.bounds.size.height / 2.0);
_dotLayer = [self circleLayerWithSize:MGLUserLocationAnnotationDotSize * 0.75];
_dotLayer.backgroundColor = [_mapView.tintColor CGColor];
_dotLayer.shouldRasterize = NO;
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"transform"];
animation.repeatCount = MAXFLOAT;
animation.fromValue = [NSValue valueWithCATransform3D:CATransform3DMakeScale(1.0, 1.0, 1.0)];
animation.toValue = [NSValue valueWithCATransform3D:CATransform3DMakeScale(0.8, 0.8, 1.0)];
animation.removedOnCompletion = NO;
animation.autoreverses = YES;
animation.duration = 1.5;
animation.beginTime = CACurrentMediaTime() + 1.0;
animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn];
// set defaults for the animations
CAAnimationGroup *animationGroup = [self loopingAnimationGroupWithDuration:1.5];
animationGroup.autoreverses = YES;
animationGroup.fillMode = kCAFillModeBoth;
[_dotLayer addAnimation:animation forKey:@"animateTransform"];
// 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];
}
}
}
- (UIImage *)accuracyRingImage
- (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 metersPerPixelAtLatitude:self.annotation.coordinate.latitude];
UIGraphicsBeginImageContextWithOptions(CGSizeMake(pixelRadius * 2, pixelRadius * 2), NO, [[UIScreen mainScreen] scale]);
CGContextSetStrokeColorWithColor(UIGraphicsGetCurrentContext(), [[UIColor colorWithRed:0.378 green:0.552 blue:0.827 alpha:0.7] CGColor]);
CGContextSetFillColorWithColor(UIGraphicsGetCurrentContext(), [[UIColor colorWithRed:0.378 green:0.552 blue:0.827 alpha:0.15] CGColor]);
CGContextSetLineWidth(UIGraphicsGetCurrentContext(), 2.0);
CGContextStrokeEllipseInRect(UIGraphicsGetCurrentContext(), CGRectMake(0, 0, pixelRadius * 2, pixelRadius * 2));
UIImage *finalImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return finalImage;
return pixelRadius * 2;
}
- (UIImage *)trackingDotHaloImage
- (UIImage *)headingIndicatorTintedGradientImage
{
UIGraphicsBeginImageContextWithOptions(CGSizeMake(100, 100), NO, [[UIScreen mainScreen] scale]);
CGContextSetFillColorWithColor(UIGraphicsGetCurrentContext(), [[_mapView.tintColor colorWithAlphaComponent:0.75] CGColor]);
CGContextFillEllipseInRect(UIGraphicsGetCurrentContext(), CGRectMake(0, 0, 100, 100));
UIImage *finalImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
UIImage *image;
return finalImage;
}
- (UIImage *)dotImage
{
CGFloat tintedWidth = MGLTrackingDotRingWidth * 0.7;
CGFloat haloRadius = MGLUserLocationAnnotationHaloSize / 2.0;
CGRect rect = CGRectMake(0, 0, tintedWidth, tintedWidth);
UIGraphicsBeginImageContextWithOptions(CGSizeMake(MGLUserLocationAnnotationHaloSize, haloRadius), NO, 0);
UIGraphicsBeginImageContextWithOptions(rect.size, NO, [[UIScreen mainScreen] scale]);
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSetFillColorWithColor(context, [_mapView.tintColor CGColor]);
CGContextFillEllipseInRect(context, CGRectMake((rect.size.width - tintedWidth) / 2.0, (rect.size.height - tintedWidth) / 2.0, tintedWidth, tintedWidth));
// 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);
UIImage *tintedForeground = UIGraphicsGetImageFromCurrentImageContext();
// 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();
return tintedForeground;
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