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
:
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