mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-14 23:18:43 +00:00
Move clearable text field to core module.
This commit is contained in:
committed by
Greyson Parrelli
parent
d9dba89781
commit
155b59d71f
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user