I'm trying to dynamically swap a text inside a LottieAnimation in jetpack compose.
The lottie file is exported without glyphs
It's working when using the old android view inside a
AndroidView(factory = { context ->
val view = LottieAnimationView(context).apply {
setAnimation(R.raw.testing_no_glyphs)
playAnimation()
repeatCount = LottieConstants.IterateForever
}
val textDel = object : TextDelegate(view) {
override fun getText(layerName: String?, input: String?): String {
return when (layerName) {
"Test234" -> "OtherLettersHere"
else -> super.getText(layerName, input)
}
}
}
val fontDel = object : FontAssetDelegate() {
override fun getFontPath(fontFamily: String?, fontStyle: String?, fontName: String?): String {
return "fonts/[MyFontInside /assets].ttf"
}
}
view.setTextDelegate(textDel)
view.setFontAssetDelegate(fontDel)
return#AndroidView view
})
But I can't find the correct handles in the JetpackCompose version of Lottie to get the same result.
If we export the lottie with glyphs, it's works for the letters in the chars array inside the lottie json. But we want to be able to replace with any letters/symbols, so this isn't a viable solution.
I've noticed in the 5.3.0-SNAPSHOT that a fontMap parameter has been added, but I can't figure out which key to hit with it.
Here is my code:
val dynamicProperties = rememberLottieDynamicProperties(
rememberLottieDynamicProperty(LottieProperty.TEXT, value = "AaBbCcEeFf", keyPath = arrayOf("Test234"))
)
val composition by rememberLottieComposition(
spec = LottieCompositionSpec.RawRes(R.raw.testing)
)
val progress by animateLottieCompositionAsState(composition, iterations = LottieConstants.IterateForever)
LottieAnimation(
composition,
{ progress },
dynamicProperties = dynamicProperties,
fontMap = mapOf("fName" to Typeface.createFromAsset(LocalContext.current.assets, "fonts/[MyFontInside /assets].ttf"))
)
It just shows a blank for all the texts inside the Lottie Animation - so this is kinda where i'm stuck.
After some trial an error I found a way to add the typeface for a specific layer:
val typeface = Typeface.createFromAsset(LocalContext.current.assets, "fonts/[MyFontInside /assets].ttf")
val dynamicProperties = rememberLottieDynamicProperties(
rememberLottieDynamicProperty(LottieProperty.TEXT, value = "AaBbCcEeFf", keyPath = arrayOf("Test234")),
--> rememberLottieDynamicProperty(LottieProperty.TYPEFACE, value = typeface, keyPath = arrayOf("Test234")),
)
Hence there is no need for the fontMap in my case
Related
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?
in my ViewModel:
private val _itemList = mutableStateListOf<Post>()
val itemList: List<Post> = _itemList
fun likePost(newPost: Post){
val index = _itemList.indexOf(newPost)
_itemList[index] = _itemList[index].copy(isLiked = true)
}
Here my Post data class:
data class Post(
val id: Int,
val name: String,
val isLiked: Boolean = false,
)
And here my Composable:
val postList = viewModel.itemList
LazyRow(content = {
items(postList.size) { i ->
val postItem = postList[i]
PostItem(
name = postItem.name,
isLiked = postItem.isLiked,
likePost = { viewModel.likePost(postItem)}
)
}
})
The change does not update in the UI instantly, I first have to scroll the updated item out of the screen so it recomposes or switch to another Screen and go back to see the change.
For some reason it doesn't like updating, it will add and delete and update instantly. You have to do it this way when updating for our to update the state.
fun likePost(newPost: Post){
val index = _itemList.indexOf(newPost)
_itemList[index] = _itemList[index].copy()
_itemList[index].isLiked = true
}
You are returning a List<> effectively and not MutableStateList from your ViewModel.
If you want the list to not be mutable from the view, I happen to use MutableStateFlow<List<>> and return StateFlow<List<>>. You could also just convert it to a list in your composable.
Edit:
//backing cached list, or could be data source like database
private val deviceList = mutableListOf<Device>()
private val _deviceListState = MutableStateFlow<List<Device>>(emptyList())
val deviceListState: StateFlow<List<BluetoothDevice>> = _deviceListState
//manipulate and publish
fun doSomething() {
_deviceListState.value = deviceList.filter ...
}
In your UI
val deviceListState = viewModel.deviceListState.collectAsState().value
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())
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.
In the app I'm working on, it is imperative that all views in all layout files have an id set on them. So I'm trying to build a custom lint rule to enforce this.
Normally, one would use the getApplicableElements() method from XmlScanner and include a list of strings for each element tag. However, I can't seem to find a way to make this look at all elements in an XML layout which subclass View.
I tried using XmlScannerConstants.ALL, however, that looks at every single element in every single XML file. Given that we have several other types of XML-based resources, that's not going to work.
My code for the inspector class is below. Does anyone know of a good way filter getApplicableElements() so it looks at every element that subclasses View, and nothing else?
class IdDetector : ResourceXmlDetector() {
companion object {
private const val ISSUE_ID = "MissingId"
private const val ISSUE_DESCRIPTION = "Missing required attribute 'id'"
private const val ISSUE_EXPLANATION = "Identifiers are required on all views."
val ISSUE = Issue.create(
id = ISSUE_ID,
briefDescription = ISSUE_DESCRIPTION,
explanation = ISSUE_EXPLANATION,
category = Category.A11Y,
priority = 10,
severity = Severity.FATAL,
androidSpecific = true,
implementation = Implementation(IdDetector::class.java, Scope.RESOURCE_FILE_SCOPE)
)
}
override fun getApplicableElements(): Collection<String>? = XmlScannerConstants.ALL
override fun visitElement(context: XmlContext, element: Element) {
if (!element.hasAttributeNS("http://schemas.android.com/apk/res/android", "id")) {
context.report(ISSUE, element, context.getLocation(element), ISSUE_DESCRIPTION)
}
}
}
There's the appliesTo(#NonNull ResourceFolderType folderType) method that can help you achieve that. In your case, I believe you will be only interested in targeting the layout folder. You're new detector would look something like this:
class IdDetector : ResourceXmlDetector() {
companion object {
private const val ISSUE_ID = "MissingId"
private const val ISSUE_DESCRIPTION = "Missing required attribute 'id'"
private const val ISSUE_EXPLANATION = "Identifiers are required on all views."
val ISSUE = Issue.create(
id = ISSUE_ID,
briefDescription = ISSUE_DESCRIPTION,
explanation = ISSUE_EXPLANATION,
category = Category.A11Y,
priority = 10,
severity = Severity.FATAL,
androidSpecific = true,
implementation = Implementation(IdDetector::class.java, Scope.RESOURCE_FILE_SCOPE)
)
}
override fun appliesTo(folderType: ResourceFolderType): Boolean = ResourceFolderType.LAYOUT == folderType
override fun getApplicableElements(): Collection<String>? = XmlScannerConstants.ALL
override fun visitElement(context: XmlContext, element: Element) {
if (!element.hasAttributeNS("http://schemas.android.com/apk/res/android", "id")) {
context.report(ISSUE, element, context.getLocation(element), ISSUE_DESCRIPTION)
}
}
}