Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 59755c08 authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Introduce SizeMatcher

This CL introduces SizeMatcher, a workaround for b/347910697 and
b/347906150 that allows to mirror the size of a node into another node.

Bug: 347906150
Test: atest SizeMatcherTest
Flag: com.android.systemui.scene_container
Change-Id: I9c0d0e5a2a5298b2d9a8089e80e6a5c2c779ae2d
parent 076e6fc6
Loading
Loading
Loading
Loading
+202 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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 com.android.compose.animation.scene.modifiers

import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.invalidateMeasurement
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.constrain

/**
 * An object to make one or more destination node be the same size as a source node.
 *
 * Important: Most of the time, you should not use this class and instead use `Box` together with
 * `Modifier.matchParentSize()`. Use this only if you need to use both `Modifier.element()` and
 * `Modifier.matchParentSize()` (see b/347910697 for details).
 *
 * Example:
 * ```
 * Box {
 *     val sizeMatcher = remember { SizeMatcher() }
 *
 *     // The content.
 *     Content(Modifier.sizeMatcherSource(sizeMatcher))
 *
 *     // The background. Note that this has to be composed after Content() so that it
 *     // is measured after it. We set its zIndex to -1 so that it is still placed
 *     // (drawn) before/below it. We don't use BoxScope.matchParentSize() because it
 *     // does not play well with Modifier.element().
 *     Box(
 *         Modifier.zIndex(-1f)
 *             .element(Background)
 *             // Set the preferred size of this element.
 *             // Important: This must be *after* the Modifier.element() so that
 *             // Modifier.element() can override the size.
 *             .sizeMatcherDestination(sizeMatcher)
 *             .background(
 *                 MaterialTheme.colorScheme.primaryContainer,
 *                 RoundedCornerShape(32.dp),
 *             )
 *     )
 * }
 * ```
 *
 * @see sizeMatcherSource
 * @see sizeMatcherDestination
 */
class SizeMatcher {
    internal var source: LayoutModifierNode? = null
        set(value) {
            if (value != null && field != null && value != field) {
                error("Exactly one Modifier.sizeMatcherSource() should be specified")
            }
        }

    internal var destinations = mutableSetOf<LayoutModifierNode>()
    internal var sourceSize: IntSize = InvalidSize
        get() {
            if (field == InvalidSize) {
                error(
                    "SizeMatcher size was retrieved before it was set. You should make sure that " +
                        "all matcher destination are measured *after* the matcher source."
                )
            }
            return field
        }
        set(value) {
            if (value != field) {
                field = value
                destinations.forEach { it.invalidateMeasurement() }
            }
        }

    companion object {
        private val InvalidSize = IntSize(Int.MIN_VALUE, Int.MIN_VALUE)
    }
}

/**
 * Mark this node as the source of a [SizeMatcher].
 *
 * Important: There must be only a single source node associated to a [SizeMatcher] and it must be
 * measured before any destination.
 */
fun Modifier.sizeMatcherSource(matcher: SizeMatcher): Modifier {
    return this.then(SizeMatcherSourceNodeElement(matcher))
}

/**
 * Mark this node as the destination of a [SizeMatcher] so that its *preferred* size is the same
 * size as the source size.
 *
 * Important: Destination nodes must be measured *after* the source node, otherwise it might cause
 * crashes or 1-frame flickers. For most simple layouts (like Box, Row or Column), this usually
 * means that the destinations nodes must be composed *after* the source node. If doing so is
 * causing layering issues, you can use `Modifier.zIndex` to explicitly set the placement order of
 * your composables.
 */
fun Modifier.sizeMatcherDestination(matcher: SizeMatcher): Modifier {
    return this.then(SizeMatcherDestinationElement(matcher))
}

private data class SizeMatcherSourceNodeElement(
    private val matcher: SizeMatcher,
) : ModifierNodeElement<SizeMatcherSourceNode>() {
    override fun create(): SizeMatcherSourceNode = SizeMatcherSourceNode(matcher)

    override fun update(node: SizeMatcherSourceNode) {
        node.update(matcher)
    }
}

private class SizeMatcherSourceNode(
    private var matcher: SizeMatcher,
) : Modifier.Node(), LayoutModifierNode {
    override fun onAttach() {
        matcher.source = this
    }

    override fun onDetach() {
        matcher.source = null
    }

    fun update(matcher: SizeMatcher) {
        val previous = this.matcher
        this.matcher = matcher

        previous.source = null
        matcher.source = this
    }

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        return measurable.measure(constraints).run {
            matcher.sourceSize = IntSize(width, height)
            layout(width, height) { place(0, 0) }
        }
    }
}

private data class SizeMatcherDestinationElement(
    private val matcher: SizeMatcher,
) : ModifierNodeElement<SizeMatcherDestinationNode>() {
    override fun create(): SizeMatcherDestinationNode = SizeMatcherDestinationNode(matcher)

    override fun update(node: SizeMatcherDestinationNode) {
        node.update(matcher)
    }
}

private class SizeMatcherDestinationNode(
    private var matcher: SizeMatcher,
) : Modifier.Node(), LayoutModifierNode {
    override fun onAttach() {
        this.matcher.destinations.add(this)
    }

    override fun onDetach() {
        this.matcher.destinations.remove(this)
    }

    fun update(matcher: SizeMatcher) {
        val previous = this.matcher
        this.matcher = matcher

        previous.destinations.remove(this)
        matcher.destinations.add(this)
    }

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val preferredSize = matcher.sourceSize
        val preferredConstraints = Constraints.fixed(preferredSize.width, preferredSize.height)

        // Make sure we still respect the incoming constraints.
        val placeable = measurable.measure(constraints.constrain(preferredConstraints))
        return layout(placeable.width, placeable.height) { placeable.place(0, 0) }
    }
}
+52 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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 com.android.compose.animation.scene.modifiers

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.compose.test.assertSizeIsEqualTo
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class SizeMatcherTest {
    @get:Rule val rule = createComposeRule()

    @Test
    fun sizeMatcher() {
        val contentSize = DpSize(200.dp, 100.dp)
        val sizeMatcher = SizeMatcher()
        val backgroundTag = "background"

        rule.setContent {
            Box {
                Box(Modifier.sizeMatcherSource(sizeMatcher).size(contentSize))
                Box(Modifier.testTag(backgroundTag).sizeMatcherDestination(sizeMatcher))
            }
        }

        rule.onNodeWithTag(backgroundTag).assertSizeIsEqualTo(contentSize.width, contentSize.height)
    }
}