Specifying NSLineBreakMode.byTruncatingTail causes issues when displaying NSAttributedString in UILabel - ios

In my app, I fetch some HTML strings from the Wordpress REST API of a website. I use the following extension to convert the HTML to an NSAttributedString that I can display in my UILabel:
extension NSAttributedString {
convenience init(htmlString html: String, font: UIFont? = nil, useDocumentFontSize: Bool = false) throws {
let options: [NSAttributedString.DocumentReadingOptionKey : Any] = [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
]
let data = html.data(using: .utf8, allowLossyConversion: true)
guard (data != nil), let fontFamily = font?.familyName, let attr = try? NSMutableAttributedString(data: data!, options: options, documentAttributes: nil) else {
try self.init(data: data ?? Data(html.utf8), options: options, documentAttributes: nil)
return
}
let fontSize: CGFloat? = useDocumentFontSize ? nil : font!.pointSize
let range = NSRange(location: 0, length: attr.length)
attr.enumerateAttribute(.font, in: range, options: .longestEffectiveRangeNotRequired) { attrib, range, _ in
if let htmlFont = attrib as? UIFont {
let traits = htmlFont.fontDescriptor.symbolicTraits
var descrip = htmlFont.fontDescriptor.withFamily(fontFamily)
if (traits.rawValue & UIFontDescriptor.SymbolicTraits.traitBold.rawValue) != 0 {
descrip = descrip.withSymbolicTraits(.traitBold)!
}
if (traits.rawValue & UIFontDescriptor.SymbolicTraits.traitItalic.rawValue) != 0 {
descrip = descrip.withSymbolicTraits(.traitItalic)!
}
attr.addAttribute(.font, value: UIFont(descriptor: descrip, size: fontSize ?? htmlFont.pointSize), range: range)
attr.addAttribute(NSAttributedString.Key.foregroundColor, value: UIColor.label, range: range)
}
}
self.init(attributedString: attr)
}
}
The UILabel is defined as follows:
let myLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = UIFont.systemFont(ofSize: 15, weight: .regular)
return label
}()
I use the following code to display the NSAttributedString in the UILabel:
let contentAttributed = try? NSAttributedString(htmlString: html, font: UIFont.systemFont(ofSize: 15, weight: .regular))
myLabel.attributedText = contentAttributed
myLabel.numberOfLines = 3
With this, everything works as expected, as shown in two examples below:
Example 1 (everything ok)
Example 2 (everything ok)
I would really like the UILabels to be completely filled and with ellipsis at the end (truncated lines). If I add myLabel.lineBreakMode = .byTruncatingTail into my code, there are weird issues, as shown in the same examples below. The weird thing is that some of the UILabels still work absolutely fine (example 2), while others show the weird issues (example 1).
Example 1 (issues)
Example 2 (still everything ok)
If anyone knows where in this rather complicated issue I should start digging, any help would be greatly appreciated.
Edit: Some examples of HTML strings that produce the issue:
\n<p>Liebe Damen & Herren</p>\n\n\n\n<p>Erneut trifft es uns und wir müssen die Sammlung vom Samstag <strong>bis auf Weiteres verschieben!</strong>
\n<p>\n\nLiebe Kollegen</p>\n\n\n\n<p>Am 28. August führen wir (endlich) wieder die Papiersammlung in der Gemeinde durch. Dabei sind wir auf möglichst viele Helfer:innen angewiesen.</p>
\n<p>​Liebe Kollegen</p>\n\n\n\n<p>Nur ungerne stören wir eure Sommerferien mit einer unerfreulichen Nachricht: <strong>Das Katre 2021 vom 4. und 5. September wurde leider abgesagt!</strong></p>
The following HTML strings do not produce the issue:
\n<p>Kürzlich hat der Bundesrat über die in Etappen stattfindende Lockerung der Massnahmen zum Schutz vor dem Coronavirus informiert. Leider haben die ersten Lockerungen des Bundes noch keinen Einfluss auf uns.</p>
<p>Liebe Eltern und Kollegen</p>\n<p>Unser Jahresprogramm für 2019 ist nun fertig und online. Ihr findet es unter Medien -> Downloads oder direkt hier:</p>\n<p>Jahresprogramm_2019</p>
The App is run on the iPhone 13 Pro Simulator in portrait orientation. The UILabel is part of a custom UITableViewCell. The UITableView is aligned to the safe area on the left and right (constant 0). The UILabel is aligned to the contentView.leadingAnchor and contentView.trailingAnchor with a constant of 30 and -30, respectively.

