Things 3: 专注简洁的艺术

Things 3如何通过约束实现简洁:自然层次结构、When与Deadline分离、键盘优先设计、有意义的色彩节制。包含SwiftUI实现模式。

5 分钟阅读 184 字
Things 3: 专注简洁的艺术 screenshot

Things 3:专注简约的艺术

"我们相信简单是最难的。" — Cultured Code

Things 3 常被誉为有史以来最美的任务管理器。这种美源于对核心功能的极致专注,以及对其他一切的果断舍弃。


Things 3 为何重要

Things 3 证明了约束创造清晰。当竞争对手不断堆砌功能(重复任务有 47 种选项、项目支持依赖关系和甘特图)时,Things 问的是:一个人完成工作究竟需要什么?

核心成就: - 在不牺牲深度的前提下实现了视觉简洁 - 让日期变得有意义(今天、晚间、将来某天) - 创建了符合人类思维方式的层级结构 - 证明了键盘优先也可以很美 - 树立了 macOS/iOS 原生应用设计的标杆


核心要点

  1. 将"我什么时候做"与"什么时候截止"分开 - Things 的双日期模型(When + Deadline)消除了困扰大多数任务应用的歧义;计划意图和硬性截止日期服务于不同目的
  2. Someday 是一项功能,而非失败状态 - 为想法提供一个无压力的归宿可以防止收件箱焦虑;Someday 中的任务不会干扰 Today,也不会引发负罪感
  3. Area 永不完成,Project 会完成 - 这一区分反映了人类认知:"工作"是持续的生活领域,"发布 v2.0"有明确的终点;混淆两者会造成困惑
  4. 将颜色留给有意义的信息 - Things 的界面 95% 是中性色(黑、白、灰);颜色仅用于语义信息(黄色=今天,红色=截止日期,靛蓝=晚间)
  5. 完成任务应该令人愉悦 - 复选框动画(填充 → 勾选 → 音效)需要 500ms,但能带来多巴胺;完成任务本身就成为内在动力

核心设计原则

1. 自然层级结构

Things 按照人类思考的方式组织工作:从宏观(生活领域)到具体(单个任务)。

THINGS HIERARCHY:

Area (Work, Personal, Health)     ← Broad life categories
  │
  └── Project (Launch App)        ← Finite goal with tasks
        │
        └── Heading (Design)      ← Optional grouping
              │
              └── To-Do           ← Single actionable item
                    │
                    └── Checklist ← Sub-steps within task

Example:
┌─────────────────────────────────────────────────────────────┐
│ [A] WORK (Area)                                             │
│   ├── [P] Ship Version 2.0 (Project)                        │
│   │     ├── [H] Design                                      │
│   │     │     ├── [ ] Create new icons                      │
│   │     │     └── [ ] Update color palette                  │
│   │     └── [H] Development                                 │
│   │           ├── [ ] Implement auth flow                   │
│   │           └── [ ] Add analytics                         │
│   └── [P] Website Redesign (Project)                        │
│         └── [ ] Write copy for landing page                 │
└─────────────────────────────────────────────────────────────┘

关键洞察:Area 永不完成(它们是生活类别)。Project 会完成(周五前发布)。这一区分消除了规划时的选择困难。

数据模型:

// Things' hierarchy expressed in code
struct Area: Identifiable {
    let id: UUID
    var title: String
    var projects: [Project]
    var tasks: [Task]  // Loose tasks not in projects
}

struct Project: Identifiable {
    let id: UUID
    var title: String
    var notes: String?
    var deadline: Date?
    var headings: [Heading]
    var tasks: [Task]
    var isComplete: Bool
}

struct Heading: Identifiable {
    let id: UUID
    var title: String
    var tasks: [Task]
}

struct Task: Identifiable {
    let id: UUID
    var title: String
    var notes: String?
    var when: TaskSchedule?
    var deadline: Date?
    var tags: [Tag]
    var checklist: [ChecklistItem]
    var isComplete: Bool
}

enum TaskSchedule {
    case today
    case evening
    case specificDate(Date)
    case someday
}

2. When,而非仅仅 Due

Things 将"我什么时候做这件事?"(Today、Evening、Someday)与"这件事必须什么时候完成?"(Deadline)区分开来。大多数应用把这两者混为一谈。

