Update remembered value using arguments - android

I have a Composable in which a remembered value (an offset) needs to be updated both by the Composable itself and also from the calling side (using the Composable's arguments) -- how can I achieve this?
In particular, I have the following piece of code. The value I'm talking about is the offset in NavigableBox: I need to both be able to control it by dragging the box and by setting it manually using the value from OffsetInputField which is passed as an argument.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Surface {
Box {
var boxOffset by remember { mutableStateOf(Offset.Zero) }
NavigableBox(boxOffset)
OffsetInputField { offset ->
offset.toFloat().let { boxOffset = Offset(it, it) }
}
}
}
}
}
}
#Composable
fun OffsetInputField(onInput: (String) -> Unit) {
var value by remember { mutableStateOf("") }
TextField(
value = value,
onValueChange = { value = it },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { onInput(value) })
)
}
#Composable
fun NavigableBox(initOffset: Offset) {
var offset by remember(initOffset) { mutableStateOf(initOffset) }
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) { detectTransformGestures { _, pan, _, _ -> offset += pan } }
) {
Box(modifier = Modifier
.size(100.dp)
.offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
.background(Color.Blue)
)
}
}
In the current implementation the dragging works fine until a new value is passed as OffsetInputField's input -- then the box stops responding to dragging. I assume it is because the MutableState object containing the offset changes when gets recalculated and the box doesn't observe it anymore.
I already tried using unidirectional data flow in NavigableBox (passing offset value and onOffsetChange lambda to it, but then dragging doesn't work as expected: the box just jiggles around its initial position, returning back to it when the gesture stops.
In case anyone interested, I'm developing an app where the draggable box is a map, and the text field is used for searching objects on it: the map is moved to be centered on an object entered.

pointerInput captures all the state variables used inside. With new initOffset you're creating a new mutable state, but pointerInput keeps updating the old reference.
You need to restart it by passing the same value(s) to key:
pointerInput(initOffset) { /*...*/ }

Related

Jetpack Compose: Moving up and down multiple multi-line BasicTextFields with physical keyboard

I noticed that if I have multiple multi-line BasicTextField in a Column, if one the text fields has focus and I press the up and down arrow keys on my physical keyboard (using Android Studio emulator), the focus is moved to a different text field instead of going up or down to a different line of text within the text field.
I kind of like the idea of letting the up and down arrow keys change focus, but only if the cursor is at the first or last line.
I came up with a solution, but I am wondering if there is an easier way to do this. Also, if I want to allow a change of focus using arrow keys AND through other methods, it seems I would have to modify this somehow to check the key that was pressed with one of those key modifiers, but I am not sure the best way to do that. Do you know how to determine if a key press is also triggering focus movement/change?
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Column {
repeat(5) {
BasicTextFieldWithText()
}
}
}
}
}
#OptIn(ExperimentalComposeUiApi::class)
#Composable
fun BasicTextFieldWithText() {
var text by remember {
mutableStateOf(
TextFieldValue("BasicTextField\n" + "with multiple lines\n" + "of text")
)
}
var textLayout by remember {
mutableStateOf<TextLayoutResult?>(null)
}
val atFirstLine by remember {
derivedStateOf {
textLayout?.let {
val range = text.selection
range.collapsed && it.getLineForOffset(range.end) == 0
} ?: false
}
}
val atLastLine by remember {
derivedStateOf {
textLayout?.let {
val range = text.selection
range.collapsed && it.getLineForOffset(range.end) == (it.lineCount - 1)
} ?: false
}
}
BasicTextField(
value = text,
onValueChange = { text = it },
onTextLayout = { textLayout = it },
modifier = Modifier
.border(1.dp, Color.Blue)
.padding(8.dp)
.focusProperties {
up = if (atFirstLine) FocusRequester.Default else FocusRequester.Cancel
down = if (atLastLine) FocusRequester.Default else FocusRequester.Cancel
}
)
}

Function body not being executed in Jetpack Compose

