ComposeView.android.kt

/*
 * 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.platform

import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Composition
import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.InternalComposeUiApi
import androidx.compose.ui.node.InternalCoreApi
import androidx.compose.ui.node.Owner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewTreeLifecycleOwner
import androidx.savedstate.ViewTreeSavedStateRegistryOwner

private const val MissingViewTreeDependenciesMessage =
    "If you are adding this ComposeView to an AppCompatActivity, make sure you " +
        "are using AppCompat version 1.3+. If you are adding this ComposeView to a " +
        "Fragment, make sure you are using Fragment version 1.3+. For other cases, manually " +
        "set owners on this view by using `ViewTreeLifecycleOwner.set()` and " +
        "`ViewTreeSavedStateRegistryOwner.set()`."

/**
 * Base class for custom [android.view.View]s implemented using Jetpack Compose UI.
 * Subclasses should implement the [Content] function with the appropriate content.
 * Calls to [addView] and its variants and overloads will fail with [IllegalStateException].
 *
 * This [android.view.View] requires that the window it is attached to contains a
 * [ViewTreeLifecycleOwner]. This [androidx.lifecycle.LifecycleOwner] is used to
 * [dispose][androidx.compose.runtime.Composition.dispose] of the underlying composition
 * when the host [Lifecycle] is destroyed, permitting the view to be attached and
 * detached repeatedly while preserving the composition. Call [disposeComposition]
 * to dispose of the underlying composition earlier, or if the view is never initially
 * attached to a window. (The requirement to dispose of the composition explicitly
 * in the event that the view is never (re)attached is temporary.)
 */
abstract class AbstractComposeView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {

    init {
        clipChildren = false
        clipToPadding = false
    }

    private var composition: Composition? = null

    private var parentContext: CompositionContext? = null
        set(value) {
            if (field !== value) {
                field = value
                val old = composition
                if (old !== null) {
                    old.dispose()
                    composition = null

                    // Recreate the composition now if we are attached.
                    if (isAttachedToWindow) {
                        ensureCompositionCreated()
                    }
                }
            }
        }

    /**
     * Set the [CompositionContext] that should be the parent of this view's composition.
     * If [parent] is `null` it will be determined automatically from the window the view is
     * attached to.
     */
    fun setParentCompositionContext(parent: CompositionContext?) {
        parentContext = parent
    }

    // Leaking `this` during init is generally dangerous, but we know that the implementation of
    // this particular ViewCompositionStrategy is not going to do something harmful with it.
    @Suppress("LeakingThis")
    private var disposeViewCompositionStrategy: (() -> Unit)? =
        ViewCompositionStrategy.DisposeOnDetachedFromWindow.installFor(this)

    /**
     * Set the strategy for managing disposal of this View's internal composition.
     * Defaults to [ViewCompositionStrategy.DisposeOnDetachedFromWindow].
     *
     * This View's composition is a live resource that must be disposed to ensure that
     * long-lived references to it do not persist
     *
     * See [ViewCompositionStrategy] for more information.
     */
    fun setViewCompositionStrategy(strategy: ViewCompositionStrategy) {
        disposeViewCompositionStrategy?.invoke()
        disposeViewCompositionStrategy = strategy.installFor(this)
    }

    /**
     * If `true`, this View's composition will be created when it becomes attached to a
     * window for the first time. Defaults to `true`.
     *
     * Subclasses may choose to override this property to prevent this eager initial composition
     * in cases where the view's content is not yet ready. Initial composition will still occur
     * when this view is first measured.
     */
    protected open val shouldCreateCompositionOnAttachedToWindow: Boolean
        get() = true

    /**
     * Enables the display of visual layout bounds for the Compose UI content of this view.
     * This is typically managed
     */
    @OptIn(InternalCoreApi::class)
    @InternalComposeUiApi
    var showLayoutBounds: Boolean = false
        set(value) {
            field = value
            getChildAt(0)?.let {
                (it as Owner).showLayoutBounds = value
            }
        }

    /**
     * The Jetpack Compose UI content for this view.
     * Subclasses must implement this method to provide content. Initial composition will
     * occur when the view becomes attached to a window or when [createComposition] is called,
     * whichever comes first.
     */
    @Composable
    abstract fun Content()

    /**
     * Perform initial composition for this view.
     * Once this method is called or the view becomes attached to a window,
     * either [disposeComposition] must be called or the [ViewTreeLifecycleOwner] must
     * reach the [Lifecycle.State.DESTROYED] state for the composition to be cleaned up
     * properly. (This restriction is temporary.)
     *
     * If this method is called when the composition has already been created it has no effect.
     *
     * This method should only be called if this view [isAttachedToWindow] or if a parent
     * [CompositionContext] has been [set][setParentCompositionContext] explicitly.
     */
    fun createComposition() {
        check(parentContext != null || isAttachedToWindow) {
            "createComposition requires either a parent reference or the View to be attached" +
                "to a window. Attach the View or call setParentCompositionReference."
        }
        ensureCompositionCreated()
    }

    private var creatingComposition = false
    private fun checkAddView() {
        if (!creatingComposition) {
            throw UnsupportedOperationException(
                "Cannot add views to " +
                    "${javaClass.simpleName}; only Compose content is supported"
            )
        }
    }

