0

I am using CAGradientLayer to add a gradient to my UIView. I am setting the gradient ad a solid red colourful testing, and when I check the color that is being displayed, it is showing different RGB values to those I have specified. Is there a way that I can make sure the gradient is showing the colours that I set.

let gradientLayer2 = CAGradientLayer()
gradientLayer2.frame.size = colourPickerView.frame.size
gradientLayer2.colors = [
    UIColor(red: 1.00, green: 0.00, blue: 0.00, alpha: 1.00).cgColor
    UIColor(red: 1.00, green: 0.00, blue: 0.00, alpha: 1.00).cgColor,
]
    
gradientLayer2.startPoint = CGPoint(x: 0.0, y: 0.0)
gradientLayer2.endPoint = CGPoint(x: 1, y: 0)
gradientLayer2.cornerRadius = 20
colourPickerView.layer.insertSublayer(gradientLayer2, at: 0)

This shows the red color as: <CGColor 0x283c8e400> [<CGColorSpace 0x283c989c0> (kCGColorSpaceICCBased; kCGColorSpaceModelRGB; sRGB IEC61966-2.1; extended range)] ( 1 0.14902 0 1 ) (Please note, it is showing the green value as '0.14902')

When I add a solid background colour to the layer, instead of a gradient, it does show the correct RGB values.

let layer = CALayer()
layer.frame.size = colourPickerView.frame.size
layer.backgroundColor = UIColor(red: 1.00, green: 0.00, blue: 0.00, alpha: 1.00).cgColor
colourPickerView.layer.insertSublayer(layer, at: 0)

Here is the code that I am using to get the colour of a certain pixel:

extension UIView {
func colorOfPointView(point: CGPoint) -> UIColor {
    let colorSpace: CGColorSpace = CGColorSpaceCreateDeviceRGB()
    let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)

    var pixelData: [UInt8] = [0, 0, 0, 0]

    let context = CGContext(data: &pixelData, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 4, space: colorSpace, bitmapInfo: bitmapInfo.rawValue)

    context!.translateBy(x: -point.x, y: -point.y)

    self.layer.render(in: context!)

    let red: CGFloat = CGFloat(pixelData[0]) / CGFloat(255.0)
    let green: CGFloat = CGFloat(pixelData[1]) / CGFloat(255.0)
    let blue: CGFloat = CGFloat(pixelData[2]) / CGFloat(255.0)
    let alpha: CGFloat = CGFloat(pixelData[3]) / CGFloat(255.0)

    let color: UIColor = UIColor(red: red, green: green, blue: blue, alpha: alpha)

    return color
}
Tom Coomer
  • 6,227
  • 12
  • 45
  • 82
  • pls attach screenshots – Ahmed Mohiy Mar 03 '23 at 19:13
  • `CAGradientLayer` renders the colors in the extended "P3" wide-color format, even if the `.colors` are the same. This looks like a pretty good discussion: https://stackoverflow.com/questions/61942565/compare-two-uicolors-tap-location-in-uiimageview-vs-assets-catalog-color – DonMag Mar 03 '23 at 21:59

2 Answers2

1

The issue you are running into is the difference between RGBA and Display P3 or Extended sRGBA.

Using your approach with CGContext and a CAGradientLayer, for example, we get back the Display P3 values.

To get the "traditional" 8-bits-per-component values, we can first render the view to a UIImage and then get the RGBA values from a point in the image.

Using these two extensions:

extension UIView {
    func colorAt(point: CGPoint) -> UIColor? {
        return renderView().getPixelColor(point: point)
    }
    func renderView() -> UIImage {
        let renderer = UIGraphicsImageRenderer(size: bounds.size)
        let image = renderer.image { rendererContext in
            drawHierarchy(in: bounds, afterScreenUpdates: true)
        }
        return image
    }
}
extension UIImage {

    // from: https://stackoverflow.com/a/34596653/6257435
    func getPixelColor(point: CGPoint) -> UIColor? {
        guard let cgImage = cgImage else { return nil }
        
        if point.x < 0 || point.x > size.width || point.y < 0 || point.y > size.height {
            return nil
        }

        let width = Int(size.width)
        let height = Int(size.height)
        let colorSpace = CGColorSpaceCreateDeviceRGB()
        
        guard let context = CGContext(data: nil,
                                      width: width,
                                      height: height,
                                      bitsPerComponent: 8,
                                      bytesPerRow: width * 4,
                                      space: colorSpace,
                                      bitmapInfo: CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue)
        else {
            return nil
        }
        
        context.draw(cgImage, in: CGRect(origin: .zero, size: size))
        
        guard let pixelBuffer = context.data else { return nil }
        
        let pointer = pixelBuffer.bindMemory(to: UInt32.self, capacity: width * height)
        let pixel = pointer[Int(point.y) * width + Int(point.x)]
        
        let r: CGFloat = CGFloat(red(for: pixel))   / 255
        let g: CGFloat = CGFloat(green(for: pixel)) / 255
        let b: CGFloat = CGFloat(blue(for: pixel))  / 255
        let a: CGFloat = CGFloat(alpha(for: pixel)) / 255
        
        return UIColor(red: r, green: g, blue: b, alpha: a)
    }
    
