导航、手势与上下文菜单
_UIPortalView 跨层显示、上下文菜单与手势仲裁。
本主题范围:Telegram-iOS 的自研导航、手势仲裁、上下文菜单、最小化容器和 GalleryUI 转场;不含普通业务逻辑。
1. 用私有 _UIPortalView 把同一内容跨层显示
- Telegram-iOS:…/Display/Source/PortalView.swift#L4–L35
- Telegram-iOS:…/Source/UIKitRuntimeUtils/UIKitUtils.m#L211–L235
位置说明: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 可在两个层级间显示同一份内容。
关键代码摘录
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。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 报告摘要
- 怎么做到:
NSClassFromString拼接类名,属性按版本保护设置。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
2. 用 Portal 暂存源内容,并在动画末尾明确归还
做了什么:代码先把真实内容重挂到 PortalSourceView。它再把 Portal 镜像放入覆盖层。动画完成时,代码调用 settle,把真实内容重挂回菜单偏移容器或原父视图。
为什么这样做:源气泡要穿过导航栏和菜单镀层。只移动内容会被原聊天容器裁剪。只移动快照会失去动态内容。此做法保留真实内容,并让镜像承担跨层可见性。
关键代码摘录
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 是隐藏前提。未来调用方改变布局会造成位置误差。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:在容器中
addPortal(view:)并在 settle 阶段复位。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
3. 给透明背景塞入 1×1 图片,修复“看得见但点不到”的菜单外部点击
做了什么:菜单背景层即使颜色为 .clear,也会拥有完全透明的 contents。这使背景层继续参与触摸投递。它因此可以接住菜单外的点击并关闭菜单。
为什么这样做:覆盖一个透明 UIView 不一定足够。此处注释记录了 iOS 27 的实测结果。透明背景和极低 alpha 会受未文档化阈值影响。纯透明图片避免了视觉副作用。
关键代码摘录
// 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。未来系统改变透明内容的命中规则时需要回归测试。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:构造 1×1 透明图,赋值给
backgroundNode.layer.contents。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
4. 提取到未缩放覆盖层前,补回祖先缩放
做了什么:代码把源视图的窗口可见宽度除以模型宽度。它把检测到的缩放施加到提取容器。它还保存该缩放,供退场补偿使用。
为什么这样做:消息气泡可能位于已缩放的 sheet 内。覆盖窗口通常没有该祖先变换。若直接重挂,气泡会瞬间跳成 1:1 大小。读取窗口坐标能捕获实际视觉缩放。
关键代码摘录
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 时启用。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
5. 在退场中用 presentation layer 重定向落点
做了什么:源消息在菜单退场时改高度,代码不重新跑整套布局。它读取 presentation() 与模型层的残差。它用稳定 key 替换一条 bounds.origin.y 的加法动画,并只使用剩余时长。
为什么这样做:退场期间,源消息可能因反应行变化而上移。普通重布局会让气泡跳到旧目标。重新发 position.y 会干扰带完成回调的主动画。独立的 bounds.origin.y 保留主动画语义。
关键代码摘录
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,否则动画不会运行或回调。维护成本较高。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
6. 全屏右滑返回先查命中视图链,再用方向阈值裁决
做了什么:识别器从 hitTest 结果向上查找。子视图可声明“有横向手势”或“严格禁止”。普通横向内容会禁用屏幕中央返回。边缘返回仍可保留。移动超过 10 pt 后,识别器才固定横向主方向。
为什么这样做:单纯 UIScreenEdgePanGestureRecognizer 不能实现 Telegram 的全屏返回。单纯全屏 UIPanGestureRecognizer 会抢走轮播、媒体和横向列表。此方案把页面语义交给命中视图,并保留边缘逃生通道。
关键代码摘录
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 扩展属性。未注册这些属性的横向控件可能被误判。阈值为经验值,需要真机调校。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 报告摘要
- 怎么做到:检测 10pt 后锁定方向,边缘保留逃生通道。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
7. 手工驱动返回进度,并同步键盘和状态栏
做了什么:右滑开始时,导航容器预先布局底层控制器。每个交互进度会移动键盘左边界。进度达到 0.3 时,状态栏风格提前切到目标页。结束条件为速度超过 1000,或进度超过 0.2。
为什么这样做:系统导航转场不理解自建 KeyboardViewManager。如果键盘和状态栏不随页面滑动,转场会出现残留或突变。预先布局底层页也避免首次显示空白。
关键代码摘录
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)代价与风险:状态栏与完成阈值是硬编码。复杂输入框的外观生命周期需要额外处理。代码会临时忽略输入高度并关闭编辑状态。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:在进度回调中更新
syncKeyboard与状态栏回调。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
8. 用像素对齐的手工几何实现导航视差和阴影
做了什么:协调器把顶部页、底部页、遮罩和阴影拆成独立节点。顶部页使用屏幕像素对齐。非平面容器让底部页只走 30% 距离,形成视差。初始化时它还持有最大帧率的 SharedDisplayLinkDriver 链接。
为什么这样做:UINavigationController 的默认转场难以匹配自定义容器。分别计算帧可让手势进度直接映射显示。像素对齐减少文字和边界在慢拖动时的抖动。
关键代码摘录
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)代价与风险:多个覆盖节点增加图层合成成本。手工维护导航生命周期、圆角和完成回调,复杂度高。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
9. 转场时临时重挂两个导航栏,再用裁剪容器切开
做了什么:容器保存两个导航栏的原父节点。它在转场时把两个导航栏重挂进独立裁剪节点。layout() 根据进度移动顶部裁剪区域,并收窄底部可见宽度。完成后它再归还导航栏。
为什么这样做:直接给两个导航栏做平移,会让顶部栏盖住底部栏。裁剪宽度能让两个栏在交界处干净拼接。它也不需要为每个按钮创建快照。
关键代码摘录
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 的调用方都可能出错。该容器需要保证完成和取消路径都归还节点。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
10. 上下文长按先避开系统左边缘,再主动取消整棵手势树
做了什么:ContextGesture 会拒绝窗口左侧 8 pt 内的按压。延迟后它用显示链接动画按压进度。真正激活时,它取消窗口中的同类手势,并沿父视图链把其他手势置为 .failed。
为什么这样做:左边缘属于系统返回和应用全屏返回的保留区域。普通长按不能可靠解决已开始的列表点击和父级手势。主动取消使菜单开启后只保留一个交互所有者。
关键代码摘录
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 会降低极边位置的菜单可用性。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
11. 一个识别器同时处理点击、双击、长按、右键和按住
做了什么:该自定义识别器根据命中位置选择等待策略。它区分单击、双击、长按、二级点击和 hold。hold 分支会先 prepareTap()。长按触发时还会取消所有同类识别器,并结束祖先滚动视图的 pan。
为什么这样做:多个标准识别器需要 require(toFail:),延迟和冲突难以按位置变化。该状态机只保留一个计时器。它还避免抢走 UIButton 的标准点击。
关键代码摘录
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。这可能影响第三方视图。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
12. Peek 从源矩形弹出,并按激活模式切换模糊策略
做了什么:Peek 根据源矩形中心计算加法位置弹簧。已出现的内容会按源宽度与内容宽度的比例缩放进入。构造阶段中,拖动激活使用 UIVisualEffectView 模糊。按压激活使用半透明纯色遮罩。构造阶段还预热了触感。
为什么这样做:源到预览的几何连续性比固定中心缩放自然。拖动菜单需要背景退后,按压菜单则需要更直接的按下反馈。普通模态展示无法表达这两种交互。
关键代码摘录
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 在复杂背景上的合成成本较高。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
13. 仅在最内层滚动视图位于顶部时才认可下滑关闭
做了什么:识别器递归从触点寻找 UIScrollView。它只在滚动位置不低于顶部 inset 时保留资格。它超过 5 pt 的横向位移就失败。下移超过 2 pt 才开始。
为什么这样做:滚动内容没有回到顶部时,下拉应该继续滚动,而不是关闭页面。内部委托允许与其他手势同时识别,却要求自己输给普通 UIPanGestureRecognizer。
关键代码摘录
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,自定义滚动容器需要适配。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
14. 最小化后延迟把活控制器换成快照
做了什么:最小化卡片先承载真实控制器视图。0.45 秒后,它抓取内容快照。它移除真实视图,只留下快照和自有标题栏。
为什么这样做:最小化容器可显示多个全屏 Web App 或控制器。持续挂载真实页面会造成绘制、计时器和触摸负担。延迟能保证最小化进场使用真实内容,再转为轻量静态卡片。
关键代码摘录
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()。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
15. 用私有 CAFilter 给快照补背景模糊
- Telegram-iOS:…/MinimizedContainer/Sources/MinimizedContainer.swift#L272–L295
- Telegram-iOS:…/Source/UIKitRuntimeUtils/UIKitUtils.m#L259–L267
位置说明:Telegram-iOS:submodules/TelegramUI/Components/MinimizedContainer/Sources/MinimizedContainer.swift#L272-L295;私有 API 定义:Telegram-iOS:submodules/UIKitRuntimeUtils/Source/UIKitRuntimeUtils/UIKitUtils.m#L259-L267
做了什么:卡片快照比例不匹配容器时,代码另抓一个快照。它给该快照的 layer.filters 设置高斯模糊,再放到清晰快照下方填满空白区域。
为什么这样做:简单拉伸快照会变形。纯色边缘会显得突兀。模糊副本能延展背景,同时保留原始内容比例。
关键代码摘录
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。审核风险很高。滤镜半径与键值也可能随系统变化。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
16. 用 3D 变换把最小化页面堆成可滚动卡片组
做了什么:展开后,每张卡片按其滚动位置计算 final3dTransform。高亮卡片额外旋转 0.04。关闭中的卡片单独接受横向偏移。折叠时,非顶卡按比例缩小并下沉。
为什么这样做:普通 UITableView 的平铺卡片缺少任务切换器的深度感。变换与滚动边界绑定,因此滚动时卡片会连续倾斜。代码还将最上卡 zPosition 设为 10000,防止被遮盖。
关键代码摘录
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 几何的触摸区域仍以未变换布局为基础,需额外验证。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
17. iPad 分栏只把真实屏幕边缘的安全区给对应窗格
做了什么:分栏宽度被限制在屏宽的三分之一到二分之一之间。master 去掉右安全区。detail 去掉左安全区。它还为两个窗格分别生成状态栏高度的滚动到顶代理区域。
为什么这样做:中间分隔线不是屏幕边缘。若两个窗格都继承完整安全区,内容会在分割处产生双重留白。每窗格独立代理能保留状态栏点击滚动到顶的预期。
关键代码摘录
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,代价与风险:宽度策略固定。极窄多任务窗口的可读性需要验证。代理视图的生命周期由上层导航控制器维护。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
18. 触感生成器按样式缓存,并在手势开始时预热
做了什么:封装延迟创建选择、通知和 impact 生成器。impact 生成器按样式缓存。长按 hold 和 Peek 构造期会先调用 prepareTap()。click05、click06 和 veryLight 在 iOS 13 以后用低强度模拟。
为什么这样做:触感首次触发可能滞后于视觉动画。提前预热使长按临界点更同步。缓存避免重复创建生成器。
关键代码摘录
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:) 的感觉不一致。无明显审核风险。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
19. 画廊退场用源快照接棒,并等待三个动画完成
做了什么:画廊关闭时,代码把源节点提供的 copyView 插到内容下方。快照从全屏内容的几何变换到源位置。真实全屏节点同时淡出并缩放。只有位置、缩放和快照动画都完成,代码才移除快照并回调。
为什么这样做:源聊天单元可能在另一个滚动层或正在重绘。直接反向移动全屏内容会被父层级裁剪。用快照接力能让视觉连续,同时让源树独立恢复。
关键代码摘录
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() }
)代价与风险:快照在转场中不会更新。三路完成状态要严格维护。任何一路没有回调都会阻塞关闭完成。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
20. 反应面板通过顶部过拉扩展,并主动取消滚动 pan
做了什么:菜单内容不足一屏时,代码只允许顶部过拉。过拉距离先乘以 0.35 做阻尼。超过 30 pt 时,它取消滚动视图自身的 pan,并把反应面板扩展为新状态。
为什么这样做:普通 UIScrollView 的 bounce 只会回弹。这里把 bounce 变成反应选择器的渐进输入。取消原 pan 能阻止滚动回弹与面板扩展同时竞争。
关键代码摘录
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 与阻尼比例是硬编码。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。