Craft:原生优先的文档卓越

Craft为何赢得2021年Apple Design Award:原生性能、嵌套页面、块架构和一键网页发布。包含Swift实现模式。

4 分钟阅读 222 字
Craft:原生优先的文档卓越 screenshot

Craft:原生优先的文档卓越体验

"我们相信,思维工具应该像思想的延伸,而非障碍。"

Craft 证明了原生应用能够提供网页应用无法企及的体验。凭借深度的平台集成,它实现了让数字笔记如纸笔般自然流畅的响应速度与精致质感。


核心要点

  1. 原生胜过 Electron - 低于 50 毫秒的响应时间需要平台原生 UI,而非网页封装
  2. 页中有页 - 任何块都可以成为页面,让结构从内容中自然涌现
  3. 多种布局,同一数据 - 列表、卡片、画廊和看板为用户在不同场景提供最合适的视图
  4. 一键网页发布 - 分享页面无需接收者注册账号或下载应用
  5. 适配平台,而非千篇一律 - Mac 版 Craft 有 Mac 的风格;iOS 版 Craft 有 iOS 的风格

为何 Craft 如此重要

Craft 荣获 2021 年 Apple 设计大奖,并持续获得 App Store 的认可,正是因为它拒绝为跨平台便利性而牺牲原生性能。

主要成就: - 真正的原生应用,支持 iOS、macOS、Windows(非 Electron) - 所有交互的响应时间均低于 50 毫秒 - 离线优先,无缝同步 - 基于块的架构,没有网页应用的卡顿感 - 美观的分享页面,无需账户即可使用


核心设计理念

原生优先,而非网页封装

Craft 的关键抉择:为每个平台构建原生应用,使用共享的业务逻辑但采用平台特定的 UI。

ELECTRON/WEB 方案                  CRAFT 的原生方案
───────────────────────────────────────────────────────────────────
单一代码库 (JavaScript)            平台特定 UI (Swift 等)
Web 渲染引擎                       原生渲染
跨平台"一致性"                     平台适配行为
~200ms 输入延迟                    ~16ms 输入延迟
通用键盘快捷键                     平台原生快捷键
Web 标准文本选择                   原生文本引擎

关键洞察:用户不想要在每个平台上看起来都一样的应用——他们想要在自己的平台上感觉恰到好处的应用。

通过 Catalyst 实现 macOS(正确的做法)

Craft 使用 Mac Catalyst,但对其进行了大量定制,使其真正具有 Mac 原生感:

// Craft's Catalyst customization approach
#if targetEnvironment(macCatalyst)
extension SceneDelegate {
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession) {
        guard let windowScene = scene as? UIWindowScene else { return }

        // 启用原生 Mac 工具栏
        if let titlebar = windowScene.titlebar {
            titlebar.titleVisibility = .hidden
            titlebar.toolbar = createNativeToolbar()
        }

        // Mac 专用窗口尺寸
        windowScene.sizeRestrictions?.minimumSize = CGSize(width: 800, height: 600)

        // 启用窗口标签页
        UIApplication.shared.connectedScenes
            .compactMap { $0 as? UIWindowScene }
            .forEach { $0.titlebar?.toolbar?.showsBaselineSeparator = false }
    }

    private func createNativeToolbar() -> NSToolbar {
        let toolbar = NSToolbar(identifier: "CraftToolbar")
        toolbar.displayMode = .iconOnly
        toolbar.delegate = self
        return toolbar
    }
}
#endif

模式库

1. 基于块的内容架构

Craft 中的每个元素都是一个块。每个包含内容的块都可能成为一个页面,实现无限嵌套而不增加认知负担。

块类型:

文本块
├── 段落(默认)
├── 标题 1、2、3
├── 引用
├── 标注(信息、警告、成功)
└── 代码(带语法高亮)

媒体块
├── 图片(带标题)
├── 视频
├── 文件附件
├── 绘图(Apple Pencil)
└── 音频录制

结构块
├── 折叠块(可折叠)
├── 页面(嵌套文档)
├── 分隔线
└── 表格

实现概念:

protocol CraftBlock: Identifiable {
    var id: UUID { get }
    var content: BlockContent { get set }
    var style: BlockStyle { get set }
    var children: [any CraftBlock] { get set }
    var metadata: BlockMetadata { get }
}

