blob: d195d8d55d21bab824be1dcd3973b7ee521a6433 [file] [log] [blame]
Alexander Gorshenev4b920e62021-10-13 15:59:48 +03001/*
2 * Copyright 2021 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16package androidx.compose.ui
17
18import androidx.compose.runtime.BroadcastFrameClock
19import androidx.compose.runtime.Composable
20import androidx.compose.runtime.Composition
21import androidx.compose.runtime.CompositionContext
22import androidx.compose.runtime.CompositionLocalProvider
23import androidx.compose.runtime.LaunchedEffect
24import androidx.compose.runtime.Recomposer
25import androidx.compose.runtime.rememberCoroutineScope
26import androidx.compose.runtime.snapshots.Snapshot
27import androidx.compose.runtime.staticCompositionLocalOf
28import androidx.compose.runtime.withFrameNanos
29import androidx.compose.ui.geometry.Offset
30import androidx.compose.ui.input.key.KeyEvent as ComposeKeyEvent
Alexander Gorshenev4b920e62021-10-13 15:59:48 +030031import androidx.compose.ui.input.pointer.PointerEventType
32import androidx.compose.ui.input.pointer.PointerInputEvent
33import androidx.compose.ui.input.pointer.PointerType
34import androidx.compose.ui.node.LayoutNode
35import androidx.compose.ui.platform.PlatformComponent
36import androidx.compose.ui.platform.SkiaBasedOwner
37import androidx.compose.ui.platform.PlatformInput
Alexander Gorshenev4b920e62021-10-13 15:59:48 +030038import androidx.compose.ui.platform.DummyPlatformComponent
39import androidx.compose.ui.platform.EmptyDispatcher
40import androidx.compose.ui.platform.FlushCoroutineDispatcher
41import androidx.compose.ui.platform.GlobalSnapshotManager
42import androidx.compose.ui.platform.setContent
43import androidx.compose.ui.node.RootForTest
44import androidx.compose.ui.unit.Constraints
45import androidx.compose.ui.unit.Density
46import androidx.compose.ui.unit.IntSize
47import androidx.compose.ui.unit.dp
48import kotlinx.coroutines.CoroutineScope
49import kotlinx.coroutines.CoroutineStart
50import kotlinx.coroutines.Job
51import kotlinx.coroutines.launch
52import org.jetbrains.skia.Canvas
53import kotlin.coroutines.CoroutineContext
54import kotlin.jvm.Volatile
55
56internal val LocalComposeScene = staticCompositionLocalOf<ComposeScene> {
57 error("CompositionLocal LocalComposeScene not provided")
58}
59
60/**
Igor Demin5c3047f2022-01-18 14:28:53 +030061 * A virtual container that encapsulates Compose UI content. UI content can be constructed via
Alexander Gorshenev4b920e62021-10-13 15:59:48 +030062 * [setContent] method and with any Composable that manipulates [LayoutNode] tree.
63 *
64 * To draw content on [Canvas], you can use [render] method.
65 *
66 * To specify available size for the content, you should use [constraints].
67 *
Igor Demin5c3047f2022-01-18 14:28:53 +030068 * After [ComposeScene] will no longer needed, you should call [close] method, so all resources
Alexander Gorshenev4b920e62021-10-13 15:59:48 +030069 * and subscriptions will be properly closed. Otherwise there can be a memory leak.
70 */
71class ComposeScene internal constructor(
72 coroutineContext: CoroutineContext,
Igor Demin5c3047f2022-01-18 14:28:53 +030073 internal val component: PlatformComponent,
Alexander Gorshenev4b920e62021-10-13 15:59:48 +030074 density: Density,
75 private val invalidate: () -> Unit
76) {
77 /**
78 * Constructs [ComposeScene]
79 *
80 * @param coroutineContext Context which will be used to launch effects ([LaunchedEffect],
81 * [rememberCoroutineScope]) and run recompositions.
82 * @param density Initial density of the content which will be used to convert [dp] units.
83 * @param invalidate Callback which will be called when the content need to be recomposed or
84 * rerendered. If you draw your content using [render] method, in this callback you should
85 * schedule the next [render] in your rendering loop.
86 */
87 constructor(
88 coroutineContext: CoroutineContext = EmptyDispatcher,
89 density: Density = Density(1f),
90 invalidate: () -> Unit = {}
91 ) : this(
92 coroutineContext,
93 DummyPlatformComponent,
94 density,
95 invalidate
96 )
97
98 private var isInvalidationDisabled = false
99
100 @Volatile
101 private var hasPendingDraws = true
Igor Demind59b8832021-10-14 20:07:07 +0300102 private inline fun <T> postponeInvalidation(block: () -> T): T {
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300103 isInvalidationDisabled = true
Igor Demind59b8832021-10-14 20:07:07 +0300104 val result = try {
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300105 block()
106 } finally {
107 isInvalidationDisabled = false
108 }
109 invalidateIfNeeded()
Igor Demind59b8832021-10-14 20:07:07 +0300110 return result
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300111 }
112
113 private fun invalidateIfNeeded() {
Igor Demind59b8832021-10-14 20:07:07 +0300114 hasPendingDraws = frameClock.hasAwaiters || list.any(SkiaBasedOwner::needRender)
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300115 if (hasPendingDraws && !isInvalidationDisabled) {
116 invalidate()
117 }
118 }
119
120 private val list = LinkedHashSet<SkiaBasedOwner>()
121 private val listCopy = mutableListOf<SkiaBasedOwner>()
122
123 private inline fun forEachOwner(action: (SkiaBasedOwner) -> Unit) {
124 listCopy.addAll(list)
125 listCopy.forEach(action)
126 listCopy.clear()
127 }
128
129 /**
Igor Demin5c3047f2022-01-18 14:28:53 +0300130 * All currently registered [RootForTest]s. After calling [setContent] the first root
131 * will be added. If there is an any [Popup] is present in the content, it will be added as
132 * another [RootForTest]
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300133 */
134 val roots: Set<RootForTest> get() = list
135
136 private var pointerId = 0L
137 private var isMousePressed = false
138
139 private val job = Job()
140 private val dispatcher = FlushCoroutineDispatcher(CoroutineScope(coroutineContext + job))
141 private val frameClock = BroadcastFrameClock(onNewAwaiters = ::invalidateIfNeeded)
142 private val coroutineScope = CoroutineScope(coroutineContext + job + dispatcher + frameClock)
143
144 private val recomposer = Recomposer(coroutineScope.coroutineContext)
145 internal val platformInputService: PlatformInput = PlatformInput(component)
146
147 private var mainOwner: SkiaBasedOwner? = null
148 private var composition: Composition? = null
149
150 /**
151 * Density of the content which will be used to convert [dp] units.
152 */
153 var density: Density = density
154 set(value) {
Igor Demin5c3047f2022-01-18 14:28:53 +0300155 check(!isClosed) { "ComposeScene is closed" }
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300156 field = value
157 mainOwner?.density = value
158 }
159
Igor Demin5c3047f2022-01-18 14:28:53 +0300160 private var isClosed = false
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300161
162 init {
163 GlobalSnapshotManager.ensureStarted()
164 coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
165 recomposer.runRecomposeAndApplyChanges()
166 }
167 }
168
169 /**
170 * Close all resources and subscriptions. Not calling this method when [ComposeScene] is no
171 * longer needed will cause a memory leak.
172 *
173 * All effects launched via [LaunchedEffect] or [rememberCoroutineScope] will be cancelled
174 * (but not immediately).
175 *
176 * After calling this method, you cannot call any other method of this [ComposeScene].
177 */
Igor Demin5c3047f2022-01-18 14:28:53 +0300178 fun close() {
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300179 composition?.dispose()
180 mainOwner?.dispose()
181 recomposer.cancel()
182 job.cancel()
Igor Demin5c3047f2022-01-18 14:28:53 +0300183 isClosed = true
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300184 }
185
186 private fun dispatchCommand(command: () -> Unit) {
187 coroutineScope.launch {
188 command()
189 }
190 }
191
192 /**
193 * Returns true if there are pending recompositions, renders or dispatched tasks.
194 * Can be called from any thread.
195 */
196 fun hasInvalidations() = hasPendingDraws ||
197 recomposer.hasPendingWork ||
198 dispatcher.hasTasks()
199
Igor Demin5c3047f2022-01-18 14:28:53 +0300200 internal fun attach(owner: SkiaBasedOwner) {
201 check(!isClosed) { "ComposeScene is closed" }
202 list.add(owner)
203 owner.onNeedRender = ::invalidateIfNeeded
204 owner.onDispatchCommand = ::dispatchCommand
205 owner.constraints = constraints
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300206 invalidateIfNeeded()
Igor Demin5c3047f2022-01-18 14:28:53 +0300207 if (owner.isFocusable) {
208 focusedOwner = owner
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300209 }
210 }
211
Igor Demin5c3047f2022-01-18 14:28:53 +0300212 internal fun detach(owner: SkiaBasedOwner) {
213 check(!isClosed) { "ComposeScene is closed" }
214 list.remove(owner)
215 owner.onDispatchCommand = null
216 owner.onNeedRender = null
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300217 invalidateIfNeeded()
Igor Demin5c3047f2022-01-18 14:28:53 +0300218 if (owner == focusedOwner) {
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300219 focusedOwner = list.lastOrNull { it.isFocusable }
220 }
221 }
222
223 /**
224 * Update the composition with the content described by the [content] composable. After this
225 * has been called the changes to produce the initial composition has been calculated and
226 * applied to the composition.
227 *
228 * Will throw an [IllegalStateException] if the composition has been disposed.
229 *
230 * @param content Content of the [ComposeScene]
231 */
232 fun setContent(
233 content: @Composable () -> Unit
234 ) = setContent(
235 parentComposition = null,
236 content = content
237 )
238
239 // TODO(demin): We should configure routing of key events if there
240 // are any popups/root present:
241 // - ComposeScene.sendKeyEvent
242 // - ComposeScene.onPreviewKeyEvent (or Window.onPreviewKeyEvent)
243 // - Popup.onPreviewKeyEvent
244 // - NestedPopup.onPreviewKeyEvent
245 // - NestedPopup.onKeyEvent
246 // - Popup.onKeyEvent
247 // - ComposeScene.onKeyEvent
248 // Currently we have this routing:
249 // - [active Popup or the main content].onPreviewKeyEvent
250 // - [active Popup or the main content].onKeyEvent
251 // After we change routing, we can remove onPreviewKeyEvent/onKeyEvent from this method
252 internal fun setContent(
253 parentComposition: CompositionContext? = null,
254 onPreviewKeyEvent: (ComposeKeyEvent) -> Boolean = { false },
255 onKeyEvent: (ComposeKeyEvent) -> Boolean = { false },
256 content: @Composable () -> Unit
257 ) {
Igor Demin5c3047f2022-01-18 14:28:53 +0300258 check(!isClosed) { "ComposeScene is closed" }
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300259 composition?.dispose()
260 mainOwner?.dispose()
261 val mainOwner = SkiaBasedOwner(
262 platformInputService,
Igor Demin5c3047f2022-01-18 14:28:53 +0300263 component,
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300264 density,
265 onPreviewKeyEvent = onPreviewKeyEvent,
266 onKeyEvent = onKeyEvent
267 )
268 attach(mainOwner)
269 composition = mainOwner.setContent(parentComposition ?: recomposer) {
270 CompositionLocalProvider(
271 LocalComposeScene provides this,
272 content = content
273 )
274 }
275 this.mainOwner = mainOwner
276
277 // to perform all pending work synchronously. to start LaunchedEffect for example
278 dispatcher.flush()
279 }
280
281 /**
282 * Set constraints, which will be used to measure and layout content.
283 */
284 var constraints: Constraints = Constraints()
285 set(value) {
286 field = value
287 forEachOwner {
288 it.constraints = constraints
289 }
290 }
291
292 /**
293 * Returns the current content size
294 */
295 val contentSize: IntSize
296 get() {
Igor Demin5c3047f2022-01-18 14:28:53 +0300297 check(!isClosed) { "ComposeScene is closed" }
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300298 val mainOwner = mainOwner ?: return IntSize.Zero
299 mainOwner.measureAndLayout()
300 return IntSize(mainOwner.root.width, mainOwner.root.height)
301 }
302
303 /**
304 * Render the current content on [canvas]. Passed [nanoTime] will be used to drive all
305 * animations in the content (or any other code, which uses [withFrameNanos]
306 */
307 fun render(canvas: Canvas, nanoTime: Long) {
Igor Demin5c3047f2022-01-18 14:28:53 +0300308 check(!isClosed) { "ComposeScene is closed" }
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300309 postponeInvalidation {
310 // TODO(https://github.com/JetBrains/compose-jb/issues/1135):
311 // Temporarily workaround for flaky tests in WithComposeUiTest.
312 // It fails when we remove synchronized and run:
313 // ./gradlew desktopTest -Pandroidx.compose.multiplatformEnabled=true
314 // We should make a minimal reproducer, and fix race condition somewhere
315 // else, not here.
316 // See also GlobalSnapshotManager.
317 synchronized(Snapshot.current) {
318 // We must see the actual state before we will render the frame
319 Snapshot.sendApplyNotifications()
320 dispatcher.flush()
321 frameClock.sendFrame(nanoTime)
322 }
323
324 forEachOwner {
325 it.render(canvas)
326 }
327 }
328 }
329
330 private var focusedOwner: SkiaBasedOwner? = null
331 private val hoveredOwner: SkiaBasedOwner?
332 get() = list.lastOrNull { it.isHovered(pointLocation) } ?: list.lastOrNull()
333
334 private fun SkiaBasedOwner?.isAbove(
335 targetOwner: SkiaBasedOwner?
336 ) = list.indexOf(this) > list.indexOf(targetOwner)
337
338 // TODO(demin): return Boolean (when it is consumed).
339 // see ComposeLayer todo about AWTDebounceEventQueue
340 /**
341 * Send pointer event to the content.
342 *
343 * @param eventType Indicates the primary reason that the event was sent.
344 * @param position The [Offset] of the current pointer event, relative to the content.
Igor Demin7b799432021-10-20 16:43:36 +0300345 * @param scrollDelta scroll delta for the PointerEventType.Scroll event
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300346 * @param timeMillis The time of the current pointer event, in milliseconds. The start (`0`) time
347 * is platform-dependent.
348 * @param type The device type that produced the event, such as [mouse][PointerType.Mouse],
349 * or [touch][PointerType.Touch].
350 * @param nativeEvent The original native event.
351 */
352 @OptIn(ExperimentalComposeUiApi::class)
353 fun sendPointerEvent(
354 eventType: PointerEventType,
355 position: Offset,
Igor Demin7b799432021-10-20 16:43:36 +0300356 scrollDelta: Offset = Offset(0f, 0f),
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300357 timeMillis: Long = System.nanoTime() / 1_000_000L,
358 type: PointerType = PointerType.Mouse,
359 nativeEvent: Any? = null,
360 // TODO(demin): support PointerButtons, PointerKeyboardModifiers
361// buttons: PointerButtons? = null,
362// keyboardModifiers: PointerKeyboardModifiers? = null,
Igor Demind59b8832021-10-14 20:07:07 +0300363 ): Unit = postponeInvalidation {
Igor Demin5c3047f2022-01-18 14:28:53 +0300364 check(!isClosed) { "ComposeScene is closed" }
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300365 when (eventType) {
366 PointerEventType.Press -> isMousePressed = true
367 PointerEventType.Release -> isMousePressed = false
368 }
369 val event = pointerInputEvent(
Igor Demin7b799432021-10-20 16:43:36 +0300370 eventType,
371 position,
372 timeMillis,
373 nativeEvent,
374 type,
375 isMousePressed,
376 pointerId,
377 scrollDelta
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300378 )
379 when (eventType) {
380 PointerEventType.Press -> onMousePressed(event)
381 PointerEventType.Release -> onMouseReleased(event)
382 PointerEventType.Move -> {
383 pointLocation = position
384 hoveredOwner?.processPointerInput(event)
385 }
386 PointerEventType.Enter -> hoveredOwner?.processPointerInput(event)
387 PointerEventType.Exit -> hoveredOwner?.processPointerInput(event)
Igor Demin7b799432021-10-20 16:43:36 +0300388 PointerEventType.Scroll -> hoveredOwner?.processPointerInput(event)
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300389 }
390 }
391
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300392 private fun onMousePressed(event: PointerInputEvent) {
393 val currentOwner = hoveredOwner
394 if (currentOwner != null) {
395 if (focusedOwner.isAbove(currentOwner)) {
396 focusedOwner?.onDismissRequest?.invoke()
397 } else {
398 currentOwner.processPointerInput(event)
399 }
400 } else {
401 focusedOwner?.processPointerInput(event)
402 }
403 }
404
405 private fun onMouseReleased(event: PointerInputEvent) {
406 val owner = hoveredOwner ?: focusedOwner
407 owner?.processPointerInput(event)
408 pointerId += 1
409 }
410
411 private var pointLocation = Offset.Zero
412
413 /**
414 * Send [KeyEvent] to the content.
415 * @return true if the event was consumed by the content
416 */
Igor Demind59b8832021-10-14 20:07:07 +0300417 fun sendKeyEvent(event: ComposeKeyEvent): Boolean = postponeInvalidation {
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300418 return focusedOwner?.sendKeyEvent(event) == true
419 }
420
421 internal fun onInputMethodEvent(event: Any) = this.onPlatformInputMethodEvent(event)
422}
423
424internal expect fun ComposeScene.onPlatformInputMethodEvent(event: Any)
425
426internal expect fun pointerInputEvent(
427 eventType: PointerEventType,
428 position: Offset,
429 timeMillis: Long,
430 nativeEvent: Any?,
431 type: PointerType,
432 isMousePressed: Boolean,
Igor Demin7b799432021-10-20 16:43:36 +0300433 pointerId: Long,
434 scrollDelta: Offset
Alexander Gorshenev4b920e62021-10-13 15:59:48 +0300435): PointerInputEvent