30

I want the underline to be below the text like any other normal underlining mechanism. However, with NSAttributed string it leaves holes with "g's" and "y's"

example: enter image description here

How it should look: enter image description here

How can I increase the spacing between the underline and label?

MobileMon
  • 8,341
  • 5
  • 56
  • 75
  • As far as I know you can't, but even then: why would want the second version? It's far less legible. Especially at small font sizes. – DarkDust Dec 30 '14 at 18:26
  • 1
    @DarkDust why would I want it? Because that's the expected underline behavior. Open up Microsoft Word or Google docs and that's what you get! – MobileMon Dec 30 '14 at 18:27

4 Answers4

25

There is no way to control that behaviour with NSAttributedString or CoreText (apart from drawing the underline yourself). NSAttributedString has no option for that (and CoreText hasn't got one, either).

On Apple systems, the first version (with the gap) is the "expected" behaviour as it's the one Apple provides and is used throughout the system (and apps like Safari, TextEdit, etc.).

If you really, really want to have underlines without a gap, you need to draw the string without an underline and draw the line yourself (which I needed to do in one of my projects and I can tell you it's hard; see this file, search for "underline").

DarkDust
  • 90,870
  • 19
  • 190
  • 224
8

I added a line (UIView) with height 1 and width like label, aligned to the bottom of the UILabel.

    let label = UILabel()
    label.text = "underlined text"

    let spacing = 2 // will be added as negative bottom margin for more spacing between label and line

    let line = UIView()
    line.translatesAutoresizingMaskIntoConstraints = false
    line.backgroundColor = label.textColor
    label.addSubview(line)
    label.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[line]|", metrics: nil, views: ["line":line]))
    label.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:[line(1)]-(\(-spacing))-|", metrics: nil, views: ["line":line]))
Ferran Maylinch
  • 10,919
  • 16
  • 85
  • 100
  • 1
    Actually a neat idea. It probably would work even better if you would align the line view to the _baseline_ of the label (you can't do that with the visual format, unfortunately). – DarkDust Jun 02 '18 at 08:20
  • Yep, the visual format is a bit limited. That reminds me I built a [LayoutBuilder](https://github.com/fmaylinch/LayoutHelperApp) utility class that accepts extended constraints (for center, baseline, etc). – Ferran Maylinch Jun 03 '18 at 10:51
7

You could use UITextView I added custom NSAttributedStringKey "customUnderline" and swizzling method drawUnderline in NSLayoutManager.

import Foundation
import SwiftyAttributes
import UIKit

private let swizzling: (AnyClass, Selector, Selector) -> Void = { forClass, originalSelector, swizzledSelector in
    guard let originalMethod = class_getInstanceMethod(forClass, originalSelector),
        let swizzledMethod = class_getInstanceMethod(forClass, swizzledSelector) else {
            return
    }
    method_exchangeImplementations(originalMethod, swizzledMethod)
}

extension NSAttributedStringKey {
    static var customUnderline: NSAttributedStringKey = NSAttributedStringKey("customUnderline")
}

extension Attribute {
    static var customUnderline: Attribute = Attribute.custom(NSAttributedStringKey.customUnderline.rawValue, true)
}

extension NSLayoutManager {

    // MARK: - Properties

    static let initSwizzling: Void = {
        let originalSelector = #selector(drawUnderline(forGlyphRange:underlineType:baselineOffset:lineFragmentRect:lineFragmentGlyphRange:containerOrigin:))
        let swizzledSelector = #selector(swizzled_drawUnderline(forGlyphRange:underlineType:baselineOffset:lineFragmentRect:lineFragmentGlyphRange:containerOrigin:))
        swizzling(NSLayoutManager.self, originalSelector, swizzledSelector)
    }()

    // MARK: - Functions

    @objc
    func swizzled_drawUnderline(forGlyphRange glyphRange: NSRange, underlineType underlineVal: NSUnderlineStyle, baselineOffset: CGFloat, lineFragmentRect lineRect: CGRect, lineFragmentGlyphRange lineGlyphRange: NSRange, containerOrigin: CGPoint) {
        guard needCustomizeUnderline(underlineType: underlineVal) else {
            swizzled_drawUnderline(forGlyphRange: glyphRange,
                                   underlineType: underlineVal,
                                   baselineOffset: baselineOffset,
                                   lineFragmentRect: lineRect,
                                   lineFragmentGlyphRange: lineGlyphRange,
                                   containerOrigin: containerOrigin)
            return
        }

        let heightOffset = containerOrigin.y - 1 + (getFontHeight(in: glyphRange) ?? (lineRect.height / 2))
        drawStrikethrough(forGlyphRange: glyphRange,
                          strikethroughType: underlineVal,
                          baselineOffset: baselineOffset,
                          lineFragmentRect: lineRect,
                          lineFragmentGlyphRange: lineGlyphRange,
                          containerOrigin: CGPoint(x: containerOrigin.x,
                                                   y: heightOffset))
    }

    // MARK: - Private functions

    private func needCustomizeUnderline(underlineType underlineVal: NSUnderlineStyle) -> Bool {
        guard underlineVal == NSUnderlineStyle.styleSingle else {
            return false
        }
        let attributes = textStorage?.attributes(at: 0, effectiveRange: nil)
        guard let isCustomUnderline = attributes?.keys.contains(.customUnderline), isCustomUnderline else {
            return false
        }
        return true
    }

    private func getFontHeight(in glyphRange: NSRange) -> CGFloat? {
        let location = characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil).location
        guard let font = textStorage?.attribute(.font, at: location, effectiveRange: nil) as? UIFont else {
            return nil
        }
        return font.capHeight
    }
}

like that

  • I just filed a suggestion for enhancement #47574345 – claude31 Jan 26 '19 at 14:51
  • The underline being stuck to the text makes it pretty hard to read underlined figures. It seems editors like TextEdit handle it on their own way with 1 more pixel spacing. – claude31 Jan 26 '19 at 15:00
2

Ferran Maylinch's answer worked the best for me.

I turned it into an UILabel extension that I thought I'd share.

extension UILabel {

func addUnbrokenUnderline() {
    let line = UIView()
    line.translatesAutoresizingMaskIntoConstraints = false
    line.backgroundColor = textColor
    addSubview(line)
    NSLayoutConstraint.activate([
        line.heightAnchor.constraint(equalToConstant: 1.0),
        line.leadingAnchor.constraint(equalTo: leadingAnchor),
        line.widthAnchor.constraint(equalToConstant: intrinsicContentSize.width),
        line.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -5.0)
    ])
}

}