Updated on 2026-08-14

This commit is contained in:
Tangem 2022-04-22 18:13:05 +03:00
parent 79f35fe41e
commit eec0a5ccb8
9 changed files with 185 additions and 102 deletions

View file

@ -0,0 +1,40 @@
package com.tangem.tap.common
import timber.log.Timber
class CompositionCounter(
val id: String,
count: Int = 0
) {
var count: Int = count
private set
fun increase(id: String): CompositionCounter {
if (this.id != id) return this
count += 1
return CompositionCounter(id, count)
}
}
class CompositionLogger(
private val recomposeViewId: String,
private val tag: String = recomposeViewId,
private var turnOnForIds: List<String> = listOf(recomposeViewId)
) {
val count: Int
get() = compositionCounter.count
private var compositionCounter: CompositionCounter = CompositionCounter(recomposeViewId)
fun nextComposition() {
compositionCounter = compositionCounter.increase(recomposeViewId)
log("")
}
fun log(message: String) {
if (!turnOnForIds.contains(recomposeViewId)) return
Timber.d("$tag[$recomposeViewId]:[${compositionCounter.count}]: $message")
}
}

View file

@ -1,24 +0,0 @@
package com.tangem.tap.common.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.tangem.domain.common.util.ValueDebouncer
/**
[REDACTED_AUTHOR]
* This is an empty compose view. It just remember the ValueDebouncer inside of itself.
*/
@Composable
fun <T> valueDebouncerAsState(
debounce: Long = 400,
onValueChanged: (T) -> Unit
): ValueDebouncer<T> {
return remember {
ValueDebouncer(
debounce = debounce,
onValueChanged = { changedValue ->
changedValue?.let { onValueChanged(it) }
},
)
}
}

View file

@ -0,0 +1,47 @@
package com.tangem.tap.common.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.tangem.domain.common.util.ValueDebouncer
/**
[REDACTED_AUTHOR]
* This is an empty compose view. It just remember the ValueDebouncer inside of itself.
*/
@Composable
fun <T> valueDebouncerAsState(
initialValue: T,
debounce: Long = 400,
onEmitValueReceived: (T) -> Unit = {},
onValueChanged: (T) -> Unit,
): ValueDebouncer<T> {
return remember {
ValueDebouncer<T>(
initialValue = initialValue,
debounceDuration = debounce,
onEmitValueReceived = { emitValue ->
emitValue?.let { onEmitValueReceived(it) }
},
onValueChanged = { changedValue ->
changedValue?.let { onValueChanged(it) }
},
)
}
}
@Composable
fun <T> valueDebouncerNullableAsState(
initialValue: T?,
debounce: Long = 400,
onEmitValueReceived: (T?) -> Unit = {},
onValueChanged: (T?) -> Unit,
): ValueDebouncer<T?> {
return remember {
ValueDebouncer(
initialValue = initialValue,
debounceDuration = debounce,
onEmitValueReceived = onEmitValueReceived,
onValueChanged = onValueChanged,
)
}
}

View file

