Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,6 @@ internal class ComposeLayersViewController(
hide()

transaction.actions.fastForEach { it.invoke() }

// If the layers list is empty, the draw call will clear the canvas.
metalView.redrawer.draw(waitUntilCompletion = false)
} else {
// It wasn't the last layer, pending transactions should be added to the list
removedLayersTransactions.add(transaction)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ internal class ComposeContainerView(
onDidMoveToWindow(window)

updateRedrawerState()
setNeedsSynchronousDraw()

// To avoid a situation where a user decided to call [layoutIfNeeded] on the detached view
// using a certain frame and it will be attached to the window later, so there is a chance
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ internal class MetalRedrawer(

private val inflightCommandBuffersGroup = dispatch_group_create()
private val drawCanvasSemaphore = dispatch_semaphore_create(1)
// A guard flag to have proper assertion when draw() method is called recursively.
private var isDrawRecursiveCall = false

var isForcedToPresentWithTransactionEveryFrame = false

Expand Down Expand Up @@ -319,6 +321,10 @@ internal class MetalRedrawer(
@OptIn(BetaInteropApi::class)
private fun draw(waitUntilCompletion: Boolean, targetTimestamp: NSTimeInterval) = trace("MetalRedrawer:draw") {
check(NSThread.isMainThread)
check(!isDrawRecursiveCall) {
"Attempt to call MetalRedrawer.draw() recursively which may lead to the PictureRecorder corruption."
}
isDrawRecursiveCall = true

lastRenderTimestamp = maxOf(targetTimestamp, lastRenderTimestamp)

Expand All @@ -328,6 +334,7 @@ internal class MetalRedrawer(
}

if (width <= 0 || height <= 0) {
isDrawRecursiveCall = false
return@autoreleasepool
}

Expand Down Expand Up @@ -358,6 +365,7 @@ internal class MetalRedrawer(
// TODO: anomaly, log
// Logger.warn { "'metalLayer.nextDrawable()' returned null. 'metalLayer.allowsNextDrawableTimeout' should be set to false. Skipping the frame." }
picture.close()
isDrawRecursiveCall = false
return@autoreleasepool
}

Expand All @@ -382,6 +390,7 @@ internal class MetalRedrawer(
picture.close()
renderTarget.close()
metalDrawablesHandler.releaseDrawable(metalDrawable)
isDrawRecursiveCall = false
return@autoreleasepool
}

Expand Down Expand Up @@ -467,6 +476,7 @@ internal class MetalRedrawer(
}
}
}
isDrawRecursiveCall = false
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ class ComposeLaunchTest {

assertEquals(1, drawsCount, "Expected to draw only one frame on startup")

image.forEachPixel { _, _, color ->
image.forEachPixel(step = 4) { _, _, color ->
assertEquals(Color.Blue, color, "Expected to draw blue background")
}

Expand Down Expand Up @@ -123,7 +123,7 @@ class ComposeLaunchTest {

assertEquals(1, drawsCount, "Expected to draw only one frame on startup")

image.forEachPixel { _, _, color ->
image.forEachPixel(step = 4) { _, _, color ->
assertEquals(Color.Blue, color, "Expected to draw blue background")
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.compose.ui.layers

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.background
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.captureScreenshot
import androidx.compose.ui.test.runUIKitInstrumentedTest
import androidx.compose.ui.test.utils.forEachPixel
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import platform.UIKit.UIImage
import platform.darwin.dispatch_async
import platform.darwin.dispatch_get_main_queue

class LayersRenderingTest {
@Test
fun testLayerContentOnFirstRender() = runUIKitInstrumentedTest {
var showRed by mutableStateOf(false)
var showGreen by mutableStateOf(false)
var onRender = {}
var frameImage: UIImage? = null

fun prepareForCaptureNextFrame() {
frameImage = null
onRender = {
dispatch_async(dispatch_get_main_queue()) {
frameImage = captureScreenshot()
}
onRender = {}
}
}

setContent {
Box(Modifier.fillMaxSize().background(Color.Blue).drawBehind {
onRender()
})
if (showRed) {
Popup(
onDismissRequest = {},
properties = PopupProperties(usePlatformInsets = false)
) {
Box(Modifier.fillMaxSize().background(Color.Red))
}
}
if (showGreen) {
Popup(
onDismissRequest = {},
properties = PopupProperties(usePlatformInsets = false)
) {
Box(Modifier.fillMaxSize().background(Color.Green))
}
}
}

fun assertNextFrameColor(expectedColor: Color) {
prepareForCaptureNextFrame()
waitForIdle()

assertNotNull(frameImage)
frameImage!!.forEachPixel(step = 4) { _, _, actualColor ->
assertEquals(
expectedColor,
actualColor,
"Expected to draw $expectedColor background"
)
}
}

showRed = true
assertNextFrameColor(Color.Red)

showRed = false
assertNextFrameColor(Color.Blue)

showGreen = true
assertNextFrameColor(Color.Green)

showGreen = false
assertNextFrameColor(Color.Blue)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,20 @@ import kotlin.time.TimeSource
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.useContents
import kotlinx.coroutines.Dispatchers
import platform.CoreGraphics.CGSizeMake
import platform.Foundation.NSDate
import platform.Foundation.NSRunLoop
import platform.Foundation.dateWithTimeIntervalSinceNow
import platform.Foundation.runUntilDate
import platform.UIKit.UIApplication
import platform.UIKit.UIApplicationDelegateProtocol
import platform.UIKit.UIColor
import platform.UIKit.UIGraphicsBeginImageContextWithOptions
import platform.UIKit.UIGraphicsEndImageContext
import platform.UIKit.UIGraphicsGetCurrentContext
import platform.UIKit.UIGraphicsGetImageFromCurrentImageContext
import platform.UIKit.UIGraphicsImageRenderer
import platform.UIKit.UIImage
import platform.UIKit.UIInterfaceOrientationLandscapeLeft
import platform.UIKit.UIInterfaceOrientationLandscapeRight
import platform.UIKit.UIInterfaceOrientationMask
Expand Down Expand Up @@ -554,3 +561,25 @@ internal fun ComposeHostingViewController.waitForIdle() {
internal fun ComposeHostingView.waitForIdle() {
UIKitInstrumentedTest.waitUntil { !this.hasInvalidations() }
}

@OptIn(ExperimentalForeignApi::class)
internal fun UIKitInstrumentedTest.captureScreenshot(): UIImage? {
val scale = UIScreen.mainScreen.scale
val size = UIScreen.mainScreen.bounds.useContents { CGSizeMake(size.width, size.height) }

UIGraphicsBeginImageContextWithOptions(size, false, scale)
UIGraphicsGetCurrentContext() ?: return null

val scene = appDelegate.window()?.windowScene ?: return null
scene.windows.mapNotNull { it as? UIWindow }
.filter { !it.hidden && it.alpha > 0.0 }
.sortedBy { it.windowLevel }
.forEach { window ->
window.drawViewHierarchyInRect(window.bounds, afterScreenUpdates = true)
}

val screenshot = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()

return screenshot
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import platform.CoreGraphics.CGRectMake
import platform.UIKit.UIImage

@OptIn(ExperimentalForeignApi::class)
internal fun UIImage.forEachPixel(onPixel: (x: Int, y: Int, color: Color) -> Unit) {
internal fun UIImage.forEachPixel(step: Int = 1, onPixel: (x: Int, y: Int, color: Color) -> Unit) {
val cgImage = this.CGImage
val width = CGImageGetWidth(cgImage).toInt()
val height = CGImageGetHeight(cgImage).toInt()
Expand All @@ -52,8 +52,8 @@ internal fun UIImage.forEachPixel(onPixel: (x: Int, y: Int, color: Color) -> Uni

CGContextDrawImage(context, CGRectMake(0.0, 0.0, width.toDouble(), height.toDouble()), cgImage)

for (y in 0 until height) {
for (x in 0 until width) {
for (y in 0 until height step step) {
for (x in 0 until width step step) {
val offset = (y * bytesPerRow) + (x * bytesPerPixel)
val r = pinned.get()[offset].toUByte().toInt()
val g = pinned.get()[offset + 1].toUByte().toInt()
Expand Down