I\'d like to flow the text in UILabel
into a circle (instead of rect).
I did some experiments with NSLayoutManager
, NSTextContainer
an
With Swift 4 and iOS 11, NSTextContainer
has a property called exclusionPaths. exclusionPaths
has the following declaration:
An array of path objects representing the regions where text is not displayed in the text container.
var exclusionPaths: [UIBezierPath] { get set }
Besides, UIBezierPath
has a property called usesEvenOddFillRule. usesEvenOddFillRule
has the following declaration:
A Boolean indicating whether the even-odd winding rule is in use for drawing paths.
var usesEvenOddFillRule: Bool { get set }
By using usesEvenOddFillRule
, you can create an exclusion path surrounding a circle with only a few lines of code:
var exclusionPath: UIBezierPath {
let path = UIBezierPath(ovalIn: bounds)
path.append(UIBezierPath(rect: bounds))
path.usesEvenOddFillRule = true
return path
}
The following UITextView
and UIViewController
subclasses show how to display text inside a circle using NSTextContainer
exclusionPaths
and UIBezierPath
usesEvenOddFillRule
properties:
TextView.swift
import UIKit
class TextView: UITextView {
convenience init() {
self.init(frame: .zero, textContainer: nil)
}
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
isScrollEnabled = false
isEditable = false
textContainerInset = .zero
self.textContainer.lineBreakMode = .byTruncatingTail
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
var exclusionPath: UIBezierPath {
let path = UIBezierPath(ovalIn: bounds)
path.append(UIBezierPath(rect: bounds))
path.usesEvenOddFillRule = true
return path
}
}
extension TextView {
// Draw circle
override func draw(_ rect: CGRect) {
UIColor.orange.setFill()
let path = UIBezierPath(ovalIn: rect)
path.fill()
}
// Draw exclusion path
/*
override func draw(_ rect: CGRect) {
UIColor.orange.setFill()
exclusionPath.fill()
}
*/
}
ViewController.swift
import UIKit
class ViewController: UIViewController {
let textView = TextView()
override func viewDidLoad() {
super.viewDidLoad()
let string = "Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda."
textView.attributedText = NSAttributedString(string: string)
view.addSubview(textView)
textView.translatesAutoresizingMaskIntoConstraints = false
let horizontalConstraint = textView.centerXAnchor.constraint(equalTo: view.centerXAnchor)
let verticalConstraint = textView.centerYAnchor.constraint(equalTo: view.centerYAnchor)
let widthConstraint = textView.widthAnchor.constraint(equalToConstant: 240)
let heightConstraint = textView.heightAnchor.constraint(equalToConstant: 240)
NSLayoutConstraint.activate([horizontalConstraint, verticalConstraint, widthConstraint, heightConstraint])
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
textView.textContainer.exclusionPaths = [textView.exclusionPath]
}
}
By picking one or the other implementation of draw(_:)
, you will have the following displays:
Here is my contribution to the above question in Swift 3. https://github.com/icatmed/ICRoundLabel.git
import UIKit
import CoreText
@IBDesignable
open class ICRoundLabel: UILabel {
// Switch on/off text rounding, is on by default
@IBInspectable open dynamic var isRounded:Bool = true {
didSet{
setNeedsDisplay()
}
}
// Specify text alignment
@available(*, unavailable, message: "This property is reserved for Interface Builder. Use 'roundedTextAlignment' instead.")
@IBInspectable open dynamic var alignment:UInt8 {
set{
self.roundedTextAlignment = CTTextAlignment(rawValue: newValue)!
setNeedsDisplay()
}
get{
return roundedTextAlignment.rawValue
}
}
// Font scale
@IBInspectable open dynamic var fillTextInCenter:Bool = true {
didSet{
setNeedsDisplay()
}
}
// Font step
@available(*, unavailable, message: "This property is reserved for Interface Builder. Use 'internalFontStep' instead.")
@IBInspectable open dynamic var fontStep:CGFloat {
set(newValue) {
internalFontStep = max(newValue, 0.1)
}
get {
return internalFontStep
}
}
open var roundedTextAlignment:CTTextAlignment = .center
open var internalFontStep:CGFloat = 1
override open func drawText(in rect: CGRect) {
// Check if custom text draw is needed
if !isRounded {
super.drawText(in: rect)
return
}
// Check if text exists
guard let text = self.text else {
return
}
if text == "" {
return
}
// Get graphics context
guard let context = UIGraphicsGetCurrentContext() else {
return
}
//MARK: Create attributed string
var stringRange = NSMakeRange(0, text.characters.count)
let attrString = CFAttributedStringCreate(kCFAllocatorDefault, text as CFString!, attributedText?.attributes(at: 0, effectiveRange: &stringRange) as CFDictionary!)
let attributedString = CFAttributedStringCreateMutableCopy(kCFAllocatorDefault, CFIndex.max, attrString)!
let stringLength = CFAttributedStringGetLength(attributedString)
// Set a paragraph style
let cfStringRange = CFRangeMake(0, stringLength)
let settings = [CTParagraphStyleSetting(spec: .alignment, valueSize: MemoryLayout.size(ofValue: roundedTextAlignment), value: &roundedTextAlignment)]
let paragraphStyle = CTParagraphStyleCreate(settings, 1)
CFAttributedStringSetAttribute(attributedString, cfStringRange, kCTParagraphStyleAttributeName, paragraphStyle)
// Make custom transitions with context
context.translateBy(x: 0.0, y: frame.size.height)
context.scaleBy(x: 1.0, y: -1.0)
// New drawing rect with insets
let drawingRect = CGRect(origin: CGPoint(x: 0, y: 0), size: CGSize(width: rect.size.width, height: rect.size.height))
// Align text in center
var boundingBox = text.boundingRect(with: drawingRect.size, options: NSStringDrawingOptions.usesLineFragmentOrigin, attributes: [NSFontAttributeName: font], context: nil)
//MARK: Create elliptical path
var path = CGPath(roundedRect: drawingRect, cornerWidth: drawingRect.width/2, cornerHeight: drawingRect.height/2, transform: nil)
//MARK: Frame and range calculation nested function
func getTextFrameRange() -> (CTFrame, CFRange) {
let textFrame = CTFramesetterCreateFrame(CTFramesetterCreateWithAttributedString(attributedString), cfStringRange, path, nil)
let rangeThatFits = CTFrameGetVisibleStringRange(textFrame)
return (textFrame, rangeThatFits)
}
var textFrame:CTFrame
var rangeThatFits:CFRange
//MARK: Scaling font size if needed
if fillTextInCenter {
var fontSize = font.pointSize
var estimatedFont = font.withSize(fontSize)
// Pin text in center of initial rect
var boxHeight = ceil(boundingBox.height)
func updateBoundingBox() {
boundingBox.origin = CGPoint(x: ceil((drawingRect.size.height - boxHeight)/2), y: ceil((drawingRect.size.height - boxHeight)/2))
boundingBox.size = CGSize(width: boxHeight, height: boxHeight)
}
path = CGPath(roundedRect: boundingBox, cornerWidth: boundingBox.width/2, cornerHeight: boundingBox.height/2, transform: nil)
(_, rangeThatFits) = getTextFrameRange()
updateBoundingBox()
// Fit text in center
while cfStringRange.length != rangeThatFits.length {
// Increase size of bounding box size if needed
// or decrease font size
if boundingBox.width < drawingRect.width {
boxHeight += 1
//Update bounding box accoringly to new box size
updateBoundingBox()
path = CGPath(roundedRect: boundingBox, cornerWidth: boundingBox.width/2, cornerHeight: boundingBox.height/2, transform: nil)
(_, rangeThatFits) = getTextFrameRange()
continue
} else {
CFAttributedStringSetAttribute(attributedString, cfStringRange, kCTFontAttributeName, estimatedFont)
(_, rangeThatFits) = getTextFrameRange()
// Increase or decrease font size
fontSize += cfStringRange.length < rangeThatFits.length ? internalFontStep : -internalFontStep
estimatedFont = font.withSize(fontSize)
}
}
}
//MARK: Draw the text frame in the view's graphics context
(textFrame, _) = getTextFrameRange()
CTFrameDraw(textFrame, context)
}
@IBInspectable var borderColor: UIColor = UIColor.white {
didSet {
layer.borderColor = borderColor.cgColor
}
}
@IBInspectable var borderWidth: CGFloat = 1.0 {
didSet {
layer.borderWidth = borderWidth
}
}
override open func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = 0.5 * bounds.size.width
clipsToBounds = true
}
}
This appeared to be a very simple solution. NSTextContainer
has an exclusionPaths
property. What you can do is to create two Bezier paths that will define areas that should be excluded.
So I did that and here is my method:
- (void)setCircularExclusionPathWithCenter:(CGPoint)center radius:(CGFloat)radius textView:(UITextView *)textView
{
UIBezierPath *topHalf = [UIBezierPath bezierPath];
[topHalf moveToPoint:CGPointMake(center.x - radius, center.y + radius)];
[topHalf addLineToPoint:CGPointMake(center.x - radius, center.y)];
[topHalf addArcWithCenter:center radius:radius startAngle:M_PI endAngle:0.0f clockwise:NO];
[topHalf addLineToPoint:CGPointMake(center.x + radius, center.y + radius)];
[topHalf closePath];
UIBezierPath *bottomHalf = [UIBezierPath bezierPath];
[bottomHalf moveToPoint:CGPointMake(center.x - radius, center.y - radius)];
[bottomHalf addLineToPoint:CGPointMake(center.x - radius, center.y)];
[bottomHalf addArcWithCenter:center radius:radius startAngle:M_PI endAngle:0 clockwise:YES];
[bottomHalf addLineToPoint:CGPointMake(center.x + radius, center.y - radius)];
[bottomHalf closePath];
textView.textContainer.exclusionPaths = @[bottomHalf, topHalf];
}
Example usage:
[self setCircularExclusionPathWithCenter:CGPointMake(160.0f, 200.0f)
radius:100.0f
textView:_textView];
And a result of my experiments:
Of course you will have to use a UITextView instead of UILabel but I hope it helps :)
You can't do this in a UILabel because it doesn't give you access to the TextKit stack. What I do is to build my own TextKit stack and subclass NSTextContainer:
-(CGRect)lineFragmentRectForProposedRect:(CGRect)proposedRect atIndex:(NSUInteger)characterIndex writingDirection:(NSWritingDirection)baseWritingDirection remainingRect:(CGRect *)remainingRect {
CGRect result = [super lineFragmentRectForProposedRect:proposedRect atIndex:characterIndex writingDirection:baseWritingDirection remainingRect:remainingRect];
CGRect r = CGRectMake(0,0,self.size.width,self.size.height);
UIBezierPath* circle = [UIBezierPath bezierPathWithOvalInRect:r];
CGPoint p = result.origin;
while (![circle containsPoint:p]) {
p.x += .1;
result.origin = p;
}
CGFloat w = result.size.width;
p = result.origin;
p.x += w;
while (![circle containsPoint:p]) {
w -= .1;
result.size.width = w;
p = result.origin;
p.x += w;
}
return result;
}
Crude but effective. Looks like this: