Android use viewbinding, without 'binding.' keyword within class globally

84 Views Asked by At

After enabling viewbinding using:

android {
    ...
    buildFeatures {
        viewBinding = true
    }
}

We can use ViewBinding, the problem is that we need to use binding. prefix before accesing each view. Is there a way we can ommit this prefix, and have direct access of the views from the class?

So instead of:

binding.textview1.text = viewModel.name
binding.button1.setOnClickListener { viewModel.userClicked() }

To use directly:

textview1.text = viewModel.name
button1.setOnClickListener { viewModel.userClicked() }

Obviously we can use with(binding) and use with each method, but is there a way to apply this for the whole class globally?

fun method = with(binding){
    // now we can access the viewbinding
    textview1.text = viewModel.name
    button1.setOnClickListener { viewModel.userClicked() }
}


Are there any binding settings, were we can specify for the compiler to generate the Interface from the layout:

So it will auto gnerate:


public interface Fragment1BindingInterface {

  @NonNull
  TextView getTextView1();
}

public final class Fragment1Binding implements ViewBinding, Fragment1BindingInterface {
  @NonNull
  private final RelativeLayout rootView;

  @Override
  @NonNull
  private final TextView _textView1;

  @Override
  @NonNull
  public TextView getTextView1() {
    return _textView1;
  }
}

Then we can implement Fragment1BindingInterface, in out fragment class and have direct access to all views, without using binding. every time. Although this will expose the views publicly, but anyway just asking if it is possible or not?

2

There are 2 best solutions below

2
On

It's probably not what you are looking for but maybe you think it's an improvement anyway, and that is to list all views that you want at the top of your class like

private val textview1 by lazy { binding.textview1 }
private val button1 by lazy { binding.button1 }

and then you can use them just like that as you want inside of the class.

0
On

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:

  1. Find every new view ID using Regex
  2. For each ID generate an extension property for the current activity
  3. 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.