Skip to content

Commit 85c32e2

Browse files
committed
feat: Add implementation details for ListView-DetailView focus state fix plan, including successful P1 implementation and validation steps
1 parent 031f633 commit 85c32e2

2 files changed

Lines changed: 172 additions & 0 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# ListView / DetailView 焦点状态修复方案(P1/P2/P3)✅ 已完成
2+
3+
## 实施总结
4+
5+
**P1 方案已成功实施并验证**
6+
- **实施时间**:2025-12-29
7+
- **修改文件**`SyncNos/Views/Components/Main/MainListView+FocusManager.swift`
8+
- **核心改动**:在 `startMouseDownMonitorIfNeeded()` 中添加点击 Detail 区域的焦点强制切换逻辑
9+
- **验证结果**:✅ 鼠标点击 Detail 后 List 选中项高亮正确变灰;✅ 点击 List 恢复蓝色;✅ 键盘 ←/→ 导航不受影响
10+
- **P2/P3 方案**:无需实施
11+
12+
---
13+
14+
## 背景与目标
15+
16+
### 现象
17+
- **键盘 ←/→ 导航**:List 选中项高亮会在“强调色(蓝色)↔ 灰色”之间正确切换 ✅
18+
- **鼠标点击 DetailView**:List 选中项高亮不会变灰,仍保持强调色 ❌
19+
20+
### 目标行为
21+
- 焦点在 **List**:选中项高亮为 **强调色(蓝色)**
22+
- 焦点在 **Detail**:选中项高亮为 **非激活(灰色)**
23+
24+
## 现状实现盘点(关键文件)
25+
26+
### 焦点/键盘基础设施(已存在)
27+
- `SyncNos/Views/Components/Main/MainListView+KeyboardMonitor.swift`
28+
- 处理 ←/→:在 `.list → .detail` 时调用 `focusDetailScrollViewIfPossible(window:)`,内部通过 `window.makeFirstResponder(scrollView.contentView)` 强制切换 firstResponder,因此高亮表现正确。
29+
- `SyncNos/Views/Components/Main/MainListView+FocusManager.swift`
30+
- 通过 `mouseDownMonitor` 监听 `.leftMouseDown`,点击后延迟 `syncNavigationTargetWithFocus()`,但**只做“同步状态”不做“切焦点”**
31+
32+
### List 强制拿焦点(导致问题更明显)
33+
各数据源 `*ListView.swift` 都有类似逻辑:
34+
- `@FocusState private var isListFocused: Bool`
35+
- `List(...).focused($isListFocused)`
36+
- `.onAppear / DataSourceSwitchedToXxx``isListFocused = true`
37+
38+
典型例子:
39+
- `SyncNos/Views/AppleBooks/AppleBooksListView.swift`
40+
- `SyncNos/Views/GoodLinks/GoodLinksListView.swift`
41+
- `SyncNos/Views/WeRead/WeReadListView.swift`
42+
- `SyncNos/Views/Dedao/DedaoListView.swift`
43+
- `SyncNos/Views/Chats/ChatListView.swift`
44+
45+
### Detail 的 ScrollView 可解析(但鼠标点击不会自动成为 firstResponder)
46+
各数据源 Detail 都用 `EnclosingScrollViewReader` 回传底层 `NSScrollView`
47+
- `SyncNos/Views/Components/Keyboard/EnclosingScrollViewReader.swift`
48+
- e.g. `SyncNos/Views/AppleBooks/AppleBooksDetailView.swift` / `GoodLinksDetailView.swift` / `ChatDetailView.swift`
49+
50+
## 根因判断(当前最符合现象的解释)
51+
52+
键盘导航之所以“正确”,是因为代码显式调用了:
53+
- `window.makeFirstResponder(detailScrollView.contentView)`
54+
55+
鼠标点击 Detail 之所以“不变灰”,是因为:
56+
- Detail 的多数区域(尤其是空白处/仅展示内容的区域)**不会抢 firstResponder**
57+
- 左侧 List 由于 `.focused($isListFocused)` 长期维持“可聚焦/已聚焦”,于是 **List 仍是 firstResponder** → 选中高亮持续强调色
58+
59+
## 方案总览(按优先级)
60+
61+
### P1(推荐,最小改动,复用现有基础设施)✅ 已实施并验证成功
62+
**思路**:在现有 `mouseDownMonitor` 里补齐“点击 Detail 但 firstResponder 仍在 List”的兜底逻辑:若点击发生在当前 Detail 的 `NSScrollView` 范围内,则主动 `makeFirstResponder(scrollView.contentView)`
63+
64+
**改动点**
65+
- 文件:`SyncNos/Views/Components/Main/MainListView+FocusManager.swift`
66+
- `startMouseDownMonitorIfNeeded()`
67+
- 记录点击坐标
68+
- 点击后延迟判断:若点击在 `currentDetailScrollView` 内,但 `syncNavigationTargetWithFocus()` 判断仍是 `.list`,则:
69+
- `savedMasterFirstResponder = window.firstResponder`(用于 ← 返回)
70+
- `keyboardNavigationTarget = .detail`
71+
- `focusDetailScrollViewIfPossible(window:)`(让 List 立刻变“非激活灰”)
72+
73+
**优点**
74+
- 改动面小,只动一处焦点管理代码
75+
- 不需要改每个 DetailView / ListView
76+
- 与现有键盘 ←/→ 策略一致(统一用 AppKit firstResponder 驱动)
77+
78+
**潜在风险**
79+
- 需避免覆盖文本输入控件的 firstResponder(例如弹出的输入框)。解决方式:只在 “点击发生在 Detail 区域 + firstResponder 仍旧是 List” 时才强制切换。
80+
81+
**验证步骤**
82+
- 手动:
83+
- 在任意数据源选中一个条目(确保右侧有 Detail)
84+
- 鼠标点击 Detail 的空白区域/卡片区域:左侧选中高亮应从蓝变灰
85+
- 鼠标点击左侧 List:高亮应恢复为蓝
86+
- 键盘 →/←:行为应与此前一致
87+
- 构建:
88+
- `xcodebuild -scheme SyncNos -configuration Debug -destination "platform=macOS" build`
89+
90+
---
91+
92+
### P2(更"SwiftUI 内聚",但改动面更大)❌ 无需实施
93+
**思路**:不使用全局 `mouseDownMonitor`,改为在 `detailColumn` 根部叠加一个 `NSViewRepresentable` 的透明点击捕获层(或用 `NSClickGestureRecognizer`),只在 detail 视图树内处理点击 → 切焦点到 `currentDetailScrollView`
94+
95+
**改动点(示例方向)**
96+
- 新增组件:`DetailClickFocusCatcher.swift`(NSViewRepresentable)
97+
-`MainListView+DetailViews.swift``detailColumn` 外层包一层 `.background(DetailClickFocusCatcher(...))`
98+
99+
**优点**
100+
- 不依赖全局事件监听器
101+
- 只在 detail 区域内工作,语义更清晰
102+
103+
**缺点/风险**
104+
- 需要处理手势穿透、与内部控件点击冲突等边界
105+
- 实现复杂度略高
106+
107+
**验证**
108+
- 同 P1
109+
110+
---
111+
112+
### P3(破坏性更强:移除 List 的 FocusState 强制逻辑)❌ 无需实施
113+
**思路**:彻底去掉各 `*ListView``.focused($isListFocused)` + `isListFocused = true`,只靠系统/firstResponder 自然流转(配合已有键盘监控)。
114+
115+
**优点**
116+
- 从源头减少“List 抢焦点”问题
117+
118+
**缺点/风险**
119+
- 可能导致数据源切换后 List 不再自动获得焦点,影响 ↑/↓ 导航体验
120+
- 改动面大(5 个 ListView),需要更充分回归测试
121+
122+
**验证**
123+
- 除 P1 验证项外,还需要验证:
124+
- 切换数据源后,↑/↓ 能直接在 List 中工作
125+
- 菜单命令/快捷键相关交互不受影响
126+
127+
## 落地顺序建议
128+
- **先做 P1**(最小改动、最快验证)
129+
- 若仍有遗漏场景(例如某些 Detail 控件点击后仍不变灰),再考虑 **P2** 细化点击判定
130+
- 若确认 `.focused($isListFocused)` 是长期副作用源头,再评估 **P3**(破坏性较强)
131+
132+

