Arrastre UIView alrededor de la forma compuesta de CGMutablePaths

Tengo una forma ovalada simple (compuesta por CGMutablePaths) de la que me gustaría que el usuario pueda arrastrar un objeto a su alrededor. Me pregunto qué tan complicado es hacer esto, ¿necesito saber un montón de matemáticas y física, o hay alguna forma simple construida que me permita hacer esto? IE, el usuario arrastra este objeto alrededor del óvalo y lo orbita.

Este es un problema interesante. Queremos arrastrar un objeto, pero restringirlo para que se encuentre en un CGPath . Dijiste que tienes “una forma ovalada simple”, pero eso es aburrido. Hagámoslo con una figura 8. Se verá así cuando hayamos terminado:

figura-8-arrastre

¿Entonces como hacemos esto? Dado un punto arbitrario, encontrar el punto más cercano en una spline Bezier es bastante complicado. Hagámoslo por la fuerza bruta. Haremos una serie de puntos muy cerca del camino. El objeto comienza en uno de esos puntos. Cuando intentemos arrastrar el objeto, miraremos los puntos vecinos. Si alguno está más cerca, moveremos el objeto a ese punto vecino.

Incluso obtener una serie de puntos muy espaciados a lo largo de una curva de Bezier no es trivial, pero hay una forma de que Core Graphics lo haga por nosotros. Podemos usar CGPathCreateCopyByDashingPath con un patrón de guión corto. Esto crea una nueva ruta con muchos segmentos cortos. Tomaremos los puntos finales de cada segmento como nuestra matriz de puntos.

Eso significa que necesitamos iterar sobre los elementos de un CGPath . La única forma de iterar sobre los elementos de un CGPath es con la función CGPathApply , que toma una callback. Sería mucho mejor iterar sobre elementos de ruta con un bloque, así que agreguemos una categoría a UIBezierPath . Comenzamos creando un nuevo proyecto utilizando la plantilla “Aplicación de vista única”, con ARC habilitado. Agregamos una categoría:

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

La implementación es muy simple. Pasamos el bloque como el argumento de info de la función del aplicador de ruta.

 #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 

Para este proyecto de juguete, haremos todo lo demás en el controlador de vista. Necesitaremos algunas variables de instancia:

 @implementation ViewController { 

Necesitamos un ivar para mantener el camino que sigue el objeto.

  UIBezierPath *path_; 

Sería bueno ver el camino, entonces usaremos un CAShapeLayer para mostrarlo. (Necesitamos agregar el marco QuartzCore a nuestro objective para que esto funcione).

  CAShapeLayer *pathLayer_; 

Tendremos que almacenar la matriz de puntos a lo largo del camino en alguna parte. NSMutableData un NSMutableData :

  NSMutableData *pathPointsData_; 

Vamos a querer un puntero a la matriz de puntos, tipeado como un puntero CGPoint :

  CGPoint const *pathPoints_; 

Y necesitamos saber cuántos de esos puntos hay:

  NSInteger pathPointsCount_; 

Para el “objeto”, tendremos una vista que se puede arrastrar en la pantalla. Lo llamo el “mango”:

  UIView *handleView_; 

Necesitamos saber en cuál de los puntos de ruta está actualmente el identificador:

  NSInteger handlePathPointIndex_; 

Y mientras el gesto panorámico está activo, necesitamos hacer un seguimiento de dónde el usuario ha intentado arrastrar el controlador:

  CGPoint desiredHandleCenter_; } 

¡Ahora tenemos que empezar a trabajar para inicializar todos esos ivars! Podemos crear nuestras vistas y capas en viewDidLoad :

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

Creamos la capa de visualización de ruta como esta:

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

Tenga en cuenta que aún no hemos configurado la ruta de la capa de ruta. Es demasiado pronto para conocer el camino en este momento, porque mi punto de vista aún no se ha establecido en su tamaño final.

Dibujaremos un círculo rojo para el mango:

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

De nuevo, es demasiado pronto para saber exactamente dónde tendremos que poner el asa en la pantalla. Nos ocuparemos de eso en el tiempo de diseño de vista.

También debemos adjuntar un reconocedor de gestos pan al identificador:

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

Al ver el tiempo de diseño, necesitamos crear la ruta en función del tamaño de la vista, calcular los puntos a lo largo de la ruta, hacer que la capa de ruta muestre la ruta y asegurarse de que el identificador esté en la ruta:

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

