iOS: Draw border around text UILabel - ios

I want to draw an outline around the text of UILabel like this:
I tried using attributed text but there is no attribute for rendering border around text. Only underline is available. I also tried rendering html using attributed text but that didn't help either:
let htmlLabelText = String(format: "<html><body><span style='color:blue; border: 1.5px solid #55DF49; border-radius: 50px;'>%#</span></body></html>", labelText)
var attributedString = NSMutableAttributedString()
guard let stringData = data(using: .unicode) else { return NSMutableAttributedString() }
do {
attributedString = try NSMutableAttributedString(
data: stringData,
options: [NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType],
documentAttributes: nil
)
} catch {}
I checked other post but none of them helped.

The problem is quite hard to solve for UILabel, because you have no direct access to NSLayoutManager, which is key for my solution.
I have created IBDesignable UILabel subclass LineHighlightedLabel, which can do the job. The visual is not quite the same as image you provided but you can get there.
Important part is to set text to UILabel as NSAttributedString, not just plain text.
#IBDesignable
public class LineHighlightedLabel: UILabel {
public override func draw(_ rect: CGRect) {
let layoutManager = NSLayoutManager()
let textStorage = NSTextStorage.init(attributedString: attributedText!)
textStorage.addLayoutManager(layoutManager)
let textContainer = NSTextContainer.init(size: bounds.size)
textContainer.lineFragmentPadding = 0
textContainer.maximumNumberOfLines = numberOfLines
textContainer.lineBreakMode = lineBreakMode
layoutManager.addTextContainer(textContainer)
layoutManager.enumerateLineFragments(forGlyphRange: NSMakeRange(0, textStorage.length)) { (rect, usedRect, textContainer, glyphRange, bool) in
let lineRect = CGRect(x: usedRect.origin.x, y: usedRect.origin.y + 1, width: usedRect.size.width, height: usedRect.size.height - 2)
UIColor.green.setStroke()
let lineRectPath = UIBezierPath(roundedRect: lineRect, cornerRadius: 5)
lineRectPath.lineWidth = 0.5
lineRectPath.stroke()
}
super.drawText(in: rect)
}
public override func layoutSubviews() {
super.layoutSubviews()
setNeedsDisplay()
}
}
LineHighlightedLabel is providing nice preview in interface builder, you can play with values easily.

#shota's answers also works.
Here is exactly how I solved the issue by subclassing UILabel without using NSLayoutManager. I copied the code for getting lines from text from somewhere in stackoverflow, although using LayoutManager.enumerateLineFragments is a better choice. Also there is a hack required to render text of UILabel with two spaces at start and end. Here is a screenshot of end result:

Here is the answer.
label1.layer.borderColor = [UIColor greenColor].CGColor;
label1.layer.borderWidth =1.0
OR
label2.layer.borderColor = [UIColor blueColor].CGColor;
label1.layer.borderWidth =5.0
OR
label3.layer.borderWidth = 2.0;
label3.layer.cornerRadius = 8;
try these different possibilities.

Related

UITextView does not adjust size when used in SwiftUI

