Swiftgram/TelegramUI/TextNode.swift
2016-08-23 16:21:34 +03:00

294 lines
13 KiB
Swift

import Foundation
import AsyncDisplayKit
import Display
private let defaultFont = UIFont.systemFont(ofSize: 15.0)
private final class TextNodeLine {
let line: CTLine
let frame: CGRect
init(line: CTLine, frame: CGRect) {
self.line = line
self.frame = frame
}
}
enum TextNodeCutoutPosition {
case TopLeft
case TopRight
}
struct TextNodeCutout: Equatable {
let position: TextNodeCutoutPosition
let size: CGSize
}
func ==(lhs: TextNodeCutout, rhs: TextNodeCutout) -> Bool {
return lhs.position == rhs.position && lhs.size == rhs.size
}
final class TextNodeLayout: NSObject {
fileprivate let attributedString: NSAttributedString?
fileprivate let maximumNumberOfLines: Int
fileprivate let truncationType: CTLineTruncationType
fileprivate let backgroundColor: UIColor?
fileprivate let constrainedSize: CGSize
fileprivate let cutout: TextNodeCutout?
let size: CGSize
fileprivate let lines: [TextNodeLine]
fileprivate init(attributedString: NSAttributedString?, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, constrainedSize: CGSize, cutout: TextNodeCutout?, size: CGSize, lines: [TextNodeLine], backgroundColor: UIColor?) {
self.attributedString = attributedString
self.maximumNumberOfLines = maximumNumberOfLines
self.truncationType = truncationType
self.constrainedSize = constrainedSize
self.cutout = cutout
self.size = size
self.lines = lines
self.backgroundColor = backgroundColor
}
var numberOfLines: Int {
return self.lines.count
}
var trailingLineWidth: CGFloat {
if let lastLine = self.lines.last {
return lastLine.frame.width
} else {
return 0.0
}
}
}
final class TextNode: ASDisplayNode {
private var cachedLayout: TextNodeLayout?
override init() {
super.init()
self.backgroundColor = UIColor.clear
self.isOpaque = false
self.clipsToBounds = false
}
private class func calculateLayout(attributedString: NSAttributedString?, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, backgroundColor: UIColor?, constrainedSize: CGSize, cutout: TextNodeCutout?) -> TextNodeLayout {
if let attributedString = attributedString {
let font: CTFont
if attributedString.length != 0 {
if let stringFont = attributedString.attribute(kCTFontAttributeName as String, at: 0, effectiveRange: nil) {
font = stringFont as! CTFont
} else {
font = defaultFont
}
} else {
font = defaultFont
}
let fontAscent = CTFontGetAscent(font)
let fontDescent = CTFontGetDescent(font)
let fontLineHeight = floor(fontAscent + fontDescent)
let fontLineSpacing = floor(fontLineHeight * 0.12)
var lines: [TextNodeLine] = []
var maybeTypesetter: CTTypesetter?
maybeTypesetter = CTTypesetterCreateWithAttributedString(attributedString as CFAttributedString)
if maybeTypesetter == nil {
return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, cutout: cutout, size: CGSize(), lines: [], backgroundColor: backgroundColor)
}
let typesetter = maybeTypesetter!
var lastLineCharacterIndex: CFIndex = 0
var layoutSize = CGSize()
var cutoutEnabled = false
var cutoutMinY: CGFloat = 0.0
var cutoutMaxY: CGFloat = 0.0
var cutoutWidth: CGFloat = 0.0
var cutoutOffset: CGFloat = 0.0
if let cutout = cutout {
cutoutMinY = -fontLineSpacing
cutoutMaxY = cutout.size.height + fontLineSpacing
cutoutWidth = cutout.size.width
if case .TopLeft = cutout.position {
cutoutOffset = cutoutWidth
}
cutoutEnabled = true
}
var first = true
while true {
var lineConstrainedWidth = constrainedSize.width
var lineOriginY = floorToScreenPixels(layoutSize.height + fontLineHeight - fontLineSpacing * 2.0)
if !first {
lineOriginY += fontLineSpacing
}
var lineCutoutOffset: CGFloat = 0.0
var lineAdditionalWidth: CGFloat = 0.0
if cutoutEnabled {
if lineOriginY < cutoutMaxY && lineOriginY + fontLineHeight > cutoutMinY {
lineConstrainedWidth = max(1.0, lineConstrainedWidth - cutoutWidth)
lineCutoutOffset = cutoutOffset
lineAdditionalWidth = cutoutWidth
}
}
let lineCharacterCount = CTTypesetterSuggestLineBreak(typesetter, lastLineCharacterIndex, Double(lineConstrainedWidth))
if maximumNumberOfLines != 0 && lines.count == maximumNumberOfLines - 1 && lineCharacterCount > 0 {
if first {
first = false
} else {
layoutSize.height += fontLineSpacing
}
let coreTextLine: CTLine
let originalLine = CTTypesetterCreateLineWithOffset(typesetter, CFRange(location: lastLineCharacterIndex, length: attributedString.length - lastLineCharacterIndex), 0.0)
if CTLineGetTypographicBounds(originalLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(originalLine) < Double(constrainedSize.width) {
coreTextLine = originalLine
} else {
var truncationTokenAttributes: [String : AnyObject] = [:]
truncationTokenAttributes[kCTFontAttributeName as String] = font
truncationTokenAttributes[kCTForegroundColorFromContextAttributeName as String] = true as NSNumber
let tokenString = "\u{2026}"
let truncatedTokenString = NSAttributedString(string: tokenString, attributes: truncationTokenAttributes)
let truncationToken = CTLineCreateWithAttributedString(truncatedTokenString)
coreTextLine = CTLineCreateTruncatedLine(originalLine, Double(constrainedSize.width), truncationType, truncationToken) ?? truncationToken
}
let lineWidth = ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine)))
let lineFrame = CGRect(x: lineCutoutOffset, y: lineOriginY, width: lineWidth, height: fontLineHeight)
layoutSize.height += fontLineHeight + fontLineSpacing
layoutSize.width = max(layoutSize.width, lineWidth + lineAdditionalWidth)
lines.append(TextNodeLine(line: coreTextLine, frame: lineFrame))
break
} else {
if lineCharacterCount > 0 {
if first {
first = false
} else {
layoutSize.height += fontLineSpacing
}
let coreTextLine = CTTypesetterCreateLineWithOffset(typesetter, CFRangeMake(lastLineCharacterIndex, lineCharacterCount), 100.0)
lastLineCharacterIndex += lineCharacterCount
let lineWidth = ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine)))
let lineFrame = CGRect(x: lineCutoutOffset, y: lineOriginY, width: lineWidth, height: fontLineHeight)
layoutSize.height += fontLineHeight
layoutSize.width = max(layoutSize.width, lineWidth + lineAdditionalWidth)
lines.append(TextNodeLine(line: coreTextLine, frame: lineFrame))
} else {
if !lines.isEmpty {
layoutSize.height += fontLineSpacing
}
break
}
}
}
return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, cutout: cutout, size: CGSize(width: ceil(layoutSize.width), height: ceil(layoutSize.height)), lines: lines, backgroundColor: backgroundColor)
} else {
return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, cutout: cutout, size: CGSize(), lines: [], backgroundColor: backgroundColor)
}
}
override func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? {
return self.cachedLayout
}
@objc override class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol!, isCancelled: asdisplaynode_iscancelled_block_t, isRasterizing: Bool) {
let context = UIGraphicsGetCurrentContext()!
context.setAllowsAntialiasing(true)
context.setAllowsFontSmoothing(false)
context.setShouldSmoothFonts(false)
context.setAllowsFontSubpixelPositioning(false)
context.setShouldSubpixelPositionFonts(false)
context.setAllowsFontSubpixelQuantization(true)
context.setShouldSubpixelQuantizeFonts(true)
if let layout = parameters as? TextNodeLayout {
if !isRasterizing || layout.backgroundColor != nil {
context.setBlendMode(.copy)
context.setFillColor((layout.backgroundColor ?? UIColor.clear).cgColor)
context.fill(bounds)
}
let textMatrix = context.textMatrix
let textPosition = context.textPosition
//CGContextSaveGState(context)
context.textMatrix = CGAffineTransform(scaleX: 1.0, y: -1.0)
//let clipRect = CGContextGetClipBoundingBox(context)
for i in 0 ..< layout.lines.count {
let line = layout.lines[i]
context.textPosition = CGPoint(x: line.frame.origin.x, y: line.frame.origin.y)
CTLineDraw(line.line, context)
}
//CGContextRestoreGState(context)
context.textMatrix = textMatrix
context.textPosition = CGPoint(x: textPosition.x, y: textPosition.y)
}
context.setBlendMode(.normal)
}
class func asyncLayout(_ maybeNode: TextNode?) -> (_ attributedString: NSAttributedString?, _ backgroundColor: UIColor?, _ maximumNumberOfLines: Int, _ truncationType: CTLineTruncationType, _ constrainedSize: CGSize, _ cutout: TextNodeCutout?) -> (TextNodeLayout, () -> TextNode) {
let existingLayout: TextNodeLayout? = maybeNode?.cachedLayout
return { attributedString, backgroundColor, maximumNumberOfLines, truncationType, constrainedSize, cutout in
let layout: TextNodeLayout
var updated = false
if let existingLayout = existingLayout, existingLayout.constrainedSize == constrainedSize && existingLayout.maximumNumberOfLines == maximumNumberOfLines && existingLayout.truncationType == truncationType && existingLayout.cutout == cutout {
let stringMatch: Bool
if let existingString = existingLayout.attributedString, let string = attributedString {
stringMatch = existingString.isEqual(to: string)
} else if existingLayout.attributedString == nil && attributedString == nil {
stringMatch = true
} else {
stringMatch = false
}
if stringMatch {
layout = existingLayout
} else {
layout = TextNode.calculateLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, backgroundColor: backgroundColor, constrainedSize: constrainedSize, cutout: cutout)
updated = true
}
} else {
layout = TextNode.calculateLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, backgroundColor: backgroundColor, constrainedSize: constrainedSize, cutout: cutout)
updated = true
}
let node = maybeNode ?? TextNode()
return (layout, {
node.cachedLayout = layout
if updated {
node.setNeedsDisplay()
}
return node
})
}
}
}