Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

CustomView looks odd in my project, but good in the playground

So I've created a custom NSButton to have a beautiful radio button, but I'm experiencing a very weird bug.

My radio button looks good in the playground, but when I add it to my project, it looks odd.

Here are screenshots:

radio button radio button x400

Left = in the playground.
Right = in my project.

As you can see, on the right (in my project), the blue dot looks horrible, it's not smooth, same thing for the white circle (it's less visible with the dark background).

In my project, the NSShadow on my CALayer is also flipped, even if the geometryFlipped property on my main (_containerLayer_) CALayer is set to true. -> FIXED: see @Bannings answer.

import AppKit

extension NSColor {
    static func colorWithDecimal(deviceRed deviceRed: Int, deviceGreen: Int, deviceBlue: Int, alpha: Float) -> NSColor {
        return NSColor(
            deviceRed: CGFloat(Double(deviceRed)/255.0),
            green: CGFloat(Double(deviceGreen)/255.0),
            blue: CGFloat(Double(deviceBlue)/255.0),
            alpha: CGFloat(alpha)
        )
    }
}

extension NSBezierPath {

    var CGPath: CGPathRef {
        return self.toCGPath()
    }

    /// Transforms the NSBezierPath into a CGPathRef
    ///
    /// :returns: The transformed NSBezierPath
    private func toCGPath() -> CGPathRef {

        // Create path
        let path = CGPathCreateMutable()
        var points = UnsafeMutablePointer<NSPoint>.alloc(3)
        let numElements = self.elementCount

        if numElements > 0 {

            var didClosePath = true

            for index in 0..<numElements {

                let pathType = self.elementAtIndex(index, associatedPoints: points)

                switch pathType {

                case .MoveToBezierPathElement:
                    CGPathMoveToPoint(path, nil, points[0].x, points[0].y)
                case .LineToBezierPathElement:
                    CGPathAddLineToPoint(path, nil, points[0].x, points[0].y)
                    didClosePath = false
                case .CurveToBezierPathElement:
                    CGPathAddCurveToPoint(path, nil, points[0].x, points[0].y, points[1].x, points[1].y, points[2].x, points[2].y)
                    didClosePath = false
                case .ClosePathBezierPathElement:
                    CGPathCloseSubpath(path)
                    didClosePath = true
                }
            }

            if !didClosePath { CGPathCloseSubpath(path) }
        }

        points.dealloc(3)
        return path
    }
}

class RadioButton: NSButton {

    private var containerLayer: CALayer!
    private var backgroundLayer: CALayer!
    private var dotLayer: CALayer!
    private var hoverLayer: CALayer!

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        self.setupLayers(radioButtonFrame: CGRectZero)
    }

    override init(frame frameRect: NSRect) {
        super.init(frame: frameRect)
        let radioButtonFrame = CGRect(
            x: 0,
            y: 0,
            width: frameRect.height,
            height: frameRect.height
        )
        self.setupLayers(radioButtonFrame: radioButtonFrame)
    }

    override func drawRect(dirtyRect: NSRect) {
    }

    private func setupLayers(radioButtonFrame radioButtonFrame: CGRect) {
        //// Enable view layer
        self.wantsLayer = true

        self.setupBackgroundLayer(radioButtonFrame)
        self.setupDotLayer(radioButtonFrame)
        self.setupHoverLayer(radioButtonFrame)
        self.setupContainerLayer(radioButtonFrame)
    }

    private func setupContainerLayer(frame: CGRect) {

        self.containerLayer = CALayer()
        self.containerLayer.frame = frame
        self.containerLayer.geometryFlipped = true

        //// Mask
        let mask = CAShapeLayer()
        mask.path = NSBezierPath(ovalInRect: frame).CGPath
        mask.fillColor = NSColor.blackColor().CGColor
        self.containerLayer.mask = mask

        self.containerLayer.addSublayer(self.backgroundLayer)
        self.containerLayer.addSublayer(self.dotLayer)
        self.containerLayer.addSublayer(self.hoverLayer)

        self.layer!.addSublayer(self.containerLayer)
    }

    private func setupBackgroundLayer(frame: CGRect) {

        self.backgroundLayer = CALayer()
        self.backgroundLayer.frame = frame
        self.backgroundLayer.backgroundColor = NSColor.whiteColor().CGColor
    }

    private func setupDotLayer(frame: CGRect) {

        let dotFrame = frame.rectByInsetting(dx: 6, dy: 6)
        let maskFrame = CGRect(origin: CGPointZero, size: dotFrame.size)

        self.dotLayer = CALayer()
        self.dotLayer.frame = dotFrame
        self.dotLayer.shadowColor = NSColor.colorWithDecimal(deviceRed: 46, deviceGreen: 146, deviceBlue: 255, alpha: 1.0).CGColor
        self.dotLayer.shadowOffset = CGSize(width: 0, height: 2)
        self.dotLayer.shadowOpacity = 0.4
        self.dotLayer.shadowRadius = 2.0

        //// Mask
        let maskLayer = CAShapeLayer()
        maskLayer.path = NSBezierPath(ovalInRect: maskFrame).CGPath
        maskLayer.fillColor = NSColor.blackColor().CGColor

        //// Gradient
        let gradientLayer = CAGradientLayer()
        gradientLayer.frame = CGRect(origin: CGPointZero, size: dotFrame.size)
        gradientLayer.colors = [
            NSColor.colorWithDecimal(deviceRed: 29, deviceGreen: 114, deviceBlue: 253, alpha: 1.0).CGColor,
            NSColor.colorWithDecimal(deviceRed: 59, deviceGreen: 154, deviceBlue: 255, alpha: 1.0).CGColor
        ]
        gradientLayer.mask = maskLayer

        //// Inner Stroke
        let strokeLayer = CAShapeLayer()
        strokeLayer.path = NSBezierPath(ovalInRect: maskFrame.rectByInsetting(dx: 0.5, dy: 0.5)).CGPath
        strokeLayer.fillColor = NSColor.clearColor().CGColor
        strokeLayer.strokeColor = NSColor.blackColor().colorWithAlphaComponent(0.12).CGColor
        strokeLayer.lineWidth = 1.0

        self.dotLayer.addSublayer(gradientLayer)
        self.dotLayer.addSublayer(strokeLayer)
    }

    private func setupHoverLayer(frame: CGRect) {

        self.hoverLayer = CALayer()
        self.hoverLayer.frame = frame

        //// Inner Shadow
        let innerShadowLayer = CAShapeLayer()
        let ovalPath = NSBezierPath(ovalInRect: frame.rectByInsetting(dx: -10, dy: -10))
        let cutout = NSBezierPath(ovalInRect: frame.rectByInsetting(dx: -1, dy: -1)).bezierPathByReversingPath
        ovalPath.appendBezierPath(cutout)
        innerShadowLayer.path = ovalPath.CGPath
        innerShadowLayer.shadowColor = NSColor.blackColor().CGColor
        innerShadowLayer.shadowOpacity = 0.2
        innerShadowLayer.shadowRadius = 2.0
        innerShadowLayer.shadowOffset = CGSize(width: 0, height: 2)

        self.hoverLayer.addSublayer(innerShadowLayer)

        //// Inner Stroke
        let strokeLayer = CAShapeLayer()
        strokeLayer.path = NSBezierPath(ovalInRect: frame.rectByInsetting(dx: -0.5, dy: -0.5)).CGPath
        strokeLayer.fillColor = NSColor.clearColor().CGColor
        strokeLayer.strokeColor = NSColor.blackColor().colorWithAlphaComponent(0.22).CGColor
        strokeLayer.lineWidth = 2.0

        self.hoverLayer.addSublayer(strokeLayer)
    }
}

