Welcome to OGeek Q&A Community for programmer and developer-Open, Learning and Share
Welcome To Ask or Share your Answers For Others

Categories

0 votes
335 views
in Technique[技术] by (71.8m points)

iphone - Drag UIView around Shape Comprised of CGMutablePaths

I have a simple oval shape (comprised of CGMutablePaths) from which I'd like the user to be able to drag an object around it. Just wondering how complicated it is to do this, do I need to know a ton of math and physics, or is there some simple built in way that will allow me to do this? IE the user drags this object around the oval, and it orbits it.

See Question&Answers more detail:os

与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome To Ask or Share your Answers For Others

1 Reply

0 votes
by (71.8m points)

This is an interesting problem. We want to drag an object, but constrain it to lie on a CGPath. You said you have “a simple oval shape”, but that's boring. Let's do it with a figure 8. It'll look like this when we're done:

figure-8-drag

So how do we do this? Given an arbitrary point, finding the nearest point on a Bezier spline is rather complicated. Let's do it by brute force. We'll just make an array of points closely spaced along the path. The object starts out on one of those points. As we try to drag the object, we'll look at the neighboring points. If either is nearer, we'll move the object to that neighbor point.

Even getting an array of closely-spaced points along a Bezier curve is not trivial, but there is a way to get Core Graphics to do it for us. We can use CGPathCreateCopyByDashingPath with a short dash pattern. This creates a new path with many short segments. We'll take the endpoints of each segment as our array of points.

That means we need to iterate over the elements of a CGPath. The only way to iterate over the elements of a CGPath is with the CGPathApply function, which takes a callback. It would be much nicer to iterate over path elements with a block, so let's add a category to UIBezierPath. We start by creating a new project using the “Single View Application” template, with ARC enabled. We add a category:

@interface UIBezierPath (forEachElement)

- (void)forEachElement:(void (^)(CGPathElement const *element))block;

@end

The implementation is very simple. We just pass the block as the info argument of the path applier function.

#import "UIBezierPath+forEachElement.h"

typedef void (^UIBezierPath_forEachElement_Block)(CGPathElement const *element);

@implementation UIBezierPath (forEachElement)

static void applyBlockToPathElement(void *info, CGPathElement const *element) {
    __unsafe_unretained UIBezierPath_forEachElement_Block block = (__bridge  UIBezierPath_forEachElement_Block)info;
    block(element);
}

- (void)forEachElement:(void (^)(const CGPathElement *))block {
    CGPathApply(self.CGPath, (__bridge void *)block, applyBlockToPathElement);
}

@end

For this toy project, we'll do everything else in the view controller. We'll need some instance variables:

@implementation ViewController {

We need an ivar to hold the path that the object follows.

    UIBezierPath *path_;

It would be nice to see the path, so we'll use a CAShapeLayer to display it. (We need to add the QuartzCore framework to our target for this to work.)

    CAShapeLayer *pathLayer_;

We'll need to store the array of points-along-the-path somewhere. Let's use an NSMutableData:

    NSMutableData *pathPointsData_;

We'll want a pointer to the array of points, typed as a CGPoint pointer:

    CGPoint const *pathPoints_;

And we need to know how many of those points there are:

    NSInteger pathPointsCount_;

For the “object”, we'll have a draggable view on the screen. I'm calling it the “handle”:

    UIView *handleView_;

We need to know which of the path points the handle is currently on:

    NSInteger handlePathPointIndex_;

And while the pan gesture is active, we need to keep track of where the user has tried to drag the handle:

    CGPoint desiredHandleCenter_;
}

Now we have to get to work initializing all those ivars! We can create our views and layers in viewDidLoad:

- (void)viewDidLoad {
    [super viewDidLoad];
    [self initPathLayer];
    [self initHandleView];
    [self initHandlePanGestureRecognizer];
}

We create the path-displaying layer like this:

- (void)initPathLayer {
    pathLayer_ = [CAShapeLayer layer];
    pathLayer_.lineWidth = 1;
    pathLayer_.fillColor = nil;
    pathLayer_.strokeColor = [UIColor blackColor].CGColor;
    pathLayer_.lineCap = kCALineCapButt;
    pathLayer_.lineJoin = kCALineJoinRound;
    [self.view.layer addSublayer:pathLayer_];
}

Note that we haven't set the path layer's path yet! It's too soon to know the path at this time, because my view hasn't been laid out at its final size yet.

We'll draw a red circle for the handle:

- (void)initHandleView {
    handlePathPointIndex_ = 0;

    CGRect rect = CGRectMake(0, 0, 30, 30);
    CAShapeLayer *circleLayer = [CAShapeLayer layer];
    circleLayer.fillColor = nil;
    circleLayer.strokeColor = [UIColor redColor].CGColor;
    circleLayer.lineWidth = 2;
    circleLayer.path = [UIBezierPath bezierPathWithOvalInRect:CGRectInset(rect, circleLayer.lineWidth, circleLayer.lineWidth)].CGPath;
    circleLayer.frame = rect;

    handleView_ = [[UIView alloc] initWithFrame:rect];
    [handleView_.layer addSublayer:circleLayer];
    [self.view addSubview:handleView_];
}

Again, it's too soon to know exactly where we'll need to put the handle on screen. We'll take care of that at view layout time.

We also need to attach a pan gesture recognizer to the handle:

- (void)initHandlePanGestureRecognizer {
    UIPanGestureRecognizer *recognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handleWasPanned:)];
    [handleView_ addGestureRecognizer:recognizer];
}

At view layout time, we need to create the path based on the size of the view, compute the points along the path, make the path layer show the path, and make sure the handle is on the path:

