mirror of
https://github.com/divkit/divkit.git
synced 2026-06-06 20:07:59 +00:00
f1563c9890
Initial commit Add notification for divkit Allow overwrite for migration [migration] browser/bitbucket/personal/booster/divkit Note: mandatory check (NEED_CHECK) was skipped DivKit files moved dIVKIT-0: Fix ts builder build update div-gallery visibility screenshots update div-gallery visibility screens a.yaml cleanup updated codegen scripts change gallery alignment change internal types in expressions removed copyrights remove permissions TemplatesSupportTests refactoring Ничего не поменялось. Отрефакторил и почистил тесты. Split package.json send all public removed yandex-team links URL added type as ArgumentType added camera permission description safe area insets for errors count view `EdgeInsets` for debug view with errors count. Needed for fullscreen divs. Naming corresponds to mapping `EdgeInsets` to div-variables. Will be used here: https://nda.ya.ru/t/Lhr5Lxgb5KN6nX fixed compiler warnings fix DivKitComponents variables setup, add update func fix DivKitComponents variables setup, add update func authors clean up Удалил коллег из инфраструктуры, тестирования и мессенджера. Половину людей я вообще тут не знаю. Список какой-то косячный, в нём, например, @booster и @askvortsov нет. Предлагаю заново вручную составить и записать туда тех, кто точно дивкитом занимался, а не просто мимо проходил. Fix DivKit pods compilability Fix DivKit pods compilability use only english in demo app translate divKit demo app russian text Scroll when keyboard opened Scroll when keyboard opened Fix gallery scroll in a11y Это копия пр-а, который я делал в репо алискита: https://bitbucket.browser.yandex-team.ru/projects/ML/repos/mobile-alice-library-android/pull-requests/18808/overview authors clean up2 SwiftGenerator flake8 errors fix & ya.make edit swift generation tests swift references swift generator implementation init swift generator code refactoring 1) rename to modeling 2) rename kind to PropertyType 3) delete apply_pluralization_rules 4) description default value is empty string 5) remove entity_type_field_alias, typename_alias, client_optional, profileEntities, ignore_properties, generate_plain_constructor 6) remove client prefix Add XcodeBuild in ignore Add XcodeBuild in ignore introduce new global variable api introduce new global variable api Revert commit bdcbc0081395ebe44ed7fe24bf5f4b25569c6df0 LayoutKitSnapshotTests Fix sandboxRelease dependencies. support zero value for width and height remove unused css variables "CONTRIBUTING.md" README draft updated gitignore/arcignore introduce color type in expressions introduce color type in expressions added xcconfig files fixed swift formatting fail tests if updating references Чтобы уж точно не забыть вернуть False. Layout changes Fix expression parsing don't change clickable state in a11y don't change clickable state in a11y Update items checks Parse description references to translations add licence file link fixed expression properties generation add ability to track requests to global variables Добавляем в GlobalVariableController возможность подписаться на запросы к глобальным переменным. Нужно дляОткрыт Накопительная палитра , а так же в целом полезная функциональность, через которую можно делать ленивый прогрев глобальных переменных Changelog Minor fixes bump divkit version up to 3.0.0 fixed snapshot tests depdendencies Иногда не запускалась сборка SnapshotTestsHostApp перед стартом тестов. used new api-generator deserialization refactoring removed gitignore samples text fixes fixed samples text remove theme button remove theme button demo activity redesign demo activity redesign habr sample app change deleted images in screenshot tests change deleted images in screenshot tests new ic_launcher new ic_launcher more readme Binding variable for div input Biding variable for div input Implement documentation generator Rename max_lines to max_visible_lines Rename max_lines to max_visible_lines add habr and medium fix div-functions doc Fix link in description Text selecting for demo app enabled Text selecting for demo app enabled KotlinGenerator kotlin code generation kotlin generation tests kotlin generation references add gradle wrapper to kotlin json builder add gradle wrapper to kotlin json builder DemoApp redesign fixed codegen scripts debugView safeAreaInsets DebugView safe area insets TypeScript Generator ya.make changes TypeScript generator TypeScript tests TypeScript test references fix enumeration aliases debugInfoParams fix compile Fix black box flicker in video-custom Fixed stub image showing by moving it away from PlayerView. Increased stub loading speed by adding VideoViewModel warmup. Fixed redundant redraws by combining stub visibility and bitmap data. PlayerView background was set to transparent. DivExpression fix DivExpression fix Implement translations Save & share bind globalVariableController to divContext bind variableController to divContext used new TypeScript generator Подключил генератор на питоне. Изменения в сгенерированных файлах связаны в основном с переводом описаний на английский язык. moved LivePreview Перенёс LivePreview из отдельного модуля в DivKitDemoApp. Добавил сканер кодов в UrlInputView. Удалил часть дублирующегося кода. remove sms and phone_state permission fix links interception fix links interception Fix link in documentation remove site from "config.stable.json" Support highlight_color and keyboardType in div input Support highlight_color in div input Action animation fix for iOS
1143 lines
33 KiB
Swift
1143 lines
33 KiB
Swift
// Copyright 2021 Yandex LLC. All rights reserved.
|
|
|
|
import CoreGraphics
|
|
import CoreText
|
|
import Foundation
|
|
|
|
import BaseTiny
|
|
|
|
extension NSAttributedString {
|
|
public enum VerticalPosition {
|
|
case top
|
|
case center
|
|
case bottom
|
|
}
|
|
|
|
public func sizeForWidth(_ width: CGFloat) -> CGSize {
|
|
sizeForString(self, CGSize(width: width, height: .infinity), Int.max)
|
|
}
|
|
|
|
public func heightForWidth(
|
|
_ width: CGFloat,
|
|
maxNumberOfLines: Int,
|
|
minNumberOfHiddenLines: Int = 0
|
|
) -> CGFloat {
|
|
let maxTextSize = CGSize(width: width, height: .infinity)
|
|
if minNumberOfHiddenLines > 0 {
|
|
let layout = computeLayout(
|
|
for: self,
|
|
maxTextSize: maxTextSize,
|
|
maxNumberOfLines: Int.max
|
|
)
|
|
if layout.lines.count < maxNumberOfLines + minNumberOfHiddenLines {
|
|
return layout.height
|
|
}
|
|
}
|
|
|
|
return sizeForString(self, maxTextSize, maxNumberOfLines).height
|
|
}
|
|
|
|
public func heightForWidth(_ width: CGFloat, maxTextHeight: CGFloat) -> CGFloat {
|
|
sizeForString(self, CGSize(width: width, height: maxTextHeight), Int.max).height
|
|
}
|
|
|
|
public func sizeThatFits(_ size: CGSize, maxNumberOfLines: Int) -> CGSize {
|
|
sizeForString(self, size, maxNumberOfLines)
|
|
}
|
|
}
|
|
|
|
public func measureString(
|
|
_ string: NSAttributedString,
|
|
maxTextSize: CGSize,
|
|
maxNumberOfLines: Int = .max
|
|
) -> (size: CGSize, numberOfLines: Int) {
|
|
let linesConstraint = (maxNumberOfLines > 0) ? maxNumberOfLines : .max
|
|
let layout = computeLayout(
|
|
for: string,
|
|
maxTextSize: maxTextSize,
|
|
maxNumberOfLines: linesConstraint
|
|
)
|
|
return (layout.size, layout.lines.count)
|
|
}
|
|
|
|
private let sizeForString =
|
|
memoizeAClass { (string: NSAttributedString, maxTextSize: CGSize, maxNumberOfLines: Int) -> CGSize in
|
|
let layout = computeLayout(
|
|
for: string,
|
|
maxTextSize: maxTextSize,
|
|
maxNumberOfLines: maxNumberOfLines
|
|
)
|
|
return layout.size
|
|
}
|
|
|
|
private func computeLayout(
|
|
for string: NSAttributedString,
|
|
maxTextSize: CGSize,
|
|
maxNumberOfLines: Int
|
|
) -> TextLayout {
|
|
if string.containsSoftHyphens {
|
|
let layout = string
|
|
.makeCopyWithoutSoftHyphens()
|
|
.makeTextLayout(
|
|
in: maxTextSize,
|
|
maxNumberOfLines: maxNumberOfLines,
|
|
breakWords: false,
|
|
truncationToken: nil
|
|
)
|
|
if layout.entireTextFits(maxTextSize) {
|
|
return layout
|
|
}
|
|
}
|
|
return string.makeTextLayout(
|
|
in: maxTextSize,
|
|
maxNumberOfLines: maxNumberOfLines,
|
|
breakWords: true,
|
|
truncationToken: nil
|
|
)
|
|
}
|
|
|
|
struct TypographicBounds {
|
|
var ascent: CGFloat
|
|
var descent: CGFloat
|
|
let width: CGFloat
|
|
|
|
static let `default` = TypographicBounds(
|
|
ascent: Font.systemFontWithDefaultSize().ascender,
|
|
descent: abs(Font.systemFontWithDefaultSize().descender),
|
|
width: 0
|
|
)
|
|
|
|
var height: CGFloat {
|
|
ascent + descent
|
|
}
|
|
|
|
func constrained(width maxWidth: CGFloat) -> TypographicBounds {
|
|
TypographicBounds(
|
|
ascent: ascent,
|
|
descent: descent,
|
|
width: min(width, maxWidth)
|
|
)
|
|
}
|
|
}
|
|
|
|
typealias LineLayout = (line: CTLine, bounds: TypographicBounds, range: NSRange)
|
|
|
|
struct TextLayout {
|
|
var lines: [LineLayout]
|
|
private var sourceLength: Int
|
|
|
|
var width: CGFloat {
|
|
lines.map { $0.bounds.width }.max() ?? 0
|
|
}
|
|
|
|
var height: CGFloat {
|
|
lines.reduce(0) { $0 + $1.bounds.height }
|
|
}
|
|
|
|
var size: CGSize {
|
|
CGSize(width: width, height: height).ceiled()
|
|
}
|
|
|
|
var range: NSRange {
|
|
let location = lines.first?.range.location ?? 0
|
|
return NSRange(location: location, length: textLength)
|
|
}
|
|
|
|
var textLength: Int {
|
|
lines.reduce(0) { $0 + $1.range.length }
|
|
}
|
|
|
|
init(lines: [LineLayout], sourceLength: Int) {
|
|
self.lines = lines
|
|
self.sourceLength = sourceLength
|
|
}
|
|
|
|
func entireTextFits(_ size: CGSize) -> Bool {
|
|
sourceLength == textLength &&
|
|
width <= size.width &&
|
|
height <= size.height
|
|
}
|
|
}
|
|
|
|
private let ellipsis = "\u{2026}"
|
|
private let softHyphen: UTF16.CodeUnit = 0x00_AD
|
|
private let lineFeed: UTF16.CodeUnit = 0x00_0A
|
|
private let carriageReturn: UTF16.CodeUnit = 0x00_0D
|
|
|
|
extension NSAttributedString {
|
|
var containsSoftHyphens: Bool {
|
|
string.utf16.contains(softHyphen)
|
|
}
|
|
|
|
func makeTextLayout(
|
|
in size: CGSize,
|
|
maxNumberOfLines: Int = .max,
|
|
breakWords: Bool,
|
|
truncationToken: NSAttributedString? = nil
|
|
) -> TextLayout {
|
|
let typesetter = CTTypesetterCreateWithAttributedString(self)
|
|
var offset = 0
|
|
var lines = [LineLayout]()
|
|
var textHeight: CGFloat = 0
|
|
|
|
while offset < length, lines.count < maxNumberOfLines {
|
|
let lineLength = breakWords ?
|
|
typesetter.lineLength(from: offset, constrainedTo: size.width) :
|
|
typesetter.lineLengthByWordBoundary(for: string, from: offset, constrainedTo: size.width)
|
|
|
|
guard lineLength > 0 else {
|
|
break
|
|
}
|
|
|
|
let suggestedRange = CFRange(location: offset, length: lineLength)
|
|
let layout: LineLayout
|
|
|
|
if subrangeEndsWithSoftHyphen(suggestedRange.nsRange) {
|
|
assert(breakWords)
|
|
layout = layoutHyphenatedLine(
|
|
typesetter: typesetter,
|
|
range: suggestedRange,
|
|
availableWidth: size.width
|
|
)
|
|
} else {
|
|
layout = layoutLine(
|
|
typesetter: typesetter,
|
|
range: suggestedRange
|
|
)
|
|
}
|
|
|
|
textHeight += layout.bounds.height
|
|
let fitsByHeight = textHeight.isApproximatelyLessOrEqualThan(size.height)
|
|
let nextLineFitsByLineNumber = (lines.count < maxNumberOfLines - 1 && !singleLineBreakMode) ||
|
|
suggestedRange.nsRange.endIndex == length
|
|
|
|
if fitsByHeight, nextLineFitsByLineNumber {
|
|
lines.append(layout)
|
|
offset += layout.range.length
|
|
} else if !nextLineFitsByLineNumber {
|
|
if let lastLine = makeTruncatedLine(
|
|
from: suggestedRange.nsRange,
|
|
constrainedTo: size.width,
|
|
truncationToken: truncationToken
|
|
) {
|
|
lines.append((lastLine, lastLine.typographicBounds, suggestedRange.nsRange))
|
|
}
|
|
break
|
|
} else {
|
|
let wholeRange = NSRange(location: 0, length: length)
|
|
let remainingRange = lines.last?.range ?? wholeRange
|
|
if let lastLine = makeTruncatedLine(
|
|
from: remainingRange,
|
|
constrainedTo: size.width,
|
|
truncationToken: truncationToken
|
|
) {
|
|
lines.removeLastIfExists()
|
|
lines.append((lastLine, lastLine.typographicBounds, suggestedRange.nsRange))
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
return TextLayout(lines: lines, sourceLength: length)
|
|
}
|
|
|
|
private func layoutHyphenatedLine(
|
|
typesetter: CTTypesetter,
|
|
range: CFRange,
|
|
availableWidth: CGFloat
|
|
) -> LineLayout {
|
|
let line = makeHyphenatedLine(from: range.nsRange)
|
|
let extent = line.typographicBounds.width - availableWidth
|
|
if extent > 0 {
|
|
let correctedLength = typesetter.lineLength(
|
|
from: range.location,
|
|
constrainedTo: availableWidth - extent
|
|
)
|
|
if correctedLength > 0 {
|
|
let correctedRange = CFRange(location: range.location, length: correctedLength)
|
|
if subrangeEndsWithSoftHyphen(correctedRange.nsRange) {
|
|
let correctedLine = makeHyphenatedLine(from: correctedRange.nsRange)
|
|
return (correctedLine, correctedLine.typographicBounds, correctedRange.nsRange)
|
|
} else {
|
|
return layoutLine(
|
|
typesetter: typesetter,
|
|
range: correctedRange
|
|
)
|
|
}
|
|
}
|
|
}
|
|
return (line, line.typographicBounds, range.nsRange)
|
|
}
|
|
|
|
private func layoutLine(
|
|
typesetter: CTTypesetter,
|
|
range: CFRange
|
|
) -> LineLayout {
|
|
let lineLength = range.length - trailingSymbolsTrimCount(range.nsRange)
|
|
let line = CTTypesetterCreateLine(
|
|
typesetter,
|
|
CFRange(location: range.location, length: lineLength)
|
|
)
|
|
return (line, line.typographicBounds, range.nsRange)
|
|
}
|
|
|
|
private func makeHyphenatedLine(from range: NSRange) -> CTLine {
|
|
let copyString = attributedSubstring(from: range).mutableCopy() as! NSMutableAttributedString
|
|
copyString.appendWithPreservedAttributes("-")
|
|
return CTLineCreateWithAttributedString(copyString)
|
|
}
|
|
|
|
public func draw(
|
|
inContext context: CGContext,
|
|
verticalPosition: VerticalPosition = .center,
|
|
rect: CGRect
|
|
) {
|
|
_ = drawAndGetLayout(
|
|
inContext: context,
|
|
verticalPosition: verticalPosition,
|
|
rect: rect,
|
|
actionKey: nil,
|
|
selectedRange: nil
|
|
) as AttributedStringLayout<Void>
|
|
}
|
|
|
|
public func drawAndGetLayout(
|
|
inContext context: CGContext?,
|
|
verticalPosition: VerticalPosition = .center,
|
|
rect: CGRect,
|
|
truncationToken: NSAttributedString? = nil
|
|
) -> AttributedStringLayout<Void> {
|
|
drawAndGetLayout(
|
|
inContext: context,
|
|
verticalPosition: verticalPosition,
|
|
rect: rect,
|
|
truncationToken: truncationToken,
|
|
actionKey: nil,
|
|
selectedRange: nil
|
|
)
|
|
}
|
|
|
|
public func drawAndGetLayout<ActionType>(
|
|
inContext context: CGContext?,
|
|
verticalPosition: VerticalPosition = .center,
|
|
rect: CGRect,
|
|
truncationToken: NSAttributedString? = nil,
|
|
actionKey: NSAttributedString.Key?,
|
|
selectedRange: Range<Int>?
|
|
) -> AttributedStringLayout<ActionType> {
|
|
context?.saveGState()
|
|
defer {
|
|
context?.restoreGState()
|
|
}
|
|
|
|
let transform = CGAffineTransform(translationX: 0, y: rect.height)
|
|
.scaledBy(x: 1, y: -1)
|
|
|
|
context?.concatenate(transform)
|
|
context?.textMatrix = CGAffineTransform.identity
|
|
let transformedRect = rect.applying(transform)
|
|
|
|
if containsSoftHyphens {
|
|
let copy = makeCopyWithoutSoftHyphens()
|
|
let layout = copy.makeTextLayout(
|
|
in: rect.size,
|
|
breakWords: false,
|
|
truncationToken: truncationToken
|
|
)
|
|
if layout.entireTextFits(rect.size) {
|
|
return copy.drawAndGetLayoutImpl(
|
|
inContext: context,
|
|
verticalPosition: verticalPosition,
|
|
rect: transformedRect,
|
|
truncationToken: truncationToken,
|
|
actionKey: actionKey,
|
|
selectedRange: selectedRange
|
|
)
|
|
}
|
|
}
|
|
return drawAndGetLayoutImpl(
|
|
inContext: context,
|
|
verticalPosition: verticalPosition,
|
|
rect: transformedRect,
|
|
truncationToken: truncationToken,
|
|
actionKey: actionKey,
|
|
selectedRange: selectedRange
|
|
)
|
|
}
|
|
|
|
private func drawAndGetLayoutImpl<ActionType>(
|
|
inContext context: CGContext?,
|
|
verticalPosition: VerticalPosition,
|
|
rect: CGRect,
|
|
truncationToken: NSAttributedString?,
|
|
actionKey: NSAttributedString.Key?,
|
|
selectedRange _: Range<Int>?
|
|
) -> AttributedStringLayout<ActionType> {
|
|
let layout = makeTextLayout(
|
|
in: rect.size,
|
|
breakWords: true,
|
|
truncationToken: truncationToken
|
|
)
|
|
var runsLayout = [AttributedStringLayout<ActionType>.Run]()
|
|
|
|
assert(
|
|
// accuracy is needed because sometimes text is a bit higher,
|
|
// but it's not noticeable in rendering and doesn't affect vertical position
|
|
layout.lines.count == 1 ||
|
|
rect.height.isApproximatelyGreaterOrEqualThan(layout.height, withAccuracy: 0.5),
|
|
"Layout constrained to some height should not exceed it"
|
|
)
|
|
let maybeNegativeOffset = verticalPosition.verticalOffset(
|
|
forHeight: layout.height,
|
|
availableHeight: rect.height
|
|
)
|
|
let verticalOffset = max(maybeNegativeOffset, 0)
|
|
var lineOriginY: CGFloat = rect.height - verticalOffset
|
|
|
|
var firstLineOriginX: CGFloat?
|
|
|
|
var lines: [AttributedStringLineLayout] = []
|
|
for (line, bounds, range) in layout.lines {
|
|
let lineOriginX = horizontalOffset(
|
|
lineWidth: bounds.width,
|
|
maxWidth: rect.width
|
|
)
|
|
if firstLineOriginX == nil {
|
|
firstLineOriginX = lineOriginX + rect.origin.x
|
|
}
|
|
let textPosition = CGPoint(
|
|
x: rect.origin.x + lineOriginX,
|
|
y: rect.origin.y + lineOriginY - bounds.ascent
|
|
)
|
|
|
|
if let context = context {
|
|
let lineRunsLayout = line.draw(
|
|
at: textPosition,
|
|
in: context,
|
|
layoutY: rect.maxY - lineOriginY,
|
|
actionKey: actionKey
|
|
) as [AttributedStringLayout<ActionType>.Run]
|
|
runsLayout += lineRunsLayout
|
|
} else {
|
|
break
|
|
}
|
|
|
|
lines.append(AttributedStringLineLayout(
|
|
line: line,
|
|
verticalOffset: lineOriginY,
|
|
horizontalOffset: lineOriginX,
|
|
range: range
|
|
))
|
|
lineOriginY -= bounds.height
|
|
}
|
|
|
|
return AttributedStringLayout(
|
|
firstLineOriginX: firstLineOriginX,
|
|
runsWithAction: runsLayout,
|
|
lines: lines
|
|
)
|
|
}
|
|
|
|
public func drawSelection(
|
|
context: CGContext?,
|
|
rect: CGRect,
|
|
linesLayout: [AttributedStringLineLayout],
|
|
selectedRange: Range<Int>?
|
|
) -> CGRect {
|
|
context?.saveGState()
|
|
defer {
|
|
context?.restoreGState()
|
|
}
|
|
|
|
let transform = CGAffineTransform(translationX: 0, y: rect.height)
|
|
.scaledBy(x: 1, y: -1)
|
|
|
|
context?.concatenate(transform)
|
|
context?.textMatrix = CGAffineTransform.identity
|
|
let transformedRect = rect.applying(transform)
|
|
var selectionRect = CGRect.zero
|
|
for lineLayout in linesLayout {
|
|
drawLineSelection(
|
|
context: context,
|
|
selectionRect: &selectionRect,
|
|
selectedRange: selectedRange,
|
|
rect: transformedRect,
|
|
line: lineLayout.line,
|
|
lineHeight: lineLayout.line.typographicBounds.height,
|
|
range: lineLayout.range,
|
|
lineOriginX: lineLayout.horizontalOffset,
|
|
lineOriginY: lineLayout.verticalOffset
|
|
)
|
|
}
|
|
return selectionRect
|
|
}
|
|
|
|
private func drawLineSelection(
|
|
context: CGContext?,
|
|
selectionRect: inout CGRect,
|
|
selectedRange: Range<Int>?,
|
|
rect: CGRect,
|
|
line: CTLine,
|
|
lineHeight: CGFloat,
|
|
range: NSRange,
|
|
lineOriginX: CGFloat,
|
|
lineOriginY: CGFloat
|
|
) {
|
|
guard let leadingSelectionIndex = selectedRange?.lowerBound,
|
|
let trailingSelectionIndex = selectedRange?.upperBound else {
|
|
return
|
|
}
|
|
|
|
let needDrawLeadingPointer: Bool
|
|
let needDrawTrailingPointer: Bool
|
|
|
|
let leadingSelectionOffset: CGFloat?
|
|
let trailingSelectionOffset: CGFloat?
|
|
|
|
if (range.lowerBound...range.upperBound).contains(leadingSelectionIndex),
|
|
(range.lowerBound...range.upperBound).contains(trailingSelectionIndex) {
|
|
let trailingOffset = CTLineGetOffsetForStringIndex(line, trailingSelectionIndex, nil)
|
|
let leadingOffset = CTLineGetOffsetForStringIndex(line, leadingSelectionIndex, nil)
|
|
needDrawLeadingPointer = true
|
|
needDrawTrailingPointer = true
|
|
leadingSelectionOffset = leadingOffset
|
|
trailingSelectionOffset = trailingOffset
|
|
selectionRect = CGRect(
|
|
origin: CGPoint(x: lineOriginX + leadingOffset, y: rect.height - lineOriginY),
|
|
size: CGSize(width: trailingOffset - leadingOffset, height: lineHeight)
|
|
)
|
|
} else if (range.lowerBound..<range.upperBound).contains(leadingSelectionIndex) {
|
|
let leadingOffset = CTLineGetOffsetForStringIndex(line, leadingSelectionIndex, nil)
|
|
let trailingOffset = CTLineGetOffsetForStringIndex(line, range.upperBound - 1, nil)
|
|
needDrawLeadingPointer = true
|
|
needDrawTrailingPointer = false
|
|
leadingSelectionOffset = leadingOffset
|
|
trailingSelectionOffset = trailingOffset
|
|
selectionRect.origin = CGPoint(x: lineOriginX, y: rect.height - lineOriginY)
|
|
} else if ((range.lowerBound + 1)...range.upperBound).contains(trailingSelectionIndex) {
|
|
let leadingOffset = CTLineGetOffsetForStringIndex(line, range.lowerBound, nil)
|
|
let trailingOffset = CTLineGetOffsetForStringIndex(line, trailingSelectionIndex, nil)
|
|
needDrawLeadingPointer = false
|
|
needDrawTrailingPointer = true
|
|
leadingSelectionOffset = leadingOffset
|
|
trailingSelectionOffset = trailingOffset
|
|
selectionRect.size = CGSize(
|
|
width: CTLineGetOffsetForStringIndex(line, range.upperBound - 1, nil) - selectionRect
|
|
.origin.x,
|
|
height: abs((rect.height - lineOriginY) - selectionRect.origin.y + lineHeight)
|
|
)
|
|
} else if leadingSelectionIndex < range.lowerBound,
|
|
trailingSelectionIndex >= range.lowerBound {
|
|
let leadingOffset = CTLineGetOffsetForStringIndex(line, range.lowerBound, nil)
|
|
let trailingOffset = CTLineGetOffsetForStringIndex(line, range.upperBound - 1, nil)
|
|
needDrawLeadingPointer = false
|
|
needDrawTrailingPointer = false
|
|
leadingSelectionOffset = leadingOffset
|
|
trailingSelectionOffset = trailingOffset
|
|
} else {
|
|
leadingSelectionOffset = nil
|
|
trailingSelectionOffset = nil
|
|
needDrawLeadingPointer = false
|
|
needDrawTrailingPointer = false
|
|
}
|
|
|
|
guard let leadingSelectionOffset = leadingSelectionOffset,
|
|
let trailingSelectionOffset = trailingSelectionOffset else {
|
|
return
|
|
}
|
|
|
|
let leftmostTextSelectionPoint = CGPoint(
|
|
x: rect.origin.x + lineOriginX + leadingSelectionOffset,
|
|
y: rect.origin.y + lineOriginY - lineHeight
|
|
)
|
|
|
|
context?.setFillColor(selectionColor.cgColor)
|
|
context?.fill(CGRect(
|
|
origin: leftmostTextSelectionPoint,
|
|
size: CGSize(
|
|
width: trailingSelectionOffset - leadingSelectionOffset,
|
|
height: lineHeight
|
|
)
|
|
))
|
|
|
|
context?.setFillColor(selectionPointerColor.cgColor)
|
|
|
|
if needDrawLeadingPointer {
|
|
drawLeadingPointer(
|
|
context: context,
|
|
leftmostTextSelectionPoint: leftmostTextSelectionPoint,
|
|
lineHeight: lineHeight
|
|
)
|
|
}
|
|
|
|
if needDrawTrailingPointer {
|
|
drawTrailingPointer(
|
|
context: context,
|
|
rightmostTextSelectionPoint: leftmostTextSelectionPoint
|
|
.movingX(by: -leadingSelectionOffset + trailingSelectionOffset),
|
|
lineHeight: lineHeight
|
|
)
|
|
}
|
|
}
|
|
|
|
private func drawTrailingPointer(
|
|
context: CGContext?,
|
|
rightmostTextSelectionPoint: CGPoint,
|
|
lineHeight: CGFloat
|
|
) {
|
|
context?.fill(CGRect(
|
|
origin: rightmostTextSelectionPoint,
|
|
size: CGSize(
|
|
width: pointerShapeWidth,
|
|
height: lineHeight
|
|
)
|
|
))
|
|
|
|
context?.fillEllipse(in: CGRect(
|
|
origin: rightmostTextSelectionPoint
|
|
.movingX(by: -pointerCircleSize.width / 2 + pointerShapeWidth / 2),
|
|
size: pointerCircleSize
|
|
))
|
|
}
|
|
|
|
private func drawLeadingPointer(
|
|
context: CGContext?,
|
|
leftmostTextSelectionPoint: CGPoint,
|
|
lineHeight: CGFloat
|
|
) {
|
|
context?.fill(CGRect(
|
|
origin: leftmostTextSelectionPoint.movingX(by: -pointerShapeWidth),
|
|
size: CGSize(
|
|
width: pointerShapeWidth,
|
|
height: lineHeight
|
|
)
|
|
))
|
|
|
|
context?.fillEllipse(in: CGRect(
|
|
origin: leftmostTextSelectionPoint
|
|
.movingX(by: -pointerCircleSize.width / 2 - pointerShapeWidth / 2).movingY(by: lineHeight),
|
|
size: pointerCircleSize
|
|
))
|
|
}
|
|
|
|
private func horizontalOffset(lineWidth: CGFloat, maxWidth: CGFloat) -> CGFloat {
|
|
switch textAlignment {
|
|
case .left, .natural, .justified:
|
|
return 0
|
|
case .center:
|
|
return max(((maxWidth - lineWidth) / 2).roundedToScreenScale, 0)
|
|
case .right:
|
|
return max(maxWidth - lineWidth, 0)
|
|
@unknown default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
private func subrangeEndsWithSoftHyphen(_ range: NSRange) -> Bool {
|
|
let index = string.utf16.index(
|
|
string.utf16.startIndex,
|
|
offsetBy: range.location + range.length - 1
|
|
)
|
|
return string.utf16[index] == softHyphen
|
|
}
|
|
|
|
private func trailingSymbolsTrimCount(_ range: NSRange) -> Int {
|
|
guard range.length > 1 else {
|
|
return 0
|
|
}
|
|
let utf16 = string.utf16
|
|
let lastSymbolIndex = utf16.index(
|
|
utf16.startIndex,
|
|
offsetBy: range.endIndex - 1
|
|
)
|
|
let lastSymbol = utf16[lastSymbolIndex]
|
|
if lastSymbol == lineFeed, range.length > 2 {
|
|
let preLastIndex = utf16.index(before: lastSymbolIndex)
|
|
return utf16[preLastIndex] == carriageReturn ? 2 : 1
|
|
}
|
|
return lastSymbol.isWhitespaceOrNewline ? 1 : 0
|
|
}
|
|
|
|
private func makeTruncatedLine(
|
|
from range: NSRange,
|
|
constrainedTo width: CGFloat,
|
|
truncationToken: NSAttributedString?
|
|
) -> CTLine? {
|
|
guard range.length > 0 else {
|
|
return nil
|
|
}
|
|
|
|
let token: NSAttributedString
|
|
if lineBreakMode == .byClipping {
|
|
token = NSAttributedString()
|
|
} else if let truncationToken = truncationToken {
|
|
token = truncationToken
|
|
} else {
|
|
let allAttributes = attributes(at: range.endIndex - 1, effectiveRange: nil)
|
|
token = NSAttributedString(string: ellipsis, attributes: allAttributes)
|
|
}
|
|
|
|
let remainingString: NSAttributedString
|
|
let lineTerminatesDueToNewlineSymbol = string.utf16.prefix(range.endIndex).last!.isNewline
|
|
if lineTerminatesDueToNewlineSymbol {
|
|
let line = attributedSubstring(from: range)
|
|
if line.truncationType == CTLineTruncationType.end {
|
|
remainingString = line + token
|
|
} else {
|
|
remainingString = line
|
|
}
|
|
} else {
|
|
remainingString = attributedSubstring(
|
|
from: NSRange(location: range.location, length: length - range.location)
|
|
)
|
|
}
|
|
|
|
let remainingLine = CTLineCreateWithAttributedString(remainingString)
|
|
let tokenLine = CTLineCreateWithAttributedString(token)
|
|
guard let truncatedLine = CTLineCreateTruncatedLine(
|
|
remainingLine,
|
|
Double(width),
|
|
remainingString.truncationType,
|
|
tokenLine
|
|
) else {
|
|
return nil
|
|
}
|
|
|
|
let overrun = truncatedLine.typographicBounds.width - width
|
|
if overrun.isApproximatelyGreaterThan(0) {
|
|
return CTLineCreateTruncatedLine(
|
|
remainingLine,
|
|
Double(width - overrun),
|
|
remainingString.truncationType,
|
|
tokenLine
|
|
)
|
|
} else {
|
|
return truncatedLine
|
|
}
|
|
}
|
|
|
|
private var lineBreakMode: LineBreakMode {
|
|
guard length > 0 else {
|
|
return .byWordWrapping
|
|
}
|
|
let paragraphStyle = attribute(
|
|
.paragraphStyle,
|
|
at: length - 1,
|
|
effectiveRange: nil
|
|
) as? NSParagraphStyle
|
|
return paragraphStyle?.lineBreakMode ?? .byWordWrapping
|
|
}
|
|
|
|
private var truncationType: CTLineTruncationType {
|
|
switch lineBreakMode {
|
|
case .byTruncatingHead:
|
|
return .start
|
|
case .byTruncatingMiddle:
|
|
return .middle
|
|
default:
|
|
return .end
|
|
}
|
|
}
|
|
|
|
private var singleLineBreakMode: Bool {
|
|
switch lineBreakMode {
|
|
case .byTruncatingHead, .byTruncatingMiddle:
|
|
return true
|
|
case .byClipping, .byCharWrapping, .byWordWrapping, .byTruncatingTail:
|
|
return false
|
|
@unknown default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private var textAlignment: TextAlignment {
|
|
let paragraphStyle = attribute(
|
|
.paragraphStyle,
|
|
at: 0,
|
|
effectiveRange: nil
|
|
) as? NSParagraphStyle
|
|
return paragraphStyle?.alignment ?? .natural
|
|
}
|
|
|
|
func makeCopyWithoutSoftHyphens() -> NSAttributedString {
|
|
let copy = mutableCopy() as! NSMutableAttributedString
|
|
let hyphenOffsets = copy.string.utf16
|
|
.enumerated()
|
|
.filter { $0.element == softHyphen }
|
|
.map { $0.offset }
|
|
|
|
var deleteCount = 0
|
|
|
|
for offset in hyphenOffsets {
|
|
copy.deleteCharacters(in: NSRange(location: offset - deleteCount, length: 1))
|
|
deleteCount += 1
|
|
}
|
|
|
|
return copy
|
|
}
|
|
}
|
|
|
|
extension CTTypesetter {
|
|
fileprivate func lineLength(from offset: Int, constrainedTo width: CGFloat) -> CFIndex {
|
|
CTTypesetterSuggestLineBreak(self, offset, Double(width))
|
|
}
|
|
|
|
fileprivate func lineLengthByWordBoundary(
|
|
for string: String,
|
|
from offset: Int,
|
|
constrainedTo width: CGFloat
|
|
) -> Int {
|
|
let lineLength = self.lineLength(from: offset, constrainedTo: width)
|
|
|
|
if lineLength + offset == string.utf16.count {
|
|
return lineLength
|
|
}
|
|
|
|
if string.utf16.prefix(offset + lineLength).last?.isWhitespaceOrNewline == true {
|
|
return lineLength
|
|
}
|
|
|
|
let whitespaceOffsetFromEnd = string.utf16
|
|
.dropFirst(offset)
|
|
.prefix(lineLength)
|
|
.reversed()
|
|
.enumerated()
|
|
.first(where: { _, unit in unit.isWhitespace })?.offset
|
|
|
|
if let whitespaceOffsetFromEnd = whitespaceOffsetFromEnd {
|
|
return lineLength - whitespaceOffsetFromEnd - 1
|
|
}
|
|
|
|
return 0
|
|
}
|
|
}
|
|
|
|
extension CTLine {
|
|
fileprivate var runs: [CTRun] {
|
|
CTLineGetGlyphRuns(self) as! [CTRun]
|
|
}
|
|
|
|
private var typographicBoundsNotConsideringEmojiHeight: TypographicBounds {
|
|
let runsBounds: [TypographicBounds] = runs.map {
|
|
let bounds = $0.typographicBounds
|
|
let font = $0.font
|
|
if font.fontName != Font.emojiFontName {
|
|
return bounds
|
|
}
|
|
let systemFont = Font.systemFont(ofSize: font.pointSize)
|
|
return modified(bounds) {
|
|
$0.ascent = systemFont.ascender
|
|
$0.descent = abs(systemFont.descender)
|
|
}
|
|
}
|
|
|
|
guard !runsBounds.isEmpty else {
|
|
return .default
|
|
}
|
|
|
|
return TypographicBounds(
|
|
ascent: runsBounds.map { $0.ascent }.max()!,
|
|
descent: runsBounds.map { $0.descent }.max()!,
|
|
width: runsBounds.map { $0.width }.reduce(0, +)
|
|
)
|
|
}
|
|
|
|
fileprivate var typographicBounds: TypographicBounds {
|
|
var bounds = typographicBoundsNotConsideringEmojiHeight
|
|
|
|
let height = bounds.height
|
|
let (minHeight, maxHeight) = overriddenHeight
|
|
|
|
let offset = max((minHeight - height) / 2, 0) + min((maxHeight - height) / 2, 0)
|
|
bounds.ascent += offset
|
|
bounds.descent += offset
|
|
|
|
let descentAddition = 1 - modf(bounds.descent).1
|
|
if descentAddition < 0.5 {
|
|
bounds.ascent -= descentAddition
|
|
bounds.descent += descentAddition
|
|
}
|
|
|
|
return bounds
|
|
}
|
|
|
|
private var overriddenHeight: (min: CGFloat, max: CGFloat) {
|
|
var minHeight: CGFloat = 0
|
|
var maxHeight: CGFloat = 0
|
|
for run in runs {
|
|
let style = run.paragraphStyle
|
|
minHeight = max(minHeight, style?.minimumLineHeight ?? 0)
|
|
maxHeight = max(maxHeight, style?.maximumLineHeight ?? 0)
|
|
}
|
|
if maxHeight == 0 {
|
|
maxHeight = .greatestFiniteMagnitude
|
|
}
|
|
return (minHeight, maxHeight)
|
|
}
|
|
|
|
fileprivate func draw<ActionType>(
|
|
at position: CGPoint,
|
|
in context: CGContext,
|
|
layoutY: CGFloat,
|
|
actionKey: NSAttributedString.Key?
|
|
) -> [AttributedStringLayout<ActionType>.Run] {
|
|
var runsWithActions = [AttributedStringLayout<ActionType>.Run]()
|
|
|
|
for run in runs {
|
|
let runPosition = CGPoint(x: position.x + run.origin.x, y: position.y)
|
|
if let action = (actionKey.flatMap(run.action) as ActionType?) {
|
|
let bounds = run.typographicBounds
|
|
runsWithActions.append(
|
|
AttributedStringLayout<ActionType>.Run(
|
|
rect: CGRect(
|
|
x: runPosition.x,
|
|
y: layoutY,
|
|
width: bounds.width,
|
|
height: bounds.height
|
|
),
|
|
action: action
|
|
)
|
|
)
|
|
}
|
|
|
|
context.textPosition = position
|
|
context.inSeparateGState {
|
|
context.performDrawing(shadedWith: run.shadow) {
|
|
CTRunDraw(run, context, .infinite)
|
|
if #available(iOS 15, *) {} else if run.isSingleStrikethrough {
|
|
drawStrikethrough(for: run, at: runPosition, in: context)
|
|
}
|
|
}
|
|
}
|
|
|
|
if let attachment = run.attachment,
|
|
let image = attachment.image {
|
|
context.saveGState()
|
|
let imagePosition = position + run.origin + attachment.bounds.origin
|
|
let positionTransform = CGAffineTransform(
|
|
translationX: imagePosition.x,
|
|
y: imagePosition.y
|
|
)
|
|
let transform = image.orientationTransform.concatenating(positionTransform)
|
|
context.concatenate(transform)
|
|
|
|
context.draw(image.cgImg!, in: CGRect(origin: .zero, size: attachment.bounds.size))
|
|
context.restoreGState()
|
|
}
|
|
}
|
|
|
|
return runsWithActions
|
|
}
|
|
|
|
private func drawStrikethrough(for run: CTRun, at position: CGPoint, in context: CGContext) {
|
|
context.saveGState()
|
|
context.setStrokeColor(run.color)
|
|
context.setLineWidth(run.font.estimatedStrikethroughWidth)
|
|
context.addPath(run.strikethroughLine(forTextPosition: position))
|
|
context.strokePath()
|
|
context.restoreGState()
|
|
}
|
|
}
|
|
|
|
extension CTRun {
|
|
private func attribute<T>(withName name: Any) -> T? {
|
|
let runAttributes = CTRunGetAttributes(self) as NSDictionary
|
|
return runAttributes[name].flatMap { $0 as? T }
|
|
}
|
|
|
|
private func attribute<T>(withName name: NSAttributedString.Key) -> T? {
|
|
attribute(withName: name as Any)
|
|
}
|
|
|
|
fileprivate var typographicBounds: TypographicBounds {
|
|
var ascent: CGFloat = 0
|
|
var descent: CGFloat = 0
|
|
let width = CGFloat(CTRunGetTypographicBounds(self, .infinite, &ascent, &descent, nil))
|
|
return TypographicBounds(ascent: ascent, descent: descent, width: width)
|
|
}
|
|
|
|
private var baselineOffset: CGFloat {
|
|
var offset: CGFloat? = attribute(withName: .baselineOffset)
|
|
if #available(iOS 11, tvOS 11, OSX 10.13, *) {
|
|
offset = offset ?? attribute(withName: kCTBaselineOffsetAttributeName)
|
|
}
|
|
return offset ?? 0
|
|
}
|
|
|
|
fileprivate var paragraphStyle: NSParagraphStyle? {
|
|
let style: NSParagraphStyle? = attribute(withName: .paragraphStyle)
|
|
?? attribute(withName: kCTParagraphStyleAttributeName)
|
|
return style
|
|
}
|
|
|
|
fileprivate var isSingleStrikethrough: Bool {
|
|
let underlineStyle: Int? = attribute(withName: .strikethroughStyle)
|
|
return underlineStyle == UnderlineStyle.single.rawValue
|
|
}
|
|
|
|
fileprivate var color: CGColor {
|
|
let color: Any? = attribute(withName: .foregroundColor) ??
|
|
attribute(withName: kCTForegroundColorAttributeName)
|
|
if let systemColor = color as? SystemColor {
|
|
return systemColor.cgColor
|
|
} else if let cgColor = safeCFCast(color as CFTypeRef) as CGColor? {
|
|
return cgColor
|
|
}
|
|
|
|
return Color.black.cgColor
|
|
}
|
|
|
|
fileprivate var font: Font {
|
|
let font: Font? = attribute(withName: .font) ?? attribute(withName: kCTFontAttributeName)
|
|
return font ?? Font.systemFontWithDefaultSize()
|
|
}
|
|
|
|
fileprivate var attachment: TextAttachment? {
|
|
let attachment: TextAttachment? = attribute(withName: .attachment)
|
|
return attachment
|
|
}
|
|
|
|
fileprivate var origin: CGPoint {
|
|
var origins = [CGPoint.zero]
|
|
CTRunGetPositions(self, CFRangeMake(0, 1), &origins)
|
|
return origins.first!
|
|
}
|
|
|
|
fileprivate func strikethroughLine(forTextPosition position: CGPoint) -> CGPath {
|
|
let offset = baselineOffset + ceil(font.xHeight * 0.5)
|
|
let width = CTRunGetTypographicBounds(self, .infinite, nil, nil, nil)
|
|
|
|
let start = CGPoint(
|
|
x: position.x,
|
|
y: position.y + offset
|
|
)
|
|
|
|
let end = CGPoint(
|
|
x: position.x + CGFloat(width),
|
|
y: position.y + offset
|
|
)
|
|
|
|
let path = CGMutablePath()
|
|
path.move(to: start)
|
|
path.addLine(to: end)
|
|
return path
|
|
}
|
|
|
|
fileprivate var shadow: SystemShadow? {
|
|
attribute(withName: .shadow)
|
|
}
|
|
|
|
fileprivate func action<ActionType>(for key: NSAttributedString.Key) -> ActionType? {
|
|
attribute(withName: key) as ActionType?
|
|
}
|
|
}
|
|
|
|
extension NSMutableAttributedString {
|
|
fileprivate func appendWithPreservedAttributes(_ string: String) {
|
|
replaceCharacters(in: NSRange(location: length, length: 0), with: string)
|
|
}
|
|
}
|
|
|
|
extension CFRange {
|
|
fileprivate static let infinite = CFRange(location: 0, length: 0)
|
|
|
|
fileprivate static func withLength(_ length: Int) -> CFRange {
|
|
CFRange(location: 0, length: length)
|
|
}
|
|
|
|
fileprivate var nsRange: NSRange {
|
|
NSRange(location: self.location, length: self.length)
|
|
}
|
|
}
|
|
|
|
extension NSRange {
|
|
fileprivate var endIndex: Int {
|
|
location + length
|
|
}
|
|
}
|
|
|
|
extension UTF16Char {
|
|
fileprivate var isWhitespace: Bool {
|
|
isIn(.whitespaces)
|
|
}
|
|
|
|
fileprivate var isWhitespaceOrNewline: Bool {
|
|
isIn(.whitespacesAndNewlines)
|
|
}
|
|
|
|
fileprivate var isNewline: Bool {
|
|
isIn(.newlines)
|
|
}
|
|
|
|
private func isIn(_ characterSet: CharacterSet) -> Bool {
|
|
UnicodeScalar(self).map(characterSet.contains) ?? false
|
|
}
|
|
}
|
|
|
|
public func +(lhs: NSAttributedString, rhs: NSAttributedString) -> NSAttributedString {
|
|
let result = lhs.mutableCopy() as! NSMutableAttributedString
|
|
result.append(rhs)
|
|
return result
|
|
}
|
|
|
|
extension Array {
|
|
fileprivate mutating func removeLastIfExists() {
|
|
guard !isEmpty else {
|
|
return
|
|
}
|
|
removeLast()
|
|
}
|
|
}
|
|
|
|
extension NSAttributedString.VerticalPosition {
|
|
fileprivate func verticalOffset(forHeight height: CGFloat, availableHeight: CGFloat) -> CGFloat {
|
|
switch self {
|
|
case .top: return 0
|
|
case .center: return (availableHeight - height) / 2
|
|
case .bottom: return availableHeight - height
|
|
}
|
|
}
|
|
}
|
|
|
|
extension CGContext {
|
|
fileprivate func performDrawing(shadedWith shadow: SystemShadow?, _ drawing: () -> Void) {
|
|
guard let shadow = shadow, let color = shadow.cgColor else {
|
|
drawing()
|
|
return
|
|
}
|
|
// fix for UIKit compatibility
|
|
let offsetToUse: CGSize
|
|
if #available(iOS 14, *) {
|
|
offsetToUse = shadow.shadowOffset
|
|
} else {
|
|
offsetToUse = CGSize(
|
|
width: shadow.shadowOffset.width,
|
|
height: -shadow.shadowOffset.height
|
|
)
|
|
}
|
|
setShadow(
|
|
offset: offsetToUse,
|
|
blur: shadow.shadowBlurRadius,
|
|
color: color
|
|
)
|
|
drawing()
|
|
}
|
|
}
|
|
|
|
extension Font {
|
|
fileprivate var estimatedStrikethroughWidth: CGFloat {
|
|
// count strikethrough line of 1 pt as normal for default 12-pt font size
|
|
max(
|
|
1 / PlatformDescription.screenScale(),
|
|
(pointSize / 12).roundedToScreenScale
|
|
)
|
|
}
|
|
}
|
|
|
|
private let selectionColor = Color.colorWithHexCode(0xB3_D7_FE_7F)
|
|
|
|
private let selectionPointerColor = Color.colorWithHexCode(0x22_66_C5_FF)
|
|
|
|
private let pointerCircleSize = CGSize(width: 10, height: 10)
|
|
private let pointerShapeWidth: CGFloat = 2
|