The CommonsBlog


How to Ripple Outside of Compose Material

There is more to life than Material Design.

After all, it is a common complaint, at least here in the US, that designers design around iOS. Last I checked, iOS designs are not very Material. So, it stands to reason that Android developers using Compose will need to deviate from Material Design in many cases.

In theory, developers would create custom design systems from the ground up. In practice, I suspect that most developers use Compose Material or Material3 and try to implement the designs that way. This can work, but sometimes developers wind up having to resort to some fairly significant hacks in order to work around cases where Material’s opinions deviate from the designers’ opinions.

I am working on a project where I am creating a from-scratch custom design system. Along the way, I will try to point out how I am filling in various Compose gaps, where Material provides a solution but “you’re on your own” for a custom design system.


This time, let’s look at indications. Indications are how a clickable composable lets the user know that, indeed, they clicked the composable. Material Design calls for a ripple effect. Compose Material offers the ripple.

Your graphic designer probably is not designing indications for you, but you are going to need some sort of touch feedback. The ripple is a perfectly cromulent option, but the ripple is a Compose Material thing and will not be supplied “out of the box” for non-Material design systems.

The good news is that a lot of the ripple logic resides in a standalone library, one without other Material dependencies. This makes it reasonable for use in a non-Material Compose app. The bad news is that the documentation on how to actually apply that library is limited.

So, here is how you can do it.

First, you will need to add that library. That is androidx.compose.material:material-ripple, and the version I wrote this blog post around is 1.7.2:

composeRipple = "1.7.2"
compose-ripple = { group = "androidx.compose.material", name = "material-ripple", version.ref = "composeRipple" }
dependencies {
    implementation(libs.compose.ripple)
    // TODO other cool libraries go here
}

The documentation tells you “oh, do what Material3 does to provide the ripple”. That is contained mostly in Ripple.kt. So, copy its contents into your own project (be sure to abide by the license!). This will require you to also grab these StateTokens defined elsewhere:

internal object StateTokens {
    const val DraggedStateLayerOpacity = 0.16f
    const val FocusStateLayerOpacity = 0.1f
    const val HoverStateLayerOpacity = 0.08f
    const val PressedStateLayerOpacity = 0.1f
}