En tu pregunta, dijiste que estás usando una “forma ovalada simple”, pero eso es aburrido. Vamos a dibujar una buena figura 8. Averiguar lo que estoy haciendo se deja como un ejercicio para el lector:

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

A continuación, vamos a querer calcular la matriz de puntos a lo largo de esa ruta. Necesitaremos una rutina de ayuda para elegir el punto final de cada elemento de ruta:

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

Para encontrar los puntos, debemos pedir a Core Graphics que “marque” la ruta:

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

Resulta que cuando Core Graphics arrastra la ruta, puede crear segmentos que se superponen un poco. Queremos eliminarlos al filtrar cada punto que esté demasiado cerca de su predecesor, por lo que definiremos una distancia mínima entre puntos:

  static CGFloat const kMinimumDistance = 0.1f; 

Para hacer el filtrado, necesitaremos hacer un seguimiento de ese predecesor:

  __block CGPoint priorPoint = { HUGE_VALF, HUGE_VALF }; 

Necesitamos crear el NSMutableData que contendrá los CGPoint s:

  pathPointsData_ = [[NSMutableData alloc] init]; 

Por fin estamos listos para iterar sobre los elementos de la ruta punteada:

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

Cada elemento de ruta puede ser un “movimiento hacia”, una “línea a”, una “curva cuadrática a”, una “curva a” (que es una curva cúbica), o una “trayectoria cerrada”. Todos aquellos excepto close-path definen un punto final de segmento, que recogemos con nuestra función de ayuda de antes:

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

Si el punto final está demasiado cerca del punto anterior, lo descartamos:

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

De lo contrario, lo adjuntamos a los datos y lo guardamos como el predecesor del siguiente punto final:

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

Ahora podemos inicializar nuestros pathPoints_ y pathPointsCount_ ivars:

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

Pero tenemos un punto más que debemos filtrar. El primer punto a lo largo del camino podría estar demasiado cerca del último punto. Si es así, descartaremos el último punto disminuyendo el conteo:

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

Blammo. Matriz de puntos creada. Oh sí, también necesitamos actualizar la capa de ruta. Prepárate:

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

Ahora podemos preocuparnos por arrastrar el asa y asegurarnos de que se mantenga en el camino. El reconocedor de gestos pan envía esta acción:

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

Si este es el inicio de la panoramización (arrastre), solo queremos guardar la ubicación de inicio del identificador como su ubicación deseada:

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

De lo contrario, debemos actualizar la ubicación deseada en función del arrastre y luego deslizar el asa a lo largo de la ruta hacia la nueva ubicación deseada:

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

Pusimos una cláusula predeterminada para que clang no nos advierta sobre los otros estados que no nos importa:

  default: break; } 

Finalmente, reiniciamos la traducción del reconocedor de gestos:

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

Entonces, ¿cómo movemos el mango hacia un punto? Queremos deslizarlo a lo largo del camino. Primero, tenemos que descubrir en qué dirección deslizarlo:

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

Es posible que en ambas direcciones se mueva más lejos del punto deseado, así que rescatamos en ese caso:

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

OK, entonces al menos una de las direcciones moverá la manija más cerca. Vamos a averiguar cuál:

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

Pero solo hemos comprobado los vecinos más cercanos del punto de partida del asa. Queremos deslizar todo lo que podamos a lo largo del camino en esa dirección, siempre que el mango se esté acercando al punto deseado:

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

Finalmente, actualice la posición del mango a nuestro punto recién descubierto:

  handlePathPointIndex_ += offset; [self layoutHandleView]; } 

Eso simplemente deja la pequeña cuestión de calcular la distancia desde el mango a un punto, si el mango se mueve a lo largo de la trayectoria por algún desplazamiento. Su antigua hypotf compinche calcula la distancia euclidiana para que no tenga que:

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

(Puede acelerar las cosas usando distancias cuadradas para evitar las raíces cuadradas que la hypotf está computando).

Un pequeño detalle más: el índice en la matriz de puntos necesita envolverse en ambas direcciones. Eso es lo que hemos estado confiando en el misterioso método handlePathPointIndexWithOffset: :

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

Aleta. Puse todo el código en una esencia para una fácil descarga . Disfrutar.