Related
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
}
}
Currently I am tracking my location on an MKMapView. My objective is to draw a bezier path identical to an MKPolyline created from tracked locations.
What I have attempted is: Store all location coordinates in a CLLocation array. Iterate over that array and store the lat/lng coordinates in a CLLocationCoordinate2D array. Then ensure the polyline is in the view of the screen to then convert all the location coordinates in CGPoints.
Current attempt:
#IBOutlet weak var bezierPathView: UIView!
var locations = [CLLocation]() // values from didUpdateLocation(_:)
func createBezierPath() {
bezierPathView.isHidden = false
var coordinates = [CLLocationCoordinate2D]()
for location in locations {
coordinates.append(location.coordinate)
}
let polyline = MKPolyline(coordinates: coordinates, count: coordinates.count)
fitPolylineInView(polyline: polyline)
let mapPoints = polyline.points()
var points = [CGPoint]()
for point in 0...polyline.pointCount
{
let coordinate = MKCoordinateForMapPoint(mapPoints[point])
points.append(mapView.convert(coordinate, toPointTo: polylineView))
}
print(points)
let path = UIBezierPath(points: points)
path.lineWidth = 2.0
path.lineJoinStyle = .round
let layer = CAShapeLayer(path: path, lineColor: UIColor.red, fillColor: UIColor.black)
bezierPathView.layer.addSublayer(layer)
}
extension UIBezierPath {
convenience init(points:[CGPoint])
{
self.init()
//connect every points by line.
//the first point is start point
for (index,aPoint) in points.enumerated()
{
if index == 0 {
self.move(to: aPoint)
}
else {
self.addLine(to: aPoint)
}
}
}
}
extension CAShapeLayer
{
convenience init(path:UIBezierPath, lineColor:UIColor, fillColor:UIColor)
{
self.init()
self.path = path.cgPath
self.strokeColor = lineColor.cgColor
self.fillColor = fillColor.cgColor
self.lineWidth = path.lineWidth
self.opacity = 1
self.frame = path.bounds
}
}
I am able to output the points to the console that stored from the convert(_:) method( not sure if they are correct ). Yet the there is not output on the bezierPathView-resulting in an empty-white background-view controller.
Your extensions work fine. The problem may be in the code that adds the layer to the view (which you do not show).
I'd suggest that you simplify your project, for example use predefined array of points that definitely fit to your view. For example, for a view that is 500 pixels wide and 300 pixels high, you could use something like:
let points = [
CGPoint(x: 10, y: 10),
CGPoint(x: 490, y: 10),
CGPoint(x: 490, y: 290),
CGPoint(x: 10, y: 290),
CGPoint(x: 10, y: 10)
]
Use colors that are clearly visible, like black and yellow for your stroke and fill.
Make sure that your path is correctly added to the view, for example:
let path = UIBezierPath(points: points)
let shapeLayer = CAShapeLayer(path: path, lineColor: UIColor.blue, fillColor: UIColor.lightGray)
view.layer.addSublayer(shapeLayer)
Inspect the controller that contains the view in Xcode's Interface Builder. In the debug view hierarchy function:
this might help you, in case you haven't solved it yet.
I wanted the shape of an MKPolyline as an image without any background.
I used the code above as an inspiration and had the same troubles as you had, the route was not shown.
In fact it was kind a scaling problem I think. At least it looked like that in the playground.
Anyway, with this methods I get an image of the polylines shape.
private func createPolylineShapeAsImage() -> UIImage? {
let vw = UIView(frame: mapView.bounds)
var image : UIImage?
if let polyline = viewModel.tourPolyline {
let path = createBezierPath(mapView, polyline, to: mapView)
let layer = getShapeLayer(path: path, lineColor: UIColor.white, fillColor: .clear)
vw.layer.addSublayer(layer)
image = vw.asImage()
}
return image
}
func createBezierPath(_ mapView : MKMapView, _ polyline : MKPolyline, to view : UIView) -> UIBezierPath {
let mapPoints = polyline.points()
var points = [CGPoint]()
let max = polyline.pointCount - 1
for point in 0...max {
let coordinate = mapPoints[point].coordinate
points.append(mapView.convert(coordinate, toPointTo: view))
}
let path = UIBezierPath(points: points)
path.lineWidth = 5.0
return path
}
private func getShapeLayer(path:UIBezierPath, lineColor:UIColor, fillColor:UIColor) -> CAShapeLayer {
let layer = CAShapeLayer()
layer.path = path.cgPath
layer.strokeColor = lineColor.cgColor
layer.fillColor = fillColor.cgColor
layer.lineWidth = path.lineWidth
layer.opacity = 1
layer.frame = path.bounds
return layer
}
And to get the image of the view use this extension
import UIKit
extension UIView {
// Using a function since `var image` might conflict with an existing variable
// (like on `UIImageView`)
func asImage() -> UIImage {
if #available(iOS 10.0, *) {
let renderer = UIGraphicsImageRenderer(bounds: bounds)
return renderer.image { rendererContext in
layer.render(in: rendererContext.cgContext)
}
} else {
UIGraphicsBeginImageContext(self.frame.size)
self.layer.render(in:UIGraphicsGetCurrentContext()!)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return UIImage(cgImage: image!.cgImage!)
}
}
}
I have a simple custom CALayer to create an overlaying gradient effect on my UIView. Here is the code:
class GradientLayer: CALayer {
var locations: [CGFloat]?
var origin: CGPoint?
var radius: CGFloat?
var color: CGColor?
convenience init(view: UIView, locations: [CGFloat]?, origin: CGPoint?, radius: CGFloat?, color: UIColor?) {
self.init()
self.locations = locations
self.origin = origin
self.radius = radius
self.color = color?.CGColor
self.frame = view.bounds
}
override func drawInContext(ctx: CGContext) {
super.drawInContext(ctx)
guard let locations = self.locations else { return }
guard let origin = self.origin else { return }
guard let radius = self.radius else { return }
let colorSpace = CGColorGetColorSpace(color)
let colorComponents = CGColorGetComponents(color)
let gradient = CGGradientCreateWithColorComponents(colorSpace, colorComponents, locations, locations.count)
CGContextDrawRadialGradient(ctx, gradient, origin, CGFloat(0), origin, radius, [.DrawsAfterEndLocation])
}
}
I initialize and set these layers here:
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
let gradient1 = GradientLayer(view: view, locations: [0.0,1.0], origin: CGPoint(x: view.frame.midX, y: view.frame.midY), radius: 100.0, color: UIColor(white: 1.0, alpha: 0.2))
let gradient2 = GradientLayer(view: view, locations: [0.0,1.0], origin: CGPoint(x: view.frame.midX-20, y: view.frame.midY+20), radius: 160.0, color: UIColor(white: 1.0, alpha: 0.2))
let gradient3 = GradientLayer(view: view, locations: [0.0,1.0], origin: CGPoint(x: view.frame.midX+30, y: view.frame.midY-30), radius: 300.0, color: UIColor(white: 1.0, alpha: 0.2))
gradient1.setNeedsDisplay()
gradient2.setNeedsDisplay()
gradient3.setNeedsDisplay()
view.layer.addSublayer(gradient1)
view.layer.addSublayer(gradient2)
view.layer.addSublayer(gradient3)
}
The view seems to display properly most of the time, but (seemingly) randomly I'll get different renderings as you'll see below. Here are some examples (the first one is what I want):
What is causing this malfunction? How do I only load the first one every time?
You have several problems.
First off, you should think of a gradient as an array of stops, where a stop has two parts: a color and a location. You must have an equal number of colors and locations, because every stop has one of each. You can see this if, for example, you check the CGGradientCreateWithColorComponents documentation regarding the components argument:
The number of items in this array should be the product of count and the number of components in the color space.
It's a product (the result of a multiplication) because you have count stops and you need a complete set of color components for each stop.
You're not providing enough color components. Your GradientLayer could have any number of locations (and you're giving it two) but has only one color. You're getting that one color's components and passing that as the components array to CGGradientCreateWithColorComponents, but the array is too short. Swift doesn't catch this error—notice that the type of your colorComponents is UnsafePointer<CGFloat>. The Unsafe part tells you that you're in dangerous territory. (You can see the type of colorComponents by option-clicking it in Xcode.)
Since you're not providing a large enough array for components, iOS is using whatever random values happen to be in memory after the components of your one color. Those may change from run to run and are often not what you want them to be.
In fact, you shouldn't even use CGGradientCreateWithColorComponents. You should use CGGradientCreateWithColors, which takes an array of CGColor so it's not only simpler to use, but safer because it's one less UnsafePointer floating around.
Here's what GradientLayer should look like:
class RadialGradientLayer: CALayer {
struct Stop {
var location: CGFloat
var color: UIColor
}
var stops: [Stop] { didSet { self.setNeedsDisplay() } }
var origin: CGPoint { didSet { self.setNeedsDisplay() } }
var radius: CGFloat { didSet { self.setNeedsDisplay() } }
init(stops: [Stop], origin: CGPoint, radius: CGFloat) {
self.stops = stops
self.origin = origin
self.radius = radius
super.init()
needsDisplayOnBoundsChange = true
}
override init(layer other: AnyObject) {
guard let other = other as? RadialGradientLayer else { fatalError() }
stops = other.stops
origin = other.origin
radius = other.radius
super.init(layer: other)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func drawInContext(ctx: CGContext) {
let locations = stops.map { $0.location }
let colors = stops.map { $0.color.CGColor }
locations.withUnsafeBufferPointer { pointer in
let gradient = CGGradientCreateWithColors(nil, colors, pointer.baseAddress)
CGContextDrawRadialGradient(ctx, gradient, origin, 0, origin, radius, [.DrawsAfterEndLocation])
}
}
}
Next problem. You're adding more gradient layers every time the system calls viewWillLayoutSubviews. It can call that function multiple times! For example, it will call it if your app supports interface rotation, or if a call comes in and iOS makes the status bar taller. (You can test that in the simulator by choosing Hardware > Toggle In-Call Status Bar.)
You need to create the gradient layers once, storing them in a property. If they have already been created, you need to update their frames and not create new layers:
class ViewController: UIViewController {
private var gradientLayers = [RadialGradientLayer]()
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
if gradientLayers.isEmpty {
createGradientLayers()
}
for layer in gradientLayers {
layer.frame = view.bounds
}
}
private func createGradientLayers() {
let bounds = view.bounds
let mid = CGPointMake(bounds.midX, bounds.midY)
typealias Stop = RadialGradientLayer.Stop
for (point, radius, color) in [
(mid, 100, UIColor(white:1, alpha:0.2)),
(CGPointMake(mid.x - 20, mid.y + 20), 160, UIColor(white:1, alpha:0.2)),
(CGPointMake(mid.x + 30, mid.y - 30), 300, UIColor(white:1, alpha:0.2))
] as [(CGPoint, CGFloat, UIColor)] {
let stops: [RadialGradientLayer.Stop] = [
Stop(location: 0, color: color),
Stop(location: 1, color: color.colorWithAlphaComponent(0))]
let layer = RadialGradientLayer(stops: stops, origin: point, radius: radius)
view.layer.addSublayer(layer)
gradientLayers.append(layer)
}
}
}
Your problem is the code you have written in viewWillLayoutSubviews function its is called multiple times when views loads just add a check to run it once or better yet add a check in viewdidlayoutsubviews to run it once
I'm trying to draw a series of vertical lines inside of an arc but I'm having trouble being able to do this. I'm trying to do this using CAShapeLayers The end result is something that looks like this.
I know how to draw the curved arc and the line segments using CAShapeLayers but what I can't seem to figure out is how to draw the vertical lines inside the CAShapeLayer
My initial approach is to subclass CAShapeLayer and in the subclass, attempt to draw the vertical lines. However, I'm not getting the desired results. Here is my code for adding a line to a bezier point and attempting to add the sub layers.
class CustomLayer : CAShapeLayer {
override init() {
super.init()
}
func drawDividerLayer(){
print("Init has been called in custom layer")
print("The bounds of the custom layer is: \(bounds)")
print("The frame of the custom layer is: \(frame)")
let bezierPath = UIBezierPath()
let dividerShapeLayer = CAShapeLayer()
dividerShapeLayer.strokeColor = UIColor.redColor().CGColor
dividerShapeLayer.lineWidth = 1
let startPoint = CGPointMake(5, 0)
let endPoint = CGPointMake(5, 8)
let convertedStart = convertPoint(startPoint, toLayer: dividerShapeLayer)
let convertedEndPoint = convertPoint(endPoint, toLayer: dividerShapeLayer)
bezierPath.moveToPoint(convertedStart)
bezierPath.addLineToPoint(convertedEndPoint)
dividerShapeLayer.path = bezierPath.CGPath
addSublayer(dividerShapeLayer)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class DrawView : UIView {
var customDrawLayer : CAShapeLayer!
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
//drawLayers()
}
override func drawRect(rect: CGRect) {
super.drawRect(rect)
}
func drawLayers() {
let bezierPath = UIBezierPath()
let startPoint = CGPointMake(5, 35)
let endPoint = CGPointMake(100, 35)
bezierPath.moveToPoint(startPoint)
bezierPath.addLineToPoint(endPoint)
let customLayer = CustomLayer()
customLayer.frame = CGPathGetBoundingBox(bezierPath.CGPath)
customLayer.drawDividerLayer()
customLayer.strokeColor = UIColor.blackColor().CGColor
customLayer.opacity = 0.5
customLayer.lineWidth = 8
customLayer.fillColor = UIColor.clearColor().CGColor
layer.addSublayer(customLayer)
customLayer.path = bezierPath.CGPath
}
However this code produces this image:
It definitely seems that I have a coordinate space problem/bounds/frame issue but I'm not quite sure. The way I want this to work is to draw from the top of the superLayer to the bottom of the superLayer inside of the CustomLayer class. But not only that, this must work using the bezier path addArcWithCenter: method which I haven't gotten to yet because I'm trying to solve this problem first. Any help would be appreciated.
The easiest way to draw an arc that consists of lines is to use lineDashPattern:
let path = UIBezierPath(arcCenter: center, radius: radius, startAngle: 0, endAngle: CGFloat(M_PI), clockwise: false)
let arc = CAShapeLayer()
arc.path = path.CGPath
arc.lineWidth = 50
arc.lineDashPattern = [4,15]
arc.strokeColor = UIColor.lightGrayColor().CGColor
arc.fillColor = UIColor.clearColor().CGColor
view.layer.addSublayer(arc)
So this is a blue arc underneath the dashed arc shown above. Obviously, I enlarged it for the sake of visibility, but it illustrates the idea.
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
}
}