I'm posting this answer, not because it is a solution but because I think it is worth discussion...
Edit
Personally, I would say Attributed Text and .lineBreakMode = .byTruncatingTail is rather buggy.
This does not appear to be related to Attributed Text, but rather to the presence of a newline character.
For example, if the label is set to .numberOfLines = 3 and we use this text:
myLabel.text = "Let's test this:\nPlenty of text to wrap onto more than three lines. Note this is plain text, not attributed text. It may or may not exhibit the same problems."
we will see the same corrupted text wrapping occur.
Also note: this does NOT happen on iOS prior to 15.4!
/Edit
In all the following examples, the same attributed string is used for pairs of rows, but only the cyan labels have myLabel.lineBreakMode = .byTruncatingTail.
First note:
If we set myLabel.numberOfLines = 0, we get this output:
Which doesn't make much sense... Apparently, a newline character gets factored in at the end of the attributed string -- but even so, we get almost, but not enough space for TWO additional lines (I'm guessing it's a newline and a paragraph vertical space?):
Second note:
If we DO NOT have enough text to exceed the bounds of the label (again, we're setting myLabel.numberOfLines = 3 here), we get another oddity. This is when I rotate the device:
Even though the text is NOT truncated, we get the ... -- because UIKit is trying to add 1.5 additional blank lines at the end of the string.
Third note:
As I mentioned in a comment, I know we came across this once before here on SO -- unfortunately, I can't remember if we resolved it or (probably) not.
To reproduce with Plain Text
class WrapBugVC: 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.widthAnchor.constraint(equalToConstant: 315.0),
stackView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
])
var vInfo: UILabel
let testStr1: String = "Let's test this (no newline character): Plenty of text to wrap onto more than three lines. Note this is plain text, not attributed text."
let testStr2: String = "Let's test this:\nPlenty of text to wrap onto more than three lines. Note this is plain text, not attributed text."
vInfo = UILabel()
vInfo.font = .italicSystemFont(ofSize: 15.0)
vInfo.text = "NO newline, .byWordWrapping"
stackView.addArrangedSubview(vInfo)
let v1 = UILabel()
v1.numberOfLines = 3
v1.text = testStr1
v1.backgroundColor = .yellow
v1.lineBreakMode = .byWordWrapping
stackView.addArrangedSubview(v1)
vInfo = UILabel()
vInfo.font = .italicSystemFont(ofSize: 15.0)
vInfo.text = "NO newline, .byTruncatingTail"
stackView.addArrangedSubview(vInfo)
let v2 = UILabel()
v2.numberOfLines = 3
v2.text = testStr1
v2.backgroundColor = .cyan
v2.lineBreakMode = .byTruncatingTail
stackView.addArrangedSubview(v2)
vInfo = UILabel()
vInfo.font = .italicSystemFont(ofSize: 15.0)
vInfo.text = "WITH newline, .byWordWrapping"
stackView.addArrangedSubview(vInfo)
let v3 = UILabel()
v3.numberOfLines = 3
v3.text = testStr2
v3.backgroundColor = .yellow
v3.lineBreakMode = .byWordWrapping
stackView.addArrangedSubview(v3)
vInfo = UILabel()
vInfo.font = .italicSystemFont(ofSize: 15.0)
vInfo.text = "WITH newline, .byTruncatingTail"
stackView.addArrangedSubview(vInfo)
let v4 = UILabel()
v4.numberOfLines = 3
v4.text = testStr2
v4.backgroundColor = .cyan
v4.lineBreakMode = .byTruncatingTail
stackView.addArrangedSubview(v4)
[v1, v2, v3, v4].forEach { v in
stackView.setCustomSpacing(16.0, after: v)
}
}
}
Result:
Workaround
You may want to try this as a workaround...
class TextViewLabel: UITextView {
public var numberOfLines: Int = 0 {
didSet {
textContainer.maximumNumberOfLines = numberOfLines
}
}
public var lineBreakMode: NSLineBreakMode = .byTruncatingTail {
didSet {
textContainer.lineBreakMode = lineBreakMode
}
}
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
private func commonInit() -> Void {
isScrollEnabled = false
isEditable = false
isSelectable = false
textContainerInset = UIEdgeInsets.zero
textContainer.lineFragmentPadding = 0
}
}
Using TextViewLabel in place of UILabel will avoid the bug:
Edit 2
As of iOS 16.2 there are still inconsistencies with word-wrapping and tail-truncation between UILabel and UITextView.
Here is some code to see them side-by-side:
// a replacement for UILabel
class TextViewLabel: UITextView {
public var numberOfLines: Int = 0 {
didSet {
textContainer.maximumNumberOfLines = numberOfLines
}
}
public var lineBreakMode: NSLineBreakMode = .byTruncatingTail {
didSet {
textContainer.lineBreakMode = lineBreakMode
}
}
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
private func commonInit() -> Void {
isScrollEnabled = false
isEditable = false
isSelectable = false
textContainerInset = UIEdgeInsets.zero
textContainer.lineFragmentPadding = 0
}
}
class ViewController: UIViewController {
let labelStackView = UIStackView()
let textViewStackView = UIStackView()
override func viewDidLoad() {
super.viewDidLoad()
let scrollView = UIScrollView()
let hStack = UIStackView()
hStack.axis = .horizontal
hStack.spacing = 8
hStack.distribution = .fillEqually
hStack.alignment = .top
[labelStackView, textViewStackView].forEach { v in
v.axis = .vertical
v.spacing = 4
hStack.addArrangedSubview(v)
}
hStack.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(hStack)
scrollView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(scrollView)
let g = view.safeAreaLayoutGuide
let cg = scrollView.contentLayoutGuide
let fg = scrollView.frameLayoutGuide
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
hStack.topAnchor.constraint(equalTo: cg.topAnchor, constant: 8.0),
hStack.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 8.0),
hStack.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: -8.0),
hStack.bottomAnchor.constraint(equalTo: cg.bottomAnchor, constant: -8.0),
hStack.widthAnchor.constraint(equalTo: fg.widthAnchor, constant: -16.0),
])
let infoFont: UIFont = .italicSystemFont(ofSize: 10.0)
let testFont: UIFont = .systemFont(ofSize: 12.0)
var testStr: String = "This is some example text to test truncation consistency. At the end of this line, is a pair of newline chars:\n\nA HEADLINE\n\nThe headline was also followed by a pair of newline chars."
for i in 3...10 {
let infoLabel = UILabel()
infoLabel.font = infoFont
infoLabel.text = "UILabel - numLines: \(i)"
labelStackView.addArrangedSubview(infoLabel)
let v = UILabel()
v.font = testFont
v.numberOfLines = i
v.text = testStr
v.backgroundColor = .yellow
v.lineBreakMode = .byTruncatingTail
labelStackView.addArrangedSubview(v)
labelStackView.setCustomSpacing(16.0, after: v)
}
for i in 3...10 {
let infoLabel = UILabel()
infoLabel.font = infoFont
infoLabel.text = "TextViewLabel - numLines: \(i)"
textViewStackView.addArrangedSubview(infoLabel)
let v = TextViewLabel()
v.font = testFont
v.numberOfLines = i
v.text = testStr
v.backgroundColor = .cyan
v.lineBreakMode = .byTruncatingTail
textViewStackView.addArrangedSubview(v)
textViewStackView.setCustomSpacing(16.0, after: v)
}
scrollView.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
}
}
Differences also exist from one iOS version to another - for example:

