Several usage scenarios of Kotlin delegate attribute in Android Development

Posted by BMN on Tue, 25 Jan 2022 19:21:37 +0100

Kotlin is really a beautiful development language. She has some great features that make Android development interesting and exciting. Delegate properties Is one of them. In this article, we will see how entrustment makes Android development easier.

Basics

First, what is delegation? How does it work? Although delegation seems magical, it is not as complex as expected.

A delegate is a class that provides values for attributes and handles changes in values. This allows us to move the getter setter logic of the attribute from the place where the attribute is declared to (or delegate to) another class to achieve the purpose of logical reuse.

For example, we have a String type attribute param. The value of this attribute needs to remove the leading and trailing spaces (trim). We can do this in the setter of the property:

class Example {
    var param: String = ""
        set(value) {
            field = value.trim()
        }
}

If you are not familiar with grammar, you can refer to the Kotlin documentation Attribute part.

What if we want to reuse this logic in other classes? This is the turn of the Commission.

class TrimDelegate : ReadWriteProperty<Any?, String> {

    private var trimmedValue: String = ""

    override fun getValue(
        thisRef: Any?,
        property: KProperty<*>
    ): String {
        return trimmedValue
    }

    override fun setValue(
        thisRef: Any?,
        property: KProperty<*>, value: String
    ) {
        trimmedValue = value.trim()
    }
}

A delegate is a class with two methods (reading and setting the value of a property). More specifically, the example of the KProperty class represents the delegated attribute, and thisRef is the object that owns this attribute. That's it. We can use the delegate just created in this way:

class Example {
    //Use the by keyword
    var param: String by TrimDelegate()
}

The above code has the same effect as the following code:

class Example {

    private val delegate = TrimDelegate()

    var param: String
        get() = delegate.getValue(this, ::param)
        set(value) {
            delegate.setValue(this, ::param, value)
        }
}

:: param is an operator that can return a KProperty instance for a property.

As you can see, delegate properties are not magical. However, although it is simple, it is very useful. Let's take a look at some examples in Android development.

You can Official documents Learn more about delegate properties in.

Pass parameters to Fragment

We often need to pass some parameters to Fragment, which usually looks like this:

class DemoFragment : Fragment() {
    private var param1: Int? = null
    private var param2: String? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        arguments?.let { args ->
            param1 = args.getInt(Args.PARAM1)
            param2 = args.getString(Args.PARAM2)
        }
    }
    companion object {
        private object Args {
            const val PARAM1 = "param1"
            const val PARAM2 = "param2"
        }

        fun newInstance(param1: Int, param2: String): DemoFragment =
            DemoFragment().apply {
                arguments = Bundle().apply {
                    putInt(Args.PARAM1, param1)
                    putString(Args.PARAM2, param2)
                }
            }
    }
}

We pass the parameters to the newInstance method used to create the Fragment instance. In the method, we pass the parameters to the arguments of the Fragment so that they can be obtained in onCreate.

We can move the logic related to arguments into the getter and setter of attributes to make the code look better.

class DemoFragment : Fragment() {
    private var param1: Int?
        get() = arguments?.getInt(Args.PARAM1)
        set(value) {
            value?.let {
                arguments?.putInt(Args.PARAM1, it)
            } ?: arguments?.remove(Args.PARAM1)
        }
    private var param2: String?
        get() = arguments?.getString(Args.PARAM2)
        set(value) {
            arguments?.putString(Args.PARAM2, value)
        }
    companion object {
        private object Args {
            const val PARAM1 = "param1"
            const val PARAM2 = "param2"
        }
        fun newInstance(param1: Int, param2: String): DemoFragment =
            DemoFragment().apply {
                this.param1 = param1
                this.param2 = param2
            }
    }
}

But we still have to write duplicate code for each attribute. If there are too many attributes, it will be too cumbersome, and too much code related to arguments looks too messy.

So is there another way to further beautify the code? The answer is yes. As you guessed, we'll use delegate properties.

