Telegram 技巧档案UIKIT · APPKIT
macOS · AppKit

列表、滚动与文本

NSTableView 行高自管、滚动锚点与 Core Text 选区。

本主题范围:TelegramSwift 的 packages/TGUIKit 与 packages/InputView 中的 macOS 列表、滚动和文本系统。

1. 用 CAEmitterLayer 私有行为做可交互隐形墨水

#13-01TelegramSwiftAppKit报告精选 13-1源码分析REA 二进制macOS 26.7 实测私有 APICore Animation

做了什么:代码动画 emitterBehaviors.randomAttractor0 的私有属性。它让粒子吸引力和位置持续随机变化。

为什么这样做:公开 CAEmitterLayer API 不提供吸引器行为。这个做法把“墨水会流动”的物理感留在渲染服务端。

关键代码摘录

InvisibleInkDustView.swift · L340–L369
let falloffAnimation1 = CABasicAnimation(
    keyPath: "emitterBehaviors.randomAttractor0.falloff"
)
falloffAnimation1.beginTime = 0.0
falloffAnimation1.fillMode = .both
falloffAnimation1.isRemovedOnCompletion = false
falloffAnimation1.autoreverses = true
falloffAnimation1.repeatCount = .infinity
falloffAnimation1.duration = 2.0
falloffAnimation1.fromValue = -20.0 as NSNumber
falloffAnimation1.toValue = 60.0 as NSNumber
falloffAnimation1.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
self.emitterLayer?.add(
    falloffAnimation1,
    forKey: "emitterBehaviors.randomAttractor0.falloff"
)

let positionAnimation1 = CAKeyframeAnimation(
    keyPath: "emitterBehaviors.randomAttractor0.position"
)
positionAnimation1.beginTime = 0.0
positionAnimation1.fillMode = .both
positionAnimation1.isRemovedOnCompletion = false
positionAnimation1.autoreverses = true
positionAnimation1.repeatCount = .infinity
positionAnimation1.duration = 3.0
positionAnimation1.calculationMode = .discrete

代价与风险:emitterBehaviors、randomAttractor0 是明文 KVC key。它们不是字符串拼接或 base64 混淆。代码在 L415-L416 也以同一前缀写入 fingerAttractor。因此可确认真实私有 API 名为 emitterBehaviors。私有 API 有审核拒绝和系统兼容风险。

证据链 · #13-01已发布 · 仍可用
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:运行时拼类名与 behaviorWithType:,IMP 函数指针调用。
REA 第 1 项
已发布 · 仍可用REA 二进制macOS 26.7 实测

13.0 二进制:"CA"、"Emitter"、"Behavior" 以 Swift 小字符串存于 __DATA.__data 0x5e75208/18/28;"behaviorWith"、"Type:" 存于 0x5e751c0/d0。完整名 CAEmitterBehavior、behaviorWithType 出现 0 次。"simpleAttractor" 编成 5 处、"valueOverLife" 编成 3 处 MOVZ/MOVK 立即数。KVC 路径 emitterBehaviors.fingerAttractor.*、randomAttractor0/1.* 以明文存于 __cstring

macOS 26.7:类存在;+behaviorWithType: 存在;两种类型都能创建

结论:已发布,当前系统仍可用

2. 用 Core Text 反查空偏移,修正跨行选区尾部

#13-02TelegramSwiftAppKit报告精选 13-2源码分析文本与输入

做了什么:如果选区结束索引的 Core Text 横向偏移为零,代码向前逐字符重试。它随后用修正后的两端偏移绘制高亮矩形。

为什么这样做:换行、行尾和某些 glyph 组合会返回零偏移。直接使用零值会让跨行选区变成反向宽度或消失。

关键代码摘录

TextView.swift · L2482–L2499
var ascent: CGFloat = 0
var descent: CGFloat = 0
var leading: CGFloat = 0

var width: CGFloat = CGFloat(
    CTLineGetTypographicBounds(line, &ascent, &descent, &leading)
)

let startOffset = CTLineGetOffsetForStringIndex(line, beginLineIndex, nil)
var endOffset = CTLineGetOffsetForStringIndex(line, endLineIndex, nil)

if beginLineIndex < endLineIndex {
    var index = endLineIndex - 1
    while endOffset == 0 && index > 0 {
        endOffset = CTLineGetOffsetForStringIndex(line, index, nil)
        index -= 1
    }
}

