Things 3: 專注簡潔的藝術

Things 3如何透過約束實現簡潔:自然層次結構、When與Deadline分離、鍵盤優先設計、有意義的色彩節制。包含SwiftUI實作模式。

5 分鐘閱讀 175 字
Things 3: 專注簡潔的藝術 screenshot

Things 3:專注簡約的藝術

「我們相信簡單是困難的。」— Cultured Code

Things 3 經常被譽為史上最美的任務管理工具。這份美感源自於對重要事物的極致專注,以及對一切多餘元素的果斷捨棄。


為什麼 Things 3 如此重要

Things 3 證明了限制能夠創造清晰。當競爭對手不斷堆疊功能(提供 47 種重複任務選項、附帶相依性的專案和甘特圖),Things 卻在思考:一個人究竟需要什麼才能把事情完成?

關鍵成就: - 在不犧牲深度的情況下達成視覺簡潔 - 讓日期變得有意義(今天、傍晚、將來某天) - 創建了符合人類思考方式的層級結構 - 證明了鍵盤優先也能兼具美感 - 為 macOS/iOS 原生應用程式設計樹立了標竿


核心要點

  1. 區分「我什麼時候做這件事?」與「截止日期是什麼時候?」 - Things 的雙日期模型(When + Deadline)消除了困擾大多數任務應用程式的模糊性;計劃意圖和硬性截止日期服務於不同目的
  2. 「將來某天」是功能,不是失敗狀態 - 給予想法一個無壓力的歸屬,能防止收件匣焦慮;「將來某天」中的任務不會堆積在「今天」,也不會引發內疚感
  3. 領域永不完成,專案會完成 - 這個區分反映了人類認知:「工作」是持續的人生領域,「發布 v2.0」則有終點線;混淆這兩者會造成困惑
  4. 將顏色保留給有意義的地方 - Things 的介面 95% 是中性色(黑、白、灰);顏色只出現在語義資訊上(黃色=今天、紅色=截止日期、靛藍色=傍晚)
  5. 完成任務應該帶來滿足感 - 勾選動畫(填滿 → 打勾 → 音效)雖然耗時 500 毫秒,卻能釋放多巴胺;完成任務本身就成為了內在動力

核心設計原則

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                 │
└─────────────────────────────────────────────────────────────┘

關鍵洞察:領域永遠不會完成(它們是生活的類別)。專案會完成(週五前發布)。這個區分消除了規劃時的猶豫不決。

資料模型:

// 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. 「何時做」,而非僅有「截止日」

Things 將「我什麼時候做這件事?」(今天、傍晚、將來某天)與「這件事什麼時候必須完成?」(截止日期)分開處理。大多數應用程式將這兩者混為一談。

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                      │
└───────────────────┴─────────────────────────────────────────┘

關鍵洞察:「將來某天」不是失敗狀態——它是壓力釋放閥。想法有了歸屬,就不會堆積在「今天」。

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        排程(When 選擇器)
  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 requests                                      │
│     [#] work(藍色標籤)                                    │
│                                                             │
│ ( ) 打電話給牙醫                                            │
│     [E] 今晚(靛藍色標記)                                  │
└─────────────────────────────────────────────────────────────┘

注意:大部分文字都是黑色。色彩只在具有意義的地方出現。

核心洞見:當所有東西都色彩繽紛時,就沒有任何東西能夠突出。將色彩保留給資訊傳達。

CSS 模式:

:root {
  /* 中性色調(UI 的主要部分) */
  --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)

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

                // 勾選標記
                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() {
        // 階段 1:填滿圓圈
        withAnimation(.easeInOut(duration: 0.2)) {
            animationPhase = .filling
        }

        // 階段 2:顯示勾選標記並帶彈跳效果
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
            withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) {
                animationPhase = .checked
            }
        }

        // 階段 3:穩定並標記完成
        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) {
            // 始終可見的列
            TaskRow(task: task, isExpanded: $isExpanded)

            // 可展開的詳情
            if isExpanded {
                TaskDetails(task: task)
                    .padding(.leading, 44) // 與標題對齊
                    .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. 關注點分離:「我何時要做這件事?」vs「它的截止日期是什麼時候?」是兩個不同的問題
  2. 色彩的克制:將顏色保留給有意義的元素;中性色是預設
  3. 完成即是獎勵:讓完成任務帶來愉悅感
  4. 鍵盤加速,滑鼠歡迎:同時為兩者設計,不犧牲任何一方
  5. 自然的層級結構:反映人類對工作的自然思考方式(領域 → 專案 → 任務)
  6. 「將來/也許」是一種功能:為想法提供一個不會造成壓力的歸屬

常見問題

Things 3 中「When」和「Deadline」有什麼區別?

「When」回答的是「我何時會處理這件事?」(今天、今晚、將來/也許,或特定日期)。「Deadline」回答的是「這件事必須何時完成?」大多數任務應用程式將這兩者混為一談,但它們服務於不同目的。一個任務可能週五到期(Deadline),但你計劃週三處理它(When)。這種分離讓你能根據意圖規劃一天,同時仍能追蹤硬性的截止日期。

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 方法論