How to make a background with rounded corners for text view in UIKit — Telegram-Drawing-Text-Editing, ep. 4
First part
All right, the person who found this article already know something about TextKit, so I won’t annoy you with this principles and basic stuff which you can google in a couple of minutes, meanwhile I’ll put you right into the seeking solution.
Let’s start from the NSLayoutManager custom implementation:
class IPNSLayoutManager: NSLayoutManager {
var strokePoints: [Int: [String: CGPoint]] = [:]
var strokePath: UIBezierPath = UIBezierPath()
var cornerRadius: CGFloat = 7
override init() {
super.init()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) {
super.drawBackground(forGlyphRange: glyphsToShow, at: origin)
// Get a text storage from NSLayoutManger's property
guard let textStorage = textStorage else { return }
// Get attributes from the textStorage
let attributes = textStorage.attributes(at: 0, effectiveRange: nil)
guard let backgroundColor = attributes[.backgroundColor] as? UIColor else { return }
// Kind of a hack to disable default background with sharp corners, which we want to avoid
let restoredAlphaColor = backgroundColor.withAlphaComponent(1)
// Set the color as fill color
restoredAlphaColor.setFill()
let range = self.characterRange(forGlyphRange: glyphsToShow, actualGlyphRange: nil)
let glyphRange = self.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
let context = UIGraphicsGetCurrentContext()
context?.saveGState()
context?.translateBy(x: origin.x, y: origin.y)
// Keep in mind that drawBackground method will always be invoked after you change your text, so we
// Erase all data in strokePoints to avoid duplication of already drawn paths.
strokePoints = [:]
strokePath = UIBezierPath()
self.enumerateLineFragments(forGlyphRange: glyphRange) { [weak self] rect, usedRect, textContainer, glyphRange, stop in
// Get points from usedRect
self?.getPoints(from: usedRect)
context?.addPath(self?.strokePath.cgPath ?? UIBezierPath().cgPath)
context?.setFillColor(restoredAlphaColor.cgColor)
context?.setStrokeColor(restoredAlphaColor.cgColor)
context?.fillPath(using: .evenOdd)
context?.strokePath()
}
context?.restoreGState()
}
}
Alright here a quick stop to show you my way of dealing with a problem. Default implementation of some articles you’ve already seen will give something like this.
Well, we could take this approach and improve it significantly. As you can see each line is a separate rounded rectangle which has a frame and its frame has minX, minY, maxX, maxY values which are used to define four corners of the rectangle. With this knowledge we could draw a stroke that will start from the top of our text and move (in my example, you can choose a different direction) clockwise until we met our starting point.
As you can see in getPoints(from:)
implementation I define four points and give them corresponding names just for sake of simplicity. And they are really logical, consider them as:
rectangleFrame — a rectangle that represents a glyph range
ltc
- left top corner (x: rectangleFrame.minX, y: rectangleFrame.minY)
lbc
- left bottom corner (x: rectangleFrame.minX, y: rectangleFrame.maxY)
rtc
- right top corner (x: rectangleFrame.maxX, y: rectangleFrame.minY)
rbc
- right bottom corner (x: rectangleFrame.maxX, y: rectangleFrame.maxY)
Drawing concept
All right the next screenshot shows what kind of result we want to get and what direction we move and a couple of other concepts:
Yellow stroke represents the way we move. We do draw each line separately, and remove previous path. Consider this example: We have three lines, so the buildPath method will be called three times. One time per line. When we are on the first line we draw first rectangle, then we erase points and path. Then we draw first line again, and after that we move to the second line with applied rules-of-proper-arc and so on until we drew all rectangles. If you remove fill color you’ll see what I’m trying to describe here.
1 — Red group
The first group is the first line which we start to draw from the ltc
and move to rtc
and finally to rbc
2 — Green group
The second group is the middle lines, so potentially is the biggest group of our text. All four points of each line will differ depend on previous and next line from current line pov.
3 — Magenta group
The last for the text right side of the text is the magenta or third group. The last line. It’s easier to calculate than middle lines, cause the rbc
and lbc
will always be the same. Likewise for ltc
, rtc
for the first line(Red group).
After we’ve done with the right side of the figure we invoke a method that is responsible for building left side of the text background. Which has pretty much the same logic of group but with a little adjustments which I’ll describe later.
Arcs’s states
There are [2] arc states we’ll encounter. When center of the arc that rectangle possess
- is inside
- is outside
This two state are used differently depends on which line and which point of the rectangle we’re considering.
First line(Red group)
The ltc
and rtc
will always have first arc state. However rbc
will have different states depend on a simple condition: if nextLine is bigger than current use second state, otherwise the first
. The same concept is applied for lbc
with only difference that in this case we’ll compare current line with the previous one.
Each middle line(Green group)
In that case there’s no corners that always have the same state. For rtc
is the next conditional if previous line is bigger use second state, otherwise the first
for rbc
- if nextLine is bigger than current use second state, otherwise the first
. For the left side the same algorithm is applied with small adjustments, ltc
check next line, meanwhile rbc
previous one.
The last line(Magenta group)
Here only ltc
and rtc
will have changeable states which are pretty the same as for this corners described above for middle lines(green group). lbc
and rbc
always have first state for the arcs.
Reusable building arcs/lines methods
For the sake of simplicity and reusability I create a couple of methods that are used to build proper arcs and lines depend on different states. Here they’re:
Yeah, there maybe a way to avoid duplication by creating an enum
with four cases that represent different corners, but I don’t do that, cause it makes a function to be really big.
Here’s a little logical explanation about what you’ll see. If you don’t care you can move on to methods implementation by this link or you could consider the NSLayoutManager implementation directly on my Github.
When we move our cursor to a provided point the next element(line or arc) will be drawn from that point, obviously. So for cases when our next element is an arc we need to keep in mind that we don’t want to start drawing from either of these four corners(ltc, rtc, rbc, lbc), but what we want to do is to make an offset from the point to make sure our rectangle has right dimensions. Observe the underlying image to get the idea better.
As you can see we just can’t use rtc’s X and Y, because it will cause that our rectangle increases in itself size. So in case of rtc previous point ltc and we move our line to a point that has next parameters (rtc.x — cornerRadius, y: rtc.y) . For different points adjustments are distinct. Talking about left side of the figure in some cases we need to add cornerRadius to X, Y because we changed direction
Enhanced default drawing methods
Consider methods down bellow. Each of them has two states for line
methods is with or without an offset. When we draw a line with an offset it means that the corner has an arc, otherwise it doesn’t. The latter is when lines have the same length. In this version of the article I don’t cover this, because I’m still in search of a solution for that.
Methods that responsible for arcs drawing have two states too. One when previous line was bigger, and another is when current line is bigger.
RTC methods
func addLine(rtc: CGPoint, withoutOffSet: Bool = false) {
if withoutOffSet {
strokePath.addLine(to: rtc)
} else {
strokePath.addLine(to: CGPoint(x: rtc.x - cornerRadius, y: rtc.y))
}
}
func addArc(rtc: CGPoint, currentGreater: Bool = true) {
if currentGreater {
strokePath.addArc(withCenter: CGPoint(x: rtc.x - cornerRadius,
y: rtc.y + cornerRadius), radius: cornerRadius, startAngle: CGFloat(3 * Double.pi / 2), endAngle: 0, clockwise: true)
} else {
strokePath.addArc(withCenter: CGPoint(x: rtc.x + cornerRadius,
y: rtc.y + cornerRadius), radius: cornerRadius, startAngle: CGFloat(3 * Double.pi / 2), endAngle: CGFloat(Double.pi), clockwise: false)
}
}
RBC methods
func addLine(rbc: CGPoint, withoutOffSet: Bool = false) {
if withoutOffSet {
strokePath.addLine(to: rbc)
} else {
strokePath.addLine(to: CGPoint(x: rbc.x, y: rbc.y - cornerRadius))
}
}
func addArc(rbc: CGPoint, currentGreater: Bool = true) {
if currentGreater {
strokePath.addArc(withCenter: CGPoint(x: rbc.x - cornerRadius,
y: rbc.y - cornerRadius), radius: cornerRadius, startAngle: 0, endAngle: CGFloat(Double.pi / 2), clockwise: true)
} else {
strokePath.addArc(withCenter: CGPoint(x: rbc.x + cornerRadius,
y: rbc.y - cornerRadius), radius: cornerRadius, startAngle: CGFloat(Double.pi), endAngle: CGFloat(2 * Double.pi / 4) , clockwise: false)
}
}
LBC methods
func addLine(lbc: CGPoint, withoutOffSet: Bool = false) {
if withoutOffSet {
strokePath.addLine(to: lbc)
} else {
strokePath.addLine(to: CGPoint(x: lbc.x + cornerRadius, y: lbc.y))
}
}
func addArc(lbc: CGPoint, currentGreater: Bool = true) {
if currentGreater {
strokePath.addArc(withCenter: CGPoint(x: lbc.x + cornerRadius,
y: lbc.y - cornerRadius), radius: cornerRadius, startAngle: CGFloat(Double.pi / 2), endAngle: CGFloat(Double.pi), clockwise: true)
} else {
strokePath.addArc(withCenter: CGPoint(x: lbc.x - cornerRadius,
y: lbc.y - cornerRadius), radius: cornerRadius, startAngle: CGFloat(2 * Double.pi / 4), endAngle: 0, clockwise: false)
}
}
LTC methods
func addLine(ltc: CGPoint, withoutOffSet: Bool = false) {
if withoutOffSet {
strokePath.addLine(to: ltc)
} else {
strokePath.addLine(to: CGPoint(x: ltc.x, y: ltc.y + cornerRadius))
}
}
func addArc(ltc: CGPoint, currentGreater: Bool = true) {
if currentGreater {
strokePath.addArc(withCenter: CGPoint(x: ltc.x + cornerRadius,
y: ltc.y + cornerRadius), radius: cornerRadius, startAngle: CGFloat(Double.pi), endAngle: CGFloat(3 * Double.pi / 2), clockwise: true)
} else {
strokePath.addArc(withCenter: CGPoint(x: ltc.x - cornerRadius,
y: ltc.y + cornerRadius), radius: cornerRadius , startAngle: 0, endAngle: (3 * Double.pi) / 2, clockwise: false)
}
}
Build a path
Let’s begin drawing already and have a look at buildPath method once again!
override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) {
super.drawBackground(forGlyphRange: glyphsToShow, at: origin)
// Get a text storage from NSLayoutManger's property
guard let textStorage = textStorage else { return }
// Get attributes from the textStorage
let attributes = textStorage.attributes(at: 0, effectiveRange: nil)
guard let backgroundColor = attributes[.backgroundColor] as? UIColor else { return }
// Kind of a hack to disable default background with sharp corners, which we want to avoid
let restoredAlphaColor = backgroundColor.withAlphaComponent(1)
// Set the color as fill color
restoredAlphaColor.setFill()
let range = self.characterRange(forGlyphRange: glyphsToShow, actualGlyphRange: nil)
let glyphRange = self.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
let context = UIGraphicsGetCurrentContext()
context?.saveGState()
context?.translateBy(x: origin.x, y: origin.y)
strokePoints = [:]
strokePath = UIBezierPath()
self.enumerateLineFragments(forGlyphRange: glyphRange) { [weak self] rect, usedRect, textContainer, glyphRange, stop in
// Get points from usedRect
self?.getPoints(from: usedRect)
context?.addPath(self?.strokePath.cgPath ?? UIBezierPath().cgPath)
context?.setFillColor(restoredAlphaColor.cgColor)
context?.setStrokeColor(restoredAlphaColor.cgColor)
context?.fillPath(using: .evenOdd)
context?.strokePath()
}
context?.restoreGState()
}
As you can saw before I’ve added strokePoints
that has the next structure:
• We have a line index which is outermost key, the corresponding value is another dictionary. Inner dictionary holds a CGPoint instance that’s stored behind the key — string representation of the corner(rtc
, ltc
…)
And strokePath
that is UIBezierPath that will be expanded by points from strokePoints
during drawing.
// The structure is next [0 : [ltc: (x:0,y:0), rtc: (x:20,y:0), ...], ...]
var strokePoints: [Int: [String: CGPoint]] = [:]
// The path that will be used as a stroke around text's lines(rectangles)
var strokePath: UIBezierPath = UIBezierPath()
Since the method drawBackground is called whenever you add a new symbol and have enabled background drawing at the moment we need to ensure that previous values of strokePoints
will be eradicated before we start new render. The same thing should be made with strokePath
. I do this right before calling enumerateLineFragments
The very first call in the enumerateLineFragments
is getPoints(from: usedRect) that is responsible for taking points of provided rect and put them to strokePoints
with a help of method appendPointsToStrokePoints(ltc: rtc: rbc: lbc: )
func getPoints(from rect: CGRect) {
let ltc: CGPoint = rect.origin
let rtc: CGPoint = CGPoint(x: rect.maxX, y: rect.minY)
let rbc: CGPoint = CGPoint(x: rect.maxX, y: rect.maxY)
let lbc: CGPoint = CGPoint(x: rect.minX, y: rect.maxY)
appendPointsToStrokePoints(ltc: ltc, rtc: rtc, rbc: rbc, lbc: lbc)
buildPath()
}
func appendPointsToStrokePoints(ltc: CGPoint, rtc: CGPoint, rbc: CGPoint, lbc: CGPoint) {
let currentIndexOfStrokePoints: Int = strokePoints.count
strokePoints[currentIndexOfStrokePoints] = ["ltc": ltc, "rtc": rtc, "rbc": rbc, "lbc": lbc]
}
As you can see we call buildPath
inside this method. Which can cause a pretty reasonable question, why the hell we need to call this method on each line, actually because I haven’t found a better way to do that, and this method helps to ensure that we always have relevant figure. Because when iteration comes to last line the property of strokePoints
will be full of lines that we’ve written so far and because we’ll invalidate previous paths we get correct form that embeds our text properly. Currently this is the only way I can make it work, so in the future updates of the article, I probably change the way the algorithm behaves.
buildPath()
buildPath method is really big, so instead of just put it here I will cut it to a few code snippets which helps to better understand what’s going on. Keep in mind everything that goes next are placed in buildPath, except a few things I write about.
let numberOfLines = strokePoints.count
for i: Int in 0..<numberOfLines{
}
strokePath.close()
So literally buildPath possesses for loop that iterates through a range of number of lines.
Everything bellow is placed in for
loop:
Simplified structure of the method. Is not actual code!
buildPath {
for {
code to build lines and arcs
}
closePath
}
Preparation stage
// 1
var currentLine: [String : CGPoint]? = strokePoints[i]
// 2
guard var ltc: CGPoint = currentLine?["ltc"],
var rtc: CGPoint = currentLine?["rtc"],
var rbc: CGPoint = currentLine?["rbc"],
var lbc: CGPoint = currentLine?["lbc"] else { return }
var previousLine: [String : CGPoint]? = nil
// 3
if i > 0 {
previousLine = strokePoints[i - 1]
}
var nextLine: [String : CGPoint]? = nil
// 3
if i < numberOfLines - 1 {
nextLine = strokePoints[i + 1]
}
- Initialize current line which has a type of
[String: CGPoint]?
which we get fromstrokePoints
with a value that is current indexi
2. Perform optional binding to get corners from current line
3. Try to get previous and next lines if it is possible
Draw a background if numbers of lines is equal to 1
The code we put after we’ve made a preparation stage is build a rounded corners rectangle for the first, in that case one line, cause we don’t have any:
if numberOfLines == 1 {
strokePath.move(to: ltc)
addLine(rtc: rtc)
addArc(rtc: rtc)
addLine(rbc: rbc)
addArc(rbc: rbc)
addLine(lbc: lbc)
addArc(lbc: lbc)
addLine(ltc: ltc)
addArc(ltc: ltc)
}
Draw a background if numbers of lines is greater than one
The first line rectangle code:
if i == 0 {
guard let nextLine: [String : CGPoint] = nextLine else { return }
addLine(rtc: rtc)
addArc(rtc: rtc)
addLine(rbc: rbc)
if nextLine["rtc"]!.x < rtc.x {
// From top the left (corner inside)
addArc(rbc: rbc)
} else if nextLine["rtc"]!.x > rtc.x {
// From top to the right (corner outside)
addArc(rbc: rbc, currentGreater: false)
}
}
In this case we need to unwrap nextLine to ensure that this line exists. After that we start drawing our figure. Depends on whether nextLine’s x is bigger or less than currentLine’s x we draw an arc with correct direction of the end point.
Each middle line will use the next algorithm:
else {
// MARK: The right side of some middle line
guard let previousLine: [String : CGPoint] = previousLine,
let nextLine: [String : CGPoint] = nextLine else { return }
if previousLine["rtc"]!.x > rtc.x {
// From top to the left( corner outside )
addLine(rtc: rtc)
addArc(rtc: rtc, currentGreater: false)
} else {
// From top to the right ( corner inside )
addLine(rtc: rtc)
addArc(rtc: rtc)
}
if nextLine["rtc"]!.x > rbc.x {
// From bottom to the right ( corner inside )
addLine(rbc: rbc)
addArc(rbc: rbc, currentGreater: false)
} else {
// From bottom to the left ( corner outside )
addLine(rbc: rbc)
addArc(rbc: rbc)
}
}
Because middle lines are, well somewhere in between of other lines they have previous and next lines neighbours, so check whether they aren’t nil. Depends on previous line’s X we draw rtc
, hence rbc
will differ if next line’s X is greater or less than current.
The next code should be put before else
block but it’s responsible for the last line drawing:
else if i == numberOfLines - 1 {
// MARK: The last line/rectangle
guard let previousLine: [String : CGPoint] = previousLine else { return }
addLine(rtc: rtc)
if previousLine["rtc"]!.x > rtc.x {
// From top to the left (corner outside)
addArc(rtc: rtc, currentGreater: false)
} else {
// From top to the rigth (corner outside)
addArc(rtc: rtc)
}
addLine(rbc: rbc)
addArc(rbc: rbc)
drawCornersForPreviousPoints(strokePoints, index: i)
}
Check for previous line and compare Xs, like before. Move one until this line drawCornersForPreviousPoints(strokePoints, index: i)
This is the function which will draw left side of our figure, so we move backwards to the very first line. We define this function outside the scope
Well the algorithm of this method is generally the same as for right side instead of a few exceptions:
- If a line A is bigger than line B it means that A has a lower X on this left side, because nearer we get to the left side of the screen our X decreases. Which we need to take into account when compare values.
- Indexes of the next and previous line are changed in compare to right side.
- Counter will be decreased until we get to 0
drawCornersForPreviousPoints()
func drawCornersForPreviousPoints(_ strokePoints: [Int : [String : CGPoint]], index i: Int) {
// i is stands for the last index of our array of points, or simply (strokePoints.count - 1)
var c = i
// Here we move backwards to the top
// Start with left bottom corner:
while c >= 0 {
let currentLine: [String : CGPoint]? = strokePoints[c]
// MARK: The left side of some middle line
guard let ltc: CGPoint = currentLine?["ltc"],
let lbc: CGPoint = currentLine?["lbc"] else { return }
var nextLine: [String : CGPoint]? = nil
// index of next line
let iN = c - 1
if iN >= 0 {
nextLine = strokePoints[iN]
}
var previousLine: [String : CGPoint]? = nil
// index of previous line
let iP = c + 1
if iP <= i {
previousLine = strokePoints[iP]
}
if c == i {
// Keep in mind that greater line will have lower X-Axis!!!!
addLine(lbc: lbc)
addArc(lbc: lbc)
guard let nextLine: [String : CGPoint] = nextLine else { return }
addLine(ltc: ltc)
if nextLine["ltc"]!.x < ltc.x {
// Next line is greater than current
addArc(ltc: ltc, currentGreater: false)
} else {
// Next line is less than current
addArc(ltc: ltc)
}
} else if c == 0 {
// The first line/rectangle from the top.
guard let previousLine: [String : CGPoint] = previousLine else { return }
addLine(lbc: lbc)
if previousLine["ltc"]!.x < lbc.x {
// Previous is bigger
addArc(lbc: lbc, currentGreater: false)
} else {
// Previous is less than current
addArc(lbc: lbc)
}
addLine(ltc: ltc)
addArc(ltc: ltc)
} else {
guard let previousLine: [String : CGPoint] = previousLine else { return }
addLine(lbc: lbc)
if previousLine["ltc"]!.x < lbc.x {
// Previous line bigger than current
addArc(lbc: lbc, currentGreater: false)
} else {
// Previous line is less than current
addArc(lbc: lbc)
}
guard let nextLine = nextLine else { return }
addLine(ltc: ltc)
if nextLine["ltc"]!.x < ltc.x {
// Next line is greater than current
addArc(ltc: ltc, currentGreater: false)
} else {
// Next luine is less than current
addArc(ltc: ltc)
}
}
c -= 1
}
}
And that’s pretty all you need to build a background with rounded corners for text view.
Second part
This part is kind of optional, because it goes beyond the initial topic, but which, I believe, helps people to see the result.
Comming soon…