I'm trying to create a display where users can input a duration composed of hours, minutes and seconds. I manage the state of the duration with a class i wrote called TimeData. I set the value for the textfield to the state values, however this does not get updated when it changes. I have tried alot and i can't seem to figure out why this does not work, as similar implementations work just fine.
I save the state in viewmodel, inject it into the screen (composable) the screen passes fields of the TimeData to the three composables for the hours, seconds and minutes. These components handle displaying the textfields and changing the values
I've tried changing state directly, saving state in screen file with by remember instead of in viewmodel with State, i've tried changing val to var and back in the TimeData object and it's children. And much more.
p.s. this is my first question here, so if anything is not clear, please let me know.
TimeData class (saved as state in viewmodel)
data class TimeData(
val hours: TimeUnit = TimeUnit(TimeUnits.HOURS),
val mins: TimeUnit = TimeUnit(TimeUnits.MINS),
val secs: TimeUnit = TimeUnit(TimeUnits.SECS),
) {
fun isDataEmpty() = hours.value == 0L && mins.value == 0L && secs.value == 0L
}
fun TimeData.toISOString(): String = "PT" +
"${hours.value}${hours.unit.firstLetter}" +
"${mins.value}${mins.unit.firstLetter}" +
"${secs.value}${secs.unit.firstLetter}"
fun TimeData.toDuration(): Duration = Duration.parse(this.toISOString())
TimeUnit class (this value is updated)
data class TimeUnit(
var unit: TimeUnits
) {
var value: Long = 0
set(value) {
val parsedValue = value
val min = 0
val max = when (unit) {
TimeUnits.HOURS -> 99
TimeUnits.MINS -> 60
TimeUnits.SECS -> 60
}
if (parsedValue in min..max) {
field = value
}
}
override fun toString(): String {
return if (value in 1..9) "0$value" else value.toString()
}
}
enum class TimeUnits(val firstLetter: String) {
HOURS("h"),
MINS("m"),
SECS("s")
}
State in viewmodel
private val _timeState = mutableStateOf(TimeData())
val timeState: State<TimeData> = _timeState
Texfield
BasicTextField(
modifier = Modifier.widthIn(1.dp),
value = time.value.toString(),
onValueChange = {
if (it.isNotBlank()) {
//changing state directly
time.value = it.toLong()
//letting viewmodel handel change
viewModel.onEvent(AddEditExerciseEvents.DurationValueChanged(time))
}
},
singleLine = true,
textStyle = TextStyle(
fontSize = 32.sp,
fontWeight = FontWeight.Medium,
color = textColor,
textAlign = TextAlign.Center
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
Changing state value in viewmodel
when (event) {
is AddEditExerciseEvents.DurationValueChanged -> {
when (event.value.unit) {
TimeUnits.HOURS -> {
_timeState.value = timeState.value.copy(
hours = event.value
)
}
TimeUnits.MINS -> {
_timeState.value = timeState.value.copy(
mins = event.value
)
}
TimeUnits.SECS -> {
_timeState.value = timeState.value.copy(
secs = event.value
)
}
}
}
Related
I'm trying to create a scrollable background in Jetpack Compose.
The problem is that the variable "currentPadding" isn't updating it's state after the value "padding" is modified after recomposition. In the first composition (loading state) the "padding" value is set to 112.dp and after load the value changes to 160.dp.
It's strange because I have used the remember function this way multiple times in other places in the app and it's the first time that this happens.
Could you help me out?
Thanks a lot.
#Composable
fun ScrollableBackground(
scrollState: ScrollState,
composable: ComposableFun,
modifier: Modifier = Modifier,
isBannerListEmpty: Boolean,
) {
val padding = if (isBannerListEmpty) {
112.dp
} else {
160.dp
}
val minPadding: Dp = 29.dp
val dp0 = dimensionResource(id = R.dimen.no_dp)
var currentPadding: Dp by remember { mutableStateOf(padding) }
val state: Dp by animateDpAsState(targetValue = currentPadding)
val nestedScrollConnection: NestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val percent = scrollState.value.toFloat() / scrollState.maxValue.toFloat() * 100f
val delta = available.y.dp
val newSize = currentPadding + delta / 3
currentPadding = when {
percent > 20f -> minPadding
newSize < minPadding -> minPadding
newSize > padding -> padding
else -> newSize
}
return Offset.Zero
}
}
}
Box(
modifier = modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection)
) {
Surface(
color = White,
modifier = Modifier
.padding(top = state)
.fillMaxSize()
.clip(
CircleShape.copy(
topStart = Shapes.medium.topStart,
topEnd = Shapes.medium.topEnd,
bottomEnd = CornerSize(dp0),
bottomStart = CornerSize(dp0)
)
)
) {}
composable.invoke()
}
}
I tried sending other kind of parameters from the view model to the composable (instead of a boolean, in this case "isBannerListEmpty"), like the current desired padding value, and nothing seems to work.
You have put nestedScrollConnection in remember. It also remembers the padding variable when first encountered. So when the actual value of padding is changed, this change is not propagated in remember of nestedScrollConnection.
Put padding inside onPreScroll.
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val padding = if (...) { //Don't use isBannerListEmpty here as neither this will update on recomposition
112.dp
} else {
160.dp
}
...
I am trying to make a desktop application that allows you to search through a number of predefined locations stored in Kotlin classes in a separate directory. To accomplish this, I've used the reflections and compose-jb libraries.
The problem I've run into is that I can't figure out how to update a Column of Boxes (located in another Box component) to change when I press the search button after entering tags that I want to search by.
My code is below (for the Main.kt file) that describes the entire desktop application.
val reflections = Reflections("io.github.mobomega.project.attractions")
var display = mutableSetOf<Attraction>()
fun main() = application {
val stateVertical = rememberScrollState(0)
val stateHorizontal = rememberScrollState(0)
var state = Box(
modifier = Modifier
.fillMaxSize()
.verticalScroll(stateVertical)
.padding(end = 12.dp, bottom = 12.dp)
.horizontalScroll(stateHorizontal)
)
Window(
onCloseRequest = ::exitApplication,
title = "Search",
state = rememberWindowState(width = 2256.dp, height = 1504.dp)
) {
val count = remember { mutableStateOf(1) }
MaterialTheme {
Column {
val text = remember { mutableStateOf("") }
OutlinedTextField(
value = text.value,
singleLine = true,
onValueChange = { text.value = it },
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Row (modifier = Modifier.size(2256.dp, 50.dp), horizontalArrangement = Arrangement.Center) {
Button(modifier = Modifier.align(Alignment.Top),
onClick = {
val tags = text.value.split(", ", ",")
for (tag in tags) {
search(tag.lowercase())
println("$display have tag $tag")
}
// Setting the new value of the Box
state = create(stateVertical, stateHorizontal)
// Creates error:
// "#Composable invocations can only happen from the context of a #Composable function"
}) {
Text("Search")
}
Button (modifier = Modifier.align(Alignment.Top),
onClick = {
display.clear()
}) {
Text("Reset")
}
}
Row (horizontalArrangement = Arrangement.Center) {
Box(
modifier = Modifier.fillMaxSize()
.background(color = Color(red = 0xFF, green = 0xFF, blue = 0xFF))
.padding(10.dp)
) {
state // Creating the state Box component in the Row
VerticalScrollbar(
modifier = Modifier.align(Alignment.CenterEnd)
.fillMaxHeight(),
adapter = rememberScrollbarAdapter(stateVertical)
)
HorizontalScrollbar(
modifier = Modifier.align(Alignment.BottomStart)
.fillMaxWidth()
.padding(end = 12.dp),
adapter = rememberScrollbarAdapter(stateHorizontal)
)
}
}
}
}
}
}
#Composable
fun textBox(text: String = "Item") {
Box(
modifier = Modifier.height(32.dp)
.width(400.dp)
.background(color = Color(200, 0, 0, 20))
.padding(start = 10.dp),
contentAlignment = Alignment.CenterStart
) {
Text(text = text)
}
}
#Composable
fun create(stateVertical: ScrollState, stateHorizontal: ScrollState) = Box(
modifier = Modifier
.fillMaxSize()
.verticalScroll(stateVertical)
.padding(end = 12.dp, bottom = 12.dp)
.horizontalScroll(stateHorizontal)
) {
Column {
var x = 0
for (attr in display) {
x++
textBox(attr.name)
if (x < display.size) {
Spacer(modifier = Modifier.height(5.dp).align(Alignment.CenterHorizontally))
}
}
}
}
fun search(text: String) {
for (attr in reflections.getSubTypesOf(Attraction::class.java)) {
val temp = attr.getConstructor().newInstance()
println("${temp.name} has tags ${temp.tags}")
if (temp.matches(text) && (temp !in display)) {
display += temp
}
}
}
I have tried to update the value of the Box that contains all of the items that match any of the search criteria, but I have run into a number of issues, such as the "onClick" function in which I set the new value of the "state" variable (storing all of the matching items) not being a Composable function, and therefore I can't change the value.
How would I accomplish changing the value of a Component such as a Box from another Component, such as a Button?
In Compose you can't create a view like you're doing with state variable. Result of your call is just Unit, and when you later call it you should see a warning "The expression is unused". The view is added at the tree hierarchy at the moment your variable is created.
To solve your problem you need to declare display as a mutable state - it's a new thing made especially for Compose, which allows triggering recomposition when this state changes:
val display by mutableStateOf<Attraction>(setOf())
And then update like this in your search:
val mutableDisplay = mutableSetOf<Attraction>()
// for
// ...
mutableDisplay += temp
// ...
display = mutableDisplay
Note that you can't use mutable set inside your mutable state, as mutable state won't be able to track changes of this set.
To learn more about state in Compose I suggest you checking this youtube video which explains the basic principles, and Compose mental model for better understanding of how to work with it.
The Code A is from the official Sample project here.
1: I think I can wrap text.isNotBlank() withremember, so I think Code B is good, right?
BTW, I know the system will re-calculate when the text ( val (text, setText) = remember { mutableStateOf("") } ) is changed. So
2: In Code B, val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)} and val iconsVisible = remember {text.isNotBlank() } will be re-launched when the text is changed (val (text, setText) = remember { mutableStateOf("") }) , right?
Code A
#Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
val iconsVisible = text.isNotBlank()
Column {
Row( /* ... */ ) {
/* ... */
}
if (iconsVisible) {
AnimatedIconRow(icon, setIcon, Modifier.padding(top = 8.dp))
} else {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
Code B
#Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
val iconsVisible = remember {text.isNotBlank() } //I add remember
Column {
...
}
}
If you use remember as in Code B, iconsVisible will be calculated only once and the same value will be used across all recompositions and not get updates when text changes, which is not what we want here.
If you want to use remember here, you should pass text as a key to it, remember(text) { text.isNotBlank() }. But as this is not a time consuming calculation, you can just skip the remember block and use it as in Code A. The .isNotBlank() function will be invoked in every recomposition but that doesn't matter much here.
I have some doubts how to realize Search Bar in my Compose application. I call my SeachView function from ContactContent function where I pass state value.
#Composable
fun ContactContent(navigateToProfile: (Contact) -> Unit) {
val contacts = remember { DataProvider.contactList }
val textState = remember { mutableStateOf(TextFieldValue("")) }
Column(){
SearchView(textState)
LazyColumn() {
items(
items = contacts,
itemContent = {
ContactListItem(contact = it, navigateToProfile)
}
...
}
In SearchView I not sure how should I call onImeActionPerformed search state as mine search state is not be recognized.
#Composable
fun SearchView(state: MutableState<TextFieldValue>) {
Surface(){
TextField(
value = state.value,
onValueChange = { value -> state.value = value},
leadingIcon = {...},
keyBoardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Search
),
onImeActionPerformed = {action, softKeyboardController -> if (action == ImeAction.Search){
>HERE IS WHERE I AM NOT SURE WHAT TO DO<
DataProvider.newSearch(textState)
}
...
}
newSearch function snippet
fun newSearch (textState: MutableState<String>){
val result = repository.search(
token = token,
page = 1,
query = "chicken"
)
DataProvider.value = result
}
Maybe you have a different solution how to realize the search bar from the list with with Kotlin Compose.
Do not use it directly in the parenthesis of the onImeActionPerformed, extract it in the parent Composable right before calling the TextField. Store it in a val, then use that val inside your onImeActionPerformed. Alogside, I assume you are creating that parameter with something like () -> Unit, ok so I'm not sure of this, but I think changing that to #Composable () -> Unit, you can access it directly without extracting it in a val first. Try it out
I have a recyclerView. When I do the pull to refresh, if the new data is just one list item, then the recycler view loads the item perfectly. But if the updated data contains 2 or more, then I think the view is not recycled properly. In the actionContainer, there should only one item to be added for each of the updated list item. But during pull to refresh, ONLY WHEN there are 2 or more list items to be updated, the actionContainer shows 2 data where it should be only one. Can someone help me to fix this?
override fun onBindViewHolder(holder: HistoryListAdapter.ViewHolder?, position: Int) {
info("onBindViewHolder =>"+listAssets.size)
info("onBindViewHolder itemCount =>"+itemCount)
info("onBindViewHolder position =>"+position)
val notesButton = holder?.notesButton
val notesView = holder?.notesTextView
val dateTime = listAssets[position].date
val location = listAssets[position].location
val sessionId = listAssets[position].id
holder?.sessionID = sessionId
holder?.portraitImageView?.setImageDrawable(listAssets[position].image)
holder?.titleTextView?.text = DateTimeFormatter.getFormattedDate(context, dateTime)
val timeString = DateTimeFormatter.getFormattedTime(context, dateTime)
if (location.length != 0) {
holder?.subtitleTextView?.text = "$timeString # $location"
} else {
holder?.subtitleTextView?.text = "$timeString"
}
val data = listAssets[position].data
for (actionData in data) {
val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val parent = inflater.inflate(R.layout.history_card_action, null)
val icon = parent?.findViewById(R.id.historyActionIcon) as ImageView
val title = parent?.findViewById(R.id.historyActionTitle) as TextView
val subtitle = parent?.findViewById(R.id.historyActionSubtitle) as TextView
var iconDrawable: Drawable? = null
when(actionData.type) {
ActionType.HEART -> {
iconDrawable = ContextCompat.getDrawable(context, R.drawable.heart)
}
ActionType.LUNGS -> {
iconDrawable = ContextCompat.getDrawable(context, R.drawable.lungs)
}
ActionType.TEMPERATURE -> {
iconDrawable = ContextCompat.getDrawable(context, R.drawable.temperature)
}
}
icon.setImageDrawable(iconDrawable)
val titleString = actionData.title
titleString?.let {
title.text = titleString
}
val subtitleString = actionData.subtitle
subtitleString?.let {
subtitle.text = subtitleString
}
holder?.actionContainer?.addView(parent)
}
val notes = listAssets[position].notes
notesView?.text = notes
if (notes.length == 0) {
notesButton?.layoutParams?.width = 0
} else {
notesButton?.layoutParams?.width = toggleButtonWidth
}
if (expandedNotes.contains(sessionId)) {
notesView?.expandWithoutAnimation()
} else {
notesView?.collapseWithoutAnimation()
}
notesButton?.onClick {
notesView?.toggleExpansion()
}
}
data class ListAssets(val id: String,
val date: Date,
val location: String,
val notes: String,
val image: Drawable,
val data: ArrayList<ListData>)
data class ListData(val type: ActionType,
val title: String?,
val subtitle: String?)
override fun onViewRecycled(holder: HistoryListAdapter.ViewHolder?) {
super.onViewRecycled(holder)
if (holder != null) {
holder.actionContainer.removeAllViewsInLayout()
holder.actionContainer.removeAllViews()
val notesTextView = holder.notesTextView
if (notesTextView != null) {
if (notesTextView.expandedState) {
val sessionID = holder.sessionID
sessionID?.let {
val sessionSearch = expandedNotes.firstOrNull {
it.contentEquals(sessionID)
}
if (sessionSearch == null) {
expandedNotes.add(sessionID)
}
}
} else {
val sessionID = holder.sessionID
sessionID?.let {
val sessionSearch = expandedNotes.firstOrNull {
it.contentEquals(sessionID)
}
if (sessionSearch != null) {
expandedNotes.remove(sessionSearch)
}
}
}
}
}
}
First, you should probably not override onViewRecycled() unless you have to perform some very particular resources cleanup.
The place where you want to setup your views before display is onBindViewHolder().
Second, you don't need not add or remove views dynamically in a RecyclerView item, it's simpler and more efficient to only switch the visibility of the view between VISIBLE and GONE. In cases where this is not enough because views are too different, you should declare different view types, which will create separate ViewHolders for each view type.
You should not remove or add any view while overriding onBindViewHoder() method of RecyclerView Adapter because next time when a recycled layout is used, the removed views will not be found. Instead of this you can use show/hide on a view.
If you add any view to the layout dynamically, later on when this layout is recycled, it also contains the extra view which you have added before.
Similarly, if you remove any view from the layout dynamically, later on when this layout is recycled, it does not contain the view which you have removed earlier.
I have implemented a RecyclerView and Retrofit,it has the SwipeView layout (Pull to Refresh).Here is the link to the repisitory.
https://github.com/frankodoom/Retrofit-RecyclerVew