[Jetpack Compose] Implement "Pull-to-Refresh" with the New PullToRefreshBox

The "Pull-to-Refresh" gesture is a staple in Android app UI.

While we previously relied on Modifier.pullRefresh, Jetpack Compose has introduced PullToRefreshBox in Material 3 as the new standard. It's more intuitive and requires much less boilerplate code.

In this post, we’ll quickly cover everything from basic implementation to customization!

 

🧑🏻‍💻 1. Prerequisites

PullToRefreshBox is available in Material 3 (version 1.3.0 or later).

Make sure to check your build.gradle dependencies:


dependencies {
    implementation("androidx.compose.material3:material3:1.3.0")
}

 

🧑🏻‍💻 2. Basic Implementation Pattern

The best part about PullToRefreshBox is that it encapsulates both the refresh logic and the indicator UI into a single component.


@Composable
fun RefreshableListScreen() {
    var isRefreshing by remember { mutableStateOf(false) }
    val scope = rememberCoroutineScope()
    val items = remember { mutableStateListOf("Initial Item A", "Initial Item B") }

    PullToRefreshBox(
        isRefreshing = isRefreshing,
        onRefresh = {
            scope.launch {
                isRefreshing = true
                // Perform your refresh logic (e.g., API calls)
                delay(2000) 
                items.add(0, "New Item ${items.size + 1}")
                isRefreshing = false
            }
        }
    ) {
        LazyColumn(Modifier.fillMaxSize()) {
            items(items) { item ->
                ListItem(headlineContent = { Text(item) })
            }
        }
    }
}

Key Highlights

  • isRefreshing: A boolean that controls the visibility of the refresh indicator.
  • onRefresh: The callback triggered when the user performs the pull gesture.
  • Content Size: Ensure your scrollable content (like LazyColumn) uses Modifier.fillMaxSize() so the pull gesture is detectable across the entire area.

 

🧑🏻‍💻 3. Practical Usage with ViewModel

In a production environment, it's best practice to let a ViewModel handle the state.


class MyViewModel : ViewModel() {
    var isRefreshing by mutableStateOf(false)
        private set

    fun refreshData() {
        viewModelScope.launch {
            isRefreshing = true
            // Simulate network call
            isRefreshing = false
        }
    }
}

val viewModel: MyViewModel = viewModel()
PullToRefreshBox(
    isRefreshing = viewModel.isRefreshing,
    onRefresh = { viewModel.refreshData() }
) {
    // ... Content
}

 

🧑🏻‍💻 4. Customizing the Design

If you want to change the indicator's color to match your brand, use the indicator parameter.


PullToRefreshBox(
    isRefreshing = isRefreshing,
    onRefresh = { /* ... */ },
    indicator = {
        PullToRefreshDefaults.Indicator(
            state = it,
            isRefreshing = isRefreshing,
            containerColor = Color.DarkGray, // Background color
            color = Color.Cyan              // Progress spinner color
        )
    }
) {
    // ...
}

 

🧑🏻‍💻 Conclusion: Simplified Refresh Logic

With the arrival of PullToRefreshBox, implementing this common UI pattern has never been easier.

  • Use Material 3 1.3.0+.
  • Pass the state (isRefreshing).
  • Handle the logic in onRefresh.

That’s it! You now have a modern, native-feeling refresh experience.


Modernizing Android Build Scripts: Moving from "android { ... }" to "configure { ... }"

In the world of Android development, Kotlin DSL has become the standard for writing build scripts.

While the familiar android { ... } block works perfectly for simple projects, as your project grows and you start sharing build logic across multiple modules (e.g., using Convention Plugins), you might find it a bit limiting.

Today, we’ll look at why and how to switch to the more explicit and scalable configure<ApplicationExtension> syntax.

 

🧑🏻‍💻 1. Why Make the Switch?