My ultimate goal is to display html content in SwiftUI.
For that I am using UIKit's UITextView (I can't use web view, because I need to control font and text color).
This is the entire code of the view representable:
struct HTMLTextView: UIViewRepresentable {
private var htmlString: String
private var maxWidth: CGFloat = 0
private var font: UIFont = .systemFont(ofSize: 14)
private var textColor: UIColor = .darkText
init(htmlString: String) {
self.htmlString = htmlString
}
func makeUIView(context: UIViewRepresentableContext<HTMLTextView>) -> UITextView {
let textView = UITextView()
textView.isScrollEnabled = false
textView.isEditable = false
textView.backgroundColor = .clear
update(textView: textView)
return textView
}
func updateUIView(_ textView: UITextView, context: UIViewRepresentableContext<HTMLTextView>) {
update(textView: textView)
}
func sizeToFit(width: CGFloat) -> Self {
var textView = self
textView.maxWidth = width
return textView
}
func font(_ font: UIFont) -> Self {
var textView = self
textView.font = font
return textView
}
func textColor(_ textColor: UIColor) -> Self {
var textView = self
textView.textColor = textColor
return textView
}
// MARK: - Private
private func update(textView: UITextView) {
textView.attributedText = buildAttributedString(fromHTML: htmlString)
// this is one of the options that don't work
let size = textView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
textView.frame.size = size
}
private func buildAttributedString(fromHTML htmlString: String) -> NSAttributedString {
let htmlData = Data(htmlString.utf8)
let options = [NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.html]
let attributedString = try? NSMutableAttributedString(data: htmlData, options: options, documentAttributes: nil)
let range = NSRange(location: 0, length: attributedString?.length ?? 0)
attributedString?.addAttributes([.font: font,
.foregroundColor: textColor],
range: range)
return attributedString ?? NSAttributedString(string: "")
}
}
It is called from the SwiftUI code like this:
HTMLTextView(htmlString: "some string with html tags")
.font(.systemFont(ofSize: 15))
.textColor(descriptionTextColor)
.sizeToFit(width: 200)
The idea is that the HTMLTextView would stick to the width (here 200, but in practice - the screen width) and grow vertically when the text is multiline.
The problem is whatever I do (see below), it is always displayed as a one line of text stretching outside of screen on the left and right. And it never grows vertically.
The stuff I tried:
calculating the size and setting the frame (you can see that in the code snippet)
doing the above + fixedSize() on the SwiftUI side
setting frame(width: ...) on the SwiftUI side
setting translatesAutoresizingMaskIntoConstraints to false
setting hugging priorities to required
setting ideal width on the SwiftUI side
Nothing helped. Any advice on how could I solve this will be very welcome!
P.S. I can't use SwiftUI's AttributedString, because I need to support iOS 14.
UPDATE:
I have removed all the code with maxWidth and calculating size. And added textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) when creating the textView in makeUIView(context:). This kind of solved the problem, except for this: even though the height of the text view is correct, the last line is not visible; if I rotate to landscape, it becomes visible; rotate to portrait - not visible again.
UPDATE 2:
After some trial and error I figured out that it is ScrollView to blame. HTMLTextView is inside VStack, which is inside ScrollView. When I remove scroll view, everything sizes correctly.
The problem is, I need scrolling when the content is too long.
So, in the end, I had to move calculating the size that the attributed string would take in the text view with the given font/size etc into the view model, and then set .frame(width:, height:) to those values.
Not ideal, as the pre-calculated height seems a little bit larger than the actual text's height, but could not find better solution for now.
Update (for readability):
I calculate the actual size in view model (calculateDescriptionSize(limitedToWidth maxWidth:), and then I use the result on the Swift UI view:
HTMLTextView(htmlString: viewModel.attributedDescription)
.frame(width: maxWidth, height: viewModel.calculateDescriptionSize(limitedToWidth: maxWidth).height)
where HTMLTextView is my custom view wrapping the UIKit text view.
And this is the size calculation:
func calculateDescriptionSize(limitedToWidth maxWidth: CGFloat) -> CGSize {
// source: https://stackoverflow.com/questions/54497598/nsattributedstring-boundingrect-returns-wrong-height
let textStorage = NSTextStorage(attributedString: attributedDescription)
let size = CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude)
let boundingRect = CGRect(origin: .zero, size: size)
let textContainer = NSTextContainer(size: size)
textContainer.lineFragmentPadding = 0
let layoutManager = NSLayoutManager()
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
layoutManager.glyphRange(forBoundingRect: boundingRect, in: textContainer)
let rect = layoutManager.usedRect(for: textContainer)
return rect.integral.size
}

How to get height of string with maximum lines using NSTextStorage?

I am trying to get the height of an attributed string (for any font, any language, any strange utf8 characters, etc).
I found this interesting topic at Badoo Chatto about different solutions: https://github.com/badoo/Chatto/issues/129
And the solution I'm using is theirs:
func height(width: CGFloat, attributes: [NSAttributedString.Key: Any]) -> CGFloat {
let textContainer: NSTextContainer = {
let container = NSTextContainer(size: CGSize(width: width, height: .greatestFiniteMagnitude))
container.lineFragmentPadding = 0
return container
}()
let textStorage = NSTextStorage(string: self, attributes: attributes)
let layoutManager: NSLayoutManager = {
let layoutManager = NSLayoutManager()
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
return layoutManager
}()
let rect = layoutManager.usedRect(for: textContainer)
return rect.size.round().height
}
How can I modify this logic so that it can take into consideration a maximum line number?
I tried adding container.maximumNumberOfLines = 2 but it won't change anything as NSTextContainer is set with an infinite height.
Ideally I would like to avoid using any UIView or subview as this processing has to be done in a background thread. Also, it appears that any UIKit-based solution isn't 100% reliable (cf the Badoo Chatto link).

Get each line of text in a UILabel

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.

NSLayoutManager, boundingRectForGlyphRange and emoji

