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
We will use a multi-platform library setup for this app, with a multiplatform launcher that depends on it. This is not necessary to use Doodle. You could create a single multiplatform build with the common parts of your app in commonMain
etc.. This setup is used here because these apps are also launched by an app within DocApps
when embedding them like below. Therefore, we need a pure library for each app. This is why there is an app and a runner.
- Photos
- PhotosRunner
plugins {
kotlin("multiplatform")
}
kotlin {
// Defined in buildSrc/src/main/kotlin/Common.kt
jsTargets ()
jvmTargets ()
wasmJsTargets()
sourceSets {
commonMain {
dependencies {
api(libs.coroutines.core)
api(libs.doodle.controls )
api(libs.doodle.animation)
api(libs.doodle.themes )
}
}
}
}
plugins {
kotlin("multiplatform")
application
}
kotlin {
jsTargets (executable = true)
wasmJsTargets(executable = true)
jvmTargets ( )
sourceSets {
commonMain.dependencies {
implementation(project(":Photos"))
}
jsMain.dependencies {
implementation(libs.doodle.browser)
}
val wasmJsMain by getting {
dependencies {
implementation(libs.doodle.browser)
}
}
jvmMain.dependencies {
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)
}
}
}
}
application {
mainClass.set("io.nacular.doodle.examples.MainKt")
}
installFullScreenDemo("Development")
installFullScreenDemo("Production" )
Build uses libs.versions.toml file.
The Application
All Doodle apps must implement the Application
interface. The framework will then initialize our app via the constructor.
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.
Creating A Full Screen App
Doodle apps can be launched in a few different ways. We create a helper to launch the app in full screen.
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 is 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
}