[go: nahoru, domu]

blob: d27bb152dd4f252acf81fd61628239685783124a [file] [log] [blame]
Max Yingcc44d972021-10-11 13:12:45 -04001/*
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
17package androidx.compose.material3
18
19import androidx.compose.animation.animateColorAsState
20import androidx.compose.animation.core.animateFloatAsState
21import androidx.compose.animation.core.tween
22import androidx.compose.foundation.background
23import androidx.compose.foundation.interaction.Interaction
24import androidx.compose.foundation.interaction.MutableInteractionSource
25import androidx.compose.foundation.layout.Arrangement
26import androidx.compose.foundation.layout.Box
27import androidx.compose.foundation.layout.Row
28import androidx.compose.foundation.layout.RowScope
29import androidx.compose.foundation.layout.fillMaxWidth
30import androidx.compose.foundation.layout.height
31import androidx.compose.foundation.layout.padding
32import androidx.compose.foundation.selection.selectable
33import androidx.compose.foundation.selection.selectableGroup
34import androidx.compose.material.ripple.rememberRipple
35import androidx.compose.material3.tokens.NavigationBar
36import androidx.compose.runtime.Composable
37import androidx.compose.runtime.CompositionLocalProvider
38import androidx.compose.runtime.Stable
39import androidx.compose.runtime.State
40import androidx.compose.runtime.getValue
41import androidx.compose.runtime.remember
42import androidx.compose.ui.Alignment
43import androidx.compose.ui.Modifier
44import androidx.compose.ui.draw.alpha
45import androidx.compose.ui.graphics.Color
46import androidx.compose.ui.graphics.Shape
47import androidx.compose.ui.layout.LastBaseline
48import androidx.compose.ui.layout.Layout
49import androidx.compose.ui.layout.MeasureResult
50import androidx.compose.ui.layout.MeasureScope
51import androidx.compose.ui.layout.Placeable
52import androidx.compose.ui.layout.layoutId
53import androidx.compose.ui.semantics.Role
54import androidx.compose.ui.unit.Constraints
55import androidx.compose.ui.unit.Dp
56import androidx.compose.ui.unit.dp
57import kotlin.math.roundToInt
58
59/**
Nick Rout2f69d9402021-10-19 16:25:18 +020060 * ![Navigation bar image](https://developer.android.com/images/reference/androidx/compose/material3/navigation-bar.png)
61 *
Max Yingcc44d972021-10-11 13:12:45 -040062 * Material Design bottom navigation bar.
63 *
64 * A bottom navigation bar allows switching between primary destinations in an app.
65 *
66 * [NavigationBar] should contain three to five [NavigationBarItem]s, each representing a singular
67 * destination.
68 *
69 * A simple example looks like:
70 * @sample androidx.compose.material3.samples.NavigationBarSample
71 *
72 * See [NavigationBarItem] for configuration specific to each item, and not the overall
73 * [NavigationBar] component.
74 *
75 * @param modifier optional [Modifier] for this NavigationBar
76 * @param containerColor the container color for this NavigationBar
77 * @param contentColor the preferred content color provided by this NavigationBar to its children.
78 * Defaults to either the matching content color for [containerColor], or if [containerColor] is not
79 * a color from the theme, this will keep the same value set above this NavigationBar.
80 * @param tonalElevation When [containerColor] is [ColorScheme.surface], a higher tonal elevation
81 * value will result in a darker color in light theme and lighter color in dark theme. See also:
82 * [Surface].
83 * @param content destinations inside this NavigationBar. This should contain multiple
84 * [NavigationBarItem]s
85 */
86@Composable
87fun NavigationBar(
88 modifier: Modifier = Modifier,
89 containerColor: Color = MaterialTheme.colorScheme.fromToken(NavigationBar.ContainerColor),
90 contentColor: Color = MaterialTheme.colorScheme.contentColorFor(containerColor),
91 tonalElevation: Dp = NavigationBar.ContainerElevation,
92 content: @Composable RowScope.() -> Unit
93) {
94 Surface(
95 color = containerColor,
96 contentColor = contentColor,
97 tonalElevation = tonalElevation,
98 modifier = modifier
99 ) {
100 Row(
101 modifier = Modifier.fillMaxWidth().height(NavigationBarHeight).selectableGroup(),
102 horizontalArrangement = Arrangement.SpaceBetween,
103 content = content
104 )
105 }
106}
107
108/**
109 * Material Design navigation bar item.
110 *
111 * The recommended configuration for a [NavigationBarItem] depends on how many items there are
112 * inside a [NavigationBar]:
113 *
114 * - Three destinations: Display icons and text labels for all destinations.
115 * - Four destinations: Active destinations display an icon and text label. Inactive destinations
116 * display icons, and text labels are recommended.
117 * - Five destinations: Active destinations display an icon and text label. Inactive destinations
118 * use icons, and use text labels if space permits.
119 *
120 * A [NavigationBarItem] always shows text labels (if it exists) when selected. Showing text
121 * labels if not selected is controlled by [alwaysShowLabel].
122 *
123 * @param selected whether this item is selected
124 * @param onClick the callback to be invoked when this item is selected
125 * @param icon icon for this item, typically this will be an [Icon]
126 * @param modifier optional [Modifier] for this item
127 * @param enabled controls the enabled state of this item. When `false`, this item will not be
128 * clickable and will appear disabled to accessibility services.
129 * @param label optional text label for this item
130 * @param alwaysShowLabel whether to always show the label for this item. If false, the label will
131 * only be shown when this item is selected.
132 * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
133 * for this NavigationBarItem. You can create and pass in your own remembered
134 * [MutableInteractionSource] if you want to observe [Interaction]s and customize the appearance /
135 * behavior of this NavigationBarItem in different [Interaction]s.
136 * @param colors the various colors used in elements of this item
137 */
138@Composable
139fun RowScope.NavigationBarItem(
140 selected: Boolean,
141 onClick: () -> Unit,
142 icon: @Composable () -> Unit,
143 modifier: Modifier = Modifier,
144 enabled: Boolean = true,
145 label: @Composable (() -> Unit)? = null,
146 alwaysShowLabel: Boolean = true,
147 interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
148 colors: NavigationBarItemColors = NavigationBarItemDefaults.colors()
149) {
150 val styledIcon = @Composable {
151 val iconColor by colors.iconColor(selected = selected)
152 CompositionLocalProvider(LocalContentColor provides iconColor, content = icon)
153 }
154
155 val styledLabel: @Composable (() -> Unit)? = label?.let {
156 @Composable {
157 val style = MaterialTheme.typography.fromToken(NavigationBar.LabelTextFont)
158 val textColor by colors.textColor(selected = selected)
159 CompositionLocalProvider(LocalContentColor provides textColor) {
160 ProvideTextStyle(style, content = label)
161 }
162 }
163 }
164
165 Box(
166 modifier
167 .selectable(
168 selected = selected,
169 onClick = onClick,
170 enabled = enabled,
171 role = Role.Tab,
172 interactionSource = interactionSource,
173 indication = rememberRipple(),
174 )
175 .weight(1f),
176 contentAlignment = Alignment.Center
177 ) {
178 val animationProgress: Float by animateFloatAsState(
179 targetValue = if (selected) 1f else 0f,
180 animationSpec = tween(ItemAnimationDurationMillis)
181 )
182
183 val indicator = @Composable {
184 Box(
185 Modifier.layoutId(IndicatorLayoutIdTag)
186 .background(
187 color = colors.indicatorColor.copy(alpha = animationProgress),
188 shape = IndicatorShape
189 )
190 )
191 }
192
193 NavigationBarItemBaselineLayout(
194 indicator = indicator,
195 icon = styledIcon,
196 label = styledLabel,
197 alwaysShowLabel = alwaysShowLabel,
198 animationProgress = animationProgress
199 )
200 }
201}
202
203/** Defaults used in [NavigationBarItem]. */
204object NavigationBarItemDefaults {
205 /**
206 * Creates a [NavigationBarItemColors] with the provided colors according to the Material
207 * specification.
208 *
209 * @param selectedIconColor the color to use for the icon when the item is selected.
210 * @param unselectedIconColor the color to use for the icon when the item is unselected.
211 * @param selectedTextColor the color to use for the text label when the item is selected.
212 * @param unselectedTextColor the color to use for the text label when the item is unselected.
213 * @param indicatorColor the color to use for the indicator when the item is selected.
214 * @return the resulting [NavigationBarItemColors] used for [NavigationBarItem]
215 */
216 @Composable
217 fun colors(
218 selectedIconColor: Color =
219 MaterialTheme.colorScheme.fromToken(NavigationBar.ActiveIconColor),
220 unselectedIconColor: Color =
221 MaterialTheme.colorScheme.fromToken(NavigationBar.InactiveIconColor),
222 selectedTextColor: Color =
223 MaterialTheme.colorScheme.fromToken(NavigationBar.ActiveLabelTextColor),
224 unselectedTextColor: Color =
225 MaterialTheme.colorScheme.fromToken(NavigationBar.InactiveLabelTextColor),
226 indicatorColor: Color =
227 MaterialTheme.colorScheme.fromToken(NavigationBar.ActiveIndicatorColor),
228 ): NavigationBarItemColors = remember(
229 selectedIconColor,
230 unselectedIconColor,
231 selectedTextColor,
232 unselectedTextColor,
233 indicatorColor
234 ) {
235 DefaultNavigationBarItemColors(
236 selectedIconColor = selectedIconColor,
237 unselectedIconColor = unselectedIconColor,
238 selectedTextColor = selectedTextColor,
239 unselectedTextColor = unselectedTextColor,
240 selectedIndicatorColor = indicatorColor,
241 )
242 }
243}
244
245/** Represents the colors of the various elements of a navigation item. */
246@Stable
247interface NavigationBarItemColors {
248 /**
249 * Represents the icon color for this item, depending on whether it is [selected].
250 *
251 * @param selected whether the item is selected
252 */
253 @Composable
254 fun iconColor(selected: Boolean): State<Color>
255
256 /**
257 * Represents the text color for this item, depending on whether it is [selected].
258 *
259 * @param selected whether the item is selected
260 */
261 @Composable
262 fun textColor(selected: Boolean): State<Color>
263
264 /** Represents the color of the indicator used for selected items. */
265 val indicatorColor: Color
266 @Composable get
267}
268
269@Stable
270private class DefaultNavigationBarItemColors(
271 private val selectedIconColor: Color,
272 private val unselectedIconColor: Color,
273 private val selectedTextColor: Color,
274 private val unselectedTextColor: Color,
275 private val selectedIndicatorColor: Color,
276) : NavigationBarItemColors {
277 @Composable
278 override fun iconColor(selected: Boolean): State<Color> {
279 return animateColorAsState(
280 targetValue = if (selected) selectedIconColor else unselectedIconColor,
281 animationSpec = tween(ItemAnimationDurationMillis)
282 )
283 }
284
285 @Composable
286 override fun textColor(selected: Boolean): State<Color> {
287 return animateColorAsState(
288 targetValue = if (selected) selectedTextColor else unselectedTextColor,
289 animationSpec = tween(ItemAnimationDurationMillis)
290 )
291 }
292
293 override val indicatorColor: Color
294 @Composable
295 get() = selectedIndicatorColor
296}
297
298/**
299 * Base layout for a [NavigationBarItem].
300 *
301 * @param indicator indicator for this item when it is selected
302 * @param icon icon for this item
303 * @param label text label for this item
304 * @param alwaysShowLabel whether to always show the label for this item. If false, the label will
305 * only be shown when this item is selected.
306 * @param animationProgress progress of the animation, where 0 represents the unselected state of
307 * this item and 1 represents the selected state. This value controls other values such as indicator
308 * size, icon and label positions, etc.
309 */
310@Composable
311private fun NavigationBarItemBaselineLayout(
312 indicator: @Composable () -> Unit,
313 icon: @Composable () -> Unit,
314 label: @Composable (() -> Unit)?,
315 alwaysShowLabel: Boolean,
316 animationProgress: Float,
317) {
318 Layout({
319 if (animationProgress > 0) {
320 indicator()
321 }
322
323 Box(Modifier.layoutId(IconLayoutIdTag)) { icon() }
324
325 if (label != null) {
326 Box(
327 Modifier.layoutId(LabelLayoutIdTag)
328 .alpha(if (alwaysShowLabel) 1f else animationProgress)
329 .padding(horizontal = NavigationBarItemHorizontalPadding)
330 ) { label() }
331 }
332 }) { measurables, constraints ->
333 val iconPlaceable =
334 measurables.first { it.layoutId == IconLayoutIdTag }.measure(constraints)
335
336 val totalIndicatorWidth = iconPlaceable.width + (IndicatorHorizontalPadding * 2).roundToPx()
337 val animatedIndicatorWidth = (totalIndicatorWidth * animationProgress).roundToInt()
338 val indicatorHeight = iconPlaceable.height + (IndicatorVerticalPadding * 2).roundToPx()
339 val indicatorPlaceable =
340 measurables
341 .firstOrNull { it.layoutId == IndicatorLayoutIdTag }
342 ?.measure(
343 Constraints.fixed(
344 width = animatedIndicatorWidth,
345 height = indicatorHeight
346 )
347 )
348
349 val labelPlaceable =
350 label?.let {
351 measurables
352 .first { it.layoutId == LabelLayoutIdTag }
353 .measure(
354 // Measure with loose constraints for height as we don't want the label to take up more
355 // space than it needs
356 constraints.copy(minHeight = 0)
357 )
358 }
359
360 if (label == null) {
361 placeIcon(iconPlaceable, indicatorPlaceable, constraints)
362 } else {
363 placeLabelAndIcon(
364 labelPlaceable!!,
365 iconPlaceable,
366 indicatorPlaceable,
367 constraints,
368 alwaysShowLabel,
369 animationProgress
370 )
371 }
372 }
373}
374
375/**
376 * Places the provided [iconPlaceable], and possibly [indicatorPlaceable] if it exists, in the
377 * center of the provided [constraints].
378 */
379private fun MeasureScope.placeIcon(
380 iconPlaceable: Placeable,
381 indicatorPlaceable: Placeable?,
382 constraints: Constraints
383): MeasureResult {
384 val width = constraints.maxWidth
385 val height = constraints.maxHeight
386
387 val iconX = (width - iconPlaceable.width) / 2
388 val iconY = (height - iconPlaceable.height) / 2
389
390 return layout(width, height) {
391 indicatorPlaceable?.let {
392 val indicatorX = (width - it.width) / 2
393 val indicatorY = (height - it.height) / 2
394 it.placeRelative(indicatorX, indicatorY)
395 }
396 iconPlaceable.placeRelative(iconX, iconY)
397 }
398}
399
400/**
401 * Places the provided [labelPlaceable], [iconPlaceable], and [indicatorPlaceable] in the correct
402 * position, depending on [alwaysShowLabel] and [animationProgress].
403 *
404 * When [alwaysShowLabel] is true, the positions do not move. The [iconPlaceable] will be placed
405 * near the top of the item and the [labelPlaceable] will be placed near the bottom, according to
406 * the spec.
407 *
408 * When [animationProgress] is 1 (representing the selected state), the positions will be the same
409 * as above.
410 *
411 * Otherwise, when [animationProgress] is 0, [iconPlaceable] will be placed in the center, like in
412 * [placeIcon], and [labelPlaceable] will not be shown.
413 *
414 * When [animationProgress] is animating between these values, [iconPlaceable] and [labelPlaceable]
415 * will be placed at a corresponding interpolated position.
416 *
417 * [indicatorPlaceable] will always be placed in such a way that it shares the same center as
418 * [iconPlaceable].
419 *
420 * @param labelPlaceable text label placeable inside this item
421 * @param iconPlaceable icon placeable inside this item
422 * @param indicatorPlaceable indicator placeable inside this item, if it exists
423 * @param constraints constraints of the item
424 * @param alwaysShowLabel whether to always show the label for this item. If true, icon and label
425 * positions will not change. If false, positions transition between 'centered icon with no label'
426 * and 'top aligned icon with label'.
427 * @param animationProgress progress of the animation, where 0 represents the unselected state of
428 * this item and 1 represents the selected state. Values between 0 and 1 interpolate positions of
429 * the icon and label.
430 */
431private fun MeasureScope.placeLabelAndIcon(
432 labelPlaceable: Placeable,
433 iconPlaceable: Placeable,
434 indicatorPlaceable: Placeable?,
435 constraints: Constraints,
436 alwaysShowLabel: Boolean,
437 animationProgress: Float,
438): MeasureResult {
439 val height = constraints.maxHeight
440
441 val baseline = labelPlaceable[LastBaseline]
442 // Label should be `ItemVerticalPadding` from the bottom
443 val labelY = height - baseline - NavigationBarItemVerticalPadding.roundToPx()
444
445 // Icon (when selected) should be `ItemVerticalPadding` from the top
446 val selectedIconY = NavigationBarItemVerticalPadding.roundToPx()
447 val unselectedIconY =
448 if (alwaysShowLabel) selectedIconY else (height - iconPlaceable.height) / 2
449
450 // How far the icon needs to move between unselected and selected states.
451 val iconDistance = unselectedIconY - selectedIconY
452
453 // The interpolated fraction of iconDistance that all placeables need to move based on
454 // animationProgress.
455 val offset = (iconDistance * (1 - animationProgress)).roundToInt()
456
457 val containerWidth = constraints.maxWidth
458
459 val labelX = (containerWidth - labelPlaceable.width) / 2
460 val iconX = (containerWidth - iconPlaceable.width) / 2
461
462 return layout(containerWidth, height) {
463 indicatorPlaceable?.let {
464 val indicatorX = (containerWidth - it.width) / 2
465 val indicatorY = selectedIconY - IndicatorVerticalPadding.roundToPx()
466 it.placeRelative(indicatorX, indicatorY + offset)
467 }
468 if (alwaysShowLabel || animationProgress != 0f) {
469 labelPlaceable.placeRelative(labelX, labelY + offset)
470 }
471 iconPlaceable.placeRelative(iconX, selectedIconY + offset)
472 }
473}
474
475private const val IndicatorLayoutIdTag: String = "indicator"
476
477private const val IconLayoutIdTag: String = "icon"
478
479private const val LabelLayoutIdTag: String = "label"
480
481private val NavigationBarHeight: Dp = NavigationBar.ContainerHeight
482
483private const val ItemAnimationDurationMillis: Int = 100
484
485private val NavigationBarItemHorizontalPadding: Dp = 4.dp
486
487/*@VisibleForTesting*/
488internal val NavigationBarItemVerticalPadding: Dp = 16.dp
489
490private val IndicatorShape: Shape = NavigationBar.ActiveIndicatorShape
491
492private val IndicatorHorizontalPadding: Dp =
493 (NavigationBar.ActiveIndicatorWidth - NavigationBar.IconSize) / 2
494
495private val IndicatorVerticalPadding: Dp =
496 (NavigationBar.ActiveIndicatorHeight - NavigationBar.IconSize) / 2