For the file version I used, IIRC there is only one real connection to the rest of Material3 in the file: a reference to currentValueOf(LocalContentColor). This is only needed if you do not supply a color directly when creating the ripple or via a LocalRippleConfiguration composition local. In my case, I was perfectly happy with two options for providing a color and did not need a third, so I swapped currentValueOf(LocalContentColor) with a RuntimeException:

    private fun attachNewRipple() {
        val calculateColor = ColorProducer {
            val userDefinedColor = color()
            if (userDefinedColor.isSpecified) {
                userDefinedColor
            } else {
                // If this is null, the ripple will be removed, so this should always be non-null in
                // normal use
                val rippleConfiguration = currentValueOf(LocalRippleConfiguration)
                if (rippleConfiguration?.color?.isSpecified == true) {
                    rippleConfiguration.color
                } else {
                    // currentValueOf(LocalContentColor)
                    throw RuntimeException("missing color for ripple")
                }
            }
        }

That should be all the changes that are needed… at least for the version of Ripple.kt that I used. It is possible that I forgot something, in which case I apologize.

Then, to actually apply the ripple, set the LocalIndication composition local to a ripple() that you create from the Ripple.kt code that you copied and revised:

CompositionLocalProvider(LocalIndication provides ripple(color = Color.White)) {
	// TODO cool stuff goes here
}

If you want to be able to change the ripple color without replacing the entire ripple, rather than provide the color to ripple(), you could leave that parameter out and also define a LocalRippleConfiguration:

CompositionLocalProvider(
	LocalIndication provides ripple(),
	LocalRippleConfiguration provides RippleConfiguration(color = Color.White)
) {
	// TODO cool stuff goes here
}

RippleConfiguration also lets you control the alpha values used in the ripple effect, overriding the defaults coming from StateTokens.

No matter how you provide the color, you will need to do that separately if you need different colors for different themes, such as light and dark.

If you are using ComposeTheme for your custom design system, there is an indication property that you can set in your buildComposeTheme() builder:

private val AwesomeDarkTheme = buildComposeTheme {
    name = "AwesomeDarkTheme"
    indication = ripple(color = Color.White)

    // TODO define the rest of the theme, which hopefully also is cool
}

ComposeTheme will take care of setting LocalIndication for you.

Once LocalIndication is set, clickable() and other modifiers and code will apply it automatically, so your ripple should show up. Alternatively, you can provide a ripple() directly as the indication to clickable(), perhaps for cases where you want a different implementation than the one from LocalIndication.

If you want an indication, but you want to do something other than a ripple… look at the implementation of ripple(), especially the RippleNodeFactory and DelegatingThemeAwareRippleNode, which eventually link into code from that material-ripple library mentioned earlier.

Sep 25, 2024


When remember() Does Not Remember, Consider if()

One of my concerns when Jetpack Compose was released is its reliance on magic coming from things like the Compose Compiler. Magic is wonderful for newcomers, as it reduces cognitive load. Magic is fine for serious experts (magicians), as for them it is not magic, but rather is sufficiently advanced technology. Magic can be a problem for those of us in between those extremes, to the extent it makes it difficult for us to understand subtle behavior differences coming from small code changes.

For example, I have been using Alex Styl’s ComposeTheme recently, to help organize a non-Material design system in Compose UI. The way that you build a theme with ComposeTheme is via a buildComposeTheme() top-level function:

val MyTheme = buildComposeTheme { 
  // TODO wonderful theme bits go here
}

This returns a composable function, which you can apply akin to MaterialTheme():

@Composable
fun MainScreen() {
    MyTheme {
        BasicText("Um, hi!")
    }
}

This works well.

I then added support for light and dark themes. Alex’s documentation shows doing that outside of the constructed theme function:


@Composable
fun MainScreen() {
    val MyTheme = if (isSystemInDarkTheme()) MyDarkTheme else MyLightTheme

    MyTheme {
        // use the theme, where color references get mapped to light or dark
    }
}

Here, MyDarkTheme() and MyLightTheme() are created using buildComposeTheme(), just with different colors. We choose which one to use, then apply it to our content.

I wanted to hide the decision-making, so I didn’t need it sprinkled throughout the code (e.g., @Preview functions). So, I wrote my own wrapper:

@Composable
fun MyTheme(content: @Composable () -> Unit) {
    if (isSystemInDarkTheme()) MyDarkTheme(content) else MyLightTheme(content)
}

This could be called like MyTheme() was before, routing to MyDarkTheme() or MyLightTheme() as needed.

And it worked… or so I thought.

The app opts out of all automatic configuration change “destroy the activity” behavior via android:configChanges. What happens is that Compose UI recomposes, and we update the UI based on the new Configuration, not significantly different than updating the UI based on the result of some other sort of data change.

What I noticed was that while the app worked, if I changed the theme while the app was running, everything would reset to the beginning. So, if I did some stuff in the app (e.g., navigated in bottom nav), then used the notification shade tile to turn on/off dark mode, the app would draw the right theme, but my changes would be undone (e.g., I would be back at the default bottom nav location).

Eventually, after some debugging, I discovered that remember() seemed to stop working. 😮

@Composable
fun MainScreen() {
    MyTheme {
        val uuid = remember { UUID.randomUuid() }

        BasicText("Um, hi! My name is: $uuid")
    }
}

Here, I remember a generated UUID. That should survive recomposition. For most things, it would – I could rotate the screen without issue. But if I changed theme, I would get a fresh UUID.

🧐

Much debugging later, I realized the problem.

Let’s go back to the MyTheme() implementation:

@Composable
fun MyTheme(content: @Composable () -> Unit) {
    if (isSystemInDarkTheme()) MyDarkTheme(content) else MyLightTheme(content)
}

When I toggle dark mode, my use of isSystemInDarkTheme() triggers a recomposition. Let’s suppose that isSystemInDarkTheme() originally returned false, then later returns true on the recomposition. The false meant that my original composition of MyTheme() went down the MyLightTheme() branch. The later recomposition takes me down the MyDarkTheme() branch. Compose treats those as separate compositions. MyTheme() is recomposing, but it is doing so by discarding the MyLightTheme() composition and creating a new MyDarkTheme() composition. It does not matter whether content would generate the same composition nodes or not — the change in the root from MyLightTheme() to MyDarkTheme() causes the swap in compositions.

My uuid is in the content lambda expression. When we dispose of the MyLightTheme() composition and switch to the MyDarkTheme() composition, we start over with respect to the remember() call, and I wind up with a fresh random UUID.

One workaround is to “lift the if”, blending Alex’s original approach with mine:

@Composable
fun MyTheme(content: @Composable () -> Unit) {
    val theme = if (isSystemInDarkTheme()) MyDarkTheme else MyLightTheme

    theme(content)
}

This does the same thing, but Compose treats this as a single changed composition, and the remember() is retained. To be honest, I am not completely clear why this workaround works. This is still magic to me, though I am certain that there are others for whom the reasoning is clear.

This is the sort of thing that we have to watch out for when working in Compose. Compose is a principled framework, but the Principle of Least Surprise is not always followed… at least for those among us who are not magicians.

Sep 13, 2024


Requiem for a Ranch

While elements will still remain, to an extent it appears that Big Nerd Ranch is locking up the corral for good.

A long long time ago, I was their original Android app development trainer. Working on a contract basis, I delivered training in Historic Banning Mills (“come for the training, stay for the zipline!”) on many occasions. That included leading some nature walks during breaks, helping folks figure out how to get cell service (tip: walk up a steep hill to get out of the valley), and wrangling the occasional piece of luggage. Banning Mills was an unexpectedly delightful spot to deliver training, one of the most entertaining locations that I ever used.

Eventually, Big Nerd Ranch concluded that this Android thing might pan out, so they hired dedicated staff and I moved on to deliver training through a series of other firms. Over time, they wrote their own set of books, and you’ll probably recognize a few of the authors for their contributions to the Android development community.

I remain grateful to Aaron Hillegass for taking a chance on me and giving me the opportunity to deliver Big Nerd Ranch training. That played a significant role in my overall success with CommonsWare. I even wound up doing some more contract work for them in 2018-19, albeit not in the form of delivering training.

As their announcement post puts it, “The landscape of tech education has evolved significantly since our inception”. That’s putting it mildly. Discontinuing their public bootcamps and their series of books is not surprising — after all, I did much the same thing. Still, it’s tough to see and makes for a somber end to the day.

That said… maybe I’ll pick up a Stetson and put a propeller on the top, for old times’ sake. 🤠

Jun 11, 2024


Random Musings on the Android 15 Beta 2

When Google releases a new beta, I rummage through the API differences report, the high-level overviews, and even the release blog post, to see if there are things that warrant more attention from developers. I try to emphasize mainstream features that any developer might reasonably use, along with things that may not get quite as much attention, because they are buried in the JavaDocs.

This update contained a surprising amount of stuff for a second beta release. Usually by now I have no more random musings, because there is little to review. Technically, though, the first “platform stability” release is June’s, so Google just has some late-breaking changes.

What Might Break You

All apps ought to get private spaces, though most should not have an issue. If you deal with work profiles, are a launcher, or are an app store, you may have additional work to do. You might be interested in the new ACTION_PROFILE_AVAILABLE and ACTION_PROFILE_UNAVAILABLE broadcasts.

If you use NDK code (yourself or via libraries), the 16KB page size support needs to be investigated.

There is a new restriction on activity launches from back in a task’s stack. This appears to be opt-in via android:allowCrossUidActivitySwitchFromBelow. While it is in the “only affects you if you target Android 15” documentation, it is unclear if that affects the app doing the blocking, the app that might be blocked, both, or none (i.e., the docs are in the wrong spot). There are a variety of other restrictions on semi-background activity starts that hopefully won’t affect you.

screenWidthDp and screenHeightDp on Configuration now include the depth of the system bars.

What Might Break You Next Year

The 6-hour-maximum foreground service status for dataSync and mediaProcessing services will only kick in once you target Android 15 or higher.

Similarly, the boot-time restrictions on what foreground services you can start only appear when you target Android 15 or higher.

Your TextViews may add more end padding once you target Android 15, to better accommodate varying fonts and languages.

What Makes Me Go 🤨

They tightened some unsafe Intent structures, but made it opt in via StrictMode and possibly targeting Android 15.

There is a new contentSensitivity attribute for View, though it is unclear what it controls.

There is a new shouldDefaultToObserveMode attribute, probably for <service>… but we are not told what “observe mode” is.

There is a new systemUserOnly attribute for all components (activities, services, etc.). This feels like it is tied to private spaces, to have some component be ineligible for use in a private space, but that’s just a guess, because it is poorly documented.

There is a new form of requestPermissions() that takes a device ID, and it is unclear what the “device” is in this context.

There is a new registerResourcePaths() method on Resources, which seems like it allows for dynamically adding new resources, such as from some sort of library.

PowerMonitor “represents either an ODPM rail (on-device power rail monitor) or a modeled energy consumer”, which I am certain makes some sense to somebody.

There is a new form of RemoteViews that works off of DrawInstructions, but it is completely non-obvious how you create those instructions.

What Else Helps with Security

You can place an android:requireContentUriPermissionFromCaller attribute on your <activity> element to enforce that the activity that starts yours and passes a Uri has certain permissions with respect to that Uri. Similarly, you can use checkContentUriPermissionFull() on Context to see if some other app and/or user has rights to a particular Uri.

What Seems Nice

You can positively state what language your plain res/values/strings.xml file is in via a defaultLocale attribute. Presumably this goes on <application> given its scope, but as is all too typical, that is undocumented.

You now finally can find out what activity created yours, via getInitialCaller(). There is also getCurrentCaller(), which handles both onNewIntent() and onActivityResult() cases. Finally, there is a getCaller() method, but it is unclear how that differs from getInitialCaller().

You can create customized previews for your app widgets via methods like setWidgetPreview() on AppWidgetManager.

You can opt into custom vibration effects on a NotificationChannel.

You can add up to 32 “debug tags” to a JobScheduler job.

What Is Back From the Dead, Only To Die Again

Slices – a new UI option added several years ago that nobody every really seemed to use – got some API changes! Alas, those changes are deprecation notices. I once again apologize to those who attended a conference presentation on slices that I delivered years ago.

What Else Caught My Eye

There are a bunch of new permissions related to device policy controllers, such as MANAGE_DEVICE_POLICY_BLOCK_UNINSTALL.

There are new options on WindowManager for “small cover screen” UIs.

May 18, 2024


Random Musings on the Android 15 Beta 1

When Google releases a new beta, I rummage through the API differences report, the high-level overviews, and even the release blog post, to see if there are things that warrant more attention from developers. I try to emphasize mainstream features that any developer might reasonably use, along with things that may not get quite as much attention, because they are buried in the JavaDocs.

As often happens with the first beta, we had a bunch of stuff deleted, meaning it shipped in a developer preview then was removed. My assumption is that these represent things that did not quite “make the cut” and are going to be revisited.

That said, we did get a decent bunch of new things as well.

What Might Break You Next Year

Edge-to-edge will be enabled by default on Android 15, for apps that target Android 15. This means that new apps should be built from the ground up to be edge-to-edge. Ideally, existing apps that do not fully support edge-to-edge should take the next 1.5 years to adopt an edge-to-edge presentation. There is a new android:windowOptOutEdgeToEdgeEnforcement attribute, probably for <activity>, that you can set to true to perhaps buy more time. However, the docs for that attribute say that it will be deprecated and disabled “in a future SDK level”. If they do that in next year’s Android 16, the attribute will not buy you any meaningful time. Frankly, Google’s penchant for mandating their particular preferred aesthetics is annoying — as somebody told me on Stack Overflow over a decade ago, if I wanted somebody forcing their designs on me, I’d be programming for iOS.

What Makes Me Go 🤨

They added E2eeContactKeysManager. On the one hand, it provides first-class support for end-to-end encryption keys. On the other hand, it is tied to the user’s contacts app, which seems rather limiting.

They added cover screen support in an earlier developer preview, and the docs still refer to it. But they removed the actual property (COMPAT_SMALL_COVER_SCREEN_OPT_IN) from the SDK. My guess is that the docs are wrong and this feature was removed.

WindowManager now has the concept of “trusted presentations”. Basically, you can find out if your window is only partially shown or is being shown mostly translucent. For a multi-window environment, I can see the value in knowing these things, as it might impact how often you update your UI, or you might pause media playback. The “trusted”, though, makes me wonder if there is a security aspect.

They re-added FINGERPRINT_SERVICE.

The blog post’s section on “Secured background activity launches” doesn’t seem to point to anything new to Beta 1, so I am uncertain what they are referring to.

What Has Nothing to Do With Police Procedural Dramas

We now have access to a ProfilingManager system service for requesting certain types of profiling, including heap dumps and system traces.

What Else Is Interesting

ACTION_CHOOSER, which powers the “share sheet”, now supports EXTRA_CHOOSER_CONTENT_TYPE_HINT. The one documented value for this hint is CHOOSER_CONTENT_TYPE_ALBUM, to hint that the content being shared represents an “album”.

There is a new SecurityStateManager system service, which can return the system patch level and kernel version.

A View can now have an associated “credential request” tied to CredentialManager.

Wallet apps can now be set as the default recipient of NFC contactless payments via a new wallet role.

The concept of parent and child activities is being unwound: getParent() and isChild() on Activity are deprecated. There are better solutions for this nowadays, as multi-activity UIs slowly fade from existence.

Apr 13, 2024


Older Posts