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