So I have the following composable function:
#Composable
fun SearchResult() {
if (searchInput.isNotEmpty()) {
Column() {
Text("Search Result!")
}
}
}
Then I called the function from here:
private fun updateContent() {
setContent {
ChemistryAssistantTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column() {
Title(" Chemistry Assistant ", " Made by Saket Tamrakar ")
Column() {
SearchElements()
SearchResult() // Here
//Options()
}
}
}
}
}
}
The issue here is that the function gets correctly called in the beginning, when I invoke updateContent() here:
OutlinedTextField(value = input, placeholder = { Text("Search for any element!") }, onValueChange = {
input = it
searchInput = it.text
updateContent()
})
Control does reach the function (at least according to what the debugger tells me), but still fails to execute the function body.
Any ideas?
You should keep searchInput as a state like:
val searchInput by mutableStateOf("")
This ensures that whenever the value of searchInput changes, any composable whose structure depends on it will also recompose(i.e recall the function).
Hope this solves your issue.
Apparently moving the variable searchInput:
#Composable
fun SearchResult() {
if (/*this one*/searchInput.isNotEmpty()) {
Column() {
Text("Search Result!")
}
}
}
..inside the MainActivity class fixed the issue.

How to disable simultaneous clicks on multiple items in Jetpack Compose List / Column / Row (out of the box debounce?)

