Widgets can look great against a home screen wallpaper when they have a solid background (check out my article Widgets with Glance: Blending in to see how to pick a color that matches the app icons) but what if instead the background is transparent? It looks fine if the text or graphics are a good contrast from the wallpaper:
But what about if the wallpaper is not a good contrast? How do you choose a suitable color?
Even if you are using dynamic colors in your GlanceTheme
(as I am in the image above), the theme system won’t automatically check for contrast against the background. So we must do this ourselves.
First thing, we need to detect the device wallpaper. This can be done using the WallpaperManager API.
First, get the WallpaperManager
instance, then fetch the dominant colors. A list is available, arranged in order of priority (note: a minimum color occurrence percentage MIN_COLOR_OCCURRENCE
— 5% by default — is applied for the color to appear in this list), from here we need to get the primary color and decide whether dark or light text should be used.
This can be added to the GlanceTheme
and initialised in a boolean state variable that can be then passed into the composable content
.
@Composable | |
fun MotivateMeGlanceTheme( | |
context: Context, | |
content: @Composable (Boolean) -> Unit, | |
) { | |
val wallpaperManager = WallpaperManager.getInstance(context) | |
val colors = wallpaperManager.getWallpaperColors(FLAG_SYSTEM) | |
var useDarkColorOnWallpaper by remember { | |
mutableStateOf( | |
getUseDarkColorOnWallPaper(colors, FLAG_SYSTEM) ?: false | |
) | |
} | |
GlanceTheme( | |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { | |
GlanceTheme.colors | |
} else { | |
MotivateMeGlanceColorScheme.colors | |
} | |
) { | |
content.invoke(useDarkColorOnWallpaper) | |
} | |
} |
In the above code we can get the wallpaper colors using
wallpaperManager.getWallpaperColors(FLAG_SYSTEM)
FLAG_SYSTEM
indicates we want the colors for the home screen — passing in FLAG_LOCK
would give the colors of the lock screen.
An important thing to note is that getWallpaperColors
is limited to API 27
and above so you can either update the minimumSdk
of the app to 27
or surround this with an version check if statement.
To detect whether to use dark or light text, we can use a utility function getUseDarkColorOnWallPaper
. In this we can use the wallpaper colors colorHints
to check if we should use dark text with the WallpaperColors.HINT_SUPPORTS_DARK_TEXT
flag. As per the API documentation, HINT_SUPPORTS_DARK_TEXT
:
Specifies that dark text is preferred over the current wallpaper for best presentation.
eg. A launcher may set its text color to black if this flag is specified.
There is also HINT_SUPPORTS_DARK_THEME
which could also be useful for a widget with a solid background to detect whether a dark or light background would be preferable.
Using HINT_SUPPORTS_DARK_TEXT
and colorHints
:
fun getUseDarkColorOnWallpaper(colors: WallpaperColors?, type: Int): Boolean? { | |
return if (type and FLAG_SYSTEM != 0 && colors != null) { | |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { | |
(colors.colorHints) and WallpaperColors.HINT_SUPPORTS_DARK_TEXT != 0 | |
} else { | |
val hsv = FloatArray(3) | |
val primaryColor = colors.primaryColor.toArgb() | |
RGBToHSV( | |
primaryColor.red, | |
primaryColor.green, | |
primaryColor.blue, | |
hsv | |
) | |
!colorIsDarkAdvanced(primaryColor) | |
} | |
} else { | |
null | |
} | |
} |
colorHints
is only available in Android 12 and above, if we are using a lower version a more manual approach is required. For this, we get the primary color as a HSV value and then evaluate the intensity and contrast in another utility function.
Note: I did not originally write this code, I found it on this StackOverflow answer from SudoPlz. You could replace this with whichever algorithm you prefer.
fun colorIsDarkAdvanced(bgColor: Int): Boolean { | |
// hexToB | |
val uicolors = doubleArrayOf( | |
bgColor.red.toDouble() / 255.0, | |
bgColor.green.toDouble() / 255.0, | |
bgColor.blue.toDouble() / 255.0 | |
) | |
val c = uicolors.map { col -> | |
if (col <= 0.03928) { | |
col / 12.92 | |
} else { | |
Math.pow((col + 0.055) / 1.055, 2.4) | |
} | |
} | |
val L = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2] | |
return L <= 0.179 | |
} |
Job Offers
Now that we can tell if we should use dark or light text on widget creation, we need to ensure that whenever the wallpaper is changed the color is checked and the widget theme is updated.
To do this we can create a WallpaperManager.OnColorsChangedListener
in a DisposableEffect
:
@Composable | |
fun MotivateMeGlanceTheme( | |
context: Context, | |
content: @Composable (Boolean) -> Unit, | |
) { | |
val wallpaperManager = WallpaperManager.getInstance(context) | |
val colors = wallpaperManager.getWallpaperColors(FLAG_SYSTEM) | |
var useDarkColorOnWallpaper by remember { | |
mutableStateOf( | |
getUseDarkColorOnWallpaper(colors, FLAG_SYSTEM) ?: false | |
) | |
} | |
DisposableEffect(wallpaperManager) { | |
val listener = WallpaperManager.OnColorsChangedListener { colors, type -> | |
getUseDarkColorOnWallpaper(colors, type)?.let { | |
useDarkColorOnWallpaper = it | |
} | |
} | |
wallpaperManager.addOnColorsChangedListener( | |
listener, | |
Handler(Looper.getMainLooper()) | |
) | |
onDispose { | |
wallpaperManager.removeOnColorsChangedListener(listener) | |
} | |
} | |
... | |
} |
Now, every time the wallpaper is changed the widget will update!
To see a full example, see my sample widget app:
Check out my article Widgets with Glance: Blending in to see how to pick a color that matches the app icons and device dynamic colours.
This article is previously published on proandroiddev.com.