enum BlockContent {
    case text(AttributedString)
    case image(ImageData)
    case page(PageReference)
    case toggle(isExpanded: Bool)
    case code(language: String, content: String)
    case table(TableData)
}

struct BlockMetadata {
    let created: Date
    let modified: Date
    let createdBy: UserReference?
}

// 每个区块都可以包含其他区块
class PageBlock: CraftBlock {
    var id = UUID()
    var content: BlockContent
    var style: BlockStyle
    var children: [any CraftBlock] = []
    var metadata: BlockMetadata

    // 页面只是一个可以全屏打开的块
    var canOpenAsPage: Bool { true }
}

核心洞察:当每个块都可以成为页面时,信息架构便从内容中自然涌现,而非依赖预设的结构。


2. 嵌套页面(页面中的页面)

Craft 的标志性特性:任何块都可以成为页面,且页面可以无限嵌套。

文档结构
───────────────────────────────────────────────────────────────────
📄 Project Alpha
├── 📝 介绍段落
├── 📄 研究笔记               ← 这是一个页面(嵌套文档)
│   ├── 📝 用户访谈
│   ├── 📄 访谈:Sarah        ← 另一个嵌套页面
│   │   └── 📝 关键洞察
│   └── 📝 竞争分析
├── 📝 时间线概览
└── 📄 会议记录               ← 另一个页面
    └── 📝 行动事项

导航:面包屑路径显示当前位置
Project Alpha > 研究笔记 > 访谈:Sarah

SwiftUI 实现模式:

struct PageView: View {
    @Bindable var page: PageDocument
    @State private var selectedBlock: CraftBlock?

    var body: some View {
        ScrollView {
            LazyVStack(alignment: .leading, spacing: 0) {
                ForEach(page.blocks) { block in
                    BlockView(block: block, onTap: { tapped in
                        if tapped.canOpenAsPage {
                            navigateToPage(tapped)
                        } else {
                            selectedBlock = tapped
                        }
                    })
                }
            }
        }
        .navigationTitle(page.title)
        .toolbar {
            // 面包屑导航
            ToolbarItem(placement: .principal) {
                BreadcrumbView(path: page.ancestorPath)
            }
        }
    }
}

struct BreadcrumbView: View {
    let path: [PageReference]

    var body: some View {
        HStack(spacing: 4) {
            ForEach(Array(path.enumerated()), id: \.element.id) { index, page in
                if index > 0 {
                    Image(systemName: "chevron.right")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
                Button(page.title) {
                    navigateTo(page)
                }
                .buttonStyle(.plain)
                .foregroundStyle(index == path.count - 1 ? .primary : .secondary)
            }
        }
    }
}

3. 卡片布局系统

Craft 提供 5 种页面视觉样式,让文档一目了然。

卡片样式:

样式 1:列表(默认)
┌────────────────────────────────────────┐
│ 📄 页面标题                            │
│    预览文本显示在此处...               │
└────────────────────────────────────────┘

样式 2:卡片(中)
┌──────────────────┐
│ ┌──────────────┐ │
│ │    [图片]    │ │
│ └──────────────┘ │
│ 页面标题         │
│ 预览文本...      │
└──────────────────┘

样式 3:卡片(大)
┌────────────────────────────────────────┐
│ ┌────────────────────────────────────┐ │
│ │                                    │ │
│ │           [封面图片]               │ │
│ │                                    │ │
│ └────────────────────────────────────┘ │
│ 页面标题                               │
│ 更长的预览文本,拥有更多空间...        │
└────────────────────────────────────────┘

样式 4:画廊(网格)
┌────────┐ ┌────────┐ ┌────────┐
│ [图片] │ │ [图片] │ │ [图片] │
│ 标题   │ │ 标题   │ │ 标题   │
└────────┘ └────────┘ └────────┘

样式 5:看板(Kanban 风格)
│ 待办     │ 进行中   │ 已完成   │
├──────────┼──────────┼──────────┤
│ 任务 1   │ 任务 3   │ 任务 5   │
│ 任务 2   │ 任务 4   │          │

实现:

enum PageLayoutStyle: String, CaseIterable {
    case list = "list"
    case cardMedium = "card_medium"
    case cardLarge = "card_large"
    case gallery = "gallery"
    case board = "board"
}

struct PageGridView: View {
    let pages: [PageReference]
    let style: PageLayoutStyle

