reorderContainer(for:in:) to a List, the list stops giving nested DisclosureGroup rows their own disclosure control, so only the outermost chevron survives. The fix is a custom DisclosureGroupStyle that returns the label row and the expanded content as two separate views, with leading padding for indentation.Why this happens
A recursive DisclosureGroup inside a List renders fine on its own. Add .reorderContainer(for:in:) and the nested chevrons disappear — only the root one is left. The recursion is not the problem: logging inside the row’s body shows the child views are still being evaluated, and starting with every group expanded changes nothing.
The giveaway is the accessibility hierarchy. Swap the List for a ScrollView + LazyVStack, apply the reorder container to the stack, and the nested disclosure controls show up. Keep the List and only the root control is exposed. So the reordering container changes how List builds and identifies its rows, and the built-in disclosure style loses its per-row control in the process.
In practice this means the native chevron is not something you can recover by moving the modifiers around. You either replace the style or replace the container.
How to fix it
1. Confirm the container is the trigger
Before changing any code, do the two-minute check so you are not chasing your own view hierarchy:
- Comment out
.reorderContainer(for:in:). The nested indicators should come back. - Put it back, then temporarily replace
List { … }withScrollView { LazyVStack { … } }and move the reorder container onto the stack. - Inspect the accessibility hierarchy in both configurations. If the nested controls appear only in the stack version, you are looking at the same
Listbehaviour described here.
2. Write a custom DisclosureGroupStyle that emits two rows
This is the workaround that keeps the recursive structure and the reordering container intact. Two rules matter:
- Return the label row and the expanded content as sibling views, not wrapped in a
VStackor any other container. AListturns each direct child into its own row; wrapping them collapses the expanded content into the label row and breaks both layout and reordering. - Indent the expanded content with leading padding instead of relying on the built-in style.
struct OutlineDisclosureStyle: DisclosureGroupStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(spacing: 6) {
Button {
withAnimation {
configuration.isExpanded.toggle()
}
} label: {
Image(systemName: "chevron.right")
.imageScale(.small)
.rotationEffect(.degrees(configuration.isExpanded ? 90 : 0))
.foregroundStyle(.secondary)
.contentShape(.rect)
}
.buttonStyle(.plain)
configuration.label
}
if configuration.isExpanded {
configuration.content
.padding(.leading, 20)
}
}
}
Add a convenience accessor so the call site stays readable:
extension DisclosureGroupStyle where Self == OutlineDisclosureStyle {
static var outline: Self { Self() }
}
3. Apply the style in the recursive row
Nothing else in NodeRow has to change — the ForEach still carries .reorderable(collectionID:):
struct NodeRow: View {
let node: Node
@State private var isExpanded = true
var body: some View {
if let children = node.children {
DisclosureGroup(isExpanded: $isExpanded) {
ForEach(children) { child in
NodeRow(node: child)
}
.reorderable(collectionID: node.id)
} label: {
Text(node.title)
}
.disclosureGroupStyle(.outline)
} else {
Text(node.title)
}
}
}
Every level of the recursion now draws its own chevron, and because the label and content are separate rows, the reorder container still sees a flat list of draggable rows.
4. Give leaf rows an identity before you drag
With the styling fixed you can finally test dragging — and dragging an inner row can crash with an error saying the identifiers are incompatible. The rows being dragged are the leaf Text views, which currently have no explicit identity that matches the collection they belong to. Attaching the node’s id to the leaf is a cheap mitigation:
} else {
Text(node.title)
.id(node.id)
}
This stops the crash in the reported reproduction, but treat it as a mitigation rather than a documented fix. The reordering container is matching ids across nested collections, and the mismatch originates in the framework, not in your model. If the crash persists, isolate the reordering behaviour in a flat two-level list first and confirm the ids you pass to reorderable(collectionID:) are the ids of the parent whose children you are moving.
5. If you need the native chevron, drop the List
When the built-in disclosure control is a hard requirement, the ScrollView + LazyVStack configuration is the one that keeps it. You lose List styling, row insets and selection behaviour, and you have to draw your own separators and hover states on macOS. For a file-browser-style outline that is often an acceptable trade; for a settings-like list it usually is not.
How to prevent it
- Keep the label and the expanded content as sibling views in any custom
DisclosureGroupStyle— never wrap them in a stack. - Test reordering with a nested hierarchy early, not with a flat sample. Flat and sectioned collections are the documented cases; recursive ones are where the rough edges appear.
- Check the accessibility hierarchy when a control “disappears”. A missing chevron in the UI and a missing control in the hierarchy tell you very different things.
- Give every row an explicit, stable identity before enabling drag and drop, including leaf rows.
- Keep the reordering container on the
List(or stack) and.reorderable(collectionID:)on theForEach, not on individual rows.
FAQ
Is a recursive DisclosureGroup inside a reorderable List supported?
It compiles and renders, and the child views are still built, so the structure itself is not rejected. What breaks is the built-in disclosure styling once a reorder container is attached. Apple’s reordering documentation only covers flat and sectioned collections, so nested disclosure groups are effectively untested territory.
Why must the custom style avoid a VStack?
A List converts each direct child of the style’s body into a row. Wrapping the label and the content in a VStack makes them one row, which hides the expanded content from the reordering machinery and from the list’s own layout. Returning them as siblings keeps the row-per-view mapping intact.
Does adding .id(node.id) to leaf rows really fix reordering?
It removes the identifier-mismatch crash in the reproduction, but it is not a documented contract. The underlying id comparison happens inside the reordering container, so if you still see failures, reduce the hierarchy to two levels and verify the ids passed to reorderable(collectionID:) match the parent collection you are moving items within.
Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.
