mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-31 06:33:44 +00:00
fix(route): decouple sidebar selection write-back from route publish
The same-value guard only stopped same-value selection re-syncs. After an external navigation (add menu, rule editor dismissal, manage-servers/show-runtime actions) the sidebar List still held its stale cached selection and wrote it back into the custom selection binding during a view update; the resulting publish warned and would bounce the navigation back. Route the sidebar selection through a SwiftUI-managed @State with onChange hops in both directions (selection -> navigate, route -> backfill), anchor the seed on WorkspaceRoute.initialDestination, and pin the alignment with a regression test.
This commit is contained in:
@@ -44,6 +44,13 @@ struct MainWindowView: View {
|
||||
let configurationGroupFileExporter: any ConfigurationGroupFileExporter
|
||||
|
||||
@StateObject private var homeServerList = HomeServerListState()
|
||||
/// 侧栏选中项中转:macOS 的 List/NavigationSplitView 会在视图更新期间回写
|
||||
/// selection(选择再同步,且可能携带缓存的旧值),回写必须落在 SwiftUI 自管
|
||||
/// 的 @State 上,再经 onChange 单向驱动 route;直接 publish 到 route 会触发
|
||||
/// view-update 期间发布警告,且缓存的旧值会把外部导航弹回。route 的外部
|
||||
/// 导航(添加菜单、规则编辑器返回等)经 sidebar 上的 onChange 回填选中项。
|
||||
/// 初值须与 WorkspaceRoute 的初始 destination 对齐,否则首帧侧栏无高亮行。
|
||||
@State private var sidebarSelection: WorkspaceDestination? = WorkspaceRoute.initialDestination
|
||||
@State private var selection: NodeID?
|
||||
@State private var ruleDraft: CustomRuleDraft?
|
||||
/// 全局添加菜单打开的表单,以及诊断导出由窗口壳持有。
|
||||
@@ -271,7 +278,7 @@ extension MainWindowView {
|
||||
// MARK: - 侧栏
|
||||
|
||||
private var sidebar: some View {
|
||||
List(selection: navigationBinding) {
|
||||
List(selection: $sidebarSelection) {
|
||||
Section {
|
||||
ForEach(WorkspaceDestination.allCases) { destination in
|
||||
sidebarRow(destination)
|
||||
@@ -280,6 +287,17 @@ extension MainWindowView {
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
// 点击行 → @State 选中项 → route;nil 回写无对应 destination,忽略。
|
||||
.onChange(of: sidebarSelection) { _, destination in
|
||||
guard let destination else { return }
|
||||
route.navigate(to: destination)
|
||||
}
|
||||
// route 的外部导航 → 回填选中项;等值不写,避免导航回声。
|
||||
.onChange(of: route.destination) { _, destination in
|
||||
if sidebarSelection != destination {
|
||||
sidebarSelection = destination
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .top, spacing: 4) { identityHeader }
|
||||
.safeAreaInset(edge: .bottom, spacing: 4) { statusCard }
|
||||
}
|
||||
@@ -319,15 +337,6 @@ extension MainWindowView {
|
||||
}
|
||||
}
|
||||
|
||||
private var navigationBinding: Binding<WorkspaceDestination?> {
|
||||
Binding(
|
||||
get: { route.destination },
|
||||
set: { destination in
|
||||
guard let destination else { return }
|
||||
route.navigate(to: destination)
|
||||
})
|
||||
}
|
||||
|
||||
/// 行尾数量角标:服务器 = 目录树内服务器叶子数;订阅 = 订阅数。
|
||||
private func badgeCount(for destination: WorkspaceDestination) -> Int? {
|
||||
switch destination {
|
||||
|
||||
@@ -25,12 +25,17 @@ final class WorkspaceRoute: ObservableObject {
|
||||
/// scene id 散落(架构测试锚定)。
|
||||
static let workspaceSceneID = "workspace"
|
||||
|
||||
@Published private(set) var destination: WorkspaceDestination = .home
|
||||
/// 实例初始 destination。MainWindowView 侧栏 @State 选中项的初值须与此
|
||||
/// 对齐,否则首帧侧栏无高亮行。
|
||||
static let initialDestination: WorkspaceDestination = .home
|
||||
|
||||
@Published private(set) var destination: WorkspaceDestination = initialDestination
|
||||
|
||||
/// workspace 内部导航只改变 route destination;开窗不由 route 驱动。
|
||||
/// 同值导航必须是无操作:macOS 的 List/NavigationSplitView 会在视图更新
|
||||
/// 期间回写 selection Binding(选择再同步),此时 publish 会触发
|
||||
/// "Publishing changes from within view updates" 运行时警告。
|
||||
/// 同值导航必须是无操作:外部导航经 onChange 回填侧栏选中项后会再触发
|
||||
/// 一次 selection onChange(导航回声),由同值短路变成无操作;publish
|
||||
/// 一旦发生在视图更新期间(List/NavigationSplitView 的选择再同步回写)
|
||||
/// 会触发 "Publishing changes from within view updates" 运行时警告。
|
||||
func navigate(to destination: WorkspaceDestination) {
|
||||
guard destination != self.destination else { return }
|
||||
self.destination = destination
|
||||
|
||||
@@ -10,6 +10,12 @@ final class WorkspaceRouteTests: XCTestCase {
|
||||
XCTAssertEqual(WorkspaceRoute().destination, .home)
|
||||
}
|
||||
|
||||
/// 侧栏 @State 选中项的初值锚定在 initialDestination 上(见 MainWindowView);
|
||||
/// 两者脱钩会导致首帧侧栏无高亮行。
|
||||
func testInitialDestinationConstantMatchesFreshRoute() {
|
||||
XCTAssertEqual(WorkspaceRoute.initialDestination, WorkspaceRoute().destination)
|
||||
}
|
||||
|
||||
/// 侧栏 List/NavigationSplitView 会在视图更新期间回写 selection Binding;
|
||||
/// 同值导航不得 publish(否则触发 view-update 期间发布警告)。
|
||||
func testNavigateToCurrentDestinationDoesNotPublish() {
|
||||
|
||||
Reference in New Issue
Block a user