窗口、键盘与状态栏
接管键盘窗口、修正键盘高度、伪造状态栏与窗口层级。
范围:Telegram-iOS 的自定义窗口宿主、键盘、状态栏与模态展示层。
结论:该层用一个极薄的 UIViewController 接受 UIKit 决策。应用把实际布局、展示、触摸和状态栏策略放进 ASDisplayNode 与自定义上下文。最激进的做法是读取私有键盘场景,并直接改系统键盘视图的 CALayer.bounds。
1. 经私有键盘场景委托取得键盘窗口
做了什么:它从 UIWindowScene 的 keyboardSceneDelegate 取 keyboardWindow。AppDelegate 先走此路径,再回退扫描所有场景窗口。
为什么这样做:iOS 16 后,键盘属于内部场景。公开 UIWindowScene.windows 不再稳定包含该窗口。这个入口让应用能读取键盘真实视图,并与其同步动画。
关键代码摘录
- (UIWindow * _Nullable)internalGetKeyboardForScene:(UIWindowScene * _Nullable)scene {
if (scene == nil) {
return nil;
}
if (![scene respondsToSelector:@selector(keyboardSceneDelegate)]) {
return nil;
}
id keyboardSceneDelegate = [(id<UIWindowSceneKeyboardProtocol>)scene keyboardSceneDelegate];
if (![keyboardSceneDelegate respondsToSelector:@selector(keyboardWindow)]) {
return nil;
}
// nil until the keyboard has been created for this scene, and also whenever the keyboard UI is
// hosted out of process — in that configuration there is no in-process keyboard window at all.
UIWindow *result = [(id<UIKeyboardSceneDelegateProtocol>)keyboardSceneDelegate keyboardWindow];
if (![result isKindOfClass:[UIWindow class]]) {
return nil;
}
return result;
}代价与风险:keyboardSceneDelegate 和 keyboardWindow 是私有接口。系统改名、键盘进程外承载和 App Review 都可能使方案失效。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 报告摘要
- 怎么做到:声明最小协议,逐步
respondsToSelector:与类型校验。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
2. 用类名形状兼容多代系统键盘视图
做了什么:它按前后缀匹配 UIRemoteKeyboardWindow、UITextEffectsWindow、UIInputSetHostView 和 UIKeyboardItemContainerView。它也匹配容器 UIInputSetContainerView 与 UITrackingWindowView。
为什么这样做:私有类名随系统变化。前后缀匹配避开了对完整类名的硬依赖。它也给旧系统窗口枚举回退路径提供判断。
关键代码摘录
private func isKeyboardWindow(window: NSObject) -> Bool {
let typeName = NSStringFromClass(type(of: window))
if #available(iOS 9.0, *) {
if typeName.hasPrefix("UI") && typeName.hasSuffix("RemoteKeyboardWindow") {
return true
}
} else {
if typeName.hasPrefix("UI") && typeName.hasSuffix("TextEffectsWindow") {
return true
}
}
return false
}
private func isKeyboardView(view: NSObject) -> Bool {
let typeName = NSStringFromClass(type(of: view))
if typeName.hasPrefix("UI") && typeName.hasSuffix("InputSetHostView") {
return true
}
if typeName.hasPrefix("UI") && typeName.hasSuffix("KeyboardItemContainerView") {
return true
}
return false
}代价与风险:这是私有实现细节。误匹配和新系统结构变化都会导致键盘高度为零或直接失效。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
3. 直接移动系统键盘层,而非只移动应用内容
做了什么:它把键盘视图的 bounds.origin.y 设为负偏移。它再对同一 CALayer 加加性位移动画。窗口布局同时把输入高度扣除该偏移。
为什么这样做:常规 resignFirstResponder() 只能让系统完整收起键盘。这里可让键盘跟手下移,并让聊天布局在同一进度更新。
关键代码摘录
func updateInteractiveInputOffset(_ offset: CGFloat, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) {
guard let keyboardView = self.host.keyboardView else {
return
}
self.interactiveInputOffset = offset
let previousBounds = keyboardView.bounds
let updatedBounds = CGRect(origin: CGPoint(x: 0.0, y: -offset), size: previousBounds.size)
keyboardView.layer.bounds = updatedBounds
if transition.isAnimated {
transition.animateOffsetAdditive(layer: keyboardView.layer, offset: previousBounds.minY - updatedBounds.minY, completion: completion)
} else {
completion()
}
}代价与风险:它修改系统私有视图的层。系统键盘层级或动画模型改变时,键盘可能跳动、残留或无法收起。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 报告摘要
- 怎么做到:记录前后
bounds,按transition决定是否动画,回调完成。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
4. 用多个坐标修正键盘通知的错误高度
做了什么:它优先信任实时键盘视图的 frame.minY。iOS 16.1 以上,它把通知中的屏幕坐标转换到事件窗口。它还修正居中窗口的半个高度差、iPad 的近边界误差和子窗口的 27pt 偏差。
为什么这样做:keyboardWillChangeFrame 的坐标不总等于应用窗口坐标。分屏、窗口化和多场景会使普通的“屏高减键盘 Y”得到错误高度。
关键代码摘录
if #available(iOSApplicationExtension 14.2, iOS 14.2, *), UIAccessibility.prefersCrossFadeTransitions {
} else if let keyboardView = strongSelf.statusBarHost?.keyboardView {
if keyboardFrame.width.isEqual(to: keyboardView.bounds.width) && keyboardFrame.height.isEqual(to: keyboardView.bounds.height) && keyboardFrame.minX.isEqual(to: keyboardView.frame.minX) {
keyboardFrame.origin.y = keyboardView.frame.minY
}
}
var minKeyboardY: CGFloat?
if #available(iOSApplicationExtension 16.1, iOS 16.1, *), let screen = notification.object as? UIScreen, let keyboardFrameEnd = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
let fromCoordinateSpace = screen.coordinateSpace
let toCoordinateSpace: UICoordinateSpace = strongSelf.hostView.eventView
let convertedKeyboardFrameEnd = fromCoordinateSpace.convert(keyboardFrameEnd, to: toCoordinateSpace)
minKeyboardY = convertedKeyboardFrameEnd.minY
}
// ...
if keyboardFrame.isEmpty || keyboardFrame.maxY < screenHeight {
if isWindowed || (isTablet && screenHeight - keyboardFrame.maxY < 5.0) {
if let minKeyboardY {
keyboardFrame.origin.y = minKeyboardY
}
keyboardHeight = max(0.0, screenHeight - keyboardFrame.minY)代价与风险:修正值含 24pt、27pt、5pt 等经验常数。新尺寸、浮动键盘或系统改通知语义时,需要重新实测。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:用
UICoordinateSpace.convert与经验阈值修正边界。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
5. 全窗口手势用第一响应者和附件高度划定拖拽区
做了什么:它把窗口级拖拽限制在键盘上方。它沿命中视图向上读取禁用标记。它把输入附件高度计入键盘上界,并以 3pt 阈值开始更新布局。
为什么这样做:手势安装在整个宿主上。没有这几个条件,滚动、底部按钮和自定义附件都可能意外拖动键盘。
关键代码摘录
let keyboardGestureBeginLocation = location
let view = self.hostView.containerView
let (firstResponder, accessoryHeight) = getFirstResponderAndAccessoryHeight(view)
if let inputHeight = self.windowLayout.inputHeight, !inputHeight.isZero, keyboardGestureBeginLocation.y < self.windowLayout.size.height - inputHeight - (accessoryHeight ?? 0.0) {
var enableGesture = true
if let view = self.hostView.containerView.hitTest(location, with: nil) {
if doesViewTreeDisableInteractiveTransitionGestureRecognizer(view, keyboardOnly: true) {
enableGesture = false
}
}
if enableGesture, let _ = firstResponder {
self.keyboardGestureBeginLocation = keyboardGestureBeginLocation
self.keyboardGestureAccessoryHeight = accessoryHeight
}
}
if let keyboardGestureBeginLocation = self.keyboardGestureBeginLocation {
let currentLocation = location
let deltaY = keyboardGestureBeginLocation.y - location.y
if deltaY * deltaY >= 3.0 * 3.0 || self.windowLayout.upperKeyboardInputPositionBound != nil {
self.updateLayout {
$0.update(upperKeyboardInputPositionBound: currentLocation.y + (self.keyboardGestureAccessoryHeight ?? 0.0), transition: .immediate, overrideTransition: false)
}
}
}
}代价与风险:行为依赖附加在 UIView 上的关联对象标记。复杂嵌套视图必须正确设置这些标记。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
6. 键盘收起手势显式取消同一手指的列表惯性
做了什么:它在触摸结束期间记录“此手势已收起键盘”。列表稍后收到自己的 .ended 时可读取该值并放弃惯性滚动。
为什么这样做:窗口手势与 UIScrollView 可以同时识别。没有该跨手势信号,一次下拉会同时收键盘和甩动列表。
关键代码摘录
var canDismiss = false
if let upperKeyboardInputPositionBound = self.windowLayout.upperKeyboardInputPositionBound, upperKeyboardInputPositionBound >= self.windowLayout.size.height - accessoryHeight {
canDismiss = true
} else if let velocity = velocity, velocity.y > 100.0 {
canDismiss = true
}
if canDismiss, let inputHeight = self.windowLayout.inputHeight, currentLocation.y + (self.keyboardGestureAccessoryHeight ?? 0.0) > self.windowLayout.size.height - inputHeight {
// This release spent the finger's downward motion on the keyboard. Published for the
// duration of the touch sequence so a scroll view released by the SAME finger — its pan
// reports `.ended` later, in action dispatch — can decline to also fling its content.
self.dismissedKeyboardByCurrentGestureValue = true
let springDuration: CGFloat
if #available(iOS 26.0, *) {
springDuration = 0.3832
} else {
springDuration = 0.25
}
self.updateLayout {
$0.update(upperKeyboardInputPositionBound: self.windowLayout.size.height, transition: .animated(duration: springDuration, curve: .spring), overrideTransition: false)
}
}代价与风险:这是依赖 UIKit 触摸派发顺序的时序契约。自定义滚动组件必须主动读取 dismissedKeyboardByCurrentGesture。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:设置
dismissedKeyboardByCurrentGestureValue,动画到全收起。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
7. 伪造选区变化以触发文本自动更正
做了什么:它先取消 marked text。它把选区左移一个字符。它立即恢复原选区。
为什么这样做:这会让 UIKit 重新处理标记文本和选区。调用方可在不改变最终光标位置的前提下请求自动更正。具体的 UIKit 内部判定机制未验证。
关键代码摘录
void applyKeyboardAutocorrection(UITextView * _Nonnull textView) {
NSRange rangeCopy = textView.selectedRange;
NSRange fakeRange = rangeCopy;
if (fakeRange.location != 0) {
fakeRange.location--;
}
[textView unmarkText];
[textView setSelectedRange:fakeRange];
[textView setSelectedRange:rangeCopy];
}代价与风险:输入法组合状态可能对伪选区敏感。首字符不移动选区。该逻辑需覆盖多语言输入法测试。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
8. 将系统键盘窗口横向平移到第一响应者所在表面
做了什么:它根据第一响应者宿主的屏幕 X 坐标改 keyboardWindow.layer.sublayerTransform。它可限制只向前或向后平移。响应者消失时,它短暂隐藏窗口并递归取消 position 与 bounds 动画。
为什么这样做:自定义表面可横向移动,而键盘仍由独立窗口承载。单纯移动内容会让键盘和输入区域错位。取消遗留动画避免已移除响应者留下过渡帧。
关键代码摘录
if let firstResponderView = firstResponderView {
let containerOrigin = firstResponderView.convert(CGPoint(), to: nil)
var filteredTranslation = containerOrigin.x
if firstResponderDisableAutomaticKeyboardHandling.contains(.forward) {
filteredTranslation = max(0.0, filteredTranslation)
}
if firstResponderDisableAutomaticKeyboardHandling.contains(.backward) {
filteredTranslation = min(0.0, filteredTranslation)
}
let horizontalTranslation = CATransform3DMakeTranslation(filteredTranslation, 0.0, 0.0)
let currentTransform = keyboardWindow.layer.sublayerTransform
if !CATransform3DEqualToTransform(horizontalTranslation, currentTransform) {
keyboardWindow.layer.sublayerTransform = horizontalTranslation
}
} else {
keyboardWindow.layer.sublayerTransform = CATransform3DIdentity
if let previousFirstResponderView = previousFirstResponderView {
if previousFirstResponderView.window == nil {
keyboardWindow.isHidden = true
keyboardWindow.layer.cancelAnimationsRecursive(key: "position")
keyboardWindow.layer.cancelAnimationsRecursive(key: "bounds")
keyboardWindow.isHidden = false
}
}
}代价与风险:它直接改私有键盘窗口层。动画键名和键盘渲染结构均没有公开保证。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:从第一响应者取屏幕 X,构造
CATransform3DMakeTranslation后比较并写入。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
9. 抓取系统状态栏后按像素重着色
做了什么:它把每个系统状态栏子视图渲染进 DrawingContext。它再按 alpha 把像素改为黑色或白色。最后用 ASDisplayNode.contents 在相同坐标复刻图像。
为什么这样做:一个普通 preferredStatusBarStyle 只能控制系统的整体样式。此代理可在复杂覆盖层中单独覆盖系统图标,同时保留电量、信号和时间的实际外观。
关键代码摘录
context.withContext { c in
c.translateBy(x: containingBounds.minX, y: -containingBounds.minY)
UIGraphicsPushContext(c)
self.targetView.layer.render(in: c)
UIGraphicsPopContext()
}
// ...
tintStatusBarItem(context, type: type, style: self.statusBarStyle)
let image = context.generateImage()?.cgImage
self.contentNode.contents = image
let mappedFrame = self.targetView.convert(self.targetView.bounds, to: self.rootView)
self.frame = mappedFrame
self.contentNode.frame = containingBounds代价与风险:方案依赖私有状态栏视图,也会产生离屏绘制和 CPU 像素遍历。动态图标变化最多每五秒刷新一次。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
10. 运行时拼接私有状态栏类名
做了什么:它不在单个字面量中放完整私有类名。CFAbsoluteTimeGetCurrent() > 0 在正常运行时为真,因此代码会拼出完整名称后再传给 NSClassFromString。
为什么这样做:这让状态栏代理能按私有子类识别电池、时间和前景容器。代码显然也试图降低完整名称的静态可见性。
关键代码摘录
private let foregroundClass: AnyClass? = {
var nameString = "StatusBar"
if CFAbsoluteTimeGetCurrent() > 0 {
nameString += "ForegroundView"
}
return NSClassFromString("_UI" + nameString)
}()
private let foregroundClass2: AnyClass? = {
var nameString = "StatusBar"
if CFAbsoluteTimeGetCurrent() > 0 {
nameString += "ForegroundView"
}
return NSClassFromString("UI" + nameString)
}()
private let batteryItemClass: AnyClass? = {
var nameString = "StatusBarBattery"
if CFAbsoluteTimeGetCurrent() > 0 {
nameString += "ItemView"
}
return NSClassFromString("UI" + nameString)
}()解混淆确认:由前缀、初始字符串和真分支后缀逐项拼接可确认真实名。它们是 _UIStatusBarForegroundView、UIStatusBarForegroundView、UIStatusBarBatteryItemView、_UIBatteryView、_UIStatusBarBackgroundActivityView、_UIStatusBarStringView 和 UIStatusBarTimeItemView。确认依据是本文件 #L360-L414 的每个 NSClassFromString 参数构造。
代价与风险:这是针对私有 UIKit 类名的混淆。审核风险很高。系统改类名会静默返回 nil 或错误分类。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
11. 用公共 RunLoop 定期重抓状态栏的活动元素
做了什么:代理显示时立即重抓条目。它随后每五秒在 .common RunLoop 模式重抓。隐藏时它销毁定时器。
为什么这样做:截图式代理不是活的系统状态栏。五秒周期足以更新时间,也不会因用户拖动滚动视图而停止。
关键代码摘录
override var isHidden: Bool {
get {
return super.isHidden
} set(value) {
if super.isHidden != value {
super.isHidden = value
if !value {
self.updateItems()
self.timer = Timer(timeInterval: 5.0, target: StatusBarProxyNodeTimerTarget { [weak self] in
self?.updateItems()
}, selector: #selector(StatusBarProxyNodeTimerTarget.tick), userInfo: nil, repeats: true)
RunLoop.main.add(self.timer!, forMode: .common)
} else {
self.timer?.invalidate()
self.timer = nil
}
}
}
}代价与风险:状态栏变化最多会滞后五秒。每次刷新都会遍历私有子视图并生成图像。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
12. 把多个节点树的状态栏决策汇聚到唯一根控制器
做了什么:Window1 依次让根导航、普通展示层和顶层展示层覆盖状态栏值。WindowRootViewController 保存最终值,并调用 setNeedsStatusBarAppearanceUpdate()。
为什么这样做:UIKit 只会向窗口根控制器查询状态栏。Telegram 的实际树不是 UIKit controller containment 树。集中汇聚让任意 ASDisplayNode 展示层仍可控制系统状态栏。
关键代码摘录
private func updateStatusBar(transition: ContainedViewLayoutTransition) {
var style: UIStatusBarStyle = .default
var isHidden = false
if let rootController = self._rootController as? NavigationController {
let statusBar = rootController.statusBar
style = statusBar.style
isHidden = statusBar.isHidden
}
if let statusBar = self.presentationContext.statusBar {
style = statusBar.style
isHidden = statusBar.isHidden
}
if let statusBar = self.topPresentationContext.statusBar {
style = statusBar.style
isHidden = statusBar.isHidden
}
self.hostView.updateStatusBar(style, isHidden, transition)
}代价与风险:覆盖优先级写死为三个上下文的顺序。新增展示层时必须补充汇聚逻辑,否则样式会错误。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
13. 全局覆盖层在键盘可见时改挂到键盘窗口
做了什么:键盘可见且有真实高度时,它把全局控制器的父视图改为 keyboardWindow。键盘不存在时,它改回普通窗口的 parentView。
为什么这样做:键盘窗口在应用窗口之上。全局提示或 Portal 若仍挂在应用窗口,就会被键盘遮挡。这个重挂载让它们保持在同一视觉层级。
关键代码摘录
private func currentPresentationView(underStatusBar: Bool) -> UIView? {
if let statusBarHost = self.statusBarHost {
if let keyboardWindow = statusBarHost.keyboardWindow, let keyboardView = statusBarHost.keyboardView, !keyboardView.frame.height.isZero, isViewVisibleInHierarchy(keyboardView) {
var updateTrackingNode = false
if let trackingNode = self.currentTrackingNode {
if trackingNode.layer.superlayer !== keyboardView.layer {
updateTrackingNode = true
}
} else {
updateTrackingNode = true
}
// ...
return keyboardWindow
} else {
if let view = self.parentView {
return view
}
}
}
return nil
}代价与风险:依赖私有键盘窗口。代码中的 HierarchyTrackingNode 重定位块已注释,键盘窗口重建期间的行为未验证。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:检测
keyboardView高度与可见性,切换父层并维护 tracking。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
14. 展示上下文不用 UIKit containment,而是手动生命周期
做了什么:它等待 ContainableController.ready,最多两秒。它直接配置 view 和节点布局。随后代码直接插入子视图,并手动调用 viewWillAppear 和 viewDidAppear。
为什么这样做:展示栈需要独立的层级、输入屏蔽、方向交集和节点就绪门槛。普通 present 只能表达 UIKit controller 树,难以承载这些并行展示面。
关键代码摘录
public func present(_ controller: ContainableController, on level: PresentationSurfaceLevel, blockInteraction: Bool = false, completion: @escaping () -> Void) {
let controllerReady = controller.ready.get()
|> filter({ $0 })
|> take(1)
|> deliverOnMainQueue
|> timeout(2.0, queue: Queue.mainQueue(), alternate: .single(true))
if let _ = self.view, let initialLayout = self.layout {
if let controller = controller as? ViewController {
if controller.lockOrientation {
let orientations: UIInterfaceOrientationMask
if initialLayout.size.width < initialLayout.size.height {
orientations = .portrait
} else {
orientations = .landscape
}
controller.supportedOrientations = ViewControllerSupportedOrientations(regularSize: orientations, compactSize: orientations)
}
}
let (controllerLayout, controllerFrame) = self.layoutForController(containerLayout: initialLayout, controller: controller)
controller.view.frame = controllerFrame
controller.containerLayoutUpdated(controllerLayout, transition: .immediate)代价与风险:手动生命周期容易与 UIKit 的 appearance 状态冲突。两秒超时可能把尚未真正就绪的控制器展示出来。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
15. 自定义 UIWindow.hitTest 特判系统编辑菜单
做了什么:自定义窗口先倒序测试 UIKit 加在窗口顶层的编辑菜单。只有未命中时,它才分发给 Telegram 的覆盖层和根内容。
为什么这样做:NativeWindow 重写了 hitTest。若只分发自定义节点,系统 UIEditMenuInteraction 的点按会穿透到聊天内容。
关键代码摘录
for view in self.hostView.eventView.subviews.reversed() {
let classString = NSStringFromClass(type(of: view))
// The system edit menu is inserted as a top-level subview of the window (== eventView) and must
// be hit-tested here, otherwise its touches fall through to the content below. Pre-iOS-16 this is
// a `UICalloutBar`/`...ContextMenuContainerView`; on iOS 16+ `UIEditMenuInteraction` hosts it in a
// `_UIEditMenuContainerView` (matched via "EditMenu"). Without the iOS-16 case, tapping any item
// in a UIEditMenuInteraction menu does nothing and the tap passes through to the content.
if classString == "UITransitionView" || classString.contains("ContextMenuContainerView") || classString.contains("EditMenu") {
if let result = view.hitTest(point, with: event) {
return result
}
}
}
if let result = self.overlayPresentationContext.hitTest(point, with: event) {
return result
}代价与风险:代码依赖 UITransitionView 与编辑菜单类名片段。UIKit 改层级或命名时,复制、粘贴菜单会失效。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
16. 透明伪滚动视图接管状态栏“回到顶部”手势
做了什么:它在窗口上覆盖一个透明 UIScrollView。它伪造 1000pt 可滚动距离。状态栏点击触发代理回调,但 delegate 返回 false,因此代理本身不滚动。
为什么这样做:UIKit 的状态栏点击机制只识别 scrollsToTop 滚动视图。该代理可把系统手势路由到平铺、双栏 master 或 detail,而无需暴露实际列表。
关键代码摘录
self.isOpaque = false
self.delegate = self
self.scrollsToTop = true
self.contentInsetAdjustmentBehavior = .never
if #available(iOS 17.0, *) {
self.allowsKeyboardScrolling = false
}
// ...
override var frame: CGRect {
didSet {
let frame = self.frame
self.contentSize = CGSize(width: frame.width, height: frame.height + 1000.0)
self.contentOffset = CGPoint(x: 0.0, y: 1000.0)
}
}
@objc func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
if let action = self.action {
action()
}
return false
}代价与风险:覆盖 frame 必须避开交互控件。多个 scrollsToTop 视图同时启用时,UIKit 行为可能冲突。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
17. 仅全屏窗口套用按设备表测得的安全区
做了什么:它把窗口与屏幕的纵向尺寸逐项比较。差超过 1pt 时,它完全放弃设备刘海表。多显示屏设备则直接读窗口的系统安全区。
为什么这样做:分屏、台前调度和 iOS 26 自由缩放窗口不接触物理屏幕边缘。把全屏设备的横向刘海 59pt 套给窄栏会明显压缩内容。
关键代码摘录
private func deviceSafeInsets(deviceMetrics: DeviceMetrics, windowSize: CGSize) -> UIEdgeInsets {
let screenSize = UIScreen.main.bounds.size
let portraitWindowSize = CGSize(width: min(windowSize.width, windowSize.height), height: max(windowSize.width, windowSize.height))
let portraitScreenSize = CGSize(width: min(screenSize.width, screenSize.height), height: max(screenSize.width, screenSize.height))
if abs(portraitWindowSize.width - portraitScreenSize.width) > 1.0 || abs(portraitWindowSize.height - portraitScreenSize.height) > 1.0 {
return UIEdgeInsets()
}
return deviceMetrics.safeInsets(inLandscape: windowSize.width > windowSize.height)
}
private func windowSafeInsets(hostView: WindowHostView, deviceMetrics: DeviceMetrics, windowSize: CGSize) -> UIEdgeInsets {
if DeviceMetrics.hasMultipleDisplays {
let systemInsets = hostView.systemSafeAreaInsets
return UIEdgeInsets(top: systemInsets.top, left: systemInsets.left, bottom: 0.0, right: systemInsets.right)
}
return deviceSafeInsets(deviceMetrics: deviceMetrics, windowSize: windowSize)
}代价与风险:1pt 是经验阈值。非全屏窗口的系统安全区若需要额外保护,此路径不会提供设备表补偿。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
18. 用型号、缩放率和屏幕尺寸选择经验设备档案
做了什么:它从 utsname.machine 读取型号。它先按型号筛档案,再用 scale 与屏幕尺寸确认。它把高度加 20pt 后再比一次,以兼容状态栏尺寸差异。
为什么这样做:安全区、圆角、键盘高度和缺口图形需要比公开抽象设备类别更细的值。先用型号可区分屏幕尺寸相同但屏幕特性不同的设备。
关键代码摘录
init(screenSize: CGSize, scale: CGFloat, statusBarHeight: CGFloat, onScreenNavigationHeight: CGFloat?, modelIdentifier: String?) {
var screenSize = screenSize
if screenSize.width > screenSize.height {
screenSize = CGSize(width: screenSize.height, height: screenSize.width)
}
let additionalSize = CGSize(width: screenSize.width, height: screenSize.height + 20.0)
if let modelIdentifier = modelIdentifier {
for device in DeviceMetrics.profiles(for: modelIdentifier) {
let deviceScale: CGFloat = device == .iPhoneXr ? 2.0 : 3.0
if scale == deviceScale && (device.profileScreenSize == screenSize || device.profileScreenSize == additionalSize) {
self = device
return
}
}
}代价与风险:新型号会落入猜测式 unknown。档案与显示缩放的组合也需要持续维护。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 3 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
19. 把灵动岛与刘海绘制成一张按机型量出的几何表
做了什么:它为每代设备指定缺口宽、高和顶部偏移。显示缩放模式不另存一份表,而是把标准机型几何按宽度比例缩放。
为什么这样做:UIKit 没有直接公开“灵动岛矩形”。该表允许渲染层避开传感器区域,且缩放模式可复用标准测量值。
关键代码摘录
case .iPhone14Pro, .iPhone14ProMax:
size = CGSize(width: 126.0, height: 112.0 / 3.0)
top = 11.0
case .iPhone16Pro, .iPhone16ProMax:
size = CGSize(width: 126.0, height: 112.0 / 3.0)
top = 41.0 / 3.0
case .iPhone18Pro, .iPhone18ProMax:
// Temporarily use the iPhone 16 Pro / Pro Max cutout geometry.
size = CGSize(width: 126.0, height: 112.0 / 3.0)
top = 41.0 / 3.0
case .iPhoneAir:
size = CGSize(width: 126.0, height: 112.0 / 3.0)
top = 59.0 / 3.0
case .iPhone14ProZoomed, .iPhone14ProMaxZoomed:
let standardDevice: DeviceMetrics = self == .iPhone14ProZoomed ? .iPhone14Pro : .iPhone14ProMax
guard let standardFrame = standardDevice.cutoutFrame else {
return nil
}
let factor = self.profileScreenSize.width / standardDevice.profileScreenSize.width
size = CGSize(width: standardFrame.width * factor, height: standardFrame.height * factor)
top = standardFrame.minY * factor代价与风险:代码明确写明 iPhone18Pro 暂用 iPhone 16 几何。这部分在实际硬件上的准确性未验证。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
20. 双显示屏型号绕开整张设备指标表
做了什么:对型号 iPhone19,4,它不信任启动时匹配出的设备档案。它改读当前窗口的系统安全区和状态栏。状态栏暂时消失时,它按屏幕尺寸缓存上一次可见高度。
为什么这样做:同一设备的内外屏可有不同传感器区和系统栏。型号固定的刘海表无法同时描述两块屏幕。
关键代码摘录
/// The app's window moves between displays whose sensor housings and system bars differ (iPhone
/// Duo: the outer and the inner display), so no per-model table can describe its insets and they
/// are taken from the system instead. Keyed on the model identifier rather than on the resolved
/// profile, because the profile is matched by screen size and depends on which display was
/// active at launch.
static let hasMultipleDisplays: Bool = DeviceMetrics.currentModelIdentifier == "iPhone19,4"
// ...
private func systemStatusBarHeight() -> CGFloat? {
let frameHeight = self.statusBarHost?.statusBarFrame.height ?? 0.0
let screenSize = self.hostView.currentScreenSize
if frameHeight > 0.0 {
if let screenSize {
self.visibleStatusBarHeights[ScreenBoundsKey(screenSize)] = frameHeight
}
return frameHeight
}
if let screenSize, let height = self.visibleStatusBarHeights[ScreenBoundsKey(screenSize)] {
return height
}
let topInset = self.hostView.systemSafeAreaInsets.top
return topInset > 0.0 ? topInset : nil
}代价与风险:型号和 “iPhone Duo” 对应关系只在源码注释中出现。实际设备行为未验证。缓存键只含宽高,无法表达相同尺寸但不同状态栏配置。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
21. 方向锁定同时兼容现代 Scene API 与旧 KVC
做了什么:iOS 16 以上用 requestGeometryUpdate 锁竖屏。旧系统用 KVC 写 UIDevice.orientation。其他方向变更则请求 UIKit 重试设备方向。
为什么这样做:展示层可在不改变根控制器树的情况下动态收紧支持方向。旧系统没有 Scene geometry 更新接口。
关键代码摘录
var orientations: UIInterfaceOrientationMask = defaultOrientations {
didSet {
if oldValue != self.orientations {
if self.orientations == .portrait {
if #available(iOSApplicationExtension 16.0, iOS 16.0, *) {
let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene
windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait))
self.setNeedsUpdateOfSupportedInterfaceOrientations()
} else if UIDevice.current.orientation != .portrait {
let value = UIInterfaceOrientation.portrait.rawValue
UIDevice.current.setValue(value, forKey: "orientation")
}
} else {
if #available(iOSApplicationExtension 16.0, iOS 16.0, *) {
self.setNeedsUpdateOfSupportedInterfaceOrientations()
} else {
UIViewController.attemptRotationToDeviceOrientation()
}
}
}
}
}代价与风险:setValue(_:forKey: "orientation") 是未公开的方向修改手法。它可能被审核拒绝,也可能在新系统失效。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
22. 在 UIKit 继承动画期间手工补一条窗口层帧动画
做了什么:自定义 UIWindow setter 读取 UIView.inheritedAnimationDuration。有继承动画时,它在窗口层追加同持续时间的 animateFrame。无尺寸变化时,它避免布局通知。
为什么这样做:UIKit 外部可能在动画事务内改窗口 frame。该代码让自定义窗口层显式跟随该事务,并把尺寸变化推送到节点布局。具体要规避的系统视觉缺陷未在代码中说明。
关键代码摘录
override var frame: CGRect {
get {
return super.frame
} set(value) {
let sizeUpdated = super.frame.size != value.size
var frameTransition: ContainedViewLayoutTransition = .immediate
if #available(iOSApplicationExtension 9.0, iOS 9.0, *) {
let duration = UIView.inheritedAnimationDuration
if !duration.isZero {
frameTransition = .animated(duration: duration, curve: .easeInOut)
}
}
if sizeUpdated, case let .animated(duration, curve) = frameTransition {
let previousFrame = super.frame
super.frame = value
self.layer.animateFrame(from: previousFrame, to: value, duration: duration, timingFunction: curve.timingFunction)
} else {
super.frame = value
}
if sizeUpdated {
self.updateSize?(value.size)
}
}
}代价与风险:手工 CA 动画可能与 UIKit 的隐式动画重叠。animateFrame 的实现不在本主题范围内,最终合成效果未验证。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。