SyncNos/Views/Components/Main/MainListView+FocusManager.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,24 @@ extension MainListView {
1818
return event
1919
}
2020

21+
// 记录点击位置(窗口坐标系:原点在左下)
22+
let clickLocationInWindow = event.locationInWindow
23+
2124
// 延迟检查焦点,因为点击后焦点可能还没有切换
2225
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
26+
// 先按当前 firstResponder 同步状态
2327
self.syncNavigationTargetWithFocus()
28+
29+
// 兜底:如果用户点击了 Detail 区域,但 firstResponder 仍停留在 List,
30+
// 则强制让 Detail 的 NSScrollView 成为 firstResponder,
31+
// 这样 List 选中高亮会立即变为非激活(灰色)。
32+
guard self.keyboardNavigationTarget == .list else { return }
33+
guard self.isPointInsideCurrentDetailScrollView(clickLocationInWindow, window: window) else { return }
34+
35+
// 保存进入 Detail 前的 firstResponder,用于 ← 返回时恢复
36+
self.savedMasterFirstResponder = window.firstResponder
37+
self.keyboardNavigationTarget = .detail
38+
self.focusDetailScrollViewIfPossible(window: window)
2439
}
2540

2641
return event
@@ -96,5 +111,30 @@ extension MainListView {
96111
return Notification.Name("DataSourceSwitchedToChats")
97112
}
98113
}
114+
115+
// MARK: - Hit Testing Helpers
116+
117+
/// 判断一次点击是否发生在当前 Detail 的 NSScrollView 区域内(窗口坐标系)
118+
private func isPointInsideCurrentDetailScrollView(_ locationInWindow: NSPoint, window: NSWindow) -> Bool {
119+
guard let scrollView = currentDetailScrollView else { return false }
120+
// 只在 scrollView 仍挂载在当前窗口时才判断,避免使用过期引用
121+
guard scrollView.window === window else { return false }
122+
guard let contentView = window.contentView else { return false }
123+
124+
// 命中测试:确保点击确实落在 Detail 的 ScrollView 视图树内,
125+
// 避免仅靠 rect 判断在极端情况下误判(导致“点 List 也自动切到 Detail”)
126+
let pointInContentView = contentView.convert(locationInWindow, from: nil)
127+
guard let hitView = contentView.hitTest(pointInContentView) else { return false }
128+
129+
var view: NSView? = hitView
130+
while let v = view {
131+
if v === scrollView || v === scrollView.contentView || v === scrollView.documentView {
132+
return true
133+
}
134+
view = v.superview
135+
}
136+
137+
return false
138+
}
99139
}
100140

0 commit comments

Comments
 (0)