The standard android { ... } block in build.gradle.kts is actually a "shorthand" provided by the Android Gradle Plugin (AGP). While convenient, using configure<T> offers several advantages:

  • Better Type Safety: By explicitly telling Gradle that "this block is an ApplicationExtension," the IDE (Android Studio) can provide more accurate code completion and error highlighting.
  • Scalable Build Logic: If you are moving common logic into buildSrc or external plugins to keep your Gradle files DRY (Don't Repeat Yourself), using the explicit extension type becomes essential for writing clean, reusable functions.

 

🧑🏻‍💻 2. The Transformation: Before vs. After

Let’s compare the standard approach with the explicit configuration style for an App module.

Before: The Standard android Block


// app/build.gradle.kts
android {
    compileSdk = 35
    defaultConfig {
        applicationId = "com.example.myapp"
        minSdk = 26
        targetSdk = 35
    }
}

After: Using configure<ApplicationExtension>
Note that you will need to import the ApplicationExtension class explicitly.


// app/build.gradle.kts
import com.android.build.api.dsl.ApplicationExtension

configure<ApplicationExtension> {
    compileSdk = 35
    defaultConfig {
        applicationId = "com.example.myapp"
        minSdk = 26
        targetSdk = 35
        // ...
    }
}

 

🧑🏻‍💻 3. Choosing the Right Extension Type

Not every module is an "Application."

You should choose the extension type that matches your module's purpose:

[!TIP]
Use CommonExtension when writing shared logic that applies to both your App and Library modules (like Java versioning or Compose options).

 

🧑🏻‍💻 4. Practical Implementation: Reusable Build Logic

The true power of this syntax shines when you extract common configurations into a function, such as in buildSrc.


// Example of a shared configuration function in buildSrc
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.Project

fun Project.configureAndroidApplication() {
    extensions.configure<ApplicationExtension> {
        compileSdk = 35
        defaultConfig {
            minSdk = 26
            // ...other shared settings
        }
    }
}

By defining your build logic this way, your module-level Gradle files stay thin and highly maintainable.

 

🧑🏻‍💻 Conclusion

The traditional android { ... } block is great for its brevity. However, once your project reaches a certain scale and you start treating your build configuration as "real code," switching to configure is the way to go.

It brings better IDE support, type safety, and makes your build logic much easier to share across modules.


The New Standard in Android Studio Panda: Automating JDK Management with Foojay Resolver

As an Android developer, are you still wasting time managing JDK versions?

"I cloned a new project and the build failed,"
"Updating JDK settings in CI is a pain,"
"Different team members are using different JDK vendors..."

These headaches are now a thing of the past thanks to the combination of Android Studio Panda (2025.3.1), AGP 9.1, and the Foojay Resolver plugin.

 

🤔 1. What is org.gradle.toolchains.foojay-resolver-convention?

In short, it is a plugin that allows Gradle to automatically find, download, and configure the required JDK from the internet.

Normally, even if you define a Java Toolchain in your build.gradle, the build will fail if that specific JDK isn't already installed on your local machine.

By adding this plugin, Gradle communicates with the Foojay (Friends Of OpenJDK) database (via the Disco API) to automatically fetch and set up the correct JDK for you.

 

🤔 2. What Changed in Android Studio Panda?

With the release of Android Studio Panda, JDK management has shifted from "IDE-driven" to "Project-driven (Gradle-driven)."

  • Gradle Daemon JVM Criteria: Instead of manually selecting a JDK in the IDE settings, Android Studio now reads the toolchain configuration directly from your project files. It automatically switches the JVM used to run Gradle itself (the Daemon) to match your project.
  • Synchronized Environment: This eliminates the common "it works in the terminal but fails in the IDE" issue. The JDK used by ./gradlew and the "Run" button in Android Studio will now always be 100% identical.

 

🤔 3. Critical Notes for AGP 9.1

If you are using AGP 9.1 or higher, keep these points in mind:

  • Java 21 Requirement: AGP 9.x series strictly requires JDK 21.
  • Consistency is Key: Since AGP 9.1 strongly encourages the Gradle Daemon and the compilation JVM to be the same, the benefits of automatic resolution via foojay-resolver are more significant than ever. It ensures your entire pipeline stays on JDK 21 without manual intervention.

 

🤔 4. Implementation Guide (Quick Steps)

Step 1: Update settings.gradle.kts

Add the plugin to the very top of your root settings.gradle.kts file. This enables the automatic download capability.


plugins {
    // The magic line for automatic JDK downloads
    id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
}

Step 2: Configure build.gradle.kts

Define the Java version in your app module’s build.gradle.kts (or within a convention plugin).


android {
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_21
        targetCompatibility = JavaVersion.VERSION_21
    }
    
    kotlinOptions {
        jvmTarget = "21"
    }

    // Java Toolchain configuration
    java {
        toolchain {
            languageVersion.set(JavaLanguageVersion.of(21))
            // Optional: Specify a vendor if needed
            // vendor.set(JvmVendorSpec.ADOPTIUM)
        }
    }
}

 

