Move clearable text field to core module.

This commit is contained in:
jeffrey-signal
2026-01-28 13:33:25 -05:00
committed by Greyson Parrelli
parent d9dba89781
commit 155b59d71f
4 changed files with 281 additions and 138 deletions

View File

@@ -10,7 +10,6 @@ import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -20,12 +19,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -36,21 +32,19 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ClearableTextField
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.TextFields
import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
@@ -292,7 +286,8 @@ private fun NicknameContent(
onValueChange = callback::onNoteChanged,
keyboardActions = KeyboardActions.Default,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
charactersRemaining = state.noteCharactersRemaining,
charactersRemainingBeforeLimit = state.noteCharactersRemaining,
countdownConfig = ClearableTextField.CountdownConfig(displayThreshold = 100, warnThreshold = 5),
modifier = Modifier
.focusRequester(noteFocusRequester)
.fillMaxWidth()
@@ -357,132 +352,3 @@ private fun NicknameContent(
}
}
}
@DayNightPreviews
@Composable
private fun ClearableTextFieldPreview() {
Previews.Preview {
val focusRequester = remember { FocusRequester() }
Column(modifier = Modifier.padding(16.dp)) {
ClearableTextField(
value = "",
hint = "Without content",
enabled = true,
onValueChange = {},
clearContentDescription = ""
)
Spacer(modifier = Modifier.size(16.dp))
ClearableTextField(
value = "Test",
hint = "With Content",
enabled = true,
onValueChange = {},
clearContentDescription = ""
)
Spacer(modifier = Modifier.size(16.dp))
ClearableTextField(
value = "",
hint = "Disabled",
enabled = false,
onValueChange = {},
clearContentDescription = ""
)
Spacer(modifier = Modifier.size(16.dp))
ClearableTextField(
value = "",
hint = "Focused",
enabled = true,
onValueChange = {},
modifier = Modifier.focusRequester(focusRequester),
clearContentDescription = ""
)
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}
@Composable
private fun ClearableTextField(
value: String,
hint: String,
clearContentDescription: String,
enabled: Boolean,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
singleLine: Boolean = false,
clearable: Boolean = true,
charactersRemaining: Int = Int.MAX_VALUE,
keyboardActions: KeyboardActions = KeyboardActions.Default,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default
) {
var focused by remember { mutableStateOf(false) }
val displayCountdown = charactersRemaining <= 100
val clearButton: @Composable () -> Unit = {
ClearButton(
visible = focused,
onClick = { onValueChange("") },
contentDescription = clearContentDescription
)
}
Box(modifier = modifier) {
TextFields.TextField(
value = value,
onValueChange = onValueChange,
label = {
Text(text = hint)
},
enabled = enabled,
singleLine = singleLine,
keyboardActions = keyboardActions,
keyboardOptions = keyboardOptions,
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focused = it.hasFocus && clearable },
colors = TextFieldDefaults.colors(
unfocusedLabelColor = MaterialTheme.colorScheme.outline,
unfocusedIndicatorColor = MaterialTheme.colorScheme.outline
),
trailingIcon = if (clearable) clearButton else null,
contentPadding = TextFieldDefaults.contentPaddingWithLabel(end = if (displayCountdown) 48.dp else 16.dp)
)
AnimatedVisibility(
visible = displayCountdown,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = 10.dp, end = 12.dp)
) {
Text(
text = "$charactersRemaining",
style = MaterialTheme.typography.bodySmall,
color = if (charactersRemaining <= 5) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline
)
}
}
}
@Composable
private fun ClearButton(
visible: Boolean,
onClick: () -> Unit,
contentDescription: String
) {
AnimatedVisibility(visible = visible) {
IconButton(
onClick = onClick
) {
Icon(
painter = painterResource(id = R.drawable.symbol_x_circle_fill_24),
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.outline
)
}
}
}

View File

