Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
fix(android): Always remove FirstDrawDoneListener after the first draw
The listener was removed from an OnGlobalLayoutListener, which only runs if
a layout pass follows the draw. On a static screen it stayed registered and
ran on every frame, retaining its callback. Post the removal to the main
thread instead so it always happens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
  • Loading branch information
runningcode and claude committed Sep 23, 2026
commit dc98df7681868cd90191386069fe22454ca418d6
Original file line number Diff line number Diff line change
Expand Up @@ -109,17 +109,15 @@ public void onDraw() {
if (view == null) {
return;
}
// OnDrawListeners cannot be removed within onDraw, so we remove it with a
// GlobalLayoutListener
view.getViewTreeObserver()
.addOnGlobalLayoutListener(
new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
view.getViewTreeObserver().removeOnGlobalLayoutListener(this);
view.getViewTreeObserver().removeOnDrawListener(FirstDrawDoneListener.this);
}
});
// OnDrawListeners cannot be removed within onDraw. Posting the removal instead of waiting for
// the next global layout guarantees it happens, even if no layout pass follows the draw.
mainThreadHandler.post(
() -> {
final ViewTreeObserver observer = view.getViewTreeObserver();
if (observer.isAlive()) {
observer.removeOnDrawListener(this);
}
});
mainThreadHandler.postAtFrontOfQueue(callback);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,49 +113,14 @@ class FirstDrawDoneListenerTest {
}

@Test
fun `registerForNextDraw unregister itself after onDraw`() {
fun `registerForNextDraw unregisters itself after onDraw without a layout pass`() {
val view = fixture.getSut()
FirstDrawDoneListener.registerForNextDraw(view, {}, fixture.buildInfo)
assertFalse(fixture.onDrawListeners.isEmpty())

// Does not remove OnDrawListener before onDraw, even if OnGlobalLayout is triggered
view.viewTreeObserver.dispatchOnGlobalLayout()
assertFalse(fixture.onDrawListeners.isEmpty())

// Removes OnDrawListener in the next OnGlobalLayout after onDraw
view.viewTreeObserver.dispatchOnDraw()
view.viewTreeObserver.dispatchOnGlobalLayout()
assertTrue(fixture.onDrawListeners.isEmpty())
}

@Test
fun `OnGlobalLayoutListener is removed after cleanup`() {
val view = fixture.getSut()

// Initialize mOnGlobalLayoutListeners via a dummy add/remove
val dummyGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {}
view.viewTreeObserver.addOnGlobalLayoutListener(dummyGlobalListener)
view.viewTreeObserver.removeOnGlobalLayoutListener(dummyGlobalListener)

// CopyOnWriteArray wraps an internal ArrayList called mData
val copyOnWriteArray: Any = view.viewTreeObserver.getProperty("mOnGlobalLayoutListeners")
val mDataField = copyOnWriteArray.javaClass.getDeclaredField("mData")
mDataField.isAccessible = true

@Suppress("UNCHECKED_CAST")
fun globalLayoutListeners(): ArrayList<*> = mDataField.get(copyOnWriteArray) as ArrayList<*>

assertTrue(globalLayoutListeners().isEmpty())

FirstDrawDoneListener.registerForNextDraw(view, {}, fixture.buildInfo)

// onDraw registers a cleanup OnGlobalLayoutListener
view.viewTreeObserver.dispatchOnDraw()
assertFalse(globalLayoutListeners().isEmpty())
Shadows.shadowOf(Looper.getMainLooper()).idle()

// onGlobalLayout fires the cleanup, which removes both the draw and layout listeners
view.viewTreeObserver.dispatchOnGlobalLayout()
assertTrue(globalLayoutListeners().isEmpty())
assertTrue(fixture.onDrawListeners.isEmpty())
}

Expand Down
Loading