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.
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.
- kotlin
- resources
- jsMain
- jvmMain
- wasmJsMain
- 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.
Source code and resources for that are usable for platforms are stored in commonMain
. This app is designed to work on all platforms, so our app code and all logic is found under this directory.
The kotlin
directory is where all code for a platform resides. In this case, we have all the classes for our app including PhotosApp
, GestureRecognizer
and Utils
.
All of these classes are platform agnostic and used by all targets. This makes our app work on any target Doodle supports.
An app can have resources like fonts, images, etc. that it loads at runtime. This directory contains these resources.
In our case, we will be loading 2 default images we store here.
Source code and resources that are needed for Web (JS) target are stored in jsMain
. Our app is platform agnostic except for the launch portion, which is located in the source below this directory.
The Web launch portion of our app is located here in the program's main
function.
Holds the index.html
file that loads the generated JS file produced for the Web (JS) target.
Source code and resources that are needed for Desktop (JVM) target are stored in jvmMain
.
The Desktop launch portion of our app is located here in the program's main
function.
Source code and resources that are needed for Web (WASM) target are stored in wasmJsMain
. Our app is platform agnostic except for the launch portion, which is located in the source below this directory.
The Web launch portion of our app is located here in the program's main
function.
Holds the index.html
file that loads the generated JS file produced for the Web (WASM) target.
The build.gradle.kts
file defines how the app is configured and all its dependencies. The Photos app uses a multi-platform configuration so it can run on all Doodle supported targets.
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}"
}
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.
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() {}
}
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
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.
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
}