Question

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];                             
}
Was it helpful?

Solution

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:

SliceView demo screen shot

You can download my test project here: http://dl.dropbox.com/u/26919672/pie.zip

UPDATE

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;
}

OTHER TIPS

First, it is probably a better idea to store the slices in an array.

Second, it is also a better idea to define a class MySliceClass (that may inherit from UIBezierPath). This class has properties that define a slice: startingAngle, endAngle.

Now not only you improved your code, but it becomes easier to resize the slices.

You have to add a parameter radius to the class MySliceClass, and every time a slice is touched, you change its radius, and call [self setNeedsDisplay] so that the method drawRect gets called.

Finally, you should also move the initialization to the initialization method of your view because the way you do it, you create new slices every time you draw the view.

EDIT Here is an example implementation

@implementation Slice // Subclass of NSObject

@synthesize radius, startAngle, endAngle, center;

- (void)draw
{
    UIBezierPath *path = nil;
    path = [UIBezierPath bezierPath];
    [path moveToPoint:center];
    [path addArcWithCenter:center radius:radius startAngle:startAngle endAngle:endAngle clockwise:YES];
    [path closePath];
    path.lineWidth = 1;

    [[UIColor redColor] setFill];
    [path fill];
}

@end

And

@implementation SliceView

@synthesize slices;

- (id)initWithFrame:(CGRect)frame {
    self = [super initWithFrame:frame];
    if (self) {
        int numSlices = 12; // you can change this
        slices = [NSMutableArray array];
        for (int i = 0; i < numSlices; i++) {
            Slice *slice = [[Slice alloc] init];
            slice.center = self.center;
            slice.radius = 100;
            slice.startAngle = (2*M_PI / numSlices) * i;
            slice.endAngle = (2*M_PI / numSlices) * (i+0.9);
        }
    }
    return self;
}

- (void)drawRect:(CGRect)rect
{
    [[UIColor redColor] setFill];
    for (Slice *slice in slices) {
    [slice draw];
    }
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
    UITouch *touch = [touches anyObject];
    CGPoint point = [touch locationInView:self];
    CGFloat d = sqrtf(powf(point.x-self.center.x, 2) + powf(point.y-self.center.y, 2));

    int index = atan2f(point.x-self.center.x, point.y-self.center.y) * self.slices.count / (2*M_PI);
    Slice *slice = [slices objectAtIndex:index];
    slice.radius = d;
}

@end

I didn't test this code, so there might be some errors. But you can start from this I hope.

Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top