I have a question regarding rounded corners and text background color for a custom UIView.
Basically, I need to achieve an effect like this (image attached - notice the rounded corners on one side) in a custom UIView:
I'm thinking the approach to use is:
Use Core Text to get glyph runs.
Check highlight range.
If the current run is within the highlight range, draw a background rectangle with rounded corners and desired fill color before drawing the glyph run.
Draw the glyph run.
However, I'm not sure whether this is the only solution (or for that matter, whether this is the most efficient solution).
Using a UIWebView is not an option, so I have to do it in a custom UIView.
My question being, is this the best approach to use, and am I on the right track? Or am I missing out something important or going about it the wrong way?
TL;DR; Create a custom-view, which renders same old NSAttributedString, but with rounded-corners.
Unlike Android's SpannableString, iOS does not support "custom-render for custom-string-attributes", at least not without an entire custom-view (at time of writing, 2022).
I managed to achieve the above effect, so thought I'd post an answer for the same.
If anyone has any suggestions about making this more effective, please feel free to contribute. I'll be sure to mark your answer as the correct one. :)
For doing this, you'll need to add a "custom attribute" to NSAttributedString.
Basically, what that means is that you can add any key-value pair, as long as it is something that you can add to an NSDictionary instance. If the system does not recognize that attribute, it does nothing. It is up to you, as the developer, to provide a custom implementation and behavior for that attribute.
For the purposes of this answer, let us assume I've added a custom attribute called: #"MyRoundedBackgroundColor" with a value of [UIColor greenColor].
For the steps that follow, you'll need to have a basic understanding of how CoreText gets stuff done. Check out Apple's Core Text Programming Guide for understanding what's a frame/line/glyph run/glyph, etc.
So, here are the steps:
Create a custom UIView subclass.
Have a property for accepting an NSAttributedString.
Create a CTFramesetter using that NSAttributedString instance.
Override the drawRect: method
Create a CTFrame instance from the CTFramesetter.
You will need to give a CGPathRef to create the CTFrame. Make that CGPath to be the same as the frame in which you wish to draw the text.
Get the current graphics context and flip the text coordinate system.
Using CTFrameGetLines(...), get all the lines in the CTFrame you just created.
Using CTFrameGetLineOrigins(...), get all the line origins for the CTFrame.
Start a for loop - for each line in the array of CTLine...
Set the text position to the start of the CTLine using CGContextSetTextPosition(...).
Using CTLineGetGlyphRuns(...) get all the Glyph Runs (CTRunRef) from the CTLine.
Start another for loop - for each glyphRun in the array of CTRun...
Get the range of the run using CTRunGetStringRange(...).
Get typographic bounds using CTRunGetTypographicBounds(...).
Get the x offset for the run using CTLineGetOffsetForStringIndex(...).
Calculate the bounding rect (let's call it runBounds) using the values returned from the aforementioned functions.
Remember - CTRunGetTypographicBounds(...) requires pointers to variables to store the "ascent" and "descent" of the text. You need to add those to get the run height.
Get the attributes for the run using CTRunGetAttributes(...).
Check if the attribute dictionary contains your attribute.
If your attribute exists, calculate the bounds of the rectangle that needs to be painted.
Core text has the line origins at the baseline. We need to draw from the lowermost point of the text to the topmost point. Thus, we need to adjust for descent.
So, subtract the descent from the bounding rect that we calculated in step 16 (runBounds).
Now that we have the runBounds, we know what area we want to paint - now we can use any of the CoreGraphis/UIBezierPath methods to draw and fill a rect with specific rounded corners.
UIBezierPath has a convenience class method called bezierPathWithRoundedRect:byRoundingCorners:cornerRadii: that let's you round specific corners. You specify the corners using bit masks in the 2nd parameter.
Now that you've filled the rect, simply draw the glyph run using CTRunDraw(...).
Celebrate victory for having created your custom attribute - drink a beer or something! :D
Regarding detecting that the attribute range extends over multiple runs, you can get the entire effective range of your custom attribute when the 1st run encounters the attribute. If you find that the length of the maximum effective range of your attribute is greater than the length of your run, you need to paint sharp corners on the right side (for a left to right script). More math will let you detect the highlight corner style for the next line as well. :)
Attached is a screenshot of the effect. The box on the top is a standard UITextView, for which I've set the attributedText. The box on the bottom is the one that has been implemented using the above steps. The same attributed string has been set for both the textViews.
Again, if there is a better approach than the one that I've used, please do let me know! :D
Hope this helps the community. :)
Cheers!
Just customize NSLayoutManager and override drawUnderline(forGlyphRange:underlineType:baselineOffset:lineFragmentRect:lineFragmentGlyphRange:containerOrigin:) Apple API Document
In this method, you can draw underline by yourself, Swift code,
override func drawUnderline(forGlyphRange glyphRange: NSRange,
underlineType underlineVal: NSUnderlineStyle,
baselineOffset: CGFloat,
lineFragmentRect lineRect: CGRect,
lineFragmentGlyphRange lineGlyphRange: NSRange,
containerOrigin: CGPoint
) {
let firstPosition = location(forGlyphAt: glyphRange.location).x
let lastPosition: CGFloat
if NSMaxRange(glyphRange) < NSMaxRange(lineGlyphRange) {
lastPosition = location(forGlyphAt: NSMaxRange(glyphRange)).x
} else {
lastPosition = lineFragmentUsedRect(
forGlyphAt: NSMaxRange(glyphRange) - 1,
effectiveRange: nil).size.width
}
var lineRect = lineRect
let height = lineRect.size.height * 3.5 / 4.0 // replace your under line height
lineRect.origin.x += firstPosition
lineRect.size.width = lastPosition - firstPosition
lineRect.size.height = height
lineRect.origin.x += containerOrigin.x
lineRect.origin.y += containerOrigin.y
lineRect = lineRect.integral.insetBy(dx: 0.5, dy: 0.5)
let path = UIBezierPath(rect: lineRect)
// let path = UIBezierPath(roundedRect: lineRect, cornerRadius: 3)
// set your cornerRadius
path.fill()
}
Then construct your NSAttributedString and add attributes .underlineStyle and .underlineColor.
addAttributes(
[
.foregroundColor: UIColor.white,
.underlineStyle: NSUnderlineStyle.single.rawValue,
.underlineColor: UIColor(red: 51 / 255.0, green: 154 / 255.0, blue: 1.0, alpha: 1.0)
],
range: range
)
That's it!
I did it by checking frames of text fragments. In my project I needed to highlight hashtags while a user is typing text.
class HashtagTextView: UITextView {
let hashtagRegex = "#[-_0-9A-Za-z]+"
private var cachedFrames: [CGRect] = []
private var backgrounds: [UIView] = []
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
configureView()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configureView()
}
override func layoutSubviews() {
super.layoutSubviews()
// Redraw highlighted parts if frame is changed
textUpdated()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
#objc private func textUpdated() {
// You can provide whatever ranges needed to be highlighted
let ranges = resolveHighlightedRanges()
let frames = ranges.compactMap { frame(ofRange: $0) }.reduce([], +)
if cachedFrames != frames {
cachedFrames = frames
backgrounds.forEach { $0.removeFromSuperview() }
backgrounds = cachedFrames.map { frame in
let background = UIView()
background.backgroundColor = UIColor.hashtagBackground
background.frame = frame
background.layer.cornerRadius = 5
insertSubview(background, at: 0)
return background
}
}
}
/// General setup
private func configureView() {
NotificationCenter.default.addObserver(self, selector: #selector(textUpdated), name: UITextView.textDidChangeNotification, object: self)
}
/// Looks for locations of the string to be highlighted.
/// The current case - ranges of hashtags.
private func resolveHighlightedRanges() -> [NSRange] {
guard text != nil, let regex = try? NSRegularExpression(pattern: hashtagRegex, options: []) else { return [] }
let matches = regex.matches(in: text, options: [], range: NSRange(text.startIndex..<text.endIndex, in: text))
let ranges = matches.map { $0.range }
return ranges
}
}
There is also a helper extension to determine frames of ranges:
extension UITextView {
func convertRange(_ range: NSRange) -> UITextRange? {
let beginning = beginningOfDocument
if let start = position(from: beginning, offset: range.location), let end = position(from: start, offset: range.length) {
let resultRange = textRange(from: start, to: end)
return resultRange
} else {
return nil
}
}
func frame(ofRange range: NSRange) -> [CGRect]? {
if let textRange = convertRange(range) {
let rects = selectionRects(for: textRange)
return rects.map { $0.rect }
} else {
return nil
}
}
}
Result text view:
I wrote the below code following the #codeBearer answer.
import UIKit
class CustomAttributedTextView: UITextView {
override func layoutSubviews() {
super.layoutSubviews()
}
func clearForReuse() {
setNeedsDisplay()
}
var lineCountUpdate: ((Bool) -> Void)?
override func draw(_ rect: CGRect) {
super.draw(rect)
UIColor.clear.setFill()
UIColor.clear.setFill()
guard let context = UIGraphicsGetCurrentContext() else { return }
context.textMatrix = .identity
context.translateBy(x: 0, y: bounds.size.height)
context.scaleBy(x: 1.0, y: -1.0)
let path = CGMutablePath()
let size = sizeThatFits(CGSize(width: self.frame.width, height: .greatestFiniteMagnitude))
path.addRect(CGRect(x: 0, y: 0, width: size.width, height: size.height), transform: .identity)
let framesetter = CTFramesetterCreateWithAttributedString(attributedText as CFAttributedString)
let frame: CTFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attributedText.length), path, nil)
let lines: [CTLine] = frame.lines
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
for lineIndex in 0..<lines.count {
let line = lines[lineIndex]
let runs: [CTRun] = line.ctruns
var tagCountInOneLine = 0
for run in runs {
var cornerRadius: CGFloat = 3
let attributes: NSDictionary = CTRunGetAttributes(run)
var imgBounds: CGRect = .zero
if let value: UIColor = attributes.value(forKey: NSAttributedString.Key.customBackgroundColor.rawValue) as? UIColor {
var ascent: CGFloat = 0
imgBounds.size.width = CGFloat(CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, nil, nil) + 4)
imgBounds.size.height = ascent + 6
let xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, nil)
imgBounds.origin.x = origins[lineIndex].x + xOffset + 3
imgBounds.origin.y = origins[lineIndex].y - 13
if lineIndex != 0 {
imgBounds.origin.y = imgBounds.origin.y - 1
}
let path = UIBezierPath(roundedRect: imgBounds, cornerRadius: cornerRadius)
value.setFill()
path.fill()
value.setStroke()
}
}
}
}
}
extension CTFrame {
var lines: [CTLine] {
let linesAO: [AnyObject] = CTFrameGetLines(self) as [AnyObject]
guard let lines = linesAO as? [CTLine] else {
return []
}
return lines
}
}
extension CTLine {
var ctruns: [CTRun] {
let linesAO: [AnyObject] = CTLineGetGlyphRuns(self) as [AnyObject]
guard let lines = linesAO as? [CTRun] else {
return []
}
return lines
}
}
Related
How can I get the bounding box for each ruby annotation character (or furigana) in a CTFrame text rendered by CoreText and CoreGraphics in iOS? (iOS 11, 12 or 13)
Based on this question Core Text calculate letter frame in iOS I made a swift version program that encloses each character in a red box but I wasn't able to get the bounding boxes for the ruby annotation characters, aka furigana. I painted in green the ruby character boxes for the purpose of this question.
How can I achieve that? Any Ideas?
Edit: For clarity, the green boxes were painted by hand in an illustration software. I need to draw the green boxes by code.
The full source is in GitHub https://github.com/huse360/LetterFrame
This is the main source code:
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
context.textMatrix = .identity
context.translateBy(x: 0, y: self.bounds.size.height)
context.scaleBy(x: 1.0, y: -1.0)
let string = "|優勝《ゆうしょう》の|懸《か》かった|試合《しあい》。|Test《テスト》.\nThe quick brown fox jumps over the lazy dog. 12354567890 ##-+"
let attributedString = Utility.sharedInstance.furigana(String: string)
let range = attributedString.mutableString.range(of: attributedString.string)
attributedString.addAttribute(.font, value: font, range: range)
let framesetter = attributedString.framesetter()
let textBounds = self.bounds.insetBy(dx: 20, dy: 20)
let frame = framesetter.createFrame(textBounds)
//Draw the frame text:
frame.draw(in: context)
let origins = frame.lineOrigins()
let lines = frame.lines()
context.setStrokeColor(UIColor.red.cgColor)
context.setLineWidth(0.7)
for i in 0 ..< origins.count {
let line = lines[i]
for run in line.glyphRuns() {
let font = run.font
let glyphPositions = run.glyphPositions()
let glyphs = run.glyphs()
let glyphsBoundingRects = font.boundingRects(of: glyphs)
//DRAW the bounding box for each glyph:
for k in 0 ..< glyphPositions.count {
let point = glyphPositions[k]
let gRect = glyphsBoundingRects [k]
var box = gRect
box.origin += point + origins[i] + textBounds.origin
context.stroke(box)
}// for k
}//for run
}//for i
}//func draw
I have a UIView class in my app which plots a line graph. In there, I assign my graphPoints variables like so :
var graphPoints:[Int] = [1,2,3,5,7,9]
var graphPoints2:[Int] = [1,2,3,5,7,9]
What I want to do is pass an array of Int from another class and assign those variables, but I am not sure how to do it. Initially i put all my code into one func with array [Int] as parameters and called it from another class but it stopped plotting the graph altogether. How do i do this?
Here is my UIVIew GraphPlotter class code :
import UIKit
#IBDesignable class GraphPlotter: UIView {
var graphPoints:[Int] = [1,2,3,5,7,9]
var graphPoints2:[Int] = [1,2,3,5,7,9]
//1 - the properties for the gradient
var startColor: UIColor = UIColor.redColor()
var endColor: UIColor = UIColor.greenColor()
override func drawRect(rect: CGRect) {
let width = rect.width
let height = rect.height
//set up background clipping area
let path = UIBezierPath(roundedRect: rect,
byRoundingCorners: UIRectCorner.AllCorners,
cornerRadii: CGSize(width: 8.0, height: 8.0))
path.addClip()
//2 - get the current context
let context = UIGraphicsGetCurrentContext()
let colors = [startColor.CGColor, endColor.CGColor]
//3 - set up the color space
let colorSpace = CGColorSpaceCreateDeviceRGB()
//4 - set up the color stops
let colorLocations:[CGFloat] = [0.0, 1.0]
//5 - create the gradient
let gradient = CGGradientCreateWithColors(colorSpace,
colors,
colorLocations)
//6 - draw the gradient
var startPoint = CGPoint.zero
var endPoint = CGPoint(x:0, y:self.bounds.height)
CGContextDrawLinearGradient(context,
gradient,
startPoint,
endPoint,
[])
//calculate the x point
let margin:CGFloat = 40.0
let columnXPoint = { (column:Int) -> CGFloat in
//Calculate gap between points
let spacer = (width - margin*2 - 4) /
CGFloat((self.graphPoints.count - 1))
var x:CGFloat = CGFloat(column) * spacer
x += margin + 2
return x
}
// calculate the y point
let topBorder:CGFloat = 60
let bottomBorder:CGFloat = 50
let graphHeight = height - topBorder - bottomBorder
let maxValue = graphPoints2.maxElement()!
let columnYPoint = { (graphPoint2:Int) -> CGFloat in
var y:CGFloat = CGFloat(graphPoint2) /
CGFloat(maxValue) * graphHeight
y = graphHeight + topBorder - y // Flip the graph
return y
}
// draw the line graph
UIColor.flatTealColor().setFill()
UIColor.flatTealColor().setStroke()
//set up the points line
let graphPath = UIBezierPath()
//go to start of line
graphPath.moveToPoint(CGPoint(x:columnXPoint(0),
y:columnYPoint(graphPoints2[0])))
//add points for each item in the graphPoints array
//at the correct (x, y) for the point
for i in 1..<graphPoints.count {
let nextPoint = CGPoint(x:columnXPoint(i),
y:columnYPoint(graphPoints2[i]))
graphPath.addLineToPoint(nextPoint)
}
//Create the clipping path for the graph gradient
//1 - save the state of the context (commented out for now)
CGContextSaveGState(context)
//2 - make a copy of the path
let clippingPath = graphPath.copy() as! UIBezierPath
//3 - add lines to the copied path to complete the clip area
clippingPath.addLineToPoint(CGPoint(
x: columnXPoint(graphPoints.count - 1),
y:height))
clippingPath.addLineToPoint(CGPoint(
x:columnXPoint(0),
y:height))
clippingPath.closePath()
//4 - add the clipping path to the context
clippingPath.addClip()
let highestYPoint = columnYPoint(maxValue)
startPoint = CGPoint(x:margin, y: highestYPoint)
endPoint = CGPoint(x:margin, y:self.bounds.height)
CGContextDrawLinearGradient(context, gradient, startPoint, endPoint, [])
CGContextRestoreGState(context)
//draw the line on top of the clipped gradient
graphPath.lineWidth = 2.0
graphPath.stroke()
//Draw the circles on top of graph stroke
for i in 0..<graphPoints.count {
var point = CGPoint(x:columnXPoint(i), y:columnYPoint(graphPoints2[i]))
point.x -= 5.0/2
point.y -= 5.0/2
let circle = UIBezierPath(ovalInRect:
CGRect(origin: point,
size: CGSize(width: 5.0, height: 5.0)))
circle.fill()
let label = UILabel(frame: CGRectMake(0, 0, 200, 21))
label.center = CGPointMake(160, 284)
label.textAlignment = NSTextAlignment.Center
// label.text = "I'am a test label"
self.addSubview(label)
}
//Draw horizontal graph lines on the top of everything
let linePath = UIBezierPath()
//top line
linePath.moveToPoint(CGPoint(x:margin, y: topBorder))
linePath.addLineToPoint(CGPoint(x: width - margin,
y:topBorder))
//center line
linePath.moveToPoint(CGPoint(x:margin,
y: graphHeight/2 + topBorder))
linePath.addLineToPoint(CGPoint(x:width - margin,
y:graphHeight/2 + topBorder))
//bottom line
linePath.moveToPoint(CGPoint(x:margin,
y:height - bottomBorder))
linePath.addLineToPoint(CGPoint(x:width - margin,
y:height - bottomBorder))
let color = UIColor.flatTealColor()
color.setStroke()
linePath.lineWidth = 1.0
linePath.stroke()
}
}
DBController, func dosmth where I pass the array :
func dosmth(metadata: DBMetadata!) {
let documentsDirectoryPath = NSSearchPathForDirectoriesInDomains(.DocumentDirectory, .UserDomainMask, true)[0]
let localFilePath = (documentsDirectoryPath as NSString).stringByAppendingPathComponent(metadata.filename)
var newarray = [Int]()
do{
let data = try String(contentsOfFile: localFilePath as String,
encoding: NSASCIIStringEncoding)
print(data)
newarray = data.characters.split(){$0 == ","}.map{
Int(String.init($0).stringByTrimmingCharactersInSet(NSCharacterSet.whitespaceCharacterSet()))!}
print(newarray)
}
catch let error { print(error) }
//Probably wrong
GraphPlotter().graphPoints = newarray
GraphPlotter().graphPoints2 = newarray
}
So your drawRect method is based on the two variables graphPoints and graphPoints2. Create a method whose job is to update the arrays of these two variables, and then invoke setNeedsDisplay - which will go on to redraw the view.
func plotGraphPoints(gpArray1 : [Int], andMorePoints gpArray2: [Int] ) {
print("Old Values", self.graphPoints)
self.graphPoints = gpArray1
self.graphPoints2 = gpArray2
print("New values", self.graphPoints)
self.setNeedsDisplay()
}
First, I'd set these up so that any update will redraw the view:
var graphPoints:[Int]? { didSet { setNeedsDisplay() } }
var graphPoints2:[Int]? { didSet { setNeedsDisplay() } }
Note, I made those optionals, because you generally want it to handle the absence of data with nil values rather than dummy values. This does assume, though, that you'll tweak your implementation to detect and handle these nil values, e.g., before you start drawing the lines, do a
guard graphPoints != nil && graphPoints2 != nil else { return }
But, I notice that this whole class is IBDesignable, in which case, you probably want a prepareForInterfaceBuilder that provides sample data:
override func prepareForInterfaceBuilder() {
super.prepareForInterfaceBuilder()
graphPoints = [1,2,3,5,7,9]
graphPoints2 = [1,2,3,5,7,9]
}
Second, your other class needs to have a reference to this custom view.
If this "other" class is the view controller and you added the custom view via IB, you would just add a #IBOutlet for the custom view to this view controller. If you added this custom view programmatically, you'd just keep a reference to it in some property after adding it to the view hierarchy. But, however you added a reference to that view, say graphView, you'd just set these properties:
graphView.graphPoints = ...
graphView.graphPoints2 = ...
If this "other" class is something other than a view controller (and in discussion, it sounds like the class in question is a controller for processing of asynchronous DropBox API), you also need to give that class some mechanism to reference the view controller (and thus the custom view). You can accomplish this by either implementing a "completion handler pattern" or a "delegate-protocol" pattern.
I'm trying to update the value in xColor, so it can grab a different color from colorPalette. As you can see, colorPalette[xColor].CGColor is doing the work to check what color to use. I'm having trouble trying to update xColor with the swipe gestures, so that it can grab a different element in the array. Any advice would be helpful!
Canvas.swift:
var xColor = 0
var colorPalette = [UIColor.blueColor(), UIColor.yellowColor(), UIColor.redColor(), UIColor.redColor()]
override func drawRect(rect: CGRect) {
let context = UIGraphicsGetCurrentContext()
let color = colorPalette[xColor].CGColor
CGContextSetFillColorWithColor(context, color)
let rect = CGRect(x: x - r, y: y - r, width: 2 * r, height: 2 * r)
CGContextFillEllipseInRect(context, rect)
}
ViewController.swift:
func handleSwipes(sender:UISwipeGestureRecognizer) {
let cv = CanvasView()
if (sender.direction == .Left) {
cv.xColor--
print("Swipe left!")
}
if (sender.direction == .Right) {
cv.xColor++
print("Swipe right!")
}
print(cv.xColor)
}
There are two problems with your code. The first is this line:
let cv = CanvasView()
That creates a new CanvasView, but that new CanvasView is a different one from the old CanvasView that's already showing in the interface. Unfortunately, that old CanvasView is the one you need to talk to!
The second problem, once you're talking to the old CanvasView, is that you are failing to tell it that it needs redrawing. You need to send it the setNeedsDisplay message.
Looking at the documentation for NSLayoutManager, specifically the drawUnderlineForGlyphRange:underlineType:baselineOffset:lineFragmentRect:lineFragmentGlyphRange:containerOrigin: method, I noticed the following (emphasis mine):
underlineVal
The style of underlining to draw. This value is a mask derived from the value for NSUnderlineStyleAttributeName—for example, (NSUnderlinePatternDash | NSUnderlineStyleThick). Subclasses can define custom underlining styles.
My question is: how exactly is that meant to be done?
NSUnderlineStyle is an enum, which you cannot extend or override. You can of course provide a random raw Int value for the attribute, not covered by the enum cases:
self.addAttribute(NSUnderlineStyleAttributeName, value: 100022, range: lastUpdatedWordRange)
Which will deliver an "invalid" but usable underlineType to the Layout Manger:
But this hardly feels safe and is definitely inelegant.
I was not able to find any examples online or further clues in Apple documentation on what those mythical custom underline style types look like. I'd love to know if I'm missing something obvious.
I have an example project here that I used for a talk on TextKit that I gave a while back that does exactly what you're looking for: https://github.com/dtweston/text-kit-example
The underline in this case is a squiggly line:
The meat of the solution is a custom NSLayoutManager:
let CustomUnderlineStyle = 0x11
class UnderlineLayoutManager: NSLayoutManager {
func drawFancyUnderlineForRect(_ rect: CGRect) {
let left = rect.minX
let bottom = rect.maxY
let width = rect.width
let path = UIBezierPath()
path.move(to: CGPoint(x: left, y: bottom))
var x = left
var y = bottom
var i = 0
while (x <= left + width) {
path.addLine(to: CGPoint(x: x, y: y))
x += 2
if i % 2 == 0 {
y = bottom + 2.0
}
else {
y = bottom
}
i += 1;
}
path.stroke()
}
override func drawUnderline(forGlyphRange glyphRange: NSRange, underlineType underlineVal: NSUnderlineStyle, baselineOffset: CGFloat, lineFragmentRect lineRect: CGRect, lineFragmentGlyphRange lineGlyphRange: NSRange, containerOrigin: CGPoint) {
if underlineVal.rawValue & CustomUnderlineStyle == CustomUnderlineStyle {
let charRange = characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil)
if let underlineColor = textStorage?.attribute(NSUnderlineColorAttributeName, at: charRange.location, effectiveRange: nil) as? UIColor {
underlineColor.setStroke()
}
if let container = textContainer(forGlyphAt: glyphRange.location, effectiveRange: nil) {
let boundingRect = self.boundingRect(forGlyphRange: glyphRange, in: container)
let offsetRect = boundingRect.offsetBy(dx: containerOrigin.x, dy: containerOrigin.y)
drawFancyUnderlineForRect(offsetRect)
}
}
else {
super.drawUnderline(forGlyphRange: glyphRange, underlineType: underlineVal, baselineOffset: baselineOffset, lineFragmentRect: lineRect, lineFragmentGlyphRange: lineGlyphRange, containerOrigin: containerOrigin)
}
}
}
I have a question regarding rounded corners and text background color for a custom UIView.
Basically, I need to achieve an effect like this (image attached - notice the rounded corners on one side) in a custom UIView:
I'm thinking the approach to use is:
Use Core Text to get glyph runs.
Check highlight range.
If the current run is within the highlight range, draw a background rectangle with rounded corners and desired fill color before drawing the glyph run.
Draw the glyph run.
However, I'm not sure whether this is the only solution (or for that matter, whether this is the most efficient solution).
Using a UIWebView is not an option, so I have to do it in a custom UIView.
My question being, is this the best approach to use, and am I on the right track? Or am I missing out something important or going about it the wrong way?
TL;DR; Create a custom-view, which renders same old NSAttributedString, but with rounded-corners.
Unlike Android's SpannableString, iOS does not support "custom-render for custom-string-attributes", at least not without an entire custom-view (at time of writing, 2022).
I managed to achieve the above effect, so thought I'd post an answer for the same.
If anyone has any suggestions about making this more effective, please feel free to contribute. I'll be sure to mark your answer as the correct one. :)
For doing this, you'll need to add a "custom attribute" to NSAttributedString.
Basically, what that means is that you can add any key-value pair, as long as it is something that you can add to an NSDictionary instance. If the system does not recognize that attribute, it does nothing. It is up to you, as the developer, to provide a custom implementation and behavior for that attribute.
For the purposes of this answer, let us assume I've added a custom attribute called: #"MyRoundedBackgroundColor" with a value of [UIColor greenColor].
For the steps that follow, you'll need to have a basic understanding of how CoreText gets stuff done. Check out Apple's Core Text Programming Guide for understanding what's a frame/line/glyph run/glyph, etc.
So, here are the steps:
Create a custom UIView subclass.
Have a property for accepting an NSAttributedString.
Create a CTFramesetter using that NSAttributedString instance.
Override the drawRect: method
Create a CTFrame instance from the CTFramesetter.
You will need to give a CGPathRef to create the CTFrame. Make that CGPath to be the same as the frame in which you wish to draw the text.
Get the current graphics context and flip the text coordinate system.
Using CTFrameGetLines(...), get all the lines in the CTFrame you just created.
Using CTFrameGetLineOrigins(...), get all the line origins for the CTFrame.
Start a for loop - for each line in the array of CTLine...
Set the text position to the start of the CTLine using CGContextSetTextPosition(...).
Using CTLineGetGlyphRuns(...) get all the Glyph Runs (CTRunRef) from the CTLine.
Start another for loop - for each glyphRun in the array of CTRun...
Get the range of the run using CTRunGetStringRange(...).
Get typographic bounds using CTRunGetTypographicBounds(...).
Get the x offset for the run using CTLineGetOffsetForStringIndex(...).
Calculate the bounding rect (let's call it runBounds) using the values returned from the aforementioned functions.
Remember - CTRunGetTypographicBounds(...) requires pointers to variables to store the "ascent" and "descent" of the text. You need to add those to get the run height.
Get the attributes for the run using CTRunGetAttributes(...).
Check if the attribute dictionary contains your attribute.
If your attribute exists, calculate the bounds of the rectangle that needs to be painted.
Core text has the line origins at the baseline. We need to draw from the lowermost point of the text to the topmost point. Thus, we need to adjust for descent.
So, subtract the descent from the bounding rect that we calculated in step 16 (runBounds).
Now that we have the runBounds, we know what area we want to paint - now we can use any of the CoreGraphis/UIBezierPath methods to draw and fill a rect with specific rounded corners.
UIBezierPath has a convenience class method called bezierPathWithRoundedRect:byRoundingCorners:cornerRadii: that let's you round specific corners. You specify the corners using bit masks in the 2nd parameter.
Now that you've filled the rect, simply draw the glyph run using CTRunDraw(...).
Celebrate victory for having created your custom attribute - drink a beer or something! :D
Regarding detecting that the attribute range extends over multiple runs, you can get the entire effective range of your custom attribute when the 1st run encounters the attribute. If you find that the length of the maximum effective range of your attribute is greater than the length of your run, you need to paint sharp corners on the right side (for a left to right script). More math will let you detect the highlight corner style for the next line as well. :)
Attached is a screenshot of the effect. The box on the top is a standard UITextView, for which I've set the attributedText. The box on the bottom is the one that has been implemented using the above steps. The same attributed string has been set for both the textViews.
Again, if there is a better approach than the one that I've used, please do let me know! :D
Hope this helps the community. :)
Cheers!
Just customize NSLayoutManager and override drawUnderline(forGlyphRange:underlineType:baselineOffset:lineFragmentRect:lineFragmentGlyphRange:containerOrigin:) Apple API Document
In this method, you can draw underline by yourself, Swift code,
override func drawUnderline(forGlyphRange glyphRange: NSRange,
underlineType underlineVal: NSUnderlineStyle,
baselineOffset: CGFloat,
lineFragmentRect lineRect: CGRect,
lineFragmentGlyphRange lineGlyphRange: NSRange,
containerOrigin: CGPoint
) {
let firstPosition = location(forGlyphAt: glyphRange.location).x
let lastPosition: CGFloat
if NSMaxRange(glyphRange) < NSMaxRange(lineGlyphRange) {
lastPosition = location(forGlyphAt: NSMaxRange(glyphRange)).x
} else {
lastPosition = lineFragmentUsedRect(
forGlyphAt: NSMaxRange(glyphRange) - 1,
effectiveRange: nil).size.width
}
var lineRect = lineRect
let height = lineRect.size.height * 3.5 / 4.0 // replace your under line height
lineRect.origin.x += firstPosition
lineRect.size.width = lastPosition - firstPosition
lineRect.size.height = height
lineRect.origin.x += containerOrigin.x
lineRect.origin.y += containerOrigin.y
lineRect = lineRect.integral.insetBy(dx: 0.5, dy: 0.5)
let path = UIBezierPath(rect: lineRect)
// let path = UIBezierPath(roundedRect: lineRect, cornerRadius: 3)
// set your cornerRadius
path.fill()
}
Then construct your NSAttributedString and add attributes .underlineStyle and .underlineColor.
addAttributes(
[
.foregroundColor: UIColor.white,
.underlineStyle: NSUnderlineStyle.single.rawValue,
.underlineColor: UIColor(red: 51 / 255.0, green: 154 / 255.0, blue: 1.0, alpha: 1.0)
],
range: range
)
That's it!
I did it by checking frames of text fragments. In my project I needed to highlight hashtags while a user is typing text.
class HashtagTextView: UITextView {
let hashtagRegex = "#[-_0-9A-Za-z]+"
private var cachedFrames: [CGRect] = []
private var backgrounds: [UIView] = []
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
configureView()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configureView()
}
override func layoutSubviews() {
super.layoutSubviews()
// Redraw highlighted parts if frame is changed
textUpdated()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
#objc private func textUpdated() {
// You can provide whatever ranges needed to be highlighted
let ranges = resolveHighlightedRanges()
let frames = ranges.compactMap { frame(ofRange: $0) }.reduce([], +)
if cachedFrames != frames {
cachedFrames = frames
backgrounds.forEach { $0.removeFromSuperview() }
backgrounds = cachedFrames.map { frame in
let background = UIView()
background.backgroundColor = UIColor.hashtagBackground
background.frame = frame
background.layer.cornerRadius = 5
insertSubview(background, at: 0)
return background
}
}
}
/// General setup
private func configureView() {
NotificationCenter.default.addObserver(self, selector: #selector(textUpdated), name: UITextView.textDidChangeNotification, object: self)
}
/// Looks for locations of the string to be highlighted.
/// The current case - ranges of hashtags.
private func resolveHighlightedRanges() -> [NSRange] {
guard text != nil, let regex = try? NSRegularExpression(pattern: hashtagRegex, options: []) else { return [] }
let matches = regex.matches(in: text, options: [], range: NSRange(text.startIndex..<text.endIndex, in: text))
let ranges = matches.map { $0.range }
return ranges
}
}
There is also a helper extension to determine frames of ranges:
extension UITextView {
func convertRange(_ range: NSRange) -> UITextRange? {
let beginning = beginningOfDocument
if let start = position(from: beginning, offset: range.location), let end = position(from: start, offset: range.length) {
let resultRange = textRange(from: start, to: end)
return resultRange
} else {
return nil
}
}
func frame(ofRange range: NSRange) -> [CGRect]? {
if let textRange = convertRange(range) {
let rects = selectionRects(for: textRange)
return rects.map { $0.rect }
} else {
return nil
}
}
}
Result text view:
I wrote the below code following the #codeBearer answer.
import UIKit
class CustomAttributedTextView: UITextView {
override func layoutSubviews() {
super.layoutSubviews()
}
func clearForReuse() {
setNeedsDisplay()
}
var lineCountUpdate: ((Bool) -> Void)?
override func draw(_ rect: CGRect) {
super.draw(rect)
UIColor.clear.setFill()
UIColor.clear.setFill()
guard let context = UIGraphicsGetCurrentContext() else { return }
context.textMatrix = .identity
context.translateBy(x: 0, y: bounds.size.height)
context.scaleBy(x: 1.0, y: -1.0)
let path = CGMutablePath()
let size = sizeThatFits(CGSize(width: self.frame.width, height: .greatestFiniteMagnitude))
path.addRect(CGRect(x: 0, y: 0, width: size.width, height: size.height), transform: .identity)
let framesetter = CTFramesetterCreateWithAttributedString(attributedText as CFAttributedString)
let frame: CTFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attributedText.length), path, nil)
let lines: [CTLine] = frame.lines
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
for lineIndex in 0..<lines.count {
let line = lines[lineIndex]
let runs: [CTRun] = line.ctruns
var tagCountInOneLine = 0
for run in runs {
var cornerRadius: CGFloat = 3
let attributes: NSDictionary = CTRunGetAttributes(run)
var imgBounds: CGRect = .zero
if let value: UIColor = attributes.value(forKey: NSAttributedString.Key.customBackgroundColor.rawValue) as? UIColor {
var ascent: CGFloat = 0
imgBounds.size.width = CGFloat(CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, nil, nil) + 4)
imgBounds.size.height = ascent + 6
let xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, nil)
imgBounds.origin.x = origins[lineIndex].x + xOffset + 3
imgBounds.origin.y = origins[lineIndex].y - 13
if lineIndex != 0 {
imgBounds.origin.y = imgBounds.origin.y - 1
}
let path = UIBezierPath(roundedRect: imgBounds, cornerRadius: cornerRadius)
value.setFill()
path.fill()
value.setStroke()
}
}
}
}
}
extension CTFrame {
var lines: [CTLine] {
let linesAO: [AnyObject] = CTFrameGetLines(self) as [AnyObject]
guard let lines = linesAO as? [CTLine] else {
return []
}
return lines
}
}
extension CTLine {
var ctruns: [CTRun] {
let linesAO: [AnyObject] = CTLineGetGlyphRuns(self) as [AnyObject]
guard let lines = linesAO as? [CTRun] else {
return []
}
return lines
}
}