- (void)viewDidLayoutSubviews {
    [super viewDidLayoutSubviews];
    [self createPath];
    [self createPathPoints];
    [self layoutPathLayer];
    [self layoutHandleView];
}

In your question, you said you're using a “simple oval shape”, but that's boring. Let's draw a nice figure 8. Figuring out what I'm doing is left as an exercise for the reader:

- (void)createPath {
    CGRect bounds = self.view.bounds;
    CGFloat const radius = bounds.size.height / 6;
    CGFloat const offset = 2 * radius * M_SQRT1_2;
    CGPoint const topCenter = CGPointMake(CGRectGetMidX(bounds), CGRectGetMidY(bounds) - offset);
    CGPoint const bottomCenter = { topCenter.x, CGRectGetMidY(bounds) + offset };
    path_ = [UIBezierPath bezierPath];
    [path_ addArcWithCenter:topCenter radius:radius startAngle:M_PI_4 endAngle:-M_PI - M_PI_4 clockwise:NO];
    [path_ addArcWithCenter:bottomCenter radius:radius startAngle:-M_PI_4 endAngle:M_PI + M_PI_4 clockwise:YES];
    [path_ closePath];
}

Next we're going to want to compute the array of points along that path. We'll need a helper routine to pick out the endpoint of each path element:

static CGPoint *lastPointOfPathElement(CGPathElement const *element) {
    int index;
    switch (element->type) {
        case kCGPathElementMoveToPoint: index = 0; break;
        case kCGPathElementAddCurveToPoint: index = 2; break;
        case kCGPathElementAddLineToPoint: index = 0; break;
        case kCGPathElementAddQuadCurveToPoint: index = 1; break;
        case kCGPathElementCloseSubpath: index = NSNotFound; break;
    }
    return index == NSNotFound ? 0 : &element->points[index];
}

To find the points, we need to ask Core Graphics to “dash” the path:

- (void)createPathPoints {
    CGPathRef cgDashedPath = CGPathCreateCopyByDashingPath(path_.CGPath, NULL, 0, (CGFloat[]){ 1.0f, 1.0f }, 2);
    UIBezierPath *dashedPath = [UIBezierPath bezierPathWithCGPath:cgDashedPath];
    CGPathRelease(cgDashedPath);

It turns out that when Core Graphics dashes the path, it can create segments that slightly overlap. We'll want to eliminate those by filtering out each point that's too close to its predecessor, so we'll define a minimum inter-point distance:

    static CGFloat const kMinimumDistance = 0.1f;

To do the filtering, we'll need to keep track of that predecessor:

    __block CGPoint priorPoint = { HUGE_VALF, HUGE_VALF };

We need to create the NSMutableData that will hold the CGPoints:

    pathPointsData_ = [[NSMutableData alloc] init];

At last we're ready to iterate over the elements of the dashed path:

    [dashedPath forEachElement:^(const CGPathElement *element) {

Each path element can be a “move-to”, a “line-to”, a “quadratic-curve-to”, a “curve-to” (which is a cubic curve), or a “close-path”. All of those except close-path define a segment endpoint, which we pick up with our helper function from earlier:

        CGPoint *p = lastPointOfPathElement(element);
        if (!p)
            return;

If the endpoint is too close to the prior point, we discard it:

        if (hypotf(p->x - priorPoint.x, p->y - priorPoint.y) < kMinimumDistance)
            return;

Otherwise, we append it to the data and save it as the predecessor of the next endpoint:

        [pathPointsData_ appendBytes:p length:sizeof *p];
        priorPoint = *p;
    }];

Now we can initialize our pathPoints_ and pathPointsCount_ ivars:

    pathPoints_ = (CGPoint const *)pathPointsData_.bytes;
    pathPointsCount_ = pathPointsData_.length / sizeof *pathPoints_;

But we have one more point we need to filter. The very first point along the path might be too close to the very last point. If so, we'll just discard the last point by decrementing the count:

    if (pathPointsCount_ > 1 && hypotf(pathPoints_[0].x - priorPoint.x, pathPoints_[0].y - priorPoint.y) < kMinimumDistance) {
        pathPointsCount_ -= 1;
    }
}

Blammo. Point array created. Oh yeah, we also need to update the path layer. Brace yourself:

- (void)layoutPathLayer {
    pathLayer_.path = path_.CGPath;
    pathLayer_.frame = self.view.bounds;
}

Now we can worry about dragging the handle around and making sure it stays on the path. The pan gesture recognizer sends this action:

- (void)handleWasPanned:(UIPanGestureRecognizer *)recognizer {
    switch (recognizer.state) {

If this is the start of the pan (drag), we just want to save the starting location of the handle as its desired location:

        case UIGestureRecognizerStateBegan: {
            desiredHandleCenter_ = handleView_.center;
            break;
        }

Otherwise, we need to update the desired location based on the drag, and then slide the handle along the path toward the new desired location:

        case UIGestureRecognizerStateChanged:
        case UIGestureRecognizerStateEnded:
        case UIGestureRecognizerStateCancelled: {
            CGPoint translation = [recognizer translationInView:self.view];
            desiredHandleCenter_.x += translation.x;
            desiredHandleCenter_.y += translation.y;
            [self moveHandleTowardPoint:desiredHandleCent

与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
OGeek|极客中国-欢迎来到极客的世界,一个免费开放的程序员编程交流平台!开放,进步,分享!让技术改变生活,让极客改变未来! Welcome to OGeek Q&A Community for programmer and developer-Open, Learning and Share
Click Here to Ask a Question

...