object vs compositionLocalOf vs staticCompositionLocalOf: The Ultimate State Sharing Guide

When building with Jetpack Compose, choosing how to pass data down the UI tree is a critical architectural decision.

In this post, we compare three common approaches: Static Objects, Dynamic Locals, and Static Locals.

 

🧑🏻‍💻 1. Quick Comparison Table

 

🧑🏻‍💻 2. Full Implementation: All-in-One File Example

Defining your CompositionLocal at the Top-level (outside of any function or class) is the golden rule of Compose.

This makes the "Key" globally accessible and permanent.

 

🧑🏻‍💻 3. Why use a "Top-level val"?

Why outside the function?:
A CompositionLocal is a unique Key. If defined inside a function, a new key is generated every time that function recomposes.
This breaks the link between the Provider and the .current call, resulting in data not being found.

Why val?:
The Local... itself is just a definition of a "conduit." You don't need to change the conduit itself (var).
Changing the data inside the conduit is handled by the CompositionLocalProvider.

 

🧑🏻‍💻 Final Conclusion

  • object: Use for fixed values that don't need to be swapped for tests or previews.
  • compositionLocalOf: Use for dynamic states where you want to minimize recomposition overhead.
  • staticCompositionLocalOf: Use for infrastructure/themes where read speed is the priority.

By keeping your definitions at the top-level of your files, your Compose architecture will remain clean, predictable, and performant.


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


Identifying the "4 JDKs" in Android Studio: From Launcher JVM to Toolchain

Have you ever encountered a situation where your build passes in the terminal but fails in the IDE? Or perhaps you updated Java on your system, but Android Studio seems to ignore it ?

The root cause often lies in the fact that Android Studio manages four different JDKs depending on the specific task. Confusing these can lead to "mysterious" environment issues. In this guide, we will identify each one and show you how to pinpoint them in your own environment.

 

🤔 Why You Need to Know the "4 JDKs"

Android Studio separates the processes that run the IDE, manage the build, and compile your source code. If these refer to different JDKs, you may run into compatibility glitches.

Let’s identify the "true identity" of each.

 

🤔 1. Launcher JVM (The Terminal/Entry Point)

Role: This is the JVM that initially kicks off when you run the ./gradlew script. It acts as the "starter" to wake up the heavy-lifting Gradle Daemon.

How to Identify: Run the following command in your terminal:


❯ ./gradlew -v            

------------------------------------------------------------
Gradle 9.3.1
------------------------------------------------------------

Build time:    2026-01-29 14:15:01 UTC
Revision:      44f4e8d3122ee6e7cbf5a248d7e20b4ca666bda3

Kotlin:        2.2.21
Groovy:        4.0.29
Ant:           Apache Ant(TM) version 1.10.15 compiled on August 25 2024
Launcher JVM:  21.0.10 (Eclipse Adoptium 21.0.10+7-LTS)
Daemon JVM:    Compatible with Java 21, JetBrains, nativeImageCapable=false (from gradle/gradle-daemon-jvm.properties)
OS:            Mac OS X 26.3 aarch64

Look for the line labeled Launcher JVM.

Dependent on: Your OS environment variables, specifically JAVA_HOME or your PATH setting.

 

🤔 2. Daemon JVM (The Build Engine)

Role: This is the "workhorse" JVM that actually performs the heavy lifting—compiling code and packaging your APK/AAB. It stays resident in memory to speed up subsequent builds.

How to Identify: Check the project-level configuration file:
gradle/gradle-daemon-jvm.properties


toolchain.vendor=JetBrains
toolchain.version=21

You can also see this in the ./gradlew -v output under Daemon JVM.

Dependent on: Gradle 8.8+ project settings.

 

🤔 3. IDE JDK (The Android Studio Runtime)

Role: This JVM runs the Android Studio application itself. It handles the editor UI, code completion (indexing), and IDE plugins.

How to Identify:
Mac: Android Studio > About Android Studio

Dependent on: The jbr folder bundled within your Android Studio installation directory.

 

🤔 4. Java Toolchain (The Compilation Target)

Role: This setting strictly defines which Java version should be used to compile your source code. It ensures that everyone on a team uses the exact same Java version for the final binary, regardless of their local IDE settings.

How to Identify: Check your app/build.gradle.kts (or build.gradle):


// build.gradle.kts

java {
  toolchain {
    languageVersion = JavaLanguageVersion.of(21)
  }
}


// build.gradle.kts

with(javaToolchains.compilerFor(java.toolchain).get().metadata) {
  logger.lifecycle("Compiler JVM: $vendor $languageVersion ($jvmVersion)")
  logger.lifecycle("$installationPath")
}

// Output:
// Compiler JVM: JetBrains 21 (21.0.10+7-b1163.108)
// /Users/jake/.gradle/jdks/jetbrains_s_r_o_-21-aarch64-os_x.2/jbrsdk_jcef-21.0.10-osx-aarch64-b1163.108/Contents/Home

Note: If this is not explicitly set, the Daemon JVM usually handles the compilation tasks by default.

 

🤔 Summary: Identification Checklist

Use this table as a quick reference to audit your development environment:

Final Thought
Even if your Launcher JVM is "Eclipse Adoptium" while your Daemon JVM is "JetBrains Runtime," your project will generally work fine as long as the major versions (e.g., Java 21) match.