Implementing Night Mode in Android is pretty straightforward: you have a theme with attributes and you can just define those attributes in the values-night
qualifier and that's it. Five minutes and you are done. Well...
Most likely, reality is different: you have a project 4-5 years old, you don't even have a second theme, you have been using hardcoded colors #FFFFFF
everywhere, maybe at times you felt brave and used instead a reference color @color/white
. And maybe some other times you had to tint something programmatically with your friend ContextCompat.getColor(context, R.color.white)
to tint that icon that comes from an API.
So now your code is polluted with things that will be hard to migrate to night: you have white defined directly, as a reference and in code. Guess what, white is not white in night mode anymore :)
And even if you have a brand new project, how do you make sure someone in future will not do the same, how do you enforce that night mode is implemented in every commit/PR? That's where Lint checks come in to help us!
Most of Android developers are familiar with Lint, but just as a refresh, Lint is a static analysis tool which looks for different types of bugs; you can find out more at https://developer.android.com/studio/write/lint.
Android comes with some Lint checks already made for you, but you can extend them and add your own! Here I'm going to show you how to add three Lint checks to your project: one to detect the usage of a direct color (eg. #FFFFFF
), one to detect if you have defined a color and its night mode equivalent, and one to check all those color's name that may be problematic (eg. white, red, green
).
I’ve already wrote a post about writing a custom Lint rule (https://dev.to/dbottillo/how-to-write-a-custom-rule-in-lint-23gf) so I'm going to assume you know how to write one and focus on the specific content of the three rules.
The first two rules are heavily inspired from Saurabh Arora's post: https://proandroiddev.com/making-android-lint-theme-aware-6285737b13bc
Direct Color Detector
The first rule is very simple: you shouldn't use any hardcoded color values like #FFFFFF
.
Let's do a bit of TDD and write a test to validate what we want from the Lint rule:
@Test
fun `should report a direct color violation`() {
val contentFile = """<?xml version="1.0" encoding="utf-8"?>
<View
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/toolbar"
android:background="#453344"
android:foreground="#667788"
android:layout_width="match_parent"
android:layout_height="wrap_content" />"""
TestLintTask.lint()
.files(TestFiles.xml("res/layout/toolbar.xml", contentFile).indented())
.issues(DIRECT_COLOR_ISSUE)
.run()
.expect("""
|res/layout/toolbar.xml:5: Error: Avoid direct use of colors in XML files. This will cause issues with different theme (eg. night) support [DirectColorUse]
| android:background="#453344"
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|res/layout/toolbar.xml:6: Error: Avoid direct use of colors in XML files. This will cause issues with different theme (eg. night) support [DirectColorUse]
| android:foreground="#667788"
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|2 errors, 0 warnings""".trimMargin())
}
That's a lot to get through, let's break it down:
val contentFile = """<?xml version="1.0" encoding="utf-8"?>
<View
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/toolbar"
android:background="#453344"
android:foreground="#667788"
android:layout_width="match_parent"
android:layout_height="wrap_content" />"""
So, here we are defining the content of an hypothetic XML file containing a view tag with few attributes, both background
and foreground
contain an hardcoded color so we should expect two violations for both of them.
Next, we can build a test lint task, which is a class lint provides to test your test files:
TestLintTask.lint()
.files(TestFiles.xml("res/layout/toolbar.xml", contentFile).indented())
With .files()
you can simulate to pass a number of files to lint. Think of them like all your project's file, in this case I'm just passing a fake res/layout/toolbar.xml
whose contentFile
is the xml we defined in the previous section.
TestLintTask.lint()
.files(TestFiles.xml("res/layout/toolbar.xml", contentFile).indented())
.issues(DIRECT_COLOR_ISSUE)
With .issues
you can pass a number of issues lint will use to perform the detection, in this case:
val DIRECT_COLOR_ISSUE = Issue.create("DirectColorUse",
"Direct color used",
"Avoid direct use of colors in XML files. This will cause issues with different theme (eg. night) support",
CORRECTNESS,
6,
Severity.ERROR,
Implementation(DirectColorDetector::class.java, Scope.RESOURCE_FILE_SCOPE)
)
So this is how you can create a new issue in Lint: DirectColorUse
is the id of the issue, then there is a brief description and an explanation, followed by category, priority, severity and the implementation. The DirectColorDetector.kt
file is the one responsible for detection. If you try to run the test of course it will fail since this file doesn't exist yet. Let’s create an empty one so we can run the test:
class DirectColorDetector : ResourceXmlDetector() {
}
Finally at the end of the test we have:
TestLintTask.lint()
.files(TestFiles.xml("res/layout/toolbar.xml", contentFile).indented())
.issues(DIRECT_COLOR_ISSUE)
.run()
.expect("""
|res/layout/toolbar.xml:5: Error: Avoid direct use of colors in XML files. This will cause issues with different theme (eg. night) support [DirectColorUse]
| android:background="#453344"
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|res/layout/toolbar.xml:6: Error: Avoid direct use of colors in XML files. This will cause issues with different theme (eg. night) support [DirectColorUse]
| android:foreground="#667788"
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|2 errors, 0 warnings""".trimMargin())
run()
makes the Lint task running and expect
is a convenient method of the TestLintTask
to assert what's the outcome of running Lint on those files. Here we are expecting those two violations about the background and the foreground values. Of course if you run this test it will fail since we haven't actually implemented DirectColorDetector
yet. Let's do that:
class DirectColorDetector : ResourceXmlDetector() {
override fun getApplicableAttributes(): Collection<String>? = listOf(
"background", "foreground", "src", "textColor", "tint", "color",
"textColorHighlight", "textColorHint", "textColorLink",
"shadowColor", "srcCompat")
override fun visitAttribute(context: XmlContext, attribute: Attr) {
if (attribute.value.startsWith("#")) {
context.report(
DIRECT_COLOR_ISSUE,
context.getLocation(attribute),
DIRECT_COLOR_ISSUE.getExplanation(TextFormat.RAW))
}
}
}
Extending ResourceXmlDetector
means we have a chance to do something when Lint is going through XML
files, and through overriding some methods we can perform our check. Here we are using getApplicableAttributes()
to notify Lint that the detector should be running on those attributes of any XML file. In the visitAttribute
we have a chance to look at the attribute value and if it starts with #
it means we can report a violation. You can do that by using the context
(which is NOT the Android context but the Lint context) and call report
on it with the values of the DIRECT_COLOR_ISSUE
defined before.
Here we go! Now if you run the test it will be green.
Missing Night Color
This check is about having a color defined without a night version. And as we did before, let's first write a test to validate our assumption:
@Test
fun `should report a missing night color violation`() {
val colorFile = TestFiles.xml("res/values/colors.xml",
"""<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="color_primary">#00a7f7</color>
<color name="color_secondary">#0193e8</color>
</resources>""").indented()
val colorNightFile = TestFiles.xml("res/values-night/colors.xml",
"""<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="color_primary">#224411</color>
</resources>""").indented()
TestLintTask.lint()
.files(colorFile, colorNightFile)
.issues(MISSING_NIGHT_COLOR_ISSUE)
.run()
.expect("""
|res/values/colors.xml:4: Error: Night color value for this color resource seems to be missing. If your app supports night theme, then you should add an equivalent color resource for it in the night values folder. [MissingNightColor]
| <color name="color_secondary">#0193e8</color>
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|1 errors, 0 warnings""".trimMargin())
}
Pretty straightforward, the color color_secondary
doesn't have a night definition so it should be reported. Let's have a look at the new Issue:
val MISSING_NIGHT_COLOR_ISSUE = Issue.create("MissingNightColor",
"Night color missing",
"Night color value for this color resource seems to be missing. If your app supports night theme, then you should add an" +
" equivalent color resource for it in the night values folder.",
CORRECTNESS,
6,
Severity.ERROR,
Implementation(MissingNightColorDetector::class.java, Scope.RESOURCE_FILE_SCOPE)
)
This is similar to the previous one, of course we have a different id, description and explanation. I'm re-using the same category, priority and severity but feel free to define your own based on your needs. Last is the implementation that points to a new detector called MissingNightColorDetector
:
class MissingNightColorDetector : ResourceXmlDetector() {
private val nightModeColors = mutableListOf<String>()
private val regularColors = mutableMapOf<String, Location>()
override fun appliesTo(folderType: ResourceFolderType): Boolean {
return folderType == ResourceFolderType.VALUES
}
override fun getApplicableElements(): Collection<String>? {
return listOf("color")
}
override fun afterCheckEachProject(context: Context) {
regularColors.forEach { (color, location) ->
if (!nightModeColors.contains(color))
context.report(
MISSING_NIGHT_COLOR_ISSUE,
location,
MISSING_NIGHT_COLOR_ISSUE.getExplanation(TextFormat.RAW)
)
}
}
override fun visitElement(context: XmlContext, element: Element) {
if (context.getFolderConfiguration()!!.isDefault)
regularColors[element.getAttribute("name")] = context.getLocation(element)
else if (context.getFolderConfiguration()!!.nightModeQualifier.isValid)
nightModeColors.add(element.getAttribute("name"))
}
}
Ok, this is way more complicated than the previous one! The reason is that for this specific detection, we can't report violations straight away: Lint is going through all project's files as if it was a tree. That means that while it's visiting the res/values/colors
folder we don't know anything yet about the res/values-night/colors
which is probably going to be visited later on. So, in this case I'm memorising all the res/values/colors
in a map of color name and location (we need the location to know where the violation is in the file) and all the night colors, then throw a detection at the end of the project evaluation if they don't match. Let's do it step by step:
private val nightModeColors = mutableListOf<String>()
private val regularColors = mutableMapOf<String, Location>()
override fun appliesTo(folderType: ResourceFolderType): Boolean {
return folderType == ResourceFolderType.VALUES
}
override fun getApplicableElements(): Collection<String>? {
return listOf("color")
}
The first two are just internal variables to store the list of night mode colors and the map of colors/location for the regular colors. appliesTo
lets you specify that, among all the xml files, you are only interested in the ResourceFolderType.VALUES
so it will skip drawables, layouts, etc. Finally in the getApplicableElements()
we are telling Lint to call this rule only for the tag color
since we don't care about style
, bool
, etc..
override fun visitElement(context: XmlContext, element: Element) {
if (context.getFolderConfiguration()!!.isDefault)
regularColors[element.getAttribute("name")] = context.getLocation(element)
else if (context.getFolderConfiguration()!!.nightModeQualifier.isValid)
nightModeColors.add(element.getAttribute("name"))
}
visitElement
is the method that we can use when the color
tag is visited, here it first checks the folder configuration (eg. default
or night
). If it's default we add name and location to the map of regular colors, if it's night we just save the color name in the night mode colors list.
override fun afterCheckEachProject(context: Context) {
regularColors.forEach { (color, location) ->
if (!nightModeColors.contains(color))
context.report(
MISSING_NIGHT_COLOR_ISSUE,
location,
MISSING_NIGHT_COLOR_ISSUE.getExplanation(TextFormat.RAW)
)
}
}
Finally with the afterCheckProject
we have another chance to report a violation after the whole project has been visited, here we can loop through all the regular colors and if we don't find the equivalent in the night mode colors list, we can report a violation. Please pay attention to the location parameter in the report
method: we are basically telling Lint where the violation occurred in the file.
Non Semantic Color
The third check is a bit more specific based on the project, but the idea here is that color names like white
or red
shouldn't be used. There is a high probability that white
is not white in night and that red
is maybe red but not that specific red in night. It would be better to use semantic color: instead of red
use maybe error
and instead of white
use surface
. So let's write a new test:
@Test
fun `should report a non semantic color violation`() {
val contentFile = """<?xml version="1.0" encoding="utf-8"?>
<View
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/toolbar"
android:background="@color/white"
android:foreground="@color/red"
android:layout_width="match_parent"
android:layout_height="wrap_content" />"""
TestLintTask.lint()
.files(TestFiles.xml("res/layout/toolbar.xml", contentFile).indented())
.issues(NON_SEMANTIC_COLOR_ISSUE)
.run()
.expect("""
|res/layout/toolbar.xml:5: Error: Avoid non semantic use of colors in XML files. This will cause issues with different theme (eg. night) support. For example, use primary instead of black. [NonSemanticColorUse]
| android:background="@color/white"
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|res/layout/toolbar.xml:6: Error: Avoid non semantic use of colors in XML files. This will cause issues with different theme (eg. night) support. For example, use primary instead of black. [NonSemanticColorUse]
| android:foreground="@color/red"
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|2 errors, 0 warnings""".trimMargin())
}
This is very similar to the first test that we wrote, so I'm not going through the details, we are expecting two violations because background is using @color/white
and foreground is using @color/red
.
val NON_SEMANTIC_COLOR_ISSUE = Issue.create("NonSemanticColorUse",
"Non semantic color used",
"Avoid non semantic use of colors in XML files. This will cause issues with different theme (eg. night) support. " +
"For example, use primary instead of black.",
CORRECTNESS,
6,
Severity.ERROR,
Implementation(NonSemanticColorDetector::class.java, Scope.RESOURCE_FILE_SCOPE)
)
Again, nothing new here, it's just a new issue definition for NonSemanticColorUse
, let's jump to the detector:
class NonSemanticColorDetector : ResourceXmlDetector() {
override fun getApplicableAttributes(): Collection<String>? = listOf(
"background", "foreground", "src", "textColor", "tint", "color",
"textColorHighlight", "textColorHint", "textColorLink",
"shadowColor", "srcCompat")
override fun visitAttribute(context: XmlContext, attribute: Attr) {
if (checkName(attribute.value)) {
context.report(
NON_SEMANTIC_COLOR_ISSUE,
context.getLocation(attribute),
NON_SEMANTIC_COLOR_ISSUE.getExplanation(TextFormat.RAW))
}
}
private fun checkName(input: String): Boolean {
return listOf("black", "blue", "green", "orange",
"teal", "white", "orange", "red").any {
input.contains(it)
}
}
}
This detector is very similar to the first detector about hardcoded colors: we just define which attributes we are interested in and with the visitAttribute
you can check the attribute value and if it contains any of black,blue,green,etc..
we report the violation. I also want to mention that this will work for images as well: if you have something like app:srcCompat="@drawable/ic_repeat_black_24dp"
it will report and for a good reason! If you don't tint that image then it may not work in night.
Conclusion
With the three new lint checks if you now run Lint you will find them in the reports:
Of course, this is a starting point, you can decide to go through them and fix all of them in one go or just have a sanity check on how far you are to fix all the potential issue while implementing night mode. My suggestion is to make them not an error in the beginning, otherwise it will fail all your builds, and turning them into error only when you have fixed all of them. Turning them into error helps prevent new code and feature to be added to the project without night mode in mind preventing that to go into the build.
Tip: you can suppress them as you do with any other lint rule:
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:srcCompat="@drawable/ic_repeat_black_24dp"
tools:ignore="NonSemanticColorUse" />
Finally, you can find a full implementation on one of my app here: https://github.com/dbottillo/MTGCardsInfo/commit/9bfd1e051f5c264cb7b5316efd50c6af9723b922
Happy coding!
Top comments (8)
Nice post!
Another cool check, for projects following Material guidelines, would be avoiding using color resources instead of attributes, for instance:
android:textColor="@color/black"
instead ofandroid:textColor="?android:textColorPrimary"
.This would apply to layouts, drawables, colors and styles files.
What do you think? :)
yeah I think it would totally make sense! Or even in general not just for Material guidelines, I think checking for using attributes reference instead of color reference should be a good a thing regardless!
So I decided to create some lint rules just for fun and - who knows - to use them in my code template :)
I'm struggling with a detail (so I hope):
dependencies {
compileOnly "com.android.tools.lint:lint-api:26.6.3"
testImplementation "com.android.tools.lint:lint-tests:26.6.3"
testImplementation "com.android.tools.lint:lint:26.6.3"
}
If I turn off
android.enableJetifier
it gets worst. Any idea how to solve it?Running
gradle assemble
I get this:Execution failed for task ':lint-rules:compileDebugKotlin'.
Thanks for your time.
Hi there, I'm not exactly sure what could be the problem there. Did you try on a simple/new project from Android Studio? maybe there is some other dependency interfering!
I've found out!
My build gradle was using apply plugin: 'com.android.library' instead of java-library '^^
nice! I'm glad you fixed it :)
your repository shows error on Android Studio 4.1 Canary 7 and Android Studio 3.6.3
can you help with this?
dev-to-uploads.s3.amazonaws.com/i/...
Unfortunately I have the same issue with AS, for some reasons it doesn't take in consideration that lint check properly. I disable it on my AS for now, thinking to open a bug ticket on AS !