Files
MapLibreParticleLab/platform/ios/src/MGLUserLocationAnnotationView.m
Minh Nguyễn 8d5a6b515f [ios] Only post layout change notifications when focused
Constantly posting accessibility layout change notifications on every location change made it very difficult to stay focused on an accessibility element, be it the user dot or some other element. Now we only post the notification when the user dot is focused, user tracking mode is off (so the user dot actually has a chance to move around), and the application is active (not in the middle of application switching).
2016-04-26 14:19:10 -07:00

604 lines
20 KiB
Objective-C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#import "MGLUserLocationAnnotationView.h"
#import "MGLUserLocation.h"
#import "MGLUserLocation_Private.h"
#import "MGLAnnotation.h"
#import "MGLMapView.h"
#import "MGLCoordinateFormatter.h"
#import "NSBundle+MGLAdditions.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;
MGLCoordinateFormatter *_accessibilityCoordinateFormatter;
}
- (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.accessibilityTraits = UIAccessibilityTraitButton | UIAccessibilityTraitAdjustable | UIAccessibilityTraitUpdatesFrequently;
_accessibilityCoordinateFormatter = [[MGLCoordinateFormatter alloc] init];
_accessibilityCoordinateFormatter.unitStyle = NSFormattingUnitStyleLong;
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)decoder
{
MGLMapView *mapView = [decoder valueForKey:@"mapView"];
return [self initInMapView:mapView];
}
- (BOOL)isAccessibilityElement
{
return !self.hidden;
}
- (NSString *)accessibilityLabel
{
return self.annotation.title;
}
- (NSString *)accessibilityValue
{
if (self.annotation.subtitle)
{
return self.annotation.subtitle;
}
// Each arcminute of longitude is at most about 1 nmi, too small for low zoom levels.
// Each arcsecond of longitude is at most about 30 m, too small for all but the very highest of zoom levels.
double zoomLevel = self.mapView.zoomLevel;
_accessibilityCoordinateFormatter.allowsMinutes = zoomLevel > 8;
_accessibilityCoordinateFormatter.allowsSeconds = zoomLevel > 20;
return [_accessibilityCoordinateFormatter stringFromCoordinate:self.mapView.centerCoordinate];
}
- (CGRect)accessibilityFrame
{
return CGRectInset(self.frame, -15, -15);
}
- (UIBezierPath *)accessibilityPath
{
return [UIBezierPath bezierPathWithOvalInRect:self.frame];
}
- (void)accessibilityIncrement
{
[self.mapView accessibilityIncrement];
}
- (void)accessibilityDecrement
{
[self.mapView accessibilityDecrement];
}
- (void)setHidden:(BOOL)hidden
{
BOOL oldValue = super.hidden;
[super setHidden:hidden];
if (oldValue != hidden)
{
UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, nil);
}
}
- (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)updateFrameWithSize:(CGFloat)size
{
CGSize newSize = CGSizeMake(size, size);
if (CGSizeEqualToSize(self.frame.size, newSize))
{
return;
}
// Update frame size, keeping the existing center point.
CGPoint oldCenter = self.center;
CGRect newFrame = self.frame;
newFrame.size = newSize;
[self setFrame:newFrame];
[self setCenter:oldCenter];
}
- (void)drawPuck
{
if ( ! _puckModeActivated)
{
self.layer.sublayers = nil;
_headingIndicatorLayer = nil;
_headingIndicatorMaskLayer = nil;
_accuracyRingLayer = nil;
_haloLayer = nil;
_dotBorderLayer = nil;
_dotLayer = nil;
[self updateFrameWithSize:MGLUserLocationAnnotationPuckSize];
}
// 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;
[self updateFrameWithSize:MGLUserLocationAnnotationDotSize];
}
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,
kNilOptions);
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;
}
- (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event
{
// Allow mbgl to drive animation of this view’s bounds.
if ([event isEqualToString:@"bounds"])
{
return [NSNull null];
}
return [super actionForLayer:layer forKey:event];
}
@end