width = endOffset - startOffset

代价与风险:这段循环依赖 Core Text 的索引语义。复杂组合字符的边界仍需人工回归。未验证全部 Unicode 边界情况。

证据链 · #13-02未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:以 CTLineGetOffsetForStringIndex 逐字符回退。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

3. 按稳定标识恢复滚动锚点,而不是恢复裸 offset

#13-03TelegramSwiftAppKit报告精选 13-3源码分析手势与滚动

做了什么:更新前保存可见行的 stableId 和屏幕相对位置。更新后代码查找同一行,再重算 NSClipView 的 y 坐标。

为什么这样做:插入消息或重算行高会改变绝对 offset。稳定锚点可避免聊天阅读位置跳动,并兼容正向和翻转坐标。

关键代码摘录

TableView.swift · L1882–L1914
let strideTo: StrideTo<Int> = stride(
    from: visibleItems.count - 1,
    to: -1,
    by: -1
)

for i in strideTo {
    let visible = visibleItems[i]
    if let item = self.item(stableId: visible.0.stableId) {
        nrect = rectOf(item: item)

        if let view = viewNecessary(at: i) {
            if view.isInsertionAnimated {
                break
            }
        }

        let y: CGFloat
        if !tableView.isFlipped {
            y = nrect.minY - (frame.height - visible.1) + nrect.height
        } else {
            y = nrect.minY - visible.1
        }

        self.clipView.updateBounds(to: NSMakePoint(0, y))
        break
    }
}

代价与风险:锚点行被删除时会退到其他可见行。插入动画中的行会中断恢复,以避免追随尚未稳定的几何状态。

证据链 · #13-03未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:逆序扫描可见项,遇到插入动画中则跳过。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

4. 平滑滚动保存目标点,并可同步结算中途动画

#13-04TelegramSwiftAppKit报告精选 13-4源码分析手势与滚动

做了什么:TGClipView 为每次动画滚动保存唯一目标。它在完成时按真实 bounds.origin 验证结果。

为什么这样做:NSScrollView 的普通滚动没有业务级完成结果。这里可让列表在动画结束后继续处理排队 transition。

关键代码摘录

TGClipView.swift · L158–L203
if point == self.destinationOrigin {
    return
}
self.scrollCompletion = completion
self.destinationOrigin = point

if animated {
    self.point = point
    NSAnimationContext.runAnimationGroup({ ctx in
        ctx.duration = 0.35
        let timingFunction = CAMediaTimingFunction(
            controlPoints: 0.5, 1.0 + 0.4 / 3.0, 1.0, 1.0
        )
        ctx.timingFunction = timingFunction
        self.animator().setBoundsOrigin(point)
    }, completionHandler: {
        self.destinationOrigin = nil
        self.point = nil
        self.scrollCompletion?(point == self.bounds.origin)
        self.scrollDidComplete?(point == self.bounds.origin)
    })
} else {
    self.updateBounds(to: point)
    self.point = nil
    self.destinationOrigin = nil
    self.scrollCompletion?(true)
    self.scrollDidComplete?(true)
}

代价与风险:scrollCompletion 只有一个槽位。新请求会覆盖旧回调。调用方必须把并发滚动串行化。

证据链 · #13-04未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:NSAnimationContext 包裹 animator().setBoundsOrigin。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

5. 把粘性标题移到滚动视图同级,并适配翻转列表

#13-05TelegramSwiftAppKit报告精选 13-5源码分析手势与滚动

做了什么:代码在可见行中按坐标方向寻找最近的 TableStickItem。TableView 随后把 TableStickView 放在滚动内容之外。

为什么这样做:NSTableView 没有可靠的聊天式粘性行机制。翻转坐标会让“前一行”方向反转,所以代码显式分支。

关键代码摘录

TableView.swift · L1424–L1445
let range = self.tableView.rows(in: NSMakeRect(
    visibleRect.minX,
    visibleRect.minY,
    visibleRect.width,
    visibleRect.height - stickTopInset - secondaryOffset
))

