diff --git a/design/api/current.api b/design/api/current.api index 52f02a66..ccba476d 100644 --- a/design/api/current.api +++ b/design/api/current.api @@ -451,6 +451,42 @@ package com.urlaunched.android.design.ui.textfield { } +package com.urlaunched.android.design.ui.textfield.hashtags { + + public abstract class Delegate implements java.io.Closeable { + ctor public Delegate(kotlinx.coroutines.CoroutineDispatcher coroutineDispatcher); + method public void close(); + method public final error.NonExistentClass! getDelegateScope(); + property public final error.NonExistentClass! delegateScope; + } + + public final class HashtagsContainerKt { + method @androidx.compose.runtime.Composable public static void HashtagsContainer(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.Modifier innerFieldModifier, optional androidx.compose.ui.text.input.TextFieldValue value, optional String? label, String? placeHolder, optional String? error, optional boolean enabled, optional boolean readOnly, optional com.urlaunched.android.design.ui.textfield.models.TextFieldBorderConfig borderConfig, optional com.urlaunched.android.design.ui.textfield.models.TextFieldBackgroundConfig backgroundConfig, optional com.urlaunched.android.design.ui.textfield.models.TextFieldInputTextConfig inputTextConfig, optional com.urlaunched.android.design.ui.textfield.models.TextFieldInputPlaceholderTextConfig inputPlaceholderTextConfig, optional com.urlaunched.android.design.ui.textfield.models.TextFieldTopLabelConfig topLabelConfig, optional com.urlaunched.android.design.ui.textfield.models.TextFieldsSpacerConfig textFieldsSpacerConfig, optional long selectionHandleColor, optional long selectionBackgroundColor, optional androidx.compose.ui.graphics.Brush cursorBrush, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional boolean singleLine, optional int maxLines, optional int minLines, optional androidx.compose.ui.text.input.VisualTransformation visualTransformation, optional boolean collapseLabel, optional androidx.compose.ui.unit.Dp? textFieldHeight, optional androidx.compose.foundation.layout.PaddingValues innerPadding, optional kotlin.jvm.functions.Function0? trailingIcon, optional kotlin.jvm.functions.Function0? leadingIcon, optional kotlin.jvm.functions.Function0? labelIcon, optional boolean trailingIconAlwaysShown, kotlin.jvm.functions.Function1 onValueChange); + } + + public interface HashtagsDelegate { + method public kotlinx.coroutines.flow.StateFlow getUiHashtagsStage(); + method public void setCurrentHashtags(androidx.compose.ui.text.input.TextFieldValue newValue); + property public abstract kotlinx.coroutines.flow.StateFlow uiHashtagsStage; + } + + public final class HashtagsDelegateImpl extends com.urlaunched.android.design.ui.textfield.hashtags.Delegate implements com.urlaunched.android.design.ui.textfield.hashtags.HashtagsDelegate { + ctor public HashtagsDelegateImpl(kotlinx.coroutines.CoroutineDispatcher coroutineDispatcher); + method public kotlinx.coroutines.flow.StateFlow getUiHashtagsStage(); + method public void setCurrentHashtags(androidx.compose.ui.text.input.TextFieldValue newValue); + property public kotlinx.coroutines.flow.StateFlow uiHashtagsStage; + } + + public final class HashtagsDelegateState { + ctor public HashtagsDelegateState(androidx.compose.ui.text.input.TextFieldValue currentHashtagsText); + method public androidx.compose.ui.text.input.TextFieldValue component1(); + method public com.urlaunched.android.design.ui.textfield.hashtags.HashtagsDelegateState copy(androidx.compose.ui.text.input.TextFieldValue currentHashtagsText); + method public androidx.compose.ui.text.input.TextFieldValue getCurrentHashtagsText(); + property public final androidx.compose.ui.text.input.TextFieldValue currentHashtagsText; + } + +} + package com.urlaunched.android.design.ui.textfield.models { public final class TextFieldBackgroundConfig { diff --git a/design/src/main/java/com/urlaunched/android/design/ui/textfield/hashtags/Delegate.kt b/design/src/main/java/com/urlaunched/android/design/ui/textfield/hashtags/Delegate.kt new file mode 100644 index 00000000..cfdd1f9a --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/textfield/hashtags/Delegate.kt @@ -0,0 +1,15 @@ +package com.urlaunched.android.design.ui.textfield.hashtags + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import java.io.Closeable + +abstract class Delegate(coroutineDispatcher: CoroutineDispatcher) : Closeable { + private val delegateScope = CoroutineScope(coroutineDispatcher + SupervisorJob()) + + override fun close() { + delegateScope.cancel() + } +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/textfield/hashtags/HashtagsDelegate.kt b/design/src/main/java/com/urlaunched/android/design/ui/textfield/hashtags/HashtagsDelegate.kt new file mode 100644 index 00000000..5c052289 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/textfield/hashtags/HashtagsDelegate.kt @@ -0,0 +1,14 @@ +package com.urlaunched.android.design.ui.textfield.hashtags + +import androidx.compose.ui.text.input.TextFieldValue +import kotlinx.coroutines.flow.StateFlow + +interface HashtagsDelegate { + val uiHashtagsStage: StateFlow + + fun setCurrentHashtags(newValue: TextFieldValue) +} + +data class HashtagsDelegateState( + val currentHashtagsText: TextFieldValue +) \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/textfield/hashtags/HashtagsDelegateImpl.kt b/design/src/main/java/com/urlaunched/android/design/ui/textfield/hashtags/HashtagsDelegateImpl.kt new file mode 100644 index 00000000..29a81506 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/textfield/hashtags/HashtagsDelegateImpl.kt @@ -0,0 +1,68 @@ +package com.urlaunched.android.design.ui.textfield.hashtags + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +class HashtagsDelegateImpl( + private val coroutineDispatcher: CoroutineDispatcher +) : Delegate(coroutineDispatcher), HashtagsDelegate { + private val _uiState = MutableStateFlow(HashtagsDelegateState(currentHashtagsText = TextFieldValue())) + + override val uiHashtagsStage: StateFlow = _uiState + + override fun setCurrentHashtags(newValue: TextFieldValue) { + val rawText = newValue.text + val selection = newValue.selection + val previousText = _uiState.value.currentHashtagsText.text + + val filteredText = rawText.filter { it.isLetter() || it.isDigit() || it == ' ' || it == '#' } + + val updatedText = buildString { + if (previousText.isEmpty() && filteredText.isNotEmpty()) { + append("#") + append(filteredText) + } else if (filteredText.isEmpty()) { + append("") + } else if (filteredText.lastOrNull() == ' ' && previousText.length <= filteredText.length) { + if (previousText.lastOrNull() != '#') { + append(filteredText.dropLast(1)) + append(" #") + } else { + append(previousText) + } + } else { + append(filteredText) + } + } + + val cursorPos = when { + previousText.isEmpty() && filteredText.isNotEmpty() -> { + updatedText.length + } + + updatedText.length > filteredText.length -> { + updatedText.length + } + + updatedText.length < filteredText.length -> { + selection.start - (filteredText.length - updatedText.length) + } + + filteredText.lastOrNull() == ' ' && previousText.lastOrNull() == '#' -> { + updatedText.length + } + + else -> { + selection.start + } + }.coerceIn(0, updatedText.length) + + _uiState.update { + it.copy(currentHashtagsText = TextFieldValue(updatedText, TextRange(cursorPos))) + } + } +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/textfield/hashtags/HashtagsTextField.kt b/design/src/main/java/com/urlaunched/android/design/ui/textfield/hashtags/HashtagsTextField.kt new file mode 100644 index 00000000..9a28019d --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/textfield/hashtags/HashtagsTextField.kt @@ -0,0 +1,284 @@ +package com.urlaunched.android.design.ui.textfield.hashtags + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.foundation.text.selection.TextSelectionColors +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import com.urlaunched.android.design.resources.dimens.Dimens +import com.urlaunched.android.design.ui.textfield.LocalSelectionBackgroundColor +import com.urlaunched.android.design.ui.textfield.LocalSelectionHandleColor +import com.urlaunched.android.design.ui.textfield.LocalTextFieldBackgroundConfig +import com.urlaunched.android.design.ui.textfield.LocalTextFieldBorderConfig +import com.urlaunched.android.design.ui.textfield.LocalTextFieldInputPlaceholderTextConfig +import com.urlaunched.android.design.ui.textfield.LocalTextFieldInputTextConfig +import com.urlaunched.android.design.ui.textfield.LocalTextFieldTopLabelConfig +import com.urlaunched.android.design.ui.textfield.LocalTextFieldsSpacerConfig +import com.urlaunched.android.design.ui.textfield.constants.TextFieldConstants +import com.urlaunched.android.design.ui.textfield.models.TextFieldBackgroundConfig +import com.urlaunched.android.design.ui.textfield.models.TextFieldBorderConfig +import com.urlaunched.android.design.ui.textfield.models.TextFieldInputPlaceholderTextConfig +import com.urlaunched.android.design.ui.textfield.models.TextFieldInputTextConfig +import com.urlaunched.android.design.ui.textfield.models.TextFieldTopLabelConfig +import com.urlaunched.android.design.ui.textfield.models.TextFieldsSpacerConfig + +/** + * Use this composable together with [HashtagsDelegate] and [HashtagsDelegateImpl]. + * + * To use [HashtagsDelegateImpl], you must manually inject or provide a [CoroutineDispatcher], + * since the library module does not support Hilt directly. + * + */ + +@Composable +fun HashtagsTextField( + modifier: Modifier = Modifier, + innerFieldModifier: Modifier = Modifier, + value: TextFieldValue = TextFieldValue(), + label: String? = null, + placeHolder: String?, + error: String? = null, + enabled: Boolean = true, + readOnly: Boolean = false, + borderConfig: TextFieldBorderConfig = LocalTextFieldBorderConfig.current, + backgroundConfig: TextFieldBackgroundConfig = LocalTextFieldBackgroundConfig.current, + inputTextConfig: TextFieldInputTextConfig = LocalTextFieldInputTextConfig.current, + inputPlaceholderTextConfig: TextFieldInputPlaceholderTextConfig = LocalTextFieldInputPlaceholderTextConfig.current, + topLabelConfig: TextFieldTopLabelConfig = LocalTextFieldTopLabelConfig.current, + textFieldsSpacerConfig: TextFieldsSpacerConfig = LocalTextFieldsSpacerConfig.current, + selectionHandleColor: Color = LocalSelectionHandleColor.current, + selectionBackgroundColor: Color = LocalSelectionBackgroundColor.current, + cursorBrush: Brush = SolidColor(Color.Black), + keyboardOptions: KeyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + capitalization = KeyboardCapitalization.Sentences + ), + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = true, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + visualTransformation: VisualTransformation = VisualTransformation.None, + collapseLabel: Boolean = true, + textFieldHeight: Dp? = null, + innerPadding: PaddingValues = PaddingValues(Dimens.spacingNormal), + trailingIcon: (@Composable () -> Unit)? = null, + leadingIcon: (@Composable () -> Unit)? = null, + labelIcon: (@Composable () -> Unit)? = null, + trailingIconAlwaysShown: Boolean = false, + onValueChange: (TextFieldValue) -> Unit +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + val borderColor by animateColorAsState( + targetValue = when { + error != null && borderConfig.errorColor != null -> borderConfig.errorColor + isFocused -> borderConfig.focusedColor + else -> borderConfig.unfocusedColor + }, + label = TextFieldConstants.LABEL_TEXT_COLOR_ANIMATION_LABEL + ) + val animatedTextColor by animateColorAsState( + targetValue = when { + error != null && inputTextConfig.errorColor != null -> inputTextConfig.errorColor + isFocused -> inputTextConfig.focusedColor + else -> inputTextConfig.unfocusedColor + }, + label = TextFieldConstants.BACKGROUND_COLOR_ANIMATION_LABEL + ) + val animatedBackgroundColor by animateColorAsState( + targetValue = when { + error != null -> backgroundConfig.focusedColor + isFocused -> backgroundConfig.focusedColor + else -> backgroundConfig.unfocusedColor + }, + label = TextFieldConstants.TEXT_COLOR_ANIMATION_LABEL + ) + val animatedLabelColor by animateColorAsState( + targetValue = when { + error != null && topLabelConfig.errorColor != null -> topLabelConfig.errorColor + isFocused -> topLabelConfig.focusedColor + else -> topLabelConfig.unfocusedColor + }, + label = TextFieldConstants.LABEL_TEXT_COLOR_ANIMATION_LABEL + ) + + CompositionLocalProvider( + LocalTextSelectionColors provides TextSelectionColors( + handleColor = selectionHandleColor, + backgroundColor = selectionBackgroundColor + ) + ) { + BasicTextField( + modifier = modifier, + value = value, + onValueChange = onValueChange, + enabled = enabled, + readOnly = readOnly, + cursorBrush = cursorBrush, + textStyle = inputTextConfig.textStyle.copy(color = animatedTextColor), + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + visualTransformation = visualTransformation, + interactionSource = interactionSource, + decorationBox = { innerTextField -> + Column { + AnimatedVisibility( + visible = (value.text.isNotEmpty() || !collapseLabel) && !label.isNullOrEmpty(), + enter = expandVertically(), + exit = shrinkVertically() + ) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(textFieldsSpacerConfig.labelIconSpacer) + ) { + Text( + text = label.orEmpty(), + style = topLabelConfig.textStyle, + color = animatedLabelColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + labelIcon?.invoke() + } + + Spacer(modifier = Modifier.height(textFieldsSpacerConfig.labelSpacer)) + } + } + + Row( + modifier = Modifier + .run { + if (textFieldHeight != null) { + height(textFieldHeight) + } else { + this + } + } + .then(innerFieldModifier) + .clip(backgroundConfig.shape) + .background( + color = animatedBackgroundColor, + shape = backgroundConfig.shape + ) + .border( + width = borderConfig.width, + brush = SolidColor(borderColor), + shape = borderConfig.shape + ) + .padding( + start = innerPadding.calculateStartPadding(LocalLayoutDirection.current), + end = innerPadding.calculateEndPadding(LocalLayoutDirection.current) + ) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + if (leadingIcon != null) { + leadingIcon.invoke() + Spacer(modifier = Modifier.width(textFieldsSpacerConfig.leadingIconSpacer)) + } + + Box( + modifier = Modifier + .padding( + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding() + ) + .weight(1f) + .wrapContentHeight() + ) { + innerTextField() + + if (value.text.isEmpty()) { + Text( + text = placeHolder ?: label.orEmpty(), + style = inputPlaceholderTextConfig.textStyle, + color = inputPlaceholderTextConfig.color, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis + ) + } + } + + if ((value.text.isNotEmpty() || trailingIconAlwaysShown) && trailingIcon != null) { + Spacer(modifier = Modifier.width(textFieldsSpacerConfig.trailingIconSpacer)) + trailingIcon.invoke() + } + } + + Spacer(modifier = Modifier.height(textFieldsSpacerConfig.errorSpacer)) + } + } + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun HashtagsContainerPreview() { + HashtagsTextField( + modifier = Modifier.padding(Dimens.spacingNormal), + value = TextFieldValue("#hasthag"), + label = "Hashtags", + onValueChange = {}, + placeHolder = "Enter hashtags" + ) +} + +@Preview(showBackground = true) +@Composable +private fun HashtagsContainerDisabledPreview() { + HashtagsTextField( + modifier = Modifier.padding(Dimens.spacingNormal), + value = TextFieldValue("#hasthag"), + label = "Hashtags", + enabled = false, + onValueChange = {}, + placeHolder = "Enter hashtags" + ) +} \ No newline at end of file