skip to Main Content

Note, that it must work with different number of lines in UILabel – 1,2,3 etc.
I’ve already found solution for 1 line label, where you mask UILabel’s layer with CAGradientLayer, but it doesn’t work for multiline labels, as it masks the whole layer and fades out all lines.

I tried to make another CALayer with position calculated to be in the position of last line with desired width and used CAGradientLayer as mask and add this layer as sublayer of UILabel, it worked for static objects, but i use this UILabel in UITableViewCell and when it’s tapped – it changes color to gray and i can see my layer, because it uses background color of UILabel when view layout its subviews, and also something wrong with x position calculation:

extension UILabel {
    func fadeOutLastLineEnd() { //Call in layoutSubviews
        guard bounds.width > 0 else { return }

        lineBreakMode = .byCharWrapping
        let tmpLayer = CALayer()
        let gradientWidth: CGFloat = 32
        let numberOfLines = CGFloat(numberOfLines)
        tmpLayer.backgroundColor = UIColor.white.cgColor
        tmpLayer.frame = CGRect(x: layer.frame.width - gradientWidth,
                                y: layer.frame.height / numberOfLines,
                                width: gradientWidth,
                                height: layer.frame.height / numberOfLines)
        
        let tmpGrLayer = CAGradientLayer()

        tmpGrLayer.colors     = [UIColor.white.cgColor, UIColor.clear.cgColor]
        tmpGrLayer.startPoint = CGPoint(x: 1, y: 0)
        tmpGrLayer.endPoint   = CGPoint(x: 0, y: 0)
        tmpGrLayer.frame = tmpLayer.bounds
        
        tmpLayer.mask = tmpGrLayer
        layer.addSublayer(tmpLayer)
    }
}

So, i need UILabel:

  • which can be multiline
  • end of last line needs to be faded out (gradient?)
  • works in UITableViewCell, when the whole object changes color

2

