Telegram 技巧档案UIKIT · APPKIT
iOS · UIKit

导航、手势与上下文菜单

_UIPortalView 跨层显示、上下文菜单与手势仲裁。

本主题范围:Telegram-iOS 的自研导航、手势仲裁、上下文菜单、最小化容器和 GalleryUI 转场;不含普通业务逻辑。

1. 用私有 _UIPortalView 把同一内容跨层显示

#06-01Telegram-iOSUIKit报告精选 06-1源码分析私有 API

位置说明:Telegram-iOS:submodules/Display/Source/PortalView.swift#L4-L35;私有 API 定义:Telegram-iOS:submodules/UIKitRuntimeUtils/Source/UIKitRuntimeUtils/UIKitUtils.m#L211-L235

做了什么:代码创建一个 Portal 视图。它镜像源视图的位置和变换。它不镜像 alpha,也不接收命中测试。PortalView 会把源视图赋给 sourceView,并强制插回原层级索引。

为什么这样做:上下文菜单需要把消息气泡显示在菜单和导航栏之上。直接移动真实气泡会破坏聊天树。普通快照不能继续播放或响应其内容状态。Portal 可在两个层级间显示同一份内容。

关键代码摘录

PortalView.swift · L4–L35 等 2 处
UIView<UIKitPortalViewProtocol> * _Nullable makePortalView(bool matchPosition) {
    static Class portalViewClass = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        portalViewClass = NSClassFromString([@[@"_", @"UI", @"Portal", @"View"] componentsJoinedByString:@""]);
    });
    if (!portalViewClass) {
        return nil;
    }
    UIView<UIKitPortalViewProtocol> *view = [[portalViewClass alloc] init];
    if (@available(iOS 14.0, *)) {
        view.forwardsClientHitTestingToSourceView = false;
    }
    view.matchesPosition = matchPosition;
    view.matchesTransform = matchPosition;
    view.matchesAlpha = false;
    if (@available(iOS 14.0, *)) {
        view.allowsHitTesting = false;
    }
    return view;
}

私有 API 解混淆:数组拼接结果为 _UIPortalView。我依据 @"_" + @"UI" + @"Portal" + @"View" 和 NSClassFromString 确认。相关属性包括 matchesPosition、matchesTransform 和 allowsHitTesting。

代价与风险:这是私有 UIKit API。App Store 审核和系统兼容性风险很高。类不存在时会返回 nil。

证据链 · #06-01iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
报告摘要
怎么做到:NSClassFromString 拼接类名,属性按版本保护设置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

2. 用 Portal 暂存源内容,并在动画末尾明确归还

做了什么:代码先把真实内容重挂到 PortalSourceView。它再把 Portal 镜像放入覆盖层。动画完成时,代码调用 settle,把真实内容重挂回菜单偏移容器或原父视图。

为什么这样做:源气泡要穿过导航栏和菜单镀层。只移动内容会被原聊天容器裁剪。只移动快照会失去动态内容。此做法保留真实内容,并让镜像承担跨层可见性。

关键代码摘录

ContextControllerExtractedPresentationNode.swift · L175–L196
let wrapperFrameInWindow = CGRect(origin: wrapperOriginInWindow, size: containingItem.view.bounds.size)
wrapper.frame = surface.convert(wrapperFrameInWindow, from: nil)
surface.addSubview(wrapper)

switch containingItem {
case let .node(containingNode):
    wrapper.addSubview(containingNode.contentNode.view)
case let .view(containingView):
    wrapper.addSubview(containingView.contentView)
}

wrapper.addPortal(view: clone)
overlayHost.addSubview(clone.view)

self.surface = surface
self.wrapper = wrapper
self.clone = clone
self.containingItem = containingItem

return wrapper.layer

代价与风险:该路径依赖上一条的私有 _UIPortalView。代码注释承认 contentNode.frame.origin == .zero 是隐藏前提。未来调用方改变布局会造成位置误差。

证据链 · #06-02iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:在容器中 addPortal(view:) 并在 settle 阶段复位。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

3. 给透明背景塞入 1×1 图片,修复“看得见但点不到”的菜单外部点击

#06-03Telegram-iOSUIKit报告精选 06-3源码分析私有 APICore Animation

做了什么:菜单背景层即使颜色为 .clear,也会拥有完全透明的 contents。这使背景层继续参与触摸投递。它因此可以接住菜单外的点击并关闭菜单。

为什么这样做:覆盖一个透明 UIView 不一定足够。此处注释记录了 iOS 27 的实测结果。透明背景和极低 alpha 会受未文档化阈值影响。纯透明图片避免了视觉副作用。