    private func alpha(for pixelData: UInt32) -> UInt8 {
        return UInt8((pixelData >> 24) & 255)
    }
    private func red(for pixelData: UInt32) -> UInt8 {
        return UInt8((pixelData >> 16) & 255)
    }
    private func green(for pixelData: UInt32) -> UInt8 {
        return UInt8((pixelData >> 8) & 255)
    }
    private func blue(for pixelData: UInt32) -> UInt8 {
        return UInt8((pixelData >> 0) & 255)
    }
    private func rgba(red: UInt8, green: UInt8, blue: UInt8, alpha: UInt8) -> UInt32 {
        return (UInt32(alpha) << 24) | (UInt32(red) << 16) | (UInt32(green) << 8) | (UInt32(blue) << 0)
    }
}

We can call:

let theColor = someView.colorAt(point: CGPoint(x: 10, y: 10))

and we'll get back what we were expecting.

Because of the need to "capture as UIImage" we don't want to be calling that over and over - such as if we're dragging along a gradient to get the current color... Instead, if possible, we'd want to render the view to a UIImage once and then repeatedly call prerenderedImage.getPixelColor(point: pt).

Here's a quick example...

It will look like this when running:

enter image description here enter image description here

enter image description here enter image description here

We have 2 CAGradientLayer views... the first one using Red -> secondColor and the second one using secondColor -> secondColor (so it appear solid).

The 3rd view has a "left-half" CALayer and a "right-half" CALayer and the 4th view is a plain UIView with the .backgroundColor set.

The button cycles through Red, Green, Blue, Yellow as the second-colors.

When we touch / drag the dashed-line, we'll get the RGBA values from the same x-coordinate on each view (at center-y) and show the results in the label below the button.

Use the above extensions with this code...

View Controller

class ColorAtPointViewController: UIViewController {

    // CAGradientLayer from color1 to color2
    let gradientView1 = MyGradientView()
    
    // CAGradientLayer from color2 to color2 (will appear solid)
    let gradientView2 = MyGradientView()
    
    // left-half CALayer color1 / right-half CALayer color2
    let layerView = MyLayerView()
    
    // plain UIView with .backgroundColor set to color2
    let bkgView = MyBackgroundView()

    // a dash-line to show the touch-point
    let lineView = MyLineView()
    
    // where we'll show the RGBA values
    let outputLabel = UILabel()

    // button to cycle through the 2nd colors
    var btn: UIButton!
    
    // references to the 4-variations of views (for convenience)
    var views: [UIView] = []
    
    // we'll "cache" the views, rendered to UIImages
    var renderedViews: [UIImage] = []
    
    let secondColors: [UIColor] = [
        UIColor(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0),
        UIColor(red: 0.0, green: 1.0, blue: 0.0, alpha: 1.0),
        UIColor(red: 0.0, green: 0.0, blue: 1.0, alpha: 1.0),
        UIColor(red: 1.0, green: 1.0, blue: 0.0, alpha: 1.0),
    ]
    let secondColorNames: [String] = [
        "Red", "Green", "Blue", "Yellow",
    ]
    var c2IDX: Int = 0
    
    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
        
        // references to the views
        views = [gradientView1, gradientView2, layerView, bkgView]
        
        let strs: [String] = [
            "CAGradientLayer View1 (red -> color2)",
            "CAGradientLayer View2 (color2 -> color2)",
            "CALayer View (left red, right color2)",
            "Background Color View (color2)"
        ]
        
        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.spacing = 4
        
        for (str, v) in zip(strs, views) {
            let label = UILabel()
            label.text = str
            v.heightAnchor.constraint(equalToConstant: 40.0).isActive = true
            stackView.addArrangedSubview(label)
            stackView.addArrangedSubview(v)
            stackView.setCustomSpacing(20.0, after: v)
        }

