/*
* Copyright 2020 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.viewinterop
import android.content.Context
import android.graphics.Rect
import android.graphics.Region
import android.os.Looper
import android.view.View
import android.view.ViewGroup
import android.view.ViewParent
import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.snapshots.SnapshotStateObserver
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.layout.IntrinsicMeasurable
import androidx.compose.ui.layout.IntrinsicMeasureScope
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasurePolicy
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.node.LayoutNode
import androidx.compose.ui.platform.AndroidComposeView
import androidx.compose.ui.platform.compositionContext
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import kotlin.math.roundToInt
/**
* A base class used to host a [View] inside Compose.
* This API is not designed to be used directly, but rather using the [AndroidView] and
* `AndroidViewBinding` APIs, which are built on top of [AndroidViewHolder].
*/
@OptIn(ExperimentalComposeUiApi::class)
internal abstract class AndroidViewHolder(
context: Context,
parentContext: CompositionContext?
) : ViewGroup(context) {
init {
// Any [Abstract]ComposeViews that are descendants of this view will host
// subcompositions of the host composition.
// UiApplier doesn't supply this, only AndroidView.
parentContext?.let {
compositionContext = it
}
// We save state ourselves, depending on composition.
isSaveFromParentEnabled = false
}
/**
* The view hosted by this holder.
*/
var view: View? = null
internal set(value) {
if (value !== field) {
field = value
removeAllViews()
if (value != null) {
addView(value)
runUpdate()
}
}
}
/**
* The update logic of the [View].
*/
var update: () -> Unit = {}
protected set(value) {
field = value
hasUpdateBlock = true
runUpdate()
}
private var hasUpdateBlock = false
/**
* The modifier of the `LayoutNode` corresponding to this [View].
*/
var modifier: Modifier = Modifier
set(value) {
if (value !== field) {
field = value
onModifierChanged?.invoke(value)
}
}
internal var onModifierChanged: ((Modifier) -> Unit)? = null
/**
* The screen density of the layout.
*/
var density: Density = Density(1f)
set(value) {
if (value !== field) {
field = value
onDensityChanged?.invoke(value)
}
}
internal var onDensityChanged: ((Density) -> Unit)? = null
private val snapshotObserver = SnapshotStateObserver { command ->
if (handler.looper === Looper.myLooper()) {
command()
} else {
handler.post(command)
}
}
private val onCommitAffectingUpdate: (AndroidViewHolder) -> Unit = {
handler.post(runUpdate)
}
private val runUpdate: () -> Unit = {
if (hasUpdateBlock) {
snapshotObserver.observeReads(this, onCommitAffectingUpdate, update)
}
}
internal var onRequestDisallowInterceptTouchEvent: ((Boolean) -> Unit)? = null
private val location = IntArray(2)
private var lastWidthMeasureSpec: Int = Unmeasured
private var lastHeightMeasureSpec: Int = Unmeasured
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
view?.measure(widthMeasureSpec, heightMeasureSpec)
setMeasuredDimension(view?.measuredWidth ?: 0, view?.measuredHeight ?: 0)
lastWidthMeasureSpec = widthMeasureSpec
lastHeightMeasureSpec = heightMeasureSpec
}
fun remeasure() {
if (lastWidthMeasureSpec == Unmeasured || lastHeightMeasureSpec == Unmeasured) {
// This should never happen: it means that the views handler was measured without
// the AndroidComposeView having been measured.
return
}
measure(lastWidthMeasureSpec, lastHeightMeasureSpec)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
view?.layout(0, 0, r - l, b - t)
}
override fun getLayoutParams(): LayoutParams? {
return view?.layoutParams
?: LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
}
override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
onRequestDisallowInterceptTouchEvent?.invoke(disallowIntercept)
super.requestDisallowInterceptTouchEvent(disallowIntercept)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
snapshotObserver.start()
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
snapshotObserver.stop()
// remove all observations:
snapshotObserver.clear()
}
// When there is no hardware acceleration invalidates are intercepted using this method,
// otherwise using onDescendantInvalidated. Return null to avoid invalidating the
// AndroidComposeView or the handler.
@Suppress("Deprecation")
override fun invalidateChildInParent(location: IntArray?, dirty: Rect?): ViewParent? {
super.invalidateChildInParent(location, dirty)
layoutNode.invalidateLayer()
return null
}
override fun onDescendantInvalidated(child: View, target: View) {
// We need to call super here in order to correctly update the dirty flags of the holder.
super.onDescendantInvalidated(child, target)
layoutNode.invalidateLayer()
}
// Always mark the region of the View to not be transparent to disable an optimisation which
// would otherwise cause certain buggy drawing scenarios. For example, Compose drawing on top
// of SurfaceViews included in Compose would sometimes not be displayed, as the drawing is
// not done by Views, therefore the area is not known as non-transparent to the View system.
override fun gatherTransparentRegion(region: Region?): Boolean {
if (region == null) return true
getLocationInWindow(location)
region.op(
location[0],
location[1],
location[0] + width,
location[1] + height,
Region.Op.DIFFERENCE
)
return true
}
/**
* A [LayoutNode] tree representation for this Android [View] holder.
* The [LayoutNode] will proxy the Compose core calls to the [View].
*/
val layoutNode: LayoutNode = run {
// Prepare layout node that proxies measure and layout passes to the View.
val layoutNode = LayoutNode()
val coreModifier = Modifier
.pointerInteropFilter(this)
.drawBehind {
drawIntoCanvas { canvas ->
(layoutNode.owner as? AndroidComposeView)
?.drawAndroidView(this@AndroidViewHolder, canvas.nativeCanvas)
}
}.onGloballyPositioned {
// The global position of this LayoutNode can change with it being replaced. For
// these cases, we need to inform the View.
layoutAccordingTo(layoutNode)
}
layoutNode.modifier = modifier.then(coreModifier)
onModifierChanged = { layoutNode.modifier = it.then(coreModifier) }
layoutNode.density = density
onDensityChanged = { layoutNode.density = it }
var viewRemovedOnDetach: View? = null
layoutNode.onAttach = { owner ->
(owner as? AndroidComposeView)?.addAndroidView(this, layoutNode)
if (viewRemovedOnDetach != null) view = viewRemovedOnDetach
}
layoutNode.onDetach = { owner ->
(owner as? AndroidComposeView)?.removeAndroidView(this)
viewRemovedOnDetach = view
view = null
}
layoutNode.measurePolicy = object : MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
if (constraints.minWidth != 0) {
getChildAt(0).minimumWidth = constraints.minWidth
}
if (constraints.minHeight != 0) {
getChildAt(0).minimumHeight = constraints.minHeight
}
measure(
obtainMeasureSpec(
constraints.minWidth,
constraints.maxWidth,
layoutParams!!.width
),
obtainMeasureSpec(
constraints.minHeight,
constraints.maxHeight,
layoutParams!!.height
)
)
return layout(measuredWidth, measuredHeight) {
layoutAccordingTo(layoutNode)
}
}
override fun IntrinsicMeasureScope.minIntrinsicWidth(
measurables: List<IntrinsicMeasurable>,
height: Int
) = intrinsicWidth(height)
override fun IntrinsicMeasureScope.maxIntrinsicWidth(
measurables: List<IntrinsicMeasurable>,
height: Int
) = intrinsicWidth(height)
private fun intrinsicWidth(height: Int): Int {
measure(
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
obtainMeasureSpec(0, height, layoutParams!!.height)
)
return measuredWidth
}
override fun IntrinsicMeasureScope.minIntrinsicHeight(
measurables: List<IntrinsicMeasurable>,
width: Int
) = intrinsicHeight(width)
override fun IntrinsicMeasureScope.maxIntrinsicHeight(
measurables: List<IntrinsicMeasurable>,
width: Int
) = intrinsicHeight(width)
private fun intrinsicHeight(width: Int): Int {
measure(
obtainMeasureSpec(0, width, layoutParams!!.width),
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
)
return measuredHeight
}
}
layoutNode
}
/**
* Intersects [Constraints] and [View] LayoutParams to obtain the suitable [View.MeasureSpec]
* for measuring the [View].
*/
private fun obtainMeasureSpec(
min: Int,
max: Int,
preferred: Int
): Int = when {
preferred >= 0 || min == max -> {
// Fixed size due to fixed size layout param or fixed constraints.
MeasureSpec.makeMeasureSpec(preferred.coerceIn(min, max), MeasureSpec.EXACTLY)
}
preferred == LayoutParams.WRAP_CONTENT && max != Constraints.Infinity -> {
// Wrap content layout param with finite max constraint. If max constraint is infinite,
// we will measure the child with UNSPECIFIED.
MeasureSpec.makeMeasureSpec(max, MeasureSpec.AT_MOST)
}
preferred == LayoutParams.MATCH_PARENT && max != Constraints.Infinity -> {
// Match parent layout param, so we force the child to fill the available space.
MeasureSpec.makeMeasureSpec(max, MeasureSpec.EXACTLY)
}
else -> {
// max constraint is infinite and layout param is WRAP_CONTENT or MATCH_PARENT.
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
}
}
}
private fun View.layoutAccordingTo(layoutNode: LayoutNode) {
val position = layoutNode.coordinates.positionInRoot()
val x = position.x.roundToInt()
val y = position.y.roundToInt()
layout(x, y, x + measuredWidth, y + measuredHeight)
}
private const val Unmeasured = Int.MIN_VALUE