It looks like the strings that work only have one paragraph of text, whereas the ones that do not have multiple. Apple's documentation implies that lineBreakMode applies to the label's whole attributedText, as long as it's set after the attributedText is assigned a value. That doesn't explain this behavior though, and the fact that parts of the string are being repeated makes me think this is a bug on how multi-paragraph NSAttributedStrings interact with a label's lineBreakMode.
NSMutableParagraphStyle has a property called lineBreakMode, which will allow you to set the line break mode for each paragraph individually. That might give you a few different options for workarounds.
Alternatively - setting the numberOfLines to 0 also fixes the problem for me, though it's not ideal since it might require you to set a max height for the label, which could worsen the user experience with dynamic font sizes.
Viel Glück!

Related

Center text vertically in line of NSMutableAttributedString

I'm using NSMutableAttributedString to setup text style. When I set .minimumLineHeight, it centre text in line to the bottom of line. I would like to customise somehow this alignment to centre text in line vertically.
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.minimumLineHeight = fontLineHeight // 24 for example
attributedText.addAttribute(.paragraphStyle, value: paragraphStyle, range: fullRange)
I want to get such result:
The problem with providing a lineHeight is that you override the default height-calculation-behaviour with a hardcoded value. Even if you've provided the font's line height as the value, it can vary dynamically based on provided content and it's best to leave this to auto layout (Refer https://stackoverflow.com/a/33278748/9293498)
Auto-layout has a solution for these issues most of the time. All you need is a proper constraint setup for your label (and lineSpacing in your scenario) which can enable it to scale automatically based on provided text with just-enough space required. Both NSAttributedString and String values should work
Here's a code sample I've written trying to simulate your requirement:
Views:
private let termsAndConditionsContainer: UIStackView = {
let container = UIStackView()
container.backgroundColor = .clear
container.spacing = 16
container.axis = .vertical
container.alignment = .leading
container.distribution = .fill
container.translatesAutoresizingMaskIntoConstraints = false
return container
}()
private let dataAgreementButton: UIButton = {
let button = UIButton(type: .custom)
button.setImage(UIImage(), for: .normal)
button.layer.borderColor = UIColor.gray.cgColor
button.layer.borderWidth = 0.5
button.layer.cornerRadius = 16
return button
}()
private let dataAgreementLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 17, weight: .medium)
label.numberOfLines = 0
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private let tosAgreementButton: UIButton = {
let button = UIButton(type: .custom)
button.setImage(UIImage(), for: .normal)
button.layer.borderColor = UIColor.gray.cgColor
button.layer.borderWidth = 0.5
button.layer.cornerRadius = 16
return button
}()
private let tosAgreementLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 17, weight: .medium)
label.numberOfLines = 0
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
Functions:
private func attributedString(with text: String) -> NSAttributedString {
//Leave it to auto-layout to determine the line height
let attributedString = NSMutableAttributedString(string: text)
let style = NSMutableParagraphStyle()
style.lineSpacing = 8 // I've just declared the spacing between lines
attributedString.addAttribute(.paragraphStyle, value: style, range: NSRange(location: 0, length: attributedString.length))
return attributedString
}
private func generateRowContainer() -> UIStackView {
let container = UIStackView()
container.backgroundColor = .clear
container.spacing = 16
container.axis = .horizontal
container.alignment = .center
container.distribution = .fill
container.layer.borderWidth = 0.5
container.layer.borderColor = UIColor.green.cgColor
container.translatesAutoresizingMaskIntoConstraints = false
return container
}
And here's my constraint setup as I add the above views in viewDidLoad:
override func viewDidLoad() {
super.viewDidLoad()
let dataAgreementContainer = generateRowContainer()
dataAgreementContainer.addArrangedSubview(dataAgreementButton)
dataAgreementLabel.attributedText = attributedString(with: "I agree with the Information note regarding personal data processing")
dataAgreementContainer.addArrangedSubview(dataAgreementLabel)
termsAndConditionsContainer.addArrangedSubview(dataAgreementContainer)
let tosAgreementContainer = generateRowContainer()
tosAgreementContainer.addArrangedSubview(tosAgreementButton)
tosAgreementLabel.attributedText = attributedString(with: "I agree with terms and conditions")
tosAgreementContainer.addArrangedSubview(tosAgreementLabel)
termsAndConditionsContainer.addArrangedSubview(tosAgreementContainer)
view.addSubview(termsAndConditionsContainer)
NSLayoutConstraint.activate([
dataAgreementButton.widthAnchor.constraint(equalToConstant: 32),
dataAgreementButton.heightAnchor.constraint(equalToConstant: 32),
tosAgreementButton.widthAnchor.constraint(equalToConstant: 32),
tosAgreementButton.heightAnchor.constraint(equalToConstant: 32),
termsAndConditionsContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
termsAndConditionsContainer.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -32),
termsAndConditionsContainer.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
I got the below output:
In the above example, I've used a horizontal stack view to maintain the alignment of text and radio button, which seems efficient to me for your requirement. But the point is, as long as your label is in a properly constrained environment, you don't have the need to manually specify any form of its height.

Swift: UIView not resizing to fit content

I am building an app in which I have a list of Reviews. They look like the following screenshot
For some reason, I am failing to make the UIView (Gray box from the top light gray line to the bottom one) resize correctly. The white text inside it (Actual review) is longer than 1 line and should not get cut off, only when reaching a maximum of say 5 lines. The thing is, it works when I don't set width and height constraints for the user image you see on the left side. Removing those will make the view resize correctly, but it will completely distort the image. The image top and botton anchors seem to be glued to the anchors in its horizontal stackview, which again are stuck to the UIView's top and botton anchors with constants, as it should. But nowhere do I say that the UIView should always have the size of the image. I don't get why it wont go bigger than the image.
Here is a screenshot of my structure with the constraints, hope it is clear enough:
NSLayoutConstraint.activate([
//Main horizontal stackview (one Rating is the name of the UIView)
hStack.leadingAnchor.constraint(equalTo: oneRating.leadingAnchor, constant: 23),
hStack.trailingAnchor.constraint(equalTo: oneRating.trailingAnchor, constant: -18),
hStack.topAnchor.constraint(equalTo: oneRating.topAnchor, constant: 15),
hStack.bottomAnchor.constraint(equalTo: oneRating.bottomAnchor, constant: -13),
reviewerImage.heightAnchor.constraint(equalToConstant: 80),
reviewerImage.widthAnchor.constraint(equalToConstant: 80),
//Limit the size of the Review Text to make sure its always at the same spot
v2Stack.widthAnchor.constraint(equalToConstant: 220.0),
])
//Verified checkmark constraints
if isUserVerified == true {
reviewerVerified.bottomAnchor.constraint(equalTo: reviewerImage.bottomAnchor).isActive = true
reviewerVerified.trailingAnchor.constraint(equalTo: reviewerImage.trailingAnchor, constant: -2).isActive = true
}
I know it is hard to help like this but I have tried to fix this for a few days and no matter what I do, I can't get it to work.
EDIT:
As per request, here is the code I have to add the ImageView to my UIView().
//Add Image
let reviewerImage = UIImageView()
reviewerImage.contentMode = .scaleAspectFill
reviewerImage.layer.cornerRadius = 40 //= 1/2 of width, because we hard coded the size
reviewerImage.image = UIImage(named: "person-icon") //Placeholder. Download image here
reviewerImage.translatesAutoresizingMaskIntoConstraints = false
VStack1.addArrangedSubview(reviewerImage)
You will need to add a few more sizing constraints, but...
The "trick" is to embed your "reviewer image view" in a clear "container" view. Then constrain the image view to the Top of that container.
Here is some sample code that gets close to your layout:
class JanView: UIView {
let reviewerImageView: UIImageView = {
let v = UIImageView()
return v
}()
let starImageView: UIImageView = {
let v = UIImageView()
return v
}()
let chevronImageView: UIImageView = {
let v = UIImageView()
return v
}()
let nameLabel: UILabel = {
let v = UILabel()
return v
}()
let locLabel: UILabel = {
let v = UILabel()
return v
}()
let reviewTextLabel: UILabel = {
let v = UILabel()
v.numberOfLines = 5
return v
}()
let publishedLabel: UILabel = {
let v = UILabel()
return v
}()
let starValueLabel: UILabel = {
let v = UILabel()
v.textAlignment = .center
return v
}()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
backgroundColor = .darkGray
let outerHStack: UIStackView = {
let v = UIStackView()
v.spacing = 10
return v
}()
let labelsVStack: UIStackView = {
let v = UIStackView()
v.axis = .vertical
v.spacing = 0
return v
}()
let starsAndChevronVStack: UIStackView = {
let v = UIStackView()
v.axis = .vertical
v.spacing = 0
return v
}()
let starsHStack: UIStackView = {
let v = UIStackView()
v.spacing = 0
v.alignment = .center
return v
}()
// review image container
let reviewerImageContainer: UIView = {
let v = UIView()
return v
}()
[nameLabel, reviewTextLabel].forEach { v in
v.textColor = .white
}
[locLabel, publishedLabel].forEach { v in
v.textColor = .lightGray
}
starValueLabel.textColor = .systemYellow
outerHStack.translatesAutoresizingMaskIntoConstraints = false
addSubview(outerHStack)
[nameLabel, locLabel, reviewTextLabel, publishedLabel].forEach { v in
labelsVStack.addArrangedSubview(v)
}
[starValueLabel, starImageView].forEach { v in
starsHStack.addArrangedSubview(v)
}
[starsHStack, chevronImageView].forEach { v in
starsAndChevronVStack.addArrangedSubview(v)
}
[reviewerImageContainer, labelsVStack, starsAndChevronVStack].forEach { v in
outerHStack.addArrangedSubview(v)
}
// add reviewer image view to container
reviewerImageContainer.addSubview(reviewerImageView)
reviewerImageView.translatesAutoresizingMaskIntoConstraints = false
// specific properties
reviewerImageView.contentMode = .scaleAspectFill
reviewerImageView.layer.cornerRadius = 40
reviewerImageView.layer.masksToBounds = true
let cfg = UIImage.SymbolConfiguration(pointSize: 12.0, weight: .bold)
if let img = UIImage(systemName: "star.fill", withConfiguration: cfg) {
starImageView.image = img
}
starImageView.tintColor = .systemYellow
starImageView.contentMode = .center
starValueLabel.text = "4"
if let img = UIImage(systemName: "chevron.right", withConfiguration: cfg) {
chevronImageView.image = img
}
chevronImageView.tintColor = .lightGray
chevronImageView.contentMode = .center
nameLabel.text = "Name Here"
locLabel.text = "Location Here"
reviewTextLabel.text = "Review Text Here"
publishedLabel.text = "Published Info Here"
let g = self
// to get the 2nd vertical stack view to fit (horizontally) to its content
let sacWidth = starsAndChevronVStack.widthAnchor.constraint(equalToConstant: 10.0)
sacWidth.priority = .defaultHigh
let vPadding: CGFloat = 12
let hPadding: CGFloat = 10
NSLayoutConstraint.activate([
outerHStack.topAnchor.constraint(equalTo: g.topAnchor, constant: vPadding),
outerHStack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: hPadding),
outerHStack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -hPadding),
outerHStack.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -vPadding),
reviewerImageView.widthAnchor.constraint(equalToConstant: 80.0),
reviewerImageView.heightAnchor.constraint(equalTo: reviewerImageView.widthAnchor),
// align the reviewer image view with the top of the container view
reviewerImageView.topAnchor.constraint(equalTo: reviewerImageContainer.topAnchor),
reviewerImageView.leadingAnchor.constraint(equalTo: reviewerImageContainer.leadingAnchor),
reviewerImageView.trailingAnchor.constraint(equalTo: reviewerImageContainer.trailingAnchor),
// give the stars value label a width, so it doesn't vary by text
// "5" is wider than "1" (or it may be "" ?)
starValueLabel.widthAnchor.constraint(equalToConstant: 16.0),
// make the star image view square
starImageView.widthAnchor.constraint(equalTo: starImageView.heightAnchor),
// make the stars HStack height equal to the stars label height
starsHStack.heightAnchor.constraint(equalTo: starValueLabel.heightAnchor),
sacWidth,
])
}
}
and an example controller:
class ReviewVC: UIViewController {
let scrollView: UIScrollView = {
let v = UIScrollView()
return v
}()
let reviewsStack: UIStackView = {
let v = UIStackView()
v.axis = .vertical
v.spacing = 0
return v
}()
override func viewDidLoad() {
super.viewDidLoad()
[reviewsStack, scrollView].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
}
scrollView.addSubview(reviewsStack)
view.addSubview(scrollView)
let g = view.safeAreaLayoutGuide
let contentG = scrollView.contentLayoutGuide
let frameG = scrollView.frameLayoutGuide
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
reviewsStack.topAnchor.constraint(equalTo: contentG.topAnchor, constant: 0.0),
reviewsStack.leadingAnchor.constraint(equalTo: contentG.leadingAnchor, constant: 0.0),
reviewsStack.trailingAnchor.constraint(equalTo: contentG.trailingAnchor, constant: 0.0),
reviewsStack.bottomAnchor.constraint(equalTo: contentG.bottomAnchor, constant: 0.0),
reviewsStack.widthAnchor.constraint(equalTo: frameG.widthAnchor, constant: 0.0),
])
let sampleLocs: [String] = [
"Koblenz, Germany",
"Westerwald, Germany",
"Bonn, Germany",
"Saarbrüken, Germany",
]
let sampleRevs: [String] = [
"For some reason, I am failing to make the UIView (Gray box from the top light gray line to the bottom one) resize correctly.",
"A Single Line",
"The white text inside it (Actual review) is longer than 1 line and should not get cut off, only when reaching a maximum of say 5 lines.",
"The thing is, it works when I don't set width and height constraints for the user image you see on the left side. Removing those will make the view resize correctly, but it will completely distort the image.",
"Another Single Line",
"The image top and botton anchors seem to be glued to the anchors in its horizontal stackview, which again are stuck to the UIView's top and botton anchors with constants, as it should.",
]
let sampleStars: [String] = [
"5", "4", "3", "2", "1",
]
for i in 0..<sampleRevs.count {
let v = JanView()
v.nameLabel.text = "Clara R."
v.locLabel.text = sampleLocs[i % sampleLocs.count]
v.reviewTextLabel.text = sampleRevs[i]
v.publishedLabel.text = "Published less than 24h ago"
v.starValueLabel.text = sampleStars[i % sampleStars.count]
if let img = UIImage(named: "prof") {
v.reviewerImageView.image = img
}
reviewsStack.addArrangedSubview(v)
let sepView = UIView()
sepView.backgroundColor = .lightGray
sepView.heightAnchor.constraint(equalToConstant: 1.0).isActive = true
reviewsStack.addArrangedSubview(sepView)
}
}
}
Here is how it ends up looking:

