Telegram 技巧档案UIKIT · APPKIT
iOS · UIKit

聊天界面

输入框焦点交接、消息转场与列表到达判定。

主题范围:Telegram-iOS 聊天界面的应用层 UIKit 技巧,包括气泡、输入、媒体键盘、录音、发送转场、滚动、跳转和编辑菜单;不覆盖通用 ListView、私有 API、Portal/Liquid Glass 或键盘窗口。

1. 先交接第一响应者,再释放旧输入框

做了什么:代码先让新输入节点成为第一响应者。代码随后才让旧节点辞去第一响应者。

为什么这样做:这会保留系统键盘的显示状态。常规的先 resign 再 become 会触发键盘收起和重现。

关键代码摘录

ChatTextInputPanelNode.swift · L1124–L1141
/// Replace the current legacy input node with a freshly-built native node, transferring first-responder
/// status (become-new BEFORE resign-old, so the keyboard is not dismissed and re-presented). The caller sets
/// content on the new node immediately afterwards.
private func replaceLegacyWithNative() {
    guard let old = self.richTextInputNode, !old.usesNativeRichTextEngine else {
        return
    }
    let wasFirstResponder = old.isInputFirstResponder
    self.loadTextInputNode(useNative: true)
    guard let new = self.richTextInputNode, new !== old else {
        return
    }
    if wasFirstResponder {
        new.makeInputFirstResponder()
    }
    old.resignInputFirstResponder()
    old.asNode.removeFromSupernode()
}

代价与风险:新节点必须先完成可编辑初始化。否则焦点和内容会短暂不同步。

证据链 · #09-01iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:代码先记录旧节点的焦点状态,加载新节点后转移焦点,最后从父节点移除旧节点。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

2. 文本发送动画分离横纵轴曲线

做了什么:代码把横向和纵向位移拆成两条动画。代码再把输入框背景和内容交给目标气泡。

为什么这样做:气泡从底部输入框飞入时需要不同的速度曲线。单一 UIView 位移动画难以同时匹配两个方向。

关键代码摘录

ChatMessageTransitionNode.swift · L689–L713
let horizontalCurve = ChatMessageTransitionNodeImpl.horizontalAnimationCurve
let horizontalTransition: ContainedViewLayoutTransition = .animated(duration: horizontalDuration, curve: horizontalCurve)
let verticalCurve = ChatMessageTransitionNodeImpl.verticalAnimationCurve
let verticalTransition: ContainedViewLayoutTransition = .animated(duration: verticalDuration, curve: verticalCurve)

let combinedTransition = CombinedTransition(horizontal: horizontalTransition, vertical: verticalTransition)

self.containerNode.frame = targetAbsoluteRect.offsetBy(dx: -self.contextSourceNode.contentRect.minX, dy: -self.contextSourceNode.contentRect.minY)
self.contextSourceNode.updateAbsoluteRect?(self.containerNode.frame, UIScreen.main.bounds.size)

self.containerNode.layer.animatePosition(from: CGPoint(x: 0.0, y: sourceAbsoluteRect.maxY - targetAbsoluteRect.maxY), to: CGPoint(), duration: verticalDuration, delay: delay, mediaTimingFunction: verticalCurve.mediaTimingFunction, additive: true, force: true, completion: { [weak self] _ in
    guard let strongSelf = self else {
        return
    }
    strongSelf.endAnimation()
})
self.containerNode.layer.animatePosition(from: CGPoint(x: sourceAbsoluteRect.minX - targetAbsoluteRect.minX, y: 0.0), to: CGPoint(), duration: horizontalDuration, delay: delay, mediaTimingFunction: horizontalCurve.mediaTimingFunction, additive: true)