I am displaying text that may contain emoji using NSAttributedString's drawInRect(rect: CGRect) method. Since I want to detect taps on the text I use the following method to see which character has been tapped:
let textStorage = NSTextStorage(attributedString: attributedString)
let layoutManager = NSLayoutManager()
layoutManager.usesFontLeading = true
textStorage.addLayoutManager(layoutManager)
let textContainer = NSTextContainer(size: containerSize)
layoutManager.addTextContainer(textContainer)
textContainer.lineFragmentPadding = 0.0
layoutManager.ensureLayoutForTextContainer(textContainer)
let tappedIndex = layoutManager.characterIndexForPoint(point,
inTextContainer: textContainer,
fractionOfDistanceBetweenInsertionPoints: nil)
This gives the correct index that I can work with until I start adding emoji to the text. As soon as emoji are added there starts to be an offset for the detection. This led me to look at the bounding rectangles of glyphs that I was looking for. I noticed that the bounding rectangles of emoji were too large. I set up the following test case to check the difference:
let emojiText = "😀"
let font = UIFont.systemFontOfSize(20.0)
let containerSize = CGSize(width: 300.0, height: 20000.0)
let attributedString = NSAttributedString(string: emojiText, attributes: [NSFontAttributeName: font])
let textStorage = NSTextStorage(attributedString: attributedString)
let layoutManager = NSLayoutManager()
layoutManager.usesFontLeading = true
textStorage.addLayoutManager(layoutManager)
let textContainer = NSTextContainer(size: containerSize)
layoutManager.addTextContainer(textContainer)
textContainer.lineFragmentPadding = 0.0
layoutManager.ensureLayoutForTextContainer(textContainer)
let glyphRect = layoutManager.boundingRectForGlyphRange(NSRange(location: 0, length: attributedString.length), inTextContainer: textContainer)
let boundingRect = attributedString.boundingRectWithSize(containerSize, options:[.UsesLineFragmentOrigin, .UsesFontLeading], context: nil)
Executing this code resulted the following CGRects:
glyphRect = (0.0, 0.0, 23.0, 28.875)
boundingRect = (0.0, 0.0, 23.0, 23.8671875)
What this means is that these two methods give two entirely different sizes! This wouldn't be a problem, but the 'offset' stacks with more lines.
Example of the stacked offset
I set a purple background for the character the characterIndexForPoint gave me, gave the rect of boundingRectForGlyphRange a green outline and the yellow dot is the actual taplocation. Note that the green rectangle lines up nicely with a different character, however, this is no indication whatsoever, since it just happens to line up nicely in this specific case.
Am I overlooking something obvious or is this an issue in iOS?
I have solved the issue. It appears that NSAttributedString.drawInRect draws differently from CoreText. I now use the following code to draw the text in drawRect:
let totalRange = layoutManager.glyphRangeForTextContainer(textContainer)
layoutManager.drawBackgroundForGlyphRange(range, atPoint: CGPointZero)
layoutManager.drawGlyphsForGlyphRange(range, atPoint: CGPointZero)

UILabel vertical alignment

In my application i am using ActiveLabelfram Github.
In that case, my label does not show the text in the middle of the UILabel. If i use a normal UILabel it works fine, but when settings it to a ActiveLabel, it gets like this.
(Image is taken in runtime)
I think this is the code to play with the alignment somehow:
/// add line break mode
private func addLineBreak(attrString: NSAttributedString) -> NSMutableAttributedString {
let mutAttrString = NSMutableAttributedString(attributedString: attrString)
var range = NSRange(location: 0, length: 0)
var attributes = mutAttrString.attributesAtIndex(0, effectiveRange: &range)
let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? NSMutableParagraphStyle ?? NSMutableParagraphStyle()
paragraphStyle.lineBreakMode = NSLineBreakMode.ByWordWrapping
if let lineSpacing = lineSpacing {
paragraphStyle.lineSpacing = CGFloat(lineSpacing)
}
attributes[NSParagraphStyleAttributeName] = paragraphStyle
mutAttrString.setAttributes(attributes, range: range)
return mutAttrString
}
ActiveLabel.swift
ActiveType.swift
Any ideas how i can make it in the middle like this:
(Image is taken from Storyboard)
In ActiveLabel.swift replace the drawTextInRect method with
public override func drawTextInRect(rect: CGRect) {
let range = NSRange(location: 0, length: textStorage.length)
textContainer.size = rect.size
let usedRect = layoutManager.usedRectForTextContainer(textContainer)
let glyphOriginY = (rect.height > usedRect.height) ? rect.origin.y + (rect.height - usedRect.height) / 2 : rect.origin.y
let glyphOrigin = CGPointMake(rect.origin.x, glyphOriginY)
layoutManager.drawBackgroundForGlyphRange(range, atPoint: glyphOrigin)
layoutManager.drawGlyphsForGlyphRange(range, atPoint: glyphOrigin)
}
I have also forked the repo under https://github.com/rishi420/ActiveLabel.swift
If you download the repo, remember to set verticalTextAlignmentCenter to true
Have a look at this post:
Programmatically Add CenterX/CenterY Constraints
Well the problem will be when u have dragged a label already from the IB and then you are trying to change its position. The code will break.
You will need to programmatically make the label and then set it to the centre.
And very seriously, #Alex is correct. AutoLayout solves a lot of problems.
You can set the textAlignment in the code like this:
showLab.textAlignment = NSTextAlignmentCenter;
Or you can also use Storyboard or xib to see what happen in the lab,StoryBoard,as you look i choose the Second of alignment what means middle in the label

Resources