🤔 5. Key Benefits at a Glance

 

🤔 Conclusion

In the era of Android Studio Panda and AGP 9.1, foojay-resolver-convention is no longer just a "nice-to-have" option—it is core infrastructure for modern Android development.

When upgrading your legacy projects, make this plugin your first priority. Stop fighting with environment variables and start focusing on what matters most: writing great code.

[!TIP] To verify that your JDKs are being recognized correctly, run ./gradlew -q javaToolchains in your terminal.


Mastering Screen Lifecycle in Jetpack Compose Navigation

When developing with Jetpack Compose, a common challenge is detecting when a screen becomes visible (to refresh data) or when it moves to the background (to pause a video).

While Compose provides onDispose, this only triggers when a Composable is completely removed from the UI tree. It cannot detect when a screen is still in the backstack but no longer visible to the user.

In this post, we’ll explore how to leverage the fact that Navigation Compose uses NavBackStackEntry as a LifecycleOwner to perfectly manage screen-level events.

 

🧑🏻‍💻 1. The Core: Who is the LocalLifecycleOwner?

In Compose, you can access the current lifecycle via LocalLifecycleOwner.current. However, its identity changes depending on your app's architecture:

  • Directly under an Activity: LifecycleOwner = Activity
  • Using Navigation Compose: LifecycleOwner = NavBackStackEntry

When using Navigation Compose, each destination is wrapped in a NavBackStackEntry. When you navigate from Screen A to Screen B, the Activity remains RESUMED, but Screen A’s NavBackStackEntry transitions to the STOPPED state.

By monitoring this, you can capture lifecycle events specific to that individual screen.

 

🧑🏻‍💻 2. The Solution: Implementing ScreenLifecycleObserver

To make this reusable, we can create a custom Composable function that observes these state changes safely.

 

🧑🏻‍💻 3. Real-World Patterns

A. Refreshing Data on Screen Return

Using LaunchedEffect(Unit) only runs once when the screen is first created. If you want to refresh data every time a user navigates back to the screen, use ON_RESUME.

B. Pausing and Resuming Video

Automatically pause video when the user navigates away or minimizes the app, and resume it when they return.

C. Tracking Screen Time (Analytics)

Start a timer on ON_RESUME and send the duration on ON_PAUSE.

 

🧑🏻‍💻 Summary: When to Use What?

Finally, let's distinguish between onDispose and Lifecycle events:

If you are using Navigation Compose, LocalLifecycleOwner.current is a powerful tool. Using it correctly ensures a robust app that respects system resources and provides a seamless user experience.

I hope this guide helps you manage screen lifecycles in your Compose projects!


Ending the Event Management Debate in ViewModel: The "MVI-style" Best Practice using StateFlow and Channel

 

🧑🏻‍💻 Introduction

When streaming data from a ViewModel to the UI, do you ever struggle with how to handle one-time events like "screen navigation" or "showing a Toast"?

It’s tempting to think, "Why not just combine everything into one state?" However, this often leads to a common pitfall: event re-emission bugs.

