How to use sealed class for placeholder values in string resource - android

Is it possible within Jetpack Compose to use a sealed class to display strings with different values in their placeholders? I got confused when trying to figure out what to use for the Text objects. i.e. text = stringResource(id = it.?)
strings.xml
<string name="size_placeholder">Size %1$d</string>
<string name="sizes_placeholder_and_placeholder">Sizes %1$d and %2$d</string>
MainActivity.kt
sealed class Clothes {
data class FixedSizeClothing(val size: String, val placeholder: String): Clothes()
data class MultiSizeClothing(val sizes: String, val placeholders: List<String>): Clothes()
}
#Composable
fun ClothesScreen() {
val clothingItems = remember { listOf(
Clothes.FixedSizeClothing(itemSize = stringResource(id = R.string.size), itemPlaceholder = "8"),
Clothes.MultiSizeClothing(itemSizes = stringResource(id = R.string.sizes), itemPlaceholders = listOf("0", "2"))
)
}
Scaffold(
topBar = { ... },
content = { it ->
Row {
LazyColumn(
modifier = Modifier.padding(it)
) {
items(items) {
Column() {
Text(
text = stringResource(id = it.?)
)
Text(
text = stringResource(id = it.?)
)
}
}
}
}
},
containerColor = MaterialTheme.colorScheme.background
)
expected result

This is more a question about how to check for the type of a subclass of a sealed class (or sealed interface). Just to avoid any confusion, it should be made clear that these are Kotlin features and are not related to Jetpack Compose.
But yes, they can be used inside Composables as well or anywhere you want, really.
You would use a when (...) expression on the value of your sealed class to determine what to do based on the (sub)type of your sealed class (it works the same for sealed interfaces). Inside the when expression you then use the is operator to check for different subtypes.
val result = when (it) {
is Clothes.FixedSizeClothing -> {
// it.size and it.placeholder are accessible here due to Kotlin smart cast
// do something with them...
// last line will be returned as the result
}
is Clothes.MultiSizeClothing -> {
// it.sizes and it.placeholders are accessible here due to Kotlin smart cast
// do something with them...
// last line will be returned as the result
}
In situations when you don't need the result you just omit the val result = part. Note that the name result is arbitrary, pick whatever best describes the value you are creating.
The advantage of this type of the when expression is that it will give you a warning (and in future versions an error) if you forget one of the subtypes inside the when expression. This means the when expression is always exhaustive when there is no warning present, i.e. the Kotlin compiler checks at compile-time for all subtypes of the specific sealed class that are defined inside your whole codebase and makes sure that you are accounting for all of them inside the when expression.
For more on sealed classes inside a when expression see https://kotlinlang.org/docs/sealed-classes.html#sealed-classes-and-when-expression
In your case, you would do the same to generate the text value that you would then pass into the Text(text = ...) composable.
Scaffold(
topBar = { ... },
content = { it ->
Row {
LazyColumn(
modifier = Modifier.padding(it)
) {
items(clothingItems) {
val text = when (it) {
is Clothes.FixedSizeClothing ->
stringResource(id = R.string.size, it.placeholder)
is Clothes.MultiSizeClothing ->
stringResource(id = R.string.sizes, it.placeholders[0], it.placeholders[1])
}
Text(text = text)
}
}
}
},
containerColor = MaterialTheme.colorScheme.background
)
I used the stringResource(#StringRes id: Int, vararg formatArgs: Any): String version of the call above to construct the final text. See here for other options available in Compose https://developer.android.com/jetpack/compose/resources
If you do want to store the presentation String inside your data classes as you are trying to do in your example you could store the resource id instead of the resolved String.
Also since you are using integer placeholders (%1$d) in your resource strings, the type of your size and sizes values can be Int. So all together that would be something like this
import androidx.annotation.StringRes
sealed class Clothes {
data class FixedSizeClothing(#StringRes val size: Int, val placeholder: Int): Clothes()
data class MultiSizeClothing(#StringRes val sizes: Int, val placeholders: List<Int>): Clothes()
}
And then when you define the items you would not call stringResource(id = ...) anymore and your size and sizes values are just integers.
val clothingItems = remember {
listOf(
Clothes.FixedSizeClothing(size = R.string.size, placeholder = 8),
Clothes.MultiSizeClothing(sizes = R.string.sizes, placeholders = listOf(0, 2))
)
}
The added benefit of this is that now you do not need a Composable context (or a Context or a Resources reference) to create instances of your Clothes sealed class.
Then when declaring the UI, you would do something like this
LazyColumn(
modifier = Modifier.padding(it)
) {
items(clothingItems) {
val text = when (it) {
is Clothes.FixedSizeClothing ->
stringResource(id = it.size, it.placeholder)
is Clothes.MultiSizeClothing ->
stringResource(id = it.sizes, it.placeholders[0], it.placeholders[1])
}
Text(text = text)
}
}

Related

Android common ViewModel for two Compose screens

I have an issue with refreshing Compose Lazy List, based on changes in persistence.
The business case - I have a screen (Fragment) with MyObject list contains all objects, there is another screen with only favorites MyObjects. Both use the same Composable as a list element with name, description and "heart" icon to set/unset favorite flag.
On "all" list setting and unsetting favorite flag works well - click on IconToggleButton sets boolean in DB and then switching to Favorite screen shows new item. Unset favorite on "all" screen sets flag to false as expected and when navigates to Favorite screen removes item.
But toggling favorite icon on Favorite screen change boolean in DB - BUT does not refresh and recompose LazyList content. I have to manually switch few times between screens, then eventually both are, let's say, synchronized with DB.
Unset favorite on the last element on Favorite list does not refresh it at all - I have to "like" another object on "all" list, then the Favorite list content is recompose with replacing items.
Moreover - there are some cases, that items on both lists disappears, while they are still in DB. I need to dig it deeper and debug this case, but maybe is related.
Some code's details:
There is a simple Entity
#Entity(tableName = "my_objects")
data class MyObject(
#PrimaryKey(autoGenerate = true)
var id: Long = 0,
#ColumnInfo(name = "name")
val name: String,
#ColumnInfo(name = "favorite")
val favorite: Boolean = false
)
Then there are also DAO, Provider and Repository with Domain Model. In DAO there are methods:
#Query("SELECT * FROM my_objects")
fun getAll(): List<MyObject>
#Query("SELECT * FROM my_objects WHERE favorite = 1")
fun getFavorites(): List<MyObject>
called in Provider and then in Repository.
In MyObjectListViewModel (with mapping from DB model do domain model):
#HiltViewModel
class MyObjectListViewModel #Inject constructor(
private val updateMyObject: UpdateMyObject,
private val getOrderedMyObjectList: GetOrderedMyObjectList,
private val dispatchers: CoroutineDispatcherProvider
) : ViewModel() {
private val mutableMyObjects = MutableLiveData<List<ItemMyObjectModel>>()
val myObjects: LiveData<List<ItemMyObjectModel>> = mutableMyObjects
fun loadMyObjects() {
viewModelScope.launch(dispatchers.io) {
val myObjectListResult = getOrderedMyObjectList()
withContext(dispatchers.main) {
when (myObjectListResult) {
is MyObjectListResult.Success -> {
val viewModelList = myObjectListResult.list.map {
ItemMyObjectModel(it)
}
mutableMyObjects.postValue(viewModelList)
}
}
}
}
}
fun switchFavoriteFlag(itemMyObjectModel: ItemMyObjectModel) {
val myObject = itemMyObjectModel.itemMyObject
myObject.favorite = !myObject.favorite
viewModelScope.launch(dispatchers.io) {
val updatedObject = updateMyObject(myObject) //save via DAO
}
}
}
MyObjectFavoriteListViewModel looks exactly the same, except that load function calls loadFavoriteMyObjects() and it uses GetOrderedFavoriteMyObjectList Repository. BTW - maybe it could be aggregate to one ViewModel, but with pair of LiveData and load function - one pair for all item and one for favorites?
Last but not least - Composables:
#OptIn(ExperimentalMaterial3Api::class)
#Composable
fun MyObjectFavoriteListScreen(
viewModel: MyObjectFavoriteListViewModel,
navigator: MyObjectNavigator
) {
val list by viewModel.myObjects.observeAsState()
val lazyListState = rememberLazyListState()
Scaffold(
floatingActionButton = {
MyObjectListFloatingActionButton(
extended = lazyListState.isScrollingUp() //local extension
) { navigator.openNewMyObjectFromObjectList() }
}
) { padding ->
if (list != null) {
LazyColumn(
contentPadding = PaddingValues(
horizontal = dimensionResource(id = R.dimen.margin_normal),
vertical = dimensionResource(id = R.dimen.margin_normal)
),
state = lazyListState,
modifier = Modifier.padding(padding)
) {
items(list!!) { item ->
MyObjectListItem( // with Card() includes Text() and IconToggleButton()
item = item,
onCardClick = { myObjectId -> navigator.openMyObjectDetailsFromFavouriteList(myObjectId) },
onFavoriteClick = { itemMyObjectModel -> viewModel.switchFavoriteFlag(itemMyObjectModel) }
)
}
}
} else {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(text = stringResource(id = "No objects available"))
}
}
}
}
I think that one issue could be related with if (list != null) {} (list is observed as State<T?>).
But for sure there is something wrong with states, I am pretty sure that the list should be triggered to recompose, but there is no(?) state to do so.
Any ideas?

Can we or should use Preview compose function for main widget as well?

Like below are two functions
#Composable
private fun WaterCounter(modifier: Modifier = Modifier) {
val count = 0
Text(
text = "You've had $count glasses of water",
modifier = modifier.padding(all = 16.dp)
)
}
#Preview(showBackground = true)
#Composable
private fun PreviewWaterCounter() {
WaterCounter()
}
So, wouldn't it be better if we add #Preview annotation to the WaterCounter, which will save some lines of code and will work both as a preview and a widget?
For simple situations like your posted code, having a separate composable preview seems a bit too much, but consider this scenario with 2 composables with non-default parameters,
#Composable
fun PersonBiography(
details: Data,
otherParameters : Any?
) {
Box(
modifier = Modifier.background(Color.Red)
) {
Text(details.dataValue)
}
}
#Composable
fun AccountDetails(
details: Data
) {
Box(
modifier = Modifier.background(Color.Green)
) {
Text(details.dataValue)
}
}
both of them requires same data class , the first one has an additional parameter. If I have to preview them I have to break their signature, assigning default values to them just for the sake of the preview.
#Preview
#Composable
fun PersonBiography(
details: Data = Data(dataValue = ""),
otherParameters : Any? = null
) { … }
#Preview
#Composable
fun AccountDetails(
details: Data = Data(dataValue = "")
) { … }
A good workaround on this is having 2 separate preview composables and taking advantage of PreviewParameterProvider to have a re-usable utility that can provide instances of the parameters I needed.
class DetailsPreviewProvider : PreviewParameterProvider<Data> {
override val values = listOf(Data(dataValue = "Some Data")).asSequence()
}
#Preview
#Composable
fun PersonBiographyPreview(#PreviewParameter(DetailsPreviewProvider::class) details: Data) {
PersonBiography(
details = details,
// you may also consider creating a separate provider for this one if needed
null
)
}
#Preview
#Composable
fun AccountDetailsPreview(#PreviewParameter(DetailsPreviewProvider::class) details: Data) {
AccountDetails(details)
}
Or if PreviewParameterProvider is a bit too much, you can simply create a preview composable where you can create and supply the mock data.
#Preview
#Composable
fun AccountDetailsPreview() {
val data = Data("Some Account Information")
AccountDetails(data)
}
With any of these approaches, you don't need to break your actual composable's structure just to have a glimpse of what it would look like.

When using List as State, how to update UI when item`attribute change in Jetpack Compose?

For example, I load data into a List, it`s wrapped by MutableStateFlow, and I collect these as State in UI Component.
The trouble is, when I change an item in the MutableStateFlow<List>, such as modifying attribute, but don`t add or delete, the UI will not change.
So how can I change the UI when I modify an item of the MutableStateFlow?
These are codes:
ViewModel:
data class TestBean(val id: Int, var name: String)
class VM: ViewModel() {
val testList = MutableStateFlow<List<TestBean>>(emptyList())
fun createTestData() {
val result = mutableListOf<TestBean>()
(0 .. 10).forEach {
result.add(TestBean(it, it.toString()))
}
testList.value = result
}
fun changeTestData(index: Int) {
// first way to change data
testList.value[index].name = System.currentTimeMillis().toString()
// second way to change data
val p = testList.value[index]
p.name = System.currentTimeMillis().toString()
val tmplist = testList.value.toMutableList()
tmplist[index].name = p.name
testList.update { tmplist }
}
}
UI:
setContent {
LaunchedEffect(key1 = Unit) {
vm.createTestData()
}
Column {
vm.testList.collectAsState().value.forEachIndexed { index, it ->
Text(text = it.name, modifier = Modifier.padding(16.dp).clickable {
vm.changeTestData(index)
Log.d("TAG", "click: ${index}")
})
}
}
}
Both Flow and Compose mutable state cannot track changes made inside of containing objects.
But you can replace an object with an updated object. data class is a nice tool to be used, which will provide you all copy out of the box, but you should emit using var and only use val for your fields to avoid mistakes.
Check out Why is immutability important in functional programming?
testList.value[index] = testList.value[index].copy(name = System.currentTimeMillis().toString())

Use a custom class for the value of TextField does not survive across re-composition

In the code below, the TextField that takes a mutableStateOf("") works as expected: The TextField shows whatever I type in.
However, if the TextField takes StrHolder.s as the value. The TextField does not show anything I type. Why is that?
class StrHolder(var s: String)
Column {
var text by remember { mutableStateOf("") }
TextField(
value = text,
onValueChange = { text = it },
)
var strHolder by remember {
mutableStateOf(StrHolder(""))
}
Text("My text is: ${strHolder.s}")
TextField(
value = strHolder.s,
onValueChange = { strHolder.s = it },
)
}
In order for mutableStateOf to trigger recomposition, the container value should be updated, e.g. strHolder = newValue.
There's no way how it can know you've changed one of inner values.
data class (with val-only properties) is a great tool to be used in functional programming for such cases: copying the data will save your from such mistakes:
data class StrHolder(val s: String)
var strHolder by remember {
mutableStateOf(StrHolder(""))
}
Text("My text is: ${strHolder.s}")
TextField(
value = strHolder.s,
onValueChange = { strHolder = strHolder.copy(s = it) },
)
You can find more info about state in Compose in documentation, including this youtube video which explains the basic principles.
If you want Compose to know about changes to your StrHolder.s property, you need to wrap it inside a State. Like this:
class StrHolder {
var s by mutableStateOf("")
}
val strHolder by remember { mutableStateOf(Data()) }
TextField(
value = strHolder.s,
onValueChange = { strHolder.s = it }
)

Why this function is called multiple times in Jetpack Compose?

I'm currently trying out Android Compose. I have a Text that shows price of a crypto coin. If a price goes up the color of a text should be green, but if a price goes down it should be red. The function is called when a user clicks a button. The problem is that the function showPrice() is called multiple times (sometimes just once, sometimes 2-4 times). And because of that the user can see the wrong color. What can I do to ensure that it's only called once?
MainActivity:
#Composable
fun MainScreen() {
val priceLiveData by viewModel.trackLiveData.observeAsState()
val price = priceLiveData ?: return
when (price) {
is ViewState.Response -> showPrice(price = price.data)
is ViewState.Error -> showError(price.text)
}
Button(onClick = {viewModel.post()} )
}
#Composable
private fun showPrice(price: Double) {
lastPrice = sharedPref.getFloat("eth", 0f).toDouble()
val color by animateColorAsState(if (price >= (lastPrice)) Color.Green else
Color.Red)
Log.v("TAG", "last=$lastPrice new = $price")
editor.putFloat("eth", price.toFloat()).apply()
Text(
text = price.toString(),
color = color,
fontSize = 28.sp,
fontFamily = fontFamily,
fontWeight = FontWeight.Bold
)
}
ViewModel:
#HiltViewModel
class MyViewModel #Inject constructor(
private val repository: Repository
): ViewModel() {
private val _trackLiveData: MutableLiveData<ViewState<Double>> = MutableLiveData()
val trackLiveData: LiveData<ViewState<Double>>
get() = _trackLiveData
fun post(
) = viewModelScope.launch(Dispatchers.Default) {
try {
val response = repository.post()
_trackLiveData.postValue(ViewState.Response(response.rate.round(7)))
} catch (e: Exception) {
_trackLiveData.postValue(ViewState.Error())
Log.v("TAG: viewmodelPost", e.message.toString())
}
}
}
ViewState:
sealed class ViewState<out T : Any> {
class Response<out T : Any>(val data: T): ViewState<T>()
class Error(val text:String = "Unknown error"): ViewState<Nothing>()
}
So when I press Button to call showPrice(). I can see these lines on Log:
2021-06-10 16:39:18.407 16781-16781/com.myapp.myapp V/TAG: last=2532.375732421875 new = 2532.7403716
2021-06-10 16:39:18.438 16781-16781/com.myapp.myapp V/TAG: last=2532.740478515625 new = 2532.7403716
2021-06-10 16:39:18.520 16781-16781/com.myapp.myapp V/TAG: last=2532.740478515625 new = 2532.7403716
What can I do to ensure that it's only called once?
Nothing, that's how it's meant to work. In the View system you would not ask "Why is my view invalidated 3 times?". The framework invalidates (recomposes) the view as it needs, you should not need to know or care when that happens.
The issue with your code is that your Composable is reading the old value from preferences, that is not how it should work, that value should be provided by the viewmodel as part of the state. Instead of providing just the new price, expose a Data Class that has both the new and old price and then use those 2 values in your composable to determine what color to show, or expose the price and the color to use.

Categories

Resources