I see that the GMSPolyline protocol already defines a color property for its stroke color, but is there a way to shade the inside of its polygon (ideally with transparency)? I’m looking for a Google Maps equivalent to MKPolygon and friends.
A Polyline is different to a Polygon's. Polylines' have no concept of a fill color. File a feature request for Polygons to be added to the SDK.
There is a way, you can get something like this:
The approach is rather simple:
Add transparent noninteracting UIView with overriden drawing code and pass it CGPoints for drawing polygons
Get your CLLocationCoordinate2D coordinates for polygons and convert them to CGPoints for drawing
Update those CGPoints every time map moves so you can redraw them in the right position and make that UIView redraw itself.
So, what you want to do is add an UIView on top of your mapview, which is transparent and non-userinteractive, which has overridden drawRect method. It is provided with a double array of CGPoints, like CGpoint **points, acccessed with points[i][j] where i is each of closed polygons and j are individual points of each polygon. The class would be, let's call it OverView:
#import "OverView.h"
#interface OverView ()
{
CGPoint **points;
int *pointsForPolygon;
int count;
}
#end
#implementation OverView
- (id)initWithFrame:(CGRect)frame andNumberOfPoints:(int)numpoints andPoints:(CGPoint **)passedPoints andPointsForPolygon:(int *)passedPointsForPolygon;{
self = [super initWithFrame:frame];
if (self) {
// You want this to be transparent and non-user-interactive
self.userInteractionEnabled = NO;
self.backgroundColor = [UIColor clearColor];
// Passed data
points = passedPoints; // all CGPoints
pointsForPolygon = passedPointsForPolygon; // number of cgpoints for each polygon
count = numpoints; // Number of polygons
}
return self;
}
// Only override drawRect: if you perform custom drawing.
// An empty implementation adversely affects performance during animation.
- (void)drawRect:(CGRect)rect
{
for(int i=0; i<count; i++) // For each of polygons, like blue ones in picture above
{
if (pointsForPolygon[i] < 2) // Require at least 3 points
continue;
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSetStrokeColorWithColor(context, [UIColor redColor].CGColor);
CGContextSetRGBFillColor(context, 0.0, 0.0, 1.0, 1.0);
CGContextSetLineWidth(context, 2.0);
for(int j = 0; j < pointsForPolygon[i]; j++)
{
CGPoint point = points[i][j];
if(j == 0)
{
// Move to the first point
CGContextMoveToPoint(context, point.x, point.y);
}
else
{
// Line to others
CGContextAddLineToPoint(context, point.x, point.y);
}
}
CGContextClosePath(context); // And close the path
CGContextFillPath(context);
CGContextStrokePath(context);
}
}
#end
Now, in original UIViewController with mapview, you need to have access to all coordinates that make all the polygons (same array as points, but consisting of CLLocationCoordinate2D, and several others:
#interface ViewController () <GMSMapViewDelegate>
{
CGPoint **points;
int howmanypoints;
int *pointsForPolygon;
CLLocationCoordinate2D **acoordinates;
}
acoordinates is populated wherever you get your coordinates for polygons, I parse the response string from Fusion Tables, part of my parser method
- (void)parseResponse2
{
NSMutableArray *fullArray = [[self.fusionStringBeaches componentsSeparatedByString:#"\n"] mutableCopy];
howmanypoints = fullArray.count; // This is number of polygons
pointsForPolygon = (int *)calloc(howmanypoints, sizeof(int)); // Number of points for each of the polygons
points = (CGPoint **)calloc(howmanypoints, sizeof(CGPoint *));
acoordinates = (CLLocationCoordinate2D **)calloc(howmanypoints, sizeof(CLLocationCoordinate2D *));
for(int i=0; i<fullArray.count; i++)
{
// Some parsing skipped here
points[i] = (CGPoint *)calloc(koji, sizeof(CGPoint));
acoordinates[i] = (CLLocationCoordinate2D *)calloc(koji, sizeof(CLLocationCoordinate2D));
pointsForPolygon[i] = koji;
if (koji > 2)
{
// Parsing skipped
for (int j=0; j<koji; j++)
{
CLLocationCoordinate2D coordinate = CLLocationCoordinate2DMake(coordinates[j].latitude, coordinates[j].longitude);
// Here, you convert coordinate and add it to points array to be passed to overview
points[i][j] = [self.mapView.projection pointForCoordinate:coordinate];
// and added that coordinate to array for future access
acoordinates[i][j] = coordinate;
}
}
}
// Finally, allocate OverView passing points array and polygon and coordinate counts
self.overView = [[OverView alloc] initWithFrame:self.view.bounds
andNumberOfPoints:howmanypoints
andPoints:points
andPointsForPolygon:pointsForPolygon];
// And add it to view
[self.view addSubview:self.overView];
}
Now, you have Polygons where you want them, but must observe - (void)mapView:(GMSMapView *)mapView didChangeCameraPosition:(GMSCameraPosition *)position delegate method as drawn polygons won't move with map. The trick is that you have your 2D array of coordinates acoordinates and you can user helper function (CGPoint *)[self.mapview.projection pointForCoordinate:(CLLocationCoordinate2D)coordinate] to recalculate the positions, like:
- (void)mapView:(GMSMapView *)mapView didChangeCameraPosition:(GMSCameraPosition *)position
{
if (points != nil)
{
// Determine new points to pass
for (int i=0; i<howmanypoints; i++)
{
for(int j=0; j<pointsForPolygon[i]; j++)
{
// Call method to determine new CGPoint for each coordinate
points[i][j] = [self.mapView.projection pointForCoordinate:acoordinates[i][j]];
}
}
// No need to pass points again as they were passed as pointers, just refresh te view
[self.overView setNeedsDisplay];
}
}
And that's it. Hope you got the gist of it. Please, comment if I need to clarify something. I can also make a small complete project and upload it to github so you can research it better.
Related
I have a list of 50+ coordinates. What is the most efficient way to draw lines between all these coordinates (should create a "circular" path because they all have a display order) that is also easy to customize (line thickness, color, etc...)?
Thanks!
I am not sure I understand your question for certain. If you are looking for a list of points to display from end to end, then you will want to create a MKPolyline object from those points, making sure the points are added to the myPoints array in the order you want to connect them:
CLLocationCoordinate2D coordinates[[myPoints count]];
int i = 0;
for (Checkpoint *point in myPoints)
{
coordinates[i] = CLLocationCoordinate2DMake([point.lat floatValue] , [point.lon floatValue]);
i++;
}
self.polyline = [MKPolyline polylineWithCoordinates:coordinates count: [myPoints count]];
[mapView addOverlay:self.polyline];
Then make sure you are implementing the delegate method - mapView:rendererForOverlay:. Here's an example, but tailor it to your needs:
-(MKOverlayRenderer*)mapView:(MKMapView*)mapView rendererForOverlay:(id <MKOverlay>)overlay
{
MKPolylineRenderer* lineView = [[MKPolylineRenderer alloc] initWithPolyline:self.polyline];
lineView.strokeColor = [UIColor blueColor];
lineView.lineWidth = 7;
return lineView;
}
However, if you really want a closed loop (circular) object, then you will want to create a MKPolygon object instead. The process is quite similar; in that case replace the self.polyline initializer above with this code:
self.polygon = [MKPolygon polygonWithCoordinates:coordinates count: [myPoints count]];
[mapView addOverlay:self.polygon];
The - mapView:rendererForOverlay: code should remain the same I think. I haven't tested this code, but hopefully it gets you moving in the right direction.
I'm new in OpenGLES, and tring to draw a points, but no results, here is my draw method
-(void) draw
{
[super draw];
if(!isBackgroundInited)
{
glDisable(GL_TEXTURE_2D);
glEnable(GL_POINTS);
ccDrawColor4B(100,100,100,255);
ccPointSize(2);
[self drawDots];
isBackgroundInited=YES;
glDisable(GL_POINTS);
glEnable(GL_TEXTURE_2D);
}
}
-(void) drawDots
{
CGSize activeArea=CGSizeMake(winSize.width-2*margin, winSize.height-2*margin);
float xMargin=activeArea.width/dotsCount;
float yMargin=activeArea.height/dotsCount;
float yPosition=margin;
for(int i =0;i<10;i++)
{
float xPosition=margin;
for (int j=0;j<10;j++)
{
ccDrawPoint(CGPointMake(xPosition, yPosition));
xPosition+=xMargin;
}
yPosition+=yMargin;
}
}
You're drawing the dots only on the very first frame. Remove the isBackgroundInited ivar and things should appear on your screen.
OpenGL clears the framebuffer every frame, as is customary for games, so the entire contents of the screen need to be redrawn every frame.
I am trying to use Apples method for detecting if a point is in on a UIBezierPath. However it returns an ‘invalid context’.
As you can see from the NSlog, I am passing a UIBezierPath and A point to check. In my case a touch point.
I don’t understand why. Can someone explain it to me or point me in the correct direction?
NSLOG -----
Path <UIBezierPath: 0x7f57110>
Contains point Path <UIBezierPath: 0x7f57110>
Touch point 425.000000 139.000000
<Error>: CGContextSaveGState: invalid context 0x0
<Error>: CGContextAddPath: invalid context 0x0
<Error>: CGContextPathContainsPoint: invalid context 0x0
<Error>: CGContextRestoreGState: invalid context 0x0
NO
Straight from Apples Documentation on how to determine a point in a path
- (BOOL)containsPoint:(CGPoint)point onPath:(UIBezierPath *)path inFillArea:(BOOL)inFill {
NSLog(#"contains point Path %#", path);
NSLog(#"Touch point %f %f", point.x, point.y );
CGContextRef context = UIGraphicsGetCurrentContext();
CGPathRef cgPath = path.CGPath;
BOOL isHit = NO;
// Determine the drawing mode to use. Default to detecting hits on the stroked portion of the path.
CGPathDrawingMode mode = kCGPathStroke;
if (inFill) { // Look for hits in the fill area of the path instead.
if (path.usesEvenOddFillRule)
mode = kCGPathEOFill;
else
mode = kCGPathFill;
}
// Save the graphics state so that the path can be removed later.
CGContextSaveGState(context);
CGContextAddPath(context, cgPath);
// Do the hit detection.
isHit = CGContextPathContainsPoint(context, point, mode);
CGContextRestoreGState(context);
return isHit;
}
Here is my touchesBegan method. I have my paths in an NSMutableArray. I parse the array to check all my paths to see if any has been touched.
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
CGPoint curPoint = [[touches anyObject] locationInView:self];
for (int i = 0; i < [pathInfo count]; i++){
NSArray *row = [[NSArray alloc] initWithArray:[pathInfo objectAtIndex:i]];
UIBezierPath *path = [row objectAtIndex:0];
NSLog(#"Path %#", path);
if ([self containsPoint:curPoint onPath:path inFillArea:NO]){
NSLog(#"YES");
} else {
NSLog(#"NO");
}
}
}
The CGContextPathContainsPoint method requires a graphics context, which Apple's sample code gets from UIGraphicsGetCurrentContext. However, UIGraphicsGetCurrentContext only works inside -[UIView drawRect:] or after a call to a function that sets a UI graphics context, like UIGraphicsBeginImageContext.
You can perform your hit-testing without a graphics context by using CGPathCreateCopyByStrokingPath (which was added in iOS 5.0) and CGPathContainsPoint on the stroked copy:
static BOOL strokedPathContainsPoint(CGPathRef unstrokedPath,
const CGAffineTransform *transform, CGFloat lineWidth,
CGLineCap lineCap, CGLineJoin lineJoin, CGFloat miterLimit,
CGPoint point, bool eoFill)
{
CGPathRef strokedPath = CGPathCreateCopyByStrokingPath(unstrokedPath,
transform, lineWidth, lineCap, lineJoin, miterLimit);
BOOL doesContain = CGPathContainsPoint(strokedPath, NULL, point, eoFill);
CGPathRelease(strokedPath);
return doesContain;
}
You have to decide what line width and other stroking parameters you want to use. For example:
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
CGPoint curPoint = [[touches anyObject] locationInView:self];
for (int i = 0; i < [pathInfo count]; i++){
NSArray *row = [[NSArray alloc] initWithArray:[pathInfo objectAtIndex:i]];
UIBezierPath *path = [row objectAtIndex:0];
NSLog(#"Path %#", path);
if (strokedPathContainsPoint(path.CGPath, NULL, 10.0f, kCGLineCapRound,
kCGLineJoinRound, 0, curPoint, path.usesEvenOddFillRule))
{
NSLog(#"YES");
} else {
NSLog(#"NO");
}
}
}
Note that CGPathCreateCopyByStrokingPath is probably somewhat expensive, so you might want to stroke your paths once, and save the stroked copies, instead of stroking them every time you need to test a point.
tl;dr: You should use CGPathContainsPoint( ... ) instead.
What went wrong
Your problem is that you have no context where you are trying to get it
CGContextRef context = UIGraphicsGetCurrentContext(); // <-- This line here...
The method UIGraphicsGetCurrentContext will only return a context if there is a valid current context. The two main examples are
Inside drawRect: (where the context is the view you are drawing into)
Inside you own image context (when you use UIGraphicsBeginImageContext() so that you can use Core Graphics to draw into an image (maybe you pass it to some other part of your code and display it in an image view or save it to disk)).
The solution
I don't know why you were doing all the extra work of contexts, saving and restoring state, etc. I seems that you missed the simple method CGPathContainsPoint().
BOOL isHit = CGPathContainsPoint(
path.CGPath,
NULL,
point,
path.usesEvenOddFillRule
);
Edit
If you want to hit test a stroke path you could use CGPathCreateCopyByStrokingPath() to to first create a new filled path of the path you are stroking (given a certain width etc.). Ole Begemann has a very good explanation on his blog about how to do it (including some sample code).
I use my custom subclass of MKAnnotationView. In mapView:didSelectAnnotationView: method of my Map's delegate I call the method of this class, which adds UIImageView with an image as a subview - it serves as my custom annotation callout.
When using default MKPinAnnotationView map does automatically adjust map region to display the annotation callout that have just appeared. How can I implement this behavior using custom MKAnnotationView subclass?
Current solution
I've crafted demo project having the stuff discussed below implemented: see there
AdjustRegionToFitAnnotationCallout project.
The latest iOS7 changes in how Map Kit's MKMapView renders map annotations made me to revisit this problem. I've made more accurate thinking about it and come up with much, very much better solution. I will leave the previous solution at the bottom of this answer, but remember - I was so wrong when I did it that way.
First of all we will need a helper CGRectTransformToContainRect() that expands a given CGRect to contain another CGRect.
Note: it's behavior is different from what CGRectUnion() does - CGRectUnion() returns just the smallest CGRect containing both CGRects, whereas the following helper allows parallel movement i.e. CGRectTransformToContainRect(CGRectMake(0, 0, 100, 100), CGRectMake(50, 50, 100, 100)) equals (CGRect){50, 50, 100, 100} and not (CGRect){0, 0, 150, 150} like CGRectUnion() does it. This behavior is exactly what we need when we want to have only adjusts using parallel movements and want to avoid map's zooming.
static inline CGRect CGRectTransformToContainRect(CGRect rectToTransform, CGRect rectToContain) {
CGFloat diff;
CGRect transformedRect = rectToTransform;
// Transformed rect dimensions should encompass the dimensions of both rects
transformedRect.size.width = MAX(CGRectGetWidth(rectToTransform), CGRectGetWidth(rectToContain));
transformedRect.size.height = MAX(CGRectGetHeight(rectToTransform), CGRectGetHeight(rectToContain));
// Comparing max X borders of both rects, adjust if
if ((diff = CGRectGetMaxX(rectToContain) - CGRectGetMaxX(transformedRect)) > 0) {
transformedRect.origin.x += diff;
}
// Comparing min X borders of both rects, adjust if
else if ((diff = CGRectGetMinX(transformedRect) - CGRectGetMinX(rectToContain)) > 0) {
transformedRect.origin.x -= diff;
}
// Comparing max Y borders of both rects, adjust if
if ((diff = CGRectGetMaxY(rectToContain) - CGRectGetMaxY(transformedRect)) > 0) {
transformedRect.origin.y += diff;
}
// Comparing min Y borders of both rects, adjust if
else if ((diff = CGRectGetMinY(transformedRect) - CGRectGetMinY(rectToContain)) > 0) {
transformedRect.origin.y -= diff;
}
return transformedRect;
}
Adjust method wrapped into an Objective-C category MKMapView(Extensions):
#implementation MKMapView (Extensions)
- (void)adjustToContainRect:(CGRect)rect usingReferenceView:(UIView *)referenceView animated:(BOOL)animated {
// I just like this assert here
NSParameterAssert(referenceView);
CGRect visibleRect = [self convertRegion:self.region toRectToView:self];
// We convert our annotation from its own coordinate system to a coodinate system of a map's top view, so we can compare it with the bounds of the map itself
CGRect annotationRect = [self convertRect:rect fromView:referenceView.superview];
// Fatten the area occupied by your annotation if you want to have a margin after adjustment
CGFloat additionalMargin = 2;
adjustedRect.origin.x -= additionalMargin;
adjustedRect.origin.y -= additionalMargin;
adjustedRect.size.width += additionalMargin * 2;
adjustedRect.size.height += additionalMargin * 2;
// This is the magic: if the map must expand its bounds to contain annotation, it will do this
CGRect adjustedRect = CGRectTransformToContainRect(visibleRect, annotationRect);
// Now we just convert adjusted rect to a coordinate region
MKCoordinateRegion adjustedRegion = [self convertRect:adjustedRect toRegionFromView:self];
// Trivial regionThatFits: sugar and final setRegion:animated: call
[self setRegion:[self regionThatFits:adjustedRegion] animated:animated];
}
#end
Now the controller and views:
#interface AnnotationView : MKAnnotationView
#property AnnotationCalloutView *calloutView;
#property (readonly) CGRect annotationViewWithCalloutViewFrame;
#end
#implementation AnnotationView
- (void)showCalloutBubble {
// This is a code where you create your custom annotation callout view
// add add it using -[self addSubview:]
// At the end of this method a callout view should be displayed.
}
- (CGRect)annotationViewWithCalloutViewFrame {
// Here you should adjust your annotation frame so it match itself in the moment when annotation callout is displayed and ...
return CGRectOfAdjustedAnnotation; // ...
}
#end
When AnnotationView-classed annotation is selected on map, it adds its calloutView as a subview, so custom annotation callout view is displayed. It is done using MKMapViewDelegate's method:
- (void)mapView:(MapView *)mapView didSelectAnnotationView:(MKAnnotationView *)view {
// AnnotationPresenter is just a class that contains information to be displayed on callout annotation view
if ([view.annotation isKindOfClass:[AnnotationPresenter class]]) {
// Hide another annotation if it is shown
if (mapView.selectedAnnotationView != nil && [mapView.selectedAnnotationView isKindOfClass:[AnnotationView class]] && mapView.selectedAnnotationView != view) {
[mapView.selectedAnnotationView hideCalloutBubble];
}
mapView.selectedAnnotationView = view;
annotationView *annotationView = (annotationView *)view;
// This just adds *calloutView* as a subview
[annotationView showCalloutBubble];
[mapView adjustToContainRect:annotationView.annotationViewWithCalloutViewFrame usingReferenceView:annotationView animated:NO];
}
}
Of course your implementation may be different from what I've described here (mine is!). The most important part of above code is of course the [MKMapView adjustToContainRect:usingReferenceView:animated: method. Now I am really satisfied with the current solution and my understanding of this (and some related) problem. If you need any comments about the solution above, feel free to contact me (see profile).
The following Apple docs are very useful to understand what is going on in methods like -[MKMapView convertRect:fromView:]:
http://developer.apple.com/library/ios/#documentation/MapKit/Reference/MKMapView_Class/MKMapView/MKMapView.html
http://developer.apple.com/library/ios/#documentation/MapKit/Reference/MapKitDataTypesReference/Reference/reference.html
http://developer.apple.com/library/ios/#documentation/MapKit/Reference/MapKitFunctionsReference/Reference/reference.html
Also the first 10-15 minutes of WWDC 2013 session "What’s New in Map Kit" (#304) are very good to watch to have an excellent quick demo of the whole "Map with annotations" setup done by Apple engineer.
Initial solution (Does not work in iOS7, do not use it, use the solution above instead)
Somehow I forgot to answer my question at a time. Here is the complete solution I use nowadays (edited slightly for readability):
First of all a bit of map logic to be encapsulated somewhere in helpers file like MapKit+Helpers.h
typedef struct {
CLLocationDegrees top;
CLLocationDegrees bottom;
} MKLatitudeEdgedSpan;
typedef struct {
CLLocationDegrees left;
CLLocationDegrees right;
} MKLongitudeEdgedSpan;
typedef struct {
MKLatitudeEdgedSpan latitude;
MKLongitudeEdgedSpan longitude;
} MKEdgedRegion;
MKEdgedRegion MKEdgedRegionFromCoordinateRegion(MKCoordinateRegion region) {
MKEdgedRegion edgedRegion;
float latitude = region.center.latitude;
float longitude = region.center.longitude;
float latitudeDelta = region.span.latitudeDelta;
float longitudeDelta = region.span.longitudeDelta;
edgedRegion.longitude.left = longitude - longitudeDelta / 2;
edgedRegion.longitude.right = longitude + longitudeDelta / 2;
edgedRegion.latitude.top = latitude + latitudeDelta / 2;
edgedRegion.latitude.bottom = latitude - latitudeDelta / 2;
return edgedRegion;
}
Like MKCoordinateRegion (center coordinate + spans), MKEdgedRegion is just a way to define a region but using coordinates of its edges instead.
MKEdgedRegionFromCoordinateRegion() is a self-explanatory converter-method.
Suppose we have the following class for our annotations, containing its callout as a subview.
#interface AnnotationView : MKAnnotationView
#property AnnotationCalloutView *calloutView;
#end
When AnnotationView-classed annotation is selected on map, it adds its calloutView as a subview, so custom annotation callout view is displayed. It is done using MKMapViewDelegate's method:
- (void)mapView:(MapView *)mapView didSelectAnnotationView:(MKAnnotationView *)view {
// AnnotationPresenter is just a class that contains information to be displayed on callout annotation view
if ([view.annotation isKindOfClass:[AnnotationPresenter class]]) {
// Hide another annotation if it is shown
if (mapView.selectedAnnotationView != nil && [mapView.selectedAnnotationView isKindOfClass:[AnnotationView class]] && mapView.selectedAnnotationView != view) {
[mapView.selectedAnnotationView hideCalloutBubble];
}
mapView.selectedAnnotationView = view;
annotationView *annotationView = (annotationView *)view;
// This just adds *calloutView* as a subview
[annotationView showCalloutBubble];
/* Here the trickiest piece of code goes */
/* 1. We capture _annotation's (not callout's)_ frame in its superview's (map's!) coordinate system resulting in something like (CGRect){4910547.000000, 2967852.000000, 23.000000, 28.000000} The .origin.x and .origin.y are especially important! */
CGRect annotationFrame = annotationView.frame;
/* 2. Now we need to perform an adjustment, so our frame would correspond to the annotation view's _callout view subview_ that it holds. */
annotationFrame.origin.x = annotationFrame.origin.x + ANNOTATION_CALLOUT_TRIANLE_HALF; // Mine callout view has small x offset - you should choose yours!
annotationFrame.origin.y = annotationFrame.origin.y - ANNOTATION_CALLOUT_HEIGHT / 2; // Again my custom offset.
annotationFrame.size = placeAnnotationView.calloutView.frame.size; // We can grab calloutView size directly because in its case we don't care about the coordinate system.
MKCoordinateRegion mapRegion = mapView.region;
/* 3. This was a long run before I did stop to try to pass mapView.view as an argument to _toRegionFromView_. */
/* annotationView.superView is very important - it gives us the same coordinate system that annotationFrame.origin is based. */
MKCoordinateRegion annotationRegion = [mapView convertRect:annotationFrame toRegionFromView:annotationView.superview];
/* I hope that the following MKEdgedRegion magic is self-explanatory */
MKEdgedRegion mapEdgedRegion = MKEdgedRegionFromCoordinateRegion(mapRegion);
MKEdgedRegion annotationEdgedRegion = MKEdgedRegionFromCoordinateRegion(annotationRegion);
float diff;
if ((diff = (annotationEdgedRegion.longitude.left - mapEdgedRegion.longitude.left)) < 0 ||
(diff = (annotationEdgedRegion.longitude.right - mapEdgedRegion.longitude.right)) > 0)
mapRegion.center.longitude += diff;
if ((diff = (annotationEdgedRegion.latitude.bottom - mapEdgedRegion.latitude.bottom)) < 0 ||
(diff = (annotationEdgedRegion.latitude.top - mapEdgedRegion.latitude.top)) > 0)
mapRegion.center.latitude += diff;
mapView.region = mapRegion;
}
}
I was looking for a similar solution, to fit a route and a callout in the visible rectangle of the screen. I tried some solutions but finally ended up just with setting enough padding on setVisibleMapRect:edgePadding:animated:. May not be as sophisticated, but basically does what I needed.
MKMapRect routeMapRect = myRoute.polyline.boundingMapRect;
CGFloat padding = myCallout.bounds.width / 2.0;
[myMapView setVisibleMapRect: routeMapRect edgePadding:UIEdgeInsetsMake(padding, padding, padding, padding) animated:YES];
Of course this can be way more optimized, e.g. with detecting on which side you actually need the padding and setting a smaller one on the other sides. But you get the idea.
I'm plotting ~768 points for a graph using CGContextStrokePath. The problem is that every second I get a new data point, and thus redraw the graph. This is currently taking 50% CPU in what's already a busy App.
Graph drawing is done in drawRect in a UIView. The graph is time based, so new data points always arrive on the right hand side.
I'm thinking a few alternative approaches:
Draw with GLKit (at cost of not supporting older devices) and seems like a lot of work.
Do some kind of screen grab (renderInContext?), shift left by 1 px, blit, and only draw a line for the last two data points.
Have a very wide CALayer and pan along it?
Smooth the data set, but this feels like cheating :)
It's also possible I'm missing something obvious here that I'm seeing such poor performance?
CGContextBeginPath(context);
CGContextSetLineWidth(context, 2.0);
UIColor *color = [UIColor whiteColor];
CGContextSetStrokeColorWithColor(context, [color CGColor]);
…
CGContextAddLines(context, points, index);
CGContextMoveToPoint(context, startPoint.x, startPoint.y);
CGContextClosePath(context);
CGContextStrokePath(context);
Let's implement a graphing view that uses a bunch of tall, skinny layers to reduce the amount of redrawing needed. We'll slide the layers to the left as we add samples, so at any time, we probably have one layer hanging off the left edge of the view and one hanging off the right edge of the view:
You can find a complete working example of the code below on my github account.
Constants
Let's make each layer 32 points wide:
#define kLayerWidth 32
And let's say we're going to space the samples along the X axis at one sample per point:
#define kPointsPerSample 1
So we can deduce the number of samples per layer. Let's call one layer's worth of samples a tile:
#define kSamplesPerTile (kLayerWidth / kPointsPerSample)
When we're drawing a layer, we can't just draw the samples strictly inside the layer. We have to draw a sample or two past each edge, because the lines to those samples cross the edge of the layer. We'll call these the padding samples:
#define kPaddingSamples 2
The maximum dimension of an iPhone screen is 320 points, so we can compute the maximum number of samples we need to retain:
#define kMaxVisibleSamples ((320 / kPointsPerSample) + 2 * kPaddingSamples)
(You should change the 320 if you want to run on an iPad.)
We'll need to be able to compute which tile contains a given sample. And as you'll see, we'll want to do this even if the sample number is negative, because it will make later computations easier:
static inline NSInteger tileForSampleIndex(NSInteger sampleIndex) {
// I need this to round toward -∞ even if sampleIndex is negative.
return (NSInteger)floorf((float)sampleIndex / kSamplesPerTile);
}
Instance Variables
Now, to implement GraphView, we'll need some instance variables. We'll need to store the layers that we're using to draw the graph. And we want to be able to look up each layer according to which tile it's graphing:
#implementation GraphView {
// Each key in _tileLayers is an NSNumber whose value is a tile number.
// The corresponding value is the CALayer that displays the tile's samples.
// There will be tiles that don't have a corresponding layer.
NSMutableDictionary *_tileLayers;
In a real project, you'd want to store the samples in a model object and give the view a reference to the model. But for this example, we'll just store the samples in the view:
// Samples are stored in _samples as instances of NSNumber.
NSMutableArray *_samples;
Since we don't want to store an arbitrarily large number of samples, we'll discard old samples when _samples gets big. But it will simplify the implementation if we can mostly pretend that we never discard samples. To do that, we keep track of the total number of samples ever received.
// I discard old samples from _samples when I have more than
// kMaxTiles' worth of samples. This is the total number of samples
// ever collected, including discarded samples.
NSInteger _totalSampleCount;
We should avoid blocking the main thread, so we'll do our drawing on a separate GCD queue. We need to keep track of which tiles need to be drawn on that queue. To avoid drawing a pending tile more than once, we use a set (which eliminates duplicates) instead of an array:
// Each member of _tilesToRedraw is an NSNumber whose value
// is a tile number to be redrawn.
NSMutableSet *_tilesToRedraw;
And here's the GCD queue on which we'll do the drawing.
// Methods prefixed with rq_ run on redrawQueue.
// All other methods run on the main queue.
dispatch_queue_t _redrawQueue;
}
Initialization / Destruction
To make this view work whether you create it in code or in a nib, we need two initialization methods:
- (id)initWithFrame:(CGRect)frame {
if ((self = [super initWithFrame:frame])) {
[self commonInit];
}
return self;
}
- (void)awakeFromNib {
[self commonInit];
}
Both methods call commonInit to do the real initialization:
- (void)commonInit {
_tileLayers = [[NSMutableDictionary alloc] init];
_samples = [[NSMutableArray alloc] init];
_tilesToRedraw = [[NSMutableSet alloc] init];
_redrawQueue = dispatch_queue_create("MyView tile redraw", 0);
}
ARC won't clean up the GCD queue for us:
- (void)dealloc {
if (_redrawQueue != NULL) {
dispatch_release(_redrawQueue);
}
}
Adding a sample
To add a new sample, we pick a random number and append it to _samples. We also increment _totalSampleCount. We discard the oldest samples if _samples has gotten big.
- (void)addRandomSample {
[_samples addObject:[NSNumber numberWithFloat:120.f * ((double)arc4random() / UINT32_MAX)]];
++_totalSampleCount;
[self discardSamplesIfNeeded];
Then, we check if we've started a new tile. If so, we find the layer that was drawing the oldest tile, and reuse it to draw the newly-created tile.
if (_totalSampleCount % kSamplesPerTile == 1) {
[self reuseOldestTileLayerForNewestTile];
}
Now we recompute the layout of all the layers, which will to the left a bit so the new sample will be visible in the graph.
[self layoutTileLayers];
Finally, we add tiles to the redraw queue.
[self queueTilesForRedrawIfAffectedByLastSample];
}
We don't want to discard samples one at a time. That would be inefficient. Instead, we let the garbage build up for a while, then throw it away all at once:
- (void)discardSamplesIfNeeded {
if (_samples.count >= 2 * kMaxVisibleSamples) {
[_samples removeObjectsInRange:NSMakeRange(0, _samples.count - kMaxVisibleSamples)];
}
}
To reuse a layer for the new tile, we need to find the layer of the oldest tile:
- (void)reuseOldestTileLayerForNewestTile {
// The oldest tile's layer should no longer be visible, so I can reuse it as the new tile's layer.
NSInteger newestTile = tileForSampleIndex(_totalSampleCount - 1);
NSInteger reusableTile = newestTile - _tileLayers.count;
NSNumber *reusableTileObject = [NSNumber numberWithInteger:reusableTile];
CALayer *layer = [_tileLayers objectForKey:reusableTileObject];
Now we can remove it from the _tileLayers dictionary under the old key and store it under the new key:
[_tileLayers removeObjectForKey:reusableTileObject];
[_tileLayers setObject:layer forKey:[NSNumber numberWithInteger:newestTile]];
By default, when we move the reused layer to its new position, Core Animation will animate it sliding over. We don't want that, because it will be a big empty orange rectangle sliding across our graph. We want to move it instantly:
// The reused layer needs to move instantly to its new position,
// lest it be seen animating on top of the other layers.
[CATransaction begin]; {
[CATransaction setDisableActions:YES];
layer.frame = [self frameForTile:newestTile];
} [CATransaction commit];
}
When we add a sample, we'll always want to redraw the tile containing the sample. We also need to redraw the prior tile, if the new sample is within the padding range of the prior tile.
- (void)queueTilesForRedrawIfAffectedByLastSample {
[self queueTileForRedraw:tileForSampleIndex(_totalSampleCount - 1)];
// This redraws the second-newest tile if the new sample is in its padding range.
[self queueTileForRedraw:tileForSampleIndex(_totalSampleCount - 1 - kPaddingSamples)];
}
Queuing a tile for redraw is just a matter of adding it to the redraw set and dispatching a block to redraw it on _redrawQueue.
- (void)queueTileForRedraw:(NSInteger)tile {
[_tilesToRedraw addObject:[NSNumber numberWithInteger:tile]];
dispatch_async(_redrawQueue, ^{
[self rq_redrawOneTile];
});
}
Layout
The system will send layoutSubviews to the GraphView when it first appears, and any time its size changes (such as if a device rotation resizes it). And we only get the layoutSubviews message when we're really about to appear on the screen, with our final bounds set. So layoutSubviews is a good place to set up the tile layers.
First, we need to create or remove layers as necessary so we have the right layers for our size. Then we need to lay out the layers by setting their frames appropriately. Finally, for each layer, we need to queue its tile for redraw.
- (void)layoutSubviews {
[self adjustTileDictionary];
[CATransaction begin]; {
// layoutSubviews only gets called on a resize, when I will be
// shuffling layers all over the place. I don't want to animate
// the layers to their new positions.
[CATransaction setDisableActions:YES];
[self layoutTileLayers];
} [CATransaction commit];
for (NSNumber *key in _tileLayers) {
[self queueTileForRedraw:key.integerValue];
}
}
Adjusting the tile dictionary means setting up a layer for each visible tile and removing layers for non-visible tiles. We'll just reset the dictionary from scratch each time, but we'll try to reuse the layer's we've already created. The tiles that need layers are the newest tile, and preceding tiles so we have enough layers to cover the view.
- (void)adjustTileDictionary {
NSInteger newestTile = tileForSampleIndex(_totalSampleCount - 1);
// Add 1 to account for layers hanging off the left and right edges.
NSInteger tileLayersNeeded = 1 + ceilf(self.bounds.size.width / kLayerWidth);
NSInteger oldestTile = newestTile - tileLayersNeeded + 1;
NSMutableArray *spareLayers = [[_tileLayers allValues] mutableCopy];
[_tileLayers removeAllObjects];
for (NSInteger tile = oldestTile; tile <= newestTile; ++tile) {
CALayer *layer = [spareLayers lastObject];
if (layer) {
[spareLayers removeLastObject];
} else {
layer = [self newTileLayer];
}
[_tileLayers setObject:layer forKey:[NSNumber numberWithInteger:tile]];
}
for (CALayer *layer in spareLayers) {
[layer removeFromSuperlayer];
}
}
The first time through, and any time the view gets sufficiently wider, we need to create new layers. While we're creating the view, we'll tell it to avoid animating its contents or position. Otherwise it will animate them by default.
- (CALayer *)newTileLayer {
CALayer *layer = [CALayer layer];
layer.backgroundColor = [UIColor greenColor].CGColor;
layer.actions = [NSDictionary dictionaryWithObjectsAndKeys:
[NSNull null], #"contents",
[NSNull null], #"position",
nil];
[self.layer addSublayer:layer];
return layer;
}
Actually laying out the tile layers is just a matter of setting each layer's frame:
- (void)layoutTileLayers {
[_tileLayers enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
CALayer *layer = obj;
layer.frame = [self frameForTile:[key integerValue]];
}];
}
Of course the trick is computing the frame for each layer. And the y, width, and height parts are easy enough:
- (CGRect)frameForTile:(NSInteger)tile {
CGRect myBounds = self.bounds;
CGFloat x = [self xForTile:tile myBounds:myBounds];
return CGRectMake(x, myBounds.origin.y, kLayerWidth, myBounds.size.height);
}
To compute the x coordinate of the tile's frame, we compute the x coordinate of the first sample in the tile:
- (CGFloat)xForTile:(NSInteger)tile myBounds:(CGRect)myBounds {
return [self xForSampleAtIndex:tile * kSamplesPerTile myBounds:myBounds];
}
Computing the x coordinate for a sample requires a little thought. We want the newest sample to be at the right edge of the view, and the second-newest to be kPointsPerSample points to the left of that, and so on:
- (CGFloat)xForSampleAtIndex:(NSInteger)index myBounds:(CGRect)myBounds {
return myBounds.origin.x + myBounds.size.width - kPointsPerSample * (_totalSampleCount - index);
}
Redrawing
Now we can talk about how to actually draw tiles. We're going to do the drawing on a separate GCD queue. We can't safely access most Cocoa Touch objects from two threads simultaneously, so we need to be careful here. We'll use a prefix of rq_ on all the methods that run on _redrawQueue to remind ourselves that we're not on the main thread.
To redraw one tile, we need to get the tile number, the graphical bounds of the tile, and the points to draw. All of those things come from data structures that we might be modifying on the main thread, so we need to access them only on the main thread. So we dispatch back to the main queue:
- (void)rq_redrawOneTile {
__block NSInteger tile;
__block CGRect bounds;
CGPoint pointStorage[kSamplesPerTile + kPaddingSamples * 2];
CGPoint *points = pointStorage; // A block cannot reference a local variable of array type, so I need a pointer.
__block NSUInteger pointCount;
dispatch_sync(dispatch_get_main_queue(), ^{
tile = [self dequeueTileToRedrawReturningBounds:&bounds points:points pointCount:&pointCount];
});
It so happens that we might not have any tiles to redraw. If you look back at queueTilesForRedrawIfAffectedByLastSample, you'll see that it usually tries to queue the same tile twice. Since _tilesToRedraw is a set (not an array), the duplicate was discarded, but rq_redrawOneTile was dispatched twice anyway. So we need to check that we actually have a tile to redraw:
if (tile == NSNotFound)
return;
Now we need to actually draw the tile's samples:
UIImage *image = [self rq_imageWithBounds:bounds points:points pointCount:pointCount];
Finally we need to update the tile's layer to show the new image. We can only touch a layer on the main thread:
dispatch_async(dispatch_get_main_queue(), ^{
[self setImage:image forTile:tile];
});
}
Here's how we actually draw the image for the layer. I will assume you know enough Core Graphics to follow this:
- (UIImage *)rq_imageWithBounds:(CGRect)bounds points:(CGPoint *)points pointCount:(NSUInteger)pointCount {
UIGraphicsBeginImageContextWithOptions(bounds.size, YES, 0); {
CGContextRef gc = UIGraphicsGetCurrentContext();
CGContextTranslateCTM(gc, -bounds.origin.x, -bounds.origin.y);
[[UIColor orangeColor] setFill];
CGContextFillRect(gc, bounds);
[[UIColor whiteColor] setStroke];
CGContextSetLineWidth(gc, 1.0);
CGContextSetLineJoin(gc, kCGLineCapRound);
CGContextBeginPath(gc);
CGContextAddLines(gc, points, pointCount);
CGContextStrokePath(gc);
}
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return image;
}
But we still have to get the tile, the graphics bounds, and the points to draw. We dispatched back to the main thread to do it:
// I return NSNotFound if I couldn't dequeue a tile.
// The `pointsOut` array must have room for at least
// kSamplesPerTile + 2*kPaddingSamples elements.
- (NSInteger)dequeueTileToRedrawReturningBounds:(CGRect *)boundsOut points:(CGPoint *)pointsOut pointCount:(NSUInteger *)pointCountOut {
NSInteger tile = [self dequeueTileToRedraw];
if (tile == NSNotFound)
return NSNotFound;
The graphics bounds are just the bounds of the tile, just like we computed earlier to set the frame of the layer:
*boundsOut = [self frameForTile:tile];
I need to start graphing from the padding samples before the first sample of the tile. But, prior to having enough samples to fill the view, my tile number may actually be negative! So I need to be sure not to try to access a sample at a negative index:
NSInteger sampleIndex = MAX(0, tile * kSamplesPerTile - kPaddingSamples);
We also need to make sure we don't try to run past the end of the samples when we compute the sample at which we stop graphing:
NSInteger endSampleIndex = MIN(_totalSampleCount, tile * kSamplesPerTile + kSamplesPerTile + kPaddingSamples);
And when I actually access the sample values, I need to account for the samples I've discarded:
NSInteger discardedSampleCount = _totalSampleCount - _samples.count;
Now we can compute the actual points to graph:
CGFloat x = [self xForSampleAtIndex:sampleIndex myBounds:self.bounds];
NSUInteger count = 0;
for ( ; sampleIndex < endSampleIndex; ++sampleIndex, ++count, x += kPointsPerSample) {
pointsOut[count] = CGPointMake(x, [[_samples objectAtIndex:sampleIndex - discardedSampleCount] floatValue]);
}
And I can return the number of points and the tile:
*pointCountOut = count;
return tile;
}
Here's how we actually pull a tile off the redraw queue. Remember that the queue might be empty:
- (NSInteger)dequeueTileToRedraw {
NSNumber *number = [_tilesToRedraw anyObject];
if (number) {
[_tilesToRedraw removeObject:number];
return number.integerValue;
} else {
return NSNotFound;
}
}
And finally, here's how we actually set the tile layer's contents to the new image. Remember that we dispatched back to the main queue to do this:
- (void)setImage:(UIImage *)image forTile:(NSInteger)tile {
CALayer *layer = [_tileLayers objectForKey:[NSNumber numberWithInteger:tile]];
if (layer) {
layer.contents = (__bridge id)image.CGImage;
}
}
Making it sexier
If you do all of that, it will work fine. But you can actually make it slightly nicer-looking by animating the repositioning of the layers when a new sample comes in. This is very easy. We just modify newTileLayer so that it adds an animation for the position property:
- (CALayer *)newTileLayer {
CALayer *layer = [CALayer layer];
layer.backgroundColor = [UIColor greenColor].CGColor;
layer.actions = [NSDictionary dictionaryWithObjectsAndKeys:
[NSNull null], #"contents",
[self newTileLayerPositionAnimation], #"position",
nil];
[self.layer addSublayer:layer];
return layer;
}
and we create the animation like this:
- (CAAnimation *)newTileLayerPositionAnimation {
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:#"position"];
animation.duration = 0.1;
animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear];
return animation;
}
You will want to set the duration to match the speed at which new samples arrive.
You don't have to rasterize whole path every time you draw it - you can cache it as raster bitmap. BTW, your idea with "scrolling" is standard solution for such task...
Create a bitmap context the same height as your view but twice the width. Start drawing your points into the context, then in drawRect create a CGImageRef. The idea is to as you initially fill the screen your image will start at the beginning. The image you will draw will have the proper width and height, but the bytesPerRow will be 2x (more on that). You continue to draw new points as they come until you get to the last point - now x is exhausted.
Continue writing points in your context, but now, when you create the image, offset the initial pointer by one pixel. Continue doing this until you have done 2x lines - you are now at the very very end of your context.
At that one time, you will need to move the "right" side of the image to the left, and reset your offset count. That is, you will need to memcpy(starOfBitMap, startOfBitMap+bytesPerRow/2, sizeOfBitMap - bytesPerRow/2). In essence, you are left shifting one visible frame.
Now as you add new lines, its at the end of the first frame, and you start offseting by one pixel as you draw.