TRADITIONAL DATE MODEL:
┌──────────────────────────────────────────────────────────────┐
│ Task: Write report                                           │
│ Due: March 15th                                              │
│                                                              │
│ Problem: Is March 15th when I'll do it, or when it's due?   │
│          If it's due, when should I start?                  │
└──────────────────────────────────────────────────────────────┘

THINGS DATE MODEL:
┌──────────────────────────────────────────────────────────────┐
│ Task: Write report                                           │
│ When: Today (I'll work on it today)                         │
│ Deadline: March 15th (must be complete by then)             │
│                                                              │
│ Clarity: Two separate questions, two separate answers       │
└──────────────────────────────────────────────────────────────┘

SMART LISTS (automatic filtering):

┌───────────────────┬─────────────────────────────────────────┐
│ [I] INBOX         │ Uncategorized captures                  │
├───────────────────┼─────────────────────────────────────────┤
│ [*] TODAY         │ when == .today OR deadline == today     │
├───────────────────┼─────────────────────────────────────────┤
│ [E] THIS EVENING  │ when == .evening                        │
├───────────────────┼─────────────────────────────────────────┤
│ [D] UPCOMING      │ when == .specificDate (future)          │
├───────────────────┼─────────────────────────────────────────┤
│ [A] ANYTIME       │ when == nil AND deadline == nil         │
├───────────────────┼─────────────────────────────────────────┤
│ [S] SOMEDAY       │ when == .someday                        │
├───────────────────┼─────────────────────────────────────────┤
│ [L] LOGBOOK       │ isComplete == true                      │
└───────────────────┴─────────────────────────────────────────┘

关键洞察:"Someday"不是失败状态——而是压力释放阀。想法有了归宿,却不会干扰 Today。

SwiftUI 实现:

struct WhenPicker: View {
    @Binding var schedule: TaskSchedule?
    @Binding var deadline: Date?
    @State private var showDatePicker = false

    var body: some View {
        VStack(spacing: 12) {
            // Quick options
            HStack(spacing: 8) {
                WhenButton(
                    icon: "star.fill",
                    label: "Today",
                    color: .yellow,
                    isSelected: schedule == .today
                ) {
                    schedule = .today
                }

                WhenButton(
                    icon: "moon.fill",
                    label: "Evening",
                    color: .indigo,
                    isSelected: schedule == .evening
                ) {
                    schedule = .evening
                }

                WhenButton(
                    icon: "calendar",
                    label: "Pick Date",
                    color: .red,
                    isSelected: showDatePicker
                ) {
                    showDatePicker = true
                }

                WhenButton(
                    icon: "archivebox.fill",
                    label: "Someday",
                    color: .brown,
                    isSelected: schedule == .someday
                ) {
                    schedule = .someday
                }
            }

            // Deadline (separate from "when")
            if let deadline = deadline {
                HStack {
                    Image(systemName: "flag.fill")
                        .foregroundStyle(.red)
                    Text("Deadline: \(deadline, style: .date)")
                    Spacer()
                    Button("Clear") { self.deadline = nil }
                }
                .font(.caption)
            }
        }
    }
}

struct WhenButton: View {
    let icon: String
    let label: String
    let color: Color
    let isSelected: Bool
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            VStack(spacing: 4) {
                Image(systemName: icon)
                    .font(.title2)
                Text(label)
                    .font(.caption2)
            }
            .frame(maxWidth: .infinity)
            .padding(.vertical, 8)
            .background(isSelected ? color.opacity(0.2) : .clear)
            .cornerRadius(8)
        }
        .buttonStyle(.plain)
        .foregroundStyle(isSelected ? color : .secondary)
    }
}

3. 键盘优先,鼠标友好

所有操作都可以完全通过键盘完成,具备肌肉记忆级别的高效率,同时对鼠标用户保持直观易用。

键盘快捷键(易于记忆的模式):

导航:
  Cmd+1-6      跳转到智能列表(收件箱、今天等)
  Cmd+Up/Down  在分区之间移动
  Space        快速预览(查看任务详情)

任务操作:
  Cmd+K        完成任务
  Cmd+S        安排日程(时间选择器)
  Cmd+Shift+D  设置截止日期
  Cmd+Shift+M  移动到项目
  Cmd+Shift+T  添加标签

创建:
  Space/Return 创建新任务(根据上下文)
  Cmd+N        在收件箱中新建任务
  Cmd+Opt+N    新建项目

