Skip to main content

Photos Tutorial

We will build a simple photo app that lets you view and manipulate images using a pointer or multi-touch. Images will be added to the app via drag-drop. You can then move, size, and rotate them with a mouse, pointer, touch, or via an info overlay.

Here is the end result.

tip

You can also see the full-screen app here: JavaScript, WebAssembly.

Project Setup

The app will use a Kotlin Multiplatform setup, which means we can run it on a range of targets supported by Doodle. The directory structure follows a fairly common layout, with common classes and resources in one source set and platform-specific items in their own.

Directory Layout
  • src
    • commonMain
      • kotlin
      • resources
  • build.gradle.kts

All source code and resources are located under the src directory.

The application logic itself is located in the common source set (src/commonMain), which means it is entirely reused for each platform. In fact, the same app is used unchanged (just targeting JS) within this documentation.

Doodle apps are built using gradle like other Kotlin apps. The build is controlled by the build.gradle.kts script in the root of the Photos directory.

build.gradle.kts

@file:OptIn(ExperimentalWasmDsl::class) import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl //sampleStart plugins { kotlin("multiplatform") application } kotlin { js { browser { binaries.executable() } } // Web (JS ) executable wasmJs { browser { binaries.executable() // Web (WASM) executable applyBinaryen {} // Binary size optimization } } jvm { // Desktop (JVM ) executable compilations.all { kotlinOptions { jvmTarget = "11" } // JVM 11 is needed for Desktop } withJava() } sourceSets { // Source set for all platforms commonMain.dependencies { api(libs.coroutines.core) // async photo loading api(libs.doodle.themes ) api(libs.doodle.controls ) api(libs.doodle.animation) } // Web (JS) platform source set jsMain.dependencies { implementation(libs.doodle.browser) } // Web (WASM) platform source set val wasmJsMain by getting { dependencies { implementation(libs.doodle.browser) } } // Desktop (JVM) platform source set jvmMain.dependencies { // helper to derive OS/architecture pair when (osTarget()) { "macos-x64" -> implementation(libs.doodle.desktop.jvm.macos.x64 ) "macos-arm64" -> implementation(libs.doodle.desktop.jvm.macos.arm64 ) "linux-x64" -> implementation(libs.doodle.desktop.jvm.linux.x64 ) "linux-arm64" -> implementation(libs.doodle.desktop.jvm.linux.arm64 ) "windows-x64" -> implementation(libs.doodle.desktop.jvm.windows.x64 ) "windows-arm64" -> implementation(libs.doodle.desktop.jvm.windows.arm64) } } } } // Desktop entry point application { mainClass.set("io.nacular.doodle.examples.MainKt") } //sampleEnd // could be moved to buildSrc, but kept here for clarity fun osTarget(): String { val osName = System.getProperty("os.name") val targetOs = when { osName == "Mac OS X" -> "macos" osName.startsWith("Win" ) -> "windows" osName.startsWith("Linux") -> "linux" else -> error("Unsupported OS: $osName") } val targetArch = when (val osArch = System.getProperty("os.arch")) { "x86_64", "amd64" -> "x64" "aarch64" -> "arm64" else -> error("Unsupported arch: $osArch") } return "${targetOs}-${targetArch}" }
info

The gradle build uses gradle version catalogs; see libs.versions.toml file for library info.

The Application

All Doodle apps must implement the Application interface. The framework will then initialize our app via the constructor.

Doodle apps can be launched in a few different ways on Web and Desktop. Use the application function in a platform source-set (i.e. jsMain, jvmMain, etc.) to launch top-level apps. It takes a list of modules to load and a lambda that builds the app. This lambda is within a Kodein injection context, which means we can inject dependencies into our app via instance, provider, etc.

The app's structure is fairly simple. It has a main Container that holds the images and supports drag-drop, and a panel with controls for manipulating a selected image.

PhotosApp.kt

