Drag a UIView around a form consisting of CGMutablePaths

I have a simple oval shape (consisting of CGMutablePaths) from which I would like the user to be able to drag an object around it. Just wondering how difficult it is to do this, do I need to know a ton of mathematics and physics, or are there some simple built-in ways that will allow me to do this? IE user drags this object around the oval and it rotates it.

+8
ios iphone cocoa-touch
source share
1 answer

This is an interesting problem. We want to drag an object, but hold it back on CGPath . You said you have a “simple oval shape,” but that’s boring. Let me do this with Figure 8. It will look when we finish:

figure-8-drag

So how do we do this? Given an arbitrary point, it is quite difficult to find the nearest point on the Bezier spline. Let it be brute force. We just make an array of points located close to each other along the path. An object starts at one of these points. When we try to drag an object, we look at neighboring points. If it is close, we will move the object to a neighboring point.

Even getting an array of closely spaced points along the 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. This creates a new path with many short segments. We take the endpoints of each segment as our array of points.

This means that we need to CGPath over the elements of a CGPath . The only way to iterate CGPath elements is through the CGPathApply function, which performs a callback. It would be much better to UIBezierPath over the elements of a path using a block, so add a category to UIBezierPath . Let's start by creating a new project using the "Single View Application" template with ARC support. Add a category:

 @interface UIBezierPath (forEachElement) - (void)forEachElement:(void (^)(CGPathElement const *element))block; @end 

The implementation is very simple. We simply pass the block as the info argument to the path applicator 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 will do the rest in the view controller. We will need some instance variables:

 @implementation ViewController { 

We need ivar to keep the path that the object follows.

  UIBezierPath *path_; 

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

  CAShapeLayer *pathLayer_; 

We will need to store an array of points along the path somewhere. Let NSMutableData be used:

  NSMutableData *pathPointsData_; 

We need a pointer to an array of points, typed as a CGPoint pointer:

  CGPoint const *pathPoints_; 

And we need to know how many of them are:

  NSInteger pathPointsCount_; 

For the "object" we will have a drag-and-drop look on the screen. I call it "handle":

  UIView *handleView_; 

We need to know which of the waypoints is currently located:

  NSInteger handlePathPointIndex_; 

And although the panorama gesture is active, we need to track where the user tried to drag the handle:

  CGPoint desiredHandleCenter_; } 

Now we need to start work on initializing all these Ivars! We can create our views and layers in viewDidLoad :

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

We create a path mapping layer as follows:

 - (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_]; } 

Please note that we have not yet set the path to the path of the path! It is too soon to find out the way at this time, because my opinion has not yet been laid out in its final size.

We will 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 is too soon to know exactly where we will need to place the handle on the screen. We will take care of this during the layout of the presentation.

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

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

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

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

In your question, you said you were using a “simple oval shape,” but that is boring. Let me draw a good figure 8. Finding out what I'm doing remains for the reader to exercise:

 - (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 need to compute an array of points along this path. We will need an auxiliary procedure to determine 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 cross out 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 divides a path, it can create segments that overlap a bit. We want to eliminate them by filtering out every point that is too close to its predecessor, so we will determine the minimum point-to-point distance:

  static CGFloat const kMinimumDistance = 0.1f; 

To do the filtering, we will need to track this predecessor:

  __block CGPoint priorPoint = { HUGE_VALF, HUGE_VALF }; 

We need to create an NSMutableData that will contain CGPoint s:

  pathPointsData_ = [[NSMutableData alloc] init]; 

Finally, we are ready to iterate over the elements of the dotted line:

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

Each path element can be “move to,” “line-to,” “quadratic curve-to,” “curve-to,” (which is a cubic curve) or “close,” track. All of them, except for the close path, determine the endpoint of the segment, which we obtain using our auxiliary function from earlier:

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

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

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

Otherwise, we add it to the data and save it as the predecessor of the following 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 that we need to filter out. The very first point along the path may be too close to the very last point. If so, we simply drop the last point by decreasing the counter:

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

Blammo. An array of points has been created. Oh yes, we also need to update the path layer. Pick yourself up:

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

Now we can worry about dragging the handle and making sure that it stays in the way. The gesture recognition function sends this action:

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

If this is the beginning of the pan (drag and drop), we just want to save the handle’s initial location in the right place:

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

Otherwise, we need to update the desired location based on drag and drop, and then move the handle along the path to 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:desiredHandleCenter_]; break; } 

We put a default sentence, so clang will not warn us about other conditions that do not bother us:

  default: break; } 

Finally, reset the gesture recognizer translation:

  [recognizer setTranslation:CGPointZero inView:self.view]; } 

So how do we move the handle to a point? We want to glide along the way. First, we need to find out in which direction it can be moved:

 - (void)moveHandleTowardPoint:(CGPoint)point { CGFloat earlierDistance = [self distanceToPoint:point ifHandleMovesByOffset:-1]; CGFloat currentDistance = [self distanceToPoint:point ifHandleMovesByOffset:0]; CGFloat laterDistance = [self distanceToPoint:point ifHandleMovesByOffset:1]; 

It is possible that both directions will move the handle further from the desired point, so let it be freed in this case:

  if (currentDistance <= earlierDistance && currentDistance <= laterDistance) return; 

OK, so at least one of the directions will move the handle closer. Determine which of them:

  NSInteger direction; CGFloat distance; if (earlierDistance < laterDistance) { direction = -1; distance = earlierDistance; } else { direction = 1; distance = laterDistance; } 

But we checked only the nearest neighbors of the starting point. We want to slide as far as possible along the path in this direction, while the handle approaches the desired point:

  NSInteger offset = direction; while (true) { NSInteger nextOffset = offset + direction; CGFloat nextDistance = [self distanceToPoint:point ifHandleMovesByOffset:nextOffset]; if (nextDistance >= distance) break; distance = nextDistance; offset = nextOffset; } 

Finally, update the descriptor position to our newly discovered point:

  handlePathPointIndex_ += offset; [self layoutHandleView]; } 

This simply leaves the little task of calculating the distance from the handle to the point if the handle moves along the path with some offset. Your old hypotf buddy computes the Euclidean distance, so you don't need to:

 - (CGFloat)distanceToPoint:(CGPoint)point ifHandleMovesByOffset:(NSInteger)offset { int index = [self handlePathPointIndexWithOffset:offset]; CGPoint proposedHandlePoint = pathPoints_[index]; return hypotf(point.x - proposedHandlePoint.x, point.y - proposedHandlePoint.y); } 

(You can speed hypotf up by using squares of distances to avoid the square roots that hypotf computes.)

Another tiny detail: an index into an array of points should wrap in both directions. This is what we rely on the mysterious method handlePathPointIndexWithOffset: ::

 - (NSInteger)handlePathPointIndexWithOffset:(NSInteger)offset { NSInteger index = handlePathPointIndex_ + offset; while (index < 0) { index += pathPointsCount_; } while (index >= pathPointsCount_) { index -= pathPointsCount_; } return index; } @end 

Fin. I put all the code in to simplify loading . Enjoy it.

+42
source share

All Articles