1 /*
<lambda>null2 * Copyright 2019 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.material
18
19 import androidx.compose.animation.animateColor
20 import androidx.compose.animation.core.LinearEasing
21 import androidx.compose.animation.core.tween
22 import androidx.compose.animation.core.updateTransition
23 import androidx.compose.foundation.interaction.Interaction
24 import androidx.compose.foundation.interaction.MutableInteractionSource
25 import androidx.compose.foundation.layout.Arrangement
26 import androidx.compose.foundation.layout.Box
27 import androidx.compose.foundation.layout.Column
28 import androidx.compose.foundation.layout.ColumnScope
29 import androidx.compose.foundation.layout.Row
30 import androidx.compose.foundation.layout.Spacer
31 import androidx.compose.foundation.layout.fillMaxWidth
32 import androidx.compose.foundation.layout.height
33 import androidx.compose.foundation.layout.padding
34 import androidx.compose.foundation.layout.requiredWidth
35 import androidx.compose.foundation.selection.selectable
36 import androidx.compose.runtime.Composable
37 import androidx.compose.runtime.CompositionLocalProvider
38 import androidx.compose.runtime.getValue
39 import androidx.compose.ui.Alignment
40 import androidx.compose.ui.Modifier
41 import androidx.compose.ui.graphics.Color
42 import androidx.compose.ui.layout.FirstBaseline
43 import androidx.compose.ui.layout.LastBaseline
44 import androidx.compose.ui.layout.Layout
45 import androidx.compose.ui.layout.Placeable
46 import androidx.compose.ui.layout.layoutId
47 import androidx.compose.ui.semantics.Role
48 import androidx.compose.ui.text.style.TextAlign
49 import androidx.compose.ui.unit.Density
50 import androidx.compose.ui.unit.dp
51 import androidx.compose.ui.unit.sp
52 import androidx.compose.ui.util.fastFirst
53 import kotlin.math.max
54
55 /**
56 * [Material Design tab](https://material.io/components/tabs)
57 *
58 * Tabs organize content across different screens, data sets, and other interactions.
59 *
60 * 
61 *
62 * A Tab represents a single page of content using a text label and/or icon. It represents its
63 * selected state by tinting the text label and/or image with [selectedContentColor].
64 *
65 * This should typically be used inside of a [TabRow], see the corresponding documentation for
66 * example usage.
67 *
68 * This Tab has slots for [text] and/or [icon] - see the other Tab overload for a generic Tab that
69 * is not opinionated about its content.
70 *
71 * @param selected whether this tab is selected or not
72 * @param onClick the callback to be invoked when this tab is selected
73 * @param modifier optional [Modifier] for this tab
74 * @param enabled controls the enabled state of this tab. When `false`, this tab will not be
75 * clickable and will appear disabled to accessibility services.
76 * @param text the text label displayed in this tab
77 * @param icon the icon displayed in this tab
78 * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
79 * emitting [Interaction]s for this tab. You can use this to change the tab's appearance or
80 * preview the tab in different states. Note that if `null` is provided, interactions will still
81 * happen internally.
82 * @param selectedContentColor the color for the content of this tab when selected, and the color of
83 * the ripple.
84 * @param unselectedContentColor the color for the content of this tab when not selected
85 * @see LeadingIconTab
86 */
87 @Composable
88 fun Tab(
89 selected: Boolean,
90 onClick: () -> Unit,
91 modifier: Modifier = Modifier,
92 enabled: Boolean = true,
93 text: @Composable (() -> Unit)? = null,
94 icon: @Composable (() -> Unit)? = null,
95 interactionSource: MutableInteractionSource? = null,
96 selectedContentColor: Color = LocalContentColor.current,
97 unselectedContentColor: Color = selectedContentColor.copy(alpha = ContentAlpha.medium)
98 ) {
99 val styledText: @Composable (() -> Unit)? =
100 text?.let {
101 @Composable {
102 val style = MaterialTheme.typography.button.copy(textAlign = TextAlign.Center)
103 ProvideTextStyle(style, content = text)
104 }
105 }
106 Tab(
107 selected,
108 onClick,
109 modifier,
110 enabled,
111 interactionSource,
112 selectedContentColor,
113 unselectedContentColor
114 ) {
115 TabBaselineLayout(icon = icon, text = styledText)
116 }
117 }
118
119 /**
120 * [Material Design tab](https://material.io/components/tabs)
121 *
122 * Tabs organize content across different screens, data sets, and other interactions.
123 *
124 * 
125 *
126 * A LeadingIconTab represents a single page of content using a text label and an icon in front of
127 * the label. It represents its selected state by tinting the text label and icon with
128 * [selectedContentColor].
129 *
130 * This should typically be used inside of a [TabRow], see the corresponding documentation for
131 * example usage.
132 *
133 * @param selected whether this tab is selected or not
134 * @param onClick the callback to be invoked when this tab is selected
135 * @param text the text label displayed in this tab
136 * @param icon the icon displayed in this tab
137 * @param modifier optional [Modifier] for this tab
138 * @param enabled controls the enabled state of this tab. When `false`, this tab will not be
139 * clickable and will appear disabled to accessibility services.
140 * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
141 * emitting [Interaction]s for this tab. You can use this to change the tab's appearance or
142 * preview the tab in different states. Note that if `null` is provided, interactions will still
143 * happen internally.
144 * @param selectedContentColor the color for the content of this tab when selected, and the color of
145 * the ripple.
146 * @param unselectedContentColor the color for the content of this tab when not selected
147 * @see Tab
148 */
149 @Composable
LeadingIconTabnull150 fun LeadingIconTab(
151 selected: Boolean,
152 onClick: () -> Unit,
153 text: @Composable (() -> Unit),
154 icon: @Composable (() -> Unit),
155 modifier: Modifier = Modifier,
156 enabled: Boolean = true,
157 interactionSource: MutableInteractionSource? = null,
158 selectedContentColor: Color = LocalContentColor.current,
159 unselectedContentColor: Color = selectedContentColor.copy(alpha = ContentAlpha.medium)
160 ) {
161 // The color of the Ripple should always the be selected color, as we want to show the color
162 // before the item is considered selected, and hence before the new contentColor is
163 // provided by TabTransition.
164 val ripple = ripple(bounded = true, color = selectedContentColor)
165
166 TabTransition(selectedContentColor, unselectedContentColor, selected) {
167 Row(
168 modifier =
169 modifier
170 .height(SmallTabHeight)
171 .selectable(
172 selected = selected,
173 onClick = onClick,
174 enabled = enabled,
175 role = Role.Tab,
176 interactionSource = interactionSource,
177 indication = ripple
178 )
179 .padding(horizontal = HorizontalTextPadding)
180 .fillMaxWidth(),
181 horizontalArrangement = Arrangement.Center,
182 verticalAlignment = Alignment.CenterVertically
183 ) {
184 icon()
185 Spacer(Modifier.requiredWidth(TextDistanceFromLeadingIcon))
186 val style = MaterialTheme.typography.button.copy(textAlign = TextAlign.Center)
187 ProvideTextStyle(style, content = text)
188 }
189 }
190 }
191
192 /**
193 * [Material Design tab](https://material.io/components/tabs)
194 *
195 * Tabs organize content across different screens, data sets, and other interactions.
196 *
197 * 
198 *
199 * Generic [Tab] overload that is not opinionated about content / color. See the other overload for
200 * a Tab that has specific slots for text and / or an icon, as well as providing the correct colors
201 * for selected / unselected states.
202 *
203 * A custom tab using this API may look like:
204 *
205 * @sample androidx.compose.material.samples.FancyTab
206 * @param selected whether this tab is selected or not
207 * @param onClick the callback to be invoked when this tab is selected
208 * @param modifier optional [Modifier] for this tab
209 * @param enabled controls the enabled state of this tab. When `false`, this tab will not be
210 * clickable and will appear disabled to accessibility services.
211 * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
212 * emitting [Interaction]s for this tab. You can use this to change the tab's appearance or
213 * preview the tab in different states. Note that if `null` is provided, interactions will still
214 * happen internally.
215 * @param selectedContentColor the color for the content of this tab when selected, and the color of
216 * the ripple.
217 * @param unselectedContentColor the color for the content of this tab when not selected
218 * @param content the content of this tab
219 */
220 @Composable
Tabnull221 fun Tab(
222 selected: Boolean,
223 onClick: () -> Unit,
224 modifier: Modifier = Modifier,
225 enabled: Boolean = true,
226 interactionSource: MutableInteractionSource? = null,
227 selectedContentColor: Color = LocalContentColor.current,
228 unselectedContentColor: Color = selectedContentColor.copy(alpha = ContentAlpha.medium),
229 content: @Composable ColumnScope.() -> Unit
230 ) {
231 // The color of the Ripple should always the selected color, as we want to show the color
232 // before the item is considered selected, and hence before the new contentColor is
233 // provided by TabTransition.
234 val ripple = ripple(bounded = true, color = selectedContentColor)
235
236 TabTransition(selectedContentColor, unselectedContentColor, selected) {
237 Column(
238 modifier =
239 modifier
240 .selectable(
241 selected = selected,
242 onClick = onClick,
243 enabled = enabled,
244 role = Role.Tab,
245 interactionSource = interactionSource,
246 indication = ripple
247 )
248 .fillMaxWidth(),
249 horizontalAlignment = Alignment.CenterHorizontally,
250 verticalArrangement = Arrangement.Center,
251 content = content
252 )
253 }
254 }
255
256 /**
257 * Transition defining how the tint color for a tab animates, when a new tab is selected. This
258 * component uses [LocalContentColor] to provide an interpolated value between [activeColor] and
259 * [inactiveColor] depending on the animation status.
260 */
261 @Composable
TabTransitionnull262 private fun TabTransition(
263 activeColor: Color,
264 inactiveColor: Color,
265 selected: Boolean,
266 content: @Composable () -> Unit
267 ) {
268 val transition = updateTransition(selected)
269 val color by
270 transition.animateColor(
271 transitionSpec = {
272 if (false isTransitioningTo true) {
273 tween(
274 durationMillis = TabFadeInAnimationDuration,
275 delayMillis = TabFadeInAnimationDelay,
276 easing = LinearEasing
277 )
278 } else {
279 tween(durationMillis = TabFadeOutAnimationDuration, easing = LinearEasing)
280 }
281 }
282 ) {
283 if (it) activeColor else inactiveColor
284 }
285 CompositionLocalProvider(
286 LocalContentColor provides color.copy(alpha = 1f),
287 LocalContentAlpha provides color.alpha,
288 content = content
289 )
290 }
291
292 /**
293 * A [Layout] that positions [text] and an optional [icon] with the correct baseline distances. This
294 * Layout will either be [SmallTabHeight] or [LargeTabHeight] depending on its content, and then
295 * place the text and/or icon inside with the correct baseline alignment.
296 */
297 @Composable
TabBaselineLayoutnull298 private fun TabBaselineLayout(text: @Composable (() -> Unit)?, icon: @Composable (() -> Unit)?) {
299 Layout({
300 if (text != null) {
301 Box(Modifier.layoutId("text").padding(horizontal = HorizontalTextPadding)) { text() }
302 }
303 if (icon != null) {
304 Box(Modifier.layoutId("icon")) { icon() }
305 }
306 }) { measurables, constraints ->
307 val textPlaceable =
308 text?.let {
309 measurables
310 .fastFirst { it.layoutId == "text" }
311 .measure(
312 // Measure with loose constraints for height as we don't want the text to
313 // take up more
314 // space than it needs
315 constraints.copy(minHeight = 0)
316 )
317 }
318
319 val iconPlaceable =
320 icon?.let { measurables.fastFirst { it.layoutId == "icon" }.measure(constraints) }
321
322 val tabWidth = max(textPlaceable?.width ?: 0, iconPlaceable?.width ?: 0)
323
324 val tabHeight =
325 if (textPlaceable != null && iconPlaceable != null) {
326 LargeTabHeight
327 } else {
328 SmallTabHeight
329 }
330 .roundToPx()
331
332 val firstBaseline = textPlaceable?.get(FirstBaseline)
333 val lastBaseline = textPlaceable?.get(LastBaseline)
334
335 layout(tabWidth, tabHeight) {
336 when {
337 textPlaceable != null && iconPlaceable != null ->
338 placeTextAndIcon(
339 density = this@Layout,
340 textPlaceable = textPlaceable,
341 iconPlaceable = iconPlaceable,
342 tabWidth = tabWidth,
343 tabHeight = tabHeight,
344 firstBaseline = firstBaseline!!,
345 lastBaseline = lastBaseline!!
346 )
347 textPlaceable != null -> placeTextOrIcon(textPlaceable, tabHeight)
348 iconPlaceable != null -> placeTextOrIcon(iconPlaceable, tabHeight)
349 else -> {}
350 }
351 }
352 }
353 }
354
355 /** Places the provided [textOrIconPlaceable] in the vertical center of the provided [tabHeight]. */
placeTextOrIconnull356 private fun Placeable.PlacementScope.placeTextOrIcon(
357 textOrIconPlaceable: Placeable,
358 tabHeight: Int
359 ) {
360 val contentY = (tabHeight - textOrIconPlaceable.height) / 2
361 textOrIconPlaceable.placeRelative(0, contentY)
362 }
363
364 /**
365 * Places the provided [textPlaceable] offset from the bottom of the tab using the correct baseline
366 * offset, with the provided [iconPlaceable] placed above the text using the correct baseline
367 * offset.
368 */
placeTextAndIconnull369 private fun Placeable.PlacementScope.placeTextAndIcon(
370 density: Density,
371 textPlaceable: Placeable,
372 iconPlaceable: Placeable,
373 tabWidth: Int,
374 tabHeight: Int,
375 firstBaseline: Int,
376 lastBaseline: Int
377 ) {
378 val baselineOffset =
379 if (firstBaseline == lastBaseline) {
380 SingleLineTextBaselineWithIcon
381 } else {
382 DoubleLineTextBaselineWithIcon
383 }
384
385 // Total offset between the last text baseline and the bottom of the Tab layout
386 val textOffset =
387 with(density) { baselineOffset.roundToPx() + TabRowDefaults.IndicatorHeight.roundToPx() }
388
389 // How much space there is between the top of the icon (essentially the top of this layout)
390 // and the top of the text layout's bounding box (not baseline)
391 val iconOffset =
392 with(density) {
393 iconPlaceable.height + IconDistanceFromBaseline.roundToPx() - firstBaseline
394 }
395
396 val textPlaceableX = (tabWidth - textPlaceable.width) / 2
397 val textPlaceableY = tabHeight - lastBaseline - textOffset
398 textPlaceable.placeRelative(textPlaceableX, textPlaceableY)
399
400 val iconPlaceableX = (tabWidth - iconPlaceable.width) / 2
401 val iconPlaceableY = textPlaceableY - iconOffset
402 iconPlaceable.placeRelative(iconPlaceableX, iconPlaceableY)
403 }
404
405 // Tab specifications
406 private val SmallTabHeight = 48.dp
407 private val LargeTabHeight = 72.dp
408
409 // Tab transition specifications
410 private const val TabFadeInAnimationDuration = 150
411 private const val TabFadeInAnimationDelay = 100
412 private const val TabFadeOutAnimationDuration = 100
413
414 // The horizontal padding on the left and right of text
415 private val HorizontalTextPadding = 16.dp
416
417 // Distance from the top of the indicator to the text baseline when there is one line of text and an
418 // icon
419 private val SingleLineTextBaselineWithIcon = 14.dp
420 // Distance from the top of the indicator to the last text baseline when there are two lines of text
421 // and an icon
422 private val DoubleLineTextBaselineWithIcon = 6.dp
423 // Distance from the first text baseline to the bottom of the icon in a combined tab
424 private val IconDistanceFromBaseline = 20.sp
425 // Distance from the end of the leading icon to the start of the text
426 private val TextDistanceFromLeadingIcon = 8.dp
427