web-dev-qa-db-fra.com

Comment dessiner un cercle lisse avec CAShapeLayer et UIBezierPath?

J'essaie de tracer un cercle en utilisant un CAShapeLayer et en définissant un tracé circulaire dessus. Cependant, cette méthode est systématiquement moins précise lors du rendu à l'écran que d'utiliser borderRadius ou de dessiner directement le chemin dans un CGContextRef.

Voici les résultats des trois méthodes: enter image description here

Notez que le troisième est mal rendu, en particulier à l'intérieur du trait en haut et en bas.

I have définit la propriété contentsScale sur [UIScreen mainScreen].scale.

Voici mon code de dessin pour ces trois cercles. Que manque-t-il pour que CAShapeLayer dessine en douceur?

@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

EDIT: Pour ceux qui ne peuvent pas voir la différence, j'ai dessiné des cercles les uns sur les autres et zoomé:

Ici, j'ai dessiné un cercle rouge avec drawRect:, puis a tracé un cercle identique avec drawRect: à nouveau en vert dessus. Notez le saignement limité de rouge. Ces deux cercles sont "lisses" (et identiques à l'implémentation cornerRadius):

enter image description here

Dans ce deuxième exemple, vous verrez le problème. J'ai dessiné une fois en utilisant un CAShapeLayer en rouge, et encore au-dessus avec un drawRect: implémentation du même chemin, mais en vert. Notez que vous pouvez voir beaucoup plus d'incohérence avec plus de fond perdu du cercle rouge en dessous. Il est clairement dessiné d'une manière différente (et pire).

enter image description here

72
bcherry

Qui savait qu'il y avait tant de façons de dessiner un cercle?

TL; DR: Si vous souhaitez utiliser CAShapeLayer tout en obtenant des cercles lisses, vous devez utiliser shouldRasterize et rasterizationScale avec soin.

Original

enter image description here

Voici votre CAShapeLayer d'origine et un diff de la version drawRect. J'ai fait une capture d'écran de mon iPad Mini avec Retina Display, puis je l'ai massée dans Photoshop et je l'ai soufflée à 200%. Comme vous pouvez le voir clairement, la version CAShapeLayer comporte des différences visibles, en particulier sur les bords gauche et droit (pixels les plus sombres du diff).

Rastériser à l'échelle de l'écran

enter image description here

Rasterize à l'échelle de l'écran, qui devrait être 2.0 sur les appareils de la rétine. Ajoutez ce code:

layer.rasterizationScale = [UIScreen mainScreen].scale;
layer.shouldRasterize = YES;

Notez que rasterizationScale par défaut est 1.0 même sur les appareils Retina, ce qui explique le caractère flou de la valeur par défaut shouldRasterize.

Le cercle est maintenant un peu plus lisse, mais les parties défectueuses (pixels les plus sombres du diff) se sont déplacées vers les bords supérieur et inférieur. Pas sensiblement meilleur que pas de tramage!

Rastériser à une échelle d'écran 2x

enter image description here

layer.rasterizationScale = 2.0 * [UIScreen mainScreen].scale;
layer.shouldRasterize = YES;

Cela permet de pixelliser le chemin à l’échelle de l'écran 2x, ou jusqu'à 4.0 sur les appareils de la rétine.

Le cercle est maintenant visiblement plus lisse, les diffs sont beaucoup plus légers et répartis uniformément.

J'ai également exécuté ceci dans Instruments: Core Animation et je ne voyais aucune différence majeure dans les options de débogage de Core Animation. Cependant, cela peut être plus lent, car la réduction d'échelle ne consiste pas simplement à transférer une image bitmap hors écran à l'écran. Vous devrez peut-être aussi définir temporairement shouldRasterize = NO en animant.

Ce qui ne marche pas

  • Set shouldRasterize = YES par lui-même. Sur les appareils à rétine, cela semble flou car rasterizationScale != screenScale.

  • Set contentScale = screenScale. Etant donné que CAShapeLayer ne s’insère pas dans contents, qu’il s’agisse ou non de la pixellisation, cela n’affecte pas le rendu.

Remerciements à Jay Hollywood de Humaan , un graphiste pointu qui l’a signalé pour la première fois.

67
Glen Low

Ah, j'ai rencontré le même problème il y a quelque temps (c'était toujours iOS 5 puis iirc), et j'ai écrit le commentaire suivant dans le code:

/*
    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 cercle rempli sous une forme de cercle saignerait de sa couleur de remplissage. Cela dépendrait beaucoup des couleurs. Et lors de l’interaction de l’utilisateur, la forme s’aggraverait encore, ce qui me permet de conclure que le shapelayer restituerait toujours avec un facteur d’échelle de 1,0, quel que soit le facteur d’échelle de la couche, car il est destiné à des fins d’animation.

c’est-à-dire que vous n’utilisez un CAShapeLayer que si vous avez un besoin spécifique de modifications animables de la forme du chemin bezier, et non d’aucune des autres propriétés pouvant être animées via les propriétés de calque habituelles.

J'ai finalement décidé d'écrire un ShapeLayer simple qui mettrait en cache son propre résultat, mais vous pouvez essayer d'implémenter displayLayer: ou drawLayer: inContext:

Quelque chose comme:

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

Je n'ai pas essayé cela, mais il serait intéressant de connaître le résultat ...

9
32Beat

Je sais que c’est une question plus ancienne, mais pour ceux qui essaient de travailler dans la méthode drawRect et qui rencontrent toujours des difficultés, un petit Tweak qui m’a énormément aidée utilisait la bonne méthode pour extraire UIGraphicsContext. En utilisant le défaut:

let newSize = CGSize(width: 50, height: 50)
UIGraphicsBeginImageContext(newSize)
let context = UIGraphicsGetCurrentContext()

entraînerait des cercles flous, peu importe la suggestion que je suivais parmi les autres réponses. Ce qui a finalement abouti pour moi a été de réaliser que la méthode par défaut pour obtenir un ImageContext définit la mise à l'échelle sur une valeur non rétinienne. Pour obtenir un ImageContext pour un écran Retina, vous devez utiliser cette méthode:

let newSize = CGSize(width: 50, height: 50)
UIGraphicsBeginImageContextWithOptions(newSize, false, 0)
let context = UIGraphicsGetCurrentContext()

à partir de là en utilisant les méthodes de dessin normales a bien fonctionné. Paramétrer la dernière option sur 0 indiquera au système d’utiliser le facteur de mise à l’échelle de l’écran principal de l’appareil. L'option médiane false indique au contexte graphique si vous dessinez ou non une image opaque (true signifie que l'image sera opaque) ou nécessite un canal alpha inclus pour transparents. Voici les documents appropriés Apple pour plus de contexte: https://developer.Apple.com/reference/uikit/1623912-uigraphicsbeginimagecontextwitho?language=objc)

4
JiuJitsuCoder

J'imagine que CAShapeLayer est soutenu par un moyen plus performant de rendre ses formes et prend quelques raccourcis. Quoi qu'il en soit, CAShapeLayer peut être un peu lent sur le thread principal. À moins que vous n'ayez besoin d'animer entre différents chemins, je vous suggère de rendre le rendu asynchrone à un UIImage sur un thread d'arrière-plan.

1
hfossli