@ -10,7 +10,6 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.common.extensions.VoidCallback
import com.tangem.domain.common.form.Field
import com.tangem.tap.common.extensions.ValueCallback
import timber.log.Timber
/**
[REDACTED_AUTHOR]
@ -28,24 +27,14 @@ fun <T> OutlinedSpinner(
isEnabled: Boolean = true,
onClose: VoidCallback = {}
) {
val compositionCounter = remember { CompositionCounter(label) }
val counter = compositionCounter.increase(label)
val rIsExpanded = remember { mutableStateOf(false) }
val stateSelectedItem = remember { mutableStateOf(selectedItem.value) }
osLog(label, "recompose ------------------------------------", counter)
osLog(label, "selectedItem: $selectedItem", counter)
osLog(label, "stateSelectedItem: ${stateSelectedItem.value}", counter)
if (!selectedItem.isUserInput) {
osLog(label, "stateSelectedItem.value: update = selectedItem.value", counter)
stateSelectedItem.value = selectedItem.value
}
osLog(label, "stateSelectedItem: ${stateSelectedItem.value}", counter)
val onDropDownItemSelectedInternal: (T) -> Unit = {
osLog(label, "onDropDownItemSelectedInternal: value: [${it.toString()}]", counter)
stateSelectedItem.value = it
osLog(label, "onDropDownItemSelectedInternal: stateSelectedItem: ${stateSelectedItem.value}", counter)
rIsExpanded.value = false
onItemSelected(it)
}
@ -85,27 +74,6 @@ fun <T> OutlinedSpinner(
}
}
private fun osLog(id: String, log: String, counter: CompositionCounter) {
if (id == "Сеть") Timber.d(
"OutlinedSpinner[$id]:[${counter.count}] - $log"
)
}
class CompositionCounter(
val id: String,
count: Int = 0
) {
var count: Int = count
private set
fun increase(id: String): CompositionCounter {
if (this.id != id) return this
count += 1
return CompositionCounter(id, count)
}
}
@Preview
@Composable
fun TestSpinnerPreview() {
@ -113,7 +81,7 @@ fun TestSpinnerPreview() {
OutlinedSpinner(
label = "Blockchain name",
itemList = listOf(Blockchain.values()),
selectedItem = Field.Data(Blockchain.Avalanche),
selectedItem = Field.Data(Blockchain.Avalanche, false),
onItemSelected = {},
)
}

View file

@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.common.module.ModuleError
import com.tangem.domain.common.form.Field
import com.tangem.tap.common.CompositionLogger
import com.tangem.tap.common.compose.extensions.stringResourceDefault
import com.tangem.tap.common.moduleMessage.ModuleMessageConverter
@ -33,7 +34,7 @@ import com.tangem.tap.common.moduleMessage.ModuleMessageConverter
@Composable
fun OutlinedTextFieldWidget(
modifier: Modifier = Modifier,
textFieldData: Field.Data<String>,
fieldData: Field.Data<String>,
labelId: Int? = null,
label: String = "",
placeholderId: Int? = null,
@ -56,7 +57,7 @@ fun OutlinedTextFieldWidget(
) {
OutlinedProgressTextField(
modifier = modifier,
textFieldData = textFieldData,
fieldData = fieldData,
label = stringResourceDefault(labelId, label),
placeholder = stringResourceDefault(placeholderId, placeholder),
trailingIcon = trailingIcon,
@ -75,7 +76,7 @@ fun OutlinedTextFieldWidget(
@Composable
private fun OutlinedProgressTextField(
modifier: Modifier = Modifier,
textFieldData: Field.Data<String>,
fieldData: Field.Data<String>,
label: String = "",
placeholder: String = "",
isEnabled: Boolean = true,
@ -87,24 +88,70 @@ private fun OutlinedProgressTextField(
trailingIcon: @Composable (() -> Unit)? = null,
onTextChanged: (String) -> Unit,
) {
val rTextDebouncer = valueDebouncerAsState(debounce, onTextChanged)
val rText = remember { mutableStateOf(textFieldData.value) }
val logger = remember {
CompositionLogger(label, "OutlinedProgressTextField", listOf("Адрес контракта"))
}
logger.nextComposition()
fun updateFieldValueAndEmmit(value: String) {
rText.value = value
rTextDebouncer.emmit(value)
}
// This action came from redux. Update the field value and send a new event as if from the user
if (!textFieldData.isUserInput) {
updateFieldValueAndEmmit(textFieldData.value)
val textValueState = remember { mutableStateOf(fieldData.value) }
val textDebouncer = valueDebouncerAsState(
initialValue = fieldData.value,
debounce = debounce,
onEmitValueReceived = {
logger.log("DEBOUNCER: onEmitValueReceived: [$it]")
logger.log("DEBOUNCER: start RECOMPOSE by new value for textValueState.value = [$it]")
textValueState.value = it
},
onValueChanged = {
logger.log("DEBOUNCER: onValueChanged: >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> dispatch.toStore([$it])")
onTextChanged(it)
})
logger.log("RECOMPOSE ---------------------------------------------------------------START [${logger.count}]")
logger.log("RECOMPOSE --data: fieldData.value: [${fieldData}]")
logger.log("RECOMPOSE --data: textValueState.value: [${textValueState.value}]")
logger.log("RECOMPOSE --data: textDebouncer.emittedValue = [${textDebouncer.emittedValue}]")
logger.log("RECOMPOSE --data: textDebouncer.debounced = [${textDebouncer.debounced}]")
if (!fieldData.isUserInput) {
// initial value is not from an user
val isNotUserInput = "-- IS NOT USER INPUT"
logger.log("recompose $isNotUserInput")
if (textValueState.value == fieldData.value) {
logger.log("$isNotUserInput: внешние данные ОДИНАКОВЫ с данными в поле")
} else {
logger.log("$isNotUserInput: внешние данные РАЗЛИЧАЮТСЯ с данными в поле")
if (textDebouncer.emittedValue != textDebouncer.debounced) {
logger.log("$isNotUserInput: пользователь ВВОДИТ данные -> внешние данные игнорируем, ждем RECOMPOSE")
} else {
logger.log("$isNotUserInput: пользователь НЕ вводит данные -> пытаемся обработать внешние данные")
if (textValueState.value != textDebouncer.emittedValue || textValueState.value != textDebouncer.debounced) {
logger.log("$isNotUserInput: даннные в поле не соответствуют данным из textDebouncer")
if (textDebouncer.emittedValue.isEmpty() && textDebouncer.debounced.isEmpty()) {
logger.log("$isNotUserInput: даннные в textDebouncer ПУСТЫ -> start RECOMPOSE новые данные для textValueState.value = [${fieldData.value}]")
textValueState.value = fieldData.value
} else {
logger.log("$isNotUserInput: даннные в textDebouncer НЕ ПУСТЫ -> start RECOMPOSE новые данные для textValueState.value = [${fieldData.value}]")
textValueState.value = fieldData.value
}
} else {
logger.log("$isNotUserInput: UNKNOWN -> start RECOMPOSE by new value for textValueState.value = [${fieldData.value}]")
textValueState.value = fieldData.value
}
}
}
}
logger.log("recompose --------------------------------------------------------------FINISH [${logger.count}]")
Box {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth(),
value = rText.value,
onValueChange = ::updateFieldValueAndEmmit,
value = textValueState.value,
onValueChange = {
logger.log("WIDGET: textDebouncer.emmit([$it])")
textDebouncer.emmit(it)
},
keyboardOptions = keyboardOptions,
label = { Text(label) },
placeholder = {
@ -128,7 +175,6 @@ private fun OutlinedProgressTextField(
visible = isLoading,
) { LinearProgressIndicator() }
}
}
@Composable
@ -168,7 +214,7 @@ fun OutlinedTextFieldWithErrorTest() {
) {
OutlinedTextFieldWidget(
modifier = modifier,
textFieldData = Field.Data(""),
fieldData = Field.Data("", false),
label = "First label",
placeholder = "1 placeholder",
error = null,
@ -176,7 +222,7 @@ fun OutlinedTextFieldWithErrorTest() {
) {}
OutlinedTextFieldWidget(
modifier = modifier,
textFieldData = Field.Data("First"),
fieldData = Field.Data("First", false),
label = "First label",
placeholder = "1 placeholder",
error = null,
@ -184,7 +230,7 @@ fun OutlinedTextFieldWithErrorTest() {
) {}
OutlinedTextFieldWidget(
modifier = modifier,
textFieldData = Field.Data("First"),
fieldData = Field.Data("First", false),
label = "First label",
placeholder = "1 placeholder",
isLoading = true,
@ -193,7 +239,7 @@ fun OutlinedTextFieldWithErrorTest() {
) {}
OutlinedTextFieldWidget(
modifier = modifier,
textFieldData = Field.Data("First"),
fieldData = Field.Data("First", false),
label = "First label",
placeholder = "1 placeholder",
error = SimpleError(),

View file

@ -27,7 +27,7 @@ fun TokenContractAddressView(screenFieldData: ScreenFieldData) {
val tokenField = screenFieldData.field as TokenField
OutlinedTextFieldWidget(
textFieldData = tokenField.data,
fieldData = tokenField.data,
labelId = R.string.custom_token_contract_address_input_title,
placeholder = "0x0000000000000000000000000000000000000000",
isEnabled = screenFieldData.viewState.isEnabled,
@ -35,7 +35,7 @@ fun TokenContractAddressView(screenFieldData: ScreenFieldData) {
error = screenFieldData.error,
errorConverter = screenFieldData.errorConverter,
) {
domainStore.dispatch(AddCustomTokenAction.OnTokenContractAddressChanged(Field.Data(it)))
domainStore.dispatch(AddCustomTokenAction.OnTokenContractAddressChanged(Field.Data(it, true)))
}
SpacerH8()
}
@ -47,14 +47,14 @@ fun TokenNameView(screenFieldData: ScreenFieldData) {
val tokenField = screenFieldData.field as TokenField
OutlinedTextFieldWidget(
textFieldData = tokenField.data,
fieldData = tokenField.data,
labelId = R.string.custom_token_name_input_title,
placeholderId = R.string.custom_token_name_input_placeholder,
isEnabled = screenFieldData.viewState.isEnabled,
error = screenFieldData.error,
errorConverter = screenFieldData.errorConverter,
) {
domainStore.dispatch(AddCustomTokenAction.OnTokenNameChanged(Field.Data(it)))
domainStore.dispatch(AddCustomTokenAction.OnTokenNameChanged(Field.Data(it, true)))
}
SpacerH8()
}
@ -72,7 +72,7 @@ fun TokenNetworkView(screenFieldData: ScreenFieldData, state: AddCustomTokenStat
selectedItem = networkField.data,
isEnabled = screenFieldData.viewState.isEnabled,
textFieldConverter = { state.blockchainToName(it) ?: notSelected },
) { domainStore.dispatch(AddCustomTokenAction.OnTokenNetworkChanged(Field.Data(it))) }
) { domainStore.dispatch(AddCustomTokenAction.OnTokenNetworkChanged(Field.Data(it, true))) }
SpacerH8()
}
@ -83,13 +83,13 @@ fun TokenSymbolView(screenFieldData: ScreenFieldData) {
val tokenField = screenFieldData.field as TokenField
OutlinedTextFieldWidget(
textFieldData = tokenField.data,
fieldData = tokenField.data,
labelId = R.string.custom_token_token_symbol_input_title,
placeholderId = R.string.custom_token_token_symbol_input_placeholder,
isEnabled = screenFieldData.viewState.isEnabled,
error = screenFieldData.error,
errorConverter = screenFieldData.errorConverter,
) { domainStore.dispatch(AddCustomTokenAction.OnTokenSymbolChanged(Field.Data(it))) }
) { domainStore.dispatch(AddCustomTokenAction.OnTokenSymbolChanged(Field.Data(it, true))) }
SpacerH8()
}
@ -100,14 +100,14 @@ fun TokenDecimalsView(screenFieldData: ScreenFieldData) {
val tokenField = screenFieldData.field as TokenField
OutlinedTextFieldWidget(
textFieldData = tokenField.data,
fieldData = tokenField.data,
labelId = R.string.custom_token_decimals_input_title,
placeholder = "8",
isEnabled = screenFieldData.viewState.isEnabled,
error = screenFieldData.error,
errorConverter = screenFieldData.errorConverter,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
) { domainStore.dispatch(AddCustomTokenAction.OnTokenDecimalsChanged(Field.Data(it))) }
) { domainStore.dispatch(AddCustomTokenAction.OnTokenDecimalsChanged(Field.Data(it, true))) }
SpacerH8()
}
@ -129,6 +129,6 @@ fun TokenDerivationPathView(screenFieldData: ScreenFieldData, state: AddCustomTo
val blockchainName = state.blockchainToName(blockchain) ?: notSelected
TitleSubtitle(derivationPathName, blockchainName)
}
) { domainStore.dispatch(AddCustomTokenAction.OnTokenDerivationPathChanged(Field.Data(it))) }
) { domainStore.dispatch(AddCustomTokenAction.OnTokenDerivationPathChanged(Field.Data(it, true))) }
SpacerH8()
}

View file

@ -38,7 +38,7 @@ interface Field<T> {
data class Data<Data>(
val value: Data,
val isUserInput: Boolean = true
val isUserInput: Boolean
)
}

View file

@ -10,31 +10,37 @@ import kotlinx.coroutines.launch
[REDACTED_AUTHOR]
*/
class ValueDebouncer<T>(
private val debounce: Long = 400,
private val onValueChanged: (T?) -> Unit
private val initialValue: T,
private val debounceDuration: Long = 400,
private val onValueChanged: (T) -> Unit,
private val onEmitValueReceived: (T) -> Unit = {},
) {
private var value: T? = null
private val debounceScope = CoroutineScope(Job() + Dispatchers.Main)
private val flow = MutableStateFlow(value)
var emittedValue: T = initialValue
private set
var debounced: T = initialValue
private set
private val debounceScope: CoroutineScope = CoroutineScope(Job() + Dispatchers.Main)
private val flow = MutableStateFlow(debounced)
init {
initFlow()
}
private fun initFlow() {
debounceScope.launch {
flow.filter { if (value == null) true else value != it }
.debounce(debounce)
flow.filter { if (debounced == null) true else debounced != it }
.debounce(debounceDuration)
.onEach {
value = it
debounced = it
onValueChanged(it)
}
.collect()
}
}
fun isDebounced(value: T): Boolean = this.debounced == value
fun emmit(emmitValue: T) {
emittedValue = emmitValue
onEmitValueReceived.invoke(emmitValue)
debounceScope.launch { flow.emit(emmitValue) }
}
}

View file

@ -19,14 +19,14 @@ enum class CustomTokenFieldId : FieldId {
data class TokenField(
override val id: FieldId,
) : BaseDataField<String>(id, Field.Data(""))
) : BaseDataField<String>(id, Field.Data("", false))
data class TokenBlockchainField(
override val id: FieldId,
val itemList: List<Blockchain>,
) : BaseDataField<Blockchain>(id, Field.Data(Blockchain.Unknown))
) : BaseDataField<Blockchain>(id, Field.Data(Blockchain.Unknown, false))
data class TokenDerivationPathField(
override val id: FieldId,
val itemList: List<Blockchain>,
) : BaseDataField<Blockchain>(id, Field.Data(Blockchain.Unknown))
) : BaseDataField<Blockchain>(id, Field.Data(Blockchain.Unknown, false))