快速输入(全局快捷键):
  Ctrl+Space   从任何地方打开浮动快速输入框
  ┌─────────────────────────────────────────────────┐
  │ [ ] |                                           │
  │   新任务出现在你当前所在的位置                    │
  └─────────────────────────────────────────────────┘

核心洞察:快捷键应该是可发现的,但不是必需的。界面会提示按键,但不强制使用。

CSS 模式(快捷键提示):

.action-button {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
}

.shortcut-hint {
  font-size: 11px;
  font-family: var(--font-mono);
  padding: 2px 6px;
  background: var(--surface-subtle);
  border-radius: 4px;
  color: var(--text-tertiary);
  opacity: 0;
  transition: opacity 0.15s ease;
}

/* 悬停时显示 */
.action-button:hover .shortcut-hint {
  opacity: 1;
}

/* 通过键盘聚焦时始终可见 */
.action-button:focus-visible .shortcut-hint {
  opacity: 1;
}

SwiftUI 快速输入:

struct QuickEntryPanel: View {
    @Binding var isPresented: Bool
    @State private var taskTitle = ""
    @FocusState private var isFocused: Bool

    var body: some View {
        VStack(spacing: 0) {
            HStack(spacing: 12) {
                // 复选框(仅用于视觉展示)
                Circle()
                    .strokeBorder(.tertiary, lineWidth: 1.5)
                    .frame(width: 20, height: 20)

                // Task input
                TextField("New To-Do", text: $taskTitle)
                    .textFieldStyle(.plain)
                    .font(.body)
                    .focused($isFocused)
                    .onSubmit {
                        createTask()
                    }
            }
            .padding()
            .background(.ultraThinMaterial)
            .cornerRadius(12)
            .shadow(color: .black.opacity(0.2), radius: 20, y: 10)
        }
        .frame(width: 500)
        .onAppear {
            isFocused = true
        }
        .onExitCommand {
            isPresented = false
        }
    }

    private func createTask() {
        guard !taskTitle.isEmpty else { return }
        // Create task in Inbox
        TaskStore.shared.createTask(title: taskTitle)
        taskTitle = ""
    }
}

4. 视觉克制

Things 对颜色的使用极为节制,仅在需要传达含义时才使用。界面的绝大部分保持中性色调,让有色元素能够脱颖而出。

THINGS 中的颜色运用:

中性色(占界面 95%):
├── 背景:纯白 / 深灰
├── 文字:黑色 / 白色
├── 边框:极淡的灰色
└── 图标:单色