First, we need to make some preparations.

Fragment's arguments are stored with a Bundle object, which provides many methods for storing different types of values. So let's write an extension function to store a certain type of value in the Bundle and throw an exception when the type is not supported.

fun <T> Bundle.put(key: String, value: T) {
    when (value) {
        is Boolean -> putBoolean(key, value)
        is String -> putString(key, value)
        is Int -> putInt(key, value)
        is Short -> putShort(key, value)
        is Long -> putLong(key, value)
        is Byte -> putByte(key, value)
        is ByteArray -> putByteArray(key, value)
        is Char -> putChar(key, value)
        is CharArray -> putCharArray(key, value)
        is CharSequence -> putCharSequence(key, value)
        is Float -> putFloat(key, value)
        is Bundle -> putBundle(key, value)
        is Parcelable -> putParcelable(key, value)
        is Serializable -> putSerializable(key, value)
        else -> throw IllegalStateException("Type of property $key is not supported")
    }
}

Next, we can create the delegate.

class FragmentArgumentDelegate<T : Any> 
    :ReadWriteProperty<Fragment, T> {

    @Suppress("UNCHECKED_CAST")
    override fun getValue(
        thisRef: Fragment,
        property: KProperty<*>
    ): T {
        //key is the attribute name
        val key = property.name
        return thisRef.arguments
            ?.get(key) as? T
            ?: throw IllegalStateException("Property ${property.name} could not be read")
    }

    override fun setValue(
        thisRef: Fragment,
        property: KProperty<*>, value: T
    ) {
        val args = thisRef.arguments
            ?: Bundle().also(thisRef::setArguments)
        val key = property.name
        args.put(key, value)
    }
}

The delegate reads the value from the arguments of the Fragment. When the attribute value changes, it will get the arguments of the Fragment (if not, it will create a new one and set it to the Fragment), and then use the extension function bundle Put stores the new value.

ReadWriteProperty is a generic interface that receives two type parameters. We set the first one to Fragment, which ensures that this delegate can only be used for Fragment properties. This allows us to get the Fragment instance and manage its arguments through thisRef.

Since we use the name of the attribute as the key when storing arguments, we no longer need to write the key as a constant.

The second type parameter of ReadWriteProperty determines which types of values this property can have. We set this type to non null and throw an exception when it cannot be read, which allows us to get non null values in the Fragment and avoid null value checking.

But sometimes we do need some properties to be nullable, so let's create another delegate and return null instead of throwing an exception when no value is found in the arguments.

class FragmentNullableArgumentDelegate<T : Any?> :
    ReadWriteProperty<Fragment, T?> {

    @Suppress("UNCHECKED_CAST")
    override fun getValue(
        thisRef: Fragment,
        property: KProperty<*>
    ): T? {
        val key = property.name
        return thisRef.arguments?.get(key) as? T
    }

    override fun setValue(
        thisRef: Fragment,
        property: KProperty<*>, value: T?
    ) {
        val args = thisRef.arguments
            ?: Bundle().also(thisRef::setArguments)
        val key = property.name
        value?.let { args.put(key, it) } ?: args.remove(key)
    }
}

Next, for convenience, we create some functions (not necessary, just to beautify the code):

fun <T : Any> argument(): ReadWriteProperty<Fragment, T> =
    FragmentArgumentDelegate()

fun <T : Any> argumentNullable(): ReadWriteProperty<Fragment, T?> =
    FragmentNullableArgumentDelegate()

Finally, let's use delegation:

class DemoFragment : Fragment() {
    private var param1: Int by argument()
    private var param2: String by argument()
    companion object {
        fun newInstance(param1: Int, param2: String): DemoFragment =
            DemoFragment().apply {
                this.param1 = param1
                this.param2 = param2
            }
    }
}

SharedPreferences delegation

We often need to store some data so that we can quickly get it when the App starts next time. For example, we may want to store some user preferences so that users can customize the functions of the application. A common approach is to use shared preferences to store key value pairs.