Answers


  1. You should create a CATextLayer with the same text properties as your UILabel.

    Fill it with the end of your text you wish to fade.

    Then calculate the position of this text segment in your UILabel.

    Finally overlay the two.

    Here are some aspect explained.

    Login or Signup to reply.
  2. There are various ways to do this — here’s one approach.

    We can mask a view by setting the layer.mask. The opaque areas of the mask will show-through, and the transparent areas will not.

    So, what we need is a custom layer subclass that will look like this:

    enter image description here

    This is an example that I’ll call InvertedGradientLayer:

    class InvertedGradientLayer: CALayer {
        
        public var lineHeight: CGFloat = 0
        public var gradWidth: CGFloat = 0
        
        override func draw(in inContext: CGContext) {
            
            // fill all but the bottom "line height" with opaque color
            inContext.setFillColor(UIColor.gray.cgColor)
            var r = self.bounds
            r.size.height -= lineHeight
            inContext.fill(r)
    
            // can be any color, we're going from Opaque to Clear
            let colors = [UIColor.gray.cgColor, UIColor.gray.withAlphaComponent(0.0).cgColor]
            
            let colorSpace = CGColorSpaceCreateDeviceRGB()
            
            let colorLocations: [CGFloat] = [0.0, 1.0]
            
            let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: colorLocations)!
            
            // start the gradient "grad width" from right edge
            let startPoint = CGPoint(x: bounds.maxX - gradWidth, y: 0.5)
            // end the gradient at the right edge, but
            // probably want to leave the farthest-right 1 or 2 points
            //  completely transparent
            let endPoint = CGPoint(x: bounds.maxX - 2.0, y: 0.5)
    
            // gradient rect starts at the bottom of the opaque rect
            r.origin.y = r.size.height - 1
            // gradient rect height can extend below the bounds, becuase it will be clipped
            r.size.height = bounds.height
            inContext.addRect(r)
            inContext.clip()
            inContext.drawLinearGradient(gradient, start: startPoint, end: endPoint, options: .drawsBeforeStartLocation)
    
        }
        
    }
    

    Next, we’ll make a UILabel subclass that implements that InvertedGradientLayer as a layer mask:

    class CornerFadeLabel: UILabel {
        let ivgLayer = InvertedGradientLayer()
        override func layoutSubviews() {
            super.layoutSubviews()
            guard let f = self.font, let t = self.text else { return }
            // we only want to fade-out the last line if
            //  it would be clipped
            let constraintRect = CGSize(width: bounds.width, height: .greatestFiniteMagnitude)
            let boundingBox = t.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font : f], context: nil)
            if boundingBox.height <= bounds.height {
                layer.mask = nil
                return
            }
            layer.mask = ivgLayer
            ivgLayer.lineHeight = f.lineHeight
            ivgLayer.gradWidth = 60.0
            ivgLayer.frame = bounds
            ivgLayer.setNeedsDisplay()
        }
    }
    

    and here is a sample view controller showing it in use:

    class FadeVC: UIViewController {
        
        let wordWrapFadeLabel: CornerFadeLabel = {
            let v = CornerFadeLabel()
            v.numberOfLines = 1
            v.lineBreakMode = .byWordWrapping
            return v
        }()
        
        let charWrapFadeLabel: CornerFadeLabel = {
            let v = CornerFadeLabel()
            v.numberOfLines = 1
            v.lineBreakMode = .byCharWrapping
            return v
        }()
        
        let normalLabel: UILabel = {
            let v = UILabel()
            v.numberOfLines = 1
            return v
        }()
        
        let numLinesLabel: UILabel = {
            let v = UILabel()
            v.textAlignment = .center
            return v
        }()
        
        var numLines: Int = 0
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = .systemBackground
            
            let sampleText = "This is some example text that will wrap onto multiple lines and fade-out the bottom-right corner instead of truncating or clipping a last line."
            wordWrapFadeLabel.text = sampleText
            charWrapFadeLabel.text = sampleText
            normalLabel.text = sampleText
            
            let stack: UIStackView = {
                let v = UIStackView()
                v.axis = .vertical
                v.spacing = 8
                v.translatesAutoresizingMaskIntoConstraints = false
                return v
            }()
            
            let bStack: UIStackView = {
                let v = UIStackView()
                v.axis = .horizontal
                v.spacing = 8
                v.translatesAutoresizingMaskIntoConstraints = false
                return v
            }()
            
            let btnUP: UIButton = {
                let v = UIButton()
                let cfg = UIImage.SymbolConfiguration(pointSize: 28.0, weight: .bold, scale: .large)
                let img = UIImage(systemName: "chevron.up.circle.fill", withConfiguration: cfg)
                v.setImage(img, for: [])
                v.tintColor = .systemGreen
                v.widthAnchor.constraint(equalTo: v.heightAnchor).isActive = true
                v.addTarget(self, action: #selector(btnUpTapped), for: .touchUpInside)
                return v
            }()
            
            let btnDown: UIButton = {
                let v = UIButton()
                let cfg = UIImage.SymbolConfiguration(pointSize: 28.0, weight: .bold, scale: .large)
                let img = UIImage(systemName: "chevron.down.circle.fill", withConfiguration: cfg)
                v.setImage(img, for: [])
                v.tintColor = .systemGreen
                v.widthAnchor.constraint(equalTo: v.heightAnchor).isActive = true
                v.addTarget(self, action: #selector(btnDownTapped), for: .touchUpInside)
                return v
            }()
            
            bStack.addArrangedSubview(btnUP)
            bStack.addArrangedSubview(numLinesLabel)
            bStack.addArrangedSubview(btnDown)
            
            let v1 = UILabel()
            v1.text = "Word-wrapping"
            v1.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
            
            let v2 = UILabel()
            v2.text = "Character-wrapping"
            v2.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
            
            let v3 = UILabel()
            v3.text = "Normal Label (Truncate Tail)"
            v3.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
            
            stack.addArrangedSubview(bStack)
            stack.addArrangedSubview(v1)
            stack.addArrangedSubview(wordWrapFadeLabel)
            stack.addArrangedSubview(v2)
            stack.addArrangedSubview(charWrapFadeLabel)
            stack.addArrangedSubview(v3)
            stack.addArrangedSubview(normalLabel)
    
            stack.setCustomSpacing(20, after: bStack)
            stack.setCustomSpacing(20, after: wordWrapFadeLabel)
            stack.setCustomSpacing(20, after: charWrapFadeLabel)
    
            view.addSubview(stack)
            
            // dashed border views so we can see the lable frames
            let wordBorderView = DashedView()
            let charBorderView = DashedView()
            let normalBorderView = DashedView()
            wordBorderView.translatesAutoresizingMaskIntoConstraints = false
            charBorderView.translatesAutoresizingMaskIntoConstraints = false
            normalBorderView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(wordBorderView)
            view.addSubview(charBorderView)
            view.addSubview(normalBorderView)
    
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                
                stack.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
                stack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 60.0),
                stack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -60.0),
                
                wordBorderView.topAnchor.constraint(equalTo: wordWrapFadeLabel.topAnchor, constant: 0.0),
                wordBorderView.leadingAnchor.constraint(equalTo: wordWrapFadeLabel.leadingAnchor, constant: 0.0),
                wordBorderView.trailingAnchor.constraint(equalTo: wordWrapFadeLabel.trailingAnchor, constant: 0.0),
                wordBorderView.bottomAnchor.constraint(equalTo: wordWrapFadeLabel.bottomAnchor, constant: 0.0),
                
                charBorderView.topAnchor.constraint(equalTo: charWrapFadeLabel.topAnchor, constant: 0.0),
                charBorderView.leadingAnchor.constraint(equalTo: charWrapFadeLabel.leadingAnchor, constant: 0.0),
                charBorderView.trailingAnchor.constraint(equalTo: charWrapFadeLabel.trailingAnchor, constant: 0.0),
                charBorderView.bottomAnchor.constraint(equalTo: charWrapFadeLabel.bottomAnchor, constant: 0.0),
                
                normalBorderView.topAnchor.constraint(equalTo: normalLabel.topAnchor, constant: 0.0),
                normalBorderView.leadingAnchor.constraint(equalTo: normalLabel.leadingAnchor, constant: 0.0),
                normalBorderView.trailingAnchor.constraint(equalTo: normalLabel.trailingAnchor, constant: 0.0),
                normalBorderView.bottomAnchor.constraint(equalTo: normalLabel.bottomAnchor, constant: 0.0),
                
            ])
            
            // set initial number of lines to 1
            btnUpTapped()
            
        }
        @objc func btnUpTapped() {
            numLines += 1
            numLinesLabel.text = "Num Lines: (numLines)"
            wordWrapFadeLabel.numberOfLines = numLines
            charWrapFadeLabel.numberOfLines = numLines
            normalLabel.numberOfLines = numLines
        }
        @objc func btnDownTapped() {
            if numLines == 1 { return }
            numLines -= 1
            numLinesLabel.text = "Num Lines: (numLines)"
            wordWrapFadeLabel.numberOfLines = numLines
            charWrapFadeLabel.numberOfLines = numLines
            normalLabel.numberOfLines = numLines
        }
    }
    

    When running, it looks like this:

    enter image description here

    The red dashed borders are there just so we can see the frames of the labels. Tapping the up/down arrows will increment/decrement the max number of lines to show in each label.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search