    var body: some View {
        switch style {
        case .list:
            LazyVStack(spacing: 8) {
                ForEach(pages) { page in
                    ListRowView(page: page)
                }
            }

        case .cardMedium, .cardLarge:
            let columns = style == .cardMedium ? 3 : 2
            LazyVGrid(columns: Array(repeating: .init(.flexible()), count: columns)) {
                ForEach(pages) { page in
                    CardView(page: page, size: style == .cardLarge ? .large : .medium)
                }
            }

        case .gallery:
            LazyVGrid(columns: Array(repeating: .init(.flexible()), count: 4)) {
                ForEach(pages) { page in
                    GalleryThumbnail(page: page)
                }
            }

        case .board:
            BoardView(pages: pages)
        }
    }
}

4. 页面背景与主题

每个页面都可以通过背景和强调色拥有独特的视觉风格。

struct PageAppearance {
    // 背景选项
    enum Background {
        case solid(Color)
        case gradient(Gradient)
        case image(ImageReference)
        case paper(PaperTexture)
    }

    // 用于书写感的纸张纹理
    enum PaperTexture: String {
        case none
        case lined
        case grid
        case dotted
    }

    var background: Background = .solid(.white)
    var accentColor: Color = .blue
    var iconEmoji: String?
    var coverImage: ImageReference?
}

// 应用于页面
struct PageContainer: View {
    let page: PageDocument

    var body: some View {
        ZStack {
            // 背景层
            backgroundView(for: page.appearance.background)

            // 内容层
            PageContentView(page: page)
        }
    }

    @ViewBuilder
    private func backgroundView(for background: PageAppearance.Background) -> some View {
        switch background {
        case .solid(let color):
            color.ignoresSafeArea()
        case .gradient(let gradient):
            LinearGradient(gradient: gradient, startPoint: .top, endPoint: .bottom)
                .ignoresSafeArea()
        case .image(let ref):
            AsyncImage(url: ref.url) { image in
                image.resizable().scaledToFill()
            } placeholder: {
                Color.gray.opacity(0.1)
            }
            .ignoresSafeArea()
            .overlay(.ultraThinMaterial)
        case .paper(let texture):
            PaperTextureView(texture: texture)
        }
    }
}

5. 共享页面(网页发布)

Craft 可以从文档生成美观的响应式网页。查看时无需账户。

CRAFT 中的文档                    网页上的共享页面
───────────────────────────────────────────────────────────────────
📄 项目提案                       https://www.craft.do/s/abc123
├── 📝 执行摘要             →
├── 📄 预算详情                   简洁、响应式布局
├── 📝 时间表                     排版保留
└── 📄 团队简介                   图片优化
                                  支持深色模式
                                  无需 Craft 账户

主要特性: - 一键发布 - 支持自定义域名 - 密码保护选项 - 浏览量分析 - SEO友好的渲染 - 响应式适配所有设备


视觉设计系统

配色方案

extension Color {
    // Craft 标志性配色
    static let craftPurple = Color(hex: "#6366F1")  // 主色调
    static let craftBackground = Color(hex: "#FAFAFA")  // 浅色模式
    static let craftSurface = Color(hex: "#FFFFFF")

    // 语义颜色
    static let craftSuccess = Color(hex: "#10B981")
    static let craftWarning = Color(hex: "#F59E0B")
    static let craftError = Color(hex: "#EF4444")
    static let craftInfo = Color(hex: "#3B82F6")

    // 文本层级
    static let craftTextPrimary = Color(hex: "#111827")
    static let craftTextSecondary = Color(hex: "#6B7280")
    static let craftTextMuted = Color(hex: "#9CA3AF")
}

排版

struct CraftTypography {
    // 文档排版
    static let title = Font.system(size: 32, weight: .bold, design: .default)
    static let heading1 = Font.system(size: 28, weight: .semibold)
    static let heading2 = Font.system(size: 22, weight: .semibold)
    static let heading3 = Font.system(size: 18, weight: .medium)
    static let body = Font.system(size: 16, weight: .regular)
    static let caption = Font.system(size: 14, weight: .regular)

