Skip to content

Commit 3d58c9a

Browse files
authored
fix: Prevent drag updates from reaching removed components (#3901)
# Description Fixes a crash caused by `MultiDragDispatcher` continuing to send drag updates to components that were removed during an active drag. Removed drag targets are now cancelled and cleaned up instead of receiving further updates. This also keeps drag state consistent for `DragCallbacks`. Added regression tests for: - joystick removal during active drag - dragged component state cleanup after removal ## Checklist - [x] I have followed the [Contributor Guide] when preparing my PR. - [x] I have updated/added tests for ALL new/updated/fixed functionality. - [x] I have updated/added relevant documentation in `docs` and added dartdoc comments with `///`. - [ ] I have updated/added relevant examples in `examples` or `docs`. ## Breaking Change? - [ ] Yes, this PR is a breaking change. - [x] No, this PR is not a breaking change. ## Related Issues Fixes #3897
1 parent 8ee838c commit 3d58c9a

3 files changed

Lines changed: 104 additions & 4 deletions

File tree

packages/flame/lib/src/events/flame_game_mixins/multi_drag_dispatcher.dart

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,21 +84,42 @@ class MultiDragDispatcher extends Component implements MultiDragListener {
8484
@mustCallSuper
8585
void onDragUpdate(DragUpdateEvent event) {
8686
final updated = <TaggedComponent<DragCallbacks>>{};
87+
// Defer cleanup so stale targets can be cancelled after iteration.
88+
final stale = <TaggedComponent<DragCallbacks>>{};
8789
event.deliverAtPoint(
8890
rootComponent: game,
8991
deliverToAll: true,
9092
eventHandler: (DragCallbacks component) {
9193
final record = TaggedComponent(event.pointerId, component);
9294
if (_records.contains(record)) {
93-
component.onDragUpdate(event);
94-
updated.add(record);
95+
if (!component.isMounted || component.isRemoving) {
96+
stale.add(record);
97+
} else {
98+
component.onDragUpdate(event);
99+
updated.add(record);
100+
}
95101
}
96102
},
97103
);
98104
for (final record in _records) {
99-
if (record.pointerId == event.pointerId && !updated.contains(record)) {
100-
record.component.onDragUpdate(event);
105+
if (record.pointerId != event.pointerId) {
106+
continue;
101107
}
108+
final component = record.component;
109+
if (!component.isMounted || component.isRemoving) {
110+
stale.add(record);
111+
continue;
112+
}
113+
if (!updated.contains(record)) {
114+
component.onDragUpdate(event);
115+
}
116+
}
117+
if (stale.isNotEmpty) {
118+
final cancelEvent = DragCancelEvent(event.pointerId);
119+
for (final record in stale) {
120+
record.component.onDragCancel(cancelEvent);
121+
}
122+
_records.removeAll(stale);
102123
}
103124
}
104125

packages/flame/test/components/joystick_component_test.dart

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,5 +119,43 @@ void main() {
119119
expect(joystick.knob!.position, closeToVector(Vector2(20, 10)));
120120
},
121121
);
122+
123+
testWithFlameGame(
124+
'does not throw when joystick is removed '
125+
'during an active drag and receives update',
126+
(game) async {
127+
final joystick = JoystickComponent(
128+
knob: CircleComponent(radius: 5.0),
129+
size: 20,
130+
margin: const EdgeInsets.only(left: 20, top: 20),
131+
);
132+
await game.add(joystick);
133+
await game.ready();
134+
final dragDispatcher = game.firstChild<MultiDragDispatcher>()!;
135+
136+
dragDispatcher.handleDragStart(
137+
1,
138+
DragStartDetails(
139+
localPosition: const Offset(20, 20),
140+
globalPosition: const Offset(20, 20),
141+
),
142+
);
143+
144+
game.remove(joystick);
145+
await game.ready();
146+
147+
expect(
148+
() => dragDispatcher.handleDragUpdate(
149+
1,
150+
DragUpdateDetails(
151+
localPosition: const Offset(21, 20),
152+
globalPosition: const Offset(21, 20),
153+
delta: const Offset(1, 0),
154+
),
155+
),
156+
returnsNormally,
157+
);
158+
},
159+
);
122160
});
123161
}

packages/flame/test/events/component_mixins/drag_callbacks_test.dart

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,47 @@ void main() {
7171
expect(component.dragEndEvent, equals(1));
7272
});
7373

74+
testWithFlameGame(
75+
'removed dragged component receives cancel on update and clears state',
76+
(game) async {
77+
final component = _DragCallbacksComponent()
78+
..x = 10
79+
..y = 10
80+
..width = 10
81+
..height = 10;
82+
await game.ensureAdd(component);
83+
final dispatcher = game.firstChild<MultiDragDispatcher>()!;
84+
85+
dispatcher.onDragStart(
86+
createDragStartEvents(
87+
game: game,
88+
localPosition: const Offset(12, 12),
89+
globalPosition: const Offset(12, 12),
90+
),
91+
);
92+
expect(component.isDragged, isTrue);
93+
94+
game.remove(component);
95+
await game.ready();
96+
97+
dispatcher.onDragUpdate(
98+
createDragUpdateEvents(
99+
game: game,
100+
localPosition: const Offset(15, 15),
101+
globalPosition: const Offset(15, 15),
102+
),
103+
);
104+
105+
expect(component.dragCancelEvent, equals(1));
106+
expect(component.dragEndEvent, equals(1));
107+
expect(component.isDragged, isFalse);
108+
109+
dispatcher.onDragEnd(DragEndEvent(1, DragEndDetails()));
110+
expect(component.dragCancelEvent, equals(1));
111+
expect(component.dragEndEvent, equals(1));
112+
},
113+
);
114+
74115
testWithFlameGame(
75116
'drag event update not called without onDragStart',
76117
(game) async {

0 commit comments

Comments
 (0)