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 * 
58 *
59 * 
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