class PhotosApp(/*...*/): Application { init { // ... val panelToggle // Button used to show/hide the panel val panel // Has controls for manipulating images val mainContainer = container { // container that holds images // ... dropReceiver = object: DropReceiver { // support drag-drop importing } GlobalScope.launch { listOf("tetons.jpg", "earth.jpg").forEachIndexed { index, file -> // load default images } } } display += listOf(mainContainer, panel, panelToggle) // ... } override fun shutdown() {} }
tip

Notice that shutdown is a no-op, since we don't have any cleanup to do when the app closes.

package io.nacular.doodle.examples import io.nacular.doodle.animation.Animator import io.nacular.doodle.animation.AnimatorImpl import io.nacular.doodle.application.Modules.Companion.DragDropModule import io.nacular.doodle.application.Modules.Companion.FocusModule import io.nacular.doodle.application.Modules.Companion.ImageModule import io.nacular.doodle.application.Modules.Companion.KeyboardModule import io.nacular.doodle.application.application import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicCircularProgressIndicatorBehavior import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicLabelBehavior import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicMutableSpinButtonBehavior import io.nacular.doodle.theme.native.NativeTheme.Companion.nativeTextFieldBehavior import org.kodein.di.DI.Module import org.kodein.di.bindSingleton import org.kodein.di.instance /** * Creates a [PhotosApp] */ //sampleStart fun main() { application(modules = listOf( FocusModule, ImageModule, KeyboardModule, DragDropModule, basicLabelBehavior(), nativeTextFieldBehavior(spellCheck = false), basicMutableSpinButtonBehavior(), basicCircularProgressIndicatorBehavior(thickness = 18.0), Module(name = "AppModule") { bindSingleton<Animator> { AnimatorImpl(instance(), instance()) } } )) { // load app PhotosApp( theme = instance(), images = instance(), display = instance(), animate = instance(), themeManager = instance(), focusManager = instance() ) } } //sampleEnd
tip

Normally this would just be your main function. But main would prevent the app from being used as a library. Which is what happens to allow both an embedded (in the docs) and full-screen version.

The application function launches apps. It takes a list of modules, and a lambda that builds the app. This lambda is within a Kodein injection context, which means we can inject dependencies into our app via instance, provider, etc.

Notice that we have included several modules for our app. This includes one for focus, keyboard, drag-drop and several for various View Behaviors (i.e. nativeTextFieldBehavior()) which loads the native behavior for TextFields. We also define some bindings directly in a new module. These are items with no built-in module, or items that only exist in our app code.

tip

Check out Kodein to learn more about how it handles dependency injection.

The application function also takes an optional HTML element within which the app will be hosted. The app will be hosted in document.body if you do not specify an element.

App launching is the only part of our code that is platform-specific; since it is the only time we might care about an HTML element. It also helps support embedding apps into non-Doodle contexts.

Drag-drop Support

Drag-drop support requires the DragDropModule to work. It then requires setting up drag/drop recognizers on the source/target Views. We created the mainContainer for this. You can see that the dropReceiver property is set to a DropReceiver that controls how the mainContainer handles drop events.

class PhotosApp(/*...*/ private val images: ImageLoader /*...*/): Application { init { // ... val mainContainer = container { // ... dropReceiver = object: DropReceiver { private val allowedFileTypes = Files(ImageType("jpg"), ImageType("jpeg"), ImageType("png")) override val active = true private fun allowed (event: DropEvent) = allowedFileTypes in event.bundle override fun dropEnter (event: DropEvent) = allowed(event) override fun dropOver (event: DropEvent) = allowed(event) override fun dropActionChanged(event: DropEvent) = allowed(event) override fun drop (event: DropEvent) = event.bundle[allowedFileTypes]?.let { files -> val photos = files.map { GlobalScope.async { images.load(it)?.let { FixedAspectPhoto(it) } } } GlobalScope.launch { photos.mapNotNull { it.await() }.forEach { photo -> import(photo, event.location) } } true } ?: false } } } // ... }

Our DropReceiver specifies the supported file-types (jpg, jpeg, and png). It then checks that any drop event contains valid files before accepting it. The drop(event: DropEvent) method is called when the user attempts the final drop. Here, the receiver fetches all the allowed files in the bundle, and tries to load and import each one. Notice that the receiver converts raw Image returned by ImageLoader into a FixedAspectPhoto.

Importing An Image

We import images using a local import function inside the mainContainer creation block. This simplifies access to local state. The import function takes a photo, which is a View, and a location to place it.

val import = { photo: View, location: Point -> }

Import resizes and centers the photo at the given point. It center-rotates it between -15° and 15°. Finally, a listener is added to the pressed pointer event. This moves the photo to the foreground and updates the panel.

photo.width = 400.0 photo.position = location - Point(photo.width / 2, photo.height / 2) photo.transform = Identity.rotate(location, (Random.nextFloat() * 30 - 15) * degrees) photo.pointerChanged += pressed { children.move(photo, to = children.size - 1) panel.setPhoto(photo) }

Using Gestures

Import also registers a custom gesture listener to support multi-touch scaling and rotations.

GestureRecognizer(photo).changed += object: GestureListener<GestureEvent> { // ... override fun started(event: GestureEvent) { // capture initial state event.consume() } override fun changed(event: GestureEvent) { // 1) calculate rotation angle // 2) update photo transform to include rotation // 3) update photo bounds based on scaling event.consume() } override fun ended(event: GestureEvent) { // simply consume event event.consume() } }

GestureRecognizer takes a View and emits events whenever it detects motion from 2 or more pointers in that View. It also calculates a scale value by comparing the distance between the selected pointers over time.

We register a listener that uses the events to update the photo's transform and bounds. The listener also consumes events to avoid them making it to subsequent pointer listeners (the Resizer used for single pointer manipulation in this case).

Capturing Initial Gesture State

We record the state of our photo, and the pointers provided by the GestureRecognizer on the started event. Notice that GestureRecognizer provides locations in the photo's local coordinate. This makes sense for a general-purpose utility and matches the way Doodle reports pointer events. We use these values to modify the photo's bounds though, which is defined in its parent's coordinates. So we map the points into the parent before our calculations.

override fun started(event: GestureEvent) { // Capture initial state to apply deltas with in `changed` originalSize = photo.size originalCenter = this@container.toLocal(event.center, photo) originalVector = event.initial[1].inParent(photo) - event.initial[0].inParent(photo) originalPosition = photo.position initialTransform = photo.transform event.consume() // ensure event is consumed from Resizer }

Handling Gesture Updates

The values recorded in started are used--along with the new state--in the changed event to update the selected photo.

override fun changed(event: GestureEvent) { val currentVector = event.current[1].inParent(photo) - event.current[0].inParent(photo) // Angle between initial set of points and their current locations val transformAngle = atan2( originalVector.x * currentVector.y - originalVector.y * currentVector.x, originalVector.x * currentVector.x + originalVector.y * currentVector.y ) // Use transform for rotation photo.transform = initialTransform.rotate(around = originalCenter, by = transformAngle) // Update bounds instead of scale transformation photo.bounds = Rectangle( originalPosition - ((originalPosition - originalCenter) * (1 - event.scale)), originalSize * event.scale) event.consume() // ensure event is consumed from Resizer }