if let itemNode = self.itemNode as? ChatMessageBubbleItemNode {
    itemNode.animateContentFromTextInputField(
        textInput: ChatMessageBubbleItemNode.AnimationTransitionTextInput(
            backgroundView: textInput.backgroundView,
            contentView: textInput.contentView,
            sourceRect: textInput.sourceRect,

代价与风险:坐标必须先换算到同一容器。曲线或时长失配会让背景和文字分离。

证据链 · #09-02iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

3. 媒体快照以反向缩放交接给气泡

做了什么:代码把媒体输入快照放进过渡容器。代码同时缩放目标内容和反向缩放快照层。

为什么这样做:两个图层在同一视觉尺寸上交接。常规的直接缩放单个快照会暴露目标内容的突变。

关键代码摘录

ChatMessageTransitionNode.swift · L986–L1010
let combinedTransition = CombinedTransition(horizontal: .animated(duration: horizontalDuration, curve: ChatMessageTransitionNodeImpl.horizontalAnimationCurve), vertical: .animated(duration: verticalDuration, curve: ChatMessageTransitionNodeImpl.verticalAnimationCurve))

if let itemNode = self.itemNode as? ChatMessageBubbleItemNode {
    itemNode.animateContentFromMediaInput(snapshotView: snapshotView, transition: combinedTransition)
}

self.containerNode.frame = targetAbsoluteRect.offsetBy(dx: -self.contextSourceNode.contentRect.minX, dy: -self.contextSourceNode.contentRect.minY)

snapshotView.center = targetAbsoluteRect.center.offsetBy(dx: -self.containerNode.frame.minX, dy: -self.containerNode.frame.minY)
self.containerNode.view.addSubview(snapshotView)

self.contextSourceNode.updateAbsoluteRect?(self.containerNode.frame, UIScreen.main.bounds.size)

self.containerNode.layer.animatePosition(from: CGPoint(x: 0.0, y: sourceAbsoluteRect.midY - targetAbsoluteRect.midY), to: CGPoint(), duration: horizontalDuration, delay: delay, mediaTimingFunction: ChatMessageTransitionNodeImpl.horizontalAnimationCurve.mediaTimingFunction, additive: true, force: true)
self.containerNode.layer.animatePosition(from: CGPoint(x: sourceAbsoluteRect.midX - targetAbsoluteRect.midX, y: 0.0), to: CGPoint(), duration: verticalDuration, delay: delay, mediaTimingFunction: ChatMessageTransitionNodeImpl.verticalAnimationCurve.mediaTimingFunction, additive: true, force: true, completion: { [weak self] _ in
    guard let strongSelf = self else {
        return
    }
    strongSelf.endAnimation()
})

combinedTransition.horizontal.animateTransformScale(node: self.contextSourceNode.contentNode, from: CGPoint(x: sourceBackgroundAbsoluteRect.width / targetAbsoluteRect.width, y: sourceBackgroundAbsoluteRect.height / targetAbsoluteRect.height))

combinedTransition.horizontal.updateTransformScale(layer: snapshotView.layer, scale: CGPoint(x: 1.0 / (sourceBackgroundAbsoluteRect.width / targetAbsoluteRect.width), y: 1.0 / (sourceBackgroundAbsoluteRect.height / targetAbsoluteRect.height)))

代价与风险:源、目标尺寸不能为零。比例计算错误会产生跳帧或拉伸。

证据链 · #09-03iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:CombinedTransition 的横向分量驱动 animateTransformScale 和 updateTransformScale,容器位置用两条 additive 动画分别走横向和纵向曲线。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

4. 录音圆球自己飞入语音气泡

做了什么:代码不截取录音圆球的快照。代码让圆球本身飞向消息内的播放按钮位置。

为什么这样做:圆球可以持续自己的动画状态。静态快照会冻结波纹和音量等动态外观。

关键代码摘录

ChatMessageTransitionNode.swift · L904–L928
// No snapshot of the blob: it flies itself, in its own container, along the same path as the
// play button here in the chat, crossfading into it at the start.
let sourceAbsoluteRect = convertRenderedSourceRect(localRect, from: container, toAnimatingView: self.view)

let combinedTransition = CombinedTransition(horizontal: .animated(duration: horizontalDuration, curve: ChatMessageTransitionNodeImpl.horizontalAnimationCurve), vertical: .animated(duration: verticalDuration, curve: ChatMessageTransitionNodeImpl.verticalAnimationCurve))

if let itemNode = self.itemNode as? ChatMessageBubbleItemNode {
    if let contextContainer = itemNode.animateFromMicInput(sourceSize: sourceAbsoluteRect.size, transition: combinedTransition) {
        self.containerNode.addSubnode(contextContainer.contentNode)

        let targetAbsoluteRect = contextContainer.view.convertAcrossWindows(contextContainer.contentRect, to: self.view)

        self.containerNode.frame = targetAbsoluteRect.offsetBy(dx: -contextContainer.contentRect.minX, dy: -contextContainer.contentRect.minY)
        contextContainer.updateAbsoluteRect?(self.containerNode.frame, UIScreen.main.bounds.size)

        let _ = audioMicInput.micButton.animateDecorationToSentMessage(targetRect: targetAbsoluteRect, in: self.view, duration: verticalDuration, horizontalCurve: ChatMessageTransitionNodeImpl.horizontalAnimationCurve, verticalCurve: ChatMessageTransitionNodeImpl.verticalAnimationCurve)

        self.containerNode.layer.animatePosition(from: CGPoint(x: 0.0, y: sourceAbsoluteRect.midY - targetAbsoluteRect.midY), to: CGPoint(), duration: verticalDuration, delay: delay, mediaTimingFunction: ChatMessageTransitionNodeImpl.verticalAnimationCurve.mediaTimingFunction, additive: true, force: true, completion: { [weak self, weak contextContainer] _ in
            guard let strongSelf = self else {
                return
            }
            if let contextContainer = contextContainer {
                contextContainer.isExtractedToContextPreview = false
                contextContainer.isExtractedToContextPreviewUpdated?(false)
                contextContainer.addSubnode(contextContainer.contentNode)

代价与风险:代码需要在结束时恢复被提取的内容节点。清理遗漏会造成节点丢失或重复挂载。

证据链 · #09-04iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:animateFromMicInput 取出气泡的上下文容器,animateDecorationToSentMessage 驱动圆球。动画结束时,代码把内容节点放回原容器。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

5. 媒体拼图按四个角独立决定合并形状

做了什么:代码为拼图的四个角分别计算邻接状态。代码还区分普通合并和气泡合并。

为什么这样做:拼图边缘和消息组边缘会重叠。统一圆角半径无法表达尾巴和外侧边缘。

关键代码摘录

ChatMessageBubbleItemNode.swift · L3353–L3375
let position = framesAndPositions[mosaicIndex].1

let topLeft: ChatMessageBubbleContentMosaicNeighbor
let topRight: ChatMessageBubbleContentMosaicNeighbor
let bottomLeft: ChatMessageBubbleContentMosaicNeighbor
let bottomRight: ChatMessageBubbleContentMosaicNeighbor

switch firstNodeTopPosition {
case .Neighbour:
    topLeft = .merged
    topRight = .merged
case .BubbleNeighbour:
    topLeft = .mergedBubble
    topRight = .mergedBubble
case let .None(status):
    if position.contains(.top) && position.contains(.left) {
        switch status {
        case .Left, .Both:
            topLeft = .mergedBubble
        case .Right:
            topLeft = .none(tail: false)
        case .None:
            topLeft = .none(tail: false)
        }

代价与风险:状态组合很多。新增拼图位置时必须补齐每个角的分支。

证据链 · #09-05iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:代码按单元在拼图中的位置(.top、.left 等)和上下邻居的合并状态做 switch。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

6. 反应区会改变气泡的底部合并边

做了什么:代码在有反应且不是预览时覆盖底部合并状态。入站消息取左边,出站消息取右边。

为什么这样做:反应区附在气泡下方。它需要同侧的连续轮廓,而不是沿用下一条消息的合并关系。

关键代码摘录

ChatMessageBubbleItemNode.swift · L2395–L2415
let topNodeMergeStatus: ChatMessageBubbleMergeStatus = mergedTop.merged ? (incoming ? .Left : .Right) : .None(incoming ? .Incoming : .Outgoing)
var bottomNodeMergeStatus: ChatMessageBubbleMergeStatus = mergedBottom.merged ? (incoming ? .Left : .Right) : .None(incoming ? .Incoming : .Outgoing)

let bubbleReactions: ReactionsMessageAttribute
if needReactions || forceReactionsOutside {
    bubbleReactions = mergedMessageReactions(attributes: item.message.attributes, isTags: item.message.areReactionsTags(accountPeerId: item.context.account.peerId)) ?? ReactionsMessageAttribute(canViewList: false, isTags: false, reactions: [], recentPeers: [], topPeers: [])
} else {
    bubbleReactions = ReactionsMessageAttribute(canViewList: false, isTags: false, reactions: [], recentPeers: [], topPeers: [])
}
if !bubbleReactions.reactions.isEmpty && !item.presentationData.isPreview {
    if incoming {
        bottomNodeMergeStatus = .Left
    } else {
        bottomNodeMergeStatus = .Right
    }
}

var currentCredibilityIcon: (EmojiStatusComponent.Content, UIColor?)?

let displayEphemeralBadge: Bool
if ephemeralBadgeText != nil && !mergedTop.merged && !hidesHeaders && item.message.adAttribute == nil {

代价与风险:反应显示规则会耦合圆角规则。修改反应位置时要回归测试消息组。

证据链 · #09-06iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

7. 输入框以文本测量值增长,但最多十二行

做了什么:代码用可用宽度测量富文本高度。代码把高度限制在 1 到 12 行,并返回溢出标记。

为什么这样做:输入框会随文本增长,但不会吞没聊天内容。仅依赖 contentSize 很难同时扣除按钮和内边距。

关键代码摘录

ChatTextInputPanelNode.swift · L1410–L1429
var textFieldHeight: CGFloat
var isOverflow = false
if let richTextInputNode = self.richTextInputNode {
    let maxTextWidth = width - textFieldInsets.left - textFieldInsets.right - actualTextInputViewInternalInsets.left - actualTextInputViewInternalInsets.right
    let measuredHeight = richTextInputNode.textHeightForWidth(maxTextWidth, rightInset: textInputViewRealInsets.right)
    
    let unboundTextFieldHeight = max(textFieldMinHeight, ceil(measuredHeight))
    
    let maxNumberOfLines = max(1, min(12, (Int(fieldMaxHeight - 11.0) - 33) / 22))
    
    let updatedMaxHeight = (CGFloat(maxNumberOfLines) * (22.0 + 2.0) + 10.0)
    
    textFieldHeight = max(textFieldMinHeight, unboundTextFieldHeight)
    isOverflow = textFieldHeight > updatedMaxHeight
    textFieldHeight = min(textFieldHeight, updatedMaxHeight)
} else {
    textFieldHeight = textFieldMinHeight
}

return (accessoryButtonsWidth, textFieldHeight, isOverflow)

代价与风险:行高常量是 22。字体或段落样式变化时必须校准上限公式。

证据链 · #09-07iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

8. 快速点按录音键改模式,长按才开始录音

做了什么:代码延迟 0.19 秒才调用 beginRecording。用户在定时器存活时松手,代码切换录音模式。

为什么这样做:同一按钮同时承担音频和视频入口。立即开始录音会让普通点按误录。

关键代码摘录

ChatTextInputMediaRecordingButton.swift · L519–L550
public func micButtonInteractionBegan() {
    if self.fadeDisabled {
        self.recordingDisabled()
    } else {
        self.modeTimeoutTimer?.invalidate()
        let modeTimeoutTimer = SwiftSignalKit.Timer(timeout: 0.19, repeat: false, completion: { [weak self] in
            if let strongSelf = self {
                strongSelf.modeTimeoutTimer = nil
                strongSelf.beginRecording()
            }
        }, queue: Queue.mainQueue())
        self.modeTimeoutTimer = modeTimeoutTimer
        modeTimeoutTimer.start()
    }
}

// ...
public func micButtonInteractionCompleted(_ velocity: CGPoint) {
    if let modeTimeoutTimer = self.modeTimeoutTimer {
        modeTimeoutTimer.invalidate()
        self.modeTimeoutTimer = nil
        self.switchMode()
    }
    self.endRecording(true)
}

代价与风险:0.19 秒会增加长按开始延迟。阈值必须配合触觉反馈测试。

证据链 · #09-08iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:micButtonInteractionBegan 启动一次性 SwiftSignalKit.Timer。micButtonInteractionCompleted 发现定时器仍在时,作废它并调用 switchMode()。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

9. 录音手势把横移、取消位移和锁定拆开上报

做了什么:代码把控制条横向位移和取消提示位移分别保存。代码在锁定事件中上报锁定,并允许锁定。

为什么这样做:上滑锁定和侧滑取消使用不同视觉元素。单一平移值会让两个反馈互相干扰。

关键代码摘录

ChatTextInputMediaRecordingButton.swift · L553–L575
public func micButtonInteractionUpdate(_ offset: CGPoint) {
    self.controlsOffset = offset.x
    self.offsetRecordingControls()
}

public func micButtonInteractionUpdateCancelTranslation(_ translation: CGFloat) {
    self.cancelTranslation = translation
    self.updateCancelTranslation()
}

public func micButtonInteractionLocked() {
    self.updateLocked(true)
}

public func micButtonInteractionRequestedLockedAction() {
}

public func micButtonInteractionStopped() {
    self.stopRecording()
}

public func micButtonShouldLock() -> Bool {
    return true
}

代价与风险:本类无条件返回 true。具体锁定阈值由父类手势实现决定,未在本笔记验证。

证据链 · #09-09iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

10. 媒体面板只在允许时自动切回文字输入

#09-10Telegram-iOSUIKit源码分析文本与输入

做了什么:输入面板请求文字模式时先询问当前实体键盘。代码只把 .media 状态改成 .text。

为什么这样做:某些实体键盘状态不应被自动收回。直接强制切换会打断表情、贴纸或搜索操作。

关键代码摘录

ChatControllerNode.swift · L1035–L1051
self.textInputPanelNode?.switchToTextInputIfNeeded = { [weak self] in
    guard let strongSelf = self, let interfaceInteraction = strongSelf.interfaceInteraction else {
        return
    }
    
    if let inputNode = strongSelf.inputNode as? ChatEntityKeyboardInputNode, !inputNode.canSwitchToTextInputAutomatically {
        return
    }
    
    interfaceInteraction.updateInputModeAndDismissedButtonKeyboardMessageId({ state in
        switch state.inputMode {
        case .media:
            return (.text, state.keyboardButtonsMessage?.id)
        default:
            return (state.inputMode, state.keyboardButtonsMessage?.id)
        }
    })
}

代价与风险:切换许可藏在 canSwitchToTextInputAutomatically。该属性的具体判定未在本笔记验证。

证据链 · #09-10iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

11. 实体键盘把隐藏输入框高度当作组件输入

做了什么:代码缓存完整布局状态。代码把被隐藏的文字输入面板高度传给实体键盘组件。

为什么这样做:表情搜索可扩展 118 点,同时要避免内容压住被隐藏的输入区。只使用键盘总高度会丢失这个局部约束。

关键代码摘录

ChatEntityKeyboardInputNode.swift · L1814–L1836
public override func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, standardInputHeight: CGFloat, inputHeight: CGFloat, maximumHeight: CGFloat, inputPanelHeight: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState, layoutMetrics: LayoutMetrics, deviceMetrics: DeviceMetrics, isVisible: Bool, isExpanded: Bool) -> (CGFloat, CGFloat) {
    self.currentState = (width, leftInset, rightInset, bottomInset, standardInputHeight, inputHeight, maximumHeight, inputPanelHeight, interfaceState, layoutMetrics, deviceMetrics, isVisible, isExpanded)

    let innerTransition: ComponentTransition
    if let scheduledInnerTransition = self.scheduledInnerTransition {
        self.scheduledInnerTransition = nil
        innerTransition = scheduledInnerTransition
    } else {
        innerTransition = ComponentTransition(transition)
    }

    let wasMarkedInputCollapsed = self.isMarkInputCollapsed
    self.isMarkInputCollapsed = false

    var expandedHeight = standardInputHeight
    if self.isEmojiSearchActive && !isExpanded {
        expandedHeight += 118.0
    }

    var hiddenInputHeight: CGFloat = 0.0
    if self.hideInput && !self.adjustLayoutForHiddenInput {
        hiddenInputHeight = inputPanelHeight
    }

代价与风险:hideInput 和 adjustLayoutForHiddenInput 的组合形成状态矩阵。状态切换必须触发重新布局。

证据链 · #09-11iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

12. 表情搜索覆盖层只在首次出现时立即建树

#09-12Telegram-iOSUIKit源码分析文本与输入

做了什么:代码把搜索放在现有键盘上的独立 searchView。首次出现时淡入并按顶部面板位移,退出后淡出再移除。

为什么这样做:Pager 保持存在,搜索内容替换为覆盖层。整体重建键盘会丢失分页位置并增加闪烁。

关键代码摘录

EntityKeyboard.swift · L813–L842
if let current = self.searchView {
    searchView = current
} else {
    searchViewTransition = .immediate
    searchView = ComponentHostView<EntitySearchContentEnvironment>()
    self.searchView = searchView
    self.addSubview(searchView)
    
    animateIn = true
    component.topPanelExtensionUpdated(0.0, transition)
}

// ...
searchViewTransition.setFrame(view: searchView, frame: CGRect(origin: CGPoint(), size: availableSize))
if animateIn {
    transition.animateAlpha(view: searchView, from: 0.0, to: 1.0)
    transition.animatePosition(view: searchView, from: CGPoint(x: 0.0, y: self.topPanelExtension ?? 0.0), to: CGPoint(), additive: true, completion: nil)
}

代价与风险:覆盖层和 Pager 需要一致的 inputHeight。不一致会在键盘边缘出现空隙。

证据链 · #09-12iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

13. 列表更新前只采样一次已稳定的偏移

#09-13Telegram-iOSUIKit报告精选 09-6源码分析

做了什么:代码在准备列表事务时读取一次 settledContentOffsets。代码用同一份样本判断是否贴近最新消息和是否填满屏幕。

为什么这样做:正在动画的两次几何读取可能对应不同帧。分别读取会让位置保持决策自相矛盾。

关键代码摘录

ChatHistoryListNode.swift · L2515–L2534
if let strongSelf = self, updatedScrollPosition == nil, case .InteractiveChanges = reason, let previous = previous {
    // ONE sample feeds both gates below. They are compared against each other to decide a
    // scroll position, and this runs while a transaction is being PREPARED — so the
    // question is what state the list is in, not where a pass in flight happens to have
    // the content at this instant. On ListViewImpl the settled and presented geometries
    // are the same thing; under a hosting backend, two separate reads would let "am I
    // pinned to the newest message" and "does the content fill the screen" describe
    // different moments of the same animation.
    let settledOffsets = strongSelf.listView.settledContentOffsets()

    if case let .known(offset) = settledOffsets.top, abs(offset) <= 0.9 {
        var fillsScreen = true
        switch settledOffsets.bottom {
        case let .known(bottomOffset):
            if bottomOffset <= strongSelf.listView.visibleSize.height - strongSelf.insets.bottom {
                fillsScreen = false
            }
        default:
            break
        }

代价与风险:这依赖列表实现提供稳定偏移。自定义承载后端必须保持该语义。

证据链 · #09-13iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:代码在 .InteractiveChanges 分支开头取一次样本,两个条件都用这份样本。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

14. 未读分割条离屏时强制重新聚焦

#09-14Telegram-iOSUIKit源码分析性能与内存

做了什么:代码遍历现有项目节点。它找到 ChatUnreadItemNode 后立即确保该节点可见。

为什么这样做:未读分割条是用户重新定位的视觉锚点。仅滚动到消息索引会在事务后把分割条留在屏外。

关键代码摘录

ChatHistoryListNode.swift · L2957–L2975
let _ = self.clientId.swap(nextClientId)
nextClientId += 1

self.listView.updateVisibleItemRange(force: true)
}

func refocusOnUnreadMessagesIfNeeded() {
    self.forEachItemNode({ itemNode in
        if let itemNode = itemNode as? ChatUnreadItemNode {
            self.listView.ensureItemNodeVisible(itemNode, animated: false, overflow: 0.0, curve: .Default(duration: nil))
        }
    })
}

private func maybeInsertPendingAdMessage(historyView: ChatHistoryView, toLaterRange: (Int, Int), toEarlierRange: (Int, Int)) {
    if self.pendingDynamicAdMessages.isEmpty {
        return
    }
    
    var eligibleMessageIndices = Set<MessageIndex>()

代价与风险:代码会遍历当前项目节点。超大可见范围下会增加少量主线程工作。

证据链 · #09-14iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

15. 回复跳转不能伪装成新消息到达

#09-15Telegram-iOSUIKit报告精选 09-7源码分析

做了什么:代码在创建内容节点前记录真正的新到达消息。代码明确排除加载另一段历史,包括回复跳转。

为什么这样做:回复跳转的插入项目只是加载结果。把它当作收消息会错误播放到达转场。

关键代码摘录

ChatHistoryListNode.swift · L4095–L4115
private func dequeueHistoryViewTransitions() {
    if self.enqueuedHistoryViewTransitions.isEmpty || self.hasActiveTransition {
        return
    }
    self.hasActiveTransition = true
    let transition = self.enqueuedHistoryViewTransitions.removeFirst()

    // Record live arrivals before creating their content nodes. Loading another history
    // window (including a reply jump) must never look like a newly received transfer.
    if case .InteractiveChanges = transition.reason,
       !transition.insertItems.isEmpty,
       self.controllerInteraction.canReadHistory,
       let previous = self.historyView,
       previous.id == transition.historyView.id,
       !previous.originalView.isLoading, previous.originalView.laterId == nil,
       transition.historyView.originalView.laterId == nil {
        let previousIds = Set(previous.originalView.entries.map { $0.message.id })
        let previousLastIndex = previous.originalView.entries.map { $0.message.index }.max()
        for entry in transition.historyView.originalView.entries {
            let message = entry.message

代价与风险:到达判定依赖历史视图的 id、加载状态和 laterId。数据源语义改变时必须复核。

证据链 · #09-15iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:判定条件是:同一个 historyView.id,上一视图不在加载中,前后两个视图的 laterId 都为 nil。满足后,代码再与旧消息 ID 集合比较。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

16. 跳转请求同时携带高亮、引用和回复设置

#09-16Telegram-iOSUIKit源码分析手势与滚动

做了什么:代码把高亮、引用片段、回复主题和滚动锚点封装进一次 .Scroll 请求。每次请求还生成新的位置标识。

为什么这样做:视觉定位不是单纯的偏移。只传目标消息无法表达引用高亮或跳转后打开回复状态。

关键代码摘录

ChatHistoryListNode.swift · L3856–L3879
if let currentMessage = currentMessage {
    self.chatHistoryLocationValue = ChatHistoryLocationInput(content: .Scroll(subject: MessageHistoryScrollToSubject(index: .message(currentMessage.index), quote: nil), anchorIndex: .message(currentMessage.index), sourceIndex: .upperBound, scrollPosition: .top(0.0), animated: true, highlight: true, setupReply: false), id: self.takeNextHistoryLocationId())
}
}
}

public func scrollToStartOfHistory() {
    self.beganDragging?()
    self.chatHistoryLocationValue = ChatHistoryLocationInput(content: .Scroll(subject: MessageHistoryScrollToSubject(index: .lowerBound, quote: nil), anchorIndex: .lowerBound, sourceIndex: .upperBound, scrollPosition: .bottom(0.0), animated: true, highlight: false, setupReply: false), id: self.takeNextHistoryLocationId())
}

public func scrollToEndOfHistory() {
    self.beganDragging?()
    switch self.visibleContentOffset() {
    case let .known(value) where value <= CGFloat.ulpOfOne:
        break
    default:
        let locationInput = ChatHistoryLocationInput(content: .Scroll(subject: MessageHistoryScrollToSubject(index: .upperBound, quote: nil), anchorIndex: .upperBound, sourceIndex: .lowerBound, scrollPosition: .top(0.0), animated: true, highlight: false, setupReply: false), id: self.takeNextHistoryLocationId())
        self.chatHistoryLocationValue = locationInput
    }
}

public func scrollToMessage(from fromIndex: MessageIndex, to toIndex: MessageIndex, animated: Bool, highlight: Bool = true, quote: (string: String, offset: Int?)? = nil, subject: EngineMessageReplyInnerSubject? = nil, scrollPosition: ListViewScrollPosition = .center(.bottom), setupReply: Bool = false) {
    self.chatHistoryLocationValue = ChatHistoryLocationInput(content: .Scroll(subject: MessageHistoryScrollToSubject(index: .message(toIndex), quote: quote.flatMap { quote in MessageHistoryScrollToSubject.Quote(string: quote.string, offset: quote.offset) }, subject: subject, setupReply: setupReply), anchorIndex: .message(toIndex), sourceIndex: .message(fromIndex), scrollPosition: scrollPosition, animated: animated, highlight: highlight, setupReply: setupReply), id: self.takeNextHistoryLocationId())
}

代价与风险:请求参数很长。新增跳转语义时容易遗漏某个布尔值。

证据链 · #09-16iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

17. 用标识符替换编辑器格式菜单,保留系统菜单其余项

做了什么:代码移除编辑器自带的格式子菜单。代码按固定位置插入聊天输入框自己的格式菜单。

为什么这样做:它保留默认菜单的其他系统能力。按本地化标题筛选会随语言变化而失效。

关键代码摘录

ChatTextInputPanelNode.swift · L5201–L5220
children.append(contentsOf: [
    UIAction(title: self.strings?.TextFormat_Strikethrough ?? "Strikethrough", image: nil) { [weak richTextInputNode] _ in
        richTextInputNode?.performFormatAction(.strikethrough)
    },
    UIAction(title: self.strings?.TextFormat_Underline ?? "Underline", image: nil) { [weak richTextInputNode] _ in
        richTextInputNode?.performFormatAction(.underline)
    }
])
children.append(UIAction(title: self.strings?.TextFormat_Code ?? "Code", image: nil) { [weak richTextInputNode] _ in
    richTextInputNode?.performFormatAction(.code)
})

let formatMenu = UIMenu(title: self.strings?.TextFormat_Format ?? "Format", image: nil, children: children)

// Drop the editor's built-in "Format" submenu by its language-independent identifier, then
// splice in the composer's after the system Cut/Copy/Paste actions.
var elements = defaultElements.filter { ($0 as? UIMenu)?.identifier != UIMenu.Identifier("org.telegram.RichTextEditor.format") }
let insertIndex = min(1, elements.count)
elements.insert(formatMenu, at: insertIndex)
return elements
}

代价与风险:自定义标识符属于编辑器契约。编辑器升级后必须验证该标识符仍存在。

证据链 · #09-17iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:代码按 UIMenu.Identifier("org.telegram.RichTextEditor.format") 过滤 defaultElements,再在索引 1 插入新菜单。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

18. 录音期间禁用交互式返回手势

#09-18Telegram-iOSUIKit源码分析手势与滚动

做了什么:代码注册窗口平移识别器后,提供交互式页面转场的动态禁用回调。录音状态会令回调返回 true。

为什么这样做:录音期间的侧滑返回会中断录音交互。常规返回手势不会知道聊天的录音状态。

关键代码摘录

ChatControllerNode.swift · L1098–L1120
self.panRecognizer = recognizer
self.view.addGestureRecognizer(recognizer)

self.view.disablesInteractiveTransitionGestureRecognizerNow = { [weak self] in
    guard let strongSelf = self else {
        return false
    }
    if let _ = strongSelf.chatPresentationInterfaceState.inputTextPanelState.mediaRecordingState {
        return true
    }
    var hasChatThemeScreen = false
    strongSelf.controller?.window?.forEachController { c in
        if c is ChatThemeScreen {
            hasChatThemeScreen = true
        }
    }
    if hasChatThemeScreen {
        return true
    }
    
    return false
}
}

代价与风险:主题页面也会禁用返回手势。新增全屏聊天状态时必须同步加入该回调。

证据链 · #09-18iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

本页目录