TGUIKit 核心
用 AppKit 复刻 UIKit:无键窗口菜单、键盘分发与图层变换。
本主题范围:TelegramSwift 的 packages/TGUIKit/Sources 中视图、图层、窗口、控制器、导航、模态、浮层、菜单与显示刷新基础。
**结论:**TGUIKit 没有把 AppKit 当成主要 UI 状态机。它用 NSView、CALayer 和 NSWindow 组合出一套 UIKit 式框架。最有价值的做法是:自绘高层菜单、劫持窗口事件链、手工驱动导航转场,以及直接修补 NSVisualEffectView 的内部图层。
版本依据:TelegramSwift
579cebbf0c01fd41b712eff3647fa7f69db9665d。以下结论均来自已读取源码。未发现通过字符串拼接、Base64 或哈希混淆的 selector 名。window、paste:、copy:和contentsMultiplyColor都以明文出现。
1. 用无键窗口自绘全功能 AppMenu
做了什么:它没有调用 NSMenu 的原生弹出流程。它为每层菜单创建透明、无键、非主窗口。它把窗口级别设为 .screenSaver。菜单内容由 MenuView 和表格绘制。
为什么这样做:原生 NSMenu 很难承载自定义行、动态搜索和多级动画。独立窗口让子菜单独立布局。它也让菜单可复用 Window 的全局事件分发。
关键代码摘录
private func getView(for menu: ContextMenu, screen: NSScreen,
parentView: Window?, submenuId: Int64?) -> Window {
let panel = Window(contentRect: .zero,
styleMask: [.fullSizeContentView],
backing: .buffered, defer: false)
panel._canBecomeMain = false
panel._canBecomeKey = false
panel.level = .screenSaver
panel.backgroundColor = .clear
panel.isOpaque = false
panel.hasShadow = false
let contentView = View()
panel.contentView = contentView
contentView.backgroundColor = .clear
contentView.flip = false
contentView.layer?.isOpaque = false
let view = MenuView(frame: .zero)
view.submenuId = submenuId
view.parentView = parentView
// ...
panel.setFrame(view.frame.insetBy(dx: -20, dy: -20), display: false)
panel.contentView?.addSubview(view)
}代价与风险:.screenSaver 会把菜单放到极高层级。它可能遮挡其他浮层。该做法可能破坏辅助功能和系统菜单预期。维护者还要处理多个窗口的焦点与关闭。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:自建
Window+MenuView,独立布局与事件。 - 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
2. 删除毛玻璃内部图层和滤镜
做了什么:VisualEffect 在每次 updateLayer() 后检查毛玻璃子树。它移除名称不为 backdrop 的内部子层。它只保留饱和度和高斯模糊滤镜。最后它清空所有内部层背景。
为什么这样做:这会把系统毛玻璃降为透明模糊底板。自定义 overlay 再负责色调。常规 NSVisualEffectView 会保留系统材料的背景和额外效果,难以匹配 Telegram 的主题。
关键代码摘录
open override func updateLayer() {
super.updateLayer()
if #available(macOS 10.13, *) {
guard let backdrop = self.layer?.sublayers?.first else {
return
}
let sublayers = backdrop.sublayers ?? []
for layer in sublayers {
if layer.name != "backdrop" && layer.name != "Backdrop" {
layer.removeFromSuperlayer()
}
}
let allowedKeys: [String] = [
"colorSaturate",
"gaussianBlur"
]
sublayers.first?.filters = sublayers.first?.filters?.filter { filter in
guard let filter = filter as? NSObject else { return true }
let filterName = String(describing: filter)
return allowedKeys.contains(filterName)
}
// ...代价与风险:它依赖 AppKit 未承诺的层级顺序、层名和滤镜描述。backdrop、Backdrop、filters 的组合在新系统可能失效。代码已经用 macOS 10.13 做最低版本保护,但没有兼容性兜底。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:遍历子层,按名称与滤镜描述白名单过滤。
- REA 第 3 项
- 已发布 · 仍可用REA 二进制macOS 26.7 实测
13.0 二进制:白名单
["colorSaturate","gaussianBlur"]以小字符串存于__DATA.__data0x5e75720/30,标记字节 0xED/0xECmacOS 26.7:实际层树:
kCUIVariantSidebarMaterial›Backdrop(CABackdropLayer,滤镜sdrNormalize、gaussianBlur、colorSaturate)、fill、tone、Chameleon(CAChameleonLayer)结论:代码同时匹配
backdrop和Backdrop,结构仍吻合。它会删掉新出现的sdrNormalize,影响未验证
3. 用优先级重写 Window 键盘分发
做了什么:Window.sendEvent(_:) 先自动选择 responder。然后它先调度 .All 全局处理器。它只让优先级更高的全局处理器抢占具体按键。.supreme 可以跨过修饰键判定。处理器可消费、拒绝或交给 AppKit。
为什么这样做:AppKit responder chain 不能表达模态、导航、弹窗和提示的显式优先级。这个链让 Escape、Return 和全局关闭行为有确定顺序。它也避免低层页面抢走高层浮层的按键。
关键代码摘录
let eventType = event.type
if sheets.isEmpty {
if eventType == .keyDown {
applyResponderIfNeeded(event)
cleanUndefinedHandlers()
if let globalHandlers = keyHandlers[.All]?.sorted(by: >),
let keyCode = KeyboardKey(rawValue:event.keyCode) {
for globalHandler in globalHandlers {
let handle = keyHandlers[keyCode]?.sorted(by: >).first
if handle == nil || globalHandler.priority > handle!.priority {
if (handle?.modifierFlags == nil ||
!event.modifierFlags.contains(handle!.modifierFlags!)) ||
globalHandler.priority == .supreme {
switch globalHandler.handler(event) {
case .invoked: return
case .rejected: break
case .invokeNext:
super.sendEvent(event)
return
}
}
}
}
}
// ...代价与风险:sheets 非空时,该方法不向 super 转发事件。这个分支有明显行为风险。每个呈现方必须及时调用 removeAllHandlers(for:)。漏删会造成幽灵快捷键。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:在
sendEvent里查表执行,必要时交回 AppKit。 - 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
4. 以条件 updateLayer 模拟 UIKit 变换属性
做了什么:View 把锚点、变换和子层变换包装为存储属性。属性变更时,它主动调用 updateLayer()。它只在至少一个属性被使用时请求 AppKit 的 updateLayer 路径。
为什么这样做:这让 NSView 拥有接近 UIKit 的 layer-transform API。条件化的 wantsUpdateLayer 避免所有普通视图都走 layer 更新。常规做法会在各调用点直接修改 layer,状态更分散。
关键代码摘录
public var _transformation: CATransform3D? = nil {
didSet { self.updateLayer() }
}
public var _anchorPoint: NSPoint? = nil {
didSet { self.updateLayer() }
}
public var _sublayerTransform: CATransform3D? = nil {
didSet { self.updateLayer() }
}
open override var wantsUpdateLayer: Bool {
return self._anchorPoint != nil || self._transformation != nil ||
self._sublayerTransform != nil
}
open override func updateLayer() {
super.updateLayer()
if let _anchorPoint = _anchorPoint {
self.setAnchorPoint(anchorPoint: _anchorPoint)
}
if let _transformation = _transformation {
self.layer?.transform = _transformation
}
if let _sublayerTransform = _sublayerTransform {
self.layer?.sublayerTransform = _sublayerTransform
}
}代价与风险:setAnchorPoint 的具体补偿实现在其他扩展中。该代码只在非 nil 时写入。把属性重新设为 nil 不会在此处恢复默认变换,调用方需要了解这个语义。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:存储属性 +
updateLayer()写入layer。 - 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
5. 在基础 View 上统一翻转坐标并关闭隐式动画
做了什么:所有 View 默认 layer-backed。flip 默认值为 true。初始化关闭图层隐式动画,关闭自动子视图缩放,并按需重绘 layer。
为什么这样做:翻转坐标让原点在左上,符合 UIKit 和聊天列表布局。禁用隐式动画避免一次 frame 或颜色更新触发不可控的 CA 动画。onSetNeedsDisplay 把绘制请求集中到显式失效。
关键代码摘录
open override var isFlipped: Bool {
return flip
}
private func initialize() {
acceptsTouchEvents = true
self.wantsLayer = true
self.layer?.masksToBounds = true
self.autoresizesSubviews = false
self.layer?.disableActions()
self.layer?.backgroundColor = backgroundColor.cgColor
self.layer?.isOpaque = true
self.layerContentsRedrawPolicy = .onSetNeedsDisplay
if #available(macOS 10.15, *) {
self.layer?.cornerCurve = .continuous
}
}代价与风险:所有子类都继承翻转坐标。混入原生未翻转 NSView 时必须显式转换。默认 masksToBounds 会裁掉阴影和越界动画。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:初始化时设置 layer,
layerContentsRedrawPolicy=.onSetNeedsDisplay。 - 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
6. 用空 CAAction 兼做层级进入通知
做了什么:SimpleShapeLayer 拦截 CA 的 onOrderIn 和 onOrderOut 动作查询。它把这两个动作转换为层级生命周期回调。无论事件是什么,它都返回无操作 CAAction。初始化时设置 Retina 比例。
为什么这样做:这避免额外 NSView 监测层插入和移除。它也一刀切关闭层属性的隐式动画。常规 CALayer 没有公开的 didMoveToSuperview 等价回调。
关键代码摘录
open class SimpleShapeLayer : CAShapeLayer {
public var didEnterHierarchy: (() -> Void)?
public var didExitHierarchy: (() -> Void)?
public private(set) var isInHierarchy: Bool = false
override open func action(forKey event: String) -> CAAction? {
if event == kCAOnOrderIn {
self.isInHierarchy = true
self.didEnterHierarchy?()
} else if event == kCAOnOrderOut {
self.isInHierarchy = false
self.didExitHierarchy?()
}
return nullAction
}
override public init() {
super.init()
contentsScale = System.backingScale
}
}代价与风险:action(forKey:) 会压制该层所有默认 CA 动画。调用方必须手动添加动画。kCAOnOrderIn 和 kCAOnOrderOut 的触发时机需要持续回归验证。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:重写
action(forKey:)返回nullAction。 - 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
7. 用未公开 KVC 键给 Layer 内容着色
做了什么:扩展用 KVC 读写 contentsMultiplyColor。它把该值包装为 layerTintColor。同一扩展还把大量 layer action 映射为 NSNull()。
为什么这样做:这可能让现有 contents 图像通过乘色实现换色。它避免为每种主题重新生成图片。源码以明文给出 contentsMultiplyColor,因此不存在 selector 混淆。苹果是否把此键列为公开 API,未验证。
关键代码摘录
public extension CALayer {
var layerTintColor: CGColor? {
get {
if let value = self.value(forKey: "contentsMultiplyColor"),
CFGetTypeID(value as CFTypeRef) == CGColor.typeID {
let result = value as! CGColor
return result
} else {
return nil
}
}
set(value) {
self.setValue(value, forKey: "contentsMultiplyColor")
}
}
func disableActions() -> Void {
self.actions = ["onOrderIn":NSNull(), "sublayers":NSNull(),
"bounds":NSNull(), "frame":NSNull(),
"background":NSNull(), "position":NSNull(),
"contents":NSNull(), "backgroundColor":NSNull()]
removeAllAnimations()
}
}代价与风险:未知 KVC 键会在运行时抛异常。该键依赖 CA 实现,存在审核和系统兼容风险。disableActions() 还会删除已存在的动画,不能在进行中的转场中随意调用。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- REA 第 2 项
- 已发布 · 仍可用REA 二进制macOS 26.7 实测
13.0 二进制:
__cstring0x104b6ef30macOS 26.7:
CALayer响应 getter 和 setter结论:已发布,仍可用
8. 让装饰 View 穿透命中测试
做了什么:isEventLess 为真时,视图仍参与显示。它不会作为命中结果返回。它还跳过同类、ImageView 和 LayerBackedView 的装饰层。
为什么这样做:背景、阴影和图片常覆盖控件。常规 AppKit 需要为每个装饰层重写命中测试。这里把“视觉存在、交互不存在”变成基础语义。
关键代码摘录
open override func hitTest(_ point: NSPoint) -> NSView? {
if isEventLess {
let view = super.hitTest(point)
if let view = view as? View {
if view.isEventLess || view === self {
return nil
}
}
if let view = view as? ImageView {
if view.isEventLess || view === self {
return nil
}
}
if let view = view as? LayerBackedView {
if view.isEventLess || view === self {
return nil
}
}
return view
} else {
return super.hitTest(point)
}
}代价与风险:该逻辑只识别列出的三个类型。新的装饰类可能重新截获鼠标。嵌套的 eventless 视图和真正交互子视图需要仔细安排层级。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
9. 用真实 hit-test 修正 hover 判断
做了什么:_mouseInside() 不只测试坐标是否在 bounds。它先拿内容视图的真实命中结果。它把 eventless 装饰层和 VisualEffect 视为可穿透。后续代码还沿 superview 链检查,并对 NSTableView 的行视图再做一次 hit-test。
为什么这样做:透明遮罩和毛玻璃会让简单 bounds 检查产生误判。表格虚拟行也让命中层级不稳定。该方法把 hover、长按和浮层关闭建立在实际可交互层级上。
关键代码摘录
func _mouseInside() -> Bool {
if let window = self.window {
var location:NSPoint = window.mouseLocationOutsideOfEventStream
location = self.convert(location, from: nil)
if let view = window.contentView!.hitTest(
window.mouseLocationOutsideOfEventStream) {
if let view = view as? View {
if view.isEventLess {
return NSPointInRect(location, self.bounds)
}
} else if let view = view as? ImageView, view.isEventLess {
return NSPointInRect(location, self.bounds)
} else if let view = view as? LayerBackedView, view.isEventLess {
return NSPointInRect(location, self.bounds)
} else if let view = view as? EventLessView, view.isEventLess {
return NSPointInRect(location, self.bounds)
} else if let _ = view as? VisualEffect {
return NSPointInRect(location, self.bounds)
}
if view == self {
return NSPointInRect(location, self.bounds)
}代价与风险:每次调用都会执行 hitTest、坐标转换和潜在的表格查询。高频鼠标移动场景有成本。它依赖具体子类,扩展性有限。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
10. 用 NSTrackingArea 处理非激活窗口 hover
做了什么:控件在进入窗口或 superview 变更后重建 tracking area。它使用 .inVisibleRect,因此不依赖过期 bounds。它使用 .activeInActiveApp 和 .assumeInside。
为什么这样做:普通 tracking area 在窗口失焦、滚动裁剪或动态加入时容易丢失 hover。assumeInside 让控件加入时立即拥有内部状态。activeInActiveApp 让非激活应用仍更新鼠标状态。
关键代码摘录
open override func updateTrackingAreas() {
super.updateTrackingAreas();
if let trackingArea = trackingArea {
self.removeTrackingArea(trackingArea)
}
trackingArea = nil
if let _ = window, visibleRect != .zero {
let options:NSTrackingArea.Options = [
.cursorUpdate, .mouseEnteredAndExited, .mouseMoved,
.activeInActiveApp, .assumeInside, .inVisibleRect
]
self.trackingArea = NSTrackingArea(rect: self.bounds,
options: options, owner: self, userInfo: nil)
self.addTrackingArea(self.trackingArea!)
}
}
open override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
updateTrackingAreas()
}代价与风险:每次 updateTrackingAreas() 都删除并新建对象。复杂列表中会产生额外开销。.assumeInside 可能在鼠标实际不在控件内时先触发 hover。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
11. 将 CVDisplayLink 回调安全转送到主队列
做了什么:CVDisplayLink 的实时线程只向 DispatchSourceUserDataAdd 累加数据。主队列上的 source handler 再调用 Swift 闭包。C 回调通过 passUnretained 传递 source 指针。
为什么这样做:AppKit 视图只能在主线程更新。直接在 CVDisplayLink 回调中改 UI 会违反线程约束。用户数据 source 还能合并多次刷新,避免主队列积压每一帧。
关键代码摘录
init?(onQueue queue: DispatchQueue = DispatchQueue.main) {
source = DispatchSource.makeUserDataAddSource(queue: queue)
var timerRef : CVDisplayLink? = nil
var successLink = CVDisplayLinkCreateWithCGDisplay(
CGMainDisplayID(), &timerRef)
if let timer = timerRef {
successLink = CVDisplayLinkSetOutputCallback(timer, {
(timer, currentTime, outputTime, _, _, sourceUnsafeRaw) -> CVReturn in
if let sourceUnsafeRaw = sourceUnsafeRaw {
let sourceUnmanaged = Unmanaged<DispatchSourceUserDataAdd>
.fromOpaque(sourceUnsafeRaw)
sourceUnmanaged.takeUnretainedValue().add(data: 1)
}
return kCVReturnSuccess
}, Unmanaged.passUnretained(source).toOpaque())
guard successLink == kCVReturnSuccess else {
NSLog("Failed to create timer with active display")
return nil
}
self.timer = timer
} else {
return nil
}
source.setEventHandler(handler: { [weak self] in self?.callback?() })
}代价与风险:takeUnretainedValue() 要求 source 生命周期长于回调。注释也明确指出这一前提。cancel() 后不能再次 resume(),因此同一个 DisplayLink 生命周期不可重启,未验证是否有实际重启路径。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- REA 第 14 项
- 已发布 · 仍可用REA 二进制REA 运行时观测
13.0 二进制:导入
CVDisplayLinkCreateWithActiveCGDisplays、CVDisplayLinkSetOutputHandler等 9 个macOS 26.7:启动 40 秒内未调用(登录页,原因未验证)
结论:已发布
12. 共享一个 DisplayLink 并按请求降帧
做了什么:SharedDisplayLinkDriver 统一维护请求。低帧率请求累积显示器真实周期。累计到目标周期 95% 时才回调。失效 link 在本轮后倒序清理。
为什么这样做:每个动效各建一个 DisplayLink 会增加回调和对象成本。这个驱动器把多个动画合为一个显示同步源。低帧率动画不会被强制以高刷新率计算。
关键代码摘录
private func displayLinkEvent(duration: TimeInterval) {
self.isProcessingEvent = true
var removeIndices: [Int]?
loop: for i in 0 ..< self.requests.count {
let request = self.requests[i]
if let link = request.link, link.isValid {
if !link.isPaused {
var itemDuration = duration
switch request.framesPerSecond {
case let .fps(value):
let secondsPerFrame = 1.0 / CGFloat(value)
itemDuration = secondsPerFrame
request.lastDuration += duration
if request.lastDuration >= secondsPerFrame * 0.95 {
} else {
continue loop
}
case .max:
break
}
request.lastDuration = 0.0
link.update(itemDuration)
}
}
// ...代价与风险:它使用 CGMainDisplayID()。多显示器窗口未必跟随所在屏幕刷新率。0.95 是经验阈值,在 120Hz 等频率下可能有抖动。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- REA 第 14 项
- 已发布 · 仍可用REA 二进制REA 运行时观测
13.0 二进制:导入
CVDisplayLinkCreateWithActiveCGDisplays、CVDisplayLinkSetOutputHandler等 9 个macOS 26.7:启动 40 秒内未调用(登录页,原因未验证)
结论:已发布
13. 手工求解 CAMediaTimingFunction 的三次贝塞尔
做了什么:DisplayLinkAnimator 不把数值交给 CA。它从 CAMediaTimingFunction 提取控制点。它在每个显示刷新时求解贝塞尔曲线,再回调插值结果。
为什么这样做:某些状态不是 layer 属性,无法由 CABasicAnimation 直接驱动。手工插值可同时更新布局、文本或业务状态。它还保证回调与显示刷新同步。
关键代码摘录
@objc private func tick(_ isFirst: Bool) {
if self.completed { return }
let timestamp = CACurrentMediaTime()
var t = min(timestamp - self.startTime, self.duration) / self.duration
t = max(0.0, t)
t = min(1.0, t)
let solved: Float
if isFirst {
solved = 0
} else {
solved = TimingFunctionSolve(timingControlPoints, Float(t),
solveEps(Float(duration)))
}
self.update(self.fromValue * CGFloat(1 - solved) +
self.toValue * CGFloat(solved))
if abs(t - 1.0) < Double.ulpOfOne {
self.completed = true
self.displayLink?.invalidate()
self.completion()
}
}代价与风险:源码中的 UnitBezier 实现使用牛顿迭代和二分回退。实现复杂度高于 CA 动画。duration == 0 会产生除零风险,调用方必须避免。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
14. 导航 push 前冻结动态子内容
做了什么:push 前,导航控制器递归锁住新页面的动态内容。它把新页面从右侧放入。旧页面向左移三分之一宽度。它只为不透明页面添加边缘阴影。转场结束后会调用 restoreHierarchyDynamicContent()。
为什么这样做:聊天 UI 内部可能有滚动、动图或异步重绘。冻结动态内容减少转场期间的重绘竞争。三分之一视差保留了旧页层次感。常规 AppKit 控制器没有 UIKit push 的这种组合语义。
关键代码摘录
case .push:
controller.view.disableHierarchyDynamicContent()
previous.viewWillDisappear(true);
controller.viewWillAppear(true);
nfrom = popInteractiveInset != nil ? popInteractiveInset! : frame.width
nto = keepLeft
pfrom = previous.view.frame.minX
if previous.stake.keepLeft > 0 && previous.stake.keepLeft != frame.width {
pto = frame.maxX
} else if keepLeft > 0 {
pto = 0
} else {
pto = -round(NSWidth(self.frame)/3.0)
}
previous.view.setFrameOrigin(NSMakePoint(pfrom, previous.frame.minY))
controller.view.setFrameOrigin(NSMakePoint(nfrom, controller.frame.minY))
containerView.addSubview(controller.view, positioned: .above,
relativeTo: previous.view)
sto = -shadowView.frame.width + keepLeft
if controller.isOpaque {
addShadowView(.right, controller: controller,
updateOrigin: shadowView.superview == nil)
}代价与风险:递归冻结仅处理 View 和 TableRowView。其他动态 NSView 不会被锁住。锁恢复依赖动画 completion。动画被打断时可能留下错误状态。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
15. 把滚轮横向 delta 变成可取消的交互式导航
做了什么:横向滚轮手势会连续直接改两个页面的位置。当前页按完整 delta 移动。前页只移动三分之一。阴影、边界和导航栏也同步更新。返回值把已消费 delta 交给 Window 状态机。
为什么这样做:AppKit 的 swipe(with:) 不提供这种连续页面位置控制。直接修改 model frame 能实现跟手。外围代码还会排除水平列表、水平滚动视图和已显示的 popover。
关键代码摘录
case let .swiping(delta, previous):
let settings = self.controller.stake
let keepLeft: CGFloat = self.frame.width == settings.keepLeft
? 0 : settings.keepLeft
let nPosition = min(max(keepLeft, delta + keepLeft),
self.containerView.frame.width)
self.controller.view._change(pos: NSMakePoint(
nPosition, self.controller.view.frame.minY), animated: false)
let previousStart = keepLeft > 0 ? 0
: -round(NSWidth(self.containerView.frame)/3.0)
previous.view._change(pos: NSMakePoint(
min(previousStart + max(delta, 0) / 3.0, 0),
previous.view.frame.minY), animated: false)
self.shadowView.setFrameOrigin(nPosition - self.shadowView.frame.width,
self.shadowView.frame.minY)
self.navigationLeftBorder.setFrameOrigin(
nPosition - self.navigationLeftBorder.frame.width,
self.navigationLeftBorder.frame.minY)
self.shadowView.layer?.opacity = min(1.0 - Float(
nPosition / self.containerView.frame.width) + 0.2, 1.0)
// ...代价与风险:手势与横向滚动组件天然竞争。代码依赖类名 HorizontalRowView、HorizontalScrollView 和 HorizontalTableView。错误识别会抢走正常滚动。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
16. 以 contentView 覆盖层替代 AppKit sheet
做了什么:Modal 是被加到 window.contentView 的 ModalBackground,不是 beginSheet。它注册最高层 responder、全局按键、Escape 和 Return。背景控件还负责外点关闭或把点击转交给下层。
为什么这样做:原生 sheet 会限制窗口交互和动画样式。覆盖层可以支持全屏、局部 parent view、模糊背景和自定义转场。它还可与导航栈共用 responder 优先级系统。
关键代码摘录
background.addSubview(container)
background.userInteractionEnabled = controller.handleEvents
if controller.handleEvents {
window.set(responder: { [weak controller] () -> NSResponder? in
return controller?.firstResponder()
}, with: self, priority: controller.responderPriority)
if controller.handleAllEvents {
window.set(handler: { [weak controller] _ -> KeyHandlerResult in
if let controller = controller, controller.redirectMouseAfterClosing {
controller.close()
return .rejected
}
return .invokeNext
}, with: self, for: .All, priority: controller.responderPriority)
}
window.set(escape: { [weak self] _ -> KeyHandlerResult in
if self?.controller?.escapeKeyAction() == .rejected {
if self?.controller?.closable == true {
self?.controller?.close()
}
}
return .invoked
}, with: self, priority: controller.responderPriority)
// ...代价与风险:模态正确性完全由自定义事件链保证。覆盖层不能自动获得原生 sheet 的无障碍、窗口管理和阻塞语义。未正确删除 handler 时,关闭后仍会消费按键。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
17. 光栅化模态容器以压低阴影开销
做了什么:模态外容器允许阴影越界。内层容器裁剪圆角内容。外层开启 rasterization,并把比例设为当前屏幕比例。边框独立成一层。
为什么这样做:带圆角、阴影和复杂子视图的模态在移动动画中成本高。先栅格化能减少重复合成。双容器同时满足裁剪内容和显示阴影。
关键代码摘录
let noBorder = controller.contentBelowBackground ||
controller.containerBackground == .clear || controller.isFullScreen ||
!controller.hasBorder
container = ModalContainerView(frame: noBorder ? containerRect :
containerRect.insetBy(dx: -1, dy: -1))
container.layer?.cornerRadius = controller.cornerRadius
container.background = controller.containerBackground
self.container.layer?.shouldRasterize = true
self.container.layer?.rasterizationScale = CGFloat(System.backingScale)
self.container.layer?.isOpaque = false
let shadow = NSShadow()
shadow.shadowBlurRadius = 20
shadow.shadowColor = NSColor.black.withAlphaComponent(0.3)
shadow.shadowOffset = NSMakeSize(0, 0)
self.container.shadow = shadow
container.layer?.masksToBounds = false
container.container.layer?.masksToBounds = true
container.container.layer?.cornerRadius = controller.cornerRadius
container.borderView.layer?.borderColor =
controller.modalTheme.grayText.withAlphaComponent(0.1).cgColor
container.borderView.layer?.cornerRadius = controller.cornerRadius代价与风险:栅格化会在内容频繁变化时反复生成缓存。缓存也会占用显存。跨显示器时,System.backingScale 若未及时更新可能变模糊。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
18. 从触发控件位置缩放自定义 Popover
做了什么:Popover 先把控件中心转到屏幕坐标。它再转换到背景坐标作为缩放锚点。翻转背景时补偿 y。它提供缩放和 reveal 两种动画,完成后才设置 first responder。
为什么这样做:NSPopover 的动画与样式受系统控制。自定义 overlay 可以从实际触发点展开。延后设置 first responder 避免入场动画中闪现输入焦点。
关键代码摘录
let point = control.convert(NSMakePoint(control.frame.width / 2,
control.frame.height / 2), to: nil)
var anchor = strongSelf.background.convert(point, from: nil)
if strongSelf.background.flip {
anchor.y += strongSelf.background.frame.height
}
let completion:(Bool)->Void = { [weak controller] _ in
if let strongSelf = self, !once {
once = true
controller?.viewDidAppear(strongSelf.animates)
if result == true {
_ = strongSelf.window?.makeFirstResponder(
controller?.firstResponder())
} else if result == false {
_ = strongSelf.window?.makeFirstResponder(nil)
}
}
}
switch strongSelf.animationMode {
case .classic:
strongSelf.background.layer?.animateScaleSpringFrom(
anchor: anchor, from: 0.1, to: 1.0, duration: 0.4, bounce: false)
strongSelf.background.layer?.animateAlpha(from: 0, to: 1,
duration: 0.2, completion: completion)
// ...代价与风险:坐标经过控件、屏幕和背景三次转换。翻转补偿依赖当前 background.flip。多显示器和窗口移动时必须回归验证。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
19. Tooltip 反转坐标并进行像素对齐
做了什么:Tooltip 从目标视图转换位置。父内容翻转时,它手动反转 y。x 坐标按 backing scale 向下对齐。它把提示限定在左右 10 pt 内。箭头可自动指向控件中心或鼠标位置。
为什么这样做:TGUIKit 既有翻转 View,也可承载原生未翻转视图。手动反转避免提示在错误侧。像素对齐避免 Retina 下的半像素模糊。
关键代码摘录
let updatePosition:(Bool)->Void = { [weak tooltip, weak view, weak window] animated in
if let tooltip = tooltip, let view = view, let window = window {
var point = view.convert(NSZeroPoint, to: nil)
if window.contentView!.isFlipped {
point.y = window.contentView!.frame.height - point.y -
view.frame.height / 2 - 10
}
point.y += offset.y
let pos = NSMakePoint(min(max(floorToScreenPixels(
System.backingScale, point.x -
(tooltip.frame.width - view.frame.width) / 2), 10),
window.frame.width - tooltip.frame.width - 10), point.y)
tooltip.change(pos: pos, animated: isExists || animated)
}
}
updatePosition(isExists)
if autoCorner {
let point = tooltip.convert(NSMakePoint(floorToScreenPixels(
System.backingScale, view.frame.width / 2), 0), from: view)
tooltip.move(corner: point.x, animated: isExists)
} else {
let mousePoint = tooltip.convert(location, from: nil)
tooltip.move(corner: mousePoint.x, animated: isExists)
}代价与风险:y 坐标的 -10 是视觉常数。大型提示只做横向边界限制。它只在 TableView 滚动时注册重定位监听,其他滚动容器未覆盖。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
20. 复制 SimpleLayer 实现桌布横向铺砖
做了什么:BackgroundView.TileControl 根据宽度计算图片块。它只增删不足的 SimpleLayer。每个块复用主层的内容、混合模式、背景和透明度。后续代码再更新每层 frame 并插入目标层。
为什么这样做:它不在 draw(_:) 中反复绘制或拼接大位图。静态 contents 可由 CA 合成。层数量随窗口宽度增减,适合聊天背景的铺砖效果。
关键代码摘录
if let superlayer = superlayer {
if let tile = tile {
let size = tile.size
let tileSize = size.aspectFitted(frame.size)
if shouldTile {
if tileSize.width < frame.width {
while rects.reduce(CGFloat(0), { $0 + $1.width }) < frame.width {
let width = rects.reduce(CGFloat(0), { $0 + $1.width })
rects.append(CGRect(origin: .init(x: width,
y: (frame.height - tileSize.height) / 2), size: tileSize))
}
} else {
rects.append(frame.focus(size.aspectFilled(frame.size)))
}
} else {
rects.append(frame.focus(size.aspectFilled(frame.size)))
}
while self.tileLayers.count > rects.count {
self.tileLayers.removeLast().removeFromSuperlayer()
}
while self.tileLayers.count < rects.count {
let layer = SimpleLayer()
self.tileLayers.append(layer)
}
// ...代价与风险:循环中每次都用 reduce 计算当前总宽度。极宽窗口和小图片会有二次复杂度。很多 layer 也会增加合成成本。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
21. 以全局鼠标移动轮询保持 Popover 打开
做了什么:鼠标首次离开控件和 popover 后,代码启动 200 ms 的循环信号。循环检查鼠标是否回到任一方。未按下鼠标且没有上下文菜单时,它关闭 popover。
为什么这样做:两个独立视图之间常有间隙。立即在 mouseExited 关闭会让用户无法移动到 popover。延迟轮询给鼠标跨越间隙的容错。
关键代码摘录
let nHandler:(Control) -> Void = { [weak strongSelf] control in
if let strongSelf = strongSelf {
let s = Signal<Void,NoError>.single(Void()) |> delay(0.2,
queue: Queue.mainQueue()) |> then(Signal<Void,NoError>.single(Void())
|> delay(0.2, queue: Queue.mainQueue()) |> restart)
strongSelf.disposable.set(s.start(next: { [weak strongSelf] () in
if let strongSelf = strongSelf {
if !strongSelf.inside() && !control.mouseInside() {
if (NSEvent.pressedMouseButtons & (1 << 0)) == 0 {
if !contextMenuOnScreen() {
strongSelf.hide()
}
}
}
}
}))
}
}
var first: Bool = true
// ...代价与风险:这是轮询而非 tracking area。每个活跃 popover 每 200 ms 执行一次检查。若 disposable 生命周期处理错误,可能保留无效循环。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。