RippleContainer.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.content.Context
import android.view.ViewGroup
import androidx.compose.ui.R

/**
 * A root-level container [ViewGroup] that manages creating and assigning [RippleHostView]s used
 * throughout a Compose hierarchy. Each root Compose View that has components that use ripples
 * inside will have a [RippleContainer] as a direct child.
 */
internal class RippleContainer(context: Context) : ViewGroup(context) {
    /**
     * Maximum number of [RippleHostView]s that will be allocated and added, limiting the total
     * number of Views attached to the root Compose View.
     */
    private val MaxRippleHosts = 5

    /**
     * [RippleHostView]s that will be assigned to [AndroidRippleIndicationInstance]s when
     * necessary.
     */
    private val rippleHosts = mutableListOf<RippleHostView>()

    /**
     * [RippleHostView]s that are not currently assigned to any
     * [AndroidRippleIndicationInstance], so they can be reused without needing to allocate new
     * instances.
     */
    private val unusedRippleHosts = mutableListOf<RippleHostView>()

    private val rippleHostMap = RippleHostMap()

    /**
     * Index of the next host that will be assigned to a ripple
     */
    private var nextHostIndex = 0

    init {
        clipChildren = false

        // Start by initially assigning one RippleHostView - we will allocate more when needed.
        // We start by only assigning one to avoid creating a lot of unused Views for cases where
        // there are multiple Compose roots inside a hierarchy, such as when putting Compose
        // roots inside a RecyclerView.
        val initialHostView = RippleHostView(context).also { addView(it) }
        rippleHosts.add(initialHostView)
        unusedRippleHosts.add(initialHostView)
        // Since we now have an unused ripple host, the next index should be 1 - the unused host
        // will be used first.
        nextHostIndex = 1

        // Hide this view and its children in tools:
        setTag(R.id.hide_in_inspector_tag, true)
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        // RippleHostViews don't partake in layout
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // RippleHostViews don't partake in measurement
        setMeasuredDimension(0, 0)
    }

    /**
     * @return a [RippleHostView] for [this] [AndroidRippleIndicationInstance]. This result will
     * be cached if possible, to allow re-using the same [RippleHostView].
     */
    fun AndroidRippleIndicationInstance.getRippleHostView(): RippleHostView {
        val existingRippleHostView = rippleHostMap[this]
        if (existingRippleHostView != null) {
            return existingRippleHostView
        }

        // If we have an unused RippleHostView, use that before creating a new one
        var rippleHostView = unusedRippleHosts.removeFirstOrNull()

        if (rippleHostView == null) {
            // If the next host is larger than the current index, we haven't reached maximum
            // capacity yet and so we need to allocate a new RippleHostView
            rippleHostView = if (nextHostIndex > rippleHosts.lastIndex) {
                RippleHostView(context).also {
                    // Add this host to the view hierarchy
                    addView(it)
                    // And add it to the list of hosts
                    rippleHosts += it
                }
            } else {
                // Otherwise we are looping through the current hosts and re-using an existing,
                // un-disposed host
                val host = rippleHosts[nextHostIndex]

                // Since this host was re-used, and not in the unused host list, it may still be
                // linked to an instance
                val existingInstance = rippleHostMap[host]

                // TODO: possible future optimization
                //  Consider checking to see if the existing ripple is still drawing, and if so,
                //  create a new RippleHostView one instead of reassigning
                if (existingInstance != null) {
                    existingInstance.resetHostView()
                    rippleHostMap.remove(existingInstance)
                    host.disposeRipple()
                }
                host
            }

            // Update the index for the next host - loop around if we reach the maximum capacity
            if (nextHostIndex < MaxRippleHosts - 1) {
                nextHostIndex++
            } else {
                nextHostIndex = 0
            }
        }

        rippleHostMap[this] = rippleHostView

        return rippleHostView
    }

    /**
     * Unassigns the current [RippleHostView] from [this] [AndroidRippleIndicationInstance] and
     * resets its state, so it can be used by another [AndroidRippleIndicationInstance].
     */
    fun AndroidRippleIndicationInstance.disposeRippleIfNeeded() {
        resetHostView()
        val rippleHost = rippleHostMap[this]

        if (rippleHost != null) {
            rippleHost.disposeRipple()
            rippleHostMap.remove(this)
            // This ripple host has been disposed, so it is safe to be re-used
            unusedRippleHosts.add(rippleHost)
        }
    }
}

/**
 * Simple bidirectional map for [AndroidRippleIndicationInstance] : [RippleHostView].
 */
private class RippleHostMap {
    private val indicationToHostMap =
        mutableMapOf<AndroidRippleIndicationInstance, RippleHostView>()
    private val hostToIndicationMap =
        mutableMapOf<RippleHostView, AndroidRippleIndicationInstance>()

    operator fun set(
        indicationInstance: AndroidRippleIndicationInstance,
        rippleHostView: RippleHostView
    ) {
        indicationToHostMap[indicationInstance] = rippleHostView
        hostToIndicationMap[rippleHostView] = indicationInstance
    }

    operator fun get(indicationInstance: AndroidRippleIndicationInstance): RippleHostView? {
        return indicationToHostMap[indicationInstance]
    }

    operator fun get(rippleHostView: RippleHostView): AndroidRippleIndicationInstance? {
        return hostToIndicationMap[rippleHostView]
    }

    fun remove(indicationInstance: AndroidRippleIndicationInstance) {
        indicationToHostMap[indicationInstance]?.let { hostToIndicationMap.remove(it) }
        indicationToHostMap.remove(indicationInstance)
    }
}