if let stickClass = stickClass, !updating {
    let flipped = tableView.isFlipped
    var index: Int = flipped
        ? range.location
        : range.location + range.length - 1

    let scrollInset = self.documentOffset.y - stickTopInset
        + (flipped ? 0 : frame.height)
    var item: TableRowItem? = optionalItem(at: index)

    if !flipped {
        while let s = item, !s.isKind(of: stickClass) {
            index += 1
            item = self.optionalItem(at: index)
        }
    } else {
        while let s = item, !s.isKind(of: stickClass) {
            index -= 1
            item = self.optionalItem(at: index)
        }
    }
}

代价与风险:算法与行排序和 isFlipped 强耦合。列表更新期间刻意跳过,以避免过渡中取到错误行。

证据链 · #13-05未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:基于可见行 NSRange 与滚动 inset 选取。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

6. 粘性标题压住下一标题时,手算 y 轴推挤和层级

#13-06TelegramSwiftAppKit报告精选 13-6源码分析

做了什么:代码以候选标题的实际 row rect 计算覆盖标题的 y 偏移。它为第二个粘性标题预留 secondaryOffset。

为什么这样做:普通 sticky header 只固定顶部。聊天日期标题在相邻标题出现时必须被推出去,并且还要和另一层标题共存。

关键代码摘录

TableView.swift · L1503–L1553
let rect: NSRect = self.rectOf(item: item)
let dif: CGFloat
if currentStick != nil {
    dif = min(scrollInset - rect.maxY, item.heightValue)
} else {
    dif = item.heightValue
}
var yTopOffset: CGFloat
if !flipped {
    yTopOffset = min(
        (scrollInset - rect.maxY - secondaryOffset) - rect.height,
        0
    )
} else {
    yTopOffset = min(
        -(rect.height + (scrollInset - rect.minY - secondaryOffset)),
        0
    )
}
if yTopOffset <= -rect.height {
    yTopOffset = 0
}
let updatedPoint = NSMakePoint(
    0,
    yTopOffset + stickTopInset + secondaryOffset
)

代价与风险:几何公式复杂。改变边距或加入第三层标题时,维护者必须同步修改分支。

证据链 · #13-06未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:结合 scrollInset/secondaryOffset 与行高求偏移。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

7. 用 presentation layer 接住行位移,再人工补 Core Animation

#13-07TelegramSwiftAppKit报告精选 13-7源码分析Core Animation

做了什么:列表插入时读取 presentation() 层的当前位置。它从屏幕上的实际位置继续动画,而不是从 model layer 跳变后的值开始。

为什么这样做:连续 merge 时,前一帧位置动画可能未结束。直接读取 model layer 会造成行跳帧。

关键代码摘录

TableAnimationInterface.swift · L95–L110
if let view = table.viewNecessary(at: idx), let layer = view.layer {
    var inset = (layer.frame.minY - height)
    if let presentLayer = layer.presentation(),
       presentLayer.animation(forKey: "position") != nil {
        inset = presentLayer.position.y
    }

    let from: CGPoint = NSMakePoint(0, inset)
    let to: CGPoint = NSMakePoint(0, layer.position.y)

    layer.animatePosition(
        from: from,
        to: to,
        duration: 0.2,
        timingFunction: .easeOut
    )
    animatedItems.append(
        AnimateItem(index: added[0].index, from: from, to: to)
    )
}

代价与风险:逻辑依赖自定义 animatePosition 扩展和动画 key position。更改动画 key 会静默失效。

证据链 · #13-07未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:检测动画 key position,构造 animatePosition。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

8. 为倒序消息流写独立的增量 diff

做了什么:mergeListsStableWithUpdatesReversed 以反向比较规则生成删除、插入和更新索引。它还保留更新项的旧索引。

为什么这样做:倒序聊天数据不能直接复用正序 merge。保留旧索引可让视图选择正确的插入和移动动画。

关键代码摘录

MergeLists.swift · L288–L312
if let left = left, let right = right {
    if left.stableId == right.stableId && (left != right || allUpdated) {
        updatedIndices.append((i, right, previousIndices[left.stableId]!))
        i += 1
        j += 1
    } else {
        if left == right && !allUpdated {
            i += 1
            j += 1
        } else if left > right {
            removeIndices.append(i)
            i += 1
        } else if !(left < right) {
            removeIndices.append(i)
            i += 1
        } else {
            j += 1
        }
    }
} else if let _ = left {
    removeIndices.append(i)
    i += 1
} else if let _ = right {
    j += 1
} else {
    break
}

