diff --git a/ui/report/compose-metrics/ui_debug-module.json b/ui/report/compose-metrics/ui_debug-module.json index 74c3c268c..f31ff27b9 100644 --- a/ui/report/compose-metrics/ui_debug-module.json +++ b/ui/report/compose-metrics/ui_debug-module.json @@ -1,25 +1,25 @@ { - "skippableComposables": 8, - "restartableComposables": 14, + "skippableComposables": 10, + "restartableComposables": 18, "readonlyComposables": 1, - "totalComposables": 53, - "restartGroups": 14, - "totalGroups": 67, - "staticArguments": 55, - "certainArguments": 273, - "knownStableArguments": 624, - "knownUnstableArguments": 32, + "totalComposables": 59, + "restartGroups": 18, + "totalGroups": 73, + "staticArguments": 60, + "certainArguments": 318, + "knownStableArguments": 735, + "knownUnstableArguments": 35, "unknownStableArguments": 0, - "totalArguments": 656, - "markedStableClasses": 18, - "inferredStableClasses": 11, + "totalArguments": 770, + "markedStableClasses": 19, + "inferredStableClasses": 12, "inferredUnstableClasses": 0, "inferredUncertainClasses": 0, - "effectivelyStableClasses": 29, - "totalClasses": 29, - "memoizedLambdas": 23, - "singletonLambdas": 5, + "effectivelyStableClasses": 31, + "totalClasses": 31, + "memoizedLambdas": 31, + "singletonLambdas": 7, "singletonComposableLambdas": 0, - "composableLambdas": 3, - "totalLambdas": 35 + "composableLambdas": 4, + "totalLambdas": 45 } \ No newline at end of file diff --git a/ui/report/compose-metrics/ui_debugUnitTest-module.json b/ui/report/compose-metrics/ui_debugUnitTest-module.json index 09dd80b45..149ca7b38 100644 --- a/ui/report/compose-metrics/ui_debugUnitTest-module.json +++ b/ui/report/compose-metrics/ui_debugUnitTest-module.json @@ -1,25 +1,25 @@ { - "skippableComposables": 139, - "restartableComposables": 140, + "skippableComposables": 8, + "restartableComposables": 8, "readonlyComposables": 0, - "totalComposables": 140, - "restartGroups": 140, - "totalGroups": 140, - "staticArguments": 460, + "totalComposables": 8, + "restartGroups": 8, + "totalGroups": 8, + "staticArguments": 14, "certainArguments": 0, - "knownStableArguments": 1773, + "knownStableArguments": 64, "knownUnstableArguments": 0, "unknownStableArguments": 0, - "totalArguments": 1773, + "totalArguments": 64, "markedStableClasses": 0, - "inferredStableClasses": 2, - "inferredUnstableClasses": 1, - "inferredUncertainClasses": 10, - "effectivelyStableClasses": 2, - "totalClasses": 13, - "memoizedLambdas": 221, - "singletonLambdas": 78, - "singletonComposableLambdas": 102, - "composableLambdas": 139, - "totalLambdas": 226 + "inferredStableClasses": 0, + "inferredUnstableClasses": 0, + "inferredUncertainClasses": 1, + "effectivelyStableClasses": 0, + "totalClasses": 1, + "memoizedLambdas": 12, + "singletonLambdas": 4, + "singletonComposableLambdas": 8, + "composableLambdas": 8, + "totalLambdas": 12 } \ No newline at end of file diff --git a/ui/report/compose-reports/ui_debug-classes.txt b/ui/report/compose-reports/ui_debug-classes.txt index f0c83eedd..bcc6b0caa 100644 --- a/ui/report/compose-reports/ui_debug-classes.txt +++ b/ui/report/compose-reports/ui_debug-classes.txt @@ -142,6 +142,21 @@ stable class QuackGrayscaleOutlinedTagDefaults { stable var typography: QuackTypography stable var unselectedTypography: QuackTypography } +stable class TextAreaColors { + stable val backgroundColor: QuackColor + stable val contentColor: QuackColor + stable val placeholderColor: QuackColor + = Stable +} +stable class QuackTextAreaDefaultStyle { + stable var radius: Dp + stable var minHeight: Dp + stable var colors: TextAreaColors + stable var border: QuackBorder + stable var contentPadding: QuackPadding + stable var placeholderTypography: QuackTypography + stable var typography: QuackTypography +} stable class Success { stable val label: String? = Stable diff --git a/ui/report/compose-reports/ui_debug-composables.csv b/ui/report/compose-reports/ui_debug-composables.csv index b8ce61392..336c5f42e 100644 --- a/ui/report/compose-reports/ui_debug-composables.csv +++ b/ui/report/compose-reports/ui_debug-composables.csv @@ -35,6 +35,9 @@ team.duckie.quackquack.ui.QuackBaseTag,QuackBaseTag,1,1,1,0,0,0,0,0,1,2, team.duckie.quackquack.ui.QuackText,QuackText,1,1,1,0,0,0,0,0,4,11, team.duckie.quackquack.ui.ClickableText,ClickableText,1,1,1,0,0,0,0,0,1,4, team.duckie.quackquack.ui.rememberSpanAnnotatedString,rememberSpanAnnotatedString,1,0,0,0,0,0,0,0,1,1, +team.duckie.quackquack.ui.QuackTextArea,QuackTextArea,1,0,0,0,0,0,0,0,1,7, +team.duckie.quackquack.ui.QuackTextArea,QuackTextArea,1,0,0,0,0,0,0,0,1,6, +team.duckie.quackquack.ui.QuackBaseTextArea,QuackBaseTextArea,1,1,1,0,0,0,0,0,1,19, team.duckie.quackquack.ui.QuackDefaultTextField,QuackDefaultTextField,1,0,0,0,0,0,0,0,1,7, team.duckie.quackquack.ui.QuackDefaultTextField,QuackDefaultTextField,1,0,0,0,0,0,0,0,1,10, team.duckie.quackquack.ui.QuackFilledTextField,QuackFilledTextField,1,0,0,0,0,0,0,0,1,7, diff --git a/ui/report/compose-reports/ui_debug-composables.txt b/ui/report/compose-reports/ui_debug-composables.txt index cc936dc7d..bbeb18585 100644 --- a/ui/report/compose-reports/ui_debug-composables.txt +++ b/ui/report/compose-reports/ui_debug-composables.txt @@ -287,6 +287,78 @@ fun rememberSpanAnnotatedString( stable spanStyle: SpanStyle unstable annotationTexts: List ): AnnotatedString +scheme("[androidx.compose.ui.UiComposable]") fun QuackTextArea( + stable value: String + stable onValueChange: Function1<@[ParameterName(name = 'value')] String, Unit> + stable style: QuackTextAreaStyle? = @static Companion.Default + stable modifier: Modifier? = @static Companion + stable enabled: Boolean = @static true + stable readOnly: Boolean = @static false + stable placeholderText: String? = @static null + stable keyboardOptions: KeyboardOptions? = @static Companion.Default + stable keyboardActions: KeyboardActions? = @static Companion.Default + stable minLines: Int = @static 1 + stable maxLines: Int = @static Companion.MAX_VALUE + stable visualTransformation: VisualTransformation? = @static Companion.None + stable onTextLayout: Function1<@[ParameterName(name = 'layoutResult')] TextLayoutResult, Unit>? = @static { it: TextLayoutResult -> + +} + + stable interactionSource: MutableInteractionSource? = @static remember({ + MutableInteractionSource ( ) +} +, $composer, 0) +) +scheme("[androidx.compose.ui.UiComposable]") fun QuackTextArea( + stable value: TextFieldValue + stable onValueChange: Function1<@[ParameterName(name = 'value')] TextFieldValue, Unit> + stable style: QuackTextAreaStyle? = @static Companion.Default + stable modifier: Modifier? = @static Companion + stable enabled: Boolean = @static true + stable readOnly: Boolean = @static false + stable placeholderText: String? = @static null + stable keyboardOptions: KeyboardOptions? = @static Companion.Default + stable keyboardActions: KeyboardActions? = @static Companion.Default + stable minLines: Int = @static 1 + stable maxLines: Int = @static Companion.MAX_VALUE + stable visualTransformation: VisualTransformation? = @static Companion.None + stable onTextLayout: Function1<@[ParameterName(name = 'layoutResult')] TextLayoutResult, Unit>? = @static { it: TextLayoutResult -> + +} + + stable interactionSource: MutableInteractionSource? = @static remember({ + MutableInteractionSource ( ) +} +, $composer, 0) +) +restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun QuackBaseTextArea( + stable value: TextFieldValue + stable onValueChange: Function1<@[ParameterName(name = 'value')] TextFieldValue, Unit> + stable modifier: Modifier + stable radius: Dp + stable minHeight: Dp + stable border: QuackBorder + stable enabled: Boolean + stable readOnly: Boolean + stable placeholderText: String? + stable keyboardOptions: KeyboardOptions + stable keyboardActions: KeyboardActions + stable minLines: Int + stable maxLines: Int + stable visualTransformation: VisualTransformation + stable onTextLayout: Function1<@[ParameterName(name = 'layoutResult')] TextLayoutResult, Unit> + stable interactionSource: MutableInteractionSource + stable backgroundColor: QuackColor + stable contentPadding: PaddingValues + stable typography: QuackTypography + stable placeholderTypography: QuackTypography + stable counterBaseColor: QuackColor? + stable counterHighlightColor: QuackColor? + stable counterTypography: QuackTypography? + stable counterBaseAndHighlightGap: Dp? + stable counterMaxLength: Int? + stable counterContentGap: Dp? +) fun QuackDefaultTextField( stable value: String stable onValueChange: Function1<@[ParameterName(name = 'value')] String, Unit> diff --git a/ui/report/compose-reports/ui_debugUnitTest-classes.txt b/ui/report/compose-reports/ui_debugUnitTest-classes.txt index 72534dd46..3e645f615 100644 --- a/ui/report/compose-reports/ui_debugUnitTest-classes.txt +++ b/ui/report/compose-reports/ui_debugUnitTest-classes.txt @@ -1,52 +1,4 @@ -runtime class ImageSnapshot { +runtime class TextAreaSnapshot { runtime val snapshotPath: SnapshotPathGeneratorRule = Runtime(SnapshotPathGeneratorRule) } -runtime class SwitchSnapshot { - runtime val snapshotPath: SnapshotPathGeneratorRule - = Runtime(SnapshotPathGeneratorRule) -} -runtime class TabSnapshot { - runtime val snapshotPath: SnapshotPathGeneratorRule - = Runtime(SnapshotPathGeneratorRule) -} -unstable class TagSnapshot { - unstable val testNameToSelectState: MutableMap - runtime val compose: AndroidComposeTestRule, ComponentActivity> - unstable val roborazzi: RoborazziRule - = Unstable -} -runtime class TextFieldSnapshot { - runtime val snapshotPath: SnapshotPathGeneratorRule - = Runtime(SnapshotPathGeneratorRule) -} -runtime class TextSnapshot { - runtime val snapshotPath: SnapshotPathGeneratorRule - = Runtime(SnapshotPathGeneratorRule) -} -runtime class ButtonTest { - runtime val compose: AndroidComposeTestRule, ComponentActivity> - = Runtime(AndroidComposeTestRule) -} -runtime class TabTest { - runtime val compose: AndroidComposeTestRule, ComponentActivity> - = Runtime(AndroidComposeTestRule) -} -runtime class TagTest { - runtime val compose: AndroidComposeTestRule, ComponentActivity> - = Runtime(AndroidComposeTestRule) -} -runtime class TextFieldTest { - runtime val compose: AndroidComposeTestRule, ComponentActivity> - = Runtime(AndroidComposeTestRule) -} -runtime class TextTest { - runtime val compose: AndroidComposeTestRule, ComponentActivity> - = Runtime(AndroidComposeTestRule) -} -stable class HashCodeTest { - = Stable -} -stable class NumberBuilderTest { - = Stable -} diff --git a/ui/src/main/kotlin/team/duckie/quackquack/ui/textarea.kt b/ui/src/main/kotlin/team/duckie/quackquack/ui/textarea.kt new file mode 100644 index 000000000..bcc0a9c5a --- /dev/null +++ b/ui/src/main/kotlin/team/duckie/quackquack/ui/textarea.kt @@ -0,0 +1,566 @@ +/* + * Designed and developed by Duckie Team 2023. + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE + */ + +@file:Suppress("ModifierParameter") + +package team.duckie.quackquack.ui + +import androidx.annotation.IntRange +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.currentComposer +import androidx.compose.runtime.currentRecomposeScope +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.platform.inspectable +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.constrainHeight +import androidx.compose.ui.unit.constrainWidth +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.offset +import androidx.compose.ui.unit.sp +import kotlin.math.max +import team.duckie.quackquack.casa.annotation.CasaValue +import team.duckie.quackquack.material.QuackBorder +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackPadding +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackSurface +import team.duckie.quackquack.material.theme.LocalQuackTextFieldTheme +import team.duckie.quackquack.runtime.QuackDataModifierModel +import team.duckie.quackquack.runtime.quackMaterializeOf +import team.duckie.quackquack.sugar.material.SugarToken +import team.duckie.quackquack.ui.plugin.interceptor.rememberInterceptedStyleSafely +import team.duckie.quackquack.ui.util.LazyValue +import team.duckie.quackquack.ui.util.buildInt +import team.duckie.quackquack.ui.util.rememberLtrTextMeasurer +import team.duckie.quackquack.util.applyIf +import team.duckie.quackquack.util.fastFirstIsInstanceOrNull + +@Immutable +public interface TextAreaStyleMarker + +@Immutable +public interface QuackTextAreaStyle { + public val radius: Dp + public val minHeight: Dp + public val colors: TextAreaColors + + public val border: QuackBorder + public val contentPadding: QuackPadding + + public val placeholderTypography: QuackTypography + public val typography: QuackTypography + + public operator fun invoke(style: QuackTextAreaDefaultStyle.() -> Unit): QuackTextAreaStyle + + public companion object { + @Stable + public val Default: QuackTextAreaStyle get() = QuackTextAreaDefaultStyle() + } +} + +public data class TextAreaColors internal constructor( + public val backgroundColor: QuackColor, + public val contentColor: QuackColor, + public val placeholderColor: QuackColor, +) + +@Immutable +public class QuackTextAreaDefaultStyle : QuackTextAreaStyle { + override var radius: Dp = 8.dp + override var minHeight: Dp = 140.dp + override var colors: TextAreaColors = + TextAreaColors( + backgroundColor = QuackColor.White, + contentColor = QuackColor.Black, + placeholderColor = QuackColor.Gray2, + ) + + override var border: QuackBorder = QuackBorder(thickness = 1.dp, color = QuackColor.Gray3) + override var contentPadding: QuackPadding = QuackPadding(horizontal = 12.dp, vertical = 12.dp) + + override var placeholderTypography: QuackTypography = QuackTypography.Body1 + override var typography: QuackTypography = QuackTypography.Body1 + + public override fun invoke(style: QuackTextAreaDefaultStyle.() -> Unit): QuackTextAreaStyle = + apply(style) +} + +@Stable +private data class TextAreaCounterData( + val baseColor: QuackColor, + val highlightColor: QuackColor, + val typography: QuackTypography, + val baseAndHighlightGap: Dp, + val maxLength: Int, + val contentGap: Dp, +) : QuackDataModifierModel + +/** + * TextArea에 counter를 표시합니다. + * + * counter는 다음과 같은 구조를 갖습니다. + * + * ``` + * // 현재 10자가 입력됐고, 최대 20자까지 허용이라면 + * + * 10 /20 + * ``` + * + * 위 예시에서 현재 입력된 글자 수를 의미하는 `10` 부분을 highlight 영역이라 하고, + * 최대 글자 수를 의미하는 `/20` 부분을 base 영역이라 합니다. + * + * @param maxLength 허용하는 최대 글자 수 (base 영역으로 표시할 글자 수) + * @param baseColor base 영역의 색상 + * @param highlightColor highlight 영역의 색상 + * @param typography base 영역과 highlight 영역 모두에 공통으로 사용할 타이포그래피 + * @param baseAndHighlightGap base 영역과 highlight 영역 사이 공간 + * @param contentGap TextArea 컨텐츠와 counter 영역 사이 공간 + */ +@Stable +public fun Modifier.textAreaCounter( + @IntRange(from = 1) maxLength: Int, + baseColor: QuackColor = QuackColor.Gray2, + highlightColor: QuackColor = QuackColor.Black, + typography: QuackTypography = QuackTypography.Body1, + baseAndHighlightGap: Dp = 1.dp, + contentGap: Dp = 8.dp, +): Modifier = + inspectable( + inspectorInfo = debugInspectorInfo { + name = "textAreaCounter" + properties["maxLength"] = maxLength + properties["baseColor"] = baseColor + properties["highlightColor"] = highlightColor + properties["typography"] = typography + properties["baseAndHighlightGap"] = baseAndHighlightGap + properties["contentGap"] = contentGap + }, + ) { + TextAreaCounterData( + baseColor = baseColor, + highlightColor = highlightColor, + typography = typography, + baseAndHighlightGap = baseAndHighlightGap, + maxLength = maxLength, + contentGap = contentGap, + ) + } + +@[NonRestartableComposable Composable] +public fun QuackTextArea( + @CasaValue("\"QuackTextAreaPreview\"") value: String, + @CasaValue("{}") onValueChange: (value: String) -> Unit, + @[SugarToken CasaValue("QuackTextAreaStyle.Default")] style: QuackTextAreaStyle = QuackTextAreaStyle.Default, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + placeholderText: String? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + @IntRange(from = 1) minLines: Int = 1, + @IntRange(from = 1) maxLines: Int = Int.MAX_VALUE, + visualTransformation: VisualTransformation = VisualTransformation.None, + onTextLayout: (layoutResult: TextLayoutResult) -> Unit = {}, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + // start --- COPIED FROM BasicTextField (string value variant) + + // 최신 내부 텍스트 필드 값 상태를 보유합니다. 컴포지션의 올바른 값을 갖기 위해 이 값을 유지해야 합니다. + var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = value)) } + + // 재구성된 최신 TextFieldValue를 보유합니다. 컴포지션을 보존해야 하기 때문에 `TextFieldValue(text = value)`를 + // CoreTextField에 단순히 전달할 수 없었습니다. + val textFieldValue = textFieldValueState.copy(text = value) + + SideEffect { + if (textFieldValue.selection != textFieldValueState.selection || + textFieldValue.composition != textFieldValueState.composition + ) { + textFieldValueState = textFieldValue + } + } + + // 텍스트 필드가 재구성되었거나 onValueChange 콜백에서 업데이트된 마지막 문자열 값입니다. + // 이 값을 추적하여 다음과 같은 경우 동일한 문자열에 대해 onValueChange(String)를 호출하지 않도록 합니다. + // CoreTextField의 onValueChange가 중간에 재구성되지 않고 여러 번 호출되는 것을 방지합니다. + var lastTextValue by remember(value) { mutableStateOf(value) } + + QuackTextArea( + value = textFieldValue, + onValueChange = { newTextFieldValueState -> + textFieldValueState = newTextFieldValueState + + val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValueState.text + lastTextValue = newTextFieldValueState.text + + if (stringChangedSinceLastInvocation) { + onValueChange(newTextFieldValueState.text) + } + }, + // end --- COPIED FROM BasicTextField (string value variant) + style = style, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + placeholderText = placeholderText, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + minLines = minLines, + maxLines = maxLines, + visualTransformation = visualTransformation, + onTextLayout = onTextLayout, + interactionSource = interactionSource, + ) +} + +@[NonRestartableComposable Composable] +public fun QuackTextArea( + @CasaValue("\"QuackTextAreaPreview\"") value: TextFieldValue, + @CasaValue("{}") onValueChange: (value: TextFieldValue) -> Unit, + @[SugarToken CasaValue("QuackTextAreaStyle.Default")] style: QuackTextAreaStyle = QuackTextAreaStyle.Default, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + placeholderText: String? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + @IntRange(from = 1) minLines: Int = 1, + @IntRange(from = 1) maxLines: Int = Int.MAX_VALUE, + visualTransformation: VisualTransformation = VisualTransformation.None, + onTextLayout: (layoutResult: TextLayoutResult) -> Unit = {}, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + @Suppress("NAME_SHADOWING") + val style = rememberInterceptedStyleSafely(style = style, modifier = modifier) + + val (composeModifier, quackDataModels) = currentComposer.quackMaterializeOf(modifier) + val counterData = remember(quackDataModels) { + quackDataModels.fastFirstIsInstanceOrNull() + } + + val radius = style.radius + val minHeight = style.minHeight + + val backgroundColor = style.colors.backgroundColor + val contentColor = style.colors.contentColor + val placeholderColor = style.colors.placeholderColor + + val border = style.border + val contentPadding = style.contentPadding.asPaddingValues() + + val typography = remember(style.typography, contentColor) { + style.typography.change(color = contentColor) + } + val placeholderTypography = remember(style.typography, placeholderColor) { + style.typography.change(color = placeholderColor) + } + + QuackBaseTextArea( + value = value, + onValueChange = onValueChange, + modifier = composeModifier, + radius = radius, + minHeight = minHeight, + border = border, + enabled = enabled, + readOnly = readOnly, + placeholderText = placeholderText, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + minLines = minLines, + maxLines = maxLines, + visualTransformation = visualTransformation, + onTextLayout = onTextLayout, + interactionSource = interactionSource, + backgroundColor = backgroundColor, + contentPadding = contentPadding, + typography = typography, + placeholderTypography = placeholderTypography, + // decorators + counterBaseColor = counterData?.baseColor, + counterHighlightColor = counterData?.highlightColor, + counterTypography = counterData?.typography, + counterBaseAndHighlightGap = counterData?.baseAndHighlightGap, + counterMaxLength = counterData?.maxLength, + counterContentGap = counterData?.contentGap, + ) +} + +/** 텍스트 필드의 너비가 지정되지 않았을 떄 기본으로 사용할 너비 */ +private val DefaultMinWidth = 200.dp + +@Composable +public fun QuackBaseTextArea( + value: TextFieldValue, + onValueChange: (value: TextFieldValue) -> Unit, + modifier: Modifier, + radius: Dp, + minHeight: Dp, + border: QuackBorder, + enabled: Boolean, + readOnly: Boolean, + placeholderText: String?, + keyboardOptions: KeyboardOptions, + keyboardActions: KeyboardActions, + minLines: Int, + maxLines: Int, + visualTransformation: VisualTransformation, + onTextLayout: (layoutResult: TextLayoutResult) -> Unit, + interactionSource: MutableInteractionSource, + backgroundColor: QuackColor, + contentPadding: PaddingValues, + typography: QuackTypography, + placeholderTypography: QuackTypography, + // decorators + counterBaseColor: QuackColor?, + counterHighlightColor: QuackColor?, + counterTypography: QuackTypography?, + counterBaseAndHighlightGap: Dp?, + counterMaxLength: Int?, + counterContentGap: Dp?, +) { + assertTextAreaValidState( + counterBaseColor = counterBaseColor, + counterHighlightColor = counterHighlightColor, + counterTypography = counterTypography, + counterBaseAndHighlightGap = counterBaseAndHighlightGap, + counterMaxLength = counterMaxLength, + contentGap = counterContentGap, + ) + + val shape = remember { RoundedCornerShape(size = radius) } + + val currentCursorColor = LocalQuackTextFieldTheme.current.cursorColor + val currentDensity = LocalDensity.current + val currentCursorBrush = remember(currentCursorColor, calculation = currentCursorColor::toBrush) + val currentTextStyle = remember(typography, calculation = typography::asComposeStyle) + val currentRecomposeScope = currentRecomposeScope + + val topPaddingPx = with(currentDensity) { contentPadding.calculateTopPadding().roundToPx() } + val bottomPaddingPx = with(currentDensity) { contentPadding.calculateBottomPadding().roundToPx() } + val rightPaddingPx = + with(currentDensity) { contentPadding.calculateRightPadding(LayoutDirection.Ltr).roundToPx() } + + val lazyCoreTextFieldWidth = remember { LazyValue() } + + val placeholderComposeTypography = + remember(placeholderTypography, calculation = placeholderTypography::asComposeStyle) + val placeholderConstraints = + remember(lazyCoreTextFieldWidth.value) { + if (lazyCoreTextFieldWidth.value != null) Constraints(maxWidth = lazyCoreTextFieldWidth.value!!) + else Constraints() + } + val placeholderTextMeasurer = rememberLtrTextMeasurer(/*cacheSize = 5*/) // TODO(pref): param size? + val placeholderTextMeasureResult = + remember( + placeholderComposeTypography, + placeholderConstraints, + placeholderTextMeasurer, + placeholderText, + ) { + if (placeholderText != null) { + placeholderTextMeasurer.measure( + text = placeholderText, + style = placeholderComposeTypography, + constraints = placeholderConstraints, + ) + } else { + null + } + } + + val counterComposeTypography = remember(counterTypography) { + counterTypography?.change(textAlign = TextAlign.End)?.asComposeStyle() + } + val counterPlaceholders = remember(currentDensity, counterBaseAndHighlightGap, value.text.length) { + if (counterBaseAndHighlightGap == null) return@remember emptyList>() + + val currentLength = value.text.length + listOf( + AnnotatedString.Range( + item = Placeholder( + width = with(currentDensity) { counterBaseAndHighlightGap.toSp() }, + height = 1.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter, + ), + start = "$currentLength".length, + end = "$currentLength".length + 1, + ), + ) + } + + val counterTextMeasurer = rememberLtrTextMeasurer(/*cacheSize = 7*/) // TODO(pref): param size? + val counterTextMeasureResult = + remember( + counterPlaceholders, + counterComposeTypography, + counterTextMeasurer, + value.text, + counterMaxLength, + counterHighlightColor, + counterBaseColor, + ) { + if (counterMaxLength != null) { + counterTextMeasurer.measure( + text = buildAnnotatedString { + withStyle(SpanStyle(color = counterHighlightColor!!.value)) { + append(value.text.length.toString()) + } + withStyle(SpanStyle(color = counterBaseColor!!.value)) { + append(" /$counterMaxLength") + } + }, + placeholders = counterPlaceholders, + style = counterComposeTypography!!, + maxLines = 1, + ) + } else { + null + } + } + + BasicTextField( + value = value, + onValueChange = onValueChange, + enabled = enabled, + readOnly = readOnly, + textStyle = currentTextStyle, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = false, + minLines = minLines, + maxLines = maxLines, + visualTransformation = visualTransformation, + onTextLayout = onTextLayout, + interactionSource = interactionSource, + cursorBrush = currentCursorBrush, + ) { coreTextField -> + Layout( + modifier = modifier + .quackSurface( + shape = shape, + backgroundColor = backgroundColor, + border = border, + ) + .applyIf(counterTextMeasureResult != null) { + drawBehind { + drawText( + textLayoutResult = counterTextMeasureResult!!, + topLeft = Offset( + x = size.width - rightPaddingPx - counterTextMeasureResult.size.width, + y = size.height - bottomPaddingPx - counterTextMeasureResult.size.height, + ), + ) + } + }, + content = { + Box( + modifier = Modifier + .then(Modifier) + .applyIf(placeholderTextMeasureResult != null) { + drawBehind { + if (value.text.isEmpty()) drawText(placeholderTextMeasureResult!!) + } + }, + propagateMinConstraints = true, + ) { + coreTextField() + } + }, + ) { measurables, constraints -> + val coreTextFieldMeasurable = measurables.single() + + val leftPaddingPx = contentPadding.calculateLeftPadding(layoutDirection).roundToPx() + val horizontalPaddingPx = leftPaddingPx + rightPaddingPx + val verticalPaddingPx = topPaddingPx + bottomPaddingPx + + val minWidth = constraints.minWidth.let { minWidth -> + if (minWidth == 0) DefaultMinWidth.roundToPx() + else minWidth + } + + val coreTextFieldConstraints = + constraints + .copy(minWidth = minWidth, minHeight = 0) + .offset(horizontal = -horizontalPaddingPx, vertical = -verticalPaddingPx) + val coreTextFieldPlaceable = coreTextFieldMeasurable.measure(coreTextFieldConstraints) + val coreTextFieldWidth = max(coreTextFieldPlaceable.width, minWidth) + + if (lazyCoreTextFieldWidth.value == null) { + lazyCoreTextFieldWidth.value = coreTextFieldWidth + currentRecomposeScope.invalidate() + } + + val width = constraints.constrainWidth(coreTextFieldWidth + horizontalPaddingPx) + val height = run { + val currentHeight = + buildInt { + plus(coreTextFieldPlaceable.height) + plus(verticalPaddingPx) + if (counterMaxLength != null) plus(counterContentGap!!.roundToPx()) + } + val polishHeight = max(currentHeight, minHeight.roundToPx()) + + constraints.constrainHeight(polishHeight) + } + + layout(width = width, height = height) { + coreTextFieldPlaceable.place(x = leftPaddingPx, y = topPaddingPx) + } + } + } +} + +private fun assertTextAreaValidState( + counterBaseColor: QuackColor?, + counterHighlightColor: QuackColor?, + counterTypography: QuackTypography?, + counterBaseAndHighlightGap: Dp?, + counterMaxLength: Int?, + contentGap: Dp?, +) { + if (counterMaxLength != null) { + requireNotNull(counterBaseColor) + requireNotNull(counterHighlightColor) + requireNotNull(counterTypography) + requireNotNull(counterBaseAndHighlightGap) + requireNotNull(contentGap) + } +} diff --git a/ui/src/main/kotlin/team/duckie/quackquack/ui/textfield.kt b/ui/src/main/kotlin/team/duckie/quackquack/ui/textfield.kt index a28d72652..ee5038237 100644 --- a/ui/src/main/kotlin/team/duckie/quackquack/ui/textfield.kt +++ b/ui/src/main/kotlin/team/duckie/quackquack/ui/textfield.kt @@ -90,6 +90,7 @@ import team.duckie.quackquack.ui.plugin.interceptor.rememberInterceptedStyleSafe import team.duckie.quackquack.ui.token.HorizontalDirection import team.duckie.quackquack.ui.token.VerticalDirection import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi +import team.duckie.quackquack.ui.util.LazyValue import team.duckie.quackquack.ui.util.QuackDsl import team.duckie.quackquack.ui.util.asLoose import team.duckie.quackquack.ui.util.buildFloat @@ -1406,10 +1407,6 @@ private const val DefaultLeadingIconContainerLayoutId = "QuackBaseDefaultTextFie private const val DefaultTrailingIconLayoutId = "QuackBaseDefaultTextFieldTrailingIconLayoutId" private const val DefaultTrailingIconContainerLayoutId = "QuackBaseDefaultTextFieldTrailingIconContainerLayoutId" -/** 동적으로 계산되는 값의 인스턴스를 보관하는 래퍼 클래스 */ -@Stable -private class LazyValue(var value: T? = null) - /** 텍스트 필드의 너비가 지정되지 않았을 떄 기본으로 사용할 너비 */ private val DefaultMinWidth = 200.dp diff --git a/ui/src/main/kotlin/team/duckie/quackquack/ui/util/lazy.kt b/ui/src/main/kotlin/team/duckie/quackquack/ui/util/lazy.kt new file mode 100644 index 000000000..5f05525de --- /dev/null +++ b/ui/src/main/kotlin/team/duckie/quackquack/ui/util/lazy.kt @@ -0,0 +1,14 @@ +/* + * Designed and developed by Duckie Team 2023. + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE + */ + +package team.duckie.quackquack.ui.util + +import androidx.compose.runtime.Stable + +/** 동적으로 계산되는 값의 인스턴스를 보관하는 래퍼 클래스 */ +@Stable +internal class LazyValue(var value: T? = null) diff --git a/ui/src/test/kotlin/team/duckie/quackquack/ui/snapshot/TextAreaSnapshot.kt b/ui/src/test/kotlin/team/duckie/quackquack/ui/snapshot/TextAreaSnapshot.kt new file mode 100644 index 000000000..d9168e6d5 --- /dev/null +++ b/ui/src/test/kotlin/team/duckie/quackquack/ui/snapshot/TextAreaSnapshot.kt @@ -0,0 +1,76 @@ +/* + * Designed and developed by Duckie Team 2023. + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE + */ + +package team.duckie.quackquack.ui.snapshot + +import androidx.compose.ui.Modifier +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.captureRoboImage +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import team.duckie.quackquack.material.theme.QuackTheme +import team.duckie.quackquack.ui.QuackTextArea +import team.duckie.quackquack.ui.textAreaCounter +import team.duckie.quackquack.util.compose.snapshot.test.SnapshotPathGeneratorRule + +@RunWith(AndroidJUnit4::class) +class TextAreaSnapshot { + @get:Rule + val snapshotPath = SnapshotPathGeneratorRule("textarea") + + @Test + fun MinHeightAppliedPlaceholder() { + captureRoboImage(snapshotPath()) { + QuackTheme { + QuackTextArea( + value = "", + onValueChange = {}, + placeholderText = "안녕\n하세\n요!", + ) + } + } + } + + @Test + fun MinHeightAppliedContent() { + captureRoboImage(snapshotPath()) { + QuackTheme { + QuackTextArea( + value = "안녕\n하세\n요!", + onValueChange = {}, + ) + } + } + } + + @Test + fun MinHeightAppliedContentWithCounter() { + captureRoboImage(snapshotPath()) { + QuackTheme { + QuackTextArea( + modifier = Modifier.textAreaCounter(maxLength = 10), + value = "안녕\n하세\n요!", + onValueChange = {}, + ) + } + } + } + + @Test + fun ExtraHeightAppliedContentWithCounter() { + captureRoboImage(snapshotPath()) { + QuackTheme { + QuackTextArea( + modifier = Modifier.textAreaCounter(maxLength = 10), + value = "안녕\n하세\n요!\n".repeat(5), + onValueChange = {}, + ) + } + } + } +} diff --git a/ui/src/test/snapshots/switch/Disabled.png b/ui/src/test/snapshots/switch/Disabled.png index 425d0ef49..f67c5420a 100644 Binary files a/ui/src/test/snapshots/switch/Disabled.png and b/ui/src/test/snapshots/switch/Disabled.png differ diff --git a/ui/src/test/snapshots/switch/Enabled.png b/ui/src/test/snapshots/switch/Enabled.png index 31f4a7e83..a0582e26b 100644 Binary files a/ui/src/test/snapshots/switch/Enabled.png and b/ui/src/test/snapshots/switch/Enabled.png differ diff --git a/ui/src/test/snapshots/tag/Filled_selected.png b/ui/src/test/snapshots/tag/Filled_selected.png index 3ba5b64d8..8320a96a3 100644 Binary files a/ui/src/test/snapshots/tag/Filled_selected.png and b/ui/src/test/snapshots/tag/Filled_selected.png differ diff --git a/ui/src/test/snapshots/tag/Filled_unselected.png b/ui/src/test/snapshots/tag/Filled_unselected.png index 48fcf4dcc..523fa39fa 100644 Binary files a/ui/src/test/snapshots/tag/Filled_unselected.png and b/ui/src/test/snapshots/tag/Filled_unselected.png differ diff --git a/ui/src/test/snapshots/tag/GrayscaleFlat_selected.png b/ui/src/test/snapshots/tag/GrayscaleFlat_selected.png index 61d1f271d..d6f23a40d 100644 Binary files a/ui/src/test/snapshots/tag/GrayscaleFlat_selected.png and b/ui/src/test/snapshots/tag/GrayscaleFlat_selected.png differ diff --git a/ui/src/test/snapshots/tag/GrayscaleOutlined_selected.png b/ui/src/test/snapshots/tag/GrayscaleOutlined_selected.png index 66787f8fa..be15af0c7 100644 Binary files a/ui/src/test/snapshots/tag/GrayscaleOutlined_selected.png and b/ui/src/test/snapshots/tag/GrayscaleOutlined_selected.png differ diff --git a/ui/src/test/snapshots/tag/GrayscaleOutlined_unselected.png b/ui/src/test/snapshots/tag/GrayscaleOutlined_unselected.png index 951f30a13..7a955042e 100644 Binary files a/ui/src/test/snapshots/tag/GrayscaleOutlined_unselected.png and b/ui/src/test/snapshots/tag/GrayscaleOutlined_unselected.png differ diff --git a/ui/src/test/snapshots/tag/Outlined_selected.png b/ui/src/test/snapshots/tag/Outlined_selected.png index 63fc1ea45..78917d4c5 100644 Binary files a/ui/src/test/snapshots/tag/Outlined_selected.png and b/ui/src/test/snapshots/tag/Outlined_selected.png differ diff --git a/ui/src/test/snapshots/tag/Outlined_unselected.png b/ui/src/test/snapshots/tag/Outlined_unselected.png index 57c3b3e65..7811c72f9 100644 Binary files a/ui/src/test/snapshots/tag/Outlined_unselected.png and b/ui/src/test/snapshots/tag/Outlined_unselected.png differ diff --git a/ui/src/test/snapshots/textarea/ExtraHeightAppliedContentWithCounter.png b/ui/src/test/snapshots/textarea/ExtraHeightAppliedContentWithCounter.png new file mode 100644 index 000000000..5c19ef83c Binary files /dev/null and b/ui/src/test/snapshots/textarea/ExtraHeightAppliedContentWithCounter.png differ diff --git a/ui/src/test/snapshots/textarea/MinHeightAppliedContent.png b/ui/src/test/snapshots/textarea/MinHeightAppliedContent.png new file mode 100644 index 000000000..65221166c Binary files /dev/null and b/ui/src/test/snapshots/textarea/MinHeightAppliedContent.png differ diff --git a/ui/src/test/snapshots/textarea/MinHeightAppliedContentWithCounter.png b/ui/src/test/snapshots/textarea/MinHeightAppliedContentWithCounter.png new file mode 100644 index 000000000..2764278f6 Binary files /dev/null and b/ui/src/test/snapshots/textarea/MinHeightAppliedContentWithCounter.png differ diff --git a/ui/src/test/snapshots/textarea/MinHeightAppliedPlaceholder.png b/ui/src/test/snapshots/textarea/MinHeightAppliedPlaceholder.png new file mode 100644 index 000000000..c944ab12f Binary files /dev/null and b/ui/src/test/snapshots/textarea/MinHeightAppliedPlaceholder.png differ diff --git a/ui/src/test/snapshots/textfield/QuackDefaultTextFields_icons.png b/ui/src/test/snapshots/textfield/QuackDefaultTextFields_icons.png index 1f341d45b..ecfdabcb1 100644 Binary files a/ui/src/test/snapshots/textfield/QuackDefaultTextFields_icons.png and b/ui/src/test/snapshots/textfield/QuackDefaultTextFields_icons.png differ diff --git a/ui/src/test/snapshots/textfield/QuackDefaultTextFields_indicators.png b/ui/src/test/snapshots/textfield/QuackDefaultTextFields_indicators.png index a762948a8..6885bbe0d 100644 Binary files a/ui/src/test/snapshots/textfield/QuackDefaultTextFields_indicators.png and b/ui/src/test/snapshots/textfield/QuackDefaultTextFields_indicators.png differ diff --git a/ui/src/test/snapshots/textfield/QuackDefaultTextFields_multilines.png b/ui/src/test/snapshots/textfield/QuackDefaultTextFields_multilines.png index 0e8af065a..3051a97a5 100644 Binary files a/ui/src/test/snapshots/textfield/QuackDefaultTextFields_multilines.png and b/ui/src/test/snapshots/textfield/QuackDefaultTextFields_multilines.png differ diff --git a/ui/src/test/snapshots/textfield/QuackDefaultTextFields_multilines_x2.png b/ui/src/test/snapshots/textfield/QuackDefaultTextFields_multilines_x2.png index ddd602429..19fba2939 100644 Binary files a/ui/src/test/snapshots/textfield/QuackDefaultTextFields_multilines_x2.png and b/ui/src/test/snapshots/textfield/QuackDefaultTextFields_multilines_x2.png differ diff --git a/ui/src/test/snapshots/textfield/QuackFilledTextField_FilledLarge_icons.png b/ui/src/test/snapshots/textfield/QuackFilledTextField_FilledLarge_icons.png index 4b851abb4..f807391b1 100644 Binary files a/ui/src/test/snapshots/textfield/QuackFilledTextField_FilledLarge_icons.png and b/ui/src/test/snapshots/textfield/QuackFilledTextField_FilledLarge_icons.png differ diff --git a/ui/src/test/snapshots/textfield/QuackFilledTextField_FilledLarge_multilines.png b/ui/src/test/snapshots/textfield/QuackFilledTextField_FilledLarge_multilines.png index 9a9ec9826..f5e4f901c 100644 Binary files a/ui/src/test/snapshots/textfield/QuackFilledTextField_FilledLarge_multilines.png and b/ui/src/test/snapshots/textfield/QuackFilledTextField_FilledLarge_multilines.png differ diff --git a/ui/src/test/snapshots/textfield/QuackFilledTextField_FilledLarge_multilines_x2.png b/ui/src/test/snapshots/textfield/QuackFilledTextField_FilledLarge_multilines_x2.png index fcbbd3f63..7861f1a28 100644 Binary files a/ui/src/test/snapshots/textfield/QuackFilledTextField_FilledLarge_multilines_x2.png and b/ui/src/test/snapshots/textfield/QuackFilledTextField_FilledLarge_multilines_x2.png differ