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.
Related
I have looked at the libraries like gaugekit but they does not solve my problem.
Are there any other libraries for making gauge view as in the image?
If not, then how can I go around about it?
As #DonMag pointed out.
I have tried to make the changes in gaugekit by adding a view on top the gauge view....but it does not turns out to be good.
So I am stuck out at making the spaces in between the actual gauge.
https://imgur.com/Qk1EpcV
I suggest you create your own custom view, it's not so difficult. Here is how I would do it. I have left out some details for clarity, but you can see in the comments my suggested solutions for that.
First, create a sub-class of UIVew. We will need one property to keep track of the gauge position. This goes into your .h file.
#interface GaugeView : UIView
#property (nonatomic) CGFloat knobPosition;
#end
Next, add the implementation. The GaugeView is a view in itself, so it will be used as the container for the other parts we want. I have used awakeFromNib function to do the initialization, so that you can use the class for a UIView in Storyboard. If you prefer, you can do the initialization from an init function also.
I have not provided code for the knob in the center, but I would suggest you simply create one view with a white disc (or two to make the gray circle) and the labels to hold the texts parts, and beneath that you add an image view with the gray pointer. The pointer can be moved by applying a rotational transform it.
- (void)awakeFromNib {
[super awakeFromNib];
// Initialization part could also be placed in init
[self createSegmentLayers];
// Add knob views to self
// :
// Start somewhere
self.knobPosition = 0.7;
}
Next, create the segments. The actual shapes are not added here, since they will require the size of the view. It is better to defer that to layoutSubviews.
- (void)createSegmentLayers {
for (NSInteger segment = 0; segment < 10; ++segment) {
// Create the shape layer and set fixed properties
CAShapeLayer *shapeLayer = [CAShapeLayer layer];
// Color can be set differently for each segment
shapeLayer.strokeColor = [UIColor blueColor].CGColor;
shapeLayer.lineWidth = 1.0;
[self.layer addSublayer:shapeLayer];
}
}
Next, we need to respond to size changes to the view. This is where we create the actual shapes too.
- (void)layoutSubviews {
[super layoutSubviews];
// Dynamically create the segment paths and scale them to the current view width
NSInteger segment = 0;
for (CAShapeLayer *layer in self.layer.sublayers) {
layer.frame = self.layer.bounds;
layer.path = [self createSegmentPath:segment radius:self.bounds.size.width / 2.0].CGPath;
// If we should fill or not depends on the knob position
// Since the knobPosition's range is 0.0..1.0 we can just multiply by 10
// and compare to the segment number
layer.fillColor = segment < (_knobPosition * 10) ? layer.strokeColor : nil;
// Assume we added the segment layers first
if (++segment >= 10)
break;
}
// Move and size knob images
// :
}
Then we need the shapes.
- (UIBezierPath *)createSegmentPath:(NSInteger)segment radius:(CGFloat)radius {
UIBezierPath *path = [UIBezierPath bezierPath];
// We could also use a table with start and end angles for different segment sizes
CGFloat startAngle = segment * 21.0 + 180.0 - 12.0;
CGFloat endAngle = startAngle + 15.0;
// Draw the path, two arcs and two implicit lines
[path addArcWithCenter:CGPointMake(radius, radius) radius:0.9 * radius startAngle:DEG2RAD(startAngle) endAngle:DEG2RAD(endAngle) clockwise:YES];
[path addArcWithCenter:CGPointMake(radius, radius) radius:0.75 * radius startAngle:DEG2RAD(endAngle) endAngle:DEG2RAD(startAngle) clockwise:NO];
[path closePath];
return path;
}
Finally, we want to respond to changes to the knobPosition property. Calling setNeedsLayout will trigger a call to layoutSubviews.
// Position is 0.0 .. 1.0
- (void)setKnobPosition:(CGFloat)knobPosition {
// Rotate the knob image to point at the right segment
// self.knobPointerImageView.transform = CGAffineTransformMakeRotation(DEG2RAD(knobPosition * 207.0 + 180.0));
_knobPosition = knobPosition;
[self setNeedsLayout];
}
This is what it will look like now. Add the knob, some colors and possibly different sized segments and you are done!
Based on the image I saw the easiest solution might be to create 12 images and then programmatically swap the images as the value it represents grows or shrinks.
I've made a CCLayer that holds CCSprite and two CCLabelBMFont's. My goal is to create a customized "button" which will scale down when pressed. I've ran into problems with touch and scaling of this layer.
First is the touch, I can't touch the layer bounding box accurately even if I convert the touch like this:
CGPoint currentTouchLocation = [self convertTouchToNodeSpace:touch];
Touch is handled like this:
// Touching shop item?
if(CGRectContainsPoint([self boundingBox], currentTouchLocation)) {
NSLog(#"Pressing item");
mShopItemPushed = true;
return true;
}
return false;
Seems like there is no realistic size boundingBox for a CCLayer with it's contents by default so I figure I need to overwrite one based on the CCLayer contents? Any ideas how I can do this correctly?
Second problem is the scaling of this CCLayer based "button". If I get a touch handling to work somehow, scaling the layer down by half causes the scaled layer to move off tens of pixels from the original position. There are no anchors set but still moves the layer quite a bit to the side and up when scaling. How can I prevent this behavior?
Here is some code of the CCLayer based button:
+(id) shopItem:(NSString*)fileName : (CGPoint)position : (NSString*)itemName : (int)itemPrice
{
return [[[self alloc] initWithShopItemData:fileName:position:itemName:itemPrice] autorelease];
}
-(id) initWithShopItemData:(NSString*)fileName : (CGPoint)position : (NSString*)itemName : (int)itemPrice
{
self = [super init];
[self setPosition:position];
mShopItemPushed = false;
mPicture = [CCSprite spriteWithSpriteFrameName:fileName];
[mPicture setPosition:CGPointMake(position.x - (3.0f * [DeviceSpecific cellSize]), position.y)];
[self addChild:mPicture z:1];
// Make price string
NSString* price = [NSString stringWithFormat:#"%d", itemPrice];
mItemPrice = [CCLabelBMFont labelWithString:price fntFile:[DeviceSpecific scoreAndCoinFont]];
[mItemPrice setScale:0.5f];
[mItemPrice setAnchorPoint:CGPointMake(1.0f, 0.5f)];
[mItemPrice setPosition:CGPointMake(position.x + (3.5f * [DeviceSpecific cellSize]), position.y)];
[self addChild:mItemPrice z:1];
mItemName = [CCLabelBMFont labelWithString:itemName fntFile:[DeviceSpecific scoreAndCoinFont]];
[mItemName setScale:0.5f];
[mItemName setAnchorPoint:CGPointMake(0.0f, 0.5f)];
[mItemName setPosition:CGPointMake(mPicture.position.x + [DeviceSpecific cellSize], mPicture.position.y)];
[self addChild:mItemName z:1];
self.isTouchEnabled = YES;
return self;
}
The [DeviceSpecific cellSize] is just a measuring unit to keep the distances correct on different devices.
I solved this by overwriting the boundingBox -function with a rect based on the outer limits of the items in this layer. Scaling problem remained so I just made another indicator for received touches.
Unsatisfied with my previous results, I have been asked to create a freehand drawing view that will not blur when zoomed. The only way I can imagine this is possible is to use a CATiledLayer because otherwise it is just too slow when drawing a line when zoomed. Currently, I have it set up so that it will redraw every line every time, but I want to know if I can cache the results of the previous lines (not as pixels because they need to scale well) in a context or something.
I thought about CGBitmapContext, but would that mean I would need to tear down and set up a new context after every zoom? The problem is that on a retina display, the line drawing is too slow (on iPad 2 it is so-so), especially when drawing while zoomed. There is an app in the App Store called GoodNotes which beautifully demonstrates that this is possible, and it is possible to do it smoothly, but I can't understand how they are doing it. Here is my code so far (result of most of today):
- (void)drawRect:(CGRect)rect
{
CGContextRef c = UIGraphicsGetCurrentContext();
CGContextSetLineWidth(c, mLineWidth);
CGContextSetAllowsAntialiasing(c, true);
CGContextSetShouldAntialias(c, true);
CGContextSetLineCap(c, kCGLineCapRound);
CGContextSetLineJoin(c, kCGLineJoinRound);
//Protect the local variables against the multithreaded nature of CATiledLayer
[mLock lock];
NSArray *pathsCopy = [mStrokes copy];
for(UIBezierPath *path in pathsCopy) //**Would like to cache these**
{
CGContextAddPath(c, path.CGPath);
CGContextStrokePath(c);
}
if(mCurPath)
{
CGContextAddPath(c, mCurPath.CGPath);
CGContextStrokePath(c);
}
CGRect pathBounds = mCurPath.bounds;
if(pathBounds.size.width > 32 || pathBounds.size.height > 32)
{
[mStrokes addObject:mCurPath];
mCurPath = [[UIBezierPath alloc] init];
}
[mLock unlock];
}
Profiling shows the hottest function by far is GCSFillDRAM8by1
First, as the path stroking is the most expensive operation, you shouldn't lock around it as this prevent you from drawing tiles concurrently on different cores.
Secondly, I think you could avoid to call CGContextStrokePath several times by adding all the paths in the context and stroking them altogether.
[mLock lock];
for ( UIBezierPath *path in mStrokes ) {
CGContextAddPath(c, path.CGPath);
}
if ( mCurPath ) {
CGContextAddPath(c, mCurPath.CGPath);
}
CGRect pathBounds = mCurPath.bounds;
if ( pathBounds.size.width > 32 || pathBounds.size.height > 32 )
{
[mStrokes addObject:mCurPath];
mCurPath = [[UIBezierPath alloc] init];
}
[mLock unlock];
CGContextStrokePath(c);
The CGContextRef is just a canvas in which the drawing operations occur. You cannot cache it but you may the create a CGImageRef with a flattened bitmap image of your paths and reuse that image. This won't help with zooming (as you'd need to recreate the image when the level of detail changes) but can be useful to improve the performance when the user is drawing a really long path.
There is a really interesting WWDC 2012 Session Video on that subject: Optimizing 2D Graphics and Animation Performance.
The bottleneck was actually the way I was using CATiledLayer. I guess it is too much to update with freehand info. I set it up with levels of detail as I saw in the docs and tutorials online, but in the end I didn't need that much. I just hooked up the scrollview delegate, cleared the contents when it was done zooming and changed the contentScale of the layer to match the scroll view. The result was beautiful (it disappears and fades back in, but that can't be helped).
I'm using a pair of CALayers as image masks, allowing me to animate a bulb filling or emptying at a set speed while also following the current touch position. That is, one mask jumps to follow the touch and the other slides to that position. Since I use an explicit animation I'm forced to set the position of the mask sliding mask when I add the animation. This means that if I start a fill and then start an empty before the fill completes the empty will begin from the completed fill position (the opposite is also true).
Is there a way to get the position of the animation, set the position at each step of the animation, or to have the new animation begin from the current state of the active animation?
The code handling the animating is below:
- (void)masksFillTo:(CGFloat)height {
// Clamp the height we fill to inside the bulb. Remember Y gets bigger going down.
height = MIN(MAX(BULB_TOP, height), BULB_BOTTOM);
// We can find the target Y location by subtracting the Y value for the top of the
// bulb from the height.
CGFloat targetY = height - BULB_TOP;
// Find the bottom of the transparent mask to determine where the solid fill
// is sitting. Then find how far that fill needs to move.
// TODO: This works with the new set position, so overriding old anime doesn't work
CGFloat bottom = transMask.frame.origin.y + transMask.frame.size.height;
// If the target is above the bottom of the solid, we want to fill up.
// This means the empty mask jumps and the transparent mask slides.
CALayer *jumper;
CALayer *slider;
if (bottom - targetY >= 0) {
jumper = emptyMask;
slider = transMask;
// We need to reset the bottom to the emptyMask
bottom = emptyMask.frame.origin.y + emptyMask.frame.size.height;
} else {
jumper = transMask;
slider = emptyMask;
}
[jumper removeAllAnimations];
[slider removeAllAnimations];
CGFloat dy = bottom - targetY;
[CATransaction begin];
[CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions];
[jumper setPosition:CGPointMake(jumper.position.x, jumper.position.y - dy)];
[self slideMaskFillTo:height withMask:slider]; // Do this inside here or an odd flash glitch appears.
[CATransaction commit];
}
// TODO: Always starts from new position, even if animation hasn't reached it.
- (void)slideMaskFillTo:(CGFloat)height withMask:(CALayer *)slider {
// We can find the target Y location by subtracting the Y value for the top of the
// bulb from the height.
CGFloat targetY = height - BULB_TOP;
// We then find the bottom of the mask.
CGFloat bottom = slider.frame.origin.y + slider.frame.size.height;
CGFloat dy = bottom - targetY;
// Do the animation. Animating with duration doesn't appear to work properly.
// Apparently "When modifying layer properties from threads that don’t have a runloop,
// you must use explicit transactions."
CABasicAnimation *a = [CABasicAnimation animationWithKeyPath:#"position"];
a.duration = (dy > 0 ? dy : -dy) / PXL_PER_SEC; // Should be 2 seconds for a full fill
a.fromValue = [NSValue valueWithCGPoint:slider.position];
CGPoint newPosition = slider.position;
newPosition.y -= dy;
a.toValue = [NSValue valueWithCGPoint:newPosition];
a.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear];
[slider addAnimation:a forKey:#"colorize"];
// Update the actual position
slider.position = newPosition;
}
And an example of how this is called. Notice this means it can be called mid-animation.
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
UITouch *touch = [touches anyObject];
CGPoint point = [touch locationInView:self.view];
[self masksFillTo:point.y];
}
If anyone finds it relevant, this is the creation of the images and masks.
// Instantiate the different bulb images - empty, transparent yellow, and solid yellow. This
// includes setting the frame sizes. This approach found at http://stackoverflow.com/a/11218097/264775
emptyBulb = [[UIImageView alloc] initWithImage:[UIImage imageNamed:#"Light.png"]];
transBulb = [[UIImageView alloc] initWithImage:[UIImage imageNamed:#"Light-moving.png"]];
solidBulb = [[UIImageView alloc] initWithImage:[UIImage imageNamed:#"Light-on.png"]];
[emptyBulb setFrame:CGRectMake(10, BULB_TOP, 300, BULB_HEIGHT)]; // 298 x 280
[transBulb setFrame:CGRectMake(10, BULB_TOP, 300, BULB_HEIGHT)]; // 298 x 280
[solidBulb setFrame:CGRectMake(10, BULB_TOP, 300, BULB_HEIGHT)]; // 298 x 280
[self.view addSubview:solidBulb]; // Empty on top, then trans, then solid.
[self.view addSubview:transBulb];
[self.view addSubview:emptyBulb];
// Create a mask for the empty layer so it will cover the other layers.
emptyMask = [CALayer layer];
[emptyMask setContentsScale:emptyBulb.layer.contentsScale]; // handle retina scaling
[emptyMask setFrame:emptyBulb.layer.bounds];
[emptyMask setBackgroundColor:[UIColor blackColor].CGColor];
emptyBulb.layer.mask = emptyMask;
// Also create a mask for the transparent image.
transMask = [CALayer layer];
[transMask setContentsScale:transBulb.layer.contentsScale]; // handle retina scaling
[transMask setFrame:transBulb.layer.bounds];
[transMask setBackgroundColor:[UIColor blackColor].CGColor];
transBulb.layer.mask = transMask;
Was just led to a solution via this answer. If one looks in the right part of the docs, you'll find the following:
- (id)presentationLayer
Returns a copy of the layer containing all properties as they were at the start of the current transaction, with any active animations applied.
So if I add this code before I first check the transparent mask location (aka solid level), I grab the current animated position and can switch between fill up and fill down.
CALayer *temp;
if (transMask.animationKeys.count > 0) {
temp = transMask.presentationLayer;
transMask.position = temp.position;
}
if (emptyMask.animationKeys.count > 0) {
temp = emptyMask.presentationLayer;
emptyMask.position = temp.position;
}
layer.presentation() makes copy of original layer each time you get it, and you need to override init(layer: Any) for every custom CALayer you have.
Another possible way to start from current state is to set animation's beginTime using current animation's beginTime. It allows to start new animation with offset.
In your case it could be (Swift):
if let currentAnimation = slider.animation(forKey: "colorize") {
let currentTime = CACurrentMediaTime()
let animationElapsedTime = currentAnimation.beginTime + currentAnimation.duration - currentTime
a.beginTime = currentTime - animationElapsedTime
}
However this works great for linear timings, but for others it could be difficult to calculate the proper time.
I want to make a stack of coin, when user click "Fly in" -> coin'll fly in a curved path and arrange in a stack. I used CALayer:
CALayers *coinLayer = [CALayers layer];
coinLayer.backgroundColor = [UIColor clearColor].CGColor;
coinLayer.contents = (id)[UIImage imageNamed:#"head coin.png"].CGImage;
coinLayer.frame = CGRectMake(100, 500 - (10*coin), 55, 21);
coin = coin + 1;
[self.view.layer addSublayer:coinLayer];
I've done with animation in curved path but if i add my coinLayer in this way, then how can i remove CALayer if i don't add it in an array.
For example, i have a stack of number, i add 1,2,3,4,5,6,7,8,9 in the stack. When remove 4 numbers, it will do from 9 down to 8... down to 6 one by one. In my code, is that correct when i add CALayer in view's layer? How can i remove layers one by one as same as example?
Thank you so much!
Rather than creating and deleting CALayer objects it's better to store them in an array and simply set their hidden property whenever you need them.
in the header:
NSMutableArray* coins;
In the m file:
-(void)newCoin;
{
//create the array if it doesn't already exist - could add this to your init
if(!coins)
{
coins = [NSMutableArray array];
}
for(CALayer* aCoin in coins)
{
//find the first hidden coin and use it
if(aCoin.hidden)
{
//reset the coins position to where you want the "new" coin
return;
}
}
//didn't find any unused coins - make a new one
[coins addObject:[self createCoin]];
When the animation finishes simply set the coin's hidden property to be true.
coin.hidden = YES;
I use this method to handle thousands of CALayer objects at once and it's much more resource friendly than constantly creating new CALayers.