Early versions of iOS often used web views to render text with advanced styling such as bold, italics, and colors as they were easier to work with than the alternatives. The release of iOS 7 brought with it a new framework for working with text and text attributes: Text Kit. All text-based UIKit controls (apart from UIWebView) use Text Kit as shown in the following diagram:
Understanding Dynamic Type
To make use of dynamic type, you app needs to specify fonts using styles rather than explicitly stating the font name and size. You use preferredFont(forTextStyle:)
on UIFont to create a font for the given style using the user’s font preferences.
textView.adjustsFontForContentSizeCategory = true
textView.font = .preferredFont(forTextStyle: .body)
With that single line, you just told the text view to automatically reload the font when the system configuration changes.
NSAttributedString.TextEffectStyle
let attrText = NSAttributedString(string: "Hello", attributes: [
.textEffect: NSAttributedString.TextEffectStyle.letterpressStyle
])
The letterpress effect adds subtle shading and highlights that give text a sense of depth — much like the text has been slightly pressed into the screen.
Creating Exclusion Paths
Flowing text around images and other objects is a commonly needed styling feature. Text Kit allows you to render text around complex paths and shapes with exclusion paths.
let exclusionPath = timeView.curvePathWithOrigin(timeView.center)
textView.textContainer.exclusionPaths = [exclusionPath]
Leveraging Dynamic Text Formatting and Storage
To do this, you’ll need to understand how the text storage system in Text Kit works. Here’s a diagram that shows the “Text Kit stack” used to store, render and display text:
Behind the scenes, Apple automatically creates these classes for when you create a UITextView, UILabel or UITextField
. In your apps, you can either use these default implementations or customize any part to get your own behavior. Going over each class:
Subclassing NSTextStorage
let backingStore = NSMutableAttributedString()
override var string: String {
return backingStore.string
}
override func attributes(
at location: Int,
effectiveRange range: NSRangePointer?
) -> [NSAttributedString.Key: Any] {
return backingStore.attributes(at: location, effectiveRange: range)
}
Finally, add the remaining mandatory overrides to the same file:
override func replaceCharacters(in range: NSRange, with str: String) {
print("replaceCharactersInRange:\(range) withString:\(str)")
beginEditing()
backingStore.replaceCharacters(in: range, with:str)
edited(.editedCharacters, range: range,
changeInLength: (str as NSString).length - range.length)
endEditing()
}
override func setAttributes(_ attrs: [NSAttributedString.Key: Any]?, range: NSRange) {
print("setAttributes:\(String(describing: attrs)) range:\(range)")
beginEditing()
backingStore.setAttributes(attrs, range: range)
edited(.editedAttributes, range: range, changeInLength: 0)
endEditing()
}
Again, these methods delegate to the backing store. However, they also surround the edits with calls to beginEditing(), edited() and endEditing()
. The text storage class requires these three methods to notify its associated layout manager when making edits.
Implementing UITextView With a Custom Text Kit Stack
func createTextView() {
// 1
let attrs = [NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .body)]
let attrString = NSAttributedString(string: note.contents, attributes: attrs)
textStorage = SyntaxHighlightTextStorage()
textStorage.append(attrString)
let newTextViewRect = view.bounds
// 2
let layoutManager = NSLayoutManager()
// 3
let containerSize = CGSize(width: newTextViewRect.width,
height: .greatestFiniteMagnitude)
let container = NSTextContainer(size: containerSize)
container.widthTracksTextView = true
layoutManager.addTextContainer(container)
textStorage.addLayoutManager(layoutManager)
// 4
textView = UITextView(frame: newTextViewRect, textContainer: container)
textView.delegate = self
view.addSubview(textView)
// 5
textView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
textView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
textView.topAnchor.constraint(equalTo: view.topAnchor),
textView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
Create a text container and associate it with the layout manager. Then, associate the layout manager with the text storage.
Create the actual text view with your custom text container, set the delegate and add the text view as a subview.
Note that the text container has a width that matches the view width, but has infinite height — or as close as .greatestFiniteMagnitude
can come to infinity. This is enough to allow the UITextView to scroll and accommodate long passages of text.
Adding Dynamic Formatting
In this next step, you are going to modify your custom text storage to embolden text surrounded by asterisks.
func applyStylesToRange(searchRange: NSRange) {
// 1
let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
let boldFontDescriptor = fontDescriptor.withSymbolicTraits(.traitBold)
let boldFont = UIFont(descriptor: boldFontDescriptor!, size: 0)
let normalFont = UIFont.preferredFont(forTextStyle: .body)
// 2
let regexStr = "(\\*\\w+(\\s\\w+)*\\*)"
let regex = try! NSRegularExpression(pattern: regexStr)
let boldAttributes = [NSAttributedString.Key.font: boldFont]
let normalAttributes = [NSAttributedString.Key.font: normalFont]
// 3
regex.enumerateMatches(in: backingStore.string, range: searchRange) {
match, flags, stop in
if let matchRange = match?.range(at: 1) {
addAttributes(boldAttributes, range: matchRange)
// 4
let maxRange = matchRange.location + matchRange.length
if maxRange + 1 < length {
addAttributes(normalAttributes, range: NSMakeRange(maxRange, 1))
}
}
}
}
Now, add the following method:
func performReplacementsForRange(changedRange: NSRange) {
var extendedRange =
NSUnionRange(changedRange,
NSString(string: backingStore.string)
.lineRange(for: NSMakeRange(changedRange.location, 0)))
extendedRange =
NSUnionRange(changedRange,
NSString(string: backingStore.string)
.lineRange(for: NSMakeRange(NSMaxRange(changedRange), 0)))
applyStylesToRange(searchRange: extendedRange)
}
The code above expands the range that your code inspects when attempting to match your bold formatting pattern. This is required because changedRange
typically indicates a single character. lineRange(for:)
extends that range to the entire line of text.
Finally, add the following method right after the code above:
override func processEditing() {
performReplacementsForRange(changedRange: editedRange)
super.processEditing()
}
Adding Further Styles
func createAttributesForFontStyle(
_ style: UIFont.TextStyle,
withTrait trait: UIFontDescriptor.SymbolicTraits
) -> [NSAttributedString.Key: Any] {
let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: style)
let descriptorWithTrait = fontDescriptor.withSymbolicTraits(trait)
let font = UIFont(descriptor: descriptorWithTrait!, size: 0)
return [.font: font]
}
Now, add the following function to the end of the class:
func createHighlightPatterns() {
let scriptFontDescriptor = UIFontDescriptor(fontAttributes: [.family: "Zapfino"])
// 1
let bodyFontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
let bodyFontSize = bodyFontDescriptor.fontAttributes[.size] as! NSNumber
let scriptFont = UIFont(descriptor: scriptFontDescriptor,
size: CGFloat(bodyFontSize.floatValue))
// 2
let boldAttributes = createAttributesForFontStyle(.body, withTrait:.traitBold)
let italicAttributes = createAttributesForFontStyle(.body,
withTrait:.traitItalic)
let strikeThroughAttributes = [NSAttributedString.Key.strikethroughStyle: 1]
let scriptAttributes = [NSAttributedString.Key.font: scriptFont]
let redTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.red]
// 3
replacements = [
"(\\*\\w+(\\s\\w+)*\\*)": boldAttributes,
"(_\\w+(\\s\\w+)*_)": italicAttributes,
"([0-9]+\\.)\\s": boldAttributes,
"(-\\w+(\\s\\w+)*-)": strikeThroughAttributes,
"(~\\w+(\\s\\w+)*~)": scriptAttributes,
"\\s([A-Z]{2,})\\s": redTextAttributes
]
}
Finally, replace the implementation of applyStylesToRange(searchRange:)
with the following:
func applyStylesToRange(searchRange: NSRange) {
let normalAttrs =
[NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .body)]
addAttributes(normalAttrs, range: searchRange)
// iterate over each replacement
for (pattern, attributes) in replacements {
do {
let regex = try NSRegularExpression(pattern: pattern)
regex.enumerateMatches(in: backingStore.string, range: searchRange) {
match, flags, stop in
// apply the style
if let matchRange = match?.range(at: 1) {
print("Matched pattern: \(pattern)")
addAttributes(attributes, range: matchRange)
// reset the style to the original
let maxRange = matchRange.location + matchRange.length
if maxRange + 1 < length {
addAttributes(normalAttrs, range: NSMakeRange(maxRange, 1))
}
}
}
}
catch {
print("An error occurred attempting to locate pattern: " +
"\(error.localizedDescription)")
}
}
}
Final screen shows
Reference
https://www.raywenderlich.com/5960-text-kit-tutorial-getting-started