代价与风险:泛型元素必须同时正确实现 Comparable、Identifiable 和相等性。排序不稳定会触发大量错误 diff。

证据链 · #13-08未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

9. 只重排可见行,并用手工行高通知驱动 NSTableView

做了什么:窗口改宽时,代码只对可见 range 调用 makeSize。只有高度变化的行才 reload 并通知 NSTableView 重算高度。

为什么这样做:聊天行文本布局昂贵。全表重排会在缩放窗口时卡顿,并破坏当前阅读位置。

关键代码摘录

TableView.swift · L1083–L1101
beginTableUpdates()
for i in range.min ..< range.max {
    let item = self.item(at: i)
    let before = item.heightValue
    let updated = item.makeSize(frame.width, oldWidth: oldWidth)
    let after = item.heightValue
    if (before != after && updated) || item.instantlyResize || inLiveResize {
        reloadData(row: i, animated: false)
        noteHeightOfRow(i, false)
    }
}
endTableUpdates()
if !tableView.inLiveResize && oldWidth != 0 {
    saveScrollState(visibleItems)
}

for listener in scrollListeners {
    listener.handler(self.scrollPosition().current)
}

代价与风险:不可见行延迟到可见时才会重排。行项必须保证 makeSize 幂等。

证据链 · #13-09未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

10. 用 TextNode 复用相同输入的 Core Text layout

#13-10TelegramSwiftAppKit源码分析文本与输入性能与内存

做了什么:layoutText 把约束尺寸、截断策略、镂空、选中态和 attributed string 都作为缓存键。全部相等时直接复用旧 layout。

为什么这样做:Core Text 断行和 glyph run 构造是聊天滚动热点。缓存避免同一文本在布局阶段重复测量。

关键代码摘录

TextNode.swift · L342–L369
let existingLayout: TextNodeLayout? = maybeNode?.currentLayout
let layout: TextNodeLayout

if let existingLayout = existingLayout,
   existingLayout.constrainedSize == constrainedSize,
   existingLayout.maximumNumberOfLines == maximumNumberOfLines,
   existingLayout.truncationType == truncationType,
   existingLayout.cutout == cutout,
   existingLayout.selected == selected,
   existingLayout.alignment == alignment {
    let stringMatch: Bool
    if let existingString = existingLayout.attributedString,
       let string = attributedString {
        stringMatch = existingString == string
    } else if existingLayout.attributedString == nil && attributedString == nil {
        stringMatch = true
    } else {
        stringMatch = false
    }

    if stringMatch {
        layout = existingLayout
    } else {
        layout = TextNode.getlayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, backgroundColor: backgroundColor, constrainedSize: constrainedSize, cutout: cutout,selected:selected, alignment: alignment, lineSpacing: lineSpacing)
    }
}

代价与风险:缓存匹配依赖 NSAttributedString 相等性。属性对象的相等实现不稳定时,会降低命中率。

证据链 · #13-10未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

11. 绕过 NSTextView,直接逐 glyph run 绘制消息文本

#13-11TelegramSwiftAppKit源码分析文本与输入

做了什么:TextNode 自己翻转 Core Graphics 坐标系。它逐行、逐 CTRun 绘制文本,并计算居中 pen offset。

为什么这样做:大量不可编辑消息不需要 NSTextView 的编辑、布局和子视图成本。Core Text 绘制也便于同时控制选择、spoiler 和嵌入层。

关键代码摘录

TextNode.swift · L305–L335
if let layout = self.currentLayout {
    let textMatrix = context.textMatrix
    let textPosition = context.textPosition
    context.textMatrix = CGAffineTransform(scaleX: 1.0, y: -1.0)
    for i in 0 ..< layout.lines.count {
        let line = layout.lines[i]
        var penFlush: CGFloat = 0.0
        if layout.alignment == .center {
            penFlush = 0.5
        }
        let penOffset = CGFloat(
            CTLineGetPenOffsetForFlush(line.line, penFlush, Double(dirtyRect.width))
        )
        context.textPosition = CGPoint(
            x: penOffset + dirtyRect.minX + line.frame.minX,
            y: line.frame.minY + dirtyRect.minY
        )
        let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray
        for run in glyphRuns {
            let run = run as! CTRun
            CTRunDraw(run, context, CFRangeMake(0, CTRunGetGlyphCount(run)))
        }
    }
    context.textMatrix = textMatrix
    context.textPosition = CGPoint(x: textPosition.x, y: textPosition.y)
}

