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 :
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:
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:
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.