How can I compare characters in Swift? I have UILabel with text and UITextField

So I have a UILabel with text which I have to write in UITextField for check are the characters of UILabel and UITextField are similar?
So I have some practice in C-Lang and in C-lang to solve this we have to write Integer indices to compare the indices in "UILabel" from "UITextField".
So we have a characters and if characters in label and textfield are the same we just increment an index and have to compare the next index(character).
I need to compare each character. For example, we have a string "Hello!". And if we write "Heklo?", our characters "H", "e", "l", "o" must be green and our "k" and "?" must be red.
In swift I tried to do the same but it doesnt work.
import UIKit
class ViewController: UIViewController {
private let textLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.lineBreakMode = .byWordWrapping
label.translatesAutoresizingMaskIntoConstraints = false
label.text = "There are a text, which you must text to get the last character of this label!"
label.sizeToFit()
return label
}()
private let resultTextLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.lineBreakMode = .byWordWrapping
label.translatesAutoresizingMaskIntoConstraints = false
label.sizeToFit()
return label
}()
private let textField : UITextField = {
let text = UITextField(frame: CGRect(x: 20, y: 300, width: 300, height: 40))
text.placeholder = "Enter text Here!"
text.font = .systemFont(ofSize: 15)
text.autocorrectionType = .no
text.keyboardType = .default
text.returnKeyType = .done
text.clearButtonMode = .whileEditing
text.addTarget(self, action: #selector(checkWrongOrRight), for: .editingChanged)
return text
}()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(textLabel)
view.addSubview(textField)
view.addSubview(resultTextLabel)
setUpConstraints()
}
#objc func checkWrongOrRight(_ textField: UITextField) {
// ??????????????? I thought that a logic of app could be here
if textLabel.text == resultTextLabel.text {
textLabel.textColor = .green
} else {
textLabel.textColor = .red
}
}
func setUpConstraints() {
textLabel.leftAnchor.constraint(equalTo: self.view.leftAnchor, constant: 20).isActive = true
textLabel.rightAnchor.constraint(equalTo: self.view.rightAnchor, constant: -20).isActive = true
textLabel.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 100).isActive = true
resultTextLabel.leftAnchor.constraint(equalTo: self.view.leftAnchor, constant: 20).isActive = true
resultTextLabel.rightAnchor.constraint(equalTo: self.view.rightAnchor, constant: -20).isActive = true
resultTextLabel.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 500).isActive = true
}
}
So how can I compare characters in UILabel and in UITextField?
Why do you compare textLabel.text and resultTextLabel.text?
Change it like this.
#objc func checkWrongOrRight(_ textField: UITextField) {
if textLabel.text == textField.text {
textLabel.textColor = .green
} else {
textLabel.textColor = .red
}
}
I am not sure what role resultTextLabel is in the code.
One way to do this is to get the length of the typed text and compare that to the substring of the expected text of the same length:
#objc func checkWrongOrRight(_ textField: UITextField) {
guard let text = textLabel.text,
let input = textField.text
else {
textLabel.textColor = .red
return
}
if input == text.prefix(input.count) {
textLabel.textColor = .green
} else {
textLabel.textColor = .red
}
}
Edit
Since the actual question is about colouring individual characters depending on whether they match or not:
You can use Attributed strings for this, getting the ranges of characters that in incorrect and applying the red colour as an attribute to these ranges:
Define this helper function in your view controller
// helper function that returns an attributed string to display
func displayString(input: String?, expected: String?) -> NSAttributedString {
guard let input = input,
let expected = expected
else {
return NSAttributedString(string: "")
}
// Get an array of NSRange objects of incorrect characters
let errorRanges = zip((0...), zip(input, expected).map(==)).filter { !$1 }.map { NSRange(location: $0.0, length: 1) }
// Set the default colour
let attString = NSMutableAttributedString(string: input, attributes: [.foregroundColor: UIColor.green])
// Set the colour for incorrect characters to red
errorRanges.forEach { range in
attString.addAttribute(.foregroundColor, value: UIColor.red, range: range)
}
return attString
}
And you can use it as
#objc func checkWrongOrRight(_ textField: UITextField) {
textField.attributedText = displayString(input: textField.text, expected: textLabel.text)
}
This colours the incorrect characters.
There are optimisations that can be made - such as converting the current 1 character length ranges to a single range where consecutive characters are incorrect.

