Files
GitHawk/Classes/Systems/NSAttributedStringSizing.swift
Ryan Nystrom ed3adf74c5 Polish header spacing (#1228)
* label styles match github.com

* size assignee to content

* size milestones to content

* polish header spacings
2017-12-10 18:13:08 -05:00

226 lines
7.6 KiB
Swift

//
// NSAttributedStringSizing.swift
// Freetime
//
// Created by Ryan Nystrom on 5/14/17.
// Copyright © 2017 Ryan Nystrom. All rights reserved.
//
import UIKit
import IGListKit
extension CGSize {
func snapped(scale: CGFloat) -> CGSize {
var size = self
size.width = ceil(size.width * scale) / scale
size.height = ceil(size.height * scale) / scale
return size
}
func resized(inset: UIEdgeInsets) -> CGSize {
var size = self
size.width += inset.left + inset.right
size.height += inset.top + inset.bottom
return size
}
}
final class NSAttributedStringSizing: NSObject, ListDiffable {
private let textContainer: NSTextContainer
private let textStorage: NSTextStorage
private let layoutManager: NSLayoutManager
let inset: UIEdgeInsets
let attributedText: NSAttributedString
let screenScale: CGFloat
let backgroundColor: UIColor
init(
containerWidth: CGFloat,
attributedText: NSAttributedString,
inset: UIEdgeInsets = .zero,
backgroundColor: UIColor = .white,
exclusionPaths: [UIBezierPath] = [],
maximumNumberOfLines: Int = 0,
lineFragmentPadding: CGFloat = 0.0,
allowsNonContiguousLayout: Bool = false,
hyphenationFactor: CGFloat = 0.0,
showsInvisibleCharacters: Bool = false,
showsControlCharacters: Bool = false,
usesFontLeading: Bool = true,
screenScale: CGFloat = UIScreen.main.scale
) {
self.attributedText = attributedText
self.inset = inset
self.screenScale = screenScale
self.backgroundColor = backgroundColor
textContainer = NSTextContainer()
textContainer.exclusionPaths = exclusionPaths
textContainer.maximumNumberOfLines = maximumNumberOfLines
textContainer.lineFragmentPadding = lineFragmentPadding
layoutManager = LabelLayoutManager()
layoutManager.allowsNonContiguousLayout = allowsNonContiguousLayout
layoutManager.hyphenationFactor = hyphenationFactor
layoutManager.showsInvisibleCharacters = showsInvisibleCharacters
layoutManager.showsControlCharacters = showsControlCharacters
layoutManager.usesFontLeading = usesFontLeading
layoutManager.addTextContainer(textContainer)
// storage implicitly required to use NSLayoutManager + NSTextContainer and find a size
textStorage = NSTextStorage(attributedString: attributedText)
textStorage.addLayoutManager(layoutManager)
super.init()
NotificationCenter.default.addObserver(
self,
selector: #selector(NSAttributedStringSizing.onMemoryWarning),
name: NSNotification.Name.UIApplicationDidReceiveMemoryWarning,
object: nil
)
computeSize(containerWidth)
}
// MARK: Public API
private var _textSize = [CGFloat: CGSize]()
func textSize(_ width: CGFloat) -> CGSize {
if let cache = _textSize[width] {
return cache
}
return computeSize(width)
}
func textViewSize(_ width: CGFloat) -> CGSize {
return textSize(width).resized(inset: inset)
}
func rect(_ width: CGFloat) -> CGRect {
let size = textSize(width)
return CGRect(
x: inset.left,
y: inset.top,
width: size.width - inset.left - inset.right,
height: size.height - inset.top - inset.bottom
)
}
func configure(textView: UITextView) {
textView.attributedText = attributedText
textView.contentInset = .zero
textView.textContainerInset = inset
let textContainer = textView.textContainer
textContainer.exclusionPaths = self.textContainer.exclusionPaths
textContainer.maximumNumberOfLines = self.textContainer.maximumNumberOfLines
textContainer.lineFragmentPadding = self.textContainer.lineFragmentPadding
let layoutManager = textView.layoutManager
layoutManager.allowsNonContiguousLayout = self.layoutManager.allowsNonContiguousLayout
layoutManager.hyphenationFactor = self.layoutManager.hyphenationFactor
layoutManager.showsInvisibleCharacters = self.layoutManager.showsInvisibleCharacters
layoutManager.showsControlCharacters = self.layoutManager.showsControlCharacters
layoutManager.usesFontLeading = self.layoutManager.usesFontLeading
layoutManager.addTextContainer(textContainer)
}
private var _contents = [String: CGImage]()
func contents(_ width: CGFloat) -> CGImage? {
let key = "\(width)"
if let contents = _contents[key] {
return contents
}
let size = textSize(width)
textContainer.size = size
UIGraphicsBeginImageContextWithOptions(size, true, screenScale)
backgroundColor.setFill()
UIBezierPath(rect: CGRect(origin: .zero, size: size)).fill()
let glyphRange = layoutManager.glyphRange(for: textContainer)
layoutManager.drawBackground(forGlyphRange: glyphRange, at: .zero)
layoutManager.drawGlyphs(forGlyphRange: glyphRange, at: .zero)
let contents = UIGraphicsGetImageFromCurrentImageContext()?.cgImage
UIGraphicsEndImageContext()
// keep only one bitmap at a time
_contents.removeAll()
_contents[key] = contents
return contents
}
func attributes(point: CGPoint) -> [NSAttributedStringKey: Any]? {
var fractionDistance: CGFloat = 1.0
let index = layoutManager.characterIndex(for: point, in: textContainer, fractionOfDistanceBetweenInsertionPoints: &fractionDistance)
if index != NSNotFound, fractionDistance < 1.0 {
return attributedText.attributes(at: index, effectiveRange: nil)
}
return nil
}
// MARK: Private API
@discardableResult
func computeSize(_ width: CGFloat) -> CGSize {
let insetWidth = max(width - inset.left - inset.right, 0)
textContainer.size = CGSize(width: insetWidth, height: 0)
// find the size of the text now that everything is configured
let bounds = layoutManager.usedRect(for: textContainer)
// snap to pixel
let size = bounds.size.snapped(scale: screenScale)
_textSize[width] = size
return size
}
@objc func onMemoryWarning() {
_contents.removeAll()
}
// MARK: ListDiffable
func diffIdentifier() -> NSObjectProtocol {
return attributedText
}
func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
return true
}
}
final class LabelLayoutManager: NSLayoutManager {
override func fillBackgroundRectArray(_ rectArray: UnsafePointer<CGRect>, count rectCount: Int, forCharacterRange charRange: NSRange, color: UIColor) {
// Get the attributes for the backgroundColor attribute
var range = charRange
let attributes = textStorage?.attributes(at: charRange.location, effectiveRange: &range)
// Ensure that this is one of our labels we're dealing with, ignore basic backgroundColor attributes
guard attributes?[MarkdownAttribute.label] != nil else {
super.fillBackgroundRectArray(rectArray, count: rectCount, forCharacterRange: charRange, color: color)
return
}
let rawRect = rectArray[0]
let rect = CGRect(
x: floor(rawRect.origin.x),
y: floor(rawRect.origin.y),
width: floor(rawRect.size.width),
height: floor(rawRect.size.height)
).insetBy(dx: -3, dy: 0)
UIBezierPath(roundedRect: rect, cornerRadius: Styles.Sizes.labelCornerRadius).fill()
}
}