Suppose we have a user class that reads and stores three parameters:

class Settings(context: Context) {

    private val prefs: SharedPreferences = 
        PreferenceManager.getDefaultSharedPreferences(context)

    fun getParam1(): String? {
        return prefs.getString(PrefKeys.PARAM1, null)
    }

    fun saveParam1(param1: String?) {
        prefs.edit().putString(PrefKeys.PARAM1, param1).apply()
    }

    fun getParam2(): Int {
        return prefs.getInt(PrefKeys.PARAM2, 0)
    }

    fun saveParam2(param2: Int) {
        prefs.edit().putInt(PrefKeys.PARAM2, param2).apply()
    }

    fun getParam3(): String {
        return prefs.getString(PrefKeys.PARAM3, null) 
            ?: DefaulsValues.PARAM3
    }

    fun saveParam3(param3: String) {
        prefs.edit().putString(PrefKeys.PARAM2, param3).apply()
    }

    companion object {
        private object PrefKeys {
            const val PARAM1 = "param1"
            const val PARAM2 = "param2"
            const val PARAM3 = "special_key_param3"
        }

        private object DefaultValues {
            const val PARAM3 = "defaultParam3"
        }
    }
}

Here, we get the default SharedPreferences and provide the values of the parameters read and stored by the method user. We also made param3 special -- it uses special keys and has a non-standard default value.

Once again, we see that we have written duplicate code. Of course, we can move the duplicate logic into the method, but we will still leave very heavy code. Besides, what if we want to reuse this logic in other classes? Let's take a look at how delegates simplify code.

In order to make things more interesting, we try a slightly different way. This time we will use Object expression And create an extension function for SharedPreferences.

fun SharedPreferences.string(
    defaultValue: String = "",
    key: (KProperty<*>) -> String = KProperty<*>::name
): ReadWriteProperty<Any, String> =
    object : ReadWriteProperty<Any, String> {
        override fun getValue(
            thisRef: Any,
            property: KProperty<*>
        ) = getString(key(property), defaultValue)

        override fun setValue(
            thisRef: Any,
            property: KProperty<*>,
            value: String
        ) = edit().putString(key(property), value).apply()
    }

Here, we create the extension function of SharedPreferences, which returns an object of ReadWriteProperty subclass as our delegate.

This delegate uses the value provided by the function key as the key to read the value of String type from SharedPreferences. By default, the key is the name of the attribute, so we don't need to maintain and pass any constants. At the same time, if we want to avoid key conflict or access the key, we can also provide a custom key. We can also provide a default value for the property in case no value is found in SharedPreferences.

This delegate can also use the same key to store the new value of the property in SharedPreferences.

In order for our example to work, we also need String? Add delegation with Int, which is the same as before:

fun SharedPreferences.stringNullable(
    defaultValue: String? = null,
    key: (KProperty<*>) -> String = KProperty<*>::name
): ReadWriteProperty<Any, String?> =
    object : ReadWriteProperty<Any, String?> {
        override fun getValue(
            thisRef: Any,
            property: KProperty<*>
        ) = getString(key(property), defaultValue)

        override fun setValue(
            thisRef: Any,
            property: KProperty<*>,
            value: String?
        ) = edit().putString(key(property), value).apply()
    }

fun SharedPreferences.int(
    defaultValue: Int = 0,
    key: (KProperty<*>) -> String = KProperty<*>::name
): ReadWriteProperty<Any, Int> =
    object : ReadWriteProperty<Any, Int> {
        override fun getValue(
            thisRef: Any,
            property: KProperty<*>
        ) = getInt(key(property), defaultValue)

        override fun setValue(
            thisRef: Any,
            property: KProperty<*>,
            value: Int
        ) = edit().putInt(key(property), value).apply()
    }

Now we can finally simplify the code of the Settings class:

class Settings(context: Context) {

    private val prefs: SharedPreferences =
        PreferenceManager.getDefaultSharedPreferences(context)

