You can make the build system generate properties to access these views without specifying binding
explicitly. As an example, I will post how to do this for a default Empty Views Activity project with Kotlin as language and Kotlin DSL as build configuration language.
Let's start by changing layout/activity_main.xml
to add some example views.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/textView0"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/button0"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView0" />
<TextView
android:id="@+id/textView1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@+id/button0"
app:layout_constraintEnd_toStartOf="@+id/textView0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/textView0" />
</androidx.constraintlayout.widget.ConstraintLayout>
Now, let's make sure the view binding works. In app/build.gradle.kts
introduce the viewBinding feature.
android {
buildFeatures {
viewBinding = true
}
}
And inflate binding
in the MainActivity
accordingly:
package com.example.app
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.example.app.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
}
}
You are probably at a similar point in your original project. The following code is the solution of the real problem.
The next step is to append the following code to app/build.gradle.kts
.
abstract class ActivityExtensionsTask : DefaultTask() {
@get:Input
var buildMode = ""
@get:Input
var generatedCodeDirPath = ""
@get:Input
var layoutDirPath = ""
@get:Input
var sourceDirPath = ""
private val lastPackage = "activityExtensions"
private val layoutDir by lazy {
File(layoutDirPath).also { dir ->
if (!dir.exists()) {
throw GradleException("Layout directory does not exist")
}
}
}
private val sourceDir by lazy {
File(sourceDirPath).also { dir ->
if (!dir.exists()) {
throw GradleException("Source directory does not exist")
}
}
}
private fun findActivity(activity: String): File {
val targetName = "$activity.kt"
return sourceDir.walkTopDown().find { file ->
file.name == targetName
} ?: throw GradleException("Activity file $targetName not found")
}
private fun getExtensionProperty(activity: String, id: String) =
"val $activity.$id get() = binding.$id"
private fun getOutputFile(activity: String, packageName: String): File {
val output = File(generatedCodeDirPath)
.resolve("activity_extensions/$buildMode/out")
.resolve(packageName.replace(".", "/"))
.resolve(lastPackage)
.resolve("$activity.kt")
.absoluteFile
output.parentFile.mkdirs()
return output
}
private fun getPackageName(activity: File): String {
val source = activity.readText(charset = Charsets.UTF_8)
return Regex("package\\s*(.*)").find(source)?.groups?.get(1)?.value?.trimEnd()
?: throw GradleException("Could not find package name in ${activity.name}")
}
private fun processLayout(layoutFile: File) {
println("Processing layout: $layoutFile")
val activity = layoutFile.nameWithoutExtension.split("_").map { part ->
part.replaceFirstChar { c ->
if (c.isLowerCase()) c.titlecase(Locale.getDefault()) else c.toString()
}
}.reversed().joinToString("")
val activityFile = findActivity(activity)
val packageName = getPackageName(activityFile)
val source = layoutFile.readText(charset = Charsets.UTF_8)
val props = Regex("android:id=\"@\\+id/(\\w+)\"").findAll(source)
.mapNotNull { matchResult ->
when (val id = matchResult.groups[1]?.value) {
null -> null
else -> getExtensionProperty(activity, id)
}
}
val outputFile = getOutputFile(activity, packageName)
writeOutput(activity, outputFile, packageName, props)
}
private fun writeOutput(
activity: String, output: File, packageName: String, props: Sequence<String>
) {
output.writeText("""
#package $packageName.$lastPackage
#
#import $packageName.$activity
#
#${props.joinToString("\n")}
""".trimMargin("#")
)
println("Code generated: $output")
}
@TaskAction
fun run() {
println("Build mode: $buildMode")
println("Generated code dir: $generatedCodeDirPath")
println("Layout dir: $layoutDirPath")
println("Source dir: $sourceDirPath")
layoutDir.listFiles()?.forEach { layoutFile ->
if (layoutFile.name.contains("activity", ignoreCase = true))
processLayout(layoutFile)
}
}
}
val activityExtensionsTask = "activityExtensionsTask"
val appBuildMode = "debug"
tasks.register<ActivityExtensionsTask>(activityExtensionsTask) {
buildMode = appBuildMode
generatedCodeDirPath = buildDir.resolve("generated").absolutePath
layoutDirPath = projectDir.resolve("src/main/res/layout").absolutePath
sourceDirPath = projectDir.resolve("src/main/java").absolutePath
}
tasks.named("preBuild") {
dependsOn(activityExtensionsTask)
}
android {
sourceSets {
named("main") {
java.srcDir("$buildDir/generated/activity_extensions/$appBuildMode/out")
}
}
}
The code above will run a new Gradle task named activityExtensionsTask
on each build (in Android Studio CTRL + F9). It will look for every activity layout and process it in the following way:
- Find every new view ID using Regex
- For each ID generate an extension property for the current activity
- Generate code inside
app/build/generated
with package similar to the activity package but with string activityExtensions
appended to it. This is a similar naming convention that is used by the standard databinding package.
After the build is complete we should see a file com.example.app.activityExtensions.MainActivity.kt
with the following content:
package com.example.app.activityExtensions
import com.example.app.MainActivity
val MainActivity.textView0 get() = binding.textView0
val MainActivity.button0 get() = binding.button0
val MainActivity.textView1 get() = binding.textView1
When you open the file in Android Studio, you should see a warning:
Files under the "build" folder are generated and should not be edited.
Note that the last call in the app/build.gradle.kts
adds the generated files to the source sets. Without this, the files won't compile. You can move the call inside the first trailing lambda of the android
object on the top of the file if you'd like.
Now we can import the generated code into the activity and access the views without accessing binding
explicitly.
package com.example.app
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.example.app.activityExtensions.*
import com.example.app.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
button0.text = "Extension changed this text"
textView0.text = "Changed from extension"
textView1.text = "Extension changed this text"
}
}
Note that the only thing we had to modify inside the activity that uses binding
explicitly was to import com.example.app.activityExtensions.*
and remove the binding
in the access code.
There are features that can be added to this code:
- Define the binding in the generated code instead of inside the activity. This would also require to import
com.example.app.databinding.ActivityMainBinding
from the generated code.
- The generated code doesn't change on
Sync
so you can see errors if you change the layout and don't build the app. This can be probably fixed by adding a dependency on the sync
task to the activityExtensions
task.
onViewCreated()
anyway. – Clipfed