如何用 CAShapeLayer 和 UIBezierPath 画一个光滑的圆?

Posted

技术标签:

【中文标题】如何用 CAShapeLayer 和 UIBezierPath 画一个光滑的圆?【英文标题】:How to draw a smooth circle with CAShapeLayer and UIBezierPath? 【发布时间】:2014-08-10 14:06:43 【问题描述】:

我正在尝试通过使用 CAShapeLayer 并在其上设置圆形路径来绘制描边圆。但是,这种方法在渲染到屏幕时始终不如使用borderRadius 或直接在CGContextRef 中绘制路径准确。

以下是所有三种方法的结果:

请注意,第三个渲染效果不佳,尤其是在顶部和底部的笔划内。

已经contentsScale 属性设置为[UIScreen mainScreen].scale

这是我为这三个圆圈绘制的代码。使 CAShapeLayer 平滑绘制缺少什么?

@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

编辑:对于那些看不出区别的人,我在彼此的顶部画了圆圈并放大:

这里我用drawRect: 画了一个红色圆圈,然后在上面用绿色再次用drawRect: 画了一个相同的圆圈。注意红色的有限出血。这两个圆圈都是“平滑的”(与cornerRadius 实现相同):

在第二个示例中,您将看到问题所在。我曾经使用红色的CAShapeLayer 绘制过一次,然后再次在顶部使用相同路径的drawRect: 实现,但使用绿色。请注意,您可以看到更多的不一致,下面的红色圆圈有更多的出血。它显然是以不同(更糟)的方式绘制的。

【问题讨论】:

不确定它是否与您的图像捕获有关,但上面的三个圆圈 - 至少在我看来 - 完全一样。 在这一行中:[UIBezierPath bezierPathWithOvalInRect:CGRectInset(self.bounds, 4, 4)] self.bound 保存点(不是像素),因此从技术上讲,您正在创建一个非视网膜大小的曲线。如果将该尺寸乘以 [UIScreen mainScreen].scale 值,您的椭圆在视网膜屏幕上将非常平滑。 @MaxMacLeod 检查我的编辑是否有差异。 @holex 这只是一个更小的圆圈。我的两个实现都使用相同的路径,一个看起来不错,另一个不好看。 在文档中,它说光栅化有利于速度而不是质量。可能是您看到了不精确插值/抗锯齿的伪影。 【参考方案1】:

谁知道画圆的方法有这么多?

TL;DR:如果您想使用CAShapeLayer 并且仍然获得平滑的圆圈,则需要谨慎使用shouldRasterizerasterizationScale

原创

这是您的原始 CAShapeLayer 和与 drawRect 版本的差异。我用 Retina Display 从我的 iPad Mini 上截取了一张屏幕截图,然后在 Photoshop 中对其进行了处理,并将其放大到 200%。如您所见,CAShapeLayer 版本有明显的差异,尤其是在左右边缘(差异中最暗的像素)。

按屏幕比例光栅化

让我们以屏幕比例进行光栅化,在 Retina 设备上应该是 2.0。添加此代码:

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

请注意,即使在 Retina 设备上,rasterizationScale 也会默认为 1.0,这导致了默认 shouldRasterize 的模糊性。

圆现在稍微平滑了一点,但是坏位(diff 中最暗的像素)已经移动到顶部和底部边缘。不比不光栅化好多少!

以 2 倍屏幕比例光栅化

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

这会将路径光栅化为 2 倍屏幕比例,或在 Retina 设备上高达 4.0

圆圈现在明显更平滑,差异更轻且分布均匀。

我也在 Instruments: Core Animation 中运行过这个,但在 Core Animation Debug Options 中没有发现任何重大差异。然而,它可能会更慢,因为它是缩小比例,而不仅仅是将屏幕外位图传送到屏幕上。您可能还需要在制作动画时临时设置shouldRasterize = NO

什么不起作用

自行设置shouldRasterize = YES在视网膜设备上,这看起来很模糊,因为rasterizationScale != screenScale

设置contentScale = screenScale 由于CAShapeLayer 不绘制到contents,因此无论是否光栅化,这都不会影响再现。

感谢 Humaan 的 Jay Hollywood,他是一位敏锐的平面设计师,他首先向我指出了这一点。

【讨论】:

