Skip to main content

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.

Directory Layout
  • src
    • jsMain
      • 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.

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 ) } } }
info

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.

tip

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 Views. 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.

tip

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 } } // ... }
tip

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