    // 代码块
    static let code = Font.system(size: 14, weight: .regular, design: .monospaced)

    // 行高(宽松设计以提高可读性)
    static let bodyLineSpacing: CGFloat = 8
    static let headingLineSpacing: CGFloat = 4
}

间距系统

struct CraftSpacing {
    // 块间距
    static let blockGap: CGFloat = 4        // 块之间
    static let sectionGap: CGFloat = 24     // 区块之间
    static let pageMargin: CGFloat = 40     // 页面边缘(桌面端)
    static let mobileMargin: CGFloat = 16   // 页面边缘(移动端)

    // 内容宽度
    static let maxContentWidth: CGFloat = 720  // 最佳阅读体验
    static let wideContentWidth: CGFloat = 960 // 表格、图库
}

动画设计理念

流畅的页面过渡

// 页面之间的导航使用匹配几何效果
struct NavigationTransition: View {
    @Namespace private var namespace
    @State private var selectedPage: PageReference?

    var body: some View {
        ZStack {
            // 页面列表
            PageListView(
                onSelect: { page in
                    withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
                        selectedPage = page
                    }
                },
                namespace: namespace
            )

            // 选中的页面从其卡片位置展开
            if let page = selectedPage {
                PageDetailView(page: page)
                    .matchedGeometryEffect(id: page.id, in: namespace)
                    .transition(.scale.combined(with: .opacity))
            }
        }
    }
}

区块动画

// 重新排序时区块会产生动画效果
struct AnimatedBlockList: View {
    @Binding var blocks: [CraftBlock]

    var body: some View {
        LazyVStack(spacing: CraftSpacing.blockGap) {
            ForEach(blocks) { block in
                BlockView(block: block)
                    .transition(.asymmetric(
                        insertion: .move(edge: .top).combined(with: .opacity),
                        removal: .move(edge: .bottom).combined(with: .opacity)
                    ))
            }
        }
        .animation(.spring(response: 0.3, dampingFraction: 0.8), value: blocks)
    }
}

对我们工作的启示

1. 原生性能值得投入

用户能感受到 16ms 和 200ms 延迟之间的差异。原生应用在体验感上占据优势。

2. 页面嵌套实现有机组织

让结构从内容中自然涌现。不要强迫用户预先决定层级结构。

3. 同一内容支持多种视觉布局

卡片、列表、画廊——相同的数据,不同的视图适应不同的场景。

4. 无摩擦分享

一键网页发布彻底解决了"我该如何分享这个?"的问题。

5. 适配平台,而非照搬平台

Mac 上的 Craft 有 Mac 应用的感觉。iOS 上的 Craft 有 iOS 应用的感觉。这才是目标。


常见问题

Craft 与 Notion 有何不同?

Craft 是原生应用(专为 Apple 平台构建),而 Notion 是基于网页的。这意味着 Craft 可以实现低于 50ms 的响应时间,完全支持离线工作,并与 iOS/macOS 功能深度集成,如 Apple Pencil、快捷指令和系统级搜索。Notion 提供更多数据库功能;Craft 则优先考虑写作体验。

Craft 可以离线使用吗?

可以。Craft 将所有文档存储在本地,并在联网时通过 iCloud 同步。你可以在没有网络的情况下创建、编辑和整理文档。 重新连接后更改会自动同步。

分享 Craft 页面时会发生什么?

Craft 会在 craft.do URL 生成一个响应式网页。收件人无需创建账户或安装应用,即可在任何浏览器中查看。页面会保留您文档的排版、图片和嵌套页面结构。

Craft 支持 Markdown 吗?

Craft 使用自己的块格式,但支持导出为 Markdown。您可以将内容复制为 Markdown 或导出整个文档。编辑时某些 Markdown 快捷方式可用(如 # 表示标题),但 Craft 更强调可视化编辑而非纯文本标记。

Craft 中的嵌套页面如何工作?

按下页面图标或输入 /page,任何文本块都可以变成页面。嵌套页面会内嵌显示在父文档中,也可以全屏打开。面包屑导航会显示您在层级结构中的位置。这种方式无需预先建立文件夹结构,即可实现自然的内容组织。