@@ -0,0 +1,276 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
object ClearableTextField {
/**
* Configures how the "characters remaining" countdown is displayed.
*/
data class CountdownConfig(
/** The number of characters remaining before the countdown is displayed. */
val displayThreshold: Int,
/** The number of characters remaining before the countdown is displayed as warning. */
val warnThreshold: Int
)
}
/**
* A text field with an optional clear button that appears when focused and [clearable] is true.
*
* Also supports displaying a character countdown when the character count is approaching a limit.
*/
@Composable
fun ClearableTextField(
value: String,
onValueChange: (String) -> Unit,
hint: String,
clearContentDescription: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
textStyle: TextStyle = LocalTextStyle.current,
leadingIcon: @Composable (() -> Unit)? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = false,
clearable: Boolean = true,
charactersRemainingBeforeLimit: Int = Int.MAX_VALUE,
countdownConfig: ClearableTextField.CountdownConfig? = null,
colors: TextFieldColors = defaultTextFieldColors()
) {
ClearableTextField(
value = value,
onValueChange = onValueChange,
clearContentDescription = clearContentDescription,
modifier = modifier,
enabled = enabled,
textStyle = textStyle,
label = { Text(hint) },
leadingIcon = leadingIcon,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
singleLine = singleLine,
clearable = clearable,
charactersRemaining = charactersRemainingBeforeLimit,
countdownConfig = countdownConfig,
colors = colors
)
}
/**
* A text field with an optional clear button that appears when focused and [clearable] is true.
*
* Also supports displaying a character countdown when the character count is approaching a limit.
*/
@Composable
fun ClearableTextField(
value: String,
onValueChange: (String) -> Unit,
clearContentDescription: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
textStyle: TextStyle = LocalTextStyle.current,
label: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = false,
clearable: Boolean = true,
charactersRemaining: Int = Int.MAX_VALUE,
countdownConfig: ClearableTextField.CountdownConfig? = null,
colors: TextFieldColors = defaultTextFieldColors()
) {
var focused by remember { mutableStateOf(false) }
val displayCountdown = countdownConfig != null && charactersRemaining <= countdownConfig.displayThreshold
val clearButton: @Composable () -> Unit = {
ClearButton(
visible = focused,
onClick = { onValueChange("") },
contentDescription = clearContentDescription
)
}
Box(modifier = modifier) {
TextFields.TextField(
value = value,
onValueChange = onValueChange,
textStyle = textStyle,
label = label,
enabled = enabled,
singleLine = singleLine,
keyboardActions = keyboardActions,
keyboardOptions = keyboardOptions,
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focused = it.hasFocus && clearable },
colors = colors,
leadingIcon = leadingIcon,
trailingIcon = if (clearable) clearButton else null,
contentPadding = TextFieldDefaults.contentPaddingWithLabel(end = if (displayCountdown) 48.dp else 16.dp)
)
AnimatedVisibility(
visible = displayCountdown,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = 10.dp, end = 12.dp)
) {
val errorThresholdExceeded = countdownConfig != null && charactersRemaining <= countdownConfig.warnThreshold
Text(
text = "$charactersRemaining",
style = MaterialTheme.typography.bodySmall,
color = if (errorThresholdExceeded) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline
)
}
}
}
@Composable
private fun ClearButton(
visible: Boolean,
onClick: () -> Unit,
contentDescription: String
) {
AnimatedVisibility(visible = visible) {
IconButton(
onClick = onClick
) {
Icon(
painter = SignalIcons.XCircleFill.painter,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.outline
)
}
}
}
@Composable
private fun defaultTextFieldColors(): TextFieldColors = TextFieldDefaults.colors(
unfocusedLabelColor = MaterialTheme.colorScheme.outline,
unfocusedIndicatorColor = MaterialTheme.colorScheme.outline
)
@DayNightPreviews
@Composable
private fun ClearableTextFieldPreview() {
Previews.Preview {
val focusRequester = remember { FocusRequester() }
Column(modifier = Modifier.padding(16.dp)) {
ClearableTextField(
value = "",
onValueChange = {},
hint = "without content",
clearContentDescription = ""
)
Spacer(modifier = Modifier.size(16.dp))
ClearableTextField(
value = "foo bar",
onValueChange = {},
hint = "with content",
clearContentDescription = ""
)
Spacer(modifier = Modifier.size(16.dp))
ClearableTextField(
value = "",
onValueChange = {},
hint = "disabled",
clearContentDescription = "",
enabled = false
)
Spacer(modifier = Modifier.size(16.dp))
ClearableTextField(
value = "",
onValueChange = {},
hint = "focused without content",
clearContentDescription = "",
modifier = Modifier.focusRequester(focusRequester)
)
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}
@DayNightPreviews
@Composable
private fun ClearableTextFieldCharacterCountPreview() {
Previews.Preview {
val countdownConfig = ClearableTextField.CountdownConfig(displayThreshold = 100, warnThreshold = 10)
Column(modifier = Modifier.padding(16.dp)) {
ClearableTextField(
value = "Character count normal state",
onValueChange = {},
hint = "countdown shown",
clearContentDescription = "Clear",
charactersRemainingBeforeLimit = 50,
countdownConfig = countdownConfig
)
Spacer(modifier = Modifier.size(16.dp))
ClearableTextField(
value = "Very long text showing the character count warning state",
onValueChange = {},
hint = "countdown warning",
clearContentDescription = "Clear",
charactersRemainingBeforeLimit = 8,
countdownConfig = countdownConfig
)
Spacer(modifier = Modifier.size(16.dp))
ClearableTextField(
value = "No character count shown",
onValueChange = {},
hint = "no countdown shown",
clearContentDescription = "Clear",
charactersRemainingBeforeLimit = 150,
countdownConfig = countdownConfig
)
}
}
}

View File

@@ -52,7 +52,8 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
Settings(icon(R.drawable.symbol_settings_android_24)),
Share(icon(R.drawable.symbol_share_android_24)),
Trash(icon(R.drawable.symbol_trash_24)),
X(icon(R.drawable.symbol_x_24))
X(icon(R.drawable.symbol_x_24)),
XCircleFill(icon(R.drawable.symbol_x_circle_fill_24))
}
private fun icon(@DrawableRes id: Int) = SignalIcon.DrawableIcon(id)