Stories (Instagram, WhatsApp) are full-screen, time-limited media with a progress bar. The key UX quality signal is zero-wait time — every story must be ready before the user taps to it.
Data Model
data class Story(
val id: String,
val userId: String,
val items: List<StoryItem>,
val seenUpTo: Int = 0 // index of last seen item
)
data class StoryItem(
val id: String,
val type: StoryItemType, // IMAGE or VIDEO
val mediaUrl: String,
val thumbnailUrl: String,
val duration: Int, // display duration in ms (5000 for images, actual length for videos)
val seen: Boolean = false
)
enum class StoryItemType { IMAGE, VIDEO }
Progress Bar
@Composable
fun StoryProgressBar(
itemCount: Int,
currentIndex: Int,
progress: Float, // 0.0 - 1.0 for current item
modifier: Modifier = Modifier
) {
Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
repeat(itemCount) { index ->
val segmentProgress = when {
index < currentIndex -> 1f // completed
index == currentIndex -> progress // current (animating)
else -> 0f // not yet reached
}
LinearProgressIndicator(
progress = segmentProgress,
modifier = Modifier.weight(1f).height(2.dp),
color = Color.White,
trackColor = Color.White.copy(alpha = 0.4f)
)
}
}
}
Auto-Advance Timer
@HiltViewModel
class StoryViewModel @Inject constructor(
private val repository: StoryRepository
) : ViewModel() {
private val _currentIndex = MutableStateFlow(0)
val currentIndex: StateFlow<Int> = _currentIndex
private val _progress = MutableStateFlow(0f)
val progress: StateFlow<Float> = _progress
private var timerJob: Job? = null
fun startAutoAdvance(duration: Int) {
timerJob?.cancel()
_progress.value = 0f
timerJob = viewModelScope.launch {
val startTime = System.currentTimeMillis()
while (isActive) {
val elapsed = System.currentTimeMillis() - startTime
_progress.value = (elapsed.toFloat() / duration).coerceIn(0f, 1f)
if (elapsed >= duration) {
advanceToNext()
break
}
delay(16) // 60fps update
}
}
}
fun onPause() { timerJob?.cancel() }
fun onResume(remainingMs: Int) { startAutoAdvance(remainingMs) }
fun advanceToNext() {
val next = _currentIndex.value + 1
if (next < currentStory.items.size) {
_currentIndex.value = next
_progress.value = 0f
startAutoAdvance(currentStory.items[next].duration)
} else {
// Move to next user's story
nextUserStory()
}
}
fun goToPrevious() {
if (_currentIndex.value > 0) {
_currentIndex.value -= 1
startAutoAdvance(currentStory.items[_currentIndex.value].duration)
}
}
}
Preloading Strategy
The key to instant story transitions is preloading the next story's first item while the current one plays.
class StoryPreloader @Inject constructor(
private val imageLoader: ImageLoader, // Coil
private val playerPool: ExoPlayerPool
) {
fun preloadStory(story: Story) {
val firstItem = story.items.firstOrNull() ?: return
when (firstItem.type) {
StoryItemType.IMAGE -> {
// Preload image into Coil memory cache
val request = ImageRequest.Builder(context)
.data(firstItem.mediaUrl)
.memoryCachePolicy(CachePolicy.ENABLED)
.build()
imageLoader.enqueue(request)
}
StoryItemType.VIDEO -> {
// Prime an ExoPlayer with the video URL so buffering starts
val player = playerPool.acquire()
player.setMediaItem(MediaItem.fromUri(firstItem.mediaUrl))
player.prepare() // starts buffering but doesn't play
playerPool.releaseToPreload(story.id, player)
}
}
}
fun preloadAdjacentStories(stories: List<Story>, currentIndex: Int) {
// Preload next 2 stories
(currentIndex + 1..currentIndex + 2)
.filter { it < stories.size }
.forEach { preloadStory(stories[it]) }
}
}
ExoPlayer Pool for Video Stories
class ExoPlayerPool(private val context: Context, private val maxSize: Int = 3) {
private val available = ArrayDeque<ExoPlayer>()
private val preloaded = mutableMapOf<String, ExoPlayer>()
fun acquire(storyId: String? = null): ExoPlayer {
// Return preloaded player if available
storyId?.let { preloaded.remove(it) }?.let { return it }
return available.removeFirstOrNull() ?: ExoPlayer.Builder(context)
.setLoadControl(DefaultLoadControl.Builder()
.setBufferDurationsMs(5000, 20000, 1500, 2000)
.build())
.build()
}
fun release(player: ExoPlayer) {
player.stop()
player.clearMediaItems()
if (available.size < maxSize) {
available.addLast(player)
} else {
player.release()
}
}
}
Key Takeaways
| Pattern | Rule |
|---|---|
| Progress bar segments | One LinearProgressIndicator per story item; 2dp height; white |
| 16ms timer updates | 60fps progress animation; cancel on pause |
| Auto-advance | Move to next item when elapsed >= duration; use elapsed time not countdown |
| Preload next 2 stories | Start image load and video buffering while current story plays |
| ExoPlayer pool | Reuse players; max 3; prime with prepare() before use |
| Touch left/right | Tap left third → go back; tap right two thirds → advance |