代价与风险:实现者负责坐标、截断、可访问性和交互。强制转换 run as! CTRun 依赖 Core Text 返回类型。

证据链 · #13-11未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

12. 将内嵌内容留在文本流中,再交给独立图层渲染

#13-12TelegramSwiftAppKit源码分析Core Animation文本与输入

做了什么:代码从 CTLine 精确得到内嵌范围的左右边界。它使用 floor/ceil 和备用偏移,减少非整数 glyph 坐标的缝隙。

为什么这样做:动态表情等内容不能只靠普通字符绘制。独立 embeddedContainer 图层可以播放动画,同时文字仍参与断行。

关键代码摘录

TextView.swift · L1014–L1049
func addEmbeddedItem(
    item: AnyHashable,
    line: CTLine,
    ascent: CGFloat,
    descent: CGFloat,
    startIndex: Int,
    endIndex: Int,
    rightInset: CGFloat = 0.0
) {
    var secondaryLeftOffset: CGFloat = 0.0
    let rawLeftOffset = CTLineGetOffsetForStringIndex(
        line, startIndex, &secondaryLeftOffset
    )
    var leftOffset = floor(rawLeftOffset)
    if !rawLeftOffset.isEqual(to: secondaryLeftOffset) {
        leftOffset = floor(secondaryLeftOffset)
    }

    var secondaryRightOffset: CGFloat = 0.0
    let rawRightOffset = CTLineGetOffsetForStringIndex(
        line, endIndex, &secondaryRightOffset
    )
    var rightOffset = ceil(rawRightOffset)
    if !rawRightOffset.isEqual(to: secondaryRightOffset) {
        rightOffset = ceil(secondaryRightOffset)
    }

    let lineRange = NSMakeRange(
        CTLineGetStringRange(line).location,
        CTLineGetStringRange(line).length
    )
}

代价与风险:内嵌范围跨行时会被后续逻辑拒绝。内嵌对象必须和原 attributed string 的索引保持同步。

证据链 · #13-12未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

13. spoiler 遮罩时同步隐藏浮在文字上的嵌入层

#13-13TelegramSwiftAppKit源码分析Core Animation

做了什么:代码以嵌入层中心点和 spoiler rect 相交为条件,把对应 layer 的 opacity 置零。

为什么这样做:spoiler 只遮住 Core Text glyph 时,独立动画表情会泄露内容。此处补齐两个渲染管线。

关键代码摘录

TextView.swift · L3383–L3397
public func checkEmbeddedUnderSpoiler() {
    if let layout = self.textLayout {
        let rects = layout.spoilerRects().map { $0.0 }
        let sublayers = embeddedContainer.sublayers ?? []
        for subview in sublayers {
            var isHidden = false
            loop: for rect in rects {
                if NSIntersectsRect(
                    NSMakeRect(subview.frame.midX, subview.frame.midY, 1, 1),
                    rect
                ) {
                    isHidden = true
                    break loop
                }
            }
            subview.opacity = isHidden ? 0 : 1
        }
    }
}

代价与风险:中心点判断不能表示部分覆盖。极宽嵌入内容可能在边缘与 spoiler 视觉不一致。

证据链 · #13-13未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

14. 输入框用第二套 TextKit 栈测量高度,避免污染显示布局

#13-14TelegramSwiftAppKit源码分析文本与输入

做了什么:InputTextView 用独立的 storage、layout manager 和无限高 container 测量高度。它仅在文本或宽度改变时使测量布局失效。

为什么这样做:复用屏幕上的 layout manager 会与插入点、选择和附件布局竞争。单独测量可保持输入视图稳定。

关键代码摘录

