[go: nahoru, domu]

blob: 46d40c281a58c76ca130950af81eb84fc98a99b1 [file] [log] [blame]
/*
* Copyright 2019 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.tooling.preview
import android.content.Context
import android.graphics.Canvas
import android.graphics.DashPathEffect
import android.graphics.Paint
import android.os.Bundle
import android.util.AttributeSet
import android.util.Log
import android.widget.FrameLayout
import androidx.annotation.VisibleForTesting
import androidx.compose.animation.core.InternalAnimationApi
import androidx.compose.animation.core.Transition
import androidx.compose.runtime.AtomicReference
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Composition
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.currentComposer
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalFontLoader
import androidx.compose.ui.platform.ViewRootForTest
import androidx.compose.ui.tooling.CompositionDataRecord
import androidx.compose.ui.tooling.Inspectable
import androidx.compose.ui.tooling.data.Group
import androidx.compose.ui.tooling.data.SourceLocation
import androidx.compose.ui.tooling.data.UiToolingDataApi
import androidx.compose.ui.tooling.data.asTree
import androidx.compose.ui.tooling.preview.animation.PreviewAnimationClock
import androidx.compose.ui.unit.IntRect
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.ViewTreeLifecycleOwner
import androidx.lifecycle.ViewTreeViewModelStoreOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.ViewTreeSavedStateRegistryOwner
const val TOOLS_NS_URI = "http://schemas.android.com/tools"
private val emptyContent: @Composable () -> Unit = @Composable {}
/**
* Class containing the minimum information needed by the Preview to map components to the
* source code and render boundaries.
*
* @suppress
*/
@OptIn(UiToolingDataApi::class)
data class ViewInfo(
val fileName: String,
val lineNumber: Int,
val bounds: IntRect,
val location: SourceLocation?,
val children: List<ViewInfo>
) {
fun hasBounds(): Boolean = bounds.bottom != 0 && bounds.right != 0
fun allChildren(): List<ViewInfo> =
children + children.flatMap { it.allChildren() }
override fun toString(): String =
"""($fileName:$lineNumber,
|bounds=(top=${bounds.top}, left=${bounds.left},
|location=${location?.let { "(${it.offset}L${it.length}" } ?: "<none>"}
|bottom=${bounds.bottom}, right=${bounds.right}),
|childrenCount=${children.size})""".trimMargin()
}
/**
* View adapter that renders a `@Composable`. The `@Composable` is found by
* reading the `tools:composableName` attribute that contains the FQN. Additional attributes can
* be used to customize the behaviour of this view:
* - `tools:parameterProviderClass`: FQN of the [PreviewParameterProvider] to be instantiated by
* the [ComposeViewAdapter] that will be used as source for the `@Composable` parameters.
* - `tools:parameterProviderIndex`: The index within the [PreviewParameterProvider] of the
* value to be used in this particular instance.
* - `tools:paintBounds`: If true, the component boundaries will be painted. This is only meant
* for debugging purposes.
* - `tools:printViewInfos`: If true, the [ComposeViewAdapter] will log the tree of [ViewInfo]
* to logcat for debugging.
* - `tools:animationClockStartTime`: When set, a [PreviewAnimationClock] will control the
* animations in the [ComposeViewAdapter] context.
*
* @suppress
*/
@Suppress("unused")
@OptIn(UiToolingDataApi::class)
internal class ComposeViewAdapter : FrameLayout {
private val TAG = "ComposeViewAdapter"
/**
* [ComposeView] that will contain the [Composable] to preview.
*/
private val composeView = ComposeView(context)
/**
* When enabled, generate and cache [ViewInfo] tree that can be inspected by the Preview
* to map components to source code.
*/
private var debugViewInfos = false
/**
* When enabled, paint the boundaries generated by layout nodes.
*/
private var debugPaintBounds = false
internal var viewInfos: List<ViewInfo> = emptyList()
private val slotTableRecord = CompositionDataRecord.create()
/**
* Simple function name of the Composable being previewed.
*/
private var composableName = ""
/**
* Whether the current Composable has animations.
*/
private var hasAnimations = false
/**
* Saved exception from the last composition. Since we can not handle the exception during the
* composition, we save it and throw it during onLayout, this allows Studio to catch it and
* display it to the user.
*/
private val delayedException = AtomicReference<Throwable?>(null)
/**
* The [Composable] to be rendered in the preview. It is initialized when this adapter
* is initialized.
*/
private var previewComposition: @Composable () -> Unit = {}
// Note: the constant emptyContent below instead of a literal {} works around
// https://youtrack.jetbrains.com/issue/KT-17467, which causes the compiler to emit classes
// named `content` and `Content` (from the Content method's composable update scope)
// which causes compilation problems on case-insensitive filesystems.
@Suppress("RemoveExplicitTypeArguments")
private val content = mutableStateOf<@Composable () -> Unit>(emptyContent)
/**
* When true, the composition will be immediately invalidated after being drawn. This will
* force it to be recomposed on the next render. This is useful for live literals so the
* whole composition happens again on the next render.
*/
private var forceCompositionInvalidation = false
/**
* Callback invoked when onDraw has been called.
*/
private var onDraw = {}
private val debugBoundsPaint = Paint().apply {
pathEffect = DashPathEffect(floatArrayOf(5f, 10f, 15f, 20f), 0f)
style = Paint.Style.STROKE
color = Color.Red.toArgb()
}
private var composition: Composition? = null
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
init(attrs)
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
) {
init(attrs)
}
private fun walkTable(viewInfo: ViewInfo, indent: Int = 0) {
Log.d(TAG, ("| ".repeat(indent)) + "|-$viewInfo")
viewInfo.children.forEach { walkTable(it, indent + 1) }
}
private val Group.fileName: String
get() = location?.sourceFile ?: ""
private val Group.lineNumber: Int
get() = location?.lineNumber ?: -1
/**
* Returns true if this [Group] has no source position information
*/
private fun Group.hasNullSourcePosition(): Boolean =
fileName.isEmpty() && lineNumber == -1
/**
* Returns true if this [Group] has no source position information and no children
*/
private fun Group.isNullGroup(): Boolean =
hasNullSourcePosition() && children.isEmpty()
private fun Group.toViewInfo(): ViewInfo {
if (children.size == 1 && hasNullSourcePosition()) {
// There is no useful information in this intermediate node, remove.
return children.single().toViewInfo()
}
val childrenViewInfo = children
.filter { !it.isNullGroup() }
.map { it.toViewInfo() }
// TODO: Use group names instead of indexing once it's supported
return ViewInfo(
location?.sourceFile ?: "",
location?.lineNumber ?: -1,
box,
location,
childrenViewInfo
)
}
/**
* Processes the recorded slot table and re-generates the [viewInfos] attribute.
*/
private fun processViewInfos() {
viewInfos = slotTableRecord.store.map { it.asTree() }.map { it.toViewInfo() }.toList()
if (debugViewInfos) {
viewInfos.forEach {
walkTable(it)
}
}
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
delayedException.getAndSet(null)?.let { exception ->
// There was a pending exception. Throw it here since Studio will catch it and show
// it to the user.
throw exception
}
processViewInfos()
if (composableName.isNotEmpty()) {
// TODO(b/160126628): support other APIs, e.g. animate
findAndTrackTransitions()
}
}
override fun onAttachedToWindow() {
ViewTreeLifecycleOwner.set(composeView.rootView, FakeSavedStateRegistryOwner)
super.onAttachedToWindow()
}
/**
* Finds all the transition animations defined in the Compose tree where the root is the
* `@Composable` being previewed. We only return animations defined in the user code, i.e.
* the ones we've got source information for.
*/
@Suppress("UNCHECKED_CAST")
@OptIn(InternalAnimationApi::class)
private fun findAndTrackTransitions() {
val slotTrees = slotTableRecord.store.map { it.asTree() }
val transitions = mutableSetOf<Transition<Any>>()
// Check all the slot tables, since some animations might not be present in the same
// table as the one containing the `@Composable` being previewed, e.g. when they're
// defined using sub-composition.
slotTrees.forEach { tree ->
transitions.addAll(
tree.findAll {
// Find `updateTransition` calls in the user code, i.e. when source location is
// known.
it.name == "updateTransition" && it.location != null
}.mapNotNull {
val rememberCall =
it.firstOrNull { it.name == "remember" } ?: return@mapNotNull null
rememberCall.data.firstOrNull { data ->
data is Transition<*>
} as? Transition<Any>
}
)
}
hasAnimations = transitions.isNotEmpty()
// Make the `PreviewAnimationClock` track all the transitions found.
if (::clock.isInitialized) {
transitions.forEach { clock.trackTransition(it) }
}
}
private fun Group.firstOrNull(predicate: (Group) -> Boolean): Group? {
return findGroupsThatMatchPredicate(this, predicate, true).firstOrNull()
}
private fun Group.findAll(predicate: (Group) -> Boolean): List<Group> {
return findGroupsThatMatchPredicate(this, predicate)
}
/**
* Search [Group]s that match a given [predicate], starting from a given [root]. An optional
* boolean parameter can be set if we're interested in a single occurrence. If it's set, we
* return early after finding the first matching [Group].
*/
private fun findGroupsThatMatchPredicate(
root: Group,
predicate: (Group) -> Boolean,
findOnlyFirst: Boolean = false
): List<Group> {
val result = mutableListOf<Group>()
val stack = mutableListOf(root)
while (stack.isNotEmpty()) {
val current = stack.removeLast()
if (predicate(current)) {
if (findOnlyFirst) {
return listOf(current)
}
result.add(current)
}
stack.addAll(current.children)
}
return result
}
private fun invalidateComposition() {
// Invalidate the full composition by setting it to empty and back to the actual value
content.value = {}
content.value = previewComposition
// Invalidate the state of the view so it gets redrawn
invalidate()
}
override fun dispatchDraw(canvas: Canvas?) {
super.dispatchDraw(canvas)
if (forceCompositionInvalidation) invalidateComposition()
onDraw()
if (!debugPaintBounds) {
return
}
viewInfos
.flatMap { listOf(it) + it.allChildren() }
.forEach {
if (it.hasBounds()) {
canvas?.apply {
val pxBounds = android.graphics.Rect(
it.bounds.left,
it.bounds.top,
it.bounds.right,
it.bounds.bottom
)
drawRect(pxBounds, debugBoundsPaint)
}
}
}
}
/**
* Clock that controls the animations defined in the context of this [ComposeViewAdapter].
*
* @suppress
*/
@VisibleForTesting
internal lateinit var clock: PreviewAnimationClock
/**
* Wraps a given [Preview] method an does any necessary setup.
*/
@Composable
private fun WrapPreview(content: @Composable () -> Unit) {
// We need to replace the FontResourceLoader to avoid using ResourcesCompat.
// ResourcesCompat can not load fonts within Layoutlib and, since Layoutlib always runs
// the latest version, we do not need it.
CompositionLocalProvider(LocalFontLoader provides LayoutlibFontResourceLoader(context)) {
Inspectable(slotTableRecord, content)
}
}
/**
* Initializes the adapter and populates it with the given [Preview] composable.
* @param className name of the class containing the preview function
* @param methodName `@Preview` method name
* @param parameterProvider [Class] for the [PreviewParameterProvider] to be used as
* parameter input for this call. If null, no parameters will be passed to the composable.
* @param parameterProviderIndex when [parameterProvider] is not null, this index will
* reference the element in the [Sequence] to be used as parameter.
* @param debugPaintBounds if true, the view will paint the boundaries around the layout
* elements.
* @param debugViewInfos if true, it will generate the [ViewInfo] structures and will log it.
* @param animationClockStartTime if positive, [clock] will be defined and will control the
* animations defined in the context of the `@Composable` being previewed.
* @param forceCompositionInvalidation if true, the composition will be invalidated on every
* draw, forcing it to recompose on next render.
* @param onCommit callback invoked after every commit of the preview composable.
* @param onDraw callback invoked after every draw of the adapter. Only for test use.
*/
@VisibleForTesting
internal fun init(
className: String,
methodName: String,
parameterProvider: Class<out PreviewParameterProvider<*>>? = null,
parameterProviderIndex: Int = 0,
debugPaintBounds: Boolean = false,
debugViewInfos: Boolean = false,
animationClockStartTime: Long = -1,
forceCompositionInvalidation: Boolean = false,
onCommit: () -> Unit = {},
onDraw: () -> Unit = {}
) {
this.debugPaintBounds = debugPaintBounds
this.debugViewInfos = debugViewInfos
this.composableName = methodName
this.forceCompositionInvalidation = forceCompositionInvalidation
this.onDraw = onDraw
previewComposition = @Composable {
SideEffect {
onCommit()
}
WrapPreview {
val composer = currentComposer
// We need to delay the reflection instantiation of the class until we are in the
// composable to ensure all the right initialization has happened and the Composable
// class loads correctly.
val composable = {
try {
invokeComposableViaReflection(
className,
methodName,
composer,
*getPreviewProviderParameters(parameterProvider, parameterProviderIndex)
)
} catch (t: Throwable) {
// If there is an exception, store it for later but do not catch it so
// compose can handle it and dispose correctly.
var exception: Throwable = t
// Find the root cause and use that for the delayedException.
while (exception is ReflectiveOperationException) {
exception = exception.cause ?: break
}
delayedException.set(exception)
throw t
}
}
if (animationClockStartTime >= 0) {
// When animation inspection is enabled, i.e. when a valid (non-negative)
// `animationClockStartTime` is passed, set the Preview Animation Clock. This
// clock will control the animations defined in this `ComposeViewAdapter`
// from Android Studio.
clock = PreviewAnimationClock {
// Invalidate the descendants of this ComposeViewAdapter's only grandchild
// (an AndroidOwner) when setting the clock time to make sure the Compose
// Preview will animate when the states are read inside the draw scope.
val composeView = getChildAt(0) as ComposeView
(composeView.getChildAt(0) as? ViewRootForTest)
?.invalidateDescendants()
// Send pending apply notifications to ensure the animation duration will
// be read in the correct frame.
Snapshot.sendApplyNotifications()
}
}
composable()
}
}
composeView.setContent(previewComposition)
invalidate()
}
/**
* Disposes the Compose elements allocated during [init]
*/
internal fun dispose() {
composeView.disposeComposition()
if (::clock.isInitialized) {
clock.dispose()
}
}
/**
* Returns whether this `@Composable` has animations. This allows Android Studio to decide if
* the Animation Inspector icon should be displayed for this preview. The reason for using a
* method instead of the property directly is we use Java reflection to call it from Android
* Studio, and to find the property we'd need to filter the method names using `contains`
* instead of `equals`.
*
* @suppress
*/
fun hasAnimations() = hasAnimations
private fun init(attrs: AttributeSet) {
// ComposeView and lifecycle initialization
ViewTreeLifecycleOwner.set(this, FakeSavedStateRegistryOwner)
ViewTreeSavedStateRegistryOwner.set(this, FakeSavedStateRegistryOwner)
ViewTreeViewModelStoreOwner.set(this, FakeViewModelStoreOwner)
addView(composeView)
val composableName = attrs.getAttributeValue(TOOLS_NS_URI, "composableName") ?: return
val className = composableName.substringBeforeLast('.')
val methodName = composableName.substringAfterLast('.')
val parameterProviderIndex = attrs.getAttributeIntValue(
TOOLS_NS_URI,
"parameterProviderIndex", 0
)
val parameterProviderClass = attrs.getAttributeValue(TOOLS_NS_URI, "parameterProviderClass")
?.asPreviewProviderClass()
val animationClockStartTime = try {
attrs.getAttributeValue(TOOLS_NS_URI, "animationClockStartTime").toLong()
} catch (e: Exception) {
-1L
}
val forceCompositionInvalidation = attrs.getAttributeBooleanValue(
TOOLS_NS_URI,
"forceCompositionInvalidation", false
)
init(
className = className,
methodName = methodName,
parameterProvider = parameterProviderClass,
parameterProviderIndex = parameterProviderIndex,
debugPaintBounds = attrs.getAttributeBooleanValue(
TOOLS_NS_URI,
"paintBounds",
debugPaintBounds
),
debugViewInfos = attrs.getAttributeBooleanValue(
TOOLS_NS_URI,
"printViewInfos",
debugViewInfos
),
animationClockStartTime = animationClockStartTime,
forceCompositionInvalidation = forceCompositionInvalidation
)
}
private val FakeSavedStateRegistryOwner = object : SavedStateRegistryOwner {
private val lifecycle = LifecycleRegistry(this)
private val controller = SavedStateRegistryController.create(this).apply {
performRestore(Bundle())
}
init {
lifecycle.currentState = Lifecycle.State.RESUMED
}
override fun getSavedStateRegistry(): SavedStateRegistry = controller.savedStateRegistry
override fun getLifecycle(): Lifecycle = lifecycle
}
private val FakeViewModelStoreOwner = ViewModelStoreOwner {
throw IllegalStateException("ViewModels creation is not supported in Preview")
}
}