1 /*
<lambda>null2  * Copyright 2024 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.foundation.interaction.Interaction
20 import androidx.compose.foundation.interaction.MutableInteractionSource
21 import androidx.compose.foundation.layout.WindowInsets
22 import androidx.compose.foundation.layout.WindowInsetsSides
23 import androidx.compose.foundation.layout.defaultMinSize
24 import androidx.compose.foundation.layout.only
25 import androidx.compose.foundation.layout.windowInsetsPadding
26 import androidx.compose.foundation.selection.selectableGroup
27 import androidx.compose.material3.internal.systemBarsForVisualComponents
28 import androidx.compose.material3.tokens.NavigationBarHorizontalItemTokens
29 import androidx.compose.material3.tokens.NavigationBarTokens
30 import androidx.compose.material3.tokens.NavigationBarVerticalItemTokens
31 import androidx.compose.runtime.Composable
32 import androidx.compose.runtime.remember
33 import androidx.compose.ui.Modifier
34 import androidx.compose.ui.graphics.Color
35 import androidx.compose.ui.layout.Layout
36 import androidx.compose.ui.layout.Measurable
37 import androidx.compose.ui.layout.MeasurePolicy
38 import androidx.compose.ui.layout.MeasureResult
39 import androidx.compose.ui.layout.MeasureScope
40 import androidx.compose.ui.layout.Placeable
41 import androidx.compose.ui.unit.Constraints
42 import androidx.compose.ui.unit.Dp
43 import androidx.compose.ui.unit.constrain
44 import androidx.compose.ui.unit.dp
45 import androidx.compose.ui.util.fastForEach
46 import androidx.compose.ui.util.fastMap
47 import kotlin.jvm.JvmInline
48 import kotlin.math.roundToInt
49 
50 /**
51  * Material Design short navigation bar.
52  *
53  * Short navigation bars offer a persistent and convenient way to switch between primary
54  * destinations in an app.
55  *
56  * ![Short navigation bar with vertical items
57  * image](https://developer.android.com/images/reference/androidx/compose/material3/short-navigation-bar-vertical-items.png)
58  *
59  * ![Short navigation bar with horizontal items
60  * image](https://developer.android.com/images/reference/androidx/compose/material3/short-navigation-bar-horizontal-items.png)
61  *
62  * The recommended configuration of the [ShortNavigationBar] depends on the width size of the screen
63  * it's being displayed at:
64  * - In small screens, the [ShortNavigationBar] should contain three to five
65  *   [ShortNavigationBarItem]s, each representing a singular destination, and its [arrangement]
66  *   should be [ShortNavigationBarArrangement.EqualWeight], so that the navigation items are equally
67  *   distributed on the bar.
68  * - In medium screens, [ShortNavigationBar] should contain three to six [ShortNavigationBarItem]s,
69  *   each representing a singular destination, and its [arrangement] should be
70  *   [ShortNavigationBarArrangement.Centered], so that the navigation items are distributed grouped
71  *   on the center of the bar.
72  *
73  * A simple example of the first configuration looks like this:
74  *
75  * @sample androidx.compose.material3.samples.ShortNavigationBarSample
76  *
77  * And of the second configuration:
78  *
79  * @sample androidx.compose.material3.samples.ShortNavigationBarWithHorizontalItemsSample
80  *
81  * See [ShortNavigationBarItem] for configurations specific to each item, and not the overall
82  * [ShortNavigationBar] component.
83  *
84  * @param modifier the [Modifier] to be applied to this navigation bar
85  * @param containerColor the color used for the background of this navigation bar. Use
86  *   [Color.Transparent] to have no color
87  * @param contentColor the color for content inside this navigation bar.
88  * @param windowInsets a window insets of the navigation bar
89  * @param arrangement the [ShortNavigationBarArrangement] of this navigation bar
90  * @param content the content of this navigation bar, typically [ShortNavigationBarItem]s
91  */
92 @ExperimentalMaterial3ExpressiveApi
93 @Composable
94 fun ShortNavigationBar(
95     modifier: Modifier = Modifier,
96     containerColor: Color = ShortNavigationBarDefaults.containerColor,
97     contentColor: Color = ShortNavigationBarDefaults.contentColor,
98     windowInsets: WindowInsets = ShortNavigationBarDefaults.windowInsets,
99     arrangement: ShortNavigationBarArrangement = ShortNavigationBarDefaults.arrangement,
100     content: @Composable () -> Unit
101 ) {
102     Surface(
103         color = containerColor,
104         contentColor = contentColor,
105     ) {
106         Layout(
107             modifier =
108                 modifier
109                     .windowInsetsPadding(windowInsets)
110                     .defaultMinSize(minHeight = NavigationBarTokens.ContainerHeight)
111                     .selectableGroup(),
112             content = content,
113             measurePolicy =
114                 when (arrangement) {
115                     ShortNavigationBarArrangement.EqualWeight -> {
116                         EqualWeightContentMeasurePolicy()
117                     }
118                     ShortNavigationBarArrangement.Centered -> {
119                         CenteredContentMeasurePolicy()
120                     }
121                     else -> {
122                         throw IllegalArgumentException("Invalid ItemsArrangement value.")
123                     }
124                 }
125         )
126     }
127 }
128 
129 /** Class that describes the different supported item arrangements of the [ShortNavigationBar]. */
130 @JvmInline
131 value class ShortNavigationBarArrangement private constructor(private val value: Int) {
132     companion object {
133         /*
134          * The items are equally distributed on the Short Navigation Bar.
135          *
136          * This configuration is recommended for small width screens.
137          */
138         val EqualWeight = ShortNavigationBarArrangement(0)
139 
140         /*
141          * The items are centered on the Short Navigation Bar.
142          *
143          * This configuration is recommended for medium width screens.
144          */
145         val Centered = ShortNavigationBarArrangement(1)
146     }
147 
toStringnull148     override fun toString() =
149         when (this) {
150             EqualWeight -> "EqualWeight"
151             Centered -> "Centered"
152             else -> "Unknown"
153         }
154 }
155 
156 /**
157  * Material Design short navigation bar item.
158  *
159  * Short navigation bars offer a persistent and convenient way to switch between primary
160  * destinations in an app.
161  *
162  * It's recommend for navigation items to always have a text label. An [ShortNavigationBarItem]
163  * always displays labels (if they exist) when selected and unselected.
164  *
165  * The [ShortNavigationBarItem] supports two different icon positions, top and start, which is
166  * controlled by the [iconPosition] param:
167  * - If the icon position is [NavigationItemIconPosition.Top] the icon will be displayed above the
168  *   label. This configuration is recommended for short navigation bars used in small width screens,
169  *   like a phone in portrait mode.
170  * - If the icon position is [NavigationItemIconPosition.Start] the icon will be displayed to the
171  *   start of the label. This configuration is recommended for short navigation bars used in medium
172  *   width screens, like a phone in landscape mode.
173  *
174  * @param selected whether this item is selected
175  * @param onClick called when this item is clicked
176  * @param icon icon for this item, typically an [Icon]
177  * @param label text label for this item
178  * @param modifier the [Modifier] to be applied to this item
179  * @param enabled controls the enabled state of this item. When `false`, this component will not
180  *   respond to user input, and it will appear visually disabled and disabled to accessibility
181  *   services.
182  * @param iconPosition the [NavigationItemIconPosition] for the icon
183  * @param colors [NavigationItemColors] that will be used to resolve the colors used for this item
184  *   in different states. See [ShortNavigationBarItemDefaults.colors]
185  * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
186  *   emitting [Interaction]s for this item. You can use this to change the item's appearance or
187  *   preview the item in different states. Note that if `null` is provided, interactions will still
188  *   happen internally.
189  */
190 @ExperimentalMaterial3ExpressiveApi
191 @Composable
ShortNavigationBarItemnull192 fun ShortNavigationBarItem(
193     selected: Boolean,
194     onClick: () -> Unit,
195     icon: @Composable () -> Unit,
196     label: @Composable (() -> Unit)?,
197     modifier: Modifier = Modifier,
198     enabled: Boolean = true,
199     iconPosition: NavigationItemIconPosition = NavigationItemIconPosition.Top,
200     colors: NavigationItemColors = ShortNavigationBarItemDefaults.colors(),
201     interactionSource: MutableInteractionSource? = null,
202 ) {
203     @Suppress("NAME_SHADOWING")
204     val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
205 
206     val isIconPositionTop = iconPosition == NavigationItemIconPosition.Top
207     val indicatorHorizontalPadding =
208         if (isIconPositionTop) {
209             TopIconIndicatorHorizontalPadding
210         } else {
211             StartIconIndicatorHorizontalPadding
212         }
213     val indicatorVerticalPadding =
214         if (isIconPositionTop) {
215             TopIconIndicatorVerticalPadding
216         } else {
217             StartIconIndicatorVerticalPadding
218         }
219 
220     NavigationItem(
221         selected = selected,
222         onClick = onClick,
223         icon = icon,
224         labelTextStyle = NavigationBarTokens.LabelTextFont.value,
225         indicatorShape = NavigationBarTokens.ItemActiveIndicatorShape.value,
226         indicatorWidth = NavigationBarVerticalItemTokens.ActiveIndicatorWidth,
227         indicatorHorizontalPadding = indicatorHorizontalPadding,
228         indicatorVerticalPadding = indicatorVerticalPadding,
229         indicatorToLabelVerticalPadding = TopIconIndicatorToLabelPadding,
230         startIconToLabelHorizontalPadding = StartIconToLabelPadding,
231         topIconItemVerticalPadding = TopIconItemVerticalPadding,
232         colors = colors,
233         modifier = modifier,
234         enabled = enabled,
235         label = label,
236         iconPosition = iconPosition,
237         interactionSource = interactionSource,
238     )
239 }
240 
241 /** Defaults used in [ShortNavigationBar]. */
242 @ExperimentalMaterial3ExpressiveApi
243 object ShortNavigationBarDefaults {
244     /** Default container color for a short navigation bar. */
245     val containerColor: Color
246         @Composable get() = NavigationBarTokens.ContainerColor.value
247 
248     /** Default content color for a short navigation bar. */
249     val contentColor: Color
250         @Composable get() = contentColorFor(containerColor)
251 
252     /** Default arrangement for a short navigation bar. */
253     val arrangement: ShortNavigationBarArrangement
254         get() = ShortNavigationBarArrangement.EqualWeight
255 
256     /** Default window insets to be used and consumed by the short navigation bar. */
257     val windowInsets: WindowInsets
258         @Composable
259         get() =
260             WindowInsets.systemBarsForVisualComponents.only(
261                 WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom
262             )
263 }
264 
265 /** Defaults used in [ShortNavigationBarItem]. */
266 @ExperimentalMaterial3ExpressiveApi
267 object ShortNavigationBarItemDefaults {
268     /**
269      * Creates a [NavigationItemColors] with the provided colors according to the Material
270      * specification.
271      */
colorsnull272     @Composable fun colors() = MaterialTheme.colorScheme.defaultShortNavigationBarItemColors
273 
274     /**
275      * Creates a [NavigationItemColors] with the provided colors according to the Material
276      * specification.
277      *
278      * @param selectedIconColor the color to use for the icon when the item is selected.
279      * @param selectedTextColor the color to use for the text label when the item is selected.
280      * @param selectedIndicatorColor the color to use for the indicator when the item is selected.
281      * @param unselectedIconColor the color to use for the icon when the item is unselected.
282      * @param unselectedTextColor the color to use for the text label when the item is unselected.
283      * @param disabledIconColor the color to use for the icon when the item is disabled.
284      * @param disabledTextColor the color to use for the text label when the item is disabled.
285      * @return the resulting [NavigationItemColors] used for [ShortNavigationBarItem]
286      */
287     @Composable
288     fun colors(
289         selectedIconColor: Color = NavigationBarTokens.ItemActiveIconColor.value,
290         selectedTextColor: Color = NavigationBarTokens.ItemActiveLabelTextColor.value,
291         selectedIndicatorColor: Color = NavigationBarTokens.ItemActiveIndicatorColor.value,
292         unselectedIconColor: Color = NavigationBarTokens.ItemInactiveIconColor.value,
293         unselectedTextColor: Color = NavigationBarTokens.ItemInactiveLabelTextColor.value,
294         disabledIconColor: Color = unselectedIconColor.copy(alpha = DisabledAlpha),
295         disabledTextColor: Color = unselectedTextColor.copy(alpha = DisabledAlpha),
296     ): NavigationItemColors =
297         MaterialTheme.colorScheme.defaultShortNavigationBarItemColors.copy(
298             selectedIconColor = selectedIconColor,
299             selectedTextColor = selectedTextColor,
300             selectedIndicatorColor = selectedIndicatorColor,
301             unselectedIconColor = unselectedIconColor,
302             unselectedTextColor = unselectedTextColor,
303             disabledIconColor = disabledIconColor,
304             disabledTextColor = disabledTextColor,
305         )
306 
307     internal val ColorScheme.defaultShortNavigationBarItemColors: NavigationItemColors
308         get() {
309             return defaultShortNavigationBarItemColorsCached
310                 ?: NavigationItemColors(
311                         selectedIconColor = fromToken(NavigationBarTokens.ItemActiveIconColor),
312                         selectedTextColor = fromToken(NavigationBarTokens.ItemActiveLabelTextColor),
313                         selectedIndicatorColor =
314                             fromToken(NavigationBarTokens.ItemActiveIndicatorColor),
315                         unselectedIconColor = fromToken(NavigationBarTokens.ItemInactiveIconColor),
316                         unselectedTextColor =
317                             fromToken(NavigationBarTokens.ItemInactiveLabelTextColor),
318                         disabledIconColor =
319                             fromToken(NavigationBarTokens.ItemInactiveIconColor)
320                                 .copy(alpha = DisabledAlpha),
321                         disabledTextColor =
322                             fromToken(NavigationBarTokens.ItemInactiveLabelTextColor)
323                                 .copy(alpha = DisabledAlpha),
324                     )
325                     .also { defaultShortNavigationBarItemColorsCached = it }
326         }
327 }
328 
329 private class EqualWeightContentMeasurePolicy : MeasurePolicy {
measurenull330     override fun MeasureScope.measure(
331         measurables: List<Measurable>,
332         constraints: Constraints
333     ): MeasureResult {
334         val width = constraints.maxWidth
335         var itemHeight = constraints.minHeight
336         val itemsCount = measurables.size
337         // If there are no items, bar will be empty.
338         if (itemsCount < 1) {
339             return layout(width, itemHeight) {}
340         }
341 
342         val itemsPlaceables: List<Placeable>
343         if (!constraints.hasBoundedWidth) {
344             // If width constraint is not bounded, let item containers widths be as big as they are.
345             // This may lead to a different items arrangement than the expected.
346             itemsPlaceables =
347                 measurables.fastMap {
348                     it.measure(constraints.constrain(Constraints.fixedHeight(height = itemHeight)))
349                 }
350         } else {
351             val itemWidth = width / itemsCount
352             measurables.fastForEach {
353                 val measurableHeight = it.maxIntrinsicHeight(itemWidth)
354                 if (itemHeight < measurableHeight) {
355                     itemHeight = measurableHeight.coerceAtMost(constraints.maxHeight)
356                 }
357             }
358 
359             // Make sure the item containers have the same width and height.
360             itemsPlaceables =
361                 measurables.fastMap {
362                     it.measure(
363                         constraints.constrain(
364                             Constraints.fixed(width = itemWidth, height = itemHeight)
365                         )
366                     )
367                 }
368         }
369 
370         return layout(width, itemHeight) {
371             var x = 0
372             val y = 0
373             itemsPlaceables.fastForEach { item ->
374                 item.placeRelative(x, y)
375                 x += item.width
376             }
377         }
378     }
379 }
380 
381 private class CenteredContentMeasurePolicy : MeasurePolicy {
measurenull382     override fun MeasureScope.measure(
383         measurables: List<Measurable>,
384         constraints: Constraints
385     ): MeasureResult {
386         val width = constraints.maxWidth
387         var itemHeight = constraints.minHeight
388         val itemsCount = measurables.size
389         // If there are no items, bar will be empty.
390         if (itemsCount < 1) {
391             return layout(width, itemHeight) {}
392         }
393 
394         var barHorizontalPadding = 0
395         val itemsPlaceables: List<Placeable>
396         if (!constraints.hasBoundedWidth) {
397             // If width constraint is not bounded, let item containers widths be as big as they are.
398             // This may lead to a different items arrangement than the expected.
399             itemsPlaceables =
400                 measurables.fastMap {
401                     it.measure(constraints.constrain(Constraints.fixedHeight(height = itemHeight)))
402                 }
403         } else {
404             val itemMaxWidth = width / itemsCount
405             barHorizontalPadding = calculateCenteredContentHorizontalPadding(itemsCount, width)
406             val itemMinWidth = (width - (barHorizontalPadding * 2)) / itemsCount
407 
408             // Make sure the item containers will have the same height.
409             measurables.fastForEach {
410                 val measurableHeight = it.maxIntrinsicHeight(itemMinWidth)
411                 if (itemHeight < measurableHeight) {
412                     itemHeight = measurableHeight.coerceAtMost(constraints.maxHeight)
413                 }
414             }
415             itemsPlaceables =
416                 measurables.fastMap {
417                     var currentItemWidth = itemMinWidth
418                     val measurableWidth = it.maxIntrinsicWidth(constraints.minHeight)
419                     if (currentItemWidth < measurableWidth) {
420                         // Let an item container be bigger in width if needed, but limit it to
421                         // itemMaxWidth.
422                         currentItemWidth = measurableWidth.coerceAtMost(itemMaxWidth)
423                         // Update horizontal padding so that items remain centered.
424                         barHorizontalPadding -= (currentItemWidth - itemMinWidth) / 2
425                     }
426 
427                     it.measure(
428                         constraints.constrain(
429                             Constraints.fixed(width = currentItemWidth, height = itemHeight)
430                         )
431                     )
432                 }
433         }
434 
435         return layout(width, itemHeight) {
436             var x = barHorizontalPadding
437             val y = 0
438             itemsPlaceables.fastForEach { item ->
439                 item.placeRelative(x, y)
440                 x += item.width
441             }
442         }
443     }
444 }
445 
446 /*
447  * For the navigation bar with start icon items, the horizontal padding of the bar depends on the
448  * number of items:
449  * - 3 items: the items should occupy 60% of the bar's width, so the horizontal padding should be
450  *   20% of it on each side.
451  * - 4 items: the items should occupy 70% of the bar's width, so the horizontal padding should be
452  *   15% of it on each side.
453  * - 5 items: the items should occupy 80% of the bar's width, so the horizontal padding should be
454  *   10% of it on each side.
455  * - 6 items: the items should occupy 90% of the bar's width, so the horizontal padding should be
456  *   5% of it on each side.
457  */
calculateCenteredContentHorizontalPaddingnull458 private fun calculateCenteredContentHorizontalPadding(itemsCount: Int, barWidth: Int): Int {
459     if (itemsCount > 6) return 0
460     // Formula to calculate the padding percentage based on the number of items and bar width.
461     val paddingPercentage = ((100 - 10 * (itemsCount + 3)) / 2f) / 100
462     return (paddingPercentage * barWidth).roundToInt()
463 }
464 
465 /*@VisibleForTesting*/
466 internal val TopIconItemVerticalPadding = NavigationBarVerticalItemTokens.ContainerBetweenSpace
467 /*@VisibleForTesting*/
468 internal val TopIconIndicatorVerticalPadding =
469     (NavigationBarVerticalItemTokens.ActiveIndicatorHeight -
470         NavigationBarVerticalItemTokens.IconSize) / 2
471 /*@VisibleForTesting*/
472 internal val TopIconIndicatorHorizontalPadding =
473     (NavigationBarVerticalItemTokens.ActiveIndicatorWidth -
474         NavigationBarVerticalItemTokens.IconSize) / 2
475 /*@VisibleForTesting*/
476 internal val StartIconIndicatorVerticalPadding =
477     (NavigationBarHorizontalItemTokens.ActiveIndicatorHeight -
478         NavigationBarHorizontalItemTokens.IconSize) / 2
479 /*@VisibleForTesting*/
480 internal val TopIconIndicatorToLabelPadding: Dp = 4.dp
481 /*@VisibleForTesting*/
482 internal val StartIconIndicatorHorizontalPadding =
483     NavigationBarHorizontalItemTokens.ActiveIndicatorLeadingSpace
484 /*@VisibleForTesting*/
485 internal val StartIconToLabelPadding = NavigationBarTokens.ItemActiveIndicatorIconLabelSpace
486