1 /*
2  * Copyright 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.compose.material3
18 
19 import androidx.compose.material3.internal.identityHashCode
20 import androidx.compose.runtime.ProvidableCompositionLocal
21 import androidx.compose.runtime.Stable
22 import androidx.compose.runtime.staticCompositionLocalOf
23 import androidx.compose.ui.Modifier
24 import androidx.compose.ui.layout.AlignmentLine
25 import androidx.compose.ui.layout.HorizontalAlignmentLine
26 import androidx.compose.ui.layout.Measurable
27 import androidx.compose.ui.layout.MeasureResult
28 import androidx.compose.ui.layout.MeasureScope
29 import androidx.compose.ui.layout.Placeable
30 import androidx.compose.ui.layout.VerticalAlignmentLine
31 import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
32 import androidx.compose.ui.node.LayoutModifierNode
33 import androidx.compose.ui.node.ModifierNodeElement
34 import androidx.compose.ui.node.currentValueOf
35 import androidx.compose.ui.platform.InspectorInfo
36 import androidx.compose.ui.platform.ViewConfiguration
37 import androidx.compose.ui.unit.Constraints
38 import androidx.compose.ui.unit.Dp
39 import androidx.compose.ui.unit.coerceAtLeast
40 import androidx.compose.ui.unit.dp
41 import androidx.compose.ui.unit.isSpecified
42 import androidx.compose.ui.util.fastRoundToInt
43 import kotlin.math.min
44 import kotlin.math.roundToInt
45 
46 /**
47  * Reserves at least 48.dp in size to disambiguate touch interactions if the element would measure
48  * smaller.
49  *
50  * [Target
51  * sizes](https://m3.material.io/foundations/designing/structure#dab862b1-e042-4c40-b680-b484b9f077f6)
52  *
53  * This uses the Material recommended minimum size of 48.dp x 48.dp, which may not the same as the
54  * system enforced minimum size. The minimum clickable / touch target size (48.dp by default) is
55  * controlled by the system via [ViewConfiguration] and automatically expanded at the touch input
56  * layer.
57  *
58  * This modifier is not needed for touch target expansion to happen. It only affects layout, to make
59  * sure there is adequate space for touch target expansion.
60  *
61  * Because layout constraints are affected by modifier order, for this modifier to take effect, it
62  * must come before any size modifiers on the element that might limit its constraints.
63  *
64  * @sample androidx.compose.material3.samples.MinimumInteractiveComponentSizeSample
65  * @sample androidx.compose.material3.samples.MinimumInteractiveComponentSizeCheckboxRowSample
66  * @see LocalMinimumInteractiveComponentSize
67  */
68 @Stable
minimumInteractiveComponentSizenull69 fun Modifier.minimumInteractiveComponentSize(): Modifier = this then MinimumInteractiveModifier
70 
71 internal object MinimumInteractiveModifier : ModifierNodeElement<MinimumInteractiveModifierNode>() {
72 
73     override fun create(): MinimumInteractiveModifierNode = MinimumInteractiveModifierNode()
74 
75     override fun update(node: MinimumInteractiveModifierNode) {}
76 
77     override fun InspectorInfo.inspectableProperties() {
78         name = "minimumInteractiveComponentSize"
79         // TODO: b/214589635 - surface this information through the layout inspector in a better way
80         //  - for now just add some information to help developers debug what this size represents.
81         properties["README"] =
82             "Reserves at least 48.dp in size to disambiguate touch " +
83                 "interactions if the element would measure smaller"
84     }
85 
86     override fun hashCode(): Int = identityHashCode(this)
87 
88     override fun equals(other: Any?) = (other === this)
89 }
90 
91 @Suppress("PrimitiveInCollection")
92 internal class MinimumInteractiveModifierNode :
93     Modifier.Node(), CompositionLocalConsumerModifierNode, LayoutModifierNode {
94 
95     private var alignmentLinesCache: MutableMap<AlignmentLine, Int>? = null
96 
97     @Suppress("PrimitiveInCollection")
measurenull98     override fun MeasureScope.measure(
99         measurable: Measurable,
100         constraints: Constraints
101     ): MeasureResult {
102         val size = currentValueOf(LocalMinimumInteractiveComponentSize).coerceAtLeast(0.dp)
103         val placeable = measurable.measure(constraints)
104         val enforcement = isAttached && (size.isSpecified && size > 0.dp)
105 
106         val sizePx = if (size.isSpecified) size.roundToPx() else 0
107         // Be at least as big as the minimum dimension in both dimensions
108         val width =
109             if (enforcement) {
110                 maxOf(placeable.width, sizePx)
111             } else {
112                 placeable.width
113             }
114         val height =
115             if (enforcement) {
116                 maxOf(placeable.height, sizePx)
117             } else {
118                 placeable.height
119             }
120 
121         if (enforcement) {
122             updateAlignmentLines(sizePx, placeable)
123         }
124 
125         return layout(
126             width = width,
127             height = height,
128             alignmentLines = alignmentLinesCache ?: emptyMap()
129         ) {
130             val centerX = ((width - placeable.width) / 2f).roundToInt()
131             val centerY = ((height - placeable.height) / 2f).roundToInt()
132             placeable.place(centerX, centerY)
133         }
134     }
135 
136     /**
137      * Updates the alignment lines cache based on the enforcement of minimum interactive size and
138      * the measured size of the placeable.
139      *
140      * If the enforced minimum size (`sizePx`) is larger than the placeable's width or height, it
141      * calculates the necessary alignment offsets and adds them to the cache. If the minimum size is
142      * not enforced or is smaller than the placeable's dimensions, it sets the alignment lines to 0.
143      *
144      * @param enforcement A boolean indicating whether the minimum interactive size is enforced.
145      * @param sizePx The minimum size in pixels that should be enforced.
146      * @param placeable The [Placeable] object representing the measured component.
147      */
updateAlignmentLinesnull148     private fun updateAlignmentLines(sizePx: Int, placeable: Placeable) {
149         val cache = getAlignmentLinesCache()
150         cache[MinimumInteractiveLeftAlignmentLine] =
151             ((sizePx - placeable.width) / 2f).fastRoundToInt().coerceAtLeast(0)
152         cache[MinimumInteractiveTopAlignmentLine] =
153             ((sizePx - placeable.height) / 2f).fastRoundToInt().coerceAtLeast(0)
154     }
155 
156     /**
157      * Returns a [MutableMap] that will act as a cache of alignment lines.
158      *
159      * In case it is null, it will be initialized and assigned to the [alignmentLinesCache].
160      */
getAlignmentLinesCachenull161     private fun getAlignmentLinesCache(): MutableMap<AlignmentLine, Int> =
162         alignmentLinesCache
163             ?: LinkedHashMap<AlignmentLine, Int>(2).also { alignmentLinesCache = it }
164 }
165 
166 internal val MinimumInteractiveTopAlignmentLine = HorizontalAlignmentLine(::min)
167 internal val MinimumInteractiveLeftAlignmentLine = VerticalAlignmentLine(::min)
168 
169 /**
170  * CompositionLocal that configures whether Material components that have a visual size that is
171  * lower than the minimum touch target size for accessibility (such as Button) will include extra
172  * space outside the component to ensure that they are accessible. If set to false there will be no
173  * extra space, and so it is possible that if the component is placed near the edge of a layout /
174  * near to another component without any padding, there will not be enough space for an accessible
175  * touch target.
176  */
177 @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
178 @get:ExperimentalMaterial3Api
179 @ExperimentalMaterial3Api
180 @Deprecated(
181     message = "Use LocalMinimumInteractiveComponentSize with 0.dp to turn off enforcement instead.",
182     replaceWith = ReplaceWith("LocalMinimumInteractiveComponentSize"),
183     level = DeprecationLevel.WARNING
184 )
185 val LocalMinimumInteractiveComponentEnforcement: ProvidableCompositionLocal<Boolean> =
<lambda>null186     staticCompositionLocalOf {
187         true
188     }
189 
190 /**
191  * CompositionLocal that configures the minimum touch target size for Material components (such as
192  * [Button]) to ensure they are accessible. If a component has a visual size that is lower than the
193  * minimum touch target size, extra space outside the component will be included. If set to 0.dp,
194  * there will be no extra space, and so it is possible that if the component is placed near the edge
195  * of a layout / near to another component without any padding, there will not be enough space for
196  * an accessible touch target.
197  */
198 val LocalMinimumInteractiveComponentSize: ProvidableCompositionLocal<Dp> =
<lambda>null199     staticCompositionLocalOf {
200         48.dp
201     }
202