Today, I’ll introduce a robust, boilerplate-friendly design pattern: "State = combine / Effect = merge."

 

🧑🏻‍💻 1. Separating State from Effect

First, let’s categorize UI elements into two distinct types based on their behavior:

  • UiState (State): Represents the current look of the screen. It must always hold a "latest value" (e.g., loading flags, usernames, input fields).
  • UiEffect (Side Effect): Represents momentary occurrences. These should be processed once and then forgotten (e.g., navigation, error alerts, snackbars).

 

🧑🏻‍💻 2. ViewModel Implementation: Choosing between combine and merge

In the ViewModel, we use different operators depending on the nature of the data flow.


class UserProfileViewModel(private val repository: UserRepository) : ViewModel() {

    // --- [State] Synthesizing the latest state ---
    // We combine multiple sources (Loading, User data, etc.) 
    // to ensure the UI always has a consistent "single frame" of data.
    private val _isLoading = MutableStateFlow(false)
    val uiState: StateFlow<UserProfileState> = combine(
        _isLoading, 
        repository.userData // Flow<User>
    ) { loading, user ->
        UserProfileState(userName = user.name, isLoading = loading)
    }.stateIn(
        scope = viewModelScope, 
        started = SharingStarted.WhileSubscribed(5000), 
        initialValue = UserProfileState()
    )

    // --- [Effect] Integrating independent events ---
    // Use a Channel for one-shot events and merge them into a single flow 
    // to pipe everything through a single "event bus" to the UI.
    private val navigationEvents = Channel<UserProfileEffect.Navigate>()
    private val toastEvents = Channel<UserProfileEffect.ShowToast>()

    val uiEffect: Flow<UserProfileEffect> = merge(
        navigationEvents.receiveAsFlow(),
        toastEvents.receiveAsFlow()
    )

    fun onUpdateClick() {
        viewModelScope.launch {
            _isLoading.value = true
            if (repository.update()) {
                navigationEvents.send(UserProfileEffect.Navigate("home"))
            } else {
                toastEvents.send(UserProfileEffect.ShowToast("Update failed"))
            }
            _isLoading.value = false
        }
    }
}

Why differentiate them?

  • Why combine for State? The UI must always be consistent. Even if only one value changes, combine re-emits the set of all "latest values," preventing the UI from showing incomplete data.
  • Why merge for Effect? If you use combine for events, a simple update to a State (like a loading spinner) would trigger a re-emission of the previous navigation event. merge ensures that only the event that just happened gets triggered.

 

🧑🏻‍💻 3. Handling Events in the View (Compose)

On the UI side, we handle these flows using methods tailored to their specific lifecycles.


@Composable
fun UserProfileScreen(viewModel: UserProfileViewModel) {
    // 1. Observe State: Automatically updates the UI and respects lifecycle
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    // 2. Consume Effect: Use LaunchedEffect to handle events exactly once
    LaunchedEffect(viewModel.uiEffect) {
        viewModel.uiEffect.collect { effect ->
            when (effect) {
                is UserProfileEffect.Navigate -> navController.navigate(effect.route)
                is UserProfileEffect.ShowToast -> showToast(effect.message)
            }
        }
    }

    // 3. Render UI: Simply follow the uiState
    ProfileContent(uiState) 
}

 

🧑🏻‍💻 Summary: Why this Pattern Wins

  • Unidirectional Data Flow (UDF): It clearly separates "State flowing down" from "Events flowing up."
  • Bug Prevention: It structurally prevents issues like "Toasts reappearing on screen rotation" or "double navigation."
  • Clean Code: The UI processes all events in a single when block, and the ViewModel keeps concerns neatly separated.

If you find your event management getting messy, give the State (combine) & Effect (merge) pattern a try!

👉 Android アーキテクチャの現在地:Google が推奨する UDF と、現場が選ぶ「MVI 風 MVVM」
👉 モダンUI開発の決定版: State / Effect / Event で作る「迷わない」画面実装