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.
plugins {
kotlin("multiplatform" )
alias(libs.plugins.serialization)
}
kotlin {
// Defined in buildSrc/src/main/kotlin/Common.kt
jsTargets (executable = true)
wasmJsTargets(executable = true)
sourceSets {
jsMain {
dependencies {
implementation(libs.bundles.ktor.client)
implementation(libs.coroutines.core )
implementation(libs.serialization.json )
api(libs.doodle.browser )
api(libs.doodle.controls)
api(libs.doodle.themes )
}
}
}
}
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.
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