<lambda>null1 package com.airbnb.lottie.compose
2
3 import android.content.Context
4 import android.graphics.BitmapFactory
5 import android.graphics.Typeface
6 import android.util.Base64
7 import androidx.compose.runtime.Composable
8 import androidx.compose.runtime.LaunchedEffect
9 import androidx.compose.runtime.getValue
10 import androidx.compose.runtime.mutableStateOf
11 import androidx.compose.runtime.remember
12 import androidx.compose.ui.platform.LocalContext
13 import com.airbnb.lottie.LottieComposition
14 import com.airbnb.lottie.LottieCompositionFactory
15 import com.airbnb.lottie.LottieImageAsset
16 import com.airbnb.lottie.LottieTask
17 import com.airbnb.lottie.model.Font
18 import com.airbnb.lottie.utils.Logger
19 import com.airbnb.lottie.utils.Utils
20 import kotlinx.coroutines.Dispatchers
21 import kotlinx.coroutines.suspendCancellableCoroutine
22 import kotlinx.coroutines.withContext
23 import java.io.FileInputStream
24 import java.io.IOException
25 import java.util.zip.ZipInputStream
26 import kotlin.coroutines.resume
27 import kotlin.coroutines.resumeWithException
28
29 /**
30 * Use this with [rememberLottieComposition#cacheKey]'s cacheKey parameter to generate a default
31 * cache key for the composition.
32 */
33 private const val DefaultCacheKey = "__LottieInternalDefaultCacheKey__"
34
35 /**
36 * Takes a [LottieCompositionSpec], attempts to load and parse the animation, and returns a [LottieCompositionResult].
37 *
38 * [LottieCompositionResult] allows you to explicitly check for loading, failures, call
39 * [LottieCompositionResult.await], or invoke it like a function to get the nullable composition.
40 *
41 * [LottieCompositionResult] implements State<LottieComposition?> so if you don't need the full result class,
42 * you can use this function like:
43 * ```
44 * val compositionResult: LottieCompositionResult = lottieComposition(spec)
45 * // or...
46 * val composition: State<LottieComposition?> by lottieComposition(spec)
47 * ```
48 *
49 * The loaded composition will automatically load and set images that are embedded in the json as a base64 string
50 * or will load them from assets if an imageAssetsFolder is supplied.
51 *
52 * @param spec The [LottieCompositionSpec] that defines which LottieComposition should be loaded.
53 * @param imageAssetsFolder A subfolder in `src/main/assets` that contains the exported images
54 * that this composition uses. DO NOT rename any images from your design tool. The
55 * filenames must match the values that are in your json file.
56 * @param fontAssetsFolder The default folder Lottie will look in to find font files. Fonts will be matched
57 * based on the family name specified in the Lottie json file.
58 * Defaults to "fonts/" so if "Helvetica" was in the Json file, Lottie will auto-match
59 * fonts located at "src/main/assets/fonts/Helvetica.ttf". Missing fonts will be skipped
60 * and should be set via fontRemapping or via dynamic properties.
61 * @param fontFileExtension The default file extension for font files specified in the fontAssetsFolder or fontRemapping.
62 * Defaults to ttf.
63 * @param cacheKey Set a cache key for this composition. When set, subsequent calls to fetch this composition will
64 * return directly from the cache instead of having to reload and parse the animation. Set this to
65 * null to skip the cache. By default, this will automatically generate a cache key derived
66 * from your [LottieCompositionSpec].
67 * @param onRetry An optional callback that will be called if loading the animation fails.
68 * It is passed the failed count (the number of times it has failed) and the exception
69 * from the previous attempt to load the composition. [onRetry] is a suspending function
70 * so you can do things like add a backoff delay or await an internet connection before
71 * retrying again. [rememberLottieRetrySignal] can be used to handle explicit retires.
72 */
73 @Composable
74 @JvmOverloads
75 fun rememberLottieComposition(
76 spec: LottieCompositionSpec,
77 imageAssetsFolder: String? = null,
78 fontAssetsFolder: String = "fonts/",
79 fontFileExtension: String = ".ttf",
80 cacheKey: String? = DefaultCacheKey,
81 onRetry: suspend (failCount: Int, previousException: Throwable) -> Boolean = { _, _ -> false },
82 ): LottieCompositionResult {
83 val context = LocalContext.current
<lambda>null84 val result by remember(spec) { mutableStateOf(LottieCompositionResultImpl()) }
85 // Warm the task cache. We can start the parsing task before the LaunchedEffect gets dispatched and run.
86 // The LaunchedEffect task will join the task created inline here via LottieCompositionFactory's task cache.
<lambda>null87 remember(spec, cacheKey) { lottieTask(context, spec, cacheKey, isWarmingCache = true) }
<lambda>null88 LaunchedEffect(spec, cacheKey) {
89 var exception: Throwable? = null
90 var failedCount = 0
91 while (!result.isSuccess && (failedCount == 0 || onRetry(failedCount, exception!!))) {
92 try {
93 val composition = lottieComposition(
94 context,
95 spec,
96 imageAssetsFolder.ensureTrailingSlash(),
97 fontAssetsFolder.ensureTrailingSlash(),
98 fontFileExtension.ensureLeadingPeriod(),
99 cacheKey,
100 )
101 result.complete(composition)
102 } catch (e: Throwable) {
103 exception = e
104 failedCount++
105 }
106 }
107 if (!result.isComplete && exception != null) {
108 result.completeExceptionally(exception)
109 }
110 }
111 return result
112 }
113
lottieCompositionnull114 private suspend fun lottieComposition(
115 context: Context,
116 spec: LottieCompositionSpec,
117 imageAssetsFolder: String?,
118 fontAssetsFolder: String?,
119 fontFileExtension: String,
120 cacheKey: String?,
121 ): LottieComposition {
122 val task = requireNotNull(lottieTask(context, spec, cacheKey, isWarmingCache = false)) {
123 "Unable to create parsing task for $spec."
124 }
125
126 val composition = task.await()
127 loadImagesFromAssets(context, composition, imageAssetsFolder)
128 loadFontsFromAssets(context, composition, fontAssetsFolder, fontFileExtension)
129 return composition
130 }
131
lottieTasknull132 private fun lottieTask(
133 context: Context,
134 spec: LottieCompositionSpec,
135 cacheKey: String?,
136 isWarmingCache: Boolean,
137 ): LottieTask<LottieComposition>? {
138 return when (spec) {
139 is LottieCompositionSpec.RawRes -> {
140 if (cacheKey == DefaultCacheKey) {
141 LottieCompositionFactory.fromRawRes(context, spec.resId)
142 } else {
143 LottieCompositionFactory.fromRawRes(context, spec.resId, cacheKey)
144 }
145 }
146 is LottieCompositionSpec.Url -> {
147 if (cacheKey == DefaultCacheKey) {
148 LottieCompositionFactory.fromUrl(context, spec.url)
149 } else {
150 LottieCompositionFactory.fromUrl(context, spec.url, cacheKey)
151 }
152 }
153 is LottieCompositionSpec.File -> {
154 if (isWarmingCache) {
155 // Warming the cache is done from the main thread so we can't
156 // create the FileInputStream needed in this path.
157 null
158 } else {
159 val fis = FileInputStream(spec.fileName)
160 when {
161 spec.fileName.endsWith("zip") -> LottieCompositionFactory.fromZipStream(
162 ZipInputStream(fis),
163 if (cacheKey == DefaultCacheKey) spec.fileName else cacheKey,
164 )
165 else -> LottieCompositionFactory.fromJsonInputStream(
166 fis,
167 if (cacheKey == DefaultCacheKey) spec.fileName else cacheKey,
168 )
169 }
170 }
171 }
172 is LottieCompositionSpec.Asset -> {
173 if (cacheKey == DefaultCacheKey) {
174 LottieCompositionFactory.fromAsset(context, spec.assetName)
175 } else {
176 LottieCompositionFactory.fromAsset(context, spec.assetName, cacheKey)
177 }
178 }
179 is LottieCompositionSpec.JsonString -> {
180 val jsonStringCacheKey = if (cacheKey == DefaultCacheKey) spec.jsonString.hashCode().toString() else cacheKey
181 LottieCompositionFactory.fromJsonString(spec.jsonString, jsonStringCacheKey)
182 }
183 is LottieCompositionSpec.ContentProvider -> {
184 val inputStream = context.contentResolver.openInputStream(spec.uri)
185 LottieCompositionFactory.fromJsonInputStream(inputStream, if (cacheKey == DefaultCacheKey) spec.uri.toString() else cacheKey)
186 }
187 }
188 }
189
awaitnull190 private suspend fun <T> LottieTask<T>.await(): T = suspendCancellableCoroutine { cont ->
191 addListener { c ->
192 if (!cont.isCompleted) cont.resume(c)
193 }.addFailureListener { e ->
194 if (!cont.isCompleted) cont.resumeWithException(e)
195 }
196 }
197
loadImagesFromAssetsnull198 private suspend fun loadImagesFromAssets(
199 context: Context,
200 composition: LottieComposition,
201 imageAssetsFolder: String?,
202 ) {
203 if (!composition.hasImages()) {
204 return
205 }
206 withContext(Dispatchers.IO) {
207 for (asset in composition.images.values) {
208 maybeDecodeBase64Image(asset)
209 maybeLoadImageFromAsset(context, asset, imageAssetsFolder)
210 }
211 }
212 }
213
maybeLoadImageFromAssetnull214 private fun maybeLoadImageFromAsset(
215 context: Context,
216 asset: LottieImageAsset,
217 imageAssetsFolder: String?,
218 ) {
219 if (asset.bitmap != null || imageAssetsFolder == null) return
220 val filename = asset.fileName
221 val inputStream = try {
222 context.assets.open(imageAssetsFolder + filename)
223 } catch (e: IOException) {
224 Logger.warning("Unable to open asset.", e)
225 return
226 }
227 try {
228 val opts = BitmapFactory.Options()
229 opts.inScaled = true
230 opts.inDensity = 160
231 var bitmap = BitmapFactory.decodeStream(inputStream, null, opts)
232 bitmap = Utils.resizeBitmapIfNeeded(bitmap, asset.width, asset.height)
233 asset.bitmap = bitmap
234 } catch (e: IllegalArgumentException) {
235 Logger.warning("Unable to decode image.", e)
236 }
237 }
238
maybeDecodeBase64Imagenull239 private fun maybeDecodeBase64Image(asset: LottieImageAsset) {
240 if (asset.bitmap != null) return
241 val filename = asset.fileName
242 if (filename.startsWith("data:") && filename.indexOf("base64,") > 0) {
243 // Contents look like a base64 data URI, with the format data:image/png;base64,<data>.
244 try {
245 val data = Base64.decode(filename.substring(filename.indexOf(',') + 1), Base64.DEFAULT)
246 val opts = BitmapFactory.Options()
247 opts.inScaled = true
248 opts.inDensity = 160
249 asset.bitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opts)
250 } catch (e: IllegalArgumentException) {
251 Logger.warning("data URL did not have correct base64 format.", e)
252 }
253 }
254 }
255
loadFontsFromAssetsnull256 private suspend fun loadFontsFromAssets(
257 context: Context,
258 composition: LottieComposition,
259 fontAssetsFolder: String?,
260 fontFileExtension: String,
261 ) {
262 if (composition.fonts.isEmpty()) return
263 withContext(Dispatchers.IO) {
264 for (font in composition.fonts.values) {
265 maybeLoadTypefaceFromAssets(context, font, fontAssetsFolder, fontFileExtension)
266 }
267 }
268 }
269
maybeLoadTypefaceFromAssetsnull270 private fun maybeLoadTypefaceFromAssets(
271 context: Context,
272 font: Font,
273 fontAssetsFolder: String?,
274 fontFileExtension: String,
275 ) {
276 val path = "$fontAssetsFolder${font.family}${fontFileExtension}"
277 val typefaceWithDefaultStyle = try {
278 Typeface.createFromAsset(context.assets, path)
279 } catch (e: Exception) {
280 Logger.error("Failed to find typeface in assets with path $path.", e)
281 return
282 }
283 try {
284 val typefaceWithStyle = typefaceForStyle(typefaceWithDefaultStyle, font.style)
285 font.typeface = typefaceWithStyle
286 } catch (e: Exception) {
287 Logger.error("Failed to create ${font.family} typeface with style=${font.style}!", e)
288 }
289 }
290
typefaceForStylenull291 private fun typefaceForStyle(typeface: Typeface, style: String): Typeface? {
292 val containsItalic = style.contains("Italic")
293 val containsBold = style.contains("Bold")
294 val styleInt = when {
295 containsItalic && containsBold -> Typeface.BOLD_ITALIC
296 containsItalic -> Typeface.ITALIC
297 containsBold -> Typeface.BOLD
298 else -> Typeface.NORMAL
299 }
300 return if (typeface.style == styleInt) typeface else Typeface.create(typeface, styleInt)
301 }
302
ensureTrailingSlashnull303 private fun String?.ensureTrailingSlash(): String? = when {
304 isNullOrBlank() -> null
305 endsWith('/') -> this
306 else -> "$this/"
307 }
308
Stringnull309 private fun String.ensureLeadingPeriod(): String = when {
310 isBlank() -> this
311 startsWith(".") -> this
312 else -> ".$this"
313 }