        [stackView, lineView].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(v)
        }
        
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            
            stackView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            stackView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            stackView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            
            lineView.topAnchor.constraint(equalTo: gradientView1.topAnchor, constant: -8.0),
            lineView.leadingAnchor.constraint(equalTo: stackView.leadingAnchor, constant: 0.0),
            lineView.trailingAnchor.constraint(equalTo: stackView.trailingAnchor, constant: 0.0),
            lineView.bottomAnchor.constraint(equalTo: bkgView.bottomAnchor, constant: 12.0),

        ])

        var config = UIButton.Configuration.filled()
        config.buttonSize = .medium
        config.cornerStyle = .medium
        config.title = "Change 2nd Color to"
        
        btn = UIButton(configuration: config)
        btn.addAction (
            UIAction { _ in
                self.nextColor()
            }, for: .touchUpInside
        )

        outputLabel.numberOfLines = 0
        outputLabel.font = .systemFont(ofSize: 13, weight: .regular)
        outputLabel.text = "RGBA at X:"
        [btn, outputLabel].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(v)
        }

        NSLayoutConstraint.activate([
            
            btn.topAnchor.constraint(equalTo: stackView.bottomAnchor, constant: 40.0),
            btn.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            btn.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            
            outputLabel.topAnchor.constraint(equalTo: btn.bottomAnchor, constant: 20.0),
            outputLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            outputLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),

        ])
        
        c2IDX = -1
        nextColor()
    }
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        lineView.x = gradientView1.bounds.midX
        gotTouch(lineView.x)
    }
    func genImages() {
        // render the views to images each time we change the colors
        //  so we don't re-render them every time we want to get a color at a point
        renderedViews = []
        views.forEach { v in
            renderedViews.append(v.renderView())
        }
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let t = touches.first else { return }
        let x = t.location(in: gradientView1).x
        guard x >= 0.0, x <= gradientView1.bounds.maxX else { return }
        gotTouch(x)
    }
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let t = touches.first else { return }
        let x = t.location(in: gradientView1).x
        guard x >= 0.0, x <= gradientView1.bounds.maxX else { return }
        gotTouch(x)
    }
    func gotTouch(_ x: CGFloat) {
        var outputStr: String = "RGBA at X: "
        if UIScreen.main.scale == 3 {
            // if the screen scale is @3x - such as an iPhone 14 Pro - we get 1/3 points
            //  so, display as .000 or .333 or .667 instead of .666666666667
            outputStr += "\(String(format: "%0.3f", x))\n"
        } else {
            // @2x scale, so display as .0 or .5
            outputStr += "\(String(format: "%0.1f", x))\n"
        }
        let strs: [String] = ["G1", "G2", "L", "B"]
        for (str, img) in zip(strs, renderedViews) {
            let pt: CGPoint = .init(x: x, y: img.size.height * 0.5)
            if let c = img.getPixelColor(point: pt) {
                // change "UIExtendedSRGBColorSpace 1 0 0 1" to "RGBA  1  0  0  1"
                //  just so we can focus on the color values
                let s = "\(c)".replacingOccurrences(of: "UIExtendedSRGBColorSpace", with: "RGBA")
                    .replacingOccurrences(of: " ", with: "  ")
                outputStr += "\(str):\t\(s)\n"
            }
        }
        lineView.x = x
        outputLabel.text = outputStr
    }
    
    func nextColor() {
        // cycle to the next "second color"
        self.c2IDX += 1
        let c = self.secondColors[self.c2IDX % self.secondColors.count]
        self.views.forEach { v in
            if let v = v as? MyBaseView {
                v.c2 = c
            }
        }
        // gradientView2 uses color2 -> color2 to appear "solid"
        self.gradientView2.c1 = c
        
        // let the views' layers update before we render new images from the views
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: {
            // re-render the views
            self.genImages()
            // simiulate touch at last touch-point
            self.gotTouch(self.lineView.x)
            // update the button label
            let cName = self.secondColorNames[(self.c2IDX + 1) % self.secondColorNames.count]
            self.btn.configuration?.title = "Change 2nd Color to \(cName)"
        })
    }
}

UIView subclasses

class MyBaseView: UIView {
    
    var c1: UIColor = .red { didSet { colorChanged() } }
    var c2: UIColor = .red { didSet { colorChanged() } }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() {
    }
    func colorChanged() {
    }
    
}

class MyGradientView: MyBaseView {
    
    let gradLayer = CAGradientLayer()
    
    override func commonInit() {
        super.commonInit()
        
        gradLayer.startPoint = CGPoint(x: 0.0, y: 0.0)
        gradLayer.endPoint = CGPoint(x: 1, y: 0)
        gradLayer.colors = [c1.cgColor, c2.cgColor]
        
        layer.addSublayer(gradLayer)
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        gradLayer.frame = bounds
    }
    
    override func colorChanged() {
        super.colorChanged()
        CATransaction.begin()
        CATransaction.setDisableActions(true)
        gradLayer.colors = [c1.cgColor, c2.cgColor]
        CATransaction.commit()
    }
    
}

