Photo Stream Tutorial
We will build a simple Doodle app that displays an infinite stream of photos that are lazily loaded from unsplash.com. The photos will be shown in a list that continuously grows as the user scrolls to the bottom.
The first thing we need is an Unsplash API key. Take a look at their developer documentation to obtain one. A key is required to make API requests and fetch image urls.
Project Setup
We will use a JS only-setup for this app. Our app will use Ktor for the HTTP client and Kotlin Serialization to unmarshal the resulting JSON. We also need Kotlin's Coroutines library to load images asynchronously.
- kotlin
- resources
- build.gradle.kts
All source code and resources are located under the src
directory.
All logic and resources for this web-only (JS) app is located in the jsMain source set (src/jsMain
), which means it will only target that single platform.
Source code and resources that are needed for our app are stored in jsMain
. This is be design as our app is only meant to run in the browser using Javascript. The launch portion of our app is located in the program's main
function found in main.kt
.
Source code and resources that are needed for our app are stored in jsMain
. This is be design as our app is only meant to run in the browser using Javascript. The launch portion of our app is located in the program's main
function found in main.kt
.
Holds the index.html
file that loads the generated JS file produced for the Web (JS) target.
The build.gradle.kts
file defines how the app is configured and all its dependencies. The PhotoStream app uses a single-platform configuration and runs only as a web-app.
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
plugins {
kotlin("multiplatform" )
alias(libs.plugins.serialization)
}
kotlin {
js { browser { binaries.executable() } } // Web (JS) executable
sourceSets {
// Web (JS) platform source set
jsMain.dependencies {
implementation(libs.coroutines.core ) // async api calls
implementation(libs.bundles.ktor.client) // api calls to images service
implementation(libs.serialization.json ) // serialization for api calls
implementation(libs.doodle.themes )
implementation(libs.doodle.controls)
implementation(libs.doodle.browser )
}
}
}
The gradle build uses gradle version catalogs; see libs.versions.toml file for library info.
The Application
Our application will be fairly simple. It will create a DynamicList
with a data model bound to Unsplash's APIs. This list will be within a ScrollPanel
that fits the Display
height.
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.
package io.nacular.doodle.examples
import io.ktor.client.*
import io.nacular.doodle.application.Application
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.list.DynamicList
import io.nacular.doodle.controls.panels.ScrollPanel
import io.nacular.doodle.core.Display
import io.nacular.doodle.image.ImageLoader
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.layout.constraints.fill
import io.nacular.doodle.theme.ThemeManager
import io.nacular.doodle.theme.adhoc.DynamicTheme
import io.nacular.doodle.theme.basic.list.basicVerticalListBehavior
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
/**
* Streams an unbounded list of images from unsplash and displays them in a list.
*/
//sampleStart
class PhotoStreamApp(
display : Display,
themes : ThemeManager,
theme : DynamicTheme,
httpClient : HttpClient,
imageLoader: ImageLoader
): Application {
init {
// For scroll panel behavior
themes.selected = theme
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val imageHeight = 400.0
// List to hold images
val list = DynamicList(
model = UnSplashDataModel(appScope, httpClient, imageLoader),
itemVisualizer = itemVisualizer { image, recycledView, _ -> when(recycledView) {
is CenterCroppedPhoto -> recycledView.also { recycledView.image = image }
else -> CenterCroppedPhoto(image)
} }
).apply {
behavior = basicVerticalListBehavior(itemHeight = imageHeight)
cellAlignment = fill
}
display += ScrollPanel(list).apply {
// Ensure list's width is equal to scroll-panel's
contentWidthConstraints = { it eq parent.width - verticalScrollBarWidth }
}
display.layout = constrain(display.children[0]) {
it.width eq min(parent.width, imageHeight)
it.height eq parent.height
it.centerX eq parent.centerX
}
}
override fun shutdown() {}
}
//sampleEnd
DynamicList monitors its model for changes and updates whenever items are added, removed, or moved. This means we can simply change the underlying model to get a list that grows.
DynamicList
, like List
and MutableList
recycle their contents to avoid rendering items that are not displayed. The scrollCache
constructor parameter controls the amount of items in the buffer. Passing nothing means we get a default of 10 items cached beyond what is visible.
Binding To Unsplash Data
DynamicList requires a DynamicListModel
to hold its data, so we need to create one that binds to Unsplash.
package io.nacular.doodle.examples
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.nacular.doodle.controls.DynamicListModel
import io.nacular.doodle.controls.ModelObserver
import io.nacular.doodle.image.Image
import io.nacular.doodle.image.ImageLoader
import io.nacular.doodle.utils.ObservableList
import io.nacular.doodle.utils.SetPool
import io.nacular.doodle.utils.observable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlin.properties.Delegates
/**
* DynamicListModel of Images that fetches data from Unsplash. This model provides a stream
* of images by fetching every time the end of its current list is reached. Fetched images
* are cached in memory.
*/
//sampleStart
class UnSplashDataModel(
private val scope : CoroutineScope,
private val client : HttpClient,
private val imageLoader: ImageLoader,
private val accessToken: String = "YOUR_ACCESS_TOKEN"
): DynamicListModel<Image> {
@Serializable
data class Urls(val small: String)
@Serializable
data class UnsplashPhoto(val id: String, val urls: Urls)
// Tracks current HTTP request
private var httpRequestJob: Job? by Delegates.observable(null) { _, old, _ ->
old?.cancel()
}
// Used to avoid fetching in the middle of an ongoing fetch
private var fetchActive = false
// Page being fetched from unsplash
private var currentPage: Int by observable(-1) { _, _ ->
fetchActive = true
httpRequestJob = scope.launch {
val results = client.get(unsplashLocation).body<List<UnsplashPhoto>>()
loadedImages.addAll(results.mapNotNull { imageLoader.load(it.urls.small) })
fetchActive = false
if (nextPageNeeded) {
nextPageNeeded = false
currentPage += 1
}
}
}
private val pageSize = 5
private var nextPageNeeded = false
private val unsplashLocation get() = "https://api.unsplash.com/photos/?client_id=$accessToken&page=$currentPage&per_page=$pageSize"
override val size get() = loadedImages.size
override val changed = SetPool<ModelObserver<Image>>()
// Internal list used to cache images loaded from unsplash
private val loadedImages = ObservableList<Image>().also {
it.changed += { _, differences ->
// notify model observers whenever the underlying list changes (due to image loads)
changed.forEach {
it(this, differences)
}
}
}
init {
currentPage = 0
}
override fun get(index: Int): Result<Image> = Result.runCatching {
loadedImages[index].also {
// Load the next page if the last image is fetched from the model
if (index == size - 1) {
when {
fetchActive -> nextPageNeeded = true
else -> currentPage += 1
}
}
}
}
override fun contains(value: Image) = value in loadedImages
override fun iterator( ) = loadedImages.iterator()
override fun section (range: ClosedRange<Int>) = loadedImages.subList(range.start, range.endInclusive + 1)
}
//sampleEnd
Our model will cache a local ObservableList
of images via loadedImages
. This list will provide the model state our list uses to render. The model will then fetch paginated images from Unsplash and load the resulting urls asynchronously into this list.
We track the currentPage
and pageSize
to fetch a growing list of pages from Unsplash. We fetch a new page whenever the currentPage
is updated.
class UnSplashDataModel(private val scope: CoroutineScope, /*...*/): DynamicListModel<Image> {
// ...
private var httpRequestJob: Job? by observable(null) { _,old,_ ->
old?.cancel()
}
private var currentPage: Int by observable(-1) { _,_ ->
fetchActive = true
httpRequestJob = scope.launch {
val results = client.get<List<UnsplashPhoto>>(unsplashLocation)
loadedImages.addAll(results.mapNotNull { imageLoader.load(it.urls.small) })
fetchActive = false
if (nextPageNeeded) {
nextPageNeeded = false
currentPage += 1
}
}
}
// ...
}
Fetches are performed using Ktor's HttpClient configured to read JSON data. These reads are done via Kotlin Serialization into the UnsplashPhoto
data class. We only use the small
value in the urls
property from the JSON response, so our data classes are much simpler than the full Unsplash API.
class UnSplashDataModel(httpClient: HttpClient, /*...*/): DynamicListModel<Image> {
// ...
@Serializable
data class Urls(val small: String)
@Serializable
data class UnsplashPhoto(val id: String, val urls: Urls)
// HTTP client configured to read JSON returned from unsplash
private val client = httpClient.config {
install(JsonFeature) {
serializer = KotlinxSerializer(Json { ignoreUnknownKeys = true })
}
}
// ...
}
Our loadedImages
list triggers an event whenever we add a new set of images to it. We use this fact to notify of changes to the model, which get reflected by the DynamicList
.
class UnSplashDataModel(/*...*/): DynamicListModel<Image> {
// ...
override val changed = SetPool<ModelObserver<Image>>()
// Internal list used to cache images loaded from unsplash
private val loadedImages = ObservableList<Image>().also {
it.changed += { _ ,removed, added, moved ->
// notify model observers whenever the underlying list changes (due to image loads)
changed.forEach {
it(this, removed, added, moved)
}
}
}
// ...
}
Finally, we need to decide when to fetch more images. We do this whenever get
is called on the last image of the model. This happens when the DynamicList
needs to present that image, and is a good indication that it has reached the end.
class UnSplashDataModel(/*...*/): DynamicListModel<Image> {
// ...
override fun get(index: Int): Image? = loadedImages.getOrNull(index).also {
// Load the next page if the last image is fetched from the model
if (index == size - 1) {
when {
fetchActive -> nextPageNeeded = true
else -> currentPage += 1
}
}
}
// ...
}
Presenting The Images
Our DynamicList
holds a list of Image
items, but these are not View
s. Which means we need a way of visualizing them. Many Doodle containers use this concept of an ItemVisualizer
. It is essentially a class that maps from some type T
to View
based on a set of inputs. DynamicList
takes an itemVisualizer
in its constructor that can be used by is behavior
to render the contents of each row. Our app uses the BasicListBehavior
to configure our list. Internally, that behavior takes the list's visualizer and creates a new View
that is wrapped in another that represents the row itself. So it is sufficient to specify a visualizer that renders our images.
It is also possible to change the way BasicListBehavior
(and any ListBehavior
) represents its rows by specifying it's RowGenerator
.
class PhotoStreamApp(/*...*/): Application {
init {
// ...
val list = DynamicList(
model = UnSplashDataModel(appScope, httpClient, imageLoader),
itemVisualizer = itemVisualizer { image, recycledView, _ -> when(recycledView) {
is CenterCroppedPhoto -> recycledView.also { recycledView.image = image }
else -> CenterCroppedPhoto(image)
} }
).apply {
behavior = BasicListBehavior(rowHeight = imageHeight)
cellAlignment = fill
}
}
// ...
}
ItemVisualizer
is designed to support recycling. Each invocation may provide a recycled View that might be reusable for the new item. This lets us reuse the CenterCroppedPhoto
instances as the list scrolls.
We will render each image as with a center-crop using CenterCroppedPhoto
. This class holds an image that it renders with a centered square crop. The crop square's length is equal to the image's width or height (whichever is smaller). That center region is then scaled to fit the cropped photo View's bounds.
package io.nacular.doodle.examples
import io.nacular.doodle.core.View
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.image.Image
import io.nacular.doodle.image.height
import io.nacular.doodle.image.width
import kotlin.math.min
/**
* Renders an image with a center crop.
*/
//sampleStart
class CenterCroppedPhoto(image: Image): View() {
private lateinit var centerCrop: Rectangle
var image: Image = image
set(new) {
field = new
val cropSize = min(image.width, image.height)
centerCrop = Rectangle((image.width - cropSize) / 2, (image.height - cropSize) / 2, cropSize, cropSize)
rerender()
}
init {
this.image = image // ensure setter called, so centerCrop initialized
}
override fun render(canvas: Canvas) {
canvas.image(image, source = centerCrop, destination = bounds.atOrigin)
}
}
//sampleEnd