Seamless GIF Loading in Compose Multiplatform: Android & iOS Implementation
- nimesh Vasani
- Feb 18
- 3 min read
GIFs add a dynamic touch to mobile applications, but handling them across platforms can be tricky. In this blog, we’ll explore how to load GIFs in a Compose Multiplatform (KMP/CMP) project for both Android and iOS while maintaining a clean architecture.
1. Creating a Shared GIF Composable
To ensure platform-specific implementations work seamlessly, we define an expect function in the shared/common module.
@Composable
expect fun GifImage(url: String,modifier: Modifier)
This acts as a placeholder that will be implemented separately for Android and iOS.
2. Android Implementation: Jetpack Compose with Coil3
For Android, we use Coil3, a modern image loading library that supports GIFs through the GifDecoder.
@Composable
actual fun GifImage(url: String,modifier: Modifier) {
val context = LocalContext.current
val imageLoader = ImageLoader.Builder(context)
.components {
add(GifDecoder.Factory())
}
.build()
AsyncImage(
model = ImageRequest.Builder(context)
.data(url)
.size(Size.ORIGINAL)
.build(),
contentDescription = null,
imageLoader = imageLoader,
modifier = modifier,
contentScale = ContentScale.FillBounds
)
}
Why Use Coil?
Efficient GIF decoding with GifDecoder.Factory()
Asynchronous image loading with caching
Jetpack Compose-friendly API
3. iOS Implementation: UIKit Integration
Since Compose does not natively support GIFs on iOS, we use UIKitView to embed a UIImageView that handles GIF playback. Here i am fetching GIF from drawable resource file.
@Composable
actual fun GifImage(url: String,modifier: Modifier) {
UIKitView(
modifier = modifier,
factory = {
val imageView = UIImageView()
imageView.contentMode = UIViewContentMode.UIViewContentModeScaleAspectFit
imageView.clipsToBounds = true
//here I am using drawable resource as asset folder
val path = url.removePrefix("file://")
val gifData = NSData.create(contentsOfFile = path)
val gifImage = gifData?.let { UIImage.gifImageWithData(it) }
imageView.image = gifImage
imageView
}
)
}
How This Works:
UIKitView allows embedding native iOS UI components.
UIImageView is used to display GIFs.
gifImageWithData() converts NSData into an animated UIImage.
4. Fine-Tuning GIF Playback on iOS
Copy and paste this functions for loading gif properly.
@OptIn(ExperimentalForeignApi::class)
fun UIImage.Companion.gifImageWithData(data: NSData?): UIImage? {
return runCatching {
val dataRef = CFBridgingRetain(data) as? CFDataRef
val source = CGImageSourceCreateWithData(dataRef, null) ?: return null
val count = CGImageSourceGetCount(source).toInt()
val images = mutableListOf<CGImageRef>()
val delays = mutableListOf<Double>()
for (i in 0 until count) {
val image = CGImageSourceCreateImageAtIndex(source, i.toULong(), null)
if (image != null) {
images.add(image)
}
val delaySeconds = delayForImageAtIndex(i, source)
delays.add(delaySeconds * 400.0) // s to ms
}
println("images=${images.count()}")
val duration = delays.sum()
println("duration=$duration")
val gcd = gcdForList(delays)
println("gcd=$gcd")
val frames = mutableListOf<UIImage>()
for (i in 0 until count) {
val frame = UIImage.imageWithCGImage(images[i])
val frameCount = (delays[i] / gcd).toInt()
for (f in 0 until frameCount) {
frames.add(frame)
}
}
println("frames=${frames.count()}")
val animation = UIImage.animatedImageWithImages(frames, duration / 1000.0) ?: return null
animation
}.onFailure { it.printStackTrace() }.getOrNull()
}
@OptIn(ExperimentalForeignApi::class)
private fun UIImage.Companion.delayForImageAtIndex(index: Int, source: CGImageSourceRef): Double {
var delay: Double
val cfProperties = CGImageSourceCopyPropertiesAtIndex(source, index.toULong(), null)
val gifKey = (CFBridgingRelease(kCGImagePropertyGIFDictionary) as NSString).toString()
val gifInfo =
(CFBridgingRelease(cfProperties) as? NSDictionary)?.valueForKey(gifKey) as? NSDictionary
delay =
gifInfo?.valueForKey((CFBridgingRelease(kCGImagePropertyGIFDelayTime) as NSString).toString()) as? Double
?: 0.0
if (delay < 0.1) {
delay = 0.1
}
return delay
}
private fun UIImage.Companion.gcdForPair(_a: Int?, _b: Int?): Int {
var a = _a
var b = _b
if (b == null || a == null) {
return b ?: (a ?: 0)
}
if (a < b) {
val c = a
a = b
b = c
}
var rest: Int
while (true) {
rest = a!! % b!!
if (rest == 0) {
return b
} else {
a = b
b = rest
}
}
}
private fun UIImage.Companion.gcdForList(list: List<Double>): Double {
if (list.isEmpty()) return 1.0
var gcd = list[0]
list.onEach {
gcd = UIImage.gcdForPair(it.toInt(), gcd.toInt()).toDouble()
}
return gcd
}
To control GIF animation speed, we calculate frame delays and total animation duration.
val delaySeconds = delayForImageAtIndex(i, source)
delays.add(delaySeconds * 400.0) // s to ms
Adjust GIF Playback Speed Manually
The multiplication factor (400.0) can be increased or decreased to speed up or slow down GIF playback.
Experiment with different values to find the perfect balance.
How to Use GifImage composable
In shared/Common module, to use this GIfImage, create a screen and call function, pass value for URL if you are loading from drawable resource. If you are using URL then you need to adjust iOS side NSData object.
//change your gif file name
GifImage(
url = Res.getUri("drawable/gif_2.gif"),
modifier = Modifier.fillMaxSize()
)
Wrapping Up
By using platform-specific implementations, we can seamlessly load and display GIFs in a Compose Multiplatform project.
This approach ensures: ✅ Efficient GIF loading on Android with Coil✅ Native GIF handling on iOS using UIKitView✅ Full control over playback speed on iOS
Got questions or suggestions? Drop them in the comments! 🚀
Does this structure work for you? Let me know if you want any refinements! 😊
Comments