iOS -How to To Set Anchors to Adjust Spacing for Line Break with Self Sizing CollectionView Cells

When adjusting a label's text for self seizing collectionView cells I use the following function in sizeForItem:
func estimatedLabelHeight(text: String, width: CGFloat, font: UIFont) -> CGFloat {
let size = CGSize(width: width, height: 1000)
let options = NSStringDrawingOptions.usesFontLeading.union(.usesLineFragmentOrigin)
let attributes = [NSAttributedStringKey.font: font]
let rectangleHeight = String(text).boundingRect(with: size, options: options, attributes: attributes, context: nil).height
return rectangleHeight
}
The function works fine and my cells expand accordingly.
What has happened though is there are several line breaks inside some of the text from the pricesLabel that get fed into the function. The line breaks are too "tight" so I followed this answer when creating my label to expand the spacing in between the line breaks.
let attributedString = NSMutableAttributedString(string: pricesText)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 7 // if I set this at 2 I have no problems
attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSMakeRange(0, attributedString.length))
The problem is the text from pricesLabel inside the cell expands to far downwards. Using paragraphStyle.lineSpacing = 7 creates the space I want but it causes problems. If I set this to paragraphStyle.lineSpacing = 2 have no problems but the spacing is to tight.
As you can see in the picture the cell sizes the way it's supposed to but the the line break spacing in between the $8.00 and $12.00 makes the text expand to far and the text of $20.00 from the computedTotalLabel gets obscured.
I called sizeToFit() in layoutSubViews() but it made no difference:
override func layoutSubviews() {
super.layoutSubviews()
pricesLabel.sizeToFit()
computedTotalLabel.sizeToFit()
}
How can I make the pricesLabel text adjusted with the line breaks size itself accordingly
class MyCell: UICollectionViewCell {
let pricesLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textAlignment = .right
label.sizeToFit()
label.font = UIFont.systemFont(ofSize: 15.5)
label.adjustsFontSizeToFitWidth = true
label.minimumScaleFactor = 0.5
label.numberOfLines = 0
label.sizeToFit()
return label
}()
let computedTotalLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textAlignment = .right
label.textColor = .black
label.sizeToFit()
label.font = UIFont.boldSystemFont(ofSize: 15.5)
label.adjustsFontSizeToFitWidth = true
label.minimumScaleFactor = 0.5
label.numberOfLines = 1
label.sizeToFit()
return label
}()
let staticTotalLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = "Total"
label.textAlignment = .left
label.textColor = .black
label.font = UIFont.boldSystemFont(ofSize: 15.5)
label.adjustsFontSizeToFitWidth = true
label.minimumScaleFactor = 0.5
label.numberOfLines = 1
label.sizeToFit()
return label
}()
let separatorLine: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .lightGray
return view
}()
override func layoutSubviews() {
super.layoutSubviews()
pricesLabel.sizeToFit()
computedTotalLabel.sizeToFit()
}
var myObject: MyObject? {
didSet {
// text is "$8.00\n$12.00\n"
let pricesText = myObject?.myText ?? "error"
let attributedString = NSMutableAttributedString(string: pricesText, attributes: [NSAttributedStringKey.font: UIFont.systemFont(ofSize: 15.5)])
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 7
attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSMakeRange(0, attributedString.length))
pricesLabel.attributedText = attributedString
computedTotalLabel.text = functionThatTalliesUpAllThePrices(pricesText)
configureAnchors()
}
}
func configureAnchors() {
addSubview(pricesLabel)
addSubview(totalLabel)
addSubview(staticTotalLabel) // this is the label on the left side of the pic that says Total:
addSubview(separatorLine)
pricesLabel.topAnchor.constraint(equalTo: self.topAnchor, constant: 12).isActive = true
pricesLabel.rightAnchor.constraint(equalTo: self.rightAnchor, constant: -10).isActive = true
staticTotalLabel.lastBaselineAnchor.constraint(equalTo: totalLabel.lastBaselineAnchor).isActive = true
staticTotalLabel.leftAnchor.constraint(equalTo: self.leftAnchor, constant: 10).isActive = true
staticTotalLabel.rightAnchor.constraint(equalTo: totalLabel.leftAnchor, constant: -10).isActive = true
computedTotalLabel.topAnchor.constraint(equalTo: pricesLabel.bottomAnchor, constant: 0).isActive = true
computedTotalLabel.rightAnchor.constraint(equalTo: self.rightAnchor, constant: -10).isActive = true
separatorLine.topAnchor.constraint(equalTo: computedTotalLabel.bottomAnchor, constant: 12).isActive = true
separatorLine.leftAnchor.constraint(equalTo: self.leftAnchor, constant: 10).isActive = true
separatorLine.rightAnchor.constraint(equalTo: self.rightAnchor, constant: -10).isActive = true
separatorLine.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive = true
separatorLine.heightAnchor.constraint(equalToConstant: 1).isActive = true
}
}
This is the sizeForItem inside the collectionView cell. Not sure if this makes a difference to the problem so I added it anyway
class MyClass: UIViewController {
let tableData = [MyObect]()
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let myObect = tableData[indexPath.item]
// text is "$8.00\n$12.00\n"
let pricesText = myObject?.myText ?? "error"
let width = collectionView.frame.width
let pricesLabelHeight = estimatedLabelHeight(text: pricesText, width: width, font: UIFont.systemFont(ofSize: 15.5))
let total = functionThatTalliesUpAllThePrices(pricesText)
let totalLabelHeight = estimatedLabelHeight(text: functionThatAddsUp, width: width, font: UIFont.boldSystemFont(ofSize: 15.5))
// the 12 + 0 + 12 + 1 are the constant sizes I use inside the cell's configureAnchors functions
let cellHeight = 12 + pricesLabelHeight + 0 + totalLabelHeight + 12 + 1
return CGSize(width: width, height: ceil(cellHeight))
}
}
1st. I had to place the same estimatedLabelHeight(text: String, width: CGFloat, font: UIFont) inside the collectionView cell itself.
2nd. Inside the configureAnchors functions at the bottom of it I call pricesLabel.sizeToFit() and pricesLabel.layoutSubviews() and then I call the above function from step 1 to get the height of the pricesLabel from it's text.
3rd. I set the pricesLabel.heightAnchor.constraint(equalToConstant:) to the the height returned from step 2.
class MyCell: UICollectionViewCell {
// step 1. place this function inside the collectionView cell
func estimatedLabelHeight(text: String, width: CGFloat, font: UIFont) -> CGFloat {
let size = CGSize(width: width, height: 1000)
let options = NSStringDrawingOptions.usesFontLeading.union([.usesLineFragmentOrigin, .usesFontLeading])
let attributes = [NSAttributedStringKey.font: font]
let rectangleHeight = String(text).boundingRect(with: size, options: options, attributes: attributes, context: nil).height
return rectangleHeight
}
func configureAnchors() {
// all the other anchors are here
pricesLabel.sizeToFit()
computedTotalLabel.sizeToFit()
computedTotalLabel.layoutIfNeeded()
pricesLabel.layoutIfNeeded()
let pricesLabelText = pricesLabel.text ?? "error"
let width = self.frame.width
// step 2.
let pricesLabelHeight = estimatedLabelHeight(text: pricesLabelText, width: width, font: UIFont.systemFont(ofSize: 15.5))
// step 3.
pricesLabel.heightAnchor.constraint(equalToConstant: pricesLabelHeight).isActive = true
}
}

