0

The default behavior for UILabel is that it prevents orphan words to appear solely on a separate line. ie: if word wrapping happen to keep 1 word alone at the last line. iOS will prevent that by sending a word from the line before it, having two words in the last line.

The problem is that this feature doesn't work by default with NSMutableAttributedString. how can I enable it?

Sample:

var string = customField?.title ?? ""
    
if customField?.required == true {
    string += " *"
} else {
    string += " (\(getLocalizedString(localizedKey: .optional)))"
}
            
let style = NSMutableParagraphStyle()
if #available(iOS 14.0, *) {
    style.lineBreakStrategy = .standard
}

let att = NSMutableAttributedString(string: string, attributes: [.paragraphStyle: style])
    
titleLabel.attributedText = att

Have in mind I am forced to use NSMutableAttributedString for other reasons. 2 labels won't work for me.

enter image description here

hasan
  • 23,815
  • 10
  • 63
  • 101
  • let me try. but doesn't that prevent wrapping? – hasan Jul 01 '22 at 18:41
  • 1
    Sorry, bad suggestion. Answer incoming. – Itai Ferber Jul 01 '22 at 18:41
  • I updated question. it still not working even without setting different attributes for the * – hasan Jul 01 '22 at 18:42
  • One option is to use a non-editable non-scrollable `UITextView` instead of a `UILabel` ... however, it's not clear (to me) where you are getting an orphan from word wrapping? – DonMag Jul 01 '22 at 19:22
  • @hasan -- oh, are you saying you need your text to end with "lastword *" and the asterisk (red in attributed string) is wrapping onto a line by itself? and you need that last word to wrap with it? – DonMag Jul 02 '22 at 13:35
  • Ya I need the asterisk not to be alone by itself on a separate line. It turned out the problem wasn't with attributedtext. it still work. but the problem is when u have a constraint (trailing for example) to another label that layout at runtime because have dynamic text. the orphan words feature doesn't work. therefore the asterisk some times will be alone in a line. I ended up using \u{00A0} instead of space before the *. which is uni code for unbreakable space. ie the * and the word before it will be treated as 1 word. can't be broken to two lines. – hasan Jul 02 '22 at 13:57
  • 1
    @hasan - yep... using non-break-space character is the solution I was going to give you :) – DonMag Jul 02 '22 at 14:02
  • @DonMag Ty anyway. you can add it as solution. I can accept it. but add my explanation to the problem. – hasan Jul 02 '22 at 14:03

3 Answers3

2

As per OP's comments...

The issue is not with Attributed Text, as the same thing happens with "normal" text.

With iOS 11 (may have been 10), Apple changed UIKit to prevent orphans when a UILabel wraps to two lines of text. Orphans are still allowed with more than two lines:

enter image description here

A was prior to iOS 11... B is current... C is current with more than two lines...

Note the D example -- I don't have the Xcode beta installed, but based on other comments I've seen it appears that in iOS 16 the "no orphan" rule will also be applied when the text wraps to more than two lines.

So... a way to solve your issue is to use a "non-break-space" character between the last word and the asterisk (instead of a plain space).

Here's a quick test:

class WrapTestVC: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.spacing = 4
        stackView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(stackView)

        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.widthAnchor.constraint(equalToConstant: 320.0),
        ])
        
        var noteLabel: UILabel!
        var testLabel: UILabel!
    
        let noteFont: UIFont = .systemFont(ofSize: 14.0)
        
        noteLabel = UILabel()
        noteLabel.font = noteFont
        noteLabel.numberOfLines = 0
        noteLabel.text = "Just enough to fit:"
    
        stackView.addArrangedSubview(noteLabel)
        
        testLabel = UILabel()
        testLabel.backgroundColor = .yellow
        testLabel.numberOfLines = 0
        testLabel.attributedText = sampleAttrString(method: 0)

        stackView.addArrangedSubview(testLabel)
        
        stackView.setCustomSpacing(20.0, after: testLabel)
        
        noteLabel = UILabel()
        noteLabel.font = noteFont
        noteLabel.numberOfLines = 0
        noteLabel.text = "Using a space char:"
        
        stackView.addArrangedSubview(noteLabel)
        
        testLabel = UILabel()
        testLabel.backgroundColor = .yellow
        testLabel.numberOfLines = 0
        testLabel.attributedText = sampleAttrString(method: 1)
        
        stackView.addArrangedSubview(testLabel)
        
        stackView.setCustomSpacing(20.0, after: testLabel)
        
        noteLabel = UILabel()
        noteLabel.font = noteFont
        noteLabel.numberOfLines = 0
        noteLabel.text = "Using a non-break-space char:"
        
        stackView.addArrangedSubview(noteLabel)
        
        testLabel = UILabel()
        testLabel.backgroundColor = .yellow
        testLabel.numberOfLines = 0
        testLabel.attributedText = sampleAttrString(method: 2)
        
        stackView.addArrangedSubview(testLabel)

        stackView.setCustomSpacing(20.0, after: testLabel)
        
        noteLabel = UILabel()
        noteLabel.font = noteFont
        noteLabel.numberOfLines = 0
        noteLabel.text = "Although, iOS 16 may give:"
        
        stackView.addArrangedSubview(noteLabel)
        
        testLabel = UILabel()
        testLabel.backgroundColor = .yellow
        testLabel.numberOfLines = 0
        testLabel.attributedText = sampleAttrString(method: 3)
        
        stackView.addArrangedSubview(testLabel)
        
        stackView.setCustomSpacing(20.0, after: testLabel)
        

    }

    func sampleAttrString(method: Int) -> NSMutableAttributedString {
        let fontA: UIFont = .systemFont(ofSize: 20.0, weight: .bold)
        
        let attsA: [NSAttributedString.Key : Any] = [
            .font: fontA,
            .foregroundColor: UIColor.blue,
        ]
        
        let attsB: [NSAttributedString.Key : Any] = [
            .font: fontA,
            .foregroundColor: UIColor.red,
        ]
        
        var partOne = NSMutableAttributedString(string: "If the label has enough text so it wraps to more than two lines, UIKit will allow a last word orphan.", attributes: attsA)
        
        var partTwo: NSAttributedString = NSAttributedString()
        
        switch method {
        case 0:
            ()
        case 1:
            partTwo = NSAttributedString(string: " *", attributes: attsB)
        case 2:
            partTwo = NSAttributedString(string: "\u{a0}*", attributes: attsB)
        case 3:
            partOne = NSMutableAttributedString(string: "If the label has enough text so it wraps to more than two lines, UIKit will allow a last\nword orphan.", attributes: attsA)
            partTwo = NSAttributedString(string: "\u{a0}*", attributes: attsB)
        default:
            ()
        }
        
        partOne.append(partTwo)
        
        return partOne
    }

}

