I have a class called "rectangle" to make custom UILabels. I override "draw" in the rectangle class. When I instantiate the label, I want the FIRST line of text to show up in bolded font. I know how to solve this by manually getting the range for each string... however, I have more than 300 strings to do. The strings are currently in an array, formatted like so: "Happy \n Birthday". How can I make the word "Happy" bold?
var messageText = "Happy \n Birthday"
let rectanglePath = UIBezierPath(rect: rectangleRect)
context.saveGState()
UIColor.white.setFill()
rectanglePath.fill()
context.restoreGState()
darkPurple.setStroke()
rectanglePath.lineWidth = 0.5
rectanglePath.lineCapStyle = .square
rectanglePath.lineJoinStyle = .round
rectanglePath.stroke()
let rectangleStyle = NSMutableParagraphStyle()
rectangleStyle.alignment = .center
let rectangleFontAttributes = [
.font: UIFont.myCustomFont(true),
.foregroundColor: UIColor.black,
.paragraphStyle: rectangleStyle,
] as [NSAttributedString.Key: Any]
let rectangleTextHeight: CGFloat = messageText.boundingRect(with: CGSize(width: rectangleRect.width, height: CGFloat.infinity), options: .usesLineFragmentOrigin, attributes: rectangleFontAttributes, context: nil).height
context.saveGState()
context.clip(to: rectangleRect)
messageText.draw(in: CGRect(x: rectangleRect.minX, y: rectangleRect.minY + (rectangleRect.height - rectangleTextHeight) / 2, width: rectangleRect.width, height: rectangleTextHeight), withAttributes: rectangleFontAttributes)
context.restoreGState()
You can find the first by separating the string by newline:
let firstLine = "Happy \n Birthday".split(separator: "\n").first
This will give you the first line of the string. (long text multi lining doesn't count) then you can find the range using this and apply the bold effect.
How this works:
You need to set the label the way that accepts multiline:
Find the range of first line
Convert it to nsRange
Apply attributes to the range
Here is a fully working example:
import UIKit
import PlaygroundSupport
extension StringProtocol where Index == String.Index {
func nsRange(from range: Range<Index>) -> NSRange {
return NSRange(range, in: self)
}
}
class MyViewController : UIViewController {
override func loadView() {
let view = UIView()
view.backgroundColor = .white
let label = UILabel()
label.numberOfLines = 0
label.text = "Happy \n Birthday"
label.textColor = .black
let text = "Happy \n Birthday"
let attributedString = NSMutableAttributedString(string: text)
let firstLine = text.split(separator: "\n").first!
let range = text.range(of: firstLine)!
attributedString.addAttributes([.font : UIFont.boldSystemFont(ofSize: 14)], range: text.nsRange(from: range))
label.attributedText = attributedString
label.sizeToFit()
view.addSubview(label)
self.view = view
}
}
PlaygroundPage.current.liveView = MyViewController()
Related
I have a table view, inside the table view I have multiple cells as shows in attached image.
I want to show amenities items like : air conditioning, swimming pool, gym, tv cable etc like bullets.
How can I achieve this
I have all these value in my model coming from API response.
let ac = "Air Conditioning"
let tv = "TV Cable"
let washing = "Washing Machine"
let connectivity = "Wi-Fi"
let gym = "Gym"
Good case for stack views...
Vertical UIStackView
Each "row" is a Horizontal UIStackView with Distribution: Fill Equally
Looks like this - showing Bounds Rectangles:
without the Bounds Rectangles:
view hierarchy:
Edit
A second approach (which you may find easier)...
Use a single horizontal stack view with a single label on each "side" and use .attributedText.
Set the .attributedText of each label to a formatted, bulleted attributed string.
The cell prototype could look like this:
Then, use this code (slightly modified from https://stackoverflow.com/a/46889728/6257435) to generate a formatted, bulleted attributed string:
func add(bulletList strings: [String],
font: UIFont,
indentation: CGFloat = 15,
lineSpacing: CGFloat = 3,
paragraphSpacing: CGFloat = 10,
textColor: UIColor = .black,
bulletColor: UIColor = .red) -> NSAttributedString {
func createParagraphAttirbute() -> NSParagraphStyle {
var paragraphStyle: NSMutableParagraphStyle
let nonOptions = NSDictionary() as! [NSTextTab.OptionKey: Any]
paragraphStyle = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle
paragraphStyle.tabStops = [
NSTextTab(textAlignment: .left, location: indentation, options: nonOptions)]
paragraphStyle.defaultTabInterval = indentation
paragraphStyle.firstLineHeadIndent = 0
paragraphStyle.lineSpacing = lineSpacing
paragraphStyle.paragraphSpacing = paragraphSpacing
paragraphStyle.headIndent = indentation
return paragraphStyle
}
let bulletPoint = "\u{2022}"
let textAttributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: textColor]
let bulletAttributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: bulletColor]
let buffer = NSMutableAttributedString.init()
for string in strings {
var formattedString = "\(bulletPoint)\t\(string)"
// don't add newLine if it's the last bullet
if string != strings.last {
formattedString += "\n"
}
let attributedString = NSMutableAttributedString(string: formattedString)
let paragraphStyle = createParagraphAttirbute()
attributedString.addAttributes(
[NSAttributedString.Key.paragraphStyle : paragraphStyle],
range: NSMakeRange(0, attributedString.length))
attributedString.addAttributes(
textAttributes,
range: NSMakeRange(0, attributedString.length))
let string:NSString = NSString(string: formattedString)
let rangeForBullet:NSRange = string.range(of: bulletPoint)
attributedString.addAttributes(bulletAttributes, range: rangeForBullet)
buffer.append(attributedString)
}
return buffer
}
A quick implementation of that using your image as sample data results in this:
I want to highlight or add a background only on a text on a label that is not center-aligned.
I already tried Attributed Strings (https://stackoverflow.com/a/38069772/676822) and using regex but didn't get near a good solution.
NSAttributedString won't work because my label is not centered and it doesn't contain line breaks. It's just a long text that takes multiple lines.
This is what I'm trying to accomplish:
Note: It's not "Evangelizing\nDesign\nThinking" it's "Evangelizing Design Thinking"
Thanks!
As far as I have tried its not possible to get what you want simply with attributed text because using:
let attributedText = NSMutableAttributedString(string: "Evangelizing Desing Thinking",
attributes: [
.font: UIFont.systemFont(ofSize: 14),
.backgroundColor: UIColor.gray
]
)
Will add extray gray background at the end of each line. My previous answer was not good neither because it only adds a gray background on each word, not on spaces, and as #Alladinian noticed, ranges can be wrong in some cases.
So here is a hack you can use to achieve what you want. It uses multiple labels but it can be easily improved by putting labels in a custom view. So, in your viewDidLoad / CustomView function add:
// Maximum desired width for your text
let maxLabelWidth: CGFloat = 80
// Font you used
let font = UIFont.systemFont(ofSize: 14)
// Your text
let text = "Eva ngel izing Des ing a Thin king"
// Width of a space character
let spaceWidth = NSString(string: " ").size(withAttributes: [NSAttributedString.Key.font: font]).width
// Width of a row
var currentRowWidth: CGFloat = 0
// Content of a row
var currentRow = ""
// Previous label added (we keep it to add constraint betweeen labels)
var prevLabel: UILabel?
let subStrings = text.split(separator: " ")
for subString in subStrings {
let currentWord = String(subString)
let nsCurrentWord = NSString(string: currentWord)
// Width of the new word
let currentWordWidth = nsCurrentWord.size(withAttributes: [NSAttributedString.Key.font: font]).width
// Width of the row if you add a new word
let currentWidth = currentRow.count == 0 ? currentWordWidth : currentWordWidth + spaceWidth + currentRowWidth
if currentWidth <= maxLabelWidth { // The word can be added in the current row
currentRowWidth = currentWidth
currentRow += currentRow.count == 0 ? currentWord : " " + currentWord
} else { // Its not possible to add a new word in the current row, we create a label with the current row content
prevLabel = generateLabel(with: currentRow,
font: font,
prevLabel: prevLabel)
currentRowWidth = currentWordWidth
currentRow = currentWord
}
}
// Dont forget to add the last row
generateLabel(with: currentRow,
font: font,
prevLabel: prevLabel)
Then you have to create the generateLabel function:
#discardableResult func generateLabel(with text: String,
font: UIFont,
prevLabel: UILabel?) -> UILabel {
let leftPadding: CGFloat = 50 // Left padding of the label
let topPadding: CGFloat = 100 // Top padding of (first) label
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(label)
label.leftAnchor.constraint(equalTo: self.view.leftAnchor, constant: leftPadding).isActive = true
if let prevLabel = prevLabel {
label.topAnchor.constraint(equalTo: prevLabel.bottomAnchor).isActive = true
} else {
label.topAnchor.constraint(equalTo: self.view.topAnchor, constant: topPadding).isActive = true
}
label.font = font
label.text = text
label.backgroundColor = .gray
return label
}
Previous answer:
As Yogesh suggested, you can use attributed string:
// Init label
let label = UILabel(frame: CGRect(x: 50, y: 50, width: 90, height: 120))
self.view.addSubview(label)
label.lineBreakMode = .byTruncatingTail
label.numberOfLines = 0
label.backgroundColor = .white
// Create attributed text
let text = "Evangelizing Desing Thinking"
let attributedText = NSMutableAttributedString(string: text,
attributes: [
.font: UIFont.systemFont(ofSize: 14)
]
)
// Find ranges of each word
let subStrings = text.split(separator: " ")
let ranges = subStrings.map { (subString) -> Range<String.Index> in
guard let range = text.range(of: subString) else {
fatalError("something wrong with substring") // This case should not happen
}
return range
}
// Apply background color for each word
ranges.forEach { (range) in
let nsRange = NSRange(range, in: text)
attributedText.addAttribute(.backgroundColor, value: UIColor.gray, range: nsRange)
}
// Finally set attributed text
label.attributedText = attributedText
I am using a kerning attribute on a UILabel to display its text with some custom letter spacing. Unfortunately, as I'm displaying user-generated strings, I sometimes see things like the following:
ie sometimes some emoji characters are not being displayed.
If I comment out the kerning but apply some paragraph style instead, I get the same kind of errored rendering.
I couldn't find anything in the documentation explicitely rejecting support for special unicode characters. Am I doing something wrong or is it an iOS bug?
The code to reproduce the bug is available as a playground here: https://github.com/Bootstragram/Playgrounds/tree/master/LabelWithEmoji.playground
and copied here:
//: A UIKit based Playground for presenting user interface
import UIKit
import PlaygroundSupport
extension NSAttributedString {
static func kernedSpacedText(_ text: String,
letterSpacing: CGFloat = 0.0,
lineHeight: CGFloat? = nil) -> NSAttributedString {
// TODO add the font attribute
let attributedString = NSMutableAttributedString(string: text)
attributedString.addAttribute(NSAttributedStringKey.kern,
value: letterSpacing,
range: NSRange(location: 0, length: text.count))
if let lineHeight = lineHeight {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = lineHeight
attributedString.addAttribute(NSAttributedStringKey.paragraphStyle,
value: paragraphStyle,
range: NSRange(location: 0, length: text.count))
}
return attributedString
}
}
//for familyName in UIFont.familyNames {
// for fontName in UIFont.fontNames(forFamilyName: familyName) {
// print(fontName)
// }
//}
class MyViewController : UIViewController {
override func loadView() {
let view = UIView()
view.backgroundColor = .white
let myString = "1β½πΊπ»βΎοΈπ―πββοΈπ\n2 ππΏπΈ π»"
let label = UILabel()
label.frame = CGRect(x: 150, y: 200, width: 200, height: 100)
label.attributedText = NSAttributedString.kernedSpacedText(myString)
label.numberOfLines = 0
label.textColor = .black
view.addSubview(label)
self.view = view
}
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()
Thanks.
TL, DR:
String.count != NSString.length. Any time you see NSRange, you must convert your String into UTF-16:
static func kernedSpacedText(_ text: String,
letterSpacing: CGFloat = 0.0,
lineHeight: CGFloat? = nil) -> NSAttributedString {
// TODO add the font attribute
let attributedString = NSMutableAttributedString(string: text)
attributedString.addAttribute(NSAttributedStringKey.kern,
value: letterSpacing,
range: NSRange(location: 0, length: text.utf16.count))
if let lineHeight = lineHeight {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = lineHeight
attributedString.addAttribute(NSAttributedStringKey.paragraphStyle,
value: paragraphStyle,
range: NSRange(location: 0, length: text.utf16.count))
}
return attributedString
}
The longer explanation
Yours is a common problem converting between Swift's String and ObjC's NSString. The length of a String is the number of extended grapheme clusters; in ObjC, it's the number of UTF-16 code points needed to encode that string.
Take the thumb-up character for example:
let str = "π"
let nsStr = str as NSString
print(str.count) // 1
print(nsStr.length) // 2
Things can get even weirder when it comes to the flag emojis:
let str = "πΊπΈ"
let nsStr = str as NSString
print(str.count) // 1
print(nsStr.length) // 4
Even though this article was written all the way back in 2003, it's still a good read today:
The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets.
I'm trying to add each line in a UILabel to an array, but the code I'm using doesn't appear to be terribly accurate.
func getLinesArrayOfStringInLabel(label:UILabel) -> [String] {
guard let text: NSString = label.text as? NSString else { return [] }
let font:UIFont = label.font
let rect:CGRect = label.frame
let myFont: CTFont = CTFontCreateWithName(font.fontName as CFString, font.pointSize, nil)
let attStr:NSMutableAttributedString = NSMutableAttributedString(string: text as String)
attStr.addAttribute(NSAttributedStringKey.font, value:myFont, range: NSMakeRange(0, attStr.length))
let frameSetter:CTFramesetter = CTFramesetterCreateWithAttributedString(attStr as CFAttributedString)
let path: CGMutablePath = CGMutablePath()
path.addRect(CGRect(x: 0, y: 0, width: rect.size.width, height: 100000))
let frame:CTFrame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path, nil)
let lines = CTFrameGetLines(frame) as NSArray
var linesArray = [String]()
for line in lines {
let lineRange = CTLineGetStringRange(line as! CTLine)
let range:NSRange = NSMakeRange(lineRange.location, lineRange.length)
let lineString = text.substring(with: range)
linesArray.append(lineString as String)
}
return linesArray
}
let label = UILabel()
label.numberOfLines = 0
label.frame = CGRect(x: 40, y: 237, width: 265, height: 53)
label.font = UIFont.systemFont(ofSize: 22, weight: UIFont.Weight.regular)
label.text = "Hey there how's it going today?"
label.backgroundColor = .red
bg.addSubview(label)
print(getLinesArrayOfStringInLabel(label: label))
This prints
["Hey there how\'s it going ", "today?"]
But the label looks like this:
I expected to get ["Hey there how\'s it ", "going today?"]. What's going on?
So it appears to be something with UILabel and not something wrong with the function you are using. It was my suspicion that a CATextLayer would render the lines how they are returned from that method and I found out sadly :( that I am right.
Here is a picture of my results:
The red is the exact code you used to create your UILabel.
The green is a CATextLayer with all of the same characteristics of the UILabel from above including font, fontsize, and frame size.
The yellow is a subclassed UIView that is replacing its own layer and returning a CATextLayer. I am attaching it below. You can continue to build it out to meet your needs but I think this is the real solution and the only one that will have the get lines matching the visible lines the user sees. If you come up with a better solution please let me know.
import UIKit
class AGLabel: UIView {
var alignment : String = kCAAlignmentLeft{
didSet{
configureText()
}
}
var font : UIFont = UIFont.systemFont(ofSize: 16){
didSet{
configureText()
}
}
var fontSize : CGFloat = 16.0{
didSet{
configureText()
}
}
var textColor : UIColor = UIColor.black{
didSet{
configureText()
}
}
var text : String = ""{
didSet{
configureText()
}
}
override class var layerClass: AnyClass {
get {
return CATextLayer.self
}
}
func configureText(){
if let textLayer = self.layer as? CATextLayer{
textLayer.foregroundColor = textColor.cgColor
textLayer.font = font
textLayer.fontSize = fontSize
textLayer.string = text
textLayer.contentsScale = UIScreen.main.scale
textLayer.contentsGravity = kCAGravityCenter
textLayer.isWrapped = true
}
}
}
You should also check out Core-Text-Label on GitHub. It renders exactly as the CATextLayers do and would match the return of the get lines. It won't work for my particular needs as I need mine to be resizable and it crashes but if resizing is not need then I would check it out.
Finally I am back again and it appears that it could be a problem of word wrap that was started in iOS 11 where they do not leave an orphan word on a line.
I'd like to create a method to calculate the optimal width of a multi-line label to attach several labels in a horizontal row of a fixed height.
With one line of text there is no problem:
let textAttributes: [String : Any] = [NSFontAttributeName: UIFont.preferredFont(forTextStyle: UIFontTextStyle.title2)]
let maximalWidth: CGFloat = text!.boundingRect(
with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: height),
options: [NSStringDrawingOptions.usesLineFragmentOrigin],
attributes: textAttributes,
context: nil).size.width
As far as I understood, there is no option to indicate here, that I have several lines. This method works well in other direction when we calculate the height of the text with the fixed width. But I have the opposite goal.
As a variant, I can create a label based on the longest word (to be more precise, based on the widest word, as we can have several words with the same characters count, but different rendered width):
var sizeToReturn = CGSize()
let maxWordsCharacterCount = text?.maxWord.characters.count
let allLongWords: [String] = text!.wordList.filter {$0.characters.count == maxWordsCharacterCount}
var sizes: [CGFloat] = []
allLongWords.forEach {sizes.append($0.size(attributes: attributes).width)}
let minimalWidth = (sizes.max()! + constantElementsWidth)
I used here two String extensions to create words list and find all longest:
extension String {
var wordList: [String] {
return Array(Set(components(separatedBy: .punctuationCharacters).joined(separator: "").components(separatedBy: " "))).filter {$0.characters.count > 0}
}
}
extension String {
var maxWord: String {
if let max = self.wordList.max(by: {$1.characters.count > $0.characters.count}) {
return max
} else {return ""}
}
}
Not a bad option, but it looks ugly if we have the text that can't be fitted in three lines and that has several short words and one long word at the end. This long word, determined the width, will be just truncated. And more of that it looks not too good with 3 short words like:
Sell
the
car
Well, I have the minimum width, I have the maximum width. Perhaps, I can
go from maximum to minimum and catch when the label starts being truncated.
So I feel that there can be an elegant solution, but I'm stuck.
Hooray, I've found one of the possible solutions. You can use the code below in the playground:
import UIKit
import PlaygroundSupport
//: Just a view to launch playground timeline preview
let hostView = UIView(frame: CGRect(x: 0, y: 0, width: 320, height: 480))
hostView.backgroundColor = .lightGray
PlaygroundPage.current.liveView = hostView
// MARK: - Extensions
extension String {
var wordList: [String] {
return Array(Set(components(separatedBy: .punctuationCharacters).joined(separator: "").components(separatedBy: " "))).filter {$0.characters.count > 0}
}
}
extension String {
var longestWord: String {
if let max = self.wordList.max(by: {$1.characters.count > $0.characters.count}) {
return max
} else {return ""}
}
}
// MARK: - Mathod
func createLabelWithOptimalLabelWidth (
requestedHeight: CGFloat,
constantElementsWidth: CGFloat,
acceptableWidthForTextOfOneLine: CGFloat, //When we don't want the text to be shrinked
text: String,
attributes: [String:Any]
) -> UILabel {
let label = UILabel(frame: .zero)
label.attributedText = NSAttributedString(string: text, attributes: attributes)
let maximalLabelWidth = label.intrinsicContentSize.width
if maximalLabelWidth < acceptableWidthForTextOfOneLine {
label.frame = CGRect(origin: CGPoint.zero, size: CGSize(width: maximalLabelWidth, height: requestedHeight))
return label // We can go with this width
}
// Minimal width, calculated based on the longest word
let maxWordsCharacterCount = label.text!.longestWord.characters.count
let allLongWords: [String] = label.text!.wordList.filter {$0.characters.count == maxWordsCharacterCount}
var sizes: [CGFloat] = []
allLongWords.forEach {sizes.append($0.size(attributes: attributes).width)}
let minimalWidth = (sizes.max()! + constantElementsWidth)
// Height calculation
var flexibleWidth = maximalLabelWidth
var flexibleHeight = CGFloat()
var optimalWidth = CGFloat()
var optimalHeight = CGFloat()
while (flexibleHeight <= requestedHeight && flexibleWidth >= minimalWidth) {
optimalWidth = flexibleWidth
optimalHeight = flexibleHeight
flexibleWidth -= 1
flexibleHeight = label.attributedText!.boundingRect(
with: CGSize(width: flexibleWidth, height: CGFloat.greatestFiniteMagnitude),
options: [NSStringDrawingOptions.usesLineFragmentOrigin],
context: nil).size.height
print("Width: \(flexibleWidth)")
print("Height: \(flexibleHeight)")
print("_______________________")
}
print("Final Width: \(optimalWidth)")
print("Final Height: \(optimalHeight)")
label.frame = CGRect(origin: CGPoint.zero, size: CGSize(width: optimalWidth+constantElementsWidth, height: requestedHeight))
return label
}
// MARK: - Inputs
let text: String? = "Determine the fair price"//nil//"Select the appropriate payment method"//"Finalize the order" //"Sell the car"//"Check the payment method"
let font = UIFont.preferredFont(forTextStyle: UIFontTextStyle.callout)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineBreakMode = .byWordWrapping
paragraphStyle.allowsDefaultTighteningForTruncation = true
let attributes: [String:Any] = [
NSFontAttributeName: font,
NSParagraphStyleAttributeName: paragraphStyle,
NSBaselineOffsetAttributeName: 0
]
if text != nil {
let label = createLabelWithOptimalLabelWidth(requestedHeight: 70, constantElementsWidth: 0, acceptableWidthForTextOfOneLine: 120, text: text!, attributes: attributes)
label.frame.width
label.frame.height
label.backgroundColor = .white
label.lineBreakMode = .byWordWrapping
label.numberOfLines = 3
hostView.addSubview(label)
}