Tessera (Latin: "small tile piece") — A Compose Multiplatform tile-based high-resolution image viewer

Overview
Tessera is a memory-efficient image viewer for Compose Multiplatform. It uses tile-based rendering and platform-native decoders to handle images up to 108MP+ — keeping memory usage up to 95% lower than full-image loading.
Supported Platforms
Key Features
Platform Feature Matrix
Quick Start
Installation
Add dependencies to your build.gradle.kts:
implementation("io.github.bentleypark:tessera-core:<version>")
implementation("io.github.bentleypark:tessera-coil:<version>")
implementation("io.github.bentleypark:tessera-glide:<version>")
Android Usage
@Composable
fun MyScreen() {
TesseraImage(
imageUrl = "https://example.com/large-image.jpg",
modifier = Modifier.fillMaxSize()
)
val coilLoader = remember { CoilImageLoader(context) }
TesseraImage(
imageUrl = "https://example.com/large-image.jpg",
modifier = Modifier.fillMaxSize(),
imageLoader = coilLoader
)
TesseraImage(
imageResId = R.drawable.large_image,
modifier = Modifier.fillMaxSize()
)
}
iOS Usage
fun MainViewController() = ComposeUIViewController {
TesseraImage(
imageUrl = "https://example.com/large-image.jpg",
modifier = Modifier.fillMaxSize()
)
}
fun MainViewController() = ComposeUIViewController {
val coilLoader = remember { CoilImageLoader() }
TesseraImage(
imageUrl = "https://example.com/large-image.jpg",
modifier = Modifier.fillMaxSize(),
imageLoader = coilLoader
)
}
import TesseraCoil
let coilLoader = CoilImageLoader.companion.create()
MainViewControllerKt.MainViewController(imageLoader: coilLoader)
Desktop Usage
fun main() = application {
Window(title = "Tessera Desktop") {
TesseraImage(
imageUrl = "https://example.com/large-image.jpg",
modifier = Modifier.fillMaxSize(),
showScrollIndicators = true
)
}
}
Desktop Gestures:
| Gesture | Action |
|---|
| Scroll (trackpad/wheel) | Pan image |
| Ctrl/Cmd + Scroll | Zoom in/out (cursor-based) |
Web (Wasm) Usage
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
CanvasBasedWindow(title = "Tessera Web", canvasElementId = "ComposeTarget") {
TesseraImage(
imageUrl = "https://example.com/large-image.jpg",
modifier = Modifier.fillMaxSize()
)
}
}
Note: Web support is experimental (Kotlin/Wasm). Unlike Android/iOS/Desktop which use partial or subsampled decoding, the web decoder loads the entire image into memory (no browser partial decode API). Practical limit is ~30MP (2–4 GB JS heap). For larger images, consider server-side tile generation.
Performance
Benchmarked on iPhone 7 (A10 Fusion, 2GB RAM, iOS 15.8.5) — 5 runs each:
vs Full Image Loading
Benchmarks run on iPhone 7 (A10 Fusion, 2GB RAM, iOS 15.8.5), 5 runs each. All benchmarks use JPEG images. Android uses BitmapRegionDecoder (Tier 1 partial decode) — memory footprint scales with tile size, not image size.
Supported Image Formats
JPEG provides the best performance across all platforms due to its block-based compression structure. PNG, WebP, and other formats are supported but may have higher memory overhead during decoding. GIF animation is not supported — only the first frame is displayed.
Large non-JPEG images (iOS / Desktop): Tessera logs a warning at initialization when a PNG or other non-JPEG image exceeds 30MP. Subsample APIs decode the full image internally before downscaling — a 30MP PNG requires ~120MB during decode before the ~30MB result is cached. For images over 30MP, JPEG is strongly recommended. Filter Logcat/console by tag Tessera to catch these warnings. A callback API (onWarning) may be added in a future release based on user demand.
Modules
tessera/
├── tessera-core/
├── tessera-coil/
├── tessera-glide/
├── sample/
├── sample-desktop/
├── sample-web/
└── iosApp/
Image Loading Strategy
Architecture
TesseraImage (Composable) <- User-facing API
|-- Gesture detection
|-- Canvas tile rendering
v
TesseraState <- State + LRU Cache
|-- initializeDecoder() on background thread
|-- applyInitResult() on main thread
|-- decodeTile() / cacheTile() split for thread safety
v
TileManager <- Grid Calculation
|-- Zoom level (0-3)
|-- Visible tile selection
v
RegionDecoder (expect/actual) <- Platform Decoding
|-- Android: BitmapRegionDecoder (true region decoding)
|-- iOS: CGImageSource subsample + Skia tile extraction
|-- Desktop: ImageIO subsample cache + getSubimage extraction
|-- Web: Skia Image.makeFromEncoded + Surface.drawImageRect
Decoding Architecture
Three distinct decoding tiers exist across platforms:
Tier 1 — Partial Decode
- Android:
BitmapRegionDecoder requests only the tile region from the decoder. Memory efficiency is highest with JPEG (DCT block structure); PNG and other formats may incur higher internal overhead depending on Android's codec implementation.
Tier 2 — Subsample + Tile Extraction (JPEG optimized)
Tier 3 — Full Decode + Tile Extraction
- Web: Browsers provide no partial image decode API. The entire image is decoded into memory via
Image.makeFromEncoded, then tiles are extracted via Skia Surface.drawImageRect.
Web limitation: The 30MP practical limit is a fundamental browser constraint (2–4 GB JS heap), not a Tessera limitation. For images larger than 30MP on web, server-side tile generation is the recommended approach. Web support functions as a zoom viewer rather than a true tile-based viewer.
Memory Protection (iOS / Desktop)
Android uses BitmapRegionDecoder (true partial decode), so no subsampling step is needed — memory stays at tile level regardless of image size. Tile size is dynamic based on display density (256–512px). JPEG tiles use RGB_565 (2 bytes/pixel, ~128–512KB per tile); PNG and other alpha-capable formats use ARGB_8888. A pool of 2 decoder instances enables parallel tile decoding.
Zoom Levels
API Reference
TesseraImage Parameters
State Observation (rememberTesseraState)
Use rememberTesseraState() to observe the viewer's internal state from outside the composable:
val state = rememberTesseraState()
TesseraImage(
imageUrl = "https://example.com/large-image.jpg",
modifier = Modifier.fillMaxSize(),
state = state
)
Text("Scale: ${"%.1f".format(state.scale)}x")
Text("Loading: ${state.isLoading}")
Text("Tiles cached: ${state.cachedTileCount}")
state.imageInfo?.let { Text("Size: ${it.width}x${it.height}") }
HorizontalPager Integration
Tessera supports HorizontalPager for image gallery browsing:
val pagerState = rememberPagerState { images.size }
HorizontalPager(state = pagerState) { page ->
TesseraImage(
imageUrl = images[page],
modifier = Modifier.fillMaxSize(),
enablePagerIntegration = true,
enableDismissGesture = true
)
}
Gesture behavior with enablePagerIntegration = true:
ReadMode (ContentScale)
For long images (webtoons, documents) or wide images (panoramas), use ContentScale to auto-fit:
TesseraImage(
imageUrl = "https://example.com/webtoon.jpg",
contentScale = ContentScale.Auto
)
TesseraImage(
imageUrl = "https://example.com/document.jpg",
contentScale = ContentScale.FitWidth
)
Building
./gradlew :tessera-core:testDebugUnitTest
./gradlew :tessera-core:compileKotlinDesktop
./gradlew :tessera-core:desktopTest
./gradlew :tessera-core:compileKotlinWasmJs
./gradlew :tessera-core:assembleDebug :tessera-coil:assembleDebug :tessera-glide:assembleDebug
./gradlew :tessera-core:linkDebugFrameworkIosSimulatorArm64
./gradlew :tessera-coil:linkDebugFrameworkIosSimulatorArm64
./gradlew :sample-desktop:run
./gradlew :sample-web:wasmJsBrowserDevelopmentRun
./gradlew :tessera-core:allTests
Test Infrastructure
./gradlew :tessera-core:testDebugUnitTest
./gradlew :tessera-core:desktopTest
Requirements
- Android: API 28+ (Android 9)
- iOS: iOS 15+
- Desktop: JDK 21+ (macOS, Windows, Linux)
- Web: Modern browser with WebGL + WebAssembly support
- Kotlin: 2.3.0+
- Compose Multiplatform: 1.8.0+
Contributing
Contributions are welcome! See CONTRIBUTING.md for development setup, build commands, and guidelines.
License
Copyright 2026 bentleypark
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance the License.
You may obtain a copy of the License at
http:
Unless applicable law agreed to writing, software
distributed under the License distributed an BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express implied.
See the License the specific language governing permissions
limitations under the License.