PuzzlePieceView uses @State with a struct, creating a copy. Changes to currentPosition only affect the copy. Fix by changing @State to @Binding and passing bindings via ForEach($pieces).Why this happens
SwiftUI’s @State property wrapper creates a source of truth for a value type. When you pass a struct to a child view marked with @State, SwiftUI makes a copy of that struct for the child. Any mutations inside the child — such as updating piece.currentPosition — only modify the child’s private copy. The original array in the parent view (PuzzleView) remains unchanged.
In the puzzle game code, PuzzlePieceView declares @State var piece: PuzzlePiece. Because PuzzlePiece is a struct (a value type), the child view owns an independent instance. The drag gesture updates piece.currentPosition on that instance, but PuzzleView‘s pieces array still holds the original positions. Consequently, checkPuzzleCompletion() sees stale data and isCompleted never becomes true.
This is a classic SwiftUI pitfall: @State is designed for local view state, not for sharing mutable state up the view hierarchy. For shared mutable state, you need a binding.
How to fix it
1. Replace @State with @Binding in the child view
Change PuzzlePieceView to accept a binding instead of owning its own state:
struct PuzzlePieceView: View {
@Binding var piece: PuzzlePiece
let pieceSize: CGFloat
let puzzleAreaSize: CGSize
let onPositionChanged: () -> Void
@GestureState private var dragOffset = CGSize.zero
var body: some View {
Image(uiImage: piece.image.asUIImage())
.resizable()
.frame(width: pieceSize, height: pieceSize)
.position(
x: piece.currentPosition.x + dragOffset.width,
y: piece.currentPosition.y + dragOffset.height
)
.gesture(
DragGesture()
.updating($dragOffset) { value, state, _ in
state = value.translation
}
.onEnded { value in
let newX = piece.currentPosition.x + value.translation.width
let newY = piece.currentPosition.y + value.translation.height
// Snap to grid logic here...
piece.currentPosition = CGPoint(x: snappedX, y: snappedY)
onPositionChanged()
}
)
}
}
Key changes:
– @Binding var piece: PuzzlePiece — the view no longer owns the data; it holds a reference to the parent’s storage.
– Mutating piece.currentPosition now writes directly to the element inside PuzzleView‘s pieces array.
2. Pass bindings from the parent using ForEach($pieces)
Update puzzlePiecesView in PuzzleView to iterate over the projected value of the @State array:
private func puzzlePiecesView(geometry: GeometryProxy) -> some View {
ForEach($pieces) { $piece in
PuzzlePieceView(
piece: $piece,
pieceSize: pieceSize,
puzzleAreaSize: CGSize(
width: pieceSize * CGFloat(gridSize),
height: pieceSize * CGFloat(gridSize)
),
onPositionChanged: checkPuzzleCompletion
)
.offset(
x: geometry.size.width / 2 - pieceSize * CGFloat(gridSize) / 2,
y: geometry.size.height / 2 - pieceSize * CGFloat(gridSize) / 2
)
}
}
$pieces (the projected value of @State private var pieces) yields a Binding<PuzzlePiece> for each element. The ForEach syntax ForEach($pieces) { $piece in } (available since Swift 5.7 / iOS 16) binds each iteration variable directly, so you can pass $piece to the child.
After these two changes, dragging a piece updates the same PuzzlePiece instance that lives in the parent’s array. checkPuzzleCompletion() immediately sees the new currentPosition and can set isCompleted = true.
How to prevent it
- Default to
@Bindingfor mutable child state — if a child view needs to modify a value that the parent also reads, use@Binding. - Remember struct copy semantics — passing a struct to
@Statein a child always copies. The child’s@Stateis a new source of truth, not a window into the parent’s. - Use
ForEach($collection)for bindings — when iterating over an@Statearray and you need child mutations to persist, iterate the projected value ($pieces) and capture the binding ($piece). - Keep
@Stateprivate —@Stateshould only be used for view-local UI state (e.g.,isPresented,textFieldText). Shared model data belongs in@Observable/@StateObjectclasses or flows via bindings.
FAQ
Why does @State in the child create a copy instead of a reference?
@State allocates storage owned by the view instance. When you initialize PuzzlePieceView(piece: piece), the struct value is copied into that storage. The property wrapper does not create a reference to the original array element.
Can I use a class (@Observable) instead of a struct to avoid this?
Yes. If PuzzlePiece were an @Observable class, passing the same instance to multiple views would share mutations automatically. However, value types are preferred for simple data models; bindings give you reference-like behavior without reference semantics.
What if I’m on iOS 15 and can’t use ForEach($collection)?
Use pieces.indices and bind manually:
ForEach(pieces.indices, id: \.self) { index in
PuzzlePieceView(
piece: $pieces[index],
// ...
)
}
This achieves the same binding pass-through on older deployment targets.
Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.
