An Xcode Source Editor Extension for the command Xcode never shipped: Unembed — remove the container view around the cursor and lift its children out one level. The inverse of Embed in VStack.
// Cursor anywhere inside — Editor ▸ SwiftUI Unembed ▸ Unembed Container
VStack(spacing: 16) {
Text("one")
Text("two")
}becomes
Text("one")
Text("two")| Path | Purpose |
|---|---|
Sources/UnembedCore/ |
All of the detection and rewriting logic. Plain Foundation, no XcodeKit |
Tests/UnembedCoreTests/ |
Swift Testing suite, runs under swift test without opening Xcode |
UnembedExtension/ |
The Source Editor Extension target. Only bridges XcodeKit and converts coordinates |
SwiftUIUnembed/ |
Host app for the extension; its window is the setup instructions |
Package.swift |
Lets the core be iterated with swift test; the Xcode target compiles the same files |
Signing.xcconfig |
The one file you have to edit — your Apple Developer Team ID |
The core deliberately avoids SwiftSyntax. The extension is an appex that Xcode loads, and carrying a copy of SwiftSyntax would noticeably slow that down. SwiftLexer only works out which characters are code and how the brackets nest, which is enough to find a trailing closure, and it correctly skips comments, string literals, #"raw"# strings, multiline strings, and \(interpolation).
There is no downloadable build. An Xcode Source Editor Extension has to embed XcodeKit.framework inside the appex, and dyld requires the loading process and the nested library to carry the same Team ID — ad-hoc signing does not work (the actual error is below). Shipping a prebuilt binary would need a Developer ID certificate and Apple notarization, which I don't have. So build it yourself; it takes a minute.
- macOS with Xcode installed (developed against Xcode 27; older versions likely work but are untested)
- An Apple Developer Team ID. A free Apple ID is enough — signing in under Xcode ▸ Settings ▸ Accounts creates a personal team and an Apple Development certificate. No paid membership needed.
Put your own Team ID in Signing.xcconfig:
DEVELOPMENT_TEAM = YOUR_TEAM_ID
To find it:
security find-certificate -c "Apple Development" -p | openssl x509 -noout -subjectThe OU field in the output is your Team ID. Then:
xcodebuild -project SwiftUIUnembed.xcodeproj -scheme SwiftUIUnembed -configuration Release build- Move the resulting
SwiftUIUnembed.appto/Applications— or anywhere permanent, since macOS only registers an app it can find again. - Launch the app once. Don't skip this. macOS only vends an extension once its containing app has been launched, and you have to repeat it after every reinstall because replacing the bundle mints a new plug-in UUID.
- System Settings ▸ General ▸ Login Items & Extensions ▸ Xcode Source Editor, and switch SwiftUI Unembed on.
- Restart Xcode — ⌘Q, not just closing the window. The command lands under Editor ▸ SwiftUI Unembed ▸ Unembed Container, at the very bottom of the Editor menu, so you have to scroll. Pressing
⌘⇧/and typingUnembedis quicker. - For a keyboard shortcut, search
Unembedin Xcode ▸ Settings ▸ Key Bindings.
Signing uses CODE_SIGN_IDENTITY = "Apple Development", with the Team ID read from Signing.xcconfig.
Do not switch to ad-hoc (CODE_SIGN_IDENTITY = "-"). Tested: the build succeeds and codesign --deep --strict passes, but the extension dies in dyld the moment it launches:
Library not loaded: @rpath/XcodeKit.framework/Versions/A/XcodeKit
Reason: ... not valid for use in process:
mapping process and mapped file (non-platform) have different Team IDs
dyld requires the loading process and the nested library to have matching Team IDs. Under ad-hoc both are TeamIdentifier=not set, and dyld does not treat "neither has one" as a match. Since XcodeKit must be embedded (see troubleshooting below), the signature must carry a real Team ID. The two are coupled.
xcodebuild -project SwiftUIUnembed.xcodeproj -scheme SwiftUIUnembed -configuration Release -derivedDataPath ./build build \
&& rm -rf /Applications/SwiftUIUnembed.app \
&& cp -R ./build/Build/Products/Release/SwiftUIUnembed.app /Applications/ \
&& rm -rf ./build \
&& open -a /Applications/SwiftUIUnembed.appThat trailing open is step 2 above — don't drop it. And rm -rf ./build isn't fastidiousness: xcodebuild runs RegisterWithLaunchServices on every build, registering the app at that path. Build to a few different locations (the default DerivedData, ./build, a temp dir) and the Xcode Source Editor list in System Settings sprouts several identically named SwiftUIUnembed rows. Nothing breaks — pluginkit only honours one — but the list gets ugly.
If it does happen, list what is registered:
/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -dump | grep -oE "^\s*path:.*SwiftUIUnembed\.app.*"Then run lsregister -u <path> once per unwanted path, and close and reopen the System Settings pane.
It's enabled in System Settings, Xcode has been restarted, and the bottom of the Editor menu still has no SwiftUI Unembed.
Xcode checks whether the appex incorporates XcodeKit.framework and silently skips it if not — no process launched, nothing logged, no menu item created. Searching the system log turns up nothing, because Xcode never touched it.
Apple's own template defaults XcodeKit to Do Not Embed, so a project built from the template walks straight into this. This project's extension target has an Embed Frameworks phase (dstSubfolderSpec = 10) that copies XcodeKit into UnembedExtension.appex/Contents/Frameworks and re-signs it with CodeSignOnCopy.
To check:
ls /Applications/SwiftUIUnembed.app/Contents/PlugIns/UnembedExtension.appex/Contents/Frameworks/XcodeKit.framework has to be there.
CodeSignOnCopy isn't optional. Xcode always strips Headers/ and Modules/ when embedding a framework, which invalidates Apple's original signature — codesign --deep --strict then reports a wall of file missing. Only after re-signing does it come back valid on disk:
codesign -vvv --deep --strict /Applications/SwiftUIUnembed.app/Applications/SwiftUIUnembed.app/Contents/PlugIns/UnembedExtension.appex/Contents/MacOS/UnembedExtensionA healthy bundle prints only An XPC Service cannot be run directly. — meaning dyld resolved everything and _XCExtensionMain ran. If you get Library not loaded: @rpath/XcodeKit.framework/..., XcodeKit isn't in the bundle (see above) and @loader_path/../Frameworks can't find it.
Crash reports live here:
ls -lt ~/Library/Logs/DiagnosticReports/ | grep -i unembedBefore reading one, check whether responsibleProc is actually Xcode. Running that binary yourself from a terminal also leaves a crash report, which is easy to misread as "Xcode tried to load it and failed":
python3 -c "
import json; raw=open('path/to/report.ips').read().split(chr(10),1)
b=json.loads(raw[1]); print(b.get('responsibleProc'), b.get('procLaunch'))
"pluginkit -mAvv -p com.apple.dt.Xcode.extension.source-editorA leading + means the user elected to use the plug-in (- means ignore). Path should point at the /Applications copy. UUID changes after every reinstall — if it hasn't, you skipped launching the app in step 2.
Put the cursor anywhere inside a container; no selection needed. If the selection happens to cover a whole container including both braces, that container is removed; otherwise it's the nearest one enclosing the cursor.
Removed: capitalised callees whose trailing closure takes no parameters — VStack, HStack, ZStack, Group, ScrollView, List, Form, NavigationStack, LazyVGrid, GroupBox, and your own MyCard { … }. See ContainerFinder.knownContainers.
Not removed (the search keeps walking outwards):
| Case | Reason |
|---|---|
if / switch / for / do blocks |
Not view containers |
struct / func / var body: some View |
Declarations, not expressions |
#Preview { } |
A macro, not a container |
ForEach(items) { item in … } |
The closure binds a parameter; removing it would leave item dangling |
Button("x") { action } |
The trailing closure is an action, not content — see deniedCallees |
Section { … } header: { … } |
Multiple trailing closures; removing only the first orphans header: |
A discarded parameter, as in GeometryReader { _ in … }, is safe and does get unembedded.
With a single child, the modifiers move onto that child:
VStack { Text("only") }.padding() → Text("only")
.padding()With several children there is nothing for them to attach to. Rather than dropping them silently, they are preserved as comments for you to resolve:
// unembed: VStack modifiers could not be reattached to 2 children:
// .padding()
Text("one")
Text("two")Indentation is rebuilt from the buffer's own indentationWidth / usesTabsForIndentation, and relative indentation and blank lines between children are preserved. The promoted content stays selected afterwards, so pressing the command again peels off the next container out.
swift testLogic changes only touch Sources/UnembedCore/, and swift test runs all 28 tests in milliseconds — no Xcode restart needed to verify anything. UnembedExtension/UnembedCommand.swift only converts between XCSourceTextPosition (zero-based lines, UTF-16 columns) and character offsets; it holds no logic of its own.
MIT, see LICENSE.
Xcode Source Editor Extension,提供 Xcode 沒有的 Unembed:把游標所在的容器 view 拿掉,並把它的子 view 往外提一層。也就是 Embed in VStack 的反向操作。
// 游標在任何一行 —— Editor ▸ SwiftUI Unembed ▸ Unembed Container
VStack(spacing: 16) {
Text("one")
Text("two")
}變成
Text("one")
Text("two")| 路徑 | 用途 |
|---|---|
Sources/UnembedCore/ |
全部的判斷與改寫邏輯,純 Foundation,不依賴 XcodeKit |
Tests/UnembedCoreTests/ |
Swift Testing 測試,swift test 可直接跑,不用開 Xcode |
UnembedExtension/ |
Source Editor Extension target,只負責接 XcodeKit 與座標換算 |
SwiftUIUnembed/ |
承載 extension 的 host app,畫面就是啟用步驟說明 |
Package.swift |
讓核心邏輯能用 swift test 迭代;Xcode target 直接編譯同一批檔案 |
Signing.xcconfig |
唯一需要你修改的檔案:填入自己的 Apple Developer Team ID |
核心刻意不用 SwiftSyntax:extension 是被 Xcode 載入的 appex,多帶一份 SwiftSyntax 會明顯拖慢載入。SwiftLexer 只掃出「哪些字元是程式碼」和「括號怎麼配對」,這對找 trailing closure 已經夠用,而且能正確跳過註解、字串、#"raw"#、多行字串與 \(插值)。
沒有提供可下載的安裝檔。Xcode Source Editor Extension 必須把 XcodeKit.framework 嵌進 appex,而 dyld 要求載入方與被載入的巢狀 library 具有相同的 Team ID —— ad-hoc 簽章行不通(下方有實測的錯誤訊息)。要散佈預先建置的版本就得用 Developer ID 憑證簽章並送 Apple 公證,我沒有。所以請自己 build,只要一分鐘。
- macOS,安裝 Xcode(開發時使用 Xcode 27,較舊版本應該也可以,未測)
- 一組 Apple Developer Team ID。免費的 Apple ID 就夠:在 Xcode ▸ Settings ▸ Accounts 登入後會自動產生個人 team 與 Apple Development 憑證,不需要付費會員資格
先把 Signing.xcconfig 裡的 Team ID 改成你自己的:
DEVELOPMENT_TEAM = YOUR_TEAM_ID
找出你的 Team ID:
security find-certificate -c "Apple Development" -p | openssl x509 -noout -subject輸出裡的 OU 欄位就是。然後:
xcodebuild -project SwiftUIUnembed.xcodeproj -scheme SwiftUIUnembed -configuration Release build- 把 build 出來的
SwiftUIUnembed.app放到/Applications(或任何固定位置,macOS 只會註冊找得到的 app)。 - 開一次該 app。 這步不能省 —— macOS 要容器 app 被啟動過,才會把裡面的 extension 正式 vend 出去。每次覆蓋安裝後都要重做一次,因為 bundle 換掉會產生新的 plug-in UUID。
- 系統設定 ▸ 一般 ▸ 登入項目與延伸功能 ▸ Xcode Source Editor,勾選 SwiftUI Unembed。
- 重開 Xcode(⌘Q 完全退出,不是關視窗)。指令在 Editor ▸ SwiftUI Unembed ▸ Unembed Container,位置在 Editor 選單最底部,要往下捲。用
⌘⇧/搜尋Unembed最快。 - 想要快捷鍵的話,Xcode 設定 ▸ Key Bindings 搜尋
Unembed自行綁定。
簽章用 CODE_SIGN_IDENTITY = "Apple Development",Team ID 從 Signing.xcconfig 讀取。
不能改成 ad-hoc(CODE_SIGN_IDENTITY = "-")。 實測過:build 會成功,codesign --deep --strict 也會過,但 extension 一啟動就死在 dyld:
Library not loaded: @rpath/XcodeKit.framework/Versions/A/XcodeKit
Reason: ... not valid for use in process:
mapping process and mapped file (non-platform) have different Team IDs
dyld 要求載入方與被載入的巢狀 library 具有相同的 Team ID,而 ad-hoc 兩邊都是 TeamIdentifier=not set —— dyld 不把「兩邊都沒有」當成相符。既然 XcodeKit 一定得嵌進 bundle(見下方疑難排解),簽章就一定得帶真實 Team ID。這兩件事是綁在一起的。
xcodebuild -project SwiftUIUnembed.xcodeproj -scheme SwiftUIUnembed -configuration Release -derivedDataPath ./build build \
&& rm -rf /Applications/SwiftUIUnembed.app \
&& cp -R ./build/Build/Products/Release/SwiftUIUnembed.app /Applications/ \
&& rm -rf ./build \
&& open -a /Applications/SwiftUIUnembed.app最後那個 open 就是上面的第 2 步,別漏掉。而 rm -rf ./build 不是潔癖。xcodebuild 每次結束都會跑 RegisterWithLaunchServices,把該路徑的 app 註冊給 LaunchServices;build 到幾個不同位置(預設 DerivedData、./build、臨時目錄……),系統設定的 Xcode Source Editor 清單就會冒出幾個同名的 SwiftUIUnembed。功能不會壞(pluginkit 只會認一份),但清單很難看。
真的跑出多份時,先列出註冊了哪些路徑:
/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -dump | grep -oE "^\s*path:.*SwiftUIUnembed\.app.*"再對不要的路徑各跑一次 lsregister -u <路徑>,然後把系統設定面板關掉重開。
系統設定裡已啟用、Xcode 也重開了,但 Editor 選單最底部就是沒有 SwiftUI Unembed。
Xcode 會檢查 appex 有沒有把 XcodeKit.framework 包進 bundle,沒有就靜默跳過 —— 不啟動 process、不寫任何日誌、不建選單項目。查日誌會一無所獲,因為 Xcode 根本沒去碰它。
Apple 的官方範本預設把 XcodeKit 設成 Do Not Embed,所以照範本建的專案一開始就中這個坑。本專案的 extension target 有一個 Embed Frameworks 階段(dstSubfolderSpec = 10)把 XcodeKit 複製進 UnembedExtension.appex/Contents/Frameworks,並以 CodeSignOnCopy 重簽。
確認方式:
ls /Applications/SwiftUIUnembed.app/Contents/PlugIns/UnembedExtension.appex/Contents/Frameworks/必須看到 XcodeKit.framework。
CodeSignOnCopy 不能省。Xcode 嵌入 framework 時一定會剝除 Headers/ 與 Modules/,Apple 原本的簽章因此失效,codesign --deep --strict 會噴一整排 file missing。重簽之後才會是 valid on disk:
codesign -vvv --deep --strict /Applications/SwiftUIUnembed.app/Applications/SwiftUIUnembed.app/Contents/PlugIns/UnembedExtension.appex/Contents/MacOS/UnembedExtension正常只會印 An XPC Service cannot be run directly. —— 代表 dyld 全部解析成功、_XCExtensionMain 有跑起來。若印出 Library not loaded: @rpath/XcodeKit.framework/...,就是 XcodeKit 不在 bundle 裡(同上),@loader_path/../Frameworks 找不到它。
崩潰報告在這裡:
ls -lt ~/Library/Logs/DiagnosticReports/ | grep -i unembed看報告前先確認 responsibleProc 是不是 Xcode。自己在終端機直接執行那個 binary 也會留下崩潰報告,很容易誤判成「Xcode 嘗試載入失敗」:
python3 -c "
import json; raw=open('報告路徑.ips').read().split(chr(10),1)
b=json.loads(raw[1]); print(b.get('responsibleProc'), b.get('procLaunch'))
"pluginkit -mAvv -p com.apple.dt.Xcode.extension.source-editor開頭的 + 代表使用者已啟用(- 是忽略)。Path 要指向 /Applications 那份。覆蓋安裝後 UUID 會變,若沒變就是第 2 步的「開一次 app」漏了。
游標放在容器內任一處即可,不必選取。若選取範圍剛好完整涵蓋一個容器(含頭尾大括號),則拆掉「那個」容器;否則拆掉游標外層最近的一個。
會拆的:大寫開頭、trailing closure 沒有參數的呼叫 —— VStack、HStack、ZStack、Group、ScrollView、List、Form、NavigationStack、LazyVGrid、GroupBox 等,以及你自己寫的 MyCard { … }。清單見 ContainerFinder.knownContainers。
不會拆的(會自動往外層繼續找):
| 情況 | 原因 |
|---|---|
if / switch / for / do 區塊 |
不是 view 容器 |
struct / func / var body: some View |
是宣告,不是運算式 |
#Preview { } |
macro,不是容器 |
ForEach(items) { item in … } |
closure 有參數,拆掉後 item 會失去綁定 |
Button("x") { action } |
trailing closure 是動作不是內容,見 deniedCallees |
Section { … } header: { … } |
多個 trailing closure,只拆第一個會留下孤兒 header: |
GeometryReader { _ in … } 這種參數被丟棄的 closure 是安全的,會照拆。
只有一個子 view 時,modifier 直接接到那個子 view 上:
VStack { Text("only") }.padding() → Text("only")
.padding()有多個子 view 時,modifier 無處可掛。與其默默刪掉,會把它保留成註解讓你自己決定:
// unembed: VStack modifiers could not be reattached to 2 children:
// .padding()
Text("one")
Text("two")縮排會依照 buffer 的 indentationWidth / usesTabsForIndentation 設定重排,子 view 之間的相對縮排與空行都保留。拆完之後被提上來的內容會維持選取,方便連續按第二次往外再拆一層。
swift test改邏輯只要動 Sources/UnembedCore/,swift test 幾毫秒就跑完 28 個測試,不需要重啟 Xcode 驗證。UnembedExtension/UnembedCommand.swift 只做 XCSourceTextPosition(0-based 行號 + UTF-16 欄位)與字元位移之間的換算,不含判斷邏輯。
MIT,見 LICENSE。