mirror of
https://github.com/divkit/divkit.git
synced 2026-06-06 20:07:59 +00:00
d6dc384f0e
commit_hash:f70efdbaf7bf8044c7eb8fd1ee207ba7695f5bc0
510 lines
16 KiB
Swift
510 lines
16 KiB
Swift
import CoreFoundation
|
|
import Foundation
|
|
import LayoutKit
|
|
import VGSL
|
|
|
|
extension DivText: DivBlockModeling {
|
|
public func makeBlock(context: DivBlockModelingContext) throws -> Block {
|
|
let context = modifiedContextParentPath(context)
|
|
let expressionResolver = context.expressionResolver
|
|
let lazyText = Lazy(getter: { [unowned self] in
|
|
resolveText(expressionResolver) ?? ""
|
|
})
|
|
return try applyBaseProperties(
|
|
to: { try makeBaseBlock(context: context, text: lazyText) },
|
|
context: context,
|
|
actionsHolder: self,
|
|
customAccessibilityParams: CustomAccessibilityParams(
|
|
defaultTraits: .staticText
|
|
) { [unowned self] in
|
|
accessibility?.resolveDescription(expressionResolver) ?? lazyText.value
|
|
}
|
|
)
|
|
}
|
|
|
|
private func makeBaseBlock(
|
|
context: DivBlockModelingContext,
|
|
text: Lazy<String>
|
|
) throws -> Block {
|
|
let expressionResolver = context.expressionResolver
|
|
|
|
let fontParams = resolveFontParams(expressionResolver)
|
|
var typo = Typo(font: context.font(fontParams))
|
|
|
|
let alignment = resolveTextAlignmentHorizontal(expressionResolver)
|
|
.makeTextAlignment(uiLayoutDirection: context.layoutDirection)
|
|
if alignment != .left {
|
|
typo = typo.with(alignment: alignment)
|
|
}
|
|
|
|
let kern = CGFloat(resolveLetterSpacing(expressionResolver))
|
|
if !kern.isApproximatelyEqualTo(0) {
|
|
typo = typo.kerned(kern)
|
|
}
|
|
|
|
let isFocused = context.blockStateStorage.isFocused(path: context.path)
|
|
|
|
let resolvedColor = if isFocused,
|
|
let focusedTextColor = resolveFocusedTextColor(expressionResolver) {
|
|
focusedTextColor
|
|
} else {
|
|
resolveTextColor(expressionResolver)
|
|
}
|
|
|
|
if resolvedColor != .black {
|
|
typo = typo.with(color: resolvedColor)
|
|
}
|
|
|
|
if let lineHeight = resolveLineHeight(expressionResolver) {
|
|
typo = typo.with(height: CGFloat(lineHeight))
|
|
}
|
|
|
|
switch resolveStrike(expressionResolver) {
|
|
case .none: break
|
|
case .single: typo = typo.struckThrough(.single)
|
|
}
|
|
|
|
switch resolveUnderline(expressionResolver) {
|
|
case .none: break
|
|
case .single: typo = typo.underlined(.single)
|
|
}
|
|
if let textShadow = textShadow?.resolve(expressionResolver) {
|
|
typo = typo.shaded(textShadow.typoShadow)
|
|
}
|
|
|
|
let truncateMode = resolveTruncate(expressionResolver)
|
|
let lineBreakMode: LineBreakMode? = switch truncateMode {
|
|
case .none:
|
|
.byClipping
|
|
case .start:
|
|
.byTruncatingHead
|
|
case .end:
|
|
nil
|
|
case .middle:
|
|
.byTruncatingMiddle
|
|
}
|
|
if let lineBreakMode {
|
|
typo = typo.with(lineBreakMode: lineBreakMode)
|
|
}
|
|
|
|
let resolvedRanges = makeRanges(ranges, rangeBuilder: rangeBuilder, context: context)
|
|
let makeAttributedStringWithTypo: (Typo) -> NSAttributedString = {
|
|
self.makeAttributedString(
|
|
text: text.value as CFString,
|
|
typo: $0,
|
|
ranges: resolvedRanges,
|
|
actions: nil,
|
|
fontParams: fontParams
|
|
)
|
|
}
|
|
|
|
let attributedString = makeAttributedStringWithTypo(typo)
|
|
let gradientModel: TextBlock.GradientModel? = if let gradient = resolveGradient(context) {
|
|
TextBlock.GradientModel(
|
|
gradient: gradient,
|
|
rangedTextWithColor: makeAttributedStringWithTypo(typo.with(color: .clear))
|
|
)
|
|
} else {
|
|
nil
|
|
}
|
|
|
|
let images = makeInlineImages(
|
|
images: makeImages(self.images, imageBuilder: imageBuilder, context: context),
|
|
text: attributedString
|
|
)
|
|
|
|
let truncationToken = ellipsis.map {
|
|
makeAttributedString(
|
|
text: ($0.resolveText(expressionResolver) ?? "") as CFString,
|
|
typo: typo,
|
|
ranges: makeRanges($0.ranges, rangeBuilder: $0.rangeBuilder, context: context),
|
|
actions: $0.actions?.uiActions(context: context),
|
|
fontParams: fontParams
|
|
)
|
|
}
|
|
let truncationImages = makeInlineImages(
|
|
images: makeImages(ellipsis?.images, imageBuilder: ellipsis?.imageBuilder, context: context),
|
|
text: truncationToken
|
|
)
|
|
|
|
if let id {
|
|
context.blockStateStorage.setState(
|
|
id: id,
|
|
cardId: context.cardId,
|
|
state: TextBlockViewState(text: attributedString.string)
|
|
)
|
|
}
|
|
|
|
let additionalTextInsets = additionalTextInsets(ranges: resolvedRanges)
|
|
|
|
return TextBlock(
|
|
widthTrait: resolveContentWidthTrait(context),
|
|
heightTrait: resolveContentHeightTrait(context),
|
|
text: attributedString,
|
|
gradientModel: gradientModel,
|
|
verticalAlignment: resolveTextAlignmentVertical(expressionResolver).alignment,
|
|
maxIntrinsicNumberOfLines: resolveMaxLines(expressionResolver) ?? .max,
|
|
minNumberOfHiddenLines: resolveMinHiddenLines(expressionResolver) ?? 0,
|
|
images: images,
|
|
accessibilityElement: nil,
|
|
truncationToken: truncationToken,
|
|
truncationImages: truncationImages,
|
|
additionalTextInsets: additionalTextInsets,
|
|
canSelect: resolveSelectable(expressionResolver),
|
|
tightenWidth: resolveTightenWidth(expressionResolver),
|
|
autoEllipsize: resolveAutoEllipsize(expressionResolver)
|
|
?? context.flagsInfo.defaultTextAutoEllipsize,
|
|
path: context.path,
|
|
isFocused: isFocused
|
|
)
|
|
}
|
|
|
|
private func makeAttributedString(
|
|
text: CFString,
|
|
typo: Typo,
|
|
ranges: [(Range, DivBlockModelingContext)],
|
|
actions: [UserInterfaceAction]?,
|
|
fontParams: FontParams
|
|
) -> NSAttributedString {
|
|
let length = CFStringGetLength(text)
|
|
let attributedString = CFAttributedStringCreateMutable(kCFAllocatorDefault, length)!
|
|
CFAttributedStringBeginEditing(attributedString)
|
|
CFAttributedStringReplaceString(attributedString, CFRange(), text)
|
|
let fullRange = CFRange(location: 0, length: length)
|
|
typo.apply(to: attributedString, at: fullRange)
|
|
attributedString.apply(actions: actions, mask: nil, at: fullRange)
|
|
for (range, rangeContext) in ranges {
|
|
apply(range, to: attributedString, context: rangeContext, fontParams: fontParams)
|
|
}
|
|
CFAttributedStringEndEditing(attributedString)
|
|
return attributedString
|
|
}
|
|
|
|
private func makeInlineImages(
|
|
images: [(Image, DivBlockModelingContext)],
|
|
text: NSAttributedString?
|
|
) -> [TextBlock.InlineImage] {
|
|
guard let text else {
|
|
return []
|
|
}
|
|
let textLength = CFAttributedStringGetLength(text)
|
|
return images.compactMap { image, imageContext in
|
|
image.makeImage(
|
|
context: imageContext,
|
|
textLength: textLength
|
|
)
|
|
}
|
|
}
|
|
|
|
private func apply(
|
|
_ range: DivText.Range,
|
|
to string: CFMutableAttributedString,
|
|
context: DivBlockModelingContext,
|
|
fontParams: FontParams
|
|
) {
|
|
let expressionResolver = context.expressionResolver
|
|
|
|
let stringLength = CFAttributedStringGetLength(string)
|
|
let start = range.resolveStart(expressionResolver)
|
|
let end = range.resolveEnd(expressionResolver) ?? stringLength
|
|
guard end > start, start < stringLength else {
|
|
return
|
|
}
|
|
|
|
let actualEnd = min(end, stringLength)
|
|
let cfRange = CFRange(location: start, length: actualEnd - start)
|
|
let mask = range.makeMask(range: cfRange, resolver: expressionResolver)
|
|
|
|
let rangeVariationSettings = range.resolveFontVariationSettings(expressionResolver)?
|
|
.mapValues { $0 as? NSNumber }.filteringNilValues()
|
|
let rangeFontParams = FontParams(
|
|
family: range.resolveFontFamily(expressionResolver) ?? fontParams.family,
|
|
weight: range.resolveFontWeightValue(expressionResolver)
|
|
?? range.resolveFontWeight(expressionResolver)?.toInt()
|
|
?? fontParams.weight,
|
|
size: range.resolveFontSize(expressionResolver) ?? fontParams.size,
|
|
unit: range.resolveFontSizeUnit(expressionResolver),
|
|
featureSettings: range.resolveFontFeatureSettings(expressionResolver)
|
|
?? fontParams.featureSettings,
|
|
variationSettings: rangeVariationSettings ?? fontParams.variationSettings
|
|
)
|
|
let fontTypo = rangeFontParams == fontParams
|
|
? nil
|
|
: Typo(font: context.font(rangeFontParams))
|
|
let colorTypo = mask?.isEnabled == true
|
|
? Typo(color: .black.withAlphaComponent(0))
|
|
: range.resolveTextColor(expressionResolver).map { Typo(color: $0) }
|
|
let heightTypo = range.resolveLineHeight(expressionResolver)
|
|
.map { Typo(height: CGFloat($0)) }
|
|
let spacingTypo = range.resolveLetterSpacing(expressionResolver)
|
|
.map { Typo(kern: CGFloat($0)) }
|
|
let strikethroughTypo = range.resolveStrike(expressionResolver)
|
|
.map { Typo(strikethrough: $0.underlineStyle) }
|
|
let underlineTypo = range.resolveUnderline(expressionResolver)
|
|
.map { Typo(underline: $0.underlineStyle) }
|
|
let baselineOffset = range.resolveBaselineOffset(expressionResolver)
|
|
let baselineOffsetTypo = Typo(
|
|
baselineOffset: baselineOffset.isApproximatelyEqualTo(0) ? nil : baselineOffset
|
|
)
|
|
let blockShadow = range.textShadow?.resolve(expressionResolver).typoShadow
|
|
let shadowTypo = blockShadow.map { Typo(shadow: $0) }
|
|
let typos = [
|
|
fontTypo,
|
|
colorTypo,
|
|
heightTypo,
|
|
spacingTypo,
|
|
strikethroughTypo,
|
|
underlineTypo,
|
|
baselineOffsetTypo,
|
|
shadowTypo,
|
|
].compactMap { $0 }
|
|
let actions = range.actions?.uiActions(context: context)
|
|
if typos.isEmpty, actions == nil, range.background == nil, range.border == nil,
|
|
range.mask == nil {
|
|
return
|
|
}
|
|
|
|
typos.forEach { $0.apply(to: string, at: cfRange) }
|
|
string.apply(actions: actions, mask: mask, at: cfRange)
|
|
[
|
|
range.makeBackground(context: context, range: cfRange),
|
|
range.makeBorder(range: cfRange, resolver: expressionResolver),
|
|
range.makeVerticalAlignment(range: cfRange, resolver: expressionResolver),
|
|
].forEach {
|
|
$0?.apply(to: string, at: cfRange)
|
|
}
|
|
}
|
|
|
|
private func resolveGradient(_ context: DivBlockModelingContext) -> Gradient? {
|
|
guard let textGradient else {
|
|
return nil
|
|
}
|
|
switch textGradient {
|
|
case let .divLinearGradient(gradient):
|
|
return gradient.makeBlockLinearGradient(context).map { .linear($0) }
|
|
case let .divRadialGradient(gradient):
|
|
return gradient.makeBlockRadialGradient(context).map { .radial($0) }
|
|
}
|
|
}
|
|
|
|
private func additionalTextInsets(
|
|
ranges: [(Range, DivBlockModelingContext)]
|
|
) -> EdgeInsets {
|
|
var additionalInsets: EdgeInsets = .zero
|
|
for (range, rangeContext) in ranges {
|
|
if case let .divCloudBackground(background) = range.background {
|
|
let backgroundInsets = background.paddings?.resolve(rangeContext) ?? .zero
|
|
additionalInsets = EdgeInsets(
|
|
top: max(additionalInsets.top, backgroundInsets.top),
|
|
left: max(additionalInsets.left, backgroundInsets.left),
|
|
bottom: max(additionalInsets.bottom, backgroundInsets.bottom),
|
|
right: max(additionalInsets.right, backgroundInsets.right)
|
|
)
|
|
}
|
|
}
|
|
return additionalInsets
|
|
}
|
|
}
|
|
|
|
extension DivText: FontParamsProvider {}
|
|
|
|
extension CFMutableAttributedString {
|
|
fileprivate func apply(
|
|
actions: [UserInterfaceAction]?,
|
|
mask: TextMask?,
|
|
at range: CFRange
|
|
) {
|
|
RunWithBoundsAttribute(
|
|
actions: actions ?? [],
|
|
mask: mask
|
|
).apply(to: self, at: range)
|
|
}
|
|
}
|
|
|
|
extension DivAlignmentHorizontal {
|
|
fileprivate func makeTextAlignment(
|
|
uiLayoutDirection: UserInterfaceLayoutDirection =
|
|
.system
|
|
) -> TextAlignment {
|
|
switch self {
|
|
case .start:
|
|
uiLayoutDirection == .leftToRight ? .left : .right
|
|
case .end:
|
|
uiLayoutDirection == .leftToRight ? .right : .left
|
|
case .left:
|
|
.left
|
|
case .center:
|
|
.center
|
|
case .right:
|
|
.right
|
|
}
|
|
}
|
|
}
|
|
|
|
extension URLQueryItem {
|
|
init(name: String, value: DivCardID?) {
|
|
self.init(name: name, value: value?.rawValue)
|
|
}
|
|
}
|
|
|
|
extension DivText.Image {
|
|
fileprivate func makeImage(
|
|
context: DivBlockModelingContext,
|
|
textLength: Int
|
|
) -> TextBlock.InlineImage? {
|
|
let expressionResolver = context.expressionResolver
|
|
let start = resolveStart(expressionResolver) ?? 0
|
|
|
|
guard start <= textLength else {
|
|
return nil
|
|
}
|
|
|
|
let indexingDirection = resolveIndexingDirection(expressionResolver)
|
|
let location = switch indexingDirection {
|
|
case .normal:
|
|
start
|
|
case .reversed:
|
|
textLength - start
|
|
}
|
|
|
|
return TextBlock.InlineImage(
|
|
size: CGSize(
|
|
width: CGFloat(width.resolveValue(expressionResolver) ?? 0),
|
|
height: CGFloat(height.resolveValue(expressionResolver) ?? 0)
|
|
),
|
|
holder: context.imageHolderFactory.make(resolveUrl(expressionResolver)),
|
|
location: location,
|
|
tintColor: resolveTintColor(expressionResolver),
|
|
tintMode: resolveTintMode(expressionResolver).tintMode,
|
|
verticalAlignment: resolveAlignmentVertical(expressionResolver).inlineImageAlignment
|
|
)
|
|
}
|
|
}
|
|
|
|
extension DivTextAlignmentVertical {
|
|
fileprivate var inlineImageAlignment: TextBlock.InlineImage.VerticalAlignment {
|
|
switch self {
|
|
case .top: .top
|
|
case .center: .center
|
|
case .bottom: .bottom
|
|
case .baseline: .baseline
|
|
}
|
|
}
|
|
}
|
|
|
|
extension DivText.Range {
|
|
fileprivate func makeBorder(
|
|
range: CFRange,
|
|
resolver: ExpressionResolver
|
|
) -> BorderAttribute? {
|
|
guard let border else { return nil }
|
|
let color = border.stroke?.resolveColor(resolver)
|
|
let width = border.stroke?.resolveWidth(resolver)
|
|
let cornerRadius = border.resolveCornerRadius(resolver)
|
|
return BorderAttribute(
|
|
color: color?.cgColor,
|
|
width: width.flatMap(CGFloat.init),
|
|
cornerRadius:
|
|
cornerRadius.flatMap(CGFloat.init),
|
|
range: range
|
|
)
|
|
}
|
|
|
|
fileprivate func makeVerticalAlignment(
|
|
range: CFRange,
|
|
resolver: ExpressionResolver
|
|
) -> RangeVerticalAlignmentAttribute? {
|
|
switch resolveAlignmentVertical(resolver) {
|
|
case .top:
|
|
.init(verticalAlignment: .top, range: range)
|
|
case .center:
|
|
.init(verticalAlignment: .center, range: range)
|
|
case .bottom:
|
|
.init(verticalAlignment: .bottom, range: range)
|
|
case .baseline:
|
|
.init(verticalAlignment: .baseline, range: range)
|
|
case .none:
|
|
nil
|
|
}
|
|
}
|
|
|
|
fileprivate func makeBackground(
|
|
context: DivBlockModelingContext,
|
|
range: CFRange
|
|
) -> StringAttribute? {
|
|
guard let background else { return nil }
|
|
let resolver = context.expressionResolver
|
|
switch background {
|
|
case let .divSolidBackground(solid):
|
|
let color = solid.resolveColor(resolver) ?? .clear
|
|
return BackgroundAttribute(color: color.cgColor, range: range)
|
|
case let .divCloudBackground(cloud):
|
|
guard let color = cloud.resolveColor(resolver),
|
|
let cornerRadius = cloud.resolveCornerRadius(resolver)
|
|
else { return nil }
|
|
let insets = cloud.paddings?.resolve(context)
|
|
return CloudBackgroundAttribute(
|
|
color: color,
|
|
cornerRadius: CGFloat(cornerRadius),
|
|
range: range.location..<(range.location + range.length),
|
|
insets: insets
|
|
)
|
|
}
|
|
}
|
|
|
|
fileprivate func makeMask(
|
|
range: CFRange,
|
|
resolver: ExpressionResolver
|
|
) -> TextMask? {
|
|
guard let mask else { return nil }
|
|
|
|
switch mask {
|
|
case let .divTextRangeMaskSolid(solid):
|
|
guard let color = solid.resolveColor(resolver) else {
|
|
return nil
|
|
}
|
|
return TextMask(
|
|
color: color.cgColor,
|
|
range: range,
|
|
isEnabled: solid.resolveIsEnabled(resolver)
|
|
)
|
|
|
|
case let .divTextRangeMaskParticles(particles):
|
|
guard let color = particles.resolveColor(resolver) else {
|
|
return nil
|
|
}
|
|
let isAnimated = particles.resolveIsAnimated(resolver)
|
|
let density = particles.resolveDensity(resolver)
|
|
let particleSize = CGFloat(particles.particleSize.resolveValue(resolver) ?? 1)
|
|
|
|
return TextMask(
|
|
color: color.cgColor,
|
|
range: range,
|
|
isAnimated: isAnimated,
|
|
particleSize: particleSize,
|
|
density: density,
|
|
isEnabled: particles.resolveIsEnabled(resolver)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension DivLineStyle {
|
|
fileprivate var underlineStyle: UnderlineStyle {
|
|
switch self {
|
|
case .none: []
|
|
case .single: .single
|
|
}
|
|
}
|
|
}
|
|
|
|
extension BlockShadow {
|
|
fileprivate var typoShadow: Shadow {
|
|
Shadow(
|
|
offset: CGSize(width: offset.x, height: -offset.y),
|
|
blurRadius: blurRadius,
|
|
color: color.withAlphaComponent(CGFloat(opacity))
|
|
)
|
|
}
|
|
}
|