ChatInputTextView.swift · L695–L721
public func textHeightForWidth(_ width: CGFloat) -> CGFloat {
    let measureSize = CGSize(width: width, height: 1000000.0)

    if self.measurementTextStorage != self.attributedString()
        || self.measurementTextContainer.size != measureSize {
        self.measurementTextStorage.setAttributedString(self.attributedString())
        self.measurementTextContainer.size = measureSize
        self.measurementLayoutManager.invalidateLayout(
            forCharacterRange: NSRange(
                location: 0,
                length: self.measurementTextStorage.length
            ),
            actualCharacterRange: nil
        )
        self.measurementLayoutManager.ensureLayout(
            for: self.measurementTextContainer
        )
    }

    var textSize = self.measurementLayoutManager.usedRect(
        for: self.measurementTextContainer
    ).size
    if string.isEmpty {
        textSize.height += 2
    }
    return textSize.height + insets.top + insets.bottom
        + self.textContainerOrigin.y * 2
}

代价与风险:每次文本改变都比较完整 attributed string。超长草稿会增加比较和复制成本。

证据链 · #13-14未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

15. 重写 NSTextContainer 的行片段,为引用块预留不规则边距

#13-15TelegramSwiftAppKit源码分析文本与输入

做了什么:输入框在 TextKit 请求每个 line fragment 时检查 quote 属性。引用块行会被动态缩窄并右移。

为什么这样做:单一 textContainerInset 不能表现首行和普通行不同的引用装饰。重写 fragment 几何能让文字自然绕开边栏。

关键代码摘录