    private fun checkViewTreeOwners() {
        checkNotNull(ViewTreeLifecycleOwner.get(this)) {
            "ViewTreeLifecycleOwner not set for this ComposeView. " +
                MissingViewTreeDependenciesMessage
        }
        checkNotNull(ViewTreeSavedStateRegistryOwner.get(this)) {
            "ViewTreeSavedStateRegistryOwner not set for this ComposeView. " +
                MissingViewTreeDependenciesMessage
        }
        // Not checking for ViewTreeViewModelStoreOwner as we don't need it inside Compose,
        // but we provide it in ComponentActivity.setContent for convenience.
    }

    @Suppress("DEPRECATION") // Still using ViewGroup.setContent for now
    private fun ensureCompositionCreated() {
        if (composition == null) {
            if (isAttachedToWindow) {
                checkViewTreeOwners()
            }
            try {
                creatingComposition = true
                composition = setContent(
                    parentContext ?: findViewTreeCompositionContext() ?: windowRecomposer
                ) {
                    Content()
                }
            } finally {
                creatingComposition = false
            }
        }
    }

    /**
     * Dispose of the underlying composition and [requestLayout].
     * A new composition will be created if [createComposition] is called or when needed to
     * lay out this view.
     */
    fun disposeComposition() {
        composition?.dispose()
        composition = null
        requestLayout()
    }

    /**
     * `true` if this View is host to an active Compose UI composition.
     * An active composition may consume resources.
     */
    val hasComposition: Boolean get() = composition != null

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()

        if (composition != null) {
            checkViewTreeOwners()
        }

        if (shouldCreateCompositionOnAttachedToWindow) {
            ensureCompositionCreated()
        }
    }

    final override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        ensureCompositionCreated()
        val child = getChildAt(0)
        if (child == null) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec)
            return
        }

        val width = maxOf(0, MeasureSpec.getSize(widthMeasureSpec) - paddingLeft - paddingRight)
        val height = maxOf(0, MeasureSpec.getSize(heightMeasureSpec) - paddingTop - paddingBottom)
        child.measure(
            MeasureSpec.makeMeasureSpec(width, MeasureSpec.getMode(widthMeasureSpec)),
            MeasureSpec.makeMeasureSpec(height, MeasureSpec.getMode(heightMeasureSpec)),
        )
        setMeasuredDimension(
            child.measuredWidth + paddingLeft + paddingRight,
            child.measuredHeight + paddingTop + paddingBottom
        )
    }

    final override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        getChildAt(0)?.layout(
            paddingLeft,
            paddingTop,
            right - left - paddingRight,
            bottom - top - paddingBottom
        )
    }

    override fun onRtlPropertiesChanged(layoutDirection: Int) {
        // Force the single child for our composition to have the same LayoutDirection
        // that we do. We will get onRtlPropertiesChanged eagerly as the value changes,
        // but the composition child view won't until it measures. This can be too late
        // to catch the composition pass for that frame, so propagate it eagerly.
        getChildAt(0)?.layoutDirection = layoutDirection
    }

    // Below: enforce restrictions on adding child views to this ViewGroup

    override fun addView(child: View?) {
        checkAddView()
        super.addView(child)
    }

    override fun addView(child: View?, index: Int) {
        checkAddView()
        super.addView(child, index)
    }

    override fun addView(child: View?, width: Int, height: Int) {
        checkAddView()
        super.addView(child, width, height)
    }

    override fun addView(child: View?, params: LayoutParams?) {
        checkAddView()
        super.addView(child, params)
    }

    override fun addView(child: View?, index: Int, params: LayoutParams?) {
        checkAddView()
        super.addView(child, index, params)
    }

    override fun addViewInLayout(child: View?, index: Int, params: LayoutParams?): Boolean {
        checkAddView()
        return super.addViewInLayout(child, index, params)
    }

    override fun addViewInLayout(
        child: View?,
        index: Int,
        params: LayoutParams?,
        preventRequestLayout: Boolean
    ): Boolean {
        checkAddView()
        return super.addViewInLayout(child, index, params, preventRequestLayout)
    }
}

/**
 * A [android.view.View] that can host Jetpack Compose UI content.
 * Use [setContent] to supply the content composable function for the view.
 *
 * This [android.view.View] requires that the window it is attached to contains a
 * [ViewTreeLifecycleOwner]. This [androidx.lifecycle.LifecycleOwner] is used to
 * [dispose][androidx.compose.runtime.Composition.dispose] of the underlying composition
 * when the host [Lifecycle] is destroyed, permitting the view to be attached and
 * detached repeatedly while preserving the composition. Call [disposeComposition]
 * to dispose of the underlying composition earlier, or if the view is never initially
 * attached to a window. (The requirement to dispose of the composition explicitly
 * in the event that the view is never (re)attached is temporary.)
 */
class ComposeView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : AbstractComposeView(context, attrs, defStyleAttr) {

    private val content = mutableStateOf<(@Composable () -> Unit)?>(null)

    @Suppress("RedundantVisibilityModifier")
    protected override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
        private set

    @Composable
    override fun Content() {
        content.value?.invoke()
    }

    /**
     * Set the Jetpack Compose UI content for this view.
     * Initial composition will occur when the view becomes attached to a window or when
     * [createComposition] is called, whichever comes first.
     */
    fun setContent(content: @Composable () -> Unit) {
        shouldCreateCompositionOnAttachedToWindow = true
        this.content.value = content
        if (isAttachedToWindow) {
            createComposition()
        }
    }
}