Web-based drag-and-drop in Compose Multiplatform
I’ve recently published a project called LottieViewer, built with Compose Multiplatform, for rendering Lottie files. The initial implementation was a desktop application where you can easily drag and drop Lottie file animations to view them. I have iterated on the project, making it available for Web by adding support for Kotlin/Wasm. It works in the same way but new uses Web APIs. Let’s explore how I achieved this new milestone.
Add Kotlin/Wasm target
When creating a new Compose Multiplatform project, the preferred choice is to use JetBrains’ Wizard and select all applicable platforms. If, at any time after setup, you find that an additional platform needs to be added, you will need to do it manually. That’s what I did with LottieViewer. It isn’t rocket science, but let’s see how to add Kotlin/Wasm to an existing project.
First, we need to declare the new target in the composeApp/build.gradle
file by adding a few lines:
Secondly, we need to declare the main
function along with the invocation to the App
composable. The file we should add is called main.kt
and should be added to the composeApp/src/wasmJsMain/kotlin
folder with this content:
Finally, we need to add some basic HTML source code to wrap the App. In this case, we just have to add the index.html
file to the composeApp/src/wasmJsMain/resources
folder with this content:
JetBrains ViewModels and Lifecycle Libraries
The previous version of LottieViewer used Google’s libraries for ViewModels and Lifecycle handling for desktop. It worked nicely because those libraries support Android, iOS and JVM, but since we’re adding Web as a new target, we cannot use them anymore. Fortunately, JetBrains already has a fork from those libraries which adds the support we need. By updating the proper dependencies in libs.version.toml
file, we should end up having something like this:
Understanding how drag-and-drop works on Web
This is a common operation with well-defined APIs and guidelines. I followed the instructions described on the MDN page for File drag and drop. Here is what is needed:
- Define the drop zone.
- Add handlers for drag-and-drop events.
The following sections will describe all the necessary Web code for accepting file drops.
Define the drop zone
As described in the MDN guide, this involves adding three new attributes to the HTML element that will handle drag-and-drop events: ondrop
, ondragover
, and ondragleave
. In our case, the best candidate for this is the canvas element, as it hosts our application. This is how it should look:
There is a downside to the approach we’re following: the entire application hosted in the canvas
will react to drag-and-drop events. This is not a big deal for our use case, but if you want a smaller drop area, you could potentially work with the coordinates received by drag-and-drop events (attributes screenX
and screenY
) and map them to the appropriate Compose element that should react to them appropiatelly.
Drag-and-Drop Event Handlers
Now it’s time to define the necessary JavaScript code to handle Web-based drag-and-drop operations properly. We referenced these handlers in the previous section and will now reveal their implementation details.
First, we should provide some interop functions between JavaScript and Kotlin. This way, when a user picks a file and starts the drag-and-drop operation, we can notify the Kotlin code from the JavaScript code. With that in mind, I defined a DragAndDropListener
object that we can easily register from Kotlin and invoke from JavaScript when needed. Here is the relevant code snippet:
Later on, we’ll see the context in which register/unregister functions are invoked.
Now it’s time to see what the handlers look like:
As you can see, all of them follow the same structure: first, they call the preventDefault
function from the event. This is necessary because the default implementation is to open the dropped file, which is not what we need. After that, the equivalent method from our listener is invoked. There is an exception with dropHandler
, which calls the extractSingleFile
function to retrieve the file object that has been dropped. The details of extractSingleFile
aren’t shared here as they aren’t particularly interesting.
All this code has been added to a file named drag.js
and included in index.html
as shown below:
Back to the Compose World
In the earlier version of LottieViewer, I was using the onExternalDrag
modifier, defined only for desktop (link here), to handle drag-and-drop operations. However, this solution doesn’t scale when adding a Web target, as this modifier is undefined for Web.
So, we need to generalize the drag-and-drop functionality in a way that can be implemented differently based on the target being compiled. The good news is that we already have a solution: expect
/actual
declarations and we can also define modifiers and composables using this approach:
This is a straightforward modifier that closely resembles the JavaScript DragAndDropListener
we’ve already defined. An important detail here is the onSingleFileDropped
function, which accepts a single parameter of type FileDesc
. This is a convenient way to generalize the implementation details of how files are referenced based on the underlying platform. We used a single URI
for desktop and a JavaScript File
instance for the Web. It would be great if we could use generics here, but they are not supported yet.
Implementing onDragAndDrop for Kotlin/Wasm
Let’s deep dive into how to implement the onDragAndDrop
modifier in Kotlin/Wasm. Here, we want to call some predefined JavaScript code to bring drag-and-drop functionality from Web into Compose. We can achieve this by importing the defined JS functions into Kotlin using the external
keyword:
Now, let’s look at the onDragAndDrop
implementation:
We use the composed
API to define this new modifier. Additionally, by leveraging DisposableEffect
, we can register the incoming Kotlin methods with their JS counterparts. And of course, to ensure proper cleanup, we call the unregister function inside the onDispose
block. In the onDrop
callback, we wrap the JS File instance into a JsFileDesc
type that extends from FileDesc
interface and is available only in the scope of Kotlin/Wasm.
That’s it! This is all we need to get Web-based drag-and-drop mechanisms working seamlessly in a Kotlin Multiplatform project. But remember, file handling is a common requirement, so we’ll need to address that next.
File handling
Reading the content of a dropped file is also platform-specific. We can again leverage expect
/actual
patterns to handle this. I already have a neat functional interface called FileStore
used by the underlying ViewModel
for reading, parsing files, and displaying Lottie animations. Now, when adding Web as a new target, we need to revisit and adapt it slightly:
The createFileStore
function lets us instantiate a platform-specific implementation of the FileStore
functional interface. Here’s the Web-based implementation:
Here, we check if the incoming fileDesc
is a JsFileDesc
and throw an exception if not. We then use suspendCoroutine
from the Coroutines API to handle the asynchronous nature of FileReader
, which is defined org.w3c.files
package and exposes the corresponding JavaScript functionality (check its API here). Inside the block, we provide a lambda for the onload
callback that resumes the coroutine with the file content. Finally, we call readAsText
on the FileReader
. The resulting value from suspendCoroutine
is then emitted by the underlying flow.
Recap
Thanks to JS/Kotlin interoperability, we’ve created a new Web app that supports drag-and-drop of any file from your computer. Here’s what we’ve achieved:
- Defined a drop zone in HTML code.
- Exposed a fully functional JS listener for drag-and-drop.
- Integrated drag-and-drop functionality in Compose using a simple modifier.
- Contextualized file handling for a multiplatform project.
But you may be wondering: why the hell add Web as a target? The answer is simple: to deploy with GitHub Actions and make it available to everyone!
Extra-ball: deploy with GitHub Actions
It’s not a secret that GitHub Actions are incredibly powerful. They allow you to trigger specific tasks at any point in the SDLC (Software Development Lifecycle) effortlessly, making them a dream tool for automating tasks. That’s exactly what we want to do: automatically compile and build the Web version of LottieViewer and deploy it to GitHub Pages.
Let’s break down the workflow:
Every GitHub Action needs a name and an event to trigger it. In this case, we want it to run whenever there’s a push to the main branch.
This section defines a job named Deploy and the operating system (Ubuntu) it will run on. Now, let’s look at the deployment steps:
This step checks out the code from the repository using a personal access token (PAT) for secure checkout (more details at GitHub Docs).
These steps ensure everything is set up to build any project with Gradle. The last step runs a task to compile and get all the files needed for the Web app which we’ll deploy later.
Finally, some git
commands. We create a new orphan branch (starting fresh with no prior commits), add all the built files, and then commit and push them. Boom! The Web version of LottieViewer is ready to play any Lottie file on your desktop!
Edit: Safari compatibility issues with Kotlin/wasm
There is an issue when trying to run code targeting Kotlin/Wasm on the Safari browser. That’s why I would recommend relying on Kotlin/JS in the meantime. This involves using a different Gradle task (:compose:jsBrowserDistribution
) and copying the produced artifacts from a different directory (composeApp/build/dist/js/productionExecutable
).
Conclusion
With a few tricks, it’s amazing how much you can achieve with Kotlin Multiplatform projects. LottieViewer is just an example of how you can combine different technologies to create something new and useful.
Check out the whole project on GitHub: https://github.com/manuel-martos/LottieViewer
And feel free to use it on this GitHub Page: https://manuel-martos.github.io/LottieViewer
Thanks for reading to the end!