感谢您的回答!对于某些情况,这当然似乎提供了一个不错的解决方法。你知道@32Beat 是正确的吗,CAShapeLayer 由于动画优化而渲染了一个低保真度的副本?似乎直接绘图能够以 2 倍的比例绘制正确的东西,因此增加比例可以解决问题,但最终并不能解决 CAShapeLayer 的潜在问题。 我很确定 CAShapeLayer 已针对快速绘图和动画进行了优化。但是它还有很多其他优点,我更喜欢使用它而不是 drawRect:分辨率独立(等到 Apple 生产 4 倍视网膜显示器)、更少的内存、可变形而不失真、灵活的光栅化等。 回复:灵活的光栅化。您可以临时确定是否要栅格化,甚至可以在什么级别进行栅格化,例如在由多个CAShapeLayer 组成的层中。 我怀疑 Apple 对CAShapeLayer 再现使用了高平坦度。您有时可以在非视网膜显示器中看到这一点:再现看起来像一个多边形。那么解决办法就是自己做扁平化。参见例如ps.missouri.edu/ps2/support/tutorialfolder/flatness/index.html【参考方案2】:

啊,我前段时间遇到了同样的问题(当时还是ios 5,然后是iirc),我在代码中写了如下注释:

/*
    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. 
*/

圆形下方的实心圆会渗出其填充色。根据颜色,这将非常明显。而且在用户交互期间,形状会渲染得更糟,这让我得出结论,无论图层比例因子如何,shapelayer 总是以 1.0 的比例因子渲染,因为它是用于动画目的。

即仅当您对贝塞尔路径的形状的动画更改有特定需求时才使用 CAShapeLayer,而不是通过通常的图层属性对任何其他可设置动画的属性进行动画更改。

我最终决定编写一个简单的 ShapeLayer 来缓存它自己的结果,但你可以尝试实现 displayLayer: 或 drawLayer:inContext:

类似:

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

我没有尝试过,但知道结果会很有趣......

【讨论】:

这很有意义。我注意到我将 contentsScale 设置为什么并不重要,它看起来仍然一样。我尝试了您的 displayLayer 实现。不幸的是,这并没有什么不同。我怀疑您对 CAShapeLayer 的以动画为中心的绘图优化是正确的。我将继续为我的非动画图层使用自定义绘图。【参考方案3】:

我知道这是一个较老的问题,但是对于那些尝试使用 drawRect 方法但仍然遇到问题的人来说,一个对我有很大帮助的小调整是使用正确的方法来获取 UIGraphicsContext。使用默认值:

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

无论我从其他答案中遵循哪个建议,都会导致圆圈模糊。最终为我做的是意识到获取 ImageContext 的默认方法将缩放设置为非视网膜。要为视网膜显示器获取 ImageContext,您需要使用以下方法:

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

从那里使用正常的绘图方法工作正常。将最后一个选项设置为0 将告诉系统使用设备主屏幕的缩放因子。中间选项false 用于告诉图形上下文您是要绘制不透明图像(true 表示图像将是不透明的)还是需要包含透明度通道的 alpha 通道。以下是有关更多上下文的相应 Apple Docs:https://developer.apple.com/reference/uikit/1623912-uigraphicsbeginimagecontextwitho?language=objc

【讨论】:

【参考方案4】:

我猜CAShapeLayer 得到了一种更高效的渲染形状的方式的支持,并采用了一些捷径。无论如何CAShapeLayer 在主线程上可能会有点慢。除非您需要在不同路径之间设置动画,否则我建议在后台线程上异步渲染到 UIImage

【讨论】:

【参考方案5】:

使用此方法绘制 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;

然后像这样画

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

【讨论】:

我已经尝试过这种制作圆的方法以及使用圆角矩形路径。两者都没有解决沿用 CAShapeLayer 绘制的路径边缘的像素化问题。

以上是关于如何用 CAShapeLayer 和 UIBezierPath 画一个光滑的圆?的主要内容,如果未能解决你的问题,请参考以下文章

如何为 CAShapeLayer 路径和填充颜色设置动画

[[CAShapeLayer alloc] init] 和 [CAShapeLayer layer] 的区别

CAShapeLayer 和 SpriteKit

关于CAShapeLayer

使用UIBezierPath和CAShapeLayer画各种图形

放肆地用 UIBezierPath 和 CAShapeLayer 画各种图形