I have a pie chart which is made using UIBezierPath's. I now need those individual paths (pie pieces) to be scalable. I believe you need a view to be able to use pinch scaling, so I think touchesMoved: is the way to go (unless there's a workaround).
Any advice or help is appreciated!
Updated/Progress code
MySliceClass.m
+ (UIBezierPath *)sliceRadius:(float)radius andStartingAngle:(float)startingAngle andFinishingAngle:(float)finishingAngle
{
static UIBezierPath *path = nil;
path = [UIBezierPath bezierPath];
CGPoint center = {300,300};
[path moveToPoint:center];
[path addArcWithCenter:center radius:radius startAngle:radians(startingAngle) endAngle:radians(finishingAngle) clockwise:YES];
[path closePath];
path.lineWidth = 1;
[[UIColor redColor] setFill];
[path fill];
return path;
}
MySliceView.m
- (void)drawRect:(CGRect)rect
{
NSArray *arrayOfSlices = [NSArray arrayWithObjects:
slice01 = [WordplaySlice sliceRadius:200 andStartingAngle:0.5 andFinishingAngle:29.5],
slice02 = [WordplaySlice sliceRadius:200 andStartingAngle:30.5 andFinishingAngle:59.5],
slice03 = [WordplaySlice sliceRadius:200 andStartingAngle:60.5 andFinishingAngle:89.5],
slice04 = [WordplaySlice sliceRadius:200 andStartingAngle:90.5 andFinishingAngle:119.5],
slice05 = [WordplaySlice sliceRadius:200 andStartingAngle:120.5 andFinishingAngle:149.5],
slice06 = [WordplaySlice sliceRadius:200 andStartingAngle:150.5 andFinishingAngle:179.5],
slice07 = [WordplaySlice sliceRadius:200 andStartingAngle:180.5 andFinishingAngle:209.5],
slice08 = [WordplaySlice sliceRadius:200 andStartingAngle:210.5 andFinishingAngle:239.5],
slice09 = [WordplaySlice sliceRadius:200 andStartingAngle:240.5 andFinishingAngle:269.5],
slice10 = [WordplaySlice sliceRadius:200 andStartingAngle:270.5 andFinishingAngle:299.5],
slice11 = [WordplaySlice sliceRadius:200 andStartingAngle:300.5 andFinishingAngle:329.5],
slice12 = [WordplaySlice sliceRadius:200 andStartingAngle:330.5 andFinishingAngle:359.5], nil];
}
I think you will find it easier if you create a view for each slice, and use a UIPinchGestureRecognizer
. Here's how.
First, we need a UIView
subclass that draws one slice. It should also override pointInside:withEvent:
to ignore a touch that lands outside the slice (even if the touch is inside the view's rectangular bounds).
So we'll make a class called SliceView
. It uses CAShapeLayer
to do the slice drawing:
@interface SliceView : UIView
@property (nonatomic) CGFloat padding;
@property (nonatomic) CGFloat startRadians;
@property (nonatomic) CGFloat endRadians;
@property (nonatomic, strong) UIColor *fillColor;
@end
@implementation SliceView
@synthesize padding = _padding;
@synthesize startRadians = _startRadians;
@synthesize endRadians = _endRadians;
@synthesize fillColor = _fillColor;
We tell it to use a CAShapeLayer
instead of a plain CALayer
by overriding the layerClass
method. We'll also add a handy method that returns the view's layer as a CAShapeLayer
.
+ (Class)layerClass {
return [CAShapeLayer class];
}
- (CAShapeLayer *)shapeLayer {
return (CAShapeLayer *)self.layer;
}
We'll compute the path of the slice in layoutSubviews
, because the view receives the layoutSubviews
message any time its size is changed.
We're going to lay out each slice view to cover the entire pie, but only draw its wedge of the pie. Each slice's frame will cover the entire screen (if the pie is full-screen). That means the slice view knows that the center of its arc is at the center of its bounds. But then we use a little trigonometry to put in the padding between adjacent slices.
We also adjust the anchor point of the layer; this is the point in the layer that doesn't move when you scale or rotate the layer. We want the anchor point to be at the corner of the slice nearest the center.
- (void)layoutSubviews {
CAShapeLayer *layer = self.shapeLayer;
CGRect bounds = self.bounds;
CGFloat radius = MIN(bounds.size.width, bounds.size.height) / 2 - 2 * _padding;
CGPoint center = CGPointMake(CGRectGetMidX(bounds), CGRectGetMidY(bounds));
CGFloat sine = sinf((_startRadians + _endRadians) * 0.5f);
CGFloat cosine = cosf((_startRadians + _endRadians) * 0.5f);
center.x += _padding * cosine;
center.y += _padding * sine;
UIBezierPath *path = [UIBezierPath bezierPath];
[path moveToPoint:center];
[path addArcWithCenter:center radius:radius startAngle:_startRadians endAngle:_endRadians clockwise:YES];
[path closePath];
layer.path = path.CGPath;
// Move my anchor point to the corner of my path so scaling will leave the corner in the same place.
CGPoint cornerInSuperview = [self convertPoint:center toView:self.superview];
layer.anchorPoint = CGPointMake(center.x / bounds.size.width, center.y / bounds.size.height);
self.center = cornerInSuperview;
}
When any of the view's properties relating to the slice are changed, we need to recompute the path outlining the slice. And when the fill color of the slice is changed, we need to pass that change along to the layer. So we'll override the property setters.
- (void)setPadding:(CGFloat)padding {
_padding = padding;
[self setNeedsLayout];
}
- (void)setStartRadians:(CGFloat)startRadians {
_startRadians = startRadians;
[self setNeedsLayout];
}
- (void)setEndRadians:(CGFloat)endRadians {
_endRadians = endRadians;
[self setNeedsLayout];
}
- (void)setFillColor:(UIColor *)color {
_fillColor = color;
self.shapeLayer.fillColor = color.CGColor;
}
Finally, we override pointInside:withEvent:
so that hit-testing will only assign a touch to a slice view if the touch is actually inside the path of the slice. This is critical since all of the slice views will have a frame that covers the whole screen.
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
return CGPathContainsPoint(self.shapeLayer.path, NULL, point, NO);
}
@end
Now that we have a handy SliceView
class, we can use it to draw a pie chart with zoomable slices. It's hard to fit two fingers into a slice on an iPhone screen, so we'll let the user tap a slice to select it, and pinch anywhere to scale the selected slice. (This interface also makes it testable in the simulator.)
@implementation ViewController {
__weak SliceView *_selectedSlice;
}
We'll draw unselected slices in red and the selected slice in blue.
+ (UIColor *)unselectedSliceFillColor {
return UIColor.redColor;
}
+ (UIColor *)selectedSliceFillColor {
return UIColor.blueColor;
}
When the user taps a slice, we'll need to change the colors of the prior selection and the new selection, and record the new selection.
- (IBAction)sliceWasTapped:(UITapGestureRecognizer *)tapper {
_selectedSlice.fillColor = self.class.unselectedSliceFillColor;
_selectedSlice = (SliceView *)tapper.view;
_selectedSlice.fillColor = self.class.selectedSliceFillColor;
}
When the user pinches, we adjust the transform of the selected slice, if there is one.
- (IBAction)pinched:(UIPinchGestureRecognizer *)pincher {
if (!_selectedSlice)
return;
CGFloat scale = pincher.scale;
pincher.scale = 1;
_selectedSlice.transform = CGAffineTransformScale(_selectedSlice.transform, scale, scale);
}
Finally, we need to actually create the slice views and the gesture recognizers. We create one tap recognizer for each slice, and one “global” pinch recognizer attached to the background view.
- (void)viewDidLoad {
static int const SliceCount = 12;
CGRect bounds = self.view.bounds;
for (int i = 0; i < SliceCount; ++i) {
SliceView *slice = [[SliceView alloc] initWithFrame:bounds];
slice.startRadians = 2 * M_PI * i / SliceCount;
slice.endRadians = 2 * M_PI * (i + 1) / SliceCount;
slice.padding = 4;
slice.fillColor = self.class.unselectedSliceFillColor;
slice.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
[self.view addSubview:slice];
UITapGestureRecognizer *tapper = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(sliceWasTapped:)];
[slice addGestureRecognizer:tapper];
}
UIPinchGestureRecognizer *pincher = [[UIPinchGestureRecognizer alloc] initWithTarget:self action:@selector(pinched:)];
[self.view addGestureRecognizer:pincher];
}
- (BOOL)shouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation
{
return (interfaceOrientation != UIInterfaceOrientationPortraitUpsideDown);
}
@end
And here's what it looks like:
You can download my test project here: http://dl.dropbox.com/u/26919672/pie.zip
In response to your comment asking about limiting the scale, I would suggest adding some more properties to SliceView
:
@property (nonatomic) CGFloat minScale;
@property (nonatomic) CGFloat maxScale;
@property (nonatomic) CGFloat scale;
Important: You will need to initialize all three properties to 1 in initWithFrame:
and initWithCoder:
.
Then, implement the scale
setter to actually enforce the limits and set the scale:
- (void)setScale:(CGFloat)scale {
_scale = MAX(minScale, MIN(scale, maxScale));
self.transform = CGAffineTransformMakeScale(_scale, _scale);
}
In pinched:
, you update the scale
property of the view instead of setting the view's transform
property directly:
- (IBAction)pinched:(UIPinchGestureRecognizer *)pincher {
if (!_selectedSlice)
return;
CGFloat scale = pincher.scale;
pincher.scale = 1;
_selectedSlice.scale = _selectedSlice.scale * scale;
}