Unwrap PaperKit
SwiftUI & UI Frameworks 进阶 7m

深入探索 PaperKit

Unwrap PaperKit

2026年6月10日

在 Apple 官方观看视频

一句话判断

✅ Apple 终于把 Notes 和 Freeform 的底裤(PaperKit)扒下来给开发者穿了,这意味着你可以用几行代码手搓一个带 Apple Pencil 原生支持和无限画布的白板应用。

这场 Session 讲了什么

以前想做个支持手写、贴图、还能无限缩放的画布,要么自己用 UIScrollViewCALayer 拼凑到手秃,要么用第三方库忍受各种性能玄学。现在 Apple 直接把内部用了多年的 PaperKit 开放了。它把画布内容抽象成了 PaperMarkup,里面的元素(形状、图片、笔迹)都放在一个有序的集合(MarkupOrderedSet)里,你可以像操作数组一样去增删改查。

最绝的是它引入了 Adornments(装饰物)概念。以前在画布上放个“删除”按钮或者“添加图片”的悬浮控件,你得自己算坐标、处理缩放比例、还得在导出 PDF 时把这些 UI 控件剔除掉。现在直接丢进 Adornments 里,它自动跟随画布坐标系,且绝对不会被序列化进文档里。这对做批注、协作白板类 App 的团队来说,直接省了至少一个月的打磨时间。

值得深挖的点

Adornments 的设计哲学:UI 与数据的物理隔离

为什么 Apple 要做 Adornments?在传统的 Canvas 实现中,UI 控件(比如选中图片后的 resize 手柄,或者协作时的头像光标)往往和实际内容混在同一个渲染树里。这导致两个致命问题:一是导出或保存时,容易把这些临时 UI 也渲染进 PDF 或图片里;二是处理画布缩放时,UI 控件的缩放逻辑和内容不一致(比如光标不应该随画布缩小而缩小)。

PaperKit 的 MarkupAdornment 直接通过 API 层面做了物理隔离。它锚定在画布坐标(.canvas(location:)),自动处理 pan 和 zoom,但明确声明“不参与持久化”。这种设计把“文档数据”和“编辑态 UI”彻底解耦。相比以前我们在 UIScrollView 里加一个平行的 overlayView 然后手动同步 transform,PaperKit 的方案在底层直接由渲染引擎接管,不仅丝滑,还彻底杜绝了“导出带个删除按钮”的低级 Bug。不过坑在于,Adornments 目前看起来只支持简单的图片和系统图标(imageConfiguration),如果你想放一个复杂的 SwiftUI 视图进去,可能还得等后续的 API 扩展或者自己搞 bridge。

allowedInteractions:把权限控制下沉到元素级别

做画布应用最头疼的不是画图,而是“不让用户乱动”。比如你做了一个漫画模板,背景框是固定的,但里面的气泡可以拖拽。以前你得在 hit-test 阶段写一堆 if-else 来拦截手势。

PaperKit 给每个 Markup 元素加了 allowedInteractions 属性。这是一个 OptionSet,你可以精确控制移动、缩放、旋转、删除、样式修改。直接给背景框设个 .readOnly,系统底层就直接吞掉所有手势,根本不会触发后续的 delegate 回调。这种“声明式权限”比“命令式拦截”优雅太多。它反映了 Apple 在框架设计上的一个趋势:把常见的业务逻辑(如锁定图层)直接内建到数据模型里,而不是让开发者在视图层去打补丁。

代码片段

1. 批量生成并锁定模板元素

场景:生成一个漫画分镜模板,要求用户只能在框内画画,不能拖动或缩放分镜框本身。

import PaperKit

func generateComicTemplate(pageSize: CGSize, panelFrames: [CGRect]) -> PaperMarkup {
    // 初始化画布
    var markup = PaperMarkup(bounds: CGRect(origin: .zero, size: pageSize))
    var subelements: MarkupOrderedSet = markup.subelements
    
    for panelFrame in panelFrames {
        var shape = ShapeMarkup(frame: panelFrame, configuration: .init())
        // 关键:将模板元素设为只读,底层直接拦截手势
        shape.allowedInteractions = .readOnly
        subelements.append(shape)
    }
    
    // 必须重新赋值回 markup,值类型不会自动同步
    markup.subelements = subelements
    return markup
}

坑:subelements 是值类型集合,修改里面的元素后,一定要把修改后的集合重新赋值给 markup.subelements,再更新给 ViewController,别指望引用传递。

2. 添加不随文档保存的悬浮控件

场景:在每个分镜框中间加一个“AI 生成图片”的按钮,点击呼出 Image Playground,且这个按钮不能被导出到最终的漫画 PDF 里。

import PaperKit

func addAIButtons(for panels: [CGRect]) -> [MarkupAdornment] {
    var adornments: [MarkupAdornment] = []
    
    for (index, panel) in panels.enumerated() {
        let adornment = MarkupAdornment(
            id: UUID(),
            // 锚定在画布坐标系,自动跟随缩放和平移
            anchor: .canvas(location: CGPoint(x: panel.midX, y: panel.midY)),
            imageConfiguration: .systemImage("sparkles"),
            dragRegion: .fixed,
            // 不随画布缩放而改变自身大小,保持按钮点击区域
            scalesWithZoom: false 
        )
        adornments.append(adornment)
    }
    return adornments
}

坑:scalesWithZoom 设为 false 时,按钮在视觉上大小不变,但如果画布缩得非常小,按钮可能会遮挡大面积的画布内容,需要自己加个缩放阈值来隐藏按钮。

最佳实践

已有项目如果你的 App 已经用 PencilKit 做了手写,但外面套了一层自己写的 ScrollView 和图层管理,建议逐步把外层容器替换为 PaperMarkupViewController。笔迹部分无缝衔接,但形状和图片的管理可以大幅简化,直接把现有的自定义 Model 映射到 ShapeMarkupImageMarkup 即可。

新项目无脑用 PaperKit。但要注意,PaperKit 强依赖 UIKit,如果你在纯 SwiftUI 项目里用,需要用 UIViewControllerRepresentable 包一层。状态同步是个难点,建议把 PaperMarkup 作为单一数据源(Single Source of Truth),在 SwiftUI 的 updateUIViewController 里做 diff 更新,而不是双向绑定。

实战中容易踩的坑是 Z-axis 顺序管理。MarkupOrderedSet 是有序集合,图层的上下关系完全由数组索引决定。如果你想把某个元素置顶,不要去找 zIndex 属性,直接在 subelements 数组里把它 remove 然后 append 到末尾就行。

还有什么值得关注

  • PencilKit 能力下放:PaperKit 底层集成了 PencilKit 的新特性,现在可以直接对画布上的笔迹进行文字识别和贝塞尔曲线(Bézier path)转换,手写转文本的精度和可玩性大幅提升。
  • 细粒度样式修改:通过 MarkupInteractionsOptionSet,你可以允许用户移动图片,但禁止他们修改图片的滤镜或边框,这种细粒度控制以前只能靠手写手势识别器来实现。
  • 跨平台一致性:PaperKit 同时支持 iOS、macOS 和 visionOS 27,这意味着你写的一套画布逻辑,在 iPad 上用 Apple Pencil 画,在 Mac 上用鼠标拖,在 Vision Pro 里用手势捏,体验是原生且一致的。
PaperKit PencilKit Canvas Adornments UIKit