ChatInputTextView.swift · L467–L503
override func lineFragmentRect(
    forProposedRect proposedRect: CGRect,
    at characterIndex: Int,
    writingDirection baseWritingDirection: NSWritingDirection,
    remaining remainingRect: UnsafeMutablePointer<CGRect>?
) -> CGRect {
    var result = super.lineFragmentRect(
        forProposedRect: proposedRect,
        at: characterIndex,
        writingDirection: baseWritingDirection,
        remaining: remainingRect
    )

    result.origin.x -= 5.0
    result.size.width -= 5.0

    if let textStorage = self.layoutManager?.textStorage {
        let string: NSString = textStorage.string as NSString
        let index = Int(characterIndex)
        if index >= 0 && index < string.length {
            let attributes = textStorage.attributes(at: index, effectiveRange: nil)
            let blockQuote = attributes[TextInputAttributes.quote] as? NSObject
            if let blockQuote = blockQuote {
                result.origin.x += 9.0
                result.size.width -= 9.0
                result.size.width -= 7.0

代价与风险:依赖 TextInputAttributes.quote 的对象相等性。TextKit 2 不会自动复用此覆写。

证据链 · #13-15未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

16. 自定义表情不是 NSTextAttachment,而是 glyph rect 上的叠加层

做了什么:代码用 customEmoji 属性找字符范围,再转换为 glyph range 和 bounding rect。外层的 emojiContent 接收这些 rect 并渲染表情。

为什么这样做:NSTextAttachment 难以承载复杂动画,也不易和 spoiler 一起控制。叠加层保留 TextKit 的自然换行。

关键代码摘录

ChatInputTextView.swift · L743–L769
private func validateEmojies() {
    let textStorage = self.customTextStorage
    var rects: [(CGRect, TextInputTextCustomEmojiAttribute)] = []

    textStorage.enumerateAttributes(
        in: NSMakeRange(0, textStorage.length),
        options: [],
        using: { attributes, range, _ in
            if let value = attributes[TextInputAttributes.customEmoji]
                as? TextInputTextCustomEmojiAttribute {
                if let spoiler = attributes[TextInputAttributes.spoiler] as? NSNumber {
                    if spoiler == 1 {
                        return
                    }
                }
                let glyphRange = self.customLayoutManager.glyphRange(
                    forCharacterRange: range,
                    actualCharacterRange: nil
                )
                if self.customLayoutManager.isValidGlyphIndex(glyphRange.location)
                    && self.customLayoutManager.isValidGlyphIndex(
                        glyphRange.location + glyphRange.length - 1
                    ) {
                } else {
                    return
                }

代价与风险:多行或变形 glyph 的 bounding rect 需要额外处理。代码会跳过处于 spoiler 的表情。

证据链 · #13-16未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

17. 发送键先检查 hasMarkedText(),避免截断中文等输入法候选

#13-17TelegramSwiftAppKit源码分析文本与输入

做了什么:Enter 只有在没有 marked text 时才进入发送委托。输入法组合文本存在时,事件继续交给 NSTextView。

为什么这样做:把 Enter 一律当发送会吞掉候选确认。该判断保留系统输入法的 commit 语义。

关键代码摘录

ChatInputTextView.swift · L1163–L1184
public override func keyDown(with theEvent: NSEvent) {
    if let delegate = self.customDelegate {
        if delegate.inputViewIsEnabled(theEvent) {
            if isEnterEvent(theEvent) && !self.hasMarkedText() {
                let result = delegate.inputViewProcessEnter(theEvent)

                if (!result && (theEvent.modifierFlags.contains(.command)
                    || theEvent.modifierFlags.contains(.shift))) {
                    super.insertNewline(self)
                    return
                }

                if result {
                    return
                }
            } else if theEvent.keyCode == 53,
                      delegate.inputViewMaybeClosed() {
                return
            }

            if !theEvent.modifierFlags.contains(.command)
                || !isEnterEvent(theEvent) {
                super.keyDown(with: theEvent)
            }
        }
    } else {
        super.keyDown(with: theEvent)
    }
}

代价与风险:依赖 AppKit 的 hasMarkedText()。自定义输入法或事件注入工具仍需实机验证。

证据链 · #13-17未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

18. 为输入控件单独持久化拼写和语法检查开关

做了什么:InputTextView 覆写 AppKit 拼写和语法开关。它以 TGGrowingTextView 后缀写入 UserDefaults。

为什么这样做:该输入框可独立保存检查偏好。它不必完全依赖系统控件当前的临时状态。

关键代码摘录

ChatInputTextView.swift · L1292–L1309
public override var isContinuousSpellCheckingEnabled: Bool {
    get {
        return UserDefaults.standard.bool(
            forKey: "ContinuousSpellCheckingEnabled\(settingsKey)"
        )
    }
    set {
        UserDefaults.standard.set(
            newValue,
            forKey: "ContinuousSpellCheckingEnabled\(settingsKey)"
        )
        super.isContinuousSpellCheckingEnabled = newValue
    }
}

public override var isGrammarCheckingEnabled: Bool {
    get {
        return UserDefaults.standard.bool(
            forKey: "GrammarCheckingEnabled\(settingsKey)"
        )
    }
    set {
        UserDefaults.standard.set(
            newValue,
            forKey: "GrammarCheckingEnabled\(settingsKey)"
        )
        super.isGrammarCheckingEnabled = newValue
    }
}

代价与风险:bool(forKey:) 对未设置的键返回 false。首次使用会关闭这些检查,除非其他代码先写入默认值。未验证默认值注册路径。

证据链 · #13-18未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

19. 横向滚动直接把纵向滚轮 delta 映射到 x,并在边界冒泡

#13-19TelegramSwiftAppKit源码分析手势与滚动

做了什么:横向容器把触控板的垂直 delta 转为 x 方向。它把 x 对齐到屏幕像素,并在无可滚动内容时交给父视图。

为什么这样做:横向媒体或标签列表也应响应普通滚轮手势。像素对齐减少非整数 offset 下的模糊。

关键代码摘录

HorizontalTableView.swift · L124–L150
override open func scrollWheel(with event: NSEvent) {
    if let applyExternalScroll = self.applyExternalScroll,
       applyExternalScroll(event) {
        return
    }

    var scrollPoint = contentView.bounds.origin
    let isInverted: Bool = System.isScrollInverted
    if event.scrollingDeltaY != 0 {
        if isInverted {
            scrollPoint.x += -event.scrollingDeltaY
        } else {
            scrollPoint.x -= event.scrollingDeltaY
        }
        scrollPoint.x = min(
            max(0, floorToScreenPixels(backingScaleFactor, scrollPoint.x)),
            documentView!.frame.width - frame.width
        )
        clipView.scroll(to: scrollPoint)
        window?.scrollWheel(with: event)
        return
    }

    if documentView!.frame.width > frame.width {
        if event.scrollingDeltaY != 0 || event.scrollingDeltaX != 0 {
            super.scrollWheel(with: event)
        }
    } else {
        superview?.scrollWheel(with: event)
    }
}

代价与风险:主动向 window 或父视图转发事件。嵌套滚动容器的手势竞争需要逐页验证。

证据链 · #13-19未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

20. 数字计数器把字符串拆成多个 TextView,分别滚入和滚出

#13-20TelegramSwiftAppKit源码分析文本与输入

做了什么:DynamicCounterTextView 不直接替换整段文本。它让离场和入场的 token 各自沿 y 轴移动,并叠加透明度动画。

为什么这样做:整段 cross-fade 无法表达数值增减方向。拆分 token 还能保留未变化字符的图层实例。

关键代码摘录

DynamicCounterTextView.swift · L171–L201
if animated {
    switch updated.animation {
    case .crossFade:
        field.layer?.animateAlpha(
            from: 1,
            to: 0,
            duration: duration - 0.1,
            timingFunction: timingFunction,
            removeOnCompletion: false,
            completion: { [weak field] _ in
                field?.removeFromSuperview()
            }
        )
    case .numeric:
        field.layer?.animateAlpha(
            from: 1,
            to: 0,
            duration: duration - 0.1,
            timingFunction: timingFunction,
            removeOnCompletion: false,
            completion: { [weak field] _ in
                field?.removeFromSuperview()
            }
        )

        let direction = addition[key.hashValue]
        switch direction {
        case .forward?:
            field.layer?.animatePosition(
                from: field.frame.origin,
                to: NSMakePoint(field.frame.minX, field.frame.maxY),
                timingFunction: timingFunction,
                removeOnCompletion: false
            )

代价与风险:token 用 hashValue 关联。哈希碰撞虽罕见,但会误匹配文本视图。未验证该类型是否额外保证唯一性。

证据链 · #13-20未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

21. 强制关闭自动行高和默认背景,完全接管 NSTableView 的行几何

#13-21TelegramSwiftAppKit源码分析Core Animation手势与滚动

做了什么:TGFlipableTableView 关闭交替背景、自动行高和图层重绘策略。外层 TableView 负责尺寸、焦点和辅助元素。

为什么这样做:聊天行需要自定义高度缓存、翻转坐标和独立文本渲染。AppKit 的自动行高会和手工 heightOfRow 发生重复测量。

关键代码摘录

TableView.swift · L510–L541
override init(frame frameRect: NSRect) {
    super.init(frame: frameRect)
    backgroundColor = .clear
    self.autoresizesSubviews = false
    usesAlternatingRowBackgroundColors = false
    layerContentsRedrawPolicy = .never
    usesAutomaticRowHeights = false
}

override func becomeFirstResponder() -> Bool {
    return false
}

override func rect(ofRow row: Int) -> NSRect {
    return super.rect(ofRow: row)
}
override func row(at point: NSPoint) -> Int {
    return super.row(at: point)
}
override func rows(in rect: NSRect) -> NSRange {
    return super.rows(in: rect)
}

override func isAccessibilityElement() -> Bool {
    return false
}
override func accessibilityParent() -> Any? {
    return nil
}

override public static var isCompatibleWithResponsiveScrolling: Bool {
    return true
}

代价与风险:该类把自己从辅助功能树移除。外层若没有补充可访问性实现,辅助技术可能无法识别列表。isCompatibleWithResponsiveScrolling 是公开 API,无明显审核风险。

证据链 · #13-21未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

22. 滚动列表更新时按可视范围筛选动画对象

#13-22TelegramSwiftAppKit源码分析手势与滚动

做了什么:TableAnimationInterface 先把插入和删除限制在可见行范围。它再以这些行的高度决定是否需要补偿 content offset。

为什么这样做:屏外行不需要图层动画。筛选减少 merge 期间的 view 创建、CA transaction 和重绘。

关键代码摘录

TableAnimationInterface.swift · L41–L79
var range: NSRange = table.visibleRows(height)

let added = added.filter { item in
    if item.index < range.location
        || item.index > range.location + range.length {
        return false
    } else {
        return true
    }
}

let removed = removed.filter { item in
    if item.index < range.location
        || item.index > range.location + range.length {
        return false
    } else {
        return true
    }
}

for item in added {
    height += item.heightValue
}

for item in removed {
    height -= item.heightValue
}

if previousRange.length == 0 {
    return []
}

代价与风险:屏外变更没有过渡动画。快速滚动到刚变更区域时,用户只能看到最终状态。

证据链 · #13-22未单独核对
源码分析
已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
二进制验证
本条没有单独核对。REA 专题列出了已核对的 20 项。

本页目录