有语义的颜色(占 5%):
├── 黄色 [*] = 今天
├── 红色 [!] = 截止日期 / 已逾期
├── 蓝色 [#] = 标签(用户自定义)
├── 绿色 [x] = 完成动画
└── 靛蓝 [E] = 今晚

视觉示例:

┌─────────────────────────────────────────────────────────────┐
│ 今天                                         [*](黄色)    │
├─────────────────────────────────────────────────────────────┤
│ ( ) 编写文档                                                │
│     项目名称 - [!] 明天截止(红色)                          │
│                                                             │
│ ( ) 审查 Pull Request                                       │
│     [#] work(蓝色标签)                                    │
│                                                             │
│ ( ) 打电话给牙医                                            │
│     [E] 今晚(靛蓝徽章)                                    │
└─────────────────────────────────────────────────────────────┘

注意:大部分文字都是黑色的。颜色只在承载含义时才出现。

核心洞察:当一切都五彩斑斓时,就没有什么能突出显示了。把颜色留给真正需要传达的信息。

CSS 模式:

:root {
  /* 中性调色板(界面主体) */
  --surface-primary: #ffffff;
  --surface-secondary: #f5f5f7;
  --text-primary: #1d1d1f;
  --text-secondary: #86868b;
  --border: rgba(0, 0, 0, 0.08);

  /* 语义颜色(谨慎使用) */
  --color-today: #f5c518;
  --color-deadline: #ff3b30;
  --color-evening: #5856d6;
  --color-complete: #34c759;
  --color-tag: #007aff;
}

/* 任务行 - 以中性色为主 */
.task-row {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding: 12px 16px;
  border-bottom: 1px solid var(--border);
  background: var(--surface-primary);
  color: var(--text-primary);
}

/* 颜色仅用于传达含义 */
.deadline-badge {
  font-size: 12px;
  color: var(--color-deadline);
}

.deadline-badge.overdue {
  font-weight: 600;
  color: var(--color-deadline);
}

.tag {
  font-size: 12px;
  padding: 2px 8px;
  border-radius: 4px;
  background: var(--color-tag);
  color: white;
}

.evening-badge {
  color: var(--color-evening);
}

5. 令人愉悦的完成体验

完成动画带来的是奖励感,而非单纯的反馈。悦耳的"叮"声配合流畅的勾选动画,让完成任务成为一种享受。

完成序列:

第 1 帧:   ○ 任务标题
第 2-4 帧:◔(圆圈顺时针填充)
第 5-7 帧:●(完全填满,短暂停顿)
第 8-10 帧:✓(勾选出现,略微放大)
第 11+ 帧:行滑出,列表收拢间隙

音效:第 7-8 帧(完成瞬间)播放轻柔的"叮"声
触感:iOS 上在完成时轻触反馈

SwiftUI 实现:

struct CompletableCheckbox: View {
    @Binding var isComplete: Bool
    @State private var animationPhase: AnimationPhase = .unchecked

    enum AnimationPhase {
        case unchecked, filling, checked, done
    }

    var body: some View {
        Button {
            completeWithAnimation()
        } label: {
            ZStack {
                // Background circle
                Circle()
                    .strokeBorder(.tertiary, lineWidth: 1.5)
                    .frame(width: 22, height: 22)

                // Fill animation
                Circle()
                    .trim(from: 0, to: fillAmount)
                    .stroke(.green, lineWidth: 1.5)
                    .frame(width: 22, height: 22)
                    .rotationEffect(.degrees(-90))

                // Checkmark
                Image(systemName: "checkmark")
                    .font(.system(size: 12, weight: .bold))
                    .foregroundStyle(.green)
                    .scaleEffect(checkScale)
                    .opacity(checkOpacity)
            }
        }
        .buttonStyle(.plain)
        .sensoryFeedback(.success, trigger: animationPhase == .checked)
    }

    private var fillAmount: CGFloat {
        switch animationPhase {
        case .unchecked: return 0
        case .filling: return 1
        case .checked, .done: return 1
        }
    }

    private var checkScale: CGFloat {
        animationPhase == .checked ? 1.2 : (animationPhase == .done ? 1.0 : 0)
    }

    private var checkOpacity: CGFloat {
        animationPhase == .unchecked || animationPhase == .filling ? 0 : 1
    }

    private func completeWithAnimation() {
        // Phase 1: Fill the circle
        withAnimation(.easeInOut(duration: 0.2)) {
            animationPhase = .filling
        }

        // Phase 2: Show checkmark with bounce
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
            withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) {
                animationPhase = .checked
            }
        }

        // Phase 3: Settle and mark complete
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            withAnimation(.easeOut(duration: 0.15)) {
                animationPhase = .done
            }
            isComplete = true
        }
    }
}

可迁移的设计模式

模式 1:渐进式任务详情

任务详情在原位展开,而非跳转到单独的页面。

struct ExpandableTask: View {
    let task: Task
    @State private var isExpanded = false

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            // Always visible row
            TaskRow(task: task, isExpanded: $isExpanded)

            // Expandable details
            if isExpanded {
                TaskDetails(task: task)
                    .padding(.leading, 44) // Align with title
                    .transition(.asymmetric(
                        insertion: .push(from: .top).combined(with: .opacity),
                        removal: .push(from: .bottom).combined(with: .opacity)
                    ))
            }
        }
        .animation(.spring(response: 0.3, dampingFraction: 0.8), value: isExpanded)
    }
}

模式 2:自然语言输入

Things 能够解析自然语言来设置日期,例如"tomorrow"、"next week"、"June 15"。

