1 /*
<lambda>null2 * Copyright 2022 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.internal
18
19 import androidx.compose.animation.animateColor
20 import androidx.compose.animation.animateColorAsState
21 import androidx.compose.animation.core.animateDpAsState
22 import androidx.compose.animation.core.animateFloat
23 import androidx.compose.animation.core.updateTransition
24 import androidx.compose.foundation.BorderStroke
25 import androidx.compose.foundation.interaction.InteractionSource
26 import androidx.compose.foundation.interaction.collectIsFocusedAsState
27 import androidx.compose.foundation.layout.Box
28 import androidx.compose.foundation.layout.PaddingValues
29 import androidx.compose.material3.LocalContentColor
30 import androidx.compose.material3.LocalMinimumInteractiveComponentSize
31 import androidx.compose.material3.MaterialTheme
32 import androidx.compose.material3.OutlinedTextFieldLayout
33 import androidx.compose.material3.TextFieldColors
34 import androidx.compose.material3.TextFieldLabelPosition
35 import androidx.compose.material3.TextFieldLabelScope
36 import androidx.compose.material3.TextFieldLayout
37 import androidx.compose.material3.outlineCutout
38 import androidx.compose.material3.tokens.MotionSchemeKeyTokens
39 import androidx.compose.material3.tokens.SmallIconButtonTokens
40 import androidx.compose.material3.tokens.TypeScaleTokens
41 import androidx.compose.material3.value
42 import androidx.compose.runtime.Composable
43 import androidx.compose.runtime.CompositionLocalProvider
44 import androidx.compose.runtime.State
45 import androidx.compose.runtime.derivedStateOf
46 import androidx.compose.runtime.getValue
47 import androidx.compose.runtime.mutableStateOf
48 import androidx.compose.runtime.remember
49 import androidx.compose.runtime.rememberUpdatedState
50 import androidx.compose.runtime.structuralEqualityPolicy
51 import androidx.compose.ui.Alignment
52 import androidx.compose.ui.Modifier
53 import androidx.compose.ui.draw.drawWithCache
54 import androidx.compose.ui.geometry.Size
55 import androidx.compose.ui.graphics.Color
56 import androidx.compose.ui.graphics.ColorProducer
57 import androidx.compose.ui.graphics.Shape
58 import androidx.compose.ui.graphics.drawOutline
59 import androidx.compose.ui.graphics.graphicsLayer
60 import androidx.compose.ui.graphics.takeOrElse
61 import androidx.compose.ui.layout.layout
62 import androidx.compose.ui.layout.layoutId
63 import androidx.compose.ui.platform.LocalDensity
64 import androidx.compose.ui.semantics.error
65 import androidx.compose.ui.semantics.semantics
66 import androidx.compose.ui.text.TextStyle
67 import androidx.compose.ui.text.lerp
68 import androidx.compose.ui.unit.Dp
69 import androidx.compose.ui.unit.coerceAtLeast
70 import androidx.compose.ui.unit.constrainHeight
71 import androidx.compose.ui.unit.dp
72 import androidx.compose.ui.unit.isUnspecified
73
74 internal enum class TextFieldType {
75 Filled,
76 Outlined
77 }
78
79 @Composable
80 internal fun CommonDecorationBox(
81 type: TextFieldType,
82 visualText: CharSequence,
83 innerTextField: @Composable () -> Unit,
84 labelPosition: TextFieldLabelPosition,
85 label: @Composable (TextFieldLabelScope.() -> Unit)?,
86 placeholder: @Composable (() -> Unit)?,
87 leadingIcon: @Composable (() -> Unit)?,
88 trailingIcon: @Composable (() -> Unit)?,
89 prefix: @Composable (() -> Unit)?,
90 suffix: @Composable (() -> Unit)?,
91 supportingText: @Composable (() -> Unit)?,
92 singleLine: Boolean,
93 enabled: Boolean,
94 isError: Boolean,
95 interactionSource: InteractionSource,
96 contentPadding: PaddingValues,
97 colors: TextFieldColors,
98 container: @Composable () -> Unit,
99 ) {
100 val isFocused = interactionSource.collectIsFocusedAsState().value
101 val inputState =
102 when {
103 isFocused -> InputPhase.Focused
104 visualText.isEmpty() -> InputPhase.UnfocusedEmpty
105 else -> InputPhase.UnfocusedNotEmpty
106 }
107
108 val labelColor = colors.labelColor(enabled, isError, isFocused)
109
110 val typography = MaterialTheme.typography
111 val bodyLarge = typography.bodyLarge
112 val bodySmall = typography.bodySmall
113 val overrideLabelTextStyleColor =
114 (bodyLarge.color == Color.Unspecified && bodySmall.color != Color.Unspecified) ||
115 (bodyLarge.color != Color.Unspecified && bodySmall.color == Color.Unspecified)
116
117 TextFieldTransitionScope(
118 inputState = inputState,
119 focusedLabelTextStyleColor =
120 with(bodySmall.color) {
<lambda>null121 if (overrideLabelTextStyleColor) this.takeOrElse { labelColor } else this
122 },
123 unfocusedLabelTextStyleColor =
124 with(bodyLarge.color) {
<lambda>null125 if (overrideLabelTextStyleColor) this.takeOrElse { labelColor } else this
126 },
127 labelColor = labelColor,
128 showExpandedLabel = label != null && labelPosition.showExpandedLabel,
129 ) { labelProgress, labelTextStyleColor, labelContentColor, placeholderAlpha, prefixSuffixAlpha
130 ->
<lambda>null131 val labelScope = remember {
132 object : TextFieldLabelScope {
133 override val labelMinimizedProgress: Float
134 get() = labelProgress.value
135 }
136 }
137 val decoratedLabel: @Composable (() -> Unit)? =
labelnull138 label?.let { label ->
139 @Composable {
140 val labelTextStyle =
141 lerp(bodyLarge, bodySmall, labelProgress.value).let { textStyle ->
142 if (overrideLabelTextStyleColor) {
143 textStyle.copy(color = labelTextStyleColor.value)
144 } else {
145 textStyle
146 }
147 }
148 Decoration(labelContentColor.value, labelTextStyle) { labelScope.label() }
149 }
150 }
151
152 // Transparent components interfere with Talkback (b/261061240), so if any components below
153 // have alpha == 0, we set the component to null instead.
154
155 val placeholderColor = colors.placeholderColor(enabled, isError, isFocused)
<lambda>null156 val showPlaceholder by remember {
157 derivedStateOf(structuralEqualityPolicy()) { placeholderAlpha.value > 0f }
158 }
159 val decoratedPlaceholder: @Composable ((Modifier) -> Unit)? =
160 if (placeholder != null && visualText.isEmpty() && showPlaceholder) {
modifiernull161 @Composable { modifier ->
162 Box(modifier.graphicsLayer { alpha = placeholderAlpha.value }) {
163 Decoration(
164 contentColor = placeholderColor,
165 textStyle = bodyLarge,
166 content = placeholder
167 )
168 }
169 }
170 } else null
171
172 val prefixColor = colors.prefixColor(enabled, isError, isFocused)
<lambda>null173 val showPrefixSuffix by remember {
174 derivedStateOf(structuralEqualityPolicy()) { prefixSuffixAlpha.value > 0f }
175 }
176 val decoratedPrefix: @Composable (() -> Unit)? =
177 if (prefix != null && showPrefixSuffix) {
<lambda>null178 @Composable {
179 Box(Modifier.graphicsLayer { alpha = prefixSuffixAlpha.value }) {
180 Decoration(
181 contentColor = prefixColor,
182 textStyle = bodyLarge,
183 content = prefix
184 )
185 }
186 }
187 } else null
188
189 val suffixColor = colors.suffixColor(enabled, isError, isFocused)
190 val decoratedSuffix: @Composable (() -> Unit)? =
191 if (suffix != null && showPrefixSuffix) {
<lambda>null192 @Composable {
193 Box(Modifier.graphicsLayer { alpha = prefixSuffixAlpha.value }) {
194 Decoration(
195 contentColor = suffixColor,
196 textStyle = bodyLarge,
197 content = suffix
198 )
199 }
200 }
201 } else null
202
203 val leadingIconColor = colors.leadingIconColor(enabled, isError, isFocused)
204 val decoratedLeading: @Composable (() -> Unit)? =
<lambda>null205 leadingIcon?.let {
206 @Composable { Decoration(contentColor = leadingIconColor, content = it) }
207 }
208
209 val trailingIconColor = colors.trailingIconColor(enabled, isError, isFocused)
210 val decoratedTrailing: @Composable (() -> Unit)? =
<lambda>null211 trailingIcon?.let {
212 @Composable { Decoration(contentColor = trailingIconColor, content = it) }
213 }
214
215 val supportingTextColor = colors.supportingTextColor(enabled, isError, isFocused)
216 val decoratedSupporting: @Composable (() -> Unit)? =
<lambda>null217 supportingText?.let {
218 @Composable {
219 Decoration(
220 contentColor = supportingTextColor,
221 textStyle = bodySmall,
222 content = it
223 )
224 }
225 }
226
227 when (type) {
228 TextFieldType.Filled -> {
229 val containerWithId: @Composable () -> Unit = {
<lambda>null230 Box(Modifier.layoutId(ContainerId), propagateMinConstraints = true) {
231 container()
232 }
233 }
234
235 TextFieldLayout(
236 modifier = Modifier,
237 textField = innerTextField,
238 placeholder = decoratedPlaceholder,
239 label = decoratedLabel,
240 leading = decoratedLeading,
241 trailing = decoratedTrailing,
242 prefix = decoratedPrefix,
243 suffix = decoratedSuffix,
244 container = containerWithId,
245 supporting = decoratedSupporting,
246 singleLine = singleLine,
247 labelPosition = labelPosition,
248 labelProgress = labelProgress::value,
249 paddingValues = contentPadding
250 )
251 }
252 TextFieldType.Outlined -> {
253 // Outlined cutout
<lambda>null254 val cutoutSize = remember { mutableStateOf(Size.Zero) }
255 val borderContainerWithId: @Composable () -> Unit = {
256 Box(
257 Modifier.layoutId(ContainerId)
258 .outlineCutout(
259 labelSize = cutoutSize::value,
260 alignment = labelPosition.minimizedAlignment,
261 paddingValues = contentPadding
262 ),
263 propagateMinConstraints = true
<lambda>null264 ) {
265 container()
266 }
267 }
268
269 OutlinedTextFieldLayout(
270 modifier = Modifier,
271 textField = innerTextField,
272 placeholder = decoratedPlaceholder,
273 label = decoratedLabel,
274 leading = decoratedLeading,
275 trailing = decoratedTrailing,
276 prefix = decoratedPrefix,
277 suffix = decoratedSuffix,
278 supporting = decoratedSupporting,
279 singleLine = singleLine,
<lambda>null280 onLabelMeasured = {
281 if (labelPosition is TextFieldLabelPosition.Above) {
282 return@OutlinedTextFieldLayout
283 }
284 val progress = labelProgress.value
285 val labelWidth = it.width * progress
286 val labelHeight = it.height * progress
287 if (
288 cutoutSize.value.width != labelWidth ||
289 cutoutSize.value.height != labelHeight
290 ) {
291 cutoutSize.value = Size(labelWidth, labelHeight)
292 }
293 },
294 labelPosition = labelPosition,
295 labelProgress = labelProgress::value,
296 container = borderContainerWithId,
297 paddingValues = contentPadding
298 )
299 }
300 }
301 }
302 }
303
304 private val TextFieldLabelPosition.showExpandedLabel: Boolean
305 get() = this is TextFieldLabelPosition.Attached && !alwaysMinimize
306
307 internal val TextFieldLabelPosition.minimizedAlignment: Alignment.Horizontal
308 get() =
309 when (this) {
310 is TextFieldLabelPosition.Above -> alignment
311 is TextFieldLabelPosition.Attached -> minimizedAlignment
312 else -> throw IllegalArgumentException("Unknown position: $this")
313 }
314
315 internal val TextFieldLabelPosition.expandedAlignment: Alignment.Horizontal
316 get() =
317 when (this) {
318 is TextFieldLabelPosition.Above -> alignment
319 is TextFieldLabelPosition.Attached -> expandedAlignment
320 else -> throw IllegalArgumentException("Unknown position: $this")
321 }
322
323 /** Decorates [content] with [contentColor] and [textStyle]. */
324 @Composable
Decorationnull325 private fun Decoration(contentColor: Color, textStyle: TextStyle, content: @Composable () -> Unit) =
326 ProvideContentColorTextStyle(contentColor, textStyle, content)
327
328 /** Decorates [content] with [contentColor]. */
329 @Composable
330 private fun Decoration(contentColor: Color, content: @Composable () -> Unit) =
331 CompositionLocalProvider(LocalContentColor provides contentColor, content = content)
332
333 // Developers need to handle invalid input manually. But since we don't provide an error message
334 // slot API, we can set the default error message in case developers forget about it.
335 internal fun Modifier.defaultErrorSemantics(
336 isError: Boolean,
337 defaultErrorMessage: String,
338 ): Modifier = if (isError) semantics { error(defaultErrorMessage) } else this
339
340 /**
341 * Replacement for Modifier.background which takes color lazily to avoid recomposition while
342 * animating.
343 */
textFieldBackgroundnull344 internal fun Modifier.textFieldBackground(
345 color: ColorProducer,
346 shape: Shape,
347 ): Modifier =
348 this.drawWithCache {
349 val outline = shape.createOutline(size, layoutDirection, this)
350 onDrawBehind { drawOutline(outline, color = color()) }
351 }
352
353 /**
354 * Replacement for Modifier.heightIn which takes the constraint lazily to avoid recomposition while
355 * animating.
356 */
textFieldLabelMinHeightnull357 internal fun Modifier.textFieldLabelMinHeight(minHeight: () -> Dp): Modifier =
358 this.layout { measurable, constraints ->
359 @Suppress("NAME_SHADOWING") val minHeight = minHeight()
360 val resolvedMinHeight =
361 constraints.constrainHeight(
362 if (minHeight != Dp.Unspecified) minHeight.roundToPx() else 0
363 )
364 val placeable = measurable.measure(constraints.copy(minHeight = resolvedMinHeight))
365 layout(placeable.width, placeable.height) { placeable.place(0, 0) }
366 }
367
368 @Suppress("BanInlineOptIn")
369 @Composable
TextFieldTransitionScopenull370 private inline fun TextFieldTransitionScope(
371 inputState: InputPhase,
372 focusedLabelTextStyleColor: Color,
373 unfocusedLabelTextStyleColor: Color,
374 labelColor: Color,
375 showExpandedLabel: Boolean,
376 content:
377 @Composable
378 (
379 labelProgress: State<Float>,
380 labelTextStyleColor: State<Color>,
381 labelContentColor: State<Color>,
382 placeholderOpacity: State<Float>,
383 prefixSuffixOpacity: State<Float>,
384 ) -> Unit
385 ) {
386 // Transitions from/to InputPhase.Focused are the most critical in the transition below.
387 // UnfocusedEmpty <-> UnfocusedNotEmpty are needed when a single state is used to control
388 // multiple text fields.
389 val transition = updateTransition(inputState, label = "TextFieldInputState")
390
391 // TODO Load the motionScheme tokens from the component tokens file
392 val labelTransitionSpec = MotionSchemeKeyTokens.FastSpatial.value<Float>()
393 val labelProgress =
394 transition.animateFloat(label = "LabelProgress", transitionSpec = { labelTransitionSpec }) {
395 when (it) {
396 InputPhase.Focused -> 1f
397 InputPhase.UnfocusedEmpty -> if (showExpandedLabel) 0f else 1f
398 InputPhase.UnfocusedNotEmpty -> 1f
399 }
400 }
401
402 val fastOpacityTransitionSpec = MotionSchemeKeyTokens.FastEffects.value<Float>()
403 val slowOpacityTransitionSpec = MotionSchemeKeyTokens.SlowEffects.value<Float>()
404 val placeholderOpacity =
405 transition.animateFloat(
406 label = "PlaceholderOpacity",
407 transitionSpec = {
408 if (InputPhase.Focused isTransitioningTo InputPhase.UnfocusedEmpty) {
409 fastOpacityTransitionSpec
410 } else if (
411 InputPhase.UnfocusedEmpty isTransitioningTo InputPhase.Focused ||
412 InputPhase.UnfocusedNotEmpty isTransitioningTo InputPhase.UnfocusedEmpty
413 ) {
414 slowOpacityTransitionSpec
415 } else {
416 fastOpacityTransitionSpec
417 }
418 }
419 ) {
420 when (it) {
421 InputPhase.Focused -> 1f
422 InputPhase.UnfocusedEmpty -> if (showExpandedLabel) 0f else 1f
423 InputPhase.UnfocusedNotEmpty -> 0f
424 }
425 }
426
427 val prefixSuffixOpacity =
428 transition.animateFloat(
429 label = "PrefixSuffixOpacity",
430 transitionSpec = { fastOpacityTransitionSpec }
431 ) {
432 when (it) {
433 InputPhase.Focused -> 1f
434 InputPhase.UnfocusedEmpty -> if (showExpandedLabel) 0f else 1f
435 InputPhase.UnfocusedNotEmpty -> 1f
436 }
437 }
438
439 val colorTransitionSpec = MotionSchemeKeyTokens.FastEffects.value<Color>()
440 val labelTextStyleColor =
441 transition.animateColor(
442 transitionSpec = { colorTransitionSpec },
443 label = "LabelTextStyleColor"
444 ) {
445 when (it) {
446 InputPhase.Focused -> focusedLabelTextStyleColor
447 else -> unfocusedLabelTextStyleColor
448 }
449 }
450
451 @Suppress("UnusedTransitionTargetStateParameter")
452 val labelContentColor =
453 transition.animateColor(
454 transitionSpec = { colorTransitionSpec },
455 label = "LabelContentColor",
456 targetValueByState = { labelColor }
457 )
458
459 content(
460 labelProgress,
461 labelTextStyleColor,
462 labelContentColor,
463 placeholderOpacity,
464 prefixSuffixOpacity,
465 )
466 }
467
468 @Composable
animateBorderStrokeAsStatenull469 internal fun animateBorderStrokeAsState(
470 enabled: Boolean,
471 isError: Boolean,
472 focused: Boolean,
473 colors: TextFieldColors,
474 focusedBorderThickness: Dp,
475 unfocusedBorderThickness: Dp
476 ): State<BorderStroke> {
477 // TODO Load the motionScheme tokens from the component tokens file
478 val targetColor = colors.indicatorColor(enabled, isError, focused)
479 val colorAnimationSpec = MotionSchemeKeyTokens.FastEffects.value<Color>()
480 val indicatorColor =
481 if (enabled) {
482 animateColorAsState(targetColor, colorAnimationSpec)
483 } else {
484 rememberUpdatedState(targetColor)
485 }
486
487 val thicknessAnimationSpec = MotionSchemeKeyTokens.FastSpatial.value<Dp>()
488 val thickness =
489 if (enabled) {
490 val targetThickness = if (focused) focusedBorderThickness else unfocusedBorderThickness
491 animateDpAsState(targetThickness, thicknessAnimationSpec)
492 } else {
493 rememberUpdatedState(unfocusedBorderThickness)
494 }
495
496 return rememberUpdatedState(BorderStroke(thickness.value, indicatorColor.value))
497 }
498
499 /** An internal state used to animate a label and an indicator. */
500 private enum class InputPhase {
501 // Text field is focused
502 Focused,
503
504 // Text field is not focused and input text is empty
505 UnfocusedEmpty,
506
507 // Text field is not focused but input text is not empty
508 UnfocusedNotEmpty
509 }
510
511 internal const val TextFieldId = "TextField"
512 internal const val PlaceholderId = "Hint"
513 internal const val LabelId = "Label"
514 internal const val LeadingId = "Leading"
515 internal const val TrailingId = "Trailing"
516 internal const val PrefixId = "Prefix"
517 internal const val SuffixId = "Suffix"
518 internal const val SupportingId = "Supporting"
519 internal const val ContainerId = "Container"
520
521 // Icons are 24dp but padded to LocalMinimumInteractiveComponentSize (48dp by default), so we need
522 // to account for this visual discrepancy when applying user padding.
523 @Composable
textFieldHorizontalIconPaddingnull524 internal fun textFieldHorizontalIconPadding(): Dp {
525 val interactiveSizeOrNaN = LocalMinimumInteractiveComponentSize.current
526 val interactiveSize = if (interactiveSizeOrNaN.isUnspecified) 0.dp else interactiveSizeOrNaN
527 return ((interactiveSize - SmallIconButtonTokens.IconSize) / 2).coerceAtLeast(0.dp)
528 }
529
530 @Composable
minimizedLabelHalfHeightnull531 internal fun minimizedLabelHalfHeight(): Dp {
532 val compositionLocalValue = MaterialTheme.typography.bodySmall.lineHeight
533 val fallbackValue = TypeScaleTokens.BodySmallLineHeight
534 val value = if (compositionLocalValue.isSp) compositionLocalValue else fallbackValue
535 return with(LocalDensity.current) { value.toDp() / 2 }
536 }
537
538 internal val TextFieldPadding = 16.dp
539 internal val AboveLabelHorizontalPadding = 4.dp
540 internal val AboveLabelBottomPadding = 4.dp
541 internal val SupportingTopPadding = 4.dp
542 internal val PrefixSuffixTextPadding = 2.dp
543 internal val MinTextLineHeight = 24.dp
544 internal val MinFocusedLabelLineHeight = 16.dp
545 internal val MinSupportingTextLineHeight = 16.dp
546