ios - Cómo dibujar un círculo suave con CAShapeLayer y UIBezierPath?
core animation (5)
¿Quién sabía que hay tantas formas de dibujar un círculo?
TL; DR: si desea usar
CAShapeLayer
y aún así obtener círculos sin problemas, deberá usarshouldRasterize
yrasterizationScale
cuidado.
Original
Aquí está su CAShapeLayer
original y una diferencia de la versión drawRect
. Hice una captura de pantalla de mi iPad Mini con Retina Display, luego la masajeé en Photoshop y la volé hasta en un 200%. Como puede ver claramente, la versión CAShapeLayer
tiene diferencias visibles, especialmente en los bordes izquierdo y derecho (píxeles más oscuros en el diff).
Rasterizar en la escala de pantalla
Vamos a rasterizar en la escala de la pantalla, que debería ser 2.0
en dispositivos Retina. Agrega este código:
layer.rasterizationScale = [UIScreen mainScreen].scale;
layer.shouldRasterize = YES;
Tenga en cuenta que rasterizationScale
predeterminada en 1.0
incluso en dispositivos retina, lo que explica la falta de shouldRasterize
de shouldRasterize
.
El círculo ahora es un poco más suave, pero los bits malos (los píxeles más oscuros del gráfico) se han movido a los bordes superior e inferior. ¡No apreciablemente mejor que no rastrillar!
Rasterizar en escala de pantalla 2x
layer.rasterizationScale = 2.0 * [UIScreen mainScreen].scale;
layer.shouldRasterize = YES;
Esto rasteriza la ruta en una escala de pantalla de 2x o hasta 4.0
en dispositivos retina.
El círculo ahora es visiblemente más suave, los diffs son mucho más ligeros y se distribuyen uniformemente.
También ejecuté esto en Instruments: Core Animation y no vi ninguna diferencia importante en las Opciones de depuración de Core Animation. Sin embargo, puede ser más lento, ya que está reduciendo la escala, no solo ajustando un mapa de bits fuera de pantalla a la pantalla. Es posible que también necesite establecer temporalmente shouldRasterize = NO
durante la animación.
Lo que no funciona
Establezca
shouldRasterize = YES
solo. En dispositivos retina, esto parece borroso porquerasterizationScale != screenScale
.Establecer
contentScale = screenScale
. ComoCAShapeLayer
no dibuja en loscontents
, esté o no rastrillado, esto no afecta a la representación.
Gracias a Jay Hollywood of Humaan , un afilado diseñador gráfico que me lo señaló por primera vez.
Estoy intentando dibujar un círculo contorneado usando un CAShapeLayer y estableciendo una ruta circular sobre él. Sin embargo, este método es consistentemente menos preciso cuando se representa en la pantalla que usar borderRadius o dibujar la ruta en un CGContextRef directamente.
Aquí están los resultados de los tres métodos:
Tenga en cuenta que el tercero está pobremente renderizado, especialmente dentro del trazo en la parte superior e inferior.
Establecí la propiedad contentsScale
en [UIScreen mainScreen].scale
.
Aquí está mi código de dibujo para estos tres círculos. ¿Qué falta para hacer que CAShapeLayer se dibuje sin problemas?
@interface BCViewController ()
@end
@interface BCDrawingView : UIView
@end
@implementation BCDrawingView
- (id)initWithFrame:(CGRect)frame
{
if ((self = [super initWithFrame:frame])) {
self.backgroundColor = nil;
self.opaque = YES;
}
return self;
}
- (void)drawRect:(CGRect)rect
{
[super drawRect:rect];
[[UIColor whiteColor] setFill];
CGContextFillRect(UIGraphicsGetCurrentContext(), rect);
CGContextSetFillColorWithColor(UIGraphicsGetCurrentContext(), NULL);
[[UIColor redColor] setStroke];
CGContextSetLineWidth(UIGraphicsGetCurrentContext(), 1);
[[UIBezierPath bezierPathWithOvalInRect:CGRectInset(self.bounds, 4, 4)] stroke];
}
@end
@interface BCShapeView : UIView
@end
@implementation BCShapeView
+ (Class)layerClass
{
return [CAShapeLayer class];
}
- (id)initWithFrame:(CGRect)frame
{
if ((self = [super initWithFrame:frame])) {
self.backgroundColor = nil;
CAShapeLayer *layer = (id)self.layer;
layer.lineWidth = 1;
layer.fillColor = NULL;
layer.path = [UIBezierPath bezierPathWithOvalInRect:CGRectInset(self.bounds, 4, 4)].CGPath;
layer.strokeColor = [UIColor redColor].CGColor;
layer.contentsScale = [UIScreen mainScreen].scale;
layer.shouldRasterize = NO;
}
return self;
}
@end
@implementation BCViewController
- (void)viewDidLoad
{
[super viewDidLoad];
UIView *borderView = [[UIView alloc] initWithFrame:CGRectMake(24, 104, 36, 36)];
borderView.layer.borderColor = [UIColor redColor].CGColor;
borderView.layer.borderWidth = 1;
borderView.layer.cornerRadius = 18;
[self.view addSubview:borderView];
BCDrawingView *drawingView = [[BCDrawingView alloc] initWithFrame:CGRectMake(20, 40, 44, 44)];
[self.view addSubview:drawingView];
BCShapeView *shapeView = [[BCShapeView alloc] initWithFrame:CGRectMake(20, 160, 44, 44)];
[self.view addSubview:shapeView];
UILabel *borderLabel = [UILabel new];
borderLabel.text = @"CALayer borderRadius";
[borderLabel sizeToFit];
borderLabel.center = CGPointMake(borderView.center.x + 26 + borderLabel.bounds.size.width/2.0, borderView.center.y);
[self.view addSubview:borderLabel];
UILabel *drawingLabel = [UILabel new];
drawingLabel.text = @"drawRect: UIBezierPath";
[drawingLabel sizeToFit];
drawingLabel.center = CGPointMake(drawingView.center.x + 26 + drawingLabel.bounds.size.width/2.0, drawingView.center.y);
[self.view addSubview:drawingLabel];
UILabel *shapeLabel = [UILabel new];
shapeLabel.text = @"CAShapeLayer UIBezierPath";
[shapeLabel sizeToFit];
shapeLabel.center = CGPointMake(shapeView.center.x + 26 + shapeLabel.bounds.size.width/2.0, shapeView.center.y);
[self.view addSubview:shapeLabel];
}
@end
EDITAR: para aquellos que no pueden ver la diferencia, dibujé círculos uno sobre el otro y amplié:
Aquí dibujé un círculo rojo con drawRect:
y luego dibujé un círculo idéntico con drawRect:
nuevamente en verde sobre él. Tenga en cuenta la sangría limitada de rojo. Ambos círculos son "suaves" (e idénticos a la implementación de cornerRadius
):
En este segundo ejemplo, verás el problema. CAShapeLayer
una vez usando CAShapeLayer
en rojo, y de nuevo en la parte superior con una drawRect:
implementación de la misma ruta, pero en verde. Tenga en cuenta que puede ver mucha más inconsistencia con más sangrado del círculo rojo debajo. Está claramente dibujado de una manera diferente (y peor).
Ah, me encontré con el mismo problema hace un tiempo (todavía era iOS 5 y luego iirc), y escribí el siguiente comentario en el código:
/*
ShapeLayer
----------
Fixed equivalent of CAShapeLayer.
CAShapeLayer is meant for animatable bezierpath
and also doesn''t cache properly for retina display.
ShapeLayer converts its path into a pixelimage,
honoring any displayscaling required for retina.
*/
Un círculo relleno debajo de una forma circular haría sangrar su fillcolor. Dependiendo de los colores, esto sería muy notable. Y durante la interacción con el usuario, la forma empeoraría, lo que me permite concluir que la capa de forma siempre se renderizaría con un factor de escala de 1.0, independientemente del factor de escala de la capa, ya que está destinado a propósitos de animación.
es decir, solo utiliza un CAShapeLayer si tiene una necesidad específica de cambios animables en la forma del bezierpath, no en ninguna de las otras propiedades que son animables a través de las propiedades de capa habituales.
Finalmente decidí escribir un ShapeLayer simple que almacenaba en caché su propio resultado, pero podría intentar implementar el displayLayer: o el drawLayer: inContext:
Algo como:
- (void)displayLayer:(CALayer *)layer
{
UIImage *image = nil;
CGContextRef context = UIImageContextBegin(layer.bounds.size, NO, 0.0);
if (context != nil)
{
[layer renderInContext:context];
image = UIImageContextEnd();
}
layer.contents = image;
}
No lo he intentado, pero sería interesante saber el resultado ...
Creo que CAShapeLayer
está respaldado por una forma más CAShapeLayer
de renderizar sus formas y toma algunos atajos. De CAShapeLayer
modos CAShapeLayer
puede ser un poco lento en el hilo principal. A menos que necesites animar entre diferentes rutas, te sugiero renderizar de forma asincrónica a un UIImage
en una UIImage
de fondo.
Sé que esta es una pregunta más antigua, pero para aquellos que están tratando de trabajar en el método drawRect y todavía tienen problemas, una pequeña modificación que me ayudó muchísimo fue usar el método correcto para buscar el UIGraphicsContext. Usando el predeterminado:
let newSize = CGSize(width: 50, height: 50)
UIGraphicsBeginImageContext(newSize)
let context = UIGraphicsGetCurrentContext()
daría como resultado círculos borrosos sin importar qué sugerencia haya seguido de las otras respuestas. Lo que finalmente hizo por mí fue darme cuenta de que el método predeterminado para obtener un ImageContext establece la escala en no retina. Para obtener un ImageContext para una pantalla Retina, debe usar este método:
let newSize = CGSize(width: 50, height: 50)
UIGraphicsBeginImageContextWithOptions(newSize, false, 0)
let context = UIGraphicsGetCurrentContext()
a partir de ahí, utilizar los métodos de dibujo normales funcionó bien. Establecer la última opción en 0
indicará al sistema que use el factor de escala de la pantalla principal del dispositivo. La opción del medio false
se utiliza para indicar al contexto de gráficos si va a dibujar una imagen opaca ( true
significa que la imagen será opaca) o si necesita un canal alfa incluido para las transparencias. Aquí están los documentos de Apple apropiados para más contexto: https://developer.apple.com/reference/uikit/1623912-uigraphicsbeginimagecontextwitho?language=objc
Usa este método para dibujar UIBezierPath
/*********draw circle where double tapped******/
- (UIBezierPath *)makeCircleAtLocation:(CGPoint)location radius:(CGFloat)radius
{
self.circleCenter = location;
self.circleRadius = radius;
UIBezierPath *path = [UIBezierPath bezierPath];
[path addArcWithCenter:self.circleCenter
radius:self.circleRadius
startAngle:0.0
endAngle:M_PI * 2.0
clockwise:YES];
return path;
}
Y dibuja así
CAShapeLayer *shapeLayer = [CAShapeLayer layer];
shapeLayer.path = [[self makeCircleAtLocation:location radius:50.0] CGPath];
shapeLayer.strokeColor = [[UIColor whiteColor] CGColor];
shapeLayer.fillColor = nil;
shapeLayer.lineWidth = 3.0;
// Add CAShapeLayer to our view
[gesture.view.layer addSublayer:shapeLayer];