function parseNaturalDate(input: string): TaskSchedule | null {
  const lower = input.toLowerCase();


  // Today shortcuts
  if (['today', 'tod', 'now'].includes(lower)) {
    return { type: 'today' };
  }

  // Evening
  if (['tonight', 'evening', 'eve'].includes(lower)) {
    return { type: 'evening' };
  }

  // Tomorrow
  if (['tomorrow', 'tom', 'tmrw'].includes(lower)) {
    const date = new Date();
    date.setDate(date.getDate() + 1);
    return { type: 'date', date };
  }

  // Someday
  if (['someday', 'later', 'eventually'].includes(lower)) {
    return { type: 'someday' };
  }

  // Relative days
  const inMatch = lower.match(/in (\d+) days?/);
  if (inMatch) {
    const date = new Date();
    date.setDate(date.getDate() + parseInt(inMatch[1]));
    return { type: 'date', date };
  }

  // Day names
  const days = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
  const dayIndex = days.findIndex(d => lower.startsWith(d.slice(0, 3)));
  if (dayIndex !== -1) {
    const date = getNextDayOfWeek(dayIndex);
    return { type: 'date', date };
  }

  return null;
}
### 模式 3:基于列表的拖放 重新排序是一种直接操作,具有清晰的视觉反馈。
.task-row {
  transition: transform 0.15s ease, box-shadow 0.15s ease;
}

.task-row.dragging {
  transform: scale(1.02);
  box-shadow:
    0 4px 12px rgba(0, 0, 0, 0.15),
    0 0 0 2px var(--color-focus);
  z-index: 100;
}

.task-row.drop-above::before {
  content: '';
  position: absolute;
  top: -2px;
  left: 44px;
  right: 16px;
  height: 4px;
  background: var(--color-focus);
  border-radius: 2px;
}

.task-row.drop-below::after {
  content: '';
  position: absolute;
  bottom: -2px;
  left: 44px;
  right: 16px;
  height: 4px;
  background: var(--color-focus);
  border-radius: 2px;
}

设计启示

  1. 关注点分离:"我什么时候做这件事?"与"截止日期是什么时候?"是两个不同的问题
  2. 克制使用色彩:将色彩保留给有意义的元素;中性色是默认选择
  3. 完成即奖励:让完成任务的体验令人愉悦
  4. 键盘加速,鼠标友好:同时为两者设计,互不妥协
  5. 自然的层级结构:反映人类思考工作的方式(领域 → 项目 → 任务)
  6. "将来/也许"是一个功能:给想法一个不会产生压力的归宿

常见问题

Things 3 中"何时"与"截止日期"有什么区别?

"何时"回答的是"我什么时候处理这件事?"(今天、今晚、将来/也许,或具体日期)。"截止日期"回答的是"这件事必须在什么时候完成?"大多数任务应用混淆了这两个概念,但它们有不同的用途。一个任务可能周五到期(截止日期),但你计划周三处理它(何时)。这种分离让你可以围绕意图规划一天,同时仍能追踪硬性截止日期。

Things 3 的层级结构(领域、项目、任务)是如何运作的?

领域是永远不会完成的广泛生活类别(工作、个人、健康)。项目是有明确结束状态的有限目标(发布应用、计划假期)。任务是单个可执行的事项。标题可选择性地在项目内对任务进行分组。这反映了人类思考工作的自然方式:持续的责任包含有时限的目标,目标又包含具体的行动。

为什么 Things 3 使用如此少的色彩?

Things 将色彩专门保留给语义化的含义。界面 95% 是中性色(黑、白、灰),所以当色彩出现时,它在传达信息:黄色表示今天,红色表示截止日期/逾期,靛蓝色表示今晚,蓝色标记标签。如果所有东西都是彩色的,就没有什么能脱颖而出。这种克制使有意义的色彩不可能被忽视。

Things 3 中的"将来/也许"是什么,为什么它很有价值?

"将来/也许"是一个专门的空间,用于存放你想记住但不想让它们干扰活动列表的任务。它不是一种失败状态——它是一个压力释放阀。那些"将来/也许"的想法(学西班牙语、读那本书)有了归宿,而不会造成每日的负罪感。你可以每周回顾"将来/也许",在准备好时将任务提升到今天。

Things 3 的完成动画是如何运作的?

当你点击复选框时,圆圈顺时针填充(200ms),然后对勾带着轻微的弹跳出现(弹簧动画),同时伴随着轻柔的"叮"声和 iOS 上的触觉反馈。随后该行滑走(150ms)。整个序列大约需要 500ms,但这种刻意的时机创造了一个小小的多巴胺冲击,让完成任务感觉很有成就感。


资源

  • 官网: culturedcode.com/things
  • 设计理念: Cultured Code 关于简洁性的博客文章
  • 键盘快捷键: 内置帮助菜单(Cmd+/)
  • GTD 整合: Things 如何映射到 Getting Things Done 方法论