Ripple.android.kt

/*
 * 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.material.ripple

import android.graphics.drawable.RippleDrawable
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.runtime.Composable
import androidx.compose.runtime.RememberObserver
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.isUnspecified
import kotlinx.coroutines.CoroutineScope
import kotlin.math.roundToInt

/**
 * Android specific Ripple implementation that uses a [RippleDrawable] under the hood, which allows
 * rendering the ripple animation on the render thread (away from the main UI thread). This
 * allows the ripple to animate smoothly even while the UI thread is under heavy load, such as
 * when navigating between complex screens.
 *
 * @see Ripple
 */
@Stable
internal actual class PlatformRipple actual constructor(
    bounded: Boolean,
    radius: Dp,
    color: State<Color>
) : Ripple(bounded, radius, color) {
    @Composable
    override fun rememberUpdatedRippleInstance(
        interactionSource: InteractionSource,
        bounded: Boolean,
        radius: Dp,
        color: State<Color>,
        rippleAlpha: State<RippleAlpha>
    ): RippleIndicationInstance {
        val view = findNearestViewGroup()
        // TODO(b/188112048): Remove isInEditMode once RenderThread support is fixed in Layoutlib.
        if (view.isInEditMode) {
            return remember(interactionSource, this) {
                CommonRippleIndicationInstance(bounded, radius, color, rippleAlpha)
            }
        }

        // Create or get the RippleContainer attached to the nearest root Compose view

        var rippleContainer: RippleContainer? = null

        for (index in 0 until view.childCount) {
            val child = view.getChildAt(index)
            if (child is RippleContainer) {
                rippleContainer = child
                break
            }
        }

        if (rippleContainer == null) {
            rippleContainer = RippleContainer(view.context).apply {
                view.addView(this)
            }
        }

        return remember(interactionSource, this, rippleContainer) {
            AndroidRippleIndicationInstance(bounded, radius, color, rippleAlpha, rippleContainer)
        }
    }

    /**
     * Returns [LocalView] if it is a [ViewGroup], otherwise the nearest parent [ViewGroup] that
     * we will add a [RippleContainer] to.
     *
     * In all normal scenarios this should just be [LocalView], but since [LocalView] is public
     * API theoretically its value can be overridden with a non-[ViewGroup], so we walk up the
     * tree to be safe.
     */
    @Composable
    private fun findNearestViewGroup(): ViewGroup {
        var view: View = LocalView.current
        while (view !is ViewGroup) {
            val parent = view.parent
            // We should never get to a ViewParent that isn't a View, without finding a ViewGroup
            // first - throw an exception if we do.
            require(parent is View) {
                "Couldn't find a valid parent for $view. Are you overriding LocalView and " +
                    "providing a View that is not attached to the view hierarchy?"
            }
            view = parent
        }
        return view
    }
}

/**
 * Android specific [RippleIndicationInstance]. This uses a [RippleHostView] provided by
 * [rippleContainer] to draw ripples in the drawing bounds provided within [drawIndication].
 *
 * The state layer is still handled by [drawStateLayer], and drawn inside Compose.
 */
internal class AndroidRippleIndicationInstance(
    private val bounded: Boolean,
    private val radius: Dp,
    private val color: State<Color>,
    private val rippleAlpha: State<RippleAlpha>,
    private val rippleContainer: RippleContainer
) : RippleIndicationInstance(bounded, rippleAlpha), RememberObserver {
    /**
     * Backing [RippleHostView] used to draw ripples for this [RippleIndicationInstance].
     * [mutableStateOf] as we want changes to this to invalidate drawing, and cause us to draw /
     * stop drawing a ripple.
     */
    private var rippleHostView: RippleHostView? by mutableStateOf(null)

    /**
     * State we use to cause invalidations in Compose when the drawable requests an invalidation -
     * since we read this in the draw scope this is equivalent to manually invalidating the internal
     * layer. This is needed as layers internal to the underlying LayoutNode, which we also
     * cannot access from here.
     */
    private var invalidateTick by mutableStateOf(true)

    /**
     * Cache the size of the canvas we will draw the ripple into - this is updated each time
     * [drawIndication] is called. This is needed as before we start animating the ripple, we
     * need to know its size (changing the bounds mid-animation will cause us to continue the
     * animation on the UI thread, not the render thread), but the size is only known inside the
     * draw scope.
     */
    private var rippleSize: Size = Size.Zero

    private var rippleRadius: Int = -1

    /**
     * Flip [invalidateTick] to cause a re-draw when the ripple requests invalidation.
     */
    private val onInvalidateRipple = {
        invalidateTick = !invalidateTick
    }

    override fun ContentDrawScope.drawIndication() {
        // Update size and radius properties needed by addRipple()

        rippleSize = size

        rippleRadius = if (radius.isUnspecified) {
            // Explicitly calculate the radius instead of using RippleDrawable.RADIUS_AUTO
            // since the latest spec does not match with the existing radius calculation in the
            // framework.
            getRippleEndRadius(bounded, size).roundToInt()
        } else {
            radius.roundToPx()
        }

        val color = color.value
        val alpha = rippleAlpha.value.pressedAlpha

        drawContent()
        drawStateLayer(radius, color)
        drawIntoCanvas { canvas ->
            // Reading this ensures that we invalidate when the drawable requests invalidation
            invalidateTick

            rippleHostView?.run {
                // We set these inside addRipple() already, but they may change during the ripple
                // animation, so update them here too.
                // Note that changes to color / alpha will not be reflected in any
                // currently drawn ripples if the ripples are being drawn on the RenderThread,
                // since only the software paint is updated, not the hardware paint used in
                // RippleForeground.
                updateRippleProperties(
                    size = size,
                    radius = rippleRadius,
                    color = color,
                    alpha = alpha
                )

                draw(canvas.nativeCanvas)
            }
        }
    }

    override fun addRipple(interaction: PressInteraction.Press, scope: CoroutineScope) {
        rippleHostView = with(rippleContainer) {
            getRippleHostView().apply {
                addRipple(
                    interaction = interaction,
                    bounded = bounded,
                    size = rippleSize,
                    radius = rippleRadius,
                    color = color.value,
                    alpha = rippleAlpha.value.pressedAlpha,
                    onInvalidateRipple = onInvalidateRipple
                )
            }
        }
    }

    override fun removeRipple(interaction: PressInteraction.Press) {
        rippleHostView?.removeRipple()
    }

    override fun onRemembered() {}

    override fun onForgotten() {
        dispose()
    }

    override fun onAbandoned() {
        dispose()
    }

    private fun dispose() {
        with(rippleContainer) {
            disposeRippleIfNeeded()
        }
    }

    /**
     * Remove the reference to the existing host view, so we don't incorrectly draw if it is
     * recycled and used by another [RippleIndicationInstance].
     */
    fun resetHostView() {
        rippleHostView = null
    }
}