Label top alignment

I'm trying to put the temperature in my app. I would like to show it like that:
But all I'm able to get is that:
I have tried to use this code to align the two label on top:
#IBDesignable class TopAlignedLabel: UILabel {
override func drawText(in rect: CGRect) {
if let stringText = text {
let stringTextAsNSString = stringText as NSString
let labelStringSize = stringTextAsNSString.boundingRect(with: CGSize(width: self.frame.width,height: CGFloat.greatestFiniteMagnitude), options: NSStringDrawingOptions.usesLineFragmentOrigin, attributes: [NSFontAttributeName: font], context: nil).size
super.drawText(in: CGRect(x:0,y: 0,width: self.frame.width, height:ceil(labelStringSize.height)))
} else {
super.drawText(in: rect)
}
}
}
It worked on for the '°C' but it's not working for the 25. Can someone help me find a solution to my problem ?
A very simple way to solve this is with attributed strings:
let tempText = "25˚C"
let baseFont = UIFont.systemFont(ofSize: 23.0)!
let superscriptFont = UIFont.systemFont(ofSize: 15.0)!
let attrStr = NSMutableAttributedString(string: tempText, attributes: [NSFontAttributeName: baseFont])
attrStr.addAttributes([NSFontAttributeName: superscriptFont, NSBaselineOffsetAttributeName: 10.0], range: NSMakeRange(2,2))
myLabel.attributedText = attrStr
You can keep adding more different attributes on any range you want by using the addAttributes method.
A font has multiple characteristics, including Ascender, Descender, CapHeight, etc... So what your code gets is not a way to align the character glyphs flush with the top of the label frame, but rather it aligns the Font bounding box to the top of the frame.
Calculating the offset between font metrics might give you what you're after. Here is one example (you can run it in a Playground page):
import UIKit
import PlaygroundSupport
class TestViewController : UIViewController {
let labelOne: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 60.0)
label.text = "25"
label.backgroundColor = .cyan
label.translatesAutoresizingMaskIntoConstraints = false
label.numberOfLines = 1
label.textAlignment = .right
return label
}()
let labelTwo: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 24.0)
label.text = "°C"
label.backgroundColor = .cyan
label.translatesAutoresizingMaskIntoConstraints = false
label.numberOfLines = 1
return label
}()
override func viewDidLoad() {
super.viewDidLoad()
// add the scroll view to self.view
self.view.addSubview(labelOne)
self.view.addSubview(labelTwo)
// constrain the scroll view to 8-pts on each side
labelOne.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 8.0).isActive = true
labelOne.topAnchor.constraint(equalTo: view.topAnchor, constant: 8.0).isActive = true
labelTwo.leftAnchor.constraint(equalTo: labelOne.rightAnchor, constant: 2.0).isActive = true
if let f1 = labelOne.font, let f2 = labelTwo.font {
let offset = (f1.ascender - f1.capHeight) - (f2.ascender - f2.capHeight)
labelTwo.topAnchor.constraint(equalTo: labelOne.topAnchor, constant: offset).isActive = true
}
}
}
let vc = TestViewController()
vc.view.backgroundColor = .yellow
PlaygroundPage.current.liveView = vc
And this is the result:
Depending on what font you are actually using, though, that may not be good enough. If so, you'll want to look into CoreText / CTFont / CTFontGetGlyphsForCharacters() / etc.

Resources