Kotlin is really a beautiful language with some great features that make application development an enjoyable experience. One such feature is delegated properties. In this post, we will see how delegates can make our lives easier to develop Android.
Basic concept
First of all, what is a delegate and how does it work? It seems like some kind of magic but it’s really not that complicated. A delegate is just a class that provides value to an attribute and handles its changes. This allows us to move or delegate the getter-setter logic from the property itself to a separate class, allowing us to reuse this logic.
Suppose we want a String attribute parameter to always have a truncated string, i.e. with leading whitespace and a trailing checkmark removed. We can do this in the attribute setter like this:
1 2 3 4 5 6 7 8 | class Example { var param: String = "" set(value) { field = value.trim() } } |
Now, what if we want to reuse this function in some other class? Here, where delegates are used:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | 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() } } |
So a delegate is just a class with two methods: get and set the value of an attribute. To give it some more information, it is provided with the property it works with via the Instance of the KProperty class and an object with this property via thisRef . And here is how we can use this newly created delegate:
1 2 3 4 5 | class Example { var param: String by TrimDelegate() } |
The equivalent of this:
1 2 3 4 5 6 7 8 9 10 | class Example { private val delegate = TrimDelegate() var param: String get() = delegate.getValue(this, ::param) set(value) { delegate.setValue(this, ::param, value) } } |
:: param is the operator returns an instance of the class attribute KProperty. As you can see, there’s no mystery about delegates. But although simple, they can be very helpful. So let’s look at some examples, specific to Android.
Fragment arguments
We often need to pass some parameters to a fragment. It usually looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | 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 parameters when creating a fragment via its static newInstance method. Inside it, we put parameters into the Fragment arguments to get them later in onCreate. We can make the code a bit nicer, moving logic related arguments to Getters and setters properties:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | 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 basically we still have to write the same code for each attribute, which can be a chore if we have many of them. Besides, it seems a bit messy with all this explicit work with arguments. So is there a way to beautify the code?
The answer is yes! We will use the property delegates. First, the fragments arguments are stored in a Bundle object, with separate methods for setting different value types. So create an extension function that tries to put an arbitrary type value into the package and throws an exception if the type is not supported.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | 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") } } |
We are now ready to create a delegate for it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | class FragmentArgumentDelegate<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 ?: 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) } } |
Delegates read property values from fragment arguments. And when the attribute value changes, Delegate will access fragment arguments (or create and set a new Bundle as an argument if the fragment does not already have them), then write a new value for these arguments, using the open function. Bundle.put width that we have created before.
ReadWriteProperty a common interface to accept two types of parameters. We set the first one as fragment, making this Delegate available only for properties within a fragment. This allows us to access the fragment instance with thisRef and manage its arguments.
Note that we use the name of the property as the key for the argument, so we don’t have to store keys as constants anymore. The second type parameter of ReadWriteProperty determines which value type the attribute can have. We set the type to non-nullable and throw an exception if the value cannot be read. This allows us to have non-nullable properties in fragments, avoiding us from nasty null checks. But sometimes we need a property that is null. So let’s create another Delegate, if no arguments are found, don’t throw an exception, but return null instead:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | 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, let’s make some convenient functions (unnecessary, purely for aesthetic purposes):
1 2 3 4 5 6 | fun <T : Any> argument(): ReadWriteProperty<Fragment, T> = FragmentArgumentDelegate() fun <T : Any> argumentNullable(): ReadWriteProperty<Fragment, T?> = FragmentNullableArgumentDelegate() |
Finally, put delegates to use:
1 2 3 4 5 6 7 8 9 10 11 12 | 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 } } } |
Looks pretty neat, right?
Continue