let rbFrame = NSRect(
    x: 87,
    y: 37,
    width: 26,
    height: 26
)

let viewFrame = CGRect(
    x: 0,
    y: 0,
    width: 200,
    height: 100
)

let view = NSView(frame: viewFrame)
view.wantsLayer = true
view.layer!.backgroundColor = NSColor.colorWithDecimal(deviceRed: 40, deviceGreen: 40, deviceBlue: 40, alpha: 1.0).CGColor

let rb = RadioButton(frame: rbFrame)
view.addSubview(rb)

I'm using the exact same code on both my project and in the playground.
Here is a zip containing the playground and the project.

Just to be clear: I want to know why circles drawings are smooth in the playground but not in projects. (See @Bannings answer, it's more obvious with his screenshots)

like image 984
Vin Gazoil Avatar asked Aug 12 '15 15:08

Vin Gazoil


2 Answers

Took time but I think I finally figured out everything or almost everything.

  1. First some science : Circles or arcs can't be represented through Bézier curves. That's a property of Bézier curves as stated here : https://en.wikipedia.org/wiki/Bézier_curve

    So when using NSBezierPath(ovalInRect:) you are in fact generating a Bézier curve approximating a circle. This can lead to a difference in appearance and how the shape renders. Still this shouldn't be a problem in our case because the difference is between two Bézier curves (the one in Playground and the one in real OS X project), but still I find it interesting to note in case you think the circle is not perfect enough.

  2. Second as stated in this question (How to draw a smooth circle with CAShapeLayer and UIBezierPath) there are differences in the way the antialiasing will apply to your path depending on where the path is used. NSView's drawRect: being the place where the path antialiasing will be the best and CAShapeLayer being the worst.

    Also I found that the CAShapeLayer documentation has a note saying this :

    Shape rasterization may favor speed over accuracy. For example, pixels with multiple intersecting path segments may not give exact results.

    Glen Low's answer to the question I previously mentioned seem to work fine in our case :

    layer.rasterizationScale = 2.0 * self.window!.screen!.backingScaleFactor;
    layer.shouldRasterize = true;
    

    See the differences here :

    Simple Bézier path

    Bézier path with previously mentioned fix

    Another solution is to leverage corner radiuses instead of Bézier paths in order to simulate a circle, and this time it's pretty accurate :

    Using radiuses

  3. Finally my assumption on the difference between the Playground & the real OS X project is that Apple configured Playground so that some optimizations are turned off so the path even thought drawn using CAShapeLayer gets the best anti-aliasing possible. After all you're prototyping, performances are not really important especially on drawing operations.

    I'm not sure to be right on this but I think it wouldn't be surprising. If anyone have any source I'd happily add it.

To me the best solution if you really need the best circle possible is to use corner radiuses.

Also as stated by @Bannings in another answer to this post. Shadows are reversed due to the fact playground render in a different coordinate system. See his answer to fix this.

like image 187
Fantattitude Avatar answered Sep 28 '22 17:09

Fantattitude


I just fixed it by replace this line code:

self.dotLayer.shadowOffset = CGSize(width: 0, height: 2)

with:

self.dotLayer.shadowOffset = CGSize(width: 0, height: -2)

and replace innerShadowLayer.shadowOffset = CGSize(width: 0, height: 2) with:

innerShadowLayer.shadowOffset = CGSize(width: 0, height: -2)

I think you will get the same result like this:

enter image description here enter image description here

Left = in the playground.
Right = in my project.

It seems the Playground is showing the bezier path in LLO coordinate system, you can visit the link: https://forums.developer.apple.com/message/39277#39277

like image 40
Bannings Avatar answered Sep 28 '22 17:09

Bannings