class MyLayerView: MyBaseView {
    
    let myLayer1 = CALayer()
    let myLayer2 = CALayer()
    
    override func commonInit() {
        super.commonInit()
        
        myLayer1.backgroundColor = c1.cgColor
        myLayer2.backgroundColor = c2.cgColor
        
        layer.addSublayer(myLayer1)
        layer.addSublayer(myLayer2)
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        var r = bounds
        r.size.width *= 0.5
        myLayer1.frame = r
        r.origin.x = r.size.width
        myLayer2.frame = r
    }
    
    override func colorChanged() {
        super.colorChanged()
        CATransaction.begin()
        CATransaction.setDisableActions(true)
        myLayer1.backgroundColor = c1.cgColor
        myLayer2.backgroundColor = c2.cgColor
        CATransaction.commit()
    }
    
}

class MyBackgroundView: MyBaseView {
    
    override func commonInit() {
        super.commonInit()
        backgroundColor = c2
    }
    
    override func colorChanged() {
        super.colorChanged()
        backgroundColor = c2
    }
    
}

class MyLineView: UIView {
    
    var x: CGFloat = 0 { didSet { setNeedsLayout() } }
    
    // this allows us to use the "base" layer as a shape layer
    //  instead of adding a sublayer
    lazy var shapeLayer: CAShapeLayer = self.layer as! CAShapeLayer
    override class var layerClass: AnyClass {
        return CAShapeLayer.self
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() {
        layer.masksToBounds = true
        shapeLayer.strokeColor = UIColor.gray.cgColor
        shapeLayer.fillColor = UIColor.clear.cgColor
        shapeLayer.lineWidth = 1
        shapeLayer.lineDashPattern = [8, 8]
    }
    override func layoutSubviews() {
        super.layoutSubviews()
        
        let bez = UIBezierPath()
        bez.move(to: .init(x: x, y: bounds.minY))
        bez.addLine(to: .init(x: x, y: bounds.maxY))
        
        CATransaction.begin()
        CATransaction.setDisableActions(true)
        shapeLayer.path = bez.cgPath
        CATransaction.commit()
    }
    
}
DonMag
  • 69,424
  • 5
  • 50
  • 86
0

that's odd! Is this the only code that involves with this part of code? I just tested and got true result :

 Optional([<CGColor 0x6000002bd6e0> [<CGColorSpace 0x6000002b9980> (kCGColorSpaceICCBased; kCGColorSpaceModelRGB; sRGB IEC61966-2.1; extended range)] ( 1 0 0 1 ), <CGColor 0x6000002bd2c0> [<CGColorSpace 0x6000002b9980> (kCGColorSpaceICCBased; kCGColorSpaceModelRGB; sRGB IEC61966-2.1; extended range)] ( 1 0 0 1 )])

Copy and paste this code in a new Xcode project and check if you still get the wrong result.

import UIKit

class ViewController: UIViewController {

    let gradientLayer2 = CAGradientLayer()
    let slider = UISlider(frame: CGRect(x: 10, y: 400, width: 100, height: 50))

    override func viewDidLoad() {
        super.viewDidLoad()
        
        slider.maximumValue = 1
        slider.minimumValue = 0
        slider.addTarget(self, action: #selector(sliderChanged), for: .valueChanged)
        slider.backgroundColor = .red
        gradientLayer2.frame.size = CGSize(width: 200, height: 200)
        gradientLayer2.colors = [
            UIColor(red: 1.00, green: 0.00, blue: 0.00, alpha: 1.00).cgColor,
            UIColor(red: 1.00, green: 0.00, blue: 0.00, alpha: 1.00).cgColor
        ]
            
        gradientLayer2.startPoint = CGPoint(x: 0.0, y: 0.0)
        gradientLayer2.endPoint = CGPoint(x: 1, y: 0)
        gradientLayer2.cornerRadius = 20
        view.layer.insertSublayer(gradientLayer2, at: 0)
        view.addSubview(slider)
       
    }

    @objc func sliderChanged()
    {
        gradientLayer2.colors = [
            UIColor(red: 1.00, green: 0.00, blue: 0.00, alpha: 1.00).cgColor,
            UIColor(red: CGFloat(slider.value), green: 0.00, blue: 0.00, alpha: 1.00).cgColor
        ]
        print(gradientLayer2.colors)
    }

}
willow
  • 181
  • 1
  • 9
  • I have modified the question as I wonder if the error is with the function that gets the color of a certain point on the screen that returns the incorrect result, but only seems to get on CAGradientLayers – Tom Coomer Mar 03 '23 at 19:59