blob: d195d8d55d21bab824be1dcd3973b7ee521a6433 [file] [log] [blame]
/*
* Copyright 2021 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
import androidx.compose.runtime.BroadcastFrameClock
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Composition
import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Recomposer
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.key.KeyEvent as ComposeKeyEvent
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.PointerInputEvent
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.node.LayoutNode
import androidx.compose.ui.platform.PlatformComponent
import androidx.compose.ui.platform.SkiaBasedOwner
import androidx.compose.ui.platform.PlatformInput
import androidx.compose.ui.platform.DummyPlatformComponent
import androidx.compose.ui.platform.EmptyDispatcher
import androidx.compose.ui.platform.FlushCoroutineDispatcher
import androidx.compose.ui.platform.GlobalSnapshotManager
import androidx.compose.ui.platform.setContent
import androidx.compose.ui.node.RootForTest
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.jetbrains.skia.Canvas
import kotlin.coroutines.CoroutineContext
import kotlin.jvm.Volatile
internal val LocalComposeScene = staticCompositionLocalOf<ComposeScene> {
error("CompositionLocal LocalComposeScene not provided")
}
/**
* A virtual container that encapsulates Compose UI content. UI content can be constructed via
* [setContent] method and with any Composable that manipulates [LayoutNode] tree.
*
* To draw content on [Canvas], you can use [render] method.
*
* To specify available size for the content, you should use [constraints].
*
* After [ComposeScene] will no longer needed, you should call [close] method, so all resources
* and subscriptions will be properly closed. Otherwise there can be a memory leak.
*/
class ComposeScene internal constructor(
coroutineContext: CoroutineContext,
internal val component: PlatformComponent,
density: Density,
private val invalidate: () -> Unit
) {
/**
* Constructs [ComposeScene]
*
* @param coroutineContext Context which will be used to launch effects ([LaunchedEffect],
* [rememberCoroutineScope]) and run recompositions.
* @param density Initial density of the content which will be used to convert [dp] units.
* @param invalidate Callback which will be called when the content need to be recomposed or
* rerendered. If you draw your content using [render] method, in this callback you should
* schedule the next [render] in your rendering loop.
*/
constructor(
coroutineContext: CoroutineContext = EmptyDispatcher,
density: Density = Density(1f),
invalidate: () -> Unit = {}
) : this(
coroutineContext,
DummyPlatformComponent,
density,
invalidate
)
private var isInvalidationDisabled = false
@Volatile
private var hasPendingDraws = true
private inline fun <T> postponeInvalidation(block: () -> T): T {
isInvalidationDisabled = true
val result = try {
block()
} finally {
isInvalidationDisabled = false
}
invalidateIfNeeded()
return result
}
private fun invalidateIfNeeded() {
hasPendingDraws = frameClock.hasAwaiters || list.any(SkiaBasedOwner::needRender)
if (hasPendingDraws && !isInvalidationDisabled) {
invalidate()
}
}
private val list = LinkedHashSet<SkiaBasedOwner>()
private val listCopy = mutableListOf<SkiaBasedOwner>()
private inline fun forEachOwner(action: (SkiaBasedOwner) -> Unit) {
listCopy.addAll(list)
listCopy.forEach(action)
listCopy.clear()
}
/**
* All currently registered [RootForTest]s. After calling [setContent] the first root
* will be added. If there is an any [Popup] is present in the content, it will be added as
* another [RootForTest]
*/
val roots: Set<RootForTest> get() = list
private var pointerId = 0L
private var isMousePressed = false
private val job = Job()
private val dispatcher = FlushCoroutineDispatcher(CoroutineScope(coroutineContext + job))
private val frameClock = BroadcastFrameClock(onNewAwaiters = ::invalidateIfNeeded)
private val coroutineScope = CoroutineScope(coroutineContext + job + dispatcher + frameClock)
private val recomposer = Recomposer(coroutineScope.coroutineContext)
internal val platformInputService: PlatformInput = PlatformInput(component)
private var mainOwner: SkiaBasedOwner? = null
private var composition: Composition? = null
/**
* Density of the content which will be used to convert [dp] units.
*/
var density: Density = density
set(value) {
check(!isClosed) { "ComposeScene is closed" }
field = value
mainOwner?.density = value
}
private var isClosed = false
init {
GlobalSnapshotManager.ensureStarted()
coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
recomposer.runRecomposeAndApplyChanges()
}
}
/**
* Close all resources and subscriptions. Not calling this method when [ComposeScene] is no
* longer needed will cause a memory leak.
*
* All effects launched via [LaunchedEffect] or [rememberCoroutineScope] will be cancelled
* (but not immediately).
*
* After calling this method, you cannot call any other method of this [ComposeScene].
*/
fun close() {
composition?.dispose()
mainOwner?.dispose()
recomposer.cancel()
job.cancel()
isClosed = true
}
private fun dispatchCommand(command: () -> Unit) {
coroutineScope.launch {
command()
}
}
/**
* Returns true if there are pending recompositions, renders or dispatched tasks.
* Can be called from any thread.
*/
fun hasInvalidations() = hasPendingDraws ||
recomposer.hasPendingWork ||
dispatcher.hasTasks()
internal fun attach(owner: SkiaBasedOwner) {
check(!isClosed) { "ComposeScene is closed" }
list.add(owner)
owner.onNeedRender = ::invalidateIfNeeded
owner.onDispatchCommand = ::dispatchCommand
owner.constraints = constraints
invalidateIfNeeded()
if (owner.isFocusable) {
focusedOwner = owner
}
}
internal fun detach(owner: SkiaBasedOwner) {
check(!isClosed) { "ComposeScene is closed" }
list.remove(owner)
owner.onDispatchCommand = null
owner.onNeedRender = null
invalidateIfNeeded()
if (owner == focusedOwner) {
focusedOwner = list.lastOrNull { it.isFocusable }
}
}
/**
* Update the composition with the content described by the [content] composable. After this
* has been called the changes to produce the initial composition has been calculated and
* applied to the composition.
*
* Will throw an [IllegalStateException] if the composition has been disposed.
*
* @param content Content of the [ComposeScene]
*/
fun setContent(
content: @Composable () -> Unit
) = setContent(
parentComposition = null,
content = content
)
// TODO(demin): We should configure routing of key events if there
// are any popups/root present:
// - ComposeScene.sendKeyEvent
// - ComposeScene.onPreviewKeyEvent (or Window.onPreviewKeyEvent)
// - Popup.onPreviewKeyEvent
// - NestedPopup.onPreviewKeyEvent
// - NestedPopup.onKeyEvent
// - Popup.onKeyEvent
// - ComposeScene.onKeyEvent
// Currently we have this routing:
// - [active Popup or the main content].onPreviewKeyEvent
// - [active Popup or the main content].onKeyEvent
// After we change routing, we can remove onPreviewKeyEvent/onKeyEvent from this method
internal fun setContent(
parentComposition: CompositionContext? = null,
onPreviewKeyEvent: (ComposeKeyEvent) -> Boolean = { false },
onKeyEvent: (ComposeKeyEvent) -> Boolean = { false },
content: @Composable () -> Unit
) {
check(!isClosed) { "ComposeScene is closed" }
composition?.dispose()
mainOwner?.dispose()
val mainOwner = SkiaBasedOwner(
platformInputService,
component,
density,
onPreviewKeyEvent = onPreviewKeyEvent,
onKeyEvent = onKeyEvent
)
attach(mainOwner)
composition = mainOwner.setContent(parentComposition ?: recomposer) {
CompositionLocalProvider(
LocalComposeScene provides this,
content = content
)
}
this.mainOwner = mainOwner
// to perform all pending work synchronously. to start LaunchedEffect for example
dispatcher.flush()
}
/**
* Set constraints, which will be used to measure and layout content.
*/
var constraints: Constraints = Constraints()
set(value) {
field = value
forEachOwner {
it.constraints = constraints
}
}
/**
* Returns the current content size
*/
val contentSize: IntSize
get() {
check(!isClosed) { "ComposeScene is closed" }
val mainOwner = mainOwner ?: return IntSize.Zero
mainOwner.measureAndLayout()
return IntSize(mainOwner.root.width, mainOwner.root.height)
}
/**
* Render the current content on [canvas]. Passed [nanoTime] will be used to drive all
* animations in the content (or any other code, which uses [withFrameNanos]
*/
fun render(canvas: Canvas, nanoTime: Long) {
check(!isClosed) { "ComposeScene is closed" }
postponeInvalidation {
// TODO(https://github.com/JetBrains/compose-jb/issues/1135):
// Temporarily workaround for flaky tests in WithComposeUiTest.
// It fails when we remove synchronized and run:
// ./gradlew desktopTest -Pandroidx.compose.multiplatformEnabled=true
// We should make a minimal reproducer, and fix race condition somewhere
// else, not here.
// See also GlobalSnapshotManager.
synchronized(Snapshot.current) {
// We must see the actual state before we will render the frame
Snapshot.sendApplyNotifications()
dispatcher.flush()
frameClock.sendFrame(nanoTime)
}
forEachOwner {
it.render(canvas)
}
}
}
private var focusedOwner: SkiaBasedOwner? = null
private val hoveredOwner: SkiaBasedOwner?
get() = list.lastOrNull { it.isHovered(pointLocation) } ?: list.lastOrNull()
private fun SkiaBasedOwner?.isAbove(
targetOwner: SkiaBasedOwner?
) = list.indexOf(this) > list.indexOf(targetOwner)
// TODO(demin): return Boolean (when it is consumed).
// see ComposeLayer todo about AWTDebounceEventQueue
/**
* Send pointer event to the content.
*
* @param eventType Indicates the primary reason that the event was sent.
* @param position The [Offset] of the current pointer event, relative to the content.
* @param scrollDelta scroll delta for the PointerEventType.Scroll event
* @param timeMillis The time of the current pointer event, in milliseconds. The start (`0`) time
* is platform-dependent.
* @param type The device type that produced the event, such as [mouse][PointerType.Mouse],
* or [touch][PointerType.Touch].
* @param nativeEvent The original native event.
*/
@OptIn(ExperimentalComposeUiApi::class)
fun sendPointerEvent(
eventType: PointerEventType,
position: Offset,
scrollDelta: Offset = Offset(0f, 0f),
timeMillis: Long = System.nanoTime() / 1_000_000L,
type: PointerType = PointerType.Mouse,
nativeEvent: Any? = null,
// TODO(demin): support PointerButtons, PointerKeyboardModifiers
// buttons: PointerButtons? = null,
// keyboardModifiers: PointerKeyboardModifiers? = null,
): Unit = postponeInvalidation {
check(!isClosed) { "ComposeScene is closed" }
when (eventType) {
PointerEventType.Press -> isMousePressed = true
PointerEventType.Release -> isMousePressed = false
}
val event = pointerInputEvent(
eventType,
position,
timeMillis,
nativeEvent,
type,
isMousePressed,
pointerId,
scrollDelta
)
when (eventType) {
PointerEventType.Press -> onMousePressed(event)
PointerEventType.Release -> onMouseReleased(event)
PointerEventType.Move -> {
pointLocation = position
hoveredOwner?.processPointerInput(event)
}
PointerEventType.Enter -> hoveredOwner?.processPointerInput(event)
PointerEventType.Exit -> hoveredOwner?.processPointerInput(event)
PointerEventType.Scroll -> hoveredOwner?.processPointerInput(event)
}
}
private fun onMousePressed(event: PointerInputEvent) {
val currentOwner = hoveredOwner
if (currentOwner != null) {
if (focusedOwner.isAbove(currentOwner)) {
focusedOwner?.onDismissRequest?.invoke()
} else {
currentOwner.processPointerInput(event)
}
} else {
focusedOwner?.processPointerInput(event)
}
}
private fun onMouseReleased(event: PointerInputEvent) {
val owner = hoveredOwner ?: focusedOwner
owner?.processPointerInput(event)
pointerId += 1
}
private var pointLocation = Offset.Zero
/**
* Send [KeyEvent] to the content.
* @return true if the event was consumed by the content
*/
fun sendKeyEvent(event: ComposeKeyEvent): Boolean = postponeInvalidation {
return focusedOwner?.sendKeyEvent(event) == true
}
internal fun onInputMethodEvent(event: Any) = this.onPlatformInputMethodEvent(event)
}
internal expect fun ComposeScene.onPlatformInputMethodEvent(event: Any)
internal expect fun pointerInputEvent(
eventType: PointerEventType,
position: Offset,
timeMillis: Long,
nativeEvent: Any?,
type: PointerType,
isMousePressed: Boolean,
pointerId: Long,
scrollDelta: Offset
): PointerInputEvent