I have implemented a column of buttons in jetpack compose. We realized it is possible to click multiple items at once (with multiple fingers for example), and we would like to disable this feature.
Is there an out of the box way to disable multiple simultaneous clicks on children composables by using a parent column modifier?
Here is an example of the current state of my ui, notice there are two selected items and two unselected items.
Here is some code of how it is implemented (stripped down)
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(nestedScrollParams.childScrollState),
) {
viewDataList.forEachIndexed { index, viewData ->
Row(modifier = modifier.fillMaxWidth()
.height(dimensionResource(id = 48.dp)
.background(colorResource(id = R.color.large_button_background))
.clickable { onClick(viewData) },
verticalAlignment = Alignment.CenterVertically
) {
//Internal composables, etc
}
}
Check this solution. It has similar behavior to splitMotionEvents="false" flag. Use this extension with your Column modifier
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import kotlinx.coroutines.coroutineScope
fun Modifier.disableSplitMotionEvents() =
pointerInput(Unit) {
coroutineScope {
var currentId: Long = -1L
awaitPointerEventScope {
while (true) {
awaitPointerEvent(PointerEventPass.Initial).changes.forEach { pointerInfo ->
when {
pointerInfo.pressed && currentId == -1L -> currentId = pointerInfo.id.value
pointerInfo.pressed.not() && currentId == pointerInfo.id.value -> currentId = -1
pointerInfo.id.value != currentId && currentId != -1L -> pointerInfo.consume()
else -> Unit
}
}
}
}
}
}
Here are four solutions:
Click Debounce (ViewModel)r
For this, you need to use a viewmodel. The viewmodel handles the click event. You should pass in some id (or data) that identifies the item being clicked. In your example, you could pass an id that you assign to each item (such as a button id):
// IMPORTANT: Make sure to import kotlinx.coroutines.flow.collect
class MyViewModel : ViewModel() {
val debounceState = MutableStateFlow<String?>(null)
init {
viewModelScope.launch {
debounceState
.debounce(300)
.collect { buttonId ->
if (buttonId != null) {
when (buttonId) {
ButtonIds.Support -> displaySupport()
ButtonIds.About -> displayAbout()
ButtonIds.TermsAndService -> displayTermsAndService()
ButtonIds.Privacy -> displayPrivacy()
}
}
}
}
}
fun onItemClick(buttonId: String) {
debounceState.value = buttonId
}
}
object ButtonIds {
const val Support = "support"
const val About = "about"
const val TermsAndService = "termsAndService"
const val Privacy = "privacy"
}
The debouncer ignores any clicks that come in within 500 milliseconds of the last one received. I've tested this and it works. You'll never be able to click more than one item at a time. Although you can touch two at a time and both will be highlighted, only the first one you touch will generate the click handler.
Click Debouncer (Modifier)
This is another take on the click debouncer but is designed to be used as a Modifier. This is probably the one you will want to use the most. Most apps will make the use of scrolling lists that let you tap on a list item. If you quickly tap on an item multiple times, the code in the clickable modifier will execute multiple times. This can be a nuisance. While users normally won't tap multiple times, I've seen even accidental double clicks trigger the clickable twice. Since you want to avoid this throughout your app on not just lists but buttons as well, you probably should use a custom modifier that lets you fix this issue without having to resort to the viewmodel approach shown above.
Create a custom modifier. I've named it onClick:
fun Modifier.onClick(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit
) = composed(
inspectorInfo = debugInspectorInfo {
name = "clickable"
properties["enabled"] = enabled
properties["onClickLabel"] = onClickLabel
properties["role"] = role
properties["onClick"] = onClick
}
) {
Modifier.clickable(
enabled = enabled,
onClickLabel = onClickLabel,
onClick = {
App.debounceClicks {
onClick.invoke()
}
},
role = role,
indication = LocalIndication.current,
interactionSource = remember { MutableInteractionSource() }
)
}
You'll notice that in the code above, I'm using App.debounceClicks. This of course doesn't exist in your app. You need to create this function somewhere in your app where it is globally accessible. This could be a singleton object. In my code, I use a class that inherits from Application, as this is what gets instantiated when the app starts:
class App : Application() {
override fun onCreate() {
super.onCreate()
}
companion object {
private val debounceState = MutableStateFlow { }
init {
GlobalScope.launch(Dispatchers.Main) {
// IMPORTANT: Make sure to import kotlinx.coroutines.flow.collect
debounceState
.debounce(300)
.collect { onClick ->
onClick.invoke()
}
}
}
fun debounceClicks(onClick: () -> Unit) {
debounceState.value = onClick
}
}
}
Don't forget to include the name of your class in your AndroidManifest:
<application
android:name=".App"
Now instead of using clickable, use onClick instead:
Text("Do Something", modifier = Modifier.onClick { })
Globally disable multi-touch
In your main activity, override dispatchTouchEvent:
class MainActivity : AppCompatActivity() {
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return ev?.getPointerCount() == 1 && super.dispatchTouchEvent(ev)
}
}
This disables multi-touch globally. If your app has a Google Maps, you will want to add some code to to dispatchTouchEvent to make sure it remains enabled when the screen showing the map is visible. Users will use two fingers to zoom on a map and that requires multi-touch enabled.
State Managed Click Handler
Use a single click event handler that stores the state of which item is clicked. When the first item calls the click, it sets the state to indicate that the click handler is "in-use". If a second item attempts to call the click handler and "in-use" is set to true, it just returns without performing the handler's code. This is essentially the equivalent of a synchronous handler but instead of blocking, any further calls just get ignored.
The most simple approach that I found for this issue is to save the click state for each Item on the list, and update the state to 'true' if an item is clicked.
NOTE: Using this approach works properly only in a use-case where the list will be re-composed after the click handling; for example navigating to another Screen when the item click is performed.
Otherwise if you stay in the same Composable and try to click another item, the second click will be ignored and so on.
for example:
#Composable
fun MyList() {
// Save the click state in a MutableState
val isClicked = remember {
mutableStateOf(false)
}
LazyColumn {
items(10) {
ListItem(index = "$it", state = isClicked) {
// Handle the click
}
}
}
}
ListItem Composable:
#Composable
fun ListItem(
index: String,
state: MutableState<Boolean>,
onClick: () -> Unit
) {
Text(
text = "Item $index",
modifier = Modifier
.clickable {
// If the state is true, escape the function
if (state.value)
return#clickable
// else, call onClick block
onClick()
state.value = true
}
)
}
Trying to turn off multi-touch, or adding single click to the modifier, is not flexible enough. I borrowed the idea from #Johann‘s code. Instead of disabling at the app level, I can call it only when I need to disable it.
Here is an Alternative solution:
class ClickHelper private constructor() {
private val now: Long
get() = System.currentTimeMillis()
private var lastEventTimeMs: Long = 0
fun clickOnce(event: () -> Unit) {
if (now - lastEventTimeMs >= 300L) {
event.invoke()
}
lastEventTimeMs = now
}
companion object {
#Volatile
private var instance: ClickHelper? = null
fun getInstance() =
instance ?: synchronized(this) {
instance ?: ClickHelper().also { instance = it }
}
}
}
then you can use it anywhere you want:
Button(onClick = { ClickHelper.getInstance().clickOnce {
// Handle the click
} } ) { }
or:
Text(modifier = Modifier.clickable { ClickHelper.getInstance().clickOnce {
// Handle the click
} } ) { }
fun singleClick(onClick: () -> Unit): () -> Unit {
var latest: Long = 0
return {
val now = System.currentTimeMillis()
if (now - latest >= 300) {
onClick()
latest = now
}
}
}
Then you can use
Button(onClick = singleClick {
// TODO
})
Here is my solution.
It's based on https://stackoverflow.com/a/69914674/7011814
by I don't use GlobalScope (here is an explanation why) and I don't use MutableStateFlow as well (because its combination with GlobalScope may cause a potential memory leak).
Here is a head stone of the solution:
#OptIn(FlowPreview::class)
#Composable
fun <T>multipleEventsCutter(
content: #Composable (MultipleEventsCutterManager) -> T
) : T {
val debounceState = remember {
MutableSharedFlow<() -> Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
}
val result = content(
object : MultipleEventsCutterManager {
override fun processEvent(event: () -> Unit) {
debounceState.tryEmit(event)
}
}
)
LaunchedEffect(true) {
debounceState
.debounce(CLICK_COLLAPSING_INTERVAL)
.collect { onClick ->
onClick.invoke()
}
}
return result
}
#OptIn(FlowPreview::class)
#Composable
fun MultipleEventsCutter(
content: #Composable (MultipleEventsCutterManager) -> Unit
) {
multipleEventsCutter(content)
}
The first function can be used as a wrapper around your code like this:
MultipleEventsCutter { multipleEventsCutterManager ->
Button(
onClick = { multipleClicksCutter.processEvent(onClick) },
...
) {
...
}
}
And you can use the second one to create your own modifier, like next one:
fun Modifier.clickableSingle(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit
) = composed(
inspectorInfo = debugInspectorInfo {
name = "clickable"
properties["enabled"] = enabled
properties["onClickLabel"] = onClickLabel
properties["role"] = role
properties["onClick"] = onClick
}
) {
multipleEventsCutter { manager ->
Modifier.clickable(
enabled = enabled,
onClickLabel = onClickLabel,
onClick = { manager.processEvent { onClick() } },
role = role,
indication = LocalIndication.current,
interactionSource = remember { MutableInteractionSource() }
)
}
}
Just add two lines in your styles. This will disable multitouch in whole application:
<style name="AppTheme" parent="...">
...
<item name="android:windowEnableSplitTouch">false</item>
<item name="android:splitMotionEvents">false</item>
</style>

Android Compose remember affects other remember change

I have the next hierarchy:
WalletDetailsScreen
WalletDetailsView
SubWalletView
DefaultOutlinedButton
1st remember domainsVisible is declared in WalletDetailsScreen. Callback is propagated to DefaultOutlinedButton's onClick.
2nd remember copyToClipboardClicked is declared in SubWalletView.
What happens:
User opens the screen.
User taps copy button at first (SubWalletView). (2nd remember)
User taps DefaultOutlinedButton then. 1st remember is changed AND 2ND ONE IS CHANGED AS WELL!
Code:
#Composable
fun WalletDetailsScreen(
snackbarController: SnackbarController,
wallet: Wallet,
onNavIconClicked: () -> Unit
) {
// CHANGING THIS REMEMBER CHANGES 2ND ONE (BUT ONLY IF 2ND WAS FIRED AT LEAST ONCE)
val domainsVisible = rememberMutableStateOf(key = "domains_visible_btn", value = false)
WalletDetailsView(
snackbarController = snackbarController,
wallet = wallet,
domainsVisible = domainsVisible.value,
domainsCount = 0
) {
domainsVisible.toggle()
}
}
#Composable
private fun WalletDetailsView(
snackbarController: SnackbarController,
wallet: Wallet,
domainsVisible: Boolean,
domainsCount: Int,
onDomainsVisibilityClicked: () -> Unit
) {
Column {
wallet.subWallets.forEach { subWallet ->
SubWalletView(snackbarController = snackbarController, subWallet = subWallet)
}
// 1st REMEMBER IS CHANGED HERE
DefaultOutlinedButton(text = text, onClick = onDomainsVisibilityClicked)
}
}
#Composable
private fun SubWalletView(
snackbarController: SnackbarController,
subWallet: SubWallet
) {
// 2ND REMEMBER
val copyToClipboardClicked = rememberMutableStateOf(key = "copy_btn", value = false)
if (copyToClipboardClicked.value) {
CopyToClipboard(text = subWallet.address)
}
// 2ND REMEMBER IS CHANGED HERE
Box(
modifier = Modifier
.clickable { copyToClipboardClicked.toggle() }
.padding(start = 15.dp, top = 5.dp, bottom = 5.dp, end = 5.dp)
) {
// just icon here
}
}
Helpers:
#Composable
fun <T> rememberMutableStateOf(
key: String,
value: T,
policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
) = remember(key) { mutableStateOf(value, policy) }
fun MutableState<Boolean>.toggle() {
value = !value
}
I've tried to add keys to remember but it hasn't helped. Any ideas why changing one remember affects another? This shouldn't happen.
Finally, I figured out what's going on.
Second remember isn't changed actually.
But I rely on it to show shackbar:
if (copyToClipboardClicked.value) {
CopyToClipboard(text = subWallet.address)
ShowSnackbar(...)
copyToClipboardClicked.toggle() // <--- WE NEED THIS
}
And the missed part is that I need to switch flag off. I hadn't done it and that's why the if was triggered on each recomposition.

Jetpack Compose: react to Slider changed value

I want to show a slider that can be either updated by the user using drag/drop, or updated from the server in real-time.
When the user finishes their dragging gesture, I want to send the final value to the server.
My initial attempt is:
#Composable
fun LightView(
channel: UiChannel,
onDimmerChanged: (Float) -> Unit
) {
val sliderValue = channel....
Slider(value = sliderValue, onValueChange = onDimmerChanged)
}
And the onDimmerChanged method comes from my ViewModel, which updates the server value.
It works well, however onValueChange is called for each move, which bombards the server with unneeded requests.
I tried to create a custom slider:
#Composable
fun LightView(
channel: UiChannel,
onDimmerChanged: (Float) -> Unit
) {
val sliderValue = channel....
Slider(initialValue = sliderValue, valueSetter = onDimmerChanged)
}
#Composable
fun Slider(initialValue: Float, valueSetter: (Float) -> Unit) {
var value by remember { mutableStateOf(initialValue) }
Slider(
value = value,
onValueChange = { value = it },
onValueChangeFinished = { valueSetter(value) }
)
}
It works well on the app side, and the value is only sent once, when the user stops dragging.
However, it fails updating the view when there is an update from the server. I'm guessing this has something to do with remember. So I tried without remember:
#Composable
fun Slider(initialValue: Float, valueSetter: (Float) -> Unit) {
var value by mutableStateOf(initialValue)
Slider(
value = value,
onValueChange = { value = it },
onValueChangeFinished = { valueSetter(value) }
)
}
This time the view updates correctly when the value is updated by the server, but does not move anymore when the user drags the slider.
I'm sure I'm missing something with state hoisting and all, but I can't figure out what.
So my final question: how to create a Slider that can be either updated by the ViewModel, or by the user, and notifies the ViewModel of a new value only when the user finishes dragging?
EDIT:
I also tried what #CommonsWare suggested:
#Composable
fun LightView(
channel: UiChannel,
onDimmerChanged: (Float) -> Unit
) {
val sliderValue = channel....
val sliderState = mutableStateOf(sliderValue)
Slider(state = sliderState, valueSet = { onDimmerChanged(sliderState.value) })
}
#Composable
fun Slider(state: MutableState<Float>, valueSet: () -> Unit) {
Slider(
value = state.value,
onValueChange = { state.value = it },
onValueChangeFinished = valueSet
)
}
And it does not work either. When using drag and drop, sliderState is correctly updated, and onDimmerChanged() is called with the correct value. But for some reason, when tapping of the sliding (rather than sliding), valueSet is called and sliderState.value does not contain the correct value. I don't understand where this value comes from.
Regarding the original problem with local & server (or viewmodel) states conflicting with eachother:
I solved it for me by detecting wether or not we are interacting with the slider:
if yes, then show and update the local state value
or if not - then show the viewmodels value.
As you have said, we should never update the viewmodel from onValueChange - as this is only for updating the sliders value locally (documentation). Instead onValueChangeFinished is used for sending the current local state to the viewmodel, as soon as we are done interacting.
Regarding detection of current interaction, we can make use of InteractionSource.
Working example:
#Composable
fun SliderDemo() {
// In this demo, ViewModel updates its progress periodically from 0f..1f
val viewModel by remember { mutableStateOf(SliderDemoViewModel()) }
Column(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// local slider value state
var sliderValueRaw by remember { mutableStateOf(viewModel.progress) }
// getting current interaction with slider - are we pressing or dragging?
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val isDragged by interactionSource.collectIsDraggedAsState()
val isInteracting = isPressed || isDragged
// calculating actual slider value to display
// depending on wether we are interacting or not
// using either the local value, or the ViewModels / server one
val sliderValue by derivedStateOf {
if (isInteracting) {
sliderValueRaw
} else {
viewModel.progress
}
}
Slider(
value = sliderValue, // using calculated sliderValue here from above
onValueChange = {
sliderValueRaw = it
},
onValueChangeFinished = {
viewModel.updateProgress(sliderValue)
},
interactionSource = interactionSource
)
// Debug interaction info
Text("isPressed: ${isPressed} | isDragged: ${isDragged}")
}
}
Hope that helps.
There are a couple of things going on here, so let's try to break it down.
The initial attempt where onDimmerChanged is called for every value change looks great!
Looking at the second attempt, creating a custom Slider component works, but there are a few issues.
#Composable
fun LightView(
channel: UiChannel,
onDimmerChanged: (Float) -> Unit
) {
val sliderValue = channel....
Slider(initialValue = sliderValue, valueSetter = onDimmerChanged)
}
#Composable
fun Slider(initialValue: Float, valueSetter: (Float) -> Unit) {
var value by remember { mutableStateOf(initialValue) }
Slider(
value = value,
onValueChange = { value = it },
onValueChangeFinished = { valueSetter(value) }
)
}
Let's talk about what happens in the Slider composable here:
We remember the state with the initialValue
The channel value is updated, LightView gets recomposed, so does Slider
Since we remembered the state, it is still set to the previous initialValue
You're right with your thought about remember being the culprit here. When memorizing a value, it won't be updated when recomposing unless we tell Compose to. But without memorization (as seen in your third attempt), a state model will be created with every recomposition (var value by mutableStateOf(initialValue)) using the initialValue. Since a recomposition is triggered every time value changes, we will always pass the initialValue instead of the updated value to the Slider, causing it to never update by changes from within this Composable.
Instead, we want to pass initialValue as a key to remember, telling Compose to recalculate the value whenever the key changes.
#Composable
fun Slider(initialValue: Float, valueSetter: (Float) -> Unit) {
var value by remember { mutableStateOf(initialValue) }
Slider(
value = value,
onValueChange = { value = it },
onValueChangeFinished = { valueSetter(value) }
)
}
You can probably also just pull the Slider into your LightView:
#Composable
fun LightView(
channel: UiChannel,
onDimmerChanged: (Float) -> Unit
) {
var value by remember(channel.sliderValue) { mutableStateOf(channel.sliderValue) }
Slider(
value = value,
onValueChange = { value = it },
onValueChangeFinished = { onDimmerChanged(value) }
)
}
Lastly, about the attempt that #commonsware suggested.
#Composable
fun LightView(
channel: UiChannel,
onDimmerChanged: (Float) -> Unit
) {
val sliderValue = channel....
val sliderState = mutableStateOf(sliderValue)
Slider(state = sliderState, valueSet = { onDimmerChanged(sliderState.value) })
}
#Composable
fun Slider(state: MutableState<Float>, valueSet: () -> Unit) {
Slider(
value = state.value,
onValueChange = { state.value = it },
onValueChangeFinished = valueSet
)
}
This is another way of doing the same thing, but passing around MutableStates is an anti-pattern and should be avoided if possible. This is where state hoisting helps (what you were trying to do in the earlier attempts :))
Finally, about the Slider's onValueChangeFinished using the wrong value.
And it does not work either. When using drag and drop, sliderState is correctly updated, and onDimmerChanged() is called with the correct value. But for some reason, when tapping of the sliding (rather than sliding), valueSet is called and sliderState.value does not contain the correct value. I don't understand where this value comes from.
This is a bug in the Slider component. You can check this by looking at the output of this code:
#Composable
fun Slider(initialValue: Float, valueSetter: (Float) -> Unit) {
var value by remember { mutableStateOf(initialValue) }
val key = Random.nextInt()
Slider(
value = value,
onValueChange = { value = it },
onValueChangeFinished = {
valueSetter(value)
println("Callback invoked. Current key: $key")
}
)
}
Since key should change with every recomposition, you can see that the onValueChangeFinished callback holds a reference to an older composition (hope I put that right). So you weren't going crazy, it's not your fault :)
Hope that helped clear things up a bit!
Yeah, I can reproduce your issue as well. I answered in the bug topic, but I'll copy-paste it here so other people can fix it if they're in a rush. This solution sadly involves copying the whole Slider class.
The problem is that the drag and click modifiers use different Position instances, although they should always be the same (as Position is being created inside of remember).
The issue is with the pointer input modifier.
val press = if (enabled) {
Modifier.pointerInput(Unit) {...}
Change the Modifier.pointerInput() to accept any other subject different than Unit so your Position instance in that Modifier is always up to date and updated when Position gets recreated. For example, you can change it to Modifier.pointerInput(valueRange)
I encountered this problem. As jossiwolf pointed out, using the progress as a key for the remember{} is necessary to ensure that the Slider progress is updated after recomposition.
I had an additional issue though, where, if I updated the progress mid-seek, the Slider would recompose, and the thumb would revert back to its old position.
To work around this, I'm using a temporary slider position, which is only used while a drag is in progress:
#Composable
fun MySlider(
progress: Float,
onSeek: (progress: Float) -> Unit,
) {
val sliderPosition = remember(progress) { mutableStateOf(progress) }
val tempSliderPosition = remember { mutableStateOf(progress) }
val interactionSource = remember { MutableInteractionSource() }
val isDragged = interactionSource.collectIsDraggedAsState()
Slider(
value = if (isDragged.value) tempSliderPosition.value else sliderPosition.value,
onValueChange = { progress ->
sliderPosition.value = progress
tempSliderPosition.value = progress
},
onValueChangeFinished = {
sliderPosition.value = tempSliderPosition.value
onSeek(tempSliderPosition.value)
},
interactionSource = interactionSource
)
}
I've elaborated on Steffen's answer a bit so it is possible to update the "external" "viewModel" value even while the user is currently dragging the slider (as there are many use cases when that is required).
The usage is the same as the compose "native" Slider, with the exception that the onValueChangeFinished parameter now accepts the finishing float value.
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.material.Slider
import androidx.compose.material.SliderColors
import androidx.compose.material.SliderDefaults
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
/**
* "Wrapper" around the Slider solving the issue of overwriting values during drag. See https://stackoverflow.com/questions/66386039/jetpack-compose-react-to-slider-changed-value.
* Use as normal Slider and feel free to update the underlying value in the onValueChange method.
*/
#Composable
fun ComposeSlider(
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
steps: Int = 0,
onValueChangeFinished: ((Float) -> Unit)? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
colors: SliderColors = SliderDefaults.colors()
) {
// local slider value state with default value (from the "external" source)
var sliderValueRaw by remember { mutableStateOf(value) }
// getting current interaction with slider - are we pressing or dragging?
val isPressed by interactionSource.collectIsPressedAsState()
val isDragged by interactionSource.collectIsDraggedAsState()
val isInteracting = isPressed || isDragged
// calculating actual slider value to display depending on whether we are interacting or not
// using either the local value, or the provided one
val determinedValueToShow by remember(isInteracting, sliderValueRaw, value) {
derivedStateOf {
if (isInteracting) {
sliderValueRaw
} else {
value
}
}
}
Slider(
value = determinedValueToShow,
onValueChange = {
sliderValueRaw = it
onValueChange.invoke(it)
},
modifier = modifier,
enabled = enabled,
valueRange = valueRange,
steps = steps,
onValueChangeFinished = {
onValueChangeFinished?.invoke(sliderValueRaw)
},
interactionSource = interactionSource,
colors = colors
)
}

Categories

Resources