Model anomaly occurs when controlling frames after loading animation with Lottie
Author: BoboHeziCreated May 28, 2026Updated May 28, 2026
LabelsRendering bug
Car Model Rendering Issue Analysis Document
Involved Application
Android development application, includes two screen displays:
RemoteScreenActivity(displayed on display 1) ,loadsEnergyFragment,displays charging information and car model, uses Lottie animation.CardEditActivity(displayed on display 0)
Problem Description
- Start RemoteScreenActivity (display 1)
The car model changes normally with the battery level(refer to image:battery_normal.png).
Then start CardEditActivity(display 0),the car model shows rendering errors as the battery level changes(refer to image:battery_wrong_1.png),
After long-pressing the Home button to remove the CardEditActivity task, the car model returns to normal display as the battery level changes.
- Start CardEditActivity (display 0)
Then start
RemoteScreenActivity(display 1), the car model changes normally with the battery level.
At this point, long-press the Home button to remove theCardEditActivitytask, and the car model shows rendering errors (refer to image:battery_wrong_2.png).
After restarting CardEditActivity, the car model returns to normal display as the battery level changes.
Related Resource Images
- car_lottie_resource.png (static car model image)
- car_lottie_resource_run.png (exploded view of car model running animation)
Core Code
carLottieView?.let {
val frame = i2 + 35
val progress = (frame - it.minFrame) / (it.maxFrame - it.minFrame)
Log.i(TAG, "onDraw frame: $frame, progress: $progress, minFrame: ${it.minFrame}, maxFrame: ${it.maxFrame}")
it.progress = progress
}EnergyFragment.kt
package com.ivi.rs.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import com.baselibrary.logger.Log
import com.card_egy.EnergySingleObserver
import com.ivi.remote.card.lottie.L
import com.ivi.rs.R
import com.ivi.rs.widget.EnergyView
import com.ivi.utils.track.manager.HomePageTrackManager
class EnergyFragment : Fragment() {
companion object {
const val TAG: String = "EnergyFragment"
}
private var energyView: EnergyView? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return layoutInflater.inflate(R.layout.fragment_energy, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Log.i(L.TAG, "onViewCreated")
energyView = view.findViewById(R.id.energy_full_view)
registerEnergyState()
}
override fun onDestroyView() {
super.onDestroyView()
Log.i(L.TAG, "onDestroyView")
energyView = null
unRegisterEnergyState()
}
private fun registerEnergyState() {
EnergySingleObserver.getInstance().capacityValueData
.observe(getViewLifecycleOwner(), object : Observer<Float> {
override fun onChanged(value: Float) {
energyView?.onCapacityValueChanged(value)
}
})
EnergySingleObserver.getInstance().chargeStatusData
.observe(getViewLifecycleOwner(), object : Observer<Int> {
override fun onChanged(value: Int) {
energyView?.onChargeStatusValueChanged(value)
}
})
}
private fun unRegisterEnergyState() {
EnergySingleObserver.getInstance().capacityValueData.removeObservers(this)
EnergySingleObserver.getInstance().chargeStatusData.removeObservers(this)
}
}EnergyView.kt
package com.ivi.rs.widget
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.RelativeLayout
import com.airbnb.lottie.ImageAssetDelegate
import com.airbnb.lottie.LottieAnimationView
import com.airbnb.lottie.LottieImageAsset
import com.baselibrary.logger.Log
import com.ivi.rs.R
import com.ivi.utils.CarTypeUtils
import com.ivi.utils.VehicleConfiguration
import java.io.IOException
class EnergyView : RelativeLayout {
companion object {
const val TAG = "EnergyView"
const val CAR_LOTTIE_RESOURCE_RUN_NAME = "car_lottie_resource_run.png"
const val CAR_LOTTIE_RESOURCE_RUN_PATH = "energy/car_lottie_resource_run.png"
}
private var batteryView: RemoteBatteryView? = null
private var carLottie: LottieAnimationView? = null
private var currentCapacity: Float
private var currentChargeStatus: Int
private var mLottieAssetsPath = ASSETS_PATH_E12
constructor(context: Context?) : this(context, null)
constructor(context: Context?, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
) {
currentCapacity = 0.0f
currentChargeStatus = 1
mLottieAssetsPath = "energy"
initView(context)
}
private fun initView(context: Context?) {
LayoutInflater.from(context).inflate(R.layout.view_energy, this as ViewGroup, true)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
Log.i(TAG, "onAttachedToWindow: ")
batteryView = findViewById(R.id.battery_view)
carLottie = findViewById(R.id.car_lottie)
carLottie?.setCacheComposition(false)
carLottie?.let { initCarLottie(it) }
batteryView?.initBatterView(carLottie)
reloadChargeRes()
}
private fun initCarLottie(carLottie: LottieAnimationView) {
carLottie.enableMergePathsForKitKatAndAbove(true)
carLottie.setAnimation("$mLottieAssetsPath/lottie_animation.json")
carLottie.setImageAssetDelegate(object : ImageAssetDelegate {
override fun fetchBitmap(lottieImageAsset: LottieImageAsset): Bitmap? {
val carModelSourcePicture: Bitmap? = getCarModelBitmap(lottieImageAsset)
return carModelSourcePicture
}
})
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
Log.i(TAG, "onDetachedFromWindow: ")
batteryView?.releaseResource()
carLottie?.cancelAnimation()
}
fun onCapacityValueChanged(capacityValue: Float) {
Log.i(TAG, "onCapacityValueChanged capacityValue: $capacityValue, currentCapacity: $currentCapacity")
if (currentCapacity == capacityValue) {
return
}
currentCapacity = capacityValue
reloadChargeRes()
}
fun onChargeStatusValueChanged(chargeStatus: Int) {
Log.i(TAG, "onChargeStatusValueChanged chargeStatus: $chargeStatus, currentChargeStatus: $currentChargeStatus")
if (currentChargeStatus == chargeStatus) {
return
}
currentChargeStatus = chargeStatus
reloadChargeRes()
}
private fun reloadChargeRes() {
Log.i(TAG, "reloadChargeRes chargeStatus: $currentChargeStatus, capacity: $currentCapacity")
if (batteryView != null && currentChargeStatus != 0) {
Log.i(TAG, "reloadChargeRes update batteryView")
batteryView?.updateBattery(currentCapacity)
batteryView?.updateChargeState(currentChargeStatus == 1)
batteryView?.refreshChargeView()
return
}
}
fun getCarModelBitmap(asset: LottieImageAsset): Bitmap? {
try {
val fileName = asset.fileName.let {
if (it == CAR_LOTTIE_RESOURCE_RUN_NAME) CAR_LOTTIE_RESOURCE_RUN_PATH
else "$mLottieAssetsPath/$it"
}
Log.i(TAG, "getCarModelBitmap asset file: $fileName")
val inputStream = context.assets.open(fileName)
return BitmapFactory.decodeStream(inputStream)
} catch (e: IOException) {
Log.e(TAG, "getCarModelBitmap exception: ${e.message}")
}
return null
}
}RemoteBatteryView.kt
package com.ivi.rs.widget
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import android.graphics.Rect
import android.util.AttributeSet
import android.view.View
import com.airbnb.lottie.LottieAnimationView
import com.baselibrary.logger.Log
import com.ivi.remote.card.R
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
class RemoteBatteryView : View {
private var batteryPaint: Paint? = null
private var carLottieView: LottieAnimationView? = null
private var currentBatteryValue: Float
private var fullBitmap: Bitmap? = null
private var isChargeGunConnection: Boolean
private var lightShowRect: Rect? = null
private var maskBitmap: Bitmap? = null
private var porterDuffXfermode: PorterDuffXfermode? = null
private var showRect: Rect? = null
constructor(context: Context?) : super(context) {
currentBatteryValue = 0.0f
isChargeGunConnection = true
}
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
currentBatteryValue = 0.0f
isChargeGunConnection = true
}
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
) {
currentBatteryValue = 0.0f
isChargeGunConnection = true
}
fun initBatterView(carLottieView: LottieAnimationView?) {
this.carLottieView = carLottieView
batteryPaint = Paint()
val options = BitmapFactory.Options()
options.inScaled = false
maskBitmap = BitmapFactory.decodeResource(resources, R.drawable.battery_mask, options)
fullBitmap = BitmapFactory.decodeResource(resources, R.drawable.battery_full_charge, options)
porterDuffXfermode = PorterDuffXfermode(PorterDuff.Mode.MULTIPLY)
showRect = Rect(0, 0, 3552, SCREEN_HEIGHT)
lightShowRect = Rect(0, 0, 0, SCREEN_HEIGHT)
}
fun releaseResource() {
carLottieView = null
maskBitmap?.recycle()
fullBitmap?.recycle()
}
fun updateBattery(battery: Float) {
currentBatteryValue = battery
Log.i(TAG, "updateBattery battery = $battery")
val min = min(currentBatteryValue.toDouble(), 100.0).toFloat()
currentBatteryValue = min
currentBatteryValue = max(min.toDouble(), 0.0).toFloat()
}
fun updateChargeState(isCharge: Boolean) {
Log.i(TAG, "updateBattery isCharge = $isCharge")
isChargeGunConnection = isCharge
}
fun refreshChargeView() {
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (maskBitmap == null || maskBitmap?.isRecycled == true || fullBitmap == null || fullBitmap?.isRecycled == true) {
return
}
val abs = abs((((abs(NONE_BATTERY_POSITION.toDouble()) - abs(MAX_BATTERY_POSITION.toDouble())) / 100.0f) * currentBatteryValue).toDouble()).toInt()
lightShowRect!!.right = ((floor((currentBatteryValue / 6.25f).toDouble()).toInt()) * 100) + EMPTY_START_POSITION
showRect?.right = (4032 - abs((abs - 2418).toDouble())).toInt()
val saveLayer = canvas.saveLayer(0.0f, 0.0f, width.toFloat(), height.toFloat(), null)
val rect: Rect = showRect!!
canvas.drawBitmap(fullBitmap!!, rect, rect, batteryPaint)
batteryPaint!!.xfermode = porterDuffXfermode
canvas.drawBitmap(
maskBitmap!!,
(abs + NONE_BATTERY_POSITION + 50).toFloat(),
0.0f,
batteryPaint
)
batteryPaint!!.xfermode = null
val rect2: Rect = lightShowRect!!
canvas.drawBitmap(fullBitmap!!, rect2, rect2, batteryPaint)
canvas.restoreToCount(saveLayer)
var width = carLottieView!!.width - 89
val f = currentBatteryValue
val abs2 = (((abs(-1608.0) / 100.0f) * (if (f - 6.25f > 0.0f) f - 6.25f else 0.0f)).toInt()) + 1004
if (isChargeGunConnection) {
width = 0
}
val i = abs2 + width
carLottieView?.let {
it.translationX = (if (isChargeGunConnection) i else i - 215).toFloat()
}
carLottieView?.let {
it.scaleX = if (isChargeGunConnection) 1.0f else -1.0f
}
var i2 = i % ROTATION_ONE_STEP
if (!isChargeGunConnection) {
i2 = 184 - i2
}
carLottieView?.let {
val frame = i2 + 35
val progress = (frame - it.minFrame) / (it.maxFrame - it.minFrame)
Log.i(TAG, "onDraw frame: $frame, progress: $progress, minFrame: ${it.minFrame}, maxFrame: ${it.maxFrame}")
it.progress = progress
}
}
companion object {
const val BATTERY_ONE_STEP: Int = 100
const val BATTERY_OUT_WIDTH: Int = 1938
const val CAR_OFFSET: Int = 215
private const val CAR_PIC_TRIM_WIDTH = 89
const val EMPTY_START_POSITION: Int = 1272
const val MAS_ALPHA_POSITION: Int = 50
private const val MAX_BATTERY_POSITION = 2100
const val MAX_BATTERY_VALUE: Float = 100.0f
private const val MAX_CAR_POSITION = 70
const val MIN_BATTERY_VALUE: Float = 0.0f
private const val NONE_BATTERY_CAR_POSITION = 1004
private val NONE_BATTERY_POSITION = -480
private val NONE_CAR_POSITION = -1538
const val ONE_STEP_TO_BATTERY: Float = 6.25f
const val ROTATION_ONE_STEP: Int = 184
private const val SCREEN_HEIGHT = 284
private const val SCREEN_WIDTH = 4032
const val STOP_FRAME_NUM: Int = 35
private const val TAG = "RemoteBatteryView"
}
}lottie_animation.json
{"v":"5.12.1","fr":30,"ip":30,"op":220,"w":474,"h":180,"nm":"车 合成 1","ddd":0,"assets":[{"id":"image_0","w":421,"h":132,"u":"./","p":"car_lottie_resource.png","e":0},{"id":"image_1","w":474,"h":180,"u":"./","p":"car_lottie_resource_run.png","e":0}],"layers":[{"ddd":0,"ind":1,"ty":2,"nm":"左轮","refId":"image_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[0],"e":[1080]},{"t":600}],"ix":10},"p":{"a":0,"k":[125.5,125.25,0],"ix":2,"l":2},"a":{"a":0,"k":[110.5,90.25,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[16.983,0],[0,-16.983],[-16.983,0],[0,16.983]],"o":[[-16.983,0],[0,16.983],[16.983,0],[0,-16.983]],"v":[[110.5,59.5],[79.75,90.25],[110.5,121],[141.25,90.25]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"蒙版 1"}],"ip":0,"op":900,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":2,"nm":"右轮","refId":"image_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[0],"e":[1080]},{"t":600}],"ix":10},"p":{"a":0,"k":[360.562,124.312,0],"ix":2,"l":2},"a":{"a":0,"k":[345.562,89.312,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[16.672,0],[0,-16.672],[-16.672,0],[0,16.672]],"o":[[-16.672,0],[0,16.672],[16.672,0],[0,-16.672]],"v":[[345.562,59.125],[315.375,89.312],[345.562,119.5],[375.75,89.312]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"蒙版 1"}],"ip":0,"op":900,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":2,"nm":"car-2.png","cl":"png","refId":"image_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[237,90,0],"ix":2,"l":2},"a":{"a":0,"k":[237,90,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":900,"st":0,"bm":0}],"markers":[],"props":{}}Source: airbnb/lottie-android