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  * ![Tab image](https://developer.android.com/images/reference/androidx/compose/material/tab.png)
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  * ![Tab image](https://developer.android.com/images/reference/androidx/compose/material/tab.png)
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  * ![Tab image](https://developer.android.com/images/reference/androidx/compose/material/tab.png)
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