To move a group and its child items together, store the children inside the parent model and reorder the parent collection. This guide builds a small SwiftUI Mac list where each group moves as one row, with its children still attached.
The task is rearranging groups within your own app: for example, moving Group A, containing Tile A1 and Tile A2, below Group B. The example uses synthetic local data and provides explicit Move Up and Move Down buttons alongside SwiftUI’s list move callback.
What should count as one movable item?
Make the unit of movement match the unit in your data. For this interface, the outer array contains groups, and each group contains its own child array. Reordering that outer array changes group order while retaining each group’s children and their existing order.
Represent each group as one composite list row: a heading, movement buttons, and indented child labels inside a VStack. Attach onMove(perform:) to the outer ForEach. Apple documents that the callback’s offsets refer to that dynamic view’s underlying collection; here, that collection is the group array. Apple’s move callback documentation.
Give groups and children stable identifiers. Apple’s ForEach requires identified elements, either through Identifiable or an explicit identifier key path. In this design, identifiers belong to the models and remain unchanged when their positions change. Avoid using array positions as the identity of reorderable content. Apple’s ForEach documentation.
A SwiftUI Group view is not the application model used below. Naming a container or displaying a heading does not establish which child records should move with it. The parent’s stored children array makes that relationship explicit.
Build a complete SwiftUI example
Create a macOS app project in Xcode with the SwiftUI interface and a deployment target of macOS 12 or later. Replace the generated app entry file with the complete program below, keeping only one @main app declaration in the target. The example deliberately uses a plain list and text buttons so the movement is easy to inspect.
import Foundation
import SwiftUI
private struct DemoItem: Identifiable {
let id: String
let title: String
}
private struct DemoGroup: Identifiable {
let id: String
let title: String
let children: [DemoItem]
}
@main
struct GroupMoveDemoApp: App {
var body: some Scene {
WindowGroup {
GroupCollectionView()
}
}
}
private struct GroupCollectionView: View {
@State private var groups: [DemoGroup] = [
DemoGroup(
id: "group-a", title: "Group A",
children: [
DemoItem(id: "a1", title: "Tile A1"),
DemoItem(id: "a2", title: "Tile A2")
]
),
DemoGroup(
id: "group-b", title: "Group B",
children: [
DemoItem(id: "b1", title: "Tile B1")
]
),
DemoGroup(
id: "group-c", title: "Group C",
children: []
)
]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Arrange Groups")
.font(.title2)
.padding(.horizontal)
List {
ForEach(groups) { group in
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(group.title).font(.headline)
Spacer()
Button("Move Up") {
moveOne(id: group.id, down: false)
}
.disabled(group.id == groups.first?.id)
Button("Move Down") {
moveOne(id: group.id, down: true)
}
.disabled(group.id == groups.last?.id)
}
.buttonStyle(BorderlessButtonStyle())
ForEach(group.children) { child in
Text(child.title)
.padding(.leading, 16)
}
}
.padding(.vertical, 8)
}
.onMove(perform: moveGroups)
}
}
.padding(.vertical)
.frame(minWidth: 520, minHeight: 320)
}
private func moveGroups(from source: IndexSet, to destination: Int) {
withAnimation {
groups.move(fromOffsets: source, toOffset: destination)
}
}
private func moveOne(id: String, down: Bool) {
guard let index = groups.firstIndex(where: { $0.id == id }) else {
return
}
let neighbor = down ? index + 1 : index - 1
guard groups.indices.contains(neighbor) else {
return
}
let destination = down ? index + 2 : index - 1
moveGroups(from: IndexSet(integer: index), to: destination)
}
}
The outer ForEach produces one VStack per group. Its nested ForEach displays the children within that row; there is no separate child movement handler. Both movement buttons find the group’s current position by ID and call the same mutation used by onMove.
Run the app and click Move Down beside Group A. The expected order becomes Group B, Group A, Group C, with Tile A1 and Tile A2 still displayed beneath Group A. Clicking Move Up beside that group restores the initial order. Group C also demonstrates that a group with no children remains movable.
This program keeps its order in memory for the current view’s lifetime; it contains no persistence layer. Its references and movement logic were inspected, but it was not compiled here because the available shell has no Swift compiler. Build and check the interface in Xcode on a Mac before integrating it.
Why does moving downward use a different destination?
The destination passed to move(fromOffsets:toOffset:) is an insertion boundary in the original collection. Apple defines it as the offset before which to insert the moved elements, within 0...count. Passing a single element’s own offset or the following offset produces no change. Apple’s collection movement documentation.
For the illustrative starting order [A, B, C], these independent operations show the distinction:
| Intended result | Source offset | Destination passed to move |
Result |
|---|---|---|---|
Move A below B |
0 |
2 |
[B, A, C] |
Move B above A |
1 |
0 |
[B, A, C] |
Move A to the end |
0 |
3 |
[B, C, A] |
Leave A in place |
0 |
1 |
[A, B, C] |
That is why the downward button uses index + 2: it inserts before the element after the next neighbor, or at the end. The upward button uses index - 1. For offsets supplied directly by onMove, pass them through to the collection method without applying this button-specific calculation again.
The example’s destination arithmetic applies to a single adjacent move. For a move callback containing multiple source offsets, use the complete IndexSet with the collection method, which documents preservation of the moved elements’ ordering. Apple’s collection movement documentation.
Which interface needs a different implementation?
Choose the implementation according to how the group appears and how users need to interact with its children. The composite-row example is a useful fit when a group should always travel as one block. It deliberately gives children labels within that block rather than independent list rows.
| Interface requirement | Suitable starting point | Movement responsibility |
|---|---|---|
| Group heading and children form one movable block | Outer ForEach with one composite row per group |
Reorder the parent array |
| Expandable hierarchy with nested descendants | OutlineGroup |
Implement model movement for the intended parent collection |
| AppKit collection with one section per group | NSCollectionView |
Update the data source, then notify the collection view of the section move |
| Custom editable text inside an item | TextKit within the item’s editor | Keep group reordering in the surrounding collection model |
Expandable trees need explicit movement rules
Apple describes OutlineGroup as a view of identified, tree-structured data with disclosure controls for expanding and collapsing branches. It is available on macOS 11 and later. Its documented presentation behavior does not establish a general subtree-reordering policy for your application. Apple’s OutlineGroup documentation.
For a tree, define the destination precisely: moving a parent among its siblings and moving it inside another parent are different operations. A sibling reorder changes one parent array. Reparenting requires removing the node from its old parent, inserting it into the new parent, and rejecting a destination inside that node’s own descendants. These are application-model rules derived from the tree structure.
If the task remains moving whole top-level groups, the composite-row implementation avoids that additional destination policy. If children need independent selection or reordering, design those interactions explicitly before flattening the hierarchy into visible rows.
AppKit supports moving a section with its contained items
When your interface is an NSCollectionView with each group represented by a section, Apple provides moveSection(_:toSection:). Its documentation explicitly covers reorganizing sections and their contained items and requires updating the data source before calling it. The API is available on macOS 10.11 and later. Apple’s section movement documentation.
For that path, resolve the source and final destination section indexes, reorder the group data, and notify the collection view using its section movement API. Keep the SwiftUI insertion-boundary calculation separate from the destination-section index expected by AppKit. The two methods describe their destinations differently.
To embed an AppKit collection in SwiftUI, use NSViewRepresentable, Apple’s documented wrapper for creating and updating an AppKit view. Forward collection interactions back to the SwiftUI model through the appropriate coordination mechanism, and let SwiftUI control the represented view’s outer layout. Apple’s NSViewRepresentable documentation.
TextKit handles text layout within an item
Apple defines NSTextLayoutManager as the manager for custom text layout and presentation, available on macOS 12 and later. Its responsibilities concern text containers, elements, and layout fragments. For this collection task, the architectural consequence is to keep group ordering in the collection model, even if a child contains a custom TextKit editor. Apple’s NSTextLayoutManager documentation.
How do you diagnose a group that moves incorrectly?
Start with the smallest visible example and inspect the model after one operation. The checks below distinguish an ordering error from a mismatch between the displayed rows and the underlying data.
- Only the heading moves: check whether children are stored as unrelated entries in a flat array. Move the parent object that owns them, or explicitly implement a block move for the flat representation.
- Moving down does nothing: check whether the button passes
index + 1. For a single source item, that boundary leaves the item in place. - The wrong group moves after filtering: check whether visible offsets are being applied to the full array. Resolve visible group IDs into the canonical model before mutation, and define where a filtered destination belongs.
- The order immediately reverts: check whether the displayed array is sorted again on every render. Manual ordering needs an order that the display actually retains.
- A child moves independently: check where the move handler is attached. In this example, it belongs only to the outer group collection.
Before integration, check upward and downward movement, both ends of the list, a single remaining group, and an empty group. Verify model invariants as well as appearance: the same group IDs remain present, each group retains the same child IDs, and child order remains unchanged. These checks directly test the requirement that the parent and its children travel together.
For a compact Mac interface, begin with one parent model per movable row and one shared reorder function. The user can then move Group A with a single action while its children stay attached. Add a hierarchical or AppKit implementation when the interface needs independent child interaction or section-based collection behavior.
Sources
- onMove(perform:) — Apple
- ForEach — Apple
- move(fromOffsets:toOffset:) — Apple
- OutlineGroup — Apple
- moveSection(_:toSection:) — Apple
- NSViewRepresentable — Apple
- NSTextLayoutManager — Apple