Home > Software engineering >  Using a CATextLayer to Mask Out From Another CALayer
Using a CATextLayer to Mask Out From Another CALayer

Time:04-10

For whatever reason, I can't get this to work with a CATextLayer. I suspect it's totally obvious, and I can't see the forest for the trees, but what I need to do, is use a CATextLayer to mask a "hole" into a CAGradientLayer (so the effect is a gradient, with text "cut out" of it).

I have this working fine, the other way, but I am coming up snake eyes, trying to mask the text from the gradient.

Here's the code that I'm using (It's a UIButton class, and this is the layoutSubviews() override):

override func layoutSubviews() {
    super.layoutSubviews()
    layer.borderColor = UIColor.clear.cgColor
    if let text = titleLabel?.text,
       var dynFont = titleLabel?.font {
        let minimumFontSizeInPoints = (dynFont.pointSize * 0.5)
        let scalingStep = 0.025
        while dynFont.pointSize >= minimumFontSizeInPoints {
            let calcString = NSAttributedString(string: text, attributes: [.font: dynFont])
            let cropRect = calcString.boundingRect(with: CGSize.init(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
            if bounds.size.width >= cropRect.size.width {
                break
            }
            guard let tempDynFont = UIFont(name: dynFont.fontName, size: dynFont.pointSize - (dynFont.pointSize * scalingStep)) else { break }
            dynFont = tempDynFont
        }
        
        titleLabel?.font = dynFont
    }
    
    if let titleLabel = titleLabel,
       let font = titleLabel.font,
       let text = titleLabel.text {
        let textLayer = CATextLayer()
        textLayer.frame = titleLabel.frame
        textLayer.rasterizationScale = UIScreen.main.scale
        textLayer.contentsScale = UIScreen.main.scale
        textLayer.alignmentMode = .left
        textLayer.fontSize = font.pointSize
        textLayer.font = font
        textLayer.isWrapped = true
        textLayer.truncationMode = .none
        textLayer.string = text
        self.textLayer = textLayer
        titleLabel.textColor = .clear

        let gradient = CAGradientLayer()
        gradient.colors = [gradientStartColor.cgColor, gradientEndColor.cgColor]
        gradient.startPoint = CGPoint(x: 0.5, y: 0)
        gradient.endPoint = CGPoint(x: 0.5, y: 1.0)
        var layerFrame = textLayer.frame

        if !reversed {
            if 0 < layer.borderWidth {
                let outlineLayer = CAShapeLayer()
                outlineLayer.frame = bounds
                outlineLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: layer.cornerRadius).cgPath
                outlineLayer.lineWidth = layer.borderWidth
                outlineLayer.strokeColor = UIColor.white.cgColor
                outlineLayer.fillColor = UIColor.clear.cgColor
                layerFrame = bounds
                textLayer.masksToBounds = false
                if let compositingFilter = CIFilter(name: "CIAdditionCompositing") {
                    textLayer.compositingFilter = compositingFilter
                    outlineLayer.addSublayer(textLayer)
                }
                layer.mask = outlineLayer
            } else {
                layer.mask = textLayer
            }
        } else {
            let outlineLayer = CAShapeLayer()
            outlineLayer.frame = bounds
            textLayer.foregroundColor = UIColor.white.cgColor
            outlineLayer.backgroundColor = UIColor.white.cgColor
            layerFrame = bounds
            textLayer.masksToBounds = false
            if let compositingFilter = CIFilter(name: "CISourceOutCompositing") {
                outlineLayer.compositingFilter = compositingFilter
                outlineLayer.addSublayer(textLayer)
            }
            layer.mask = outlineLayer
        }
        
        gradient.frame = layerFrame
        layer.addSublayer(gradient)
    }
}

The problem is in this part of the code:

            let outlineLayer = CAShapeLayer()
            outlineLayer.frame = bounds
            textLayer.foregroundColor = UIColor.white.cgColor
            outlineLayer.backgroundColor = UIColor.white.cgColor
            layerFrame = bounds
            textLayer.masksToBounds = false
            if let compositingFilter = CIFilter(name: "CISourceOutCompositing") {
                outlineLayer.compositingFilter = compositingFilter
                outlineLayer.addSublayer(textLayer)
            }
            layer.mask = outlineLayer

The !reversed part works fine. I get a gradient masked through text, and, possibly, an outline.

What I need, is to get the gradient to fill the button, with the text "cut out," so the background shows through.

Like I said, this seems deeply obvious, and I seem to have a block.

Are there any suggestions as to what I might be screwing up?

I could probably break this into a playground, but maybe this is enough.

Thanks!

UPDATE:

Here it is as a playground:

//: A UIKit based Playground for presenting user interface
  
import UIKit
import PlaygroundSupport

@IBDesignable
class Rcvrr_GradientTextMaskButton: UIButton {
    /* ################################################################## */
    /**
     This contains our text
     */
    var textLayer: CALayer?
    
    /* ################################################################## */
    /**
     The starting color for the gradient.
     */
    @IBInspectable var gradientStartColor: UIColor = .white

    /* ################################################################## */
    /**
     The ending color.
     */
    @IBInspectable var gradientEndColor: UIColor = .black

    /* ################################################################## */
    /**
     The angle of the gradient. 0 (default) is top-to-bottom.
     */
    @IBInspectable var gradientAngleInDegrees: CGFloat = 0

    /* ################################################################## */
    /**
     If true, then the label is reversed, so the background is "cut out" of the foreground.
     */
    @IBInspectable var reversed: Bool = false
}

/* ###################################################################################################################################### */
// MARK: Base Class Overrides
/* ###################################################################################################################################### */
extension Rcvrr_GradientTextMaskButton {
    /* ################################################################## */
    /**
     If the button is "standard" (the text is filled with the gradient), then this method takes care of that.
     */
    override func layoutSubviews() {
        super.layoutSubviews()
        layer.borderColor = UIColor.clear.cgColor
        if let text = titleLabel?.text,
           var dynFont = titleLabel?.font {
            let minimumFontSizeInPoints = (dynFont.pointSize * 0.5)
            let scalingStep = 0.025
            while dynFont.pointSize >= minimumFontSizeInPoints {
                let calcString = NSAttributedString(string: text, attributes: [.font: dynFont])
                let cropRect = calcString.boundingRect(with: CGSize.init(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
                if bounds.size.width >= cropRect.size.width {
                    break
                }
                guard let tempDynFont = UIFont(name: dynFont.fontName, size: dynFont.pointSize - (dynFont.pointSize * scalingStep)) else { break }
                dynFont = tempDynFont
            }
            
            titleLabel?.font = dynFont
        }
        
        if let titleLabel = titleLabel,
           let font = titleLabel.font,
           let text = titleLabel.text {
            let textLayer = CATextLayer()
            textLayer.frame = titleLabel.frame
            textLayer.rasterizationScale = UIScreen.main.scale
            textLayer.contentsScale = UIScreen.main.scale
            textLayer.alignmentMode = .left
            textLayer.fontSize = font.pointSize
            textLayer.font = font
            textLayer.isWrapped = true
            textLayer.truncationMode = .none
            textLayer.string = text
            self.textLayer = textLayer
            titleLabel.textColor = .clear

            let gradient = CAGradientLayer()
            gradient.colors = [gradientStartColor.cgColor, gradientEndColor.cgColor]
            gradient.startPoint = CGPoint(x: 0.5, y: 0)
            gradient.endPoint = CGPoint(x: 0.5, y: 1.0)
            var layerFrame = textLayer.frame

            if !reversed {
                if 0 < layer.borderWidth {
                    let outlineLayer = CAShapeLayer()
                    outlineLayer.frame = bounds
                    outlineLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: layer.cornerRadius).cgPath
                    outlineLayer.lineWidth = layer.borderWidth
                    outlineLayer.strokeColor = UIColor.white.cgColor
                    outlineLayer.fillColor = UIColor.clear.cgColor
                    layerFrame = bounds
                    textLayer.masksToBounds = false
                    if let compositingFilter = CIFilter(name: "CIAdditionCompositing") {
                        textLayer.compositingFilter = compositingFilter
                        outlineLayer.addSublayer(textLayer)
                    }
                    layer.mask = outlineLayer
                } else {
                    layer.mask = textLayer
                }
            } else {
                let outlineLayer = CAShapeLayer()
                outlineLayer.frame = bounds
                textLayer.foregroundColor = UIColor.white.cgColor
                outlineLayer.backgroundColor = UIColor.white.cgColor
                layerFrame = bounds
                textLayer.masksToBounds = false
                if let compositingFilter = CIFilter(name: "CISourceOutCompositing") {
                    outlineLayer.compositingFilter = compositingFilter
                    outlineLayer.addSublayer(textLayer)
                }
                layer.mask = outlineLayer
            }
            
            gradient.frame = layerFrame
            layer.addSublayer(gradient)
        }
    }
}

class MyViewController : UIViewController {
    override func loadView() {
        let view = UIView()
        view.backgroundColor = .yellow

        let button = Rcvrr_GradientTextMaskButton()
        button.frame = CGRect(x: 10, y: 200, width: 300, height: 50)
        button.setTitle("HI", for: .normal)
        button.gradientStartColor = .green
        button.gradientEndColor = .blue
        button.reversed = true
        
        view.addSubview(button)
        self.view = view
    }
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()

CodePudding user response:

The immediate problem is that you are putting the gradient layer in front of the layer that is being masked. Your masking is working, but you are covering it up! It is not self.layer you want to mask if you want to see something happening here; it's gradient. Change layer.mask = to gradient.mask = everywhere, and you will see an actual visible result.

You will then probably realize that your mask itself is faulty, but at least you won't just be looking at the unadulterated gradient wondering where the mask went!

  • Related