关键代码摘录

ContextSourceContainer.swift · L410–L423
// This is what catches a tap outside the menu contents, which is what dismisses the menu.
// A layer that renders nothing at all does not receive those taps.
// The filter is not in `-hitTest:`.
// It is in touch delivery, against what the layer actually renders.
// Giving the layer fully transparent contents is enough to make it participate.
// It does not depend on an undocumented opacity threshold.
// Invisible, and harmless for the dim/blur cases.
self.backgroundNode.layer.contents = transparentHitTestImage.cgImage

for i in 0 ..< configuration.sources.count {
    let source = configuration.sources[i]
    let mappedSource = Source(
        controller: controller,
        id: source.id,
        title: source.title,
        footer: source.footer,

代价与风险:这是依赖 Core Animation 触摸投递行为的兼容性技巧。它不是私有 API。未来系统改变透明内容的命中规则时需要回归测试。

证据链 · #06-03iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:构造 1×1 透明图,赋值给 backgroundNode.layer.contents。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

4. 提取到未缩放覆盖层前,补回祖先缩放

做了什么:代码把源视图的窗口可见宽度除以模型宽度。它把检测到的缩放施加到提取容器。它还保存该缩放,供退场补偿使用。

为什么这样做:消息气泡可能位于已缩放的 sheet 内。覆盖窗口通常没有该祖先变换。若直接重挂,气泡会瞬间跳成 1:1 大小。读取窗口坐标能捕获实际视觉缩放。

关键代码摘录

ContextControllerExtractedPresentationNode.swift · L776–L800
let contentNodeValue = ItemContentNode(containingItem: takeInfo.containingItem)
contentNodeValue.animateClippingFromContentAreaInScreenSpace = takeInfo.contentAreaInScreenSpace
contentNodeValue.sourceTransitionSurface = takeInfo.sourceTransitionSurface

let sourceView = takeInfo.containingItem.view
let modeledWidth = sourceView.bounds.width
if modeledWidth > 0.001 {
    let visualWidth = sourceView.convert(sourceView.bounds, to: nil).width
    let detectedScale = visualWidth / modeledWidth
    if abs(detectedScale - 1.0) > 0.001 {
        contentNodeValue.presentationScale = detectedScale
        contentNodeValue.offsetContainerNode.layer.transform = CATransform3DMakeScale(
            detectedScale, detectedScale, 1.0
        )
    }
}

self.scrollNode.insertSubnode(contentNodeValue, aboveSubnode: self.actionsContainerNode)
self.itemContentNode = contentNodeValue

代价与风险:代码只从宽度推导等比缩放。非等比变换、旋转和透视未验证。Portal 路径仅在 presentationScale == 1.0 时启用。

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

5. 在退场中用 presentation layer 重定向落点

做了什么:源消息在菜单退场时改高度,代码不重新跑整套布局。它读取 presentation() 与模型层的残差。它用稳定 key 替换一条 bounds.origin.y 的加法动画,并只使用剩余时长。

为什么这样做:退场期间,源消息可能因反应行变化而上移。普通重布局会让气泡跳到旧目标。重新发 position.y 会干扰带完成回调的主动画。独立的 bounds.origin.y 保留主动画语义。

关键代码摘录

ContextControllerExtractedPresentationNode.swift · L2271–L2295
let displacement = residual + modelDelta / compensationScale

var durationFactor = UIView.animationDurationFactor()
if durationFactor.isZero {
    durationFactor = 1.0
}
let elapsed = (CACurrentMediaTime() - animatingOutState.startTimestamp) / durationFactor
let remainingDuration = max(0.0, animatingOutState.duration - elapsed)

let key = "animateOutRetarget"
if abs(displacement) < CGFloat.ulpOfOne || remainingDuration < Double.ulpOfOne {
    animateLayer.removeAnimation(forKey: key)
} else {
    animateLayer.animate(
        from: displacement as NSNumber,
        to: 0.0 as NSNumber,
        keyPath: "bounds.origin.y",
        timingFunction: animatingOutState.timingFunction,
        duration: remainingDuration,
        delay: 0.0,
        additive: true,
        key: key
    )
}

代价与风险:此逻辑强依赖 Core Animation 的模型层和展示层差异。它必须避免 from == to,否则动画不会运行或回调。维护成本较高。

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

6. 全屏右滑返回先查命中视图链,再用方向阈值裁决

做了什么:识别器从 hitTest 结果向上查找。子视图可声明“有横向手势”或“严格禁止”。普通横向内容会禁用屏幕中央返回。边缘返回仍可保留。移动超过 10 pt 后,识别器才固定横向主方向。

为什么这样做:单纯 UIScreenEdgePanGestureRecognizer 不能实现 Telegram 的全屏返回。单纯全屏 UIPanGestureRecognizer 会抢走轮播、媒体和横向列表。此方案把页面语义交给命中视图,并保留边缘逃生通道。

关键代码摘录

InteractiveTransitionGestureRecognizer.swift · L106–L128 等 2 处
if let target = self.view?.hitTest(self.firstLocation, with: event) {
    let horizontalGestures = hasHorizontalGestures(
        target, point: self.view?.convert(self.firstLocation, to: target)
    )
    switch horizontalGestures {
    case .some, .strict:
        if allowedDirections.contains(.down) {
        } else if case .strict = horizontalGestures {
            allowedDirections = []
        } else if allowedDirections.contains(.leftEdge) || allowedDirections.contains(.rightEdge) {
            allowedDirections.remove(.leftCenter)
            allowedDirections.remove(.rightCenter)
        }
    case .none:
        break
    }
}
if allowedDirections.isEmpty {
    self.state = .failed
} else {
    self.currentAllowedDirections = allowedDirections
}

代价与风险:优先级依赖多个自定义 UIView 扩展属性。未注册这些属性的横向控件可能被误判。阈值为经验值,需要真机调校。

证据链 · #06-06iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
报告摘要
怎么做到:检测 10pt 后锁定方向,边缘保留逃生通道。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

7. 手工驱动返回进度,并同步键盘和状态栏

#06-07Telegram-iOSUIKit报告精选 06-5源码分析文本与输入

做了什么:右滑开始时,导航容器预先布局底层控制器。每个交互进度会移动键盘左边界。进度达到 0.3 时,状态栏风格提前切到目标页。结束条件为速度超过 1000,或进度超过 0.2。

为什么这样做:系统导航转场不理解自建 KeyboardViewManager。如果键盘和状态栏不随页面滑动,转场会出现残留或突变。预先布局底层页也避免首次显示空白。

关键代码摘录

NavigationContainer.swift · L236–L313
let navigationTransitionCoordinator = NavigationTransitionCoordinator(transition: .Pop, isInteractive: true, isFlat: self.isFlat, container: self, topNode: topNode, topNavigationBar: topController.transitionNavigationBar, bottomNode: bottomNode, bottomNavigationBar: bottomController.transitionNavigationBar, screenCornerRadius: screenCornerRadius, didUpdateProgress: { [weak self, weak bottomController] progress, transition, topFrame, bottomFrame in
    if let strongSelf = self {
        if let top = strongSelf.state.top {
            strongSelf.syncKeyboard(leftEdge: top.value.displayNode.frame.minX, transition: transition)
            
            var updatedStatusBarStyle = strongSelf.statusBarStyle
            if let bottomController = bottomController, progress >= 0.3 {
                updatedStatusBarStyle = bottomController.statusBar.statusBarStyle
            } else {
                updatedStatusBarStyle = top.value.statusBar.statusBarStyle
            }
            if strongSelf.statusBarStyle != updatedStatusBarStyle {
                strongSelf.statusBarStyle = updatedStatusBarStyle
                strongSelf.statusBarStyleUpdated?(.animated(duration: 0.3, curve: .easeInOut))
            }
        }
    }
})
bottomController.displayNode.recursivelyEnsureDisplaySynchronously(true)

代价与风险:状态栏与完成阈值是硬编码。复杂输入框的外观生命周期需要额外处理。代码会临时忽略输入高度并关闭编辑状态。

证据链 · #06-07iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:在进度回调中更新 syncKeyboard 与状态栏回调。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

8. 用像素对齐的手工几何实现导航视差和阴影

#06-08Telegram-iOSUIKit源码分析Core Animation性能与内存

做了什么:协调器把顶部页、底部页、遮罩和阴影拆成独立节点。顶部页使用屏幕像素对齐。非平面容器让底部页只走 30% 距离,形成视差。初始化时它还持有最大帧率的 SharedDisplayLinkDriver 链接。

为什么这样做:UINavigationController 的默认转场难以匹配自定义容器。分别计算帧可让手势进度直接映射显示。像素对齐减少文字和边界在慢拖动时的抖动。

关键代码摘录

NavigationTransitionCoordinator.swift · L158–L187
let containerSize = self.container.bounds.size
let topFrame = CGRect(
    origin: CGPoint(x: floorToScreenPixels(position * containerSize.width), y: 0.0),
    size: containerSize
)
let bottomFrame = CGRect(
    origin: CGPoint(
        x: self.isFlat ? -floorToScreenPixels((1.0 - position) * containerSize.width)
                       : ((position - 1.0) * containerSize.width * 0.3),
        y: 0.0
    ),
    size: containerSize
)
transition.updateFrame(node: self.topNode, frame: topFrame, completion: { _ in
    if canInvokeCompletion { completion() } else { hadEarlyCompletion = true }
})
transition.updateFrame(node: self.dimNode, frame: CGRect(
    origin: CGPoint(x: 0.0, y: dimInset),
    size: CGSize(width: max(0.0, topFrame.minX + self.container.overflowInset + 62.0), height: self.container.bounds.size.height - dimInset)
))
transition.updateAlpha(node: self.dimNode, alpha: (1.0 - position) * 0.15)
transition.updateFrame(node: self.bottomNode, frame: bottomFrame)

代价与风险:多个覆盖节点增加图层合成成本。手工维护导航生命周期、圆角和完成回调,复杂度高。

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

9. 转场时临时重挂两个导航栏,再用裁剪容器切开

做了什么:容器保存两个导航栏的原父节点。它在转场时把两个导航栏重挂进独立裁剪节点。layout() 根据进度移动顶部裁剪区域,并收窄底部可见宽度。完成后它再归还导航栏。

为什么这样做:直接给两个导航栏做平移,会让顶部栏盖住底部栏。裁剪宽度能让两个栏在交界处干净拼接。它也不需要为每个按钮创建快照。

关键代码摘录

NavigationBarTransitionContainer.swift · L22–L68
self.topNavigationBar = topNavigationBar
self.topNavigationBarSupernode = topNavigationBar.supernode
self.bottomNavigationBar = bottomNavigationBar
self.bottomNavigationBarSupernode = bottomNavigationBar.supernode

self.topClippingNode = ASDisplayNode()
self.topClippingNode.clipsToBounds = true
self.bottomClippingNode = ASDisplayNode()
self.bottomClippingNode.clipsToBounds = true

self.topClippingNode.addSubnode(self.topNavigationBar)
self.bottomClippingNode.addSubnode(self.bottomNavigationBar)
self.addSubnode(self.bottomClippingNode)
self.addSubnode(self.topClippingNode)

func complete() {
    self.topNavigationBarSupernode?.addSubnode(self.topNavigationBar)
    self.bottomNavigationBarSupernode?.addSubnode(self.bottomNavigationBar)
}

代价与风险:转场中父节点关系会变化。任何缓存 supernode 的调用方都可能出错。该容器需要保证完成和取消路径都归还节点。

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

10. 上下文长按先避开系统左边缘,再主动取消整棵手势树

#06-10Telegram-iOSUIKit源码分析手势与滚动

做了什么:ContextGesture 会拒绝窗口左侧 8 pt 内的按压。延迟后它用显示链接动画按压进度。真正激活时,它取消窗口中的同类手势,并沿父视图链把其他手势置为 .failed。

为什么这样做:左边缘属于系统返回和应用全屏返回的保留区域。普通长按不能可靠解决已开始的列表点击和父级手势。主动取消使菜单开启后只保留一个交互所有者。

关键代码摘录

ContextGesture.swift · L130–L178
let windowLocation = touch.location(in: nil)
if windowLocation.x < 8.0 {
    self.state = .failed
    return
}

let delayTimer = Timer(timeInterval: self.beginDelay, target: TimerTargetWrapper { [weak self] in
    guard let strongSelf = self, let _ = strongSelf.delayTimer else {
        return
    }
    strongSelf.isValidated = true
    if strongSelf.animator == nil {
        strongSelf.animator = DisplayLinkAnimator(duration: 0.2, from: 0.0, to: 1.0, update: { value in
            guard let strongSelf = self else {
                return
            }
            if strongSelf.isValidated {
                strongSelf.currentProgress = value
                strongSelf.activationProgress?(value, .update)
            }
        }, completion: {
            guard let strongSelf = self else {
                return
            }
            switch strongSelf.state {
            case .possible:
                strongSelf.delayTimer?.invalidate()
                strongSelf.animator?.invalidate()
                strongSelf.activated?(strongSelf, location)
                strongSelf.wasActivated = true
                // ... 取消同窗口和父链手势。
                strongSelf.state = .began
            default:
                break
            }
        })
    }
}, selector: #selector(TimerTargetWrapper.timerEvent), userInfo: nil, repeats: false)

代价与风险:手工改写其他识别器的 state 属于侵入式仲裁。新增手势需要验证是否被误取消。左边缘 8 pt 会降低极边位置的菜单可用性。

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

11. 一个识别器同时处理点击、双击、长按、右键和按住

做了什么:该自定义识别器根据命中位置选择等待策略。它区分单击、双击、长按、二级点击和 hold。hold 分支会先 prepareTap()。长按触发时还会取消所有同类识别器,并结束祖先滚动视图的 pan。

为什么这样做:多个标准识别器需要 require(toFail:),延迟和冲突难以按位置变化。该状态机只保留一个计时器。它还避免抢走 UIButton 的标准点击。

关键代码摘录

TapLongTapOrDoubleTapGestureRecognizer.swift · L181–L239
if #available(iOS 13.4, *) {
    isSecondary = event.buttonMask == .secondary
} else {
    isSecondary = false
}

if let hitResult = self.view?.hitTest(touch.location(in: self.view), with: event),
   let _ = hitResult as? UIButton {
    self.state = .failed
    return
}

self.tapCount += 1
if self.tapCount == 2 && self.touchCount == 1 {
    self.timer?.invalidate()
    self.timer = nil
    self.lastRecognizedGestureAndLocation = (.doubleTap, self.location(in: self.view))
    self.state = .ended
} else {
    let touchLocationAndTimestamp = (touch.location(in: self.view), CACurrentMediaTime())
    self.touchLocationAndTimestamp = touchLocationAndTimestamp
    var tapAction: TapLongTapOrDoubleTapGestureRecognizerAction = .waitForDoubleTap
    if let tapActionAtPoint = self.tapActionAtPoint {
        tapAction = tapActionAtPoint(touchLocationAndTimestamp.0)
    }

代价与风险:默认 0.3 秒和单击后 0.16 秒是人为延迟。cancelScrollViewGestures 会直接结束祖先 UIPanGestureRecognizer。这可能影响第三方视图。

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

12. Peek 从源矩形弹出,并按激活模式切换模糊策略

做了什么:Peek 根据源矩形中心计算加法位置弹簧。已出现的内容会按源宽度与内容宽度的比例缩放进入。构造阶段中,拖动激活使用 UIVisualEffectView 模糊。按压激活使用半透明纯色遮罩。构造阶段还预热了触感。

为什么这样做:源到预览的几何连续性比固定中心缩放自然。拖动菜单需要背景退后,按压菜单则需要更直接的按下反馈。普通模态展示无法表达这两种交互。

关键代码摘录

PeekControllerNode.swift · L271–L300
func animateIn(from rect: CGRect) {
    self.dimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
    self.blurView.layer.animateAlpha(from: 0.0, to: self.blurView.alpha, duration: 0.3)

    let offset = CGPoint(
        x: rect.midX - self.containerNode.position.x,
        y: rect.midY - self.containerNode.position.y
    )
    self.containerNode.layer.animateSpring(
        from: NSValue(cgPoint: offset), to: NSValue(cgPoint: CGPoint()),
        keyPath: "position", duration: 0.4, initialVelocity: 0.0,
        damping: 110.0, additive: true
    )

    if let appeared = self.controller?.appeared {
        appeared()
        let scale = rect.width / self.contentNode.frame.width
        self.containerNode.layer.animateSpring(
            from: scale as NSNumber, to: 1.0 as NSNumber,
            keyPath: "transform.scale", duration: 0.4, initialVelocity: 0.0, damping: 110.0
        )
    }
}

代价与风险:缩放只依据宽度。极窄源矩形可能产生激烈缩放。UIVisualEffectView 在复杂背景上的合成成本较高。

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

13. 仅在最内层滚动视图位于顶部时才认可下滑关闭

做了什么:识别器递归从触点寻找 UIScrollView。它只在滚动位置不低于顶部 inset 时保留资格。它超过 5 pt 的横向位移就失败。下移超过 2 pt 才开始。

为什么这样做:滚动内容没有回到顶部时,下拉应该继续滚动,而不是关闭页面。内部委托允许与其他手势同时识别,却要求自己输给普通 UIPanGestureRecognizer。

关键代码摘录

SwipeToDismissGestureRecognizer.swift · L48–L107
var found = false
let point = touch.location(in: self.view)
if let scrollView = traceScrollView(view: view, point: point) {
    let contentOffset = scrollView.contentOffset
    let contentInset = scrollView.contentInset
    if contentOffset.y.isLessThanOrEqualTo(contentInset.top) {
        found = true
    }
}
if found {
    self.beginPosition = point
} else {
    self.state = .failed
}

let translation = CGPoint(x: point.x - self.beginPosition.x, y: point.y - self.beginPosition.y)
if self.state == .possible {
    if abs(translation.x) > 5.0 {
        self.state = .failed
        return
    }
    if lockDown && translation.y > 2.0 {
        self.state = .began
    }
}

代价与风险:递归扫描所有子视图。深层和动态视图树会增加命中成本。该规则只识别 UIScrollView,自定义滚动容器需要适配。

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

14. 最小化后延迟把活控制器换成快照

做了什么:最小化卡片先承载真实控制器视图。0.45 秒后,它抓取内容快照。它移除真实视图,只留下快照和自有标题栏。

为什么这样做:最小化容器可显示多个全屏 Web App 或控制器。持续挂载真实页面会造成绘制、计时器和触摸负担。延迟能保证最小化进场使用真实内容,再转为轻量静态卡片。

关键代码摘录

MinimizedContainer.swift · L125–L137
self.addSubnode(self.containerNode)
self.controllerView = self.item.controller.displayNode.view
self.containerNode.view.addSubview(self.item.controller.displayNode.view)

Queue.mainQueue().after(0.45) {
    self.isReady = true
    if !self.isDismissed, let snapshotView = self.item.controller.makeContentSnapshotView() {
        self.containerNode.view.addSubview(self.snapshotContainerView)
        self.snapshotView = snapshotView
        self.controllerView?.removeFromSuperview()
        self.controllerView = nil
        self.snapshotContainerView.addSubview(snapshotView)
        self.requestLayout(transition: .immediate)
    }
}

self.addSubnode(self.headerNode)
self.addSubnode(self.dimCoverNode)

代价与风险:快照不更新。短暂的真实视图与快照切换可能产生闪烁。控制器必须正确实现 makeContentSnapshotView()。

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

15. 用私有 CAFilter 给快照补背景模糊

#06-15Telegram-iOSUIKit源码分析私有 APICore Animation

位置说明:Telegram-iOS:submodules/TelegramUI/Components/MinimizedContainer/Sources/MinimizedContainer.swift#L272-L295;私有 API 定义:Telegram-iOS:submodules/UIKitRuntimeUtils/Source/UIKitRuntimeUtils/UIKitUtils.m#L259-L267

做了什么:卡片快照比例不匹配容器时,代码另抓一个快照。它给该快照的 layer.filters 设置高斯模糊,再放到清晰快照下方填满空白区域。

为什么这样做:简单拉伸快照会变形。纯色边缘会显得突兀。模糊副本能延展背景,同时保留原始内容比例。

关键代码摘录

MinimizedContainer.swift · L272–L295 等 2 处
if requiresBlur {
    let blurredSnapshotView: UIView?
    if let current = self.blurredSnapshotView {
        blurredSnapshotView = current
    } else {
        blurredSnapshotView = snapshotView.snapshotView(afterScreenUpdates: false)
        if let blurredSnapshotView {
            if let blurFilter = makeBlurFilter() {
                blurFilter.setValue(20.0 as NSNumber, forKey: "inputRadius")
                blurFilter.setValue(true as NSNumber, forKey: "inputNormalizeEdges")
                blurredSnapshotView.layer.filters = [blurFilter]
            }
            self.snapshotContainerView.insertSubview(blurredSnapshotView, at: 0)
            self.blurredSnapshotView = blurredSnapshotView
        }
    }
    blurredSnapshotView?.frame = blurFrame
} else if let blurredSnapshotView = self.blurredSnapshotView {
    self.blurredSnapshotView = nil
    blurredSnapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2,
        removeOnCompletion: false, completion: { _ in blurredSnapshotView.removeFromSuperview() })
}

私有 API 解混淆:makeBlurFilter() 直接执行 NSClassFromString(@"CAFilter"),再调用 filterWithName:@"gaussianBlur"。真实私有类名为 CAFilter。这不是字符串拼接混淆。

代价与风险:CAFilter 和 CALayer.filters 的此用法是私有 API。审核风险很高。滤镜半径与键值也可能随系统变化。

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

16. 用 3D 变换把最小化页面堆成可滚动卡片组

#06-16Telegram-iOSUIKit源码分析Metal / GPU手势与滚动

做了什么:展开后,每张卡片按其滚动位置计算 final3dTransform。高亮卡片额外旋转 0.04。关闭中的卡片单独接受横向偏移。折叠时,非顶卡按比例缩小并下沉。

为什么这样做:普通 UITableView 的平铺卡片缺少任务切换器的深度感。变换与滚动边界绑定,因此滚动时卡片会连续倾斜。代码还将最上卡 zPosition 设为 10000,防止被遮盖。

关键代码摘录

MinimizedContainer.swift · L935–L1017
if self.isExpanded {
    let currentItemFrame = frameForIndex(
        index: index, size: layout.size, insets: itemInsets,
        itemCount: self.items.count, boundingSize: layout.size
    )
    let currentItemTransform = final3dTransform(
        for: currentItemFrame.minY, size: currentItemFrame.size,
        contentHeight: contentHeight, itemCount: self.items.count,
        additionalAngle: self.highlightedItemId == item.id ? 0.04 : nil,
        scrollBounds: effectiveScrollBounds, insets: itemInsets
    )
    var effectiveItemFrame = currentItemFrame
    let effectiveItemTransform = currentItemTransform
    if let dismissingItemId = self.dismissingItemId,
       let deletingIndex = self.items.firstIndex(where: { $0.id == dismissingItemId }),
       let offset = self.dismissingItemOffset, deletingIndex == index {
        effectiveItemFrame = effectiveItemFrame.offsetBy(dx: offset, dy: 0.0)
    }
    itemFrame = effectiveItemFrame
    itemTransform = effectiveItemTransform
    itemNode.isCovered = false
}

代价与风险:大量全屏卡片的变换和阴影会增加 GPU 合成。3D 几何的触摸区域仍以未变换布局为基础,需额外验证。

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

17. iPad 分栏只把真实屏幕边缘的安全区给对应窗格

做了什么:分栏宽度被限制在屏宽的三分之一到二分之一之间。master 去掉右安全区。detail 去掉左安全区。它还为两个窗格分别生成状态栏高度的滚动到顶代理区域。

为什么这样做:中间分隔线不是屏幕边缘。若两个窗格都继承完整安全区,内容会在分割处产生双重留白。每窗格独立代理能保留状态栏点击滚动到顶的预期。

关键代码摘录

NavigationSplitContainer.swift · L73–L106
let masterWidth: CGFloat = min(max(320.0, floor(layout.size.width / 3.0)), floor(layout.size.width / 2.0))
let detailWidth = layout.size.width - masterWidth
let scrollToTopHeight = max(layout.statusBarHeight ?? layout.safeInsets.top, 1.0)
return (
    master: CGRect(origin: CGPoint(), size: CGSize(width: masterWidth, height: scrollToTopHeight)),
    detail: CGRect(origin: CGPoint(x: masterWidth, y: 0.0), size: CGSize(width: detailWidth, height: scrollToTopHeight))
)

var masterSafeInsets = layout.safeInsets
masterSafeInsets.right = 0.0
var detailSafeInsets = layout.safeInsets
detailSafeInsets.left = 0.0

self.masterContainer.update(
    layout: ContainerViewLayout(size: CGSize(width: masterWidth, height: layout.size.height),
        metrics: layout.metrics, deviceMetrics: layout.deviceMetrics,
        intrinsicInsets: layout.intrinsicInsets, safeInsets: masterSafeInsets,
        additionalInsets: UIEdgeInsets(), statusBarHeight: layout.statusBarHeight,

代价与风险:宽度策略固定。极窄多任务窗口的可读性需要验证。代理视图的生命周期由上层导航控制器维护。

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

18. 触感生成器按样式缓存,并在手势开始时预热

做了什么:封装延迟创建选择、通知和 impact 生成器。impact 生成器按样式缓存。长按 hold 和 Peek 构造期会先调用 prepareTap()。click05、click06 和 veryLight 在 iOS 13 以后用低强度模拟。

为什么这样做:触感首次触发可能滞后于视觉动画。提前预热使长按临界点更同步。缓存避免重复创建生成器。

关键代码摘录

HapticFeedback.swift · L29–L39 等 2 处
func prepareTap() {
    if let selectionGenerator = self.selectionGenerator {
        selectionGenerator.prepare()
    }
}

func tap() {
    if let selectionGenerator = self.selectionGenerator {
        selectionGenerator.selectionChanged()
    }
}

private func impactGenerator(for style: ImpactHapticFeedbackStyle) -> UIImpactFeedbackGenerator {
    if let impactGenerator = self.impactGenerators[style] {
        return impactGenerator
    }
    let impactGenerator: UIImpactFeedbackGenerator
    switch style {
    case .light: impactGenerator = UIImpactFeedbackGenerator(style: .light)
    case .medium: impactGenerator = UIImpactFeedbackGenerator(style: .medium)
    case .heavy: impactGenerator = UIImpactFeedbackGenerator(style: .heavy)
    case .soft: impactGenerator = UIImpactFeedbackGenerator(style: .soft)
    case .rigid: impactGenerator = UIImpactFeedbackGenerator(style: .rigid)
    case .veryLight, .click05, .click06: impactGenerator = UIImpactFeedbackGenerator()
    }
    self.impactGenerators[style] = impactGenerator
    return impactGenerator
}

代价与风险:没有释放单个样式缓存的路径。不同硬件对 impactOccurred(intensity:) 的感觉不一致。无明显审核风险。

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

19. 画廊退场用源快照接棒,并等待三个动画完成

做了什么:画廊关闭时,代码把源节点提供的 copyView 插到内容下方。快照从全屏内容的几何变换到源位置。真实全屏节点同时淡出并缩放。只有位置、缩放和快照动画都完成,代码才移除快照并回调。

为什么这样做:源聊天单元可能在另一个滚动层或正在重绘。直接反向移动全屏内容会被父层级裁剪。用快照接力能让视觉连续,同时让源树独立恢复。

关键代码摘录

ChatAnimationGalleryItem.swift · L276–L312
self.view.insertSubview(copyView, belowSubview: self.containerNode.view)
copyView.frame = transformedSelfFrame

let intermediateCompletion = { [weak copyView] in
    if positionCompleted && boundsCompleted && copyCompleted {
        copyView?.removeFromSuperview()
        completion()
    }
}

copyView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1, removeOnCompletion: false)
copyView.layer.animatePosition(
    from: CGPoint(x: transformedCopyViewInitialFrame.midX, y: transformedCopyViewInitialFrame.midY),
    to: CGPoint(x: transformedSelfFrame.midX, y: transformedSelfFrame.midY),
    duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false
)
let scale = CGSize(
    width: transformedCopyViewInitialFrame.size.width / transformedSelfFrame.size.width,
    height: transformedCopyViewInitialFrame.size.height / transformedSelfFrame.size.height
)
copyView.layer.animate(
    from: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)),
    to: NSValue(caTransform3D: CATransform3DIdentity), keyPath: "transform",
    timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false,
    completion: { _ in copyCompleted = true; intermediateCompletion() }
)

代价与风险:快照在转场中不会更新。三路完成状态要严格维护。任何一路没有回调都会阻塞关闭完成。

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

20. 反应面板通过顶部过拉扩展,并主动取消滚动 pan

做了什么:菜单内容不足一屏时,代码只允许顶部过拉。过拉距离先乘以 0.35 做阻尼。超过 30 pt 时,它取消滚动视图自身的 pan,并把反应面板扩展为新状态。

为什么这样做:普通 UIScrollView 的 bounce 只会回弹。这里把 bounce 变成反应选择器的渐进输入。取消原 pan 能阻止滚动回弹与面板扩展同时竞争。

关键代码摘录

ContextControllerExtractedPresentationNode.swift · L590–L631
case .topOnly:
    if scrollView.contentSize.height <= scrollView.bounds.height {
        if adjustedBounds.origin.y > 0.0 {
            adjustedBounds.origin.y = 0.0
        } else {
            adjustedBounds.origin.y = floorToScreenPixels(adjustedBounds.origin.y * 0.35)
            topOverscroll = -adjustedBounds.origin.y
        }
    } else {
        if adjustedBounds.origin.y < 0.0 {
            adjustedBounds.origin.y = floorToScreenPixels(adjustedBounds.origin.y * 0.35)
            topOverscroll = -adjustedBounds.origin.y
        } else if adjustedBounds.origin.y + adjustedBounds.height > scrollView.contentSize.height {
            adjustedBounds.origin.y = scrollView.contentSize.height - adjustedBounds.height
        }
    }
self.scrollNode.bounds = adjustedBounds

if let reactionContextNode = self.reactionContextNode {
    if !reactionContextNode.isExpanded && reactionContextNode.canBeExpanded {
        if topOverscroll > 30.0 && self.scroller.isTracking {
            self.scroller.panGestureRecognizer.state = .cancelled
            reactionContextNode.expand()
        } else {
            reactionContextNode.updateExtension(distance: topOverscroll)
        }
    }
}

代价与风险:直接设置系统 panGestureRecognizer.state 为 .cancelled。这一做法需要在不同 iOS 版本上验证。30 pt 与阻尼比例是硬编码。

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

本页目录