    var param1 by prefs.stringNullable()
    var param2 by prefs.int()
    var param3 by prefs.string(
        key = { "KEY_PARAM3" },
        defaultValue = "default"
    )
}

The code looks much better now. If you need to add an attribute, one line of code is enough.

View delegate

Suppose we have a custom View, which contains three text fields - a title, a subtitle, and a description - and the layout is as follows:

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <TextView
        android:id="@+id/tvTitle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <TextView
        android:id="@+id/tvSubtitle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <TextView
        android:id="@+id/tvDescription"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

</LinearLayout>

We want CustomView to provide methods for modifying and obtaining three fields:

class CustomView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
    var title: String
        get() = tvTitle.text.toString()
        set(value) {
            tvTitle.text = value
        }
    var subtitle: String
        get() = tvSubtitle.text.toString()
        set(value) {
            tvSubtitle.text = value
        }
    var description: String
        get() = tvDescription.text.toString()
        set(value) {
            tvDescription.text = value
        }
    init {
        inflate(context, R.layout.custom_view, this)
    }
}

Here we use Kotlin Android Extension View binding to get the control in the layout.

Obviously, some code can be easily moved to another class. Let's complete it with the help of delegation.

Let's write an extension function of TextView, which returns a delegate to process its text content:

fun TextView.text(): ReadWriteProperty<Any, String> =
    object : ReadWriteProperty<Any, String> {
        override fun getValue(
            thisRef: Any,
            property: KProperty<*>
        ): String = text.toString()

        override fun setValue(
            thisRef: Any,
            property: KProperty<*>, value: String
        ) {
            text = value
        }
    }

Then use it in CustomView:

class CustomView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {

    init {
        inflate(context, R.layout.custom_view, this)
    }
    
    var title by tvTitle.text()
    var subtitle by tvSubtitle.text()
    var description by tvDescription.text()
}

Make sure to initialize the property after the init method renders the layout, because the control cannot be null.

This may not be much improved compared with the source code. The key is to show the power of delegation. Besides, it's interesting to write.

Of course, it's not limited to TextView. For example, there is a control visibility delegate (keepBounds determines whether to occupy space when the control is invisible):

fun View.isVisible(keepBounds: Boolean): ReadWriteProperty<Any, Boolean> =
    object : ReadWriteProperty<Any, Boolean> {
        override fun getValue(
            thisRef: Any,
            property: KProperty<*>
        ): Boolean = visibility == View.VISIBLE

        override fun setValue(
            thisRef: Any,
            property: KProperty<*>,
            value: Boolean
        ) {
            visibility = when {
                value -> View.VISIBLE
                keepBounds -> View.INVISIBLE
                else -> View.GONE
            }
        }
    }

Here is a delegate of ProgressBar progress, which returns floating-point numbers from 0 to 1.

fun ProgressBar.progress(): ReadWriteProperty<Any, Float> =
    object : ReadWriteProperty<Any, Float> {
        override fun getValue(
            thisRef: Any,
            property: KProperty<*>
        ): Float = if (max == 0) 0f else progress / max.toFloat()

        override fun setValue(
            thisRef: Any,
            property: KProperty<*>, value: Float
        ) {
            progress = (value * max).toInt()
        }
    }

Here is how to use a ProgressBar in CustomView:

class CustomView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {

    init {
        inflate(context, R.layout.custom_view, this)
    }
    
    var title by tvTitle.text()
    var subtitle by tvSubtitle.text()
    var description by tvDescription.text()

    var progress by progressBar.progress()
    var isProgressVisible by progressBar.isVisible(keepBounds = false)

As you can see, you can delegate anything without restrictions.

summary

We see some examples of using Kotlin delegate properties in Android development. Of course, you can use it in other ways. The goal of this article is to show how powerful delegate properties are and what we can do with them.

I hope you have the idea of using delegation now.

For my inventory, please click the csdn official at the bottom to get it for free

Topics: Android kotlin