Output:

enter image description here

So... you'll want to test that with iOS 16, and, if that's the case, you may need to do a version check to determine wether to add a plain space or a non-break-space.

DonMag
  • 69,424
  • 5
  • 50
  • 86
  • Wow, great answer. ty. I appreciate an upvote to my question too :) How old are you by the way? – hasan Jul 02 '22 at 14:46
1

It is a change by Apple since iOS11 (as answered by @DonMag) to prevent orphaned word in the last line.

If your production only support iOS13.0+, setting the lineBreakStrategy will set it back to the old style.

let label = UILabel()
if #available(iOS 14.0, *) {
    label.lineBreakStrategy = NSParagraphStyle.LineBreakStrategy()
}

(One interesting thing is, I found this lineBreakStrategy also work on iOS 13.0+, even tho from Apple's document it mentioned iOS 14.0+.)

If you need to support older iOS version, you need to set the value of the NSAllowsDefaultLineBreakStrategy key when application launch, which I cannot find any document about it. I tested it worked on iOS 11 & 12, but not on iOS 13.0+.

// Setting the undocumented key NSAllowsDefaultLineBreakStrategy
UserDefaults.standard.set(false, forKey: "NSAllowsDefaultLineBreakStrategy")

So you might need both if your app support iOS 11.0+. Hope it helps ;)

paky
  • 595
  • 1
  • 5
  • 18
0

From the documentation of the lineBreakStrategy property on UILabel, which helps control this behavior:

When the label has an attributed string value, the system ignores the textColor, font, textAlignment, lineBreakMode, and lineBreakStrategy properties. Set the NSForegroundColorAttributeName, NSFontAttributeName, alignment, lineBreakMode, and lineBreakStrategy properties in the attributed string instead.

If you want to use a specific line break strategy, like .standard ("The text system uses the same configuration of line-break strategies that it uses for standard UI labels. "), you will need to apply the attribute to the attributed string via a paragraph style:

let style = NSMutableParagraphStyle()
style.lineBreakStrategy = .standard

let text = NSMutableAttributedString(
    string: "long title with an asterisk at the end *",
    attributes: [.paragraphStyle: style]
)

titleLabel.attributedText = text

Depending on your text, it may also help to set allowsDefaultTighteningForTruncation on the paragraph style because that may allow the text system to tighten the space between words on the last line of the string to get everything to fit. (I say may because this property controls truncation specifically, but it's possible that the text system can take it into account for wrapping as well.)

Itai Ferber
  • 28,308
  • 5
  • 77
  • 83
  • Wow, let me try that ty. – hasan Jul 01 '22 at 18:46
  • No, it didn't work. and actually this documentation is wrong I guess. The system doesn't ignore those things at all when u have attributed string. I always only set the attributes for the part of the string and keep the rest take the style from the UILabel it self ! – hasan Jul 01 '22 at 18:52
  • @hasan Can you share the full code you've used to test this out? Specifically, depending on how you apply attributes to parts of the string, you may be overwriting the attributes that would allow this to work. (For example, if you use `setAttributes:` instead of `addAttributes:` for part of the range of the string.) – Itai Ferber Jul 01 '22 at 18:54
  • ok wait 10 seconds – hasan Jul 01 '22 at 18:54
  • @hasan I suspect that it's also possible that the text system doesn't consider `"*"` to be a word (because it contains no letters), so it treats the text differently from regular orphan words. If you apply this text as a plain string to the label, does it manage to avoid wrapping the last asterisk character then? – Itai Ferber Jul 01 '22 at 18:56
  • let me try it then with label.text – hasan Jul 01 '22 at 18:57
  • @hasan Depending on what you discover with just `label.text`: what if you append `"*"` instead of `" *"` so the text joins the last word itself? – Itai Ferber Jul 01 '22 at 18:58
  • That could be a solution if nothing worked. but maybe product manager will not like it. – hasan Jul 01 '22 at 18:59