Created
March 17, 2025 20:46
-
-
Save sajjadyousefnia/f38a28419a190ad6ff783beae034750d to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.divadventure.divadventure.ui.otp | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.border | |
import androidx.compose.foundation.layout.Arrangement | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.Row | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.layout.size | |
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.material3.MaterialTheme | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.MutableState | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.rememberCoroutineScope | |
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.graphics.Color | |
import androidx.compose.ui.layout.onGloballyPositioned | |
import androidx.compose.ui.platform.LocalConfiguration | |
import androidx.compose.ui.platform.LocalDensity | |
import androidx.compose.ui.platform.LocalFocusManager | |
import androidx.compose.ui.platform.LocalSoftwareKeyboardController | |
import androidx.compose.ui.platform.testTag | |
import androidx.compose.ui.text.TextRange | |
import androidx.compose.ui.text.input.ImeAction | |
import androidx.compose.ui.text.input.KeyboardType | |
import androidx.compose.ui.text.input.PasswordVisualTransformation | |
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.tooling.preview.Preview | |
import androidx.compose.ui.unit.Dp | |
import androidx.compose.ui.unit.dp | |
import kotlinx.coroutines.launch | |
/** | |
* Data class representing an individual OTP field. | |
* | |
* @property text The text content of the OTP field. | |
* @property focusRequester A FocusRequester to manage focus on the field. | |
*/ | |
private data class OtpField( | |
val text: String, | |
val index: Int, | |
val focusRequester: FocusRequester? = null | |
) | |
/** | |
* A Composable function that creates a row of OTP input fields based on the specified count. | |
* Each field supports custom visual modifications and can handle input in various formats | |
* depending on the specified keyboard type. The function manages the creation and updating | |
* of these fields dynamically based on the current OTP value and provides functionality | |
* for managing focus transitions between the fields. | |
* | |
* @param otp A mutable state holding the current OTP value. This state is observed for changes | |
* to update the individual fields and to reset focus as necessary. | |
* @param count The number of OTP input boxes to display. This defines how many individual | |
* fields will be generated and managed. | |
* @param otpBoxModifier A Modifier passed to each OTP input box for custom styling and behavior. | |
* It allows for adjustments such as size or specific visual effects. | |
* Note: Avoid adding padding directly to `otpBoxModifier` as it may interfere | |
* with the layout calculations for the OTP fields. If padding is necessary, | |
* consider applying it to surrounding elements or within the `OtpBox` composable. | |
* @param otpTextType The type of keyboard to display when a field is focused, typically set to | |
* KeyboardType.Number for OTP inputs. This can be adjusted if alphanumeric | |
* OTPs are required. | |
* @param textColor The color used for the text within each OTP box, allowing for visual customization. | |
* | |
* The function sets up each input field with its own state and focus requester, managing | |
* internal state updates in response to changes in the OTP value and user interactions. | |
* The layout is organized as a horizontal row of text fields, with each field designed to | |
* capture a single character of the OTP. Focus automatically advances to the next field upon | |
* input, and if configured, input characters can be visually obscured for security. | |
* | |
* Example usage: | |
* ```kotlin | |
* OtpInputField( | |
* otp = remember { mutableStateOf("12345") }, | |
* count = 5, | |
* otpBoxModifier = Modifier.border(1.dp, Color.Black).background(Color.White), | |
* otpTextType = KeyboardType.Number, | |
* textColor = Color.Black // Setting the text color to black | |
* ) | |
* ``` | |
* This example sets up an OTP field with a basic black border and white background, without padding. | |
*/ | |
@Composable | |
fun OtpInputField( | |
otp: MutableState<String>, // The current OTP value. | |
count: Int = 5, // Number of OTP boxes. | |
otpBoxModifier: Modifier = Modifier | |
.border(1.pxToDp(), Color.Gray) | |
.background(Color.White), | |
otpTextType: KeyboardType = KeyboardType.Number, | |
textColor: Color = Color.Black, | |
) { | |
val scope = rememberCoroutineScope() | |
// Initialize state for each OTP box with its character and optional focus requester. | |
val otpFieldsValues = remember { | |
(0 until count).mapIndexed { index, i -> | |
mutableStateOf( | |
OtpField( | |
text = otp.value.getOrNull(i)?.toString() ?: "", | |
index = index, | |
focusRequester = FocusRequester() | |
) | |
) | |
} | |
} | |
// Update each OTP box's value when the overall OTP value changes, and manage focus. | |
LaunchedEffect(key1 = otp.value) { | |
for (i in otpFieldsValues.indices) { | |
otpFieldsValues[i].value = | |
otpFieldsValues[i].value.copy(otp.value.getOrNull(i)?.toString() ?: "") | |
} | |
// Request focus on the first box if the OTP is blank (e.g., reset). | |
if (otp.value.isBlank()) { | |
try { | |
otpFieldsValues[0].value.focusRequester?.requestFocus() | |
} catch (e: Exception) { | |
e.printStackTrace() | |
} | |
} | |
} | |
// Create a row of OTP boxes. | |
Row( | |
modifier = Modifier.fillMaxWidth(), | |
horizontalArrangement = Arrangement.SpaceEvenly | |
) { | |
repeat(count) { index -> | |
// For each OTP box, manage its value, focus, and what happens on value change. | |
OtpBox( | |
modifier = otpBoxModifier, | |
otpValue = otpFieldsValues[index].value, | |
textType = otpTextType, | |
textColor = textColor, | |
isLastItem = index == count - 1, // Check if this box is the last in the sequence. | |
totalBoxCount = count, | |
onValueChange = { newValue -> | |
// Handling logic for input changes, including moving focus and updating OTP state. | |
scope.launch { | |
handleOtpInputChange(index, count, newValue, otpFieldsValues, otp) | |
} | |
}, | |
onFocusSet = { focusRequester -> | |
// Save the focus requester for each box to manage focus programmatically. | |
otpFieldsValues[index].value = | |
otpFieldsValues[index].value.copy(focusRequester = focusRequester) | |
}, | |
onNext = { | |
// Attempt to move focus to the next box when the "next" action is triggered. | |
focusNextBox(index, count, otpFieldsValues) | |
}, | |
) | |
} | |
} | |
} | |
/** | |
* Handles input changes for each OTP box and manages the logic for updating the OTP state | |
* and managing focus transitions between OTP boxes. | |
* | |
* @param index The index of the OTP box where the input change occurred. | |
* @param count The total number of OTP boxes. | |
* @param newValue The new value inputted into the OTP box at the specified index. | |
* @param otpFieldsValues A list of mutable states, each representing an individual OTP box's state. | |
* @param otp A mutable state holding the current concatenated value of all OTP boxes. | |
* | |
* The function updates the text of the targeted OTP box based on the length and content of `newValue`. | |
* If `newValue` contains only one character, it replaces the existing text in the current box. | |
* If two characters are present, likely from rapid user input, it sets the box's text to the second character, | |
* assuming the first character was already accepted. If multiple characters are pasted, | |
* they are distributed across the subsequent boxes starting from the current index. | |
* | |
* Focus management is also handled, where focus is moved to the next box if a single character is inputted, | |
* and moved back to the previous box if the current box is cleared. This is especially useful for | |
* scenarios where users might quickly navigate between OTP fields either by typing or deleting characters. | |
* | |
* Exception handling is used to catch and log any errors that occur during focus management to avoid | |
* crashing the application and to provide debug information. | |
*/ | |
private fun handleOtpInputChange( | |
index: Int, | |
count: Int, | |
newValue: String, | |
otpFieldsValues: List<MutableState<OtpField>>, | |
otp: MutableState<String> | |
) { | |
// Handle input for the current box. | |
if (newValue.length <= 1) { | |
// Directly set the new value if it's a single character. | |
otpFieldsValues[index].value = otpFieldsValues[index].value.copy(text = newValue) | |
} else if (newValue.length == 2) { | |
// If length of new value is 2, we can guess the user is typing focusing on current box | |
// In this case set the unmatched character only | |
otpFieldsValues[index].value = | |
otpFieldsValues[index].value.copy(text = newValue.lastOrNull()?.toString() ?: "") | |
} else if (newValue.isNotEmpty()) { | |
// If pasting multiple characters, distribute them across the boxes starting from the current index. | |
newValue.forEachIndexed { i, char -> | |
if (index + i < count) { | |
otpFieldsValues[index + i].value = | |
otpFieldsValues[index + i].value.copy(text = char.toString()) | |
} | |
} | |
} | |
// Update the overall OTP state. | |
var currentOtp = "" | |
otpFieldsValues.forEach { | |
currentOtp += it.value.text | |
} | |
try { | |
// Logic to manage focus. | |
if (newValue.isEmpty() && index > 0) { | |
// If clearing a box and it's not the first box, move focus to the previous box. | |
otpFieldsValues.getOrNull(index - 1)?.value?.focusRequester?.requestFocus() | |
} else if (index < count - 1 && newValue.isNotEmpty()) { | |
// If adding a character and not on the last box, move focus to the next box. | |
otpFieldsValues.getOrNull(index + 1)?.value?.focusRequester?.requestFocus() | |
} | |
} catch (e: Exception) { | |
e.printStackTrace() | |
} | |
otp.value = currentOtp | |
} | |
private fun focusNextBox( | |
index: Int, | |
count: Int, | |
otpFieldsValues: List<MutableState<OtpField>> | |
) { | |
if (index + 1 < count) { | |
// Move focus to the next box if the current one is filled and it's not the last box. | |
try { | |
otpFieldsValues[index + 1].value.focusRequester?.requestFocus() | |
} catch (e: Exception) { | |
e.printStackTrace() | |
} | |
} | |
} | |
@Composable | |
private fun OtpBox( | |
modifier: Modifier, | |
otpValue: OtpField, // Current value of this OTP box. | |
textType: KeyboardType = KeyboardType.Number, | |
textColor: Color = Color.Black, | |
isLastItem: Boolean, // Whether this box is the last in the sequence. | |
totalBoxCount: Int, // Total number of OTP boxes for layout calculations. | |
onValueChange: (String) -> Unit, // Callback for when the value changes. | |
onFocusSet: (FocusRequester) -> Unit, // Callback to set focus requester. | |
onNext: () -> Unit, // Callback for handling "next" action, typically moving focus forward. | |
) { | |
val focusManager = LocalFocusManager.current | |
val focusRequest = otpValue.focusRequester ?: FocusRequester() | |
val keyboardController = LocalSoftwareKeyboardController.current | |
// Calculate the size of the box based on screen width and total count. | |
// If you're using this in Kotlin multiplatform mobile | |
// val screenWidth = LocalWindowInfo.current.containerSize.width | |
// If you're using this in Android | |
val screenWidth = LocalConfiguration.current.screenWidthDp.dp.dpToPx().toInt() | |
val paddingValue = 5 | |
val totalBoxSize = (screenWidth / totalBoxCount) - paddingValue * totalBoxCount | |
Box( | |
modifier = modifier | |
.size(totalBoxSize.pxToDp()), | |
contentAlignment = Alignment.Center, | |
) { | |
BasicTextField( | |
value = TextFieldValue(otpValue.text, TextRange(maxOf(0, otpValue.text.length))), | |
onValueChange = { | |
// Logic to prevent re-triggering onValueChange when focusing. | |
if (!it.text.equals(otpValue)) { | |
onValueChange(it.text) | |
} | |
}, | |
// Setup for focus and keyboard behavior. | |
modifier = Modifier | |
.testTag("otpBox${otpValue.index}") | |
.focusRequester(focusRequest) | |
.onGloballyPositioned { | |
onFocusSet(focusRequest) | |
}, | |
textStyle = MaterialTheme.typography.titleLarge.copy( | |
textAlign = TextAlign.Center, | |
color = textColor | |
), | |
keyboardOptions = KeyboardOptions( | |
keyboardType = textType, | |
imeAction = if (isLastItem) ImeAction.Done else ImeAction.Next | |
), | |
keyboardActions = KeyboardActions( | |
onNext = { | |
onNext() | |
}, | |
onDone = { | |
// Hide keyboard and clear focus when done. | |
keyboardController?.hide() | |
focusManager.clearFocus() | |
} | |
), | |
singleLine = true, | |
visualTransformation = getVisualTransformation(textType), | |
) | |
} | |
} | |
/** | |
* Provides an appropriate VisualTransformation based on the specified keyboard type. | |
* This method is used to determine how text should be displayed in the UI. | |
* | |
* @param textType The type of keyboard input expected, which determines if the text should be obscured. | |
* @return A VisualTransformation that either obscures text for password fields or displays text normally. | |
* Password and NumberPassword fields will have their input obscured with bullet characters. | |
* All other fields will display text as entered. | |
*/ | |
@Composable | |
private fun getVisualTransformation(textType: KeyboardType) = | |
if (textType == KeyboardType.NumberPassword || textType == KeyboardType.Password) PasswordVisualTransformation() else VisualTransformation.None | |
@Composable | |
fun Dp.dpToPx() = with(LocalDensity.current) { [email protected]() } | |
@Composable | |
fun Int.pxToDp() = with(LocalDensity.current) { [email protected]() } | |
@Preview | |
@Composable | |
fun OtpView_Preivew() { | |
MaterialTheme { | |
val otpValue = remember { | |
mutableStateOf("124") | |
} | |
Column( | |
modifier = Modifier.padding(40.pxToDp()), | |
verticalArrangement = Arrangement.spacedBy(20.pxToDp()) | |
) { | |
OtpInputField( | |
otp = otpValue, | |
count = 4, | |
otpBoxModifier = Modifier | |
.border(1.pxToDp(), Color.Black) | |
.background(Color.White), | |
otpTextType = KeyboardType.Number | |
) | |
OtpInputField( | |
otp = otpValue, | |
count = 4, | |
otpTextType = KeyboardType.NumberPassword, | |
otpBoxModifier = Modifier | |
.border(3.pxToDp(), Color.Gray) | |
.background(Color.White) | |
) | |
OtpInputField( | |
otp = otpValue, | |
count = 5, | |
textColor = MaterialTheme.colorScheme.onBackground, | |
otpBoxModifier = Modifier | |
.border(7.pxToDp(), Color(0xFF277F51), shape = RoundedCornerShape(12.pxToDp())) | |
) | |
OtpInputField( | |
otp = otpValue, | |
count = 5, | |
otpBoxModifier = Modifier | |
.bottomStroke(color = Color.DarkGray, strokeWidth = 6.pxToDp()) | |
) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment