From Java Builders to Kotlin DSLs
Introduction
DSLs - Domain Specific Languages - are an ever trending topic in Kotlin circles. They allow us to flex some of the most exciting language features while accomplishing more readable and maintainable solutions in our code.
Today I'd like to show you how to implement a certain kind of DSL - we're going to be wrapping an existing Java Builder in Kotlin. No doubt you've come across the builder pattern in Java before, for example if you're an Android developer, you must've used an AlertDialog.Builder
, an OkHttpClient.Builder
, or a Retrofit.Builder
at some point. Wrapping a builder like this is a good exercise in just pure DSL design. All you have to worry about is designing the API you provide with your wrapper, since all the implementation for whatever your DSL provides its users is already done inside the builder!
Our example case
It just so happens that I'm the creator and maintainer of a library that does this very thing, and a small part of its implementation is what we're going to be using as our example. The original library is the wonderful and hugely popular MaterialDrawer by Mike Penz, which allows you to create complex, good looking, and customized navigation drawers in your application, all via various Builder
objects in Java, with no XML writing involved on your part. This is what my library, MaterialDrawerKt provides a convenient Kotlin DSL wrapper for.
The Builder API
Let's take a look at the drawer we're going to be creating in our example.
Here's the code, using the builders in the original API:
DrawerBuilder()
.withActivity(this)
.withTranslucentStatusBar(false)
.withDrawerLayout(R.layout.material_drawer_fits_not)
.addDrawerItems(
PrimaryDrawerItem()
.withName("Home")
.withDescription("Get started here!"),
PrimaryDrawerItem()
.withName("Settings")
.withDescription("Tinker around")
)
.withOnDrawerItemClickListener { view, position, drawerItem ->
when(position) {
0 -> toast("Home clicked")
1 -> toast("Settings clicked")
}
true
}
.withOnDrawerListener(object: Drawer.OnDrawerListener {
override fun onDrawerSlide(drawerView: View?, slideOffset: Float) {
// Empty
}
override fun onDrawerClosed(drawerView: View?) {
toast("Drawer closed")
}
override fun onDrawerOpened(drawerView: View?) {
toast("Drawer opened")
}
})
.build()
In this code, we've...
- set the Activity
we want the drawer to appear in,
- made some layout adjustments so that the drawer is below the ActionBar
,
- created just two menu items with names and descriptions,
- set a listener where we can handle item selections by position, using a SAM conversion to implement a single method interface,
- added a listener to detect drawer movement, using an object expression, because the necessary interface has multiple methods.
We saw two different builders used here, namely, the DrawerBuilder and PrimaryDrawerItem classes. While their builder syntax actually looks pretty decent and readable, we'll see that a DSL can do even better.
Note that you can check out the entire working demo project for this article on GitHub here. See the commit history to follow the article step by step as we build our DSL.
Creating instances
Let's start small. We'll want to create a drawer with a drawer {}
call, let's implement just that.
fun Activity.drawer(dummy: () -> Unit) {
DrawerBuilder()
.withActivity(this)
.build()
}
We've defined our first function as an extension on Activity
, so that it's available when we're in one, and it can access the Activity
instance as this
without us having to pass it in explicitly.
Now, we should add our PrimaryDrawerItem
instances. This syntax would be nice for a start:
drawer {
primaryItem()
primaryItem()
}
To get this, we'll need a primaryItem
function that's only available within the drawer
block, and that somehow adds an item to the DrawerBuilder
we've already created.
To be able to call methods on our DrawerBuilder
instance before we build()
it, we'll introduce a new wrapper class that holds it:
class DrawerBuilderKt(activity: Activity) {
val builder = DrawerBuilder().withActivity(activity)
internal fun build() {
builder.build()
}
}
We'll update our original drawer
function to create an instance of this wrapper class. We'll also modify its parameter - by making the setup
function an extension on our own class, the client of the DSL will be placed in a new scope inside the lambda passed to the drawer
function, where the methods of DrawerBuilderKt
become available, as we'll see in a moment.
fun Activity.drawer(setup: DrawerBuilderKt.() -> Unit) {
val builder = DrawerBuilderKt(this)
builder.setup()
builder.build()
}
You might have spotted that we've marked our own build
method internal
- this is because the only call to it will be the one inside the drawer
function. Hence, there's no need to expose it to the clients of our library.
The visibility of builder
is another story - we'll have to keep this public
so that our library stays extensible. We could make it internal
for the purpose of us implementing wrappers around the built-in drawer items, but that would mean that nobody else could add their own custom drawer items to the DSL - something you could do with the original library. This is functionality we don't want to strip from our clients.
Now we can finally add the primaryItem
function we were planning earlier. To make this available inside the lambda passed to drawer
, we could make it a member of the DrawerBuilderKt
class. Modifying this class for every new drawer item type we add, however, seems like an odd thing to do. The various types of drawer items should in no way affect how the drawer itself works.
We can instead use the same mechanics as clients would use to create custom drawer items. We'll get a neat, decoupled design by adding primaryItem
as an extension function:
fun DrawerBuilderKt.primaryItem() {
builder.addDrawerItems(PrimaryDrawerItem())
}
We can now add blank drawer items to our drawer!
Setting properties
Before we get to setting the name and description of our drawer items with the DSL, we have the properties of DrawerBuilder
to take care of, as we've seen in the very first code snippet. This is the syntax we'll create for these:
drawer {
drawerLayout = R.layout.material_drawer_fits_not
translucentStatusBar = false
}
These, of course, will be properties on the DrawerBuilderKt
class so that they're available in the right scope.
Since the original builder doesn't let us access the values we've set, what we'll need to create are essentially write-only properties. These properties won't have backing fields to store values, all they'll do is forward the calls to the appropriate builder methods.
Unfortunately, Kotlin only has properties that can be both read and written (var
) and read-only ones (val
). We'll solve this by using a var
, and throwing an exception when someone tries to read these properties. We'll also include Kotlin's powerful @Deprecated
annotation that lets us mark using the getters an error, so that clients are stopped from doing so at edit/compile time, rather than just getting the runtime exception:
class DrawerBuilderKt(activity: Activity) {
...
var drawerLayout: Int
@Deprecated(message = "Non readable property.", level = DeprecationLevel.ERROR)
get() = throw UnsupportedOperationException("")
set(value) {
builder.withDrawerLayout(value)
}
}
Alternatives for setting properties
Now, we can move on to customizing the drawer items themselves. The obvious solution here is to continue with the same syntax style as before:
drawer {
primaryItem {
name = "Home"
description = "Get started here!"
}
}
We know how to do this by creating a wrapper class around PrimaryDrawerItem
, and then adding a couple non-readable properties, just like we did before. But let's do something more interesting, and create this alternative syntax:
drawer {
primaryItem(name = "Home", description = "Get started here!")
}
This is also pretty straightforward, we're just calling a function that has two parameters, and using named parameters for readability. Let's add these parameters to primaryItem
then. We'll also throw in default values so that they're each optional:
fun DrawerBuilderKt.primaryItem(name: String = "", description: String = "") {
val item = PrimaryDrawerItem()
.withName(name)
.withDescription(description)
builder.addDrawerItems(item)
}
This, of course, isn't a feasible method for adding dozens of properties to an item we're constructing with our DSL, adding a wrapper around the PrimaryItemClass
with separate write-only properties that can be set in a setup
lambda is still the way to go for most things.
However, this is a nice way to lift some very basic or commonly set properties to a more prominent position in the code. Here's what the DSL could look like with some more properties implemented:
primaryItem(name = "Games", description = "Ready, player one?") {
iicon = FontAwesome.Icon.faw_gamepad
identifier = 3
selectable = false
}
Listeners
We know how to add properties to our DSL, now let's see how we can go about listeners. We'll start with the easy one, setting the OnDrawerItemClickListener
to handle item clicks for a given position in the drawer. Here's our goal:
drawer {
onItemClick { position ->
when (position) {
0 -> toast("Home clicked")
1 -> toast("Settings clicked")
}
true
}
}
onItemClick
will be a method in DrawerBuilderKt
, and it will take a lambda parameter that can be called when the original listener fires:
class DrawerBuilderKt(activity: Activity) {
...
fun onItemClick(handler: (view: View?, position: Int, drawerItem: IDrawerItem<*, *>) -> Boolean) {
builder.withOnDrawerItemClickListener(handler)
}
}
We're making use of SAM conversion with the call to the withOnDrawerItemClickListener
method of the builder here. The usual SAM conversion syntax would have us passing in a lambda that gets transformed to the OnDrawerItemClickListener
interface, but instead, we're going just a small step further, and we're passing in the handler
parameter which has the appropriate function type for a conversion.
We'll simplify the above method a bit, by taking a lambda which only gets the position passed to it, as clients will usually only care about that parameter. We're using SAM conversion again, this time with a regular lambda, because we want to ignore some parameters when calling our simpler handler
parameter.
class DrawerBuilderKt(activity: Activity) {
...
fun onItemClick(handler: (position: Int) -> Boolean) {
builder.withOnDrawerItemClickListener { _, position, _ -> handler(position) }
}
}
Of course, having only this version of the method would hide functionality of the original library, so in the real wrapper library, I've included both of these.
Complex listeners
Last but not least, let's see what we can do about the OnDrawerListener
in our example. This interface has three methods, so the previous, simple solution won't work here. As always, let's start with the syntax we want to achieve. Not that we're only setting two of the three methods that the interface defines.
drawer {
onClosed {
toast("Drawer closed")
}
onOpened {
toast("Drawer opened")
}
}
As you can see, it would be nice to be able to specify either one or multiple of the methods of the interface independently of each other. We know that we'll want to define three methods to take the appropriate handler
lambdas, very similarly to what we did before:
class DrawerBuilderKt(activity: Activity) {
...
fun onOpened(handler: (drawerView: View) -> Unit) {
// TODO implement
}
fun onClosed(handler: (drawerView: View) -> Unit) {
// TODO implement
}
fun onSlide(handler: (drawerView: View, slideOffset: Float) -> Unit) {
// TODO implement
}
}
The question is how to pass these handlers to the builder we're holding. We can't make a withOnDrawerListener
call in each of them and create an object
that wraps just that one handler, as the object
created there would always implement just one of the three methods.
What I came up with for this is an object
property in our DrawerBuilderKt
wrapper class that implements the OnDrawerListener
interface, and delegates each of these calls to one of its properties.
class DrawerBuilderKt(activity: Activity) {
...
private val onDrawerListener = object : Drawer.OnDrawerListener {
var onSlide: ((View, Float) -> Unit)? = null
override fun onDrawerSlide(drawerView: View, slideOffset: Float) {
onSlide?.invoke(drawerView, slideOffset)
}
var onClosed: ((View) -> Unit)? = null
override fun onDrawerClosed(drawerView: View) {
onClosed?.invoke(drawerView)
}
var onOpened: ((View) -> Unit)? = null
override fun onDrawerOpened(drawerView: View) {
onOpened?.invoke(drawerView)
}
}
}
All our previous methods have to do then is to set these properties when they're called:
class DrawerBuilderKt(activity: Activity) {
...
fun onOpened(handler: (drawerView: View) -> Unit) {
onDrawerListener.onOpened = handler
}
fun onClosed(handler: (drawerView: View) -> Unit) {
onDrawerListener.onClosed = handler
}
fun onSlide(handler: (drawerView: View, slideOffset: Float) -> Unit) {
onDrawerListener.onSlide = handler
}
}
Of course, we'll still have to pass this object
to the builder at some point - we'll do that after the setup
lambda passed to drawer
has already done its work, and it calls our DrawerBuidlerKt.build()
method:
class DrawerBuilderKt(activity: Activity) {
val builder = DrawerBuilder().withActivity(activity)
internal fun build() {
builder.withOnDrawerListener(onDrawerListener)
builder.build()
}
...
}
Result
As a reminder, here's the builder code we started the article with:
DrawerBuilder()
.withActivity(this)
.withTranslucentStatusBar(false)
.withDrawerLayout(R.layout.material_drawer_fits_not)
.addDrawerItems(
PrimaryDrawerItem()
.withName("Home")
.withDescription("Get started here!"),
PrimaryDrawerItem()
.withName("Settings")
.withDescription("Tinker around")
)
.withOnDrawerItemClickListener { view, position, drawerItem ->
when(position) {
0 -> toast("Home clicked")
1 -> toast("Settings clicked")
}
true
}
.withOnDrawerListener(object: Drawer.OnDrawerListener {
override fun onDrawerSlide(drawerView: View?, slideOffset: Float) {
// Empty
}
override fun onDrawerClosed(drawerView: View?) {
toast("Drawer closed")
}
override fun onDrawerOpened(drawerView: View?) {
toast("Drawer opened")
}
})
.build()
And then here's the final DSL we've put together:
drawer {
drawerLayout = R.layout.material_drawer_fits_not
translucentStatusBar = false
primaryItem(name = "Home", description = "Get started here!")
primaryItem(name = "Settings", description = "Tinker around")
onItemClick { position ->
when (position) {
0 -> toast("Home clicked")
1 -> toast("Settings clicked")
}
true
}
onClosed {
toast("Drawer closed")
}
onOpened {
toast("Drawer opened")
}
}
I hope this serves as a good example of how much better looking and more expressive a DSL can be than builder style solutions. Another benefit is how much easier making changes in the DSL is, which of course becomes apparent when you're actually making them (but worrying about placing commas in the right places, for example, is in the past).
Conclusion
Thank you for sticking with me for this entire journey. I hope you got a good feel for how to design your own DSLs (always write down your desired syntax first!), and how to structure your DSL implementations.
If you're interested in more, I recommend checking out the source of the actual MaterialDrawerKt library, as it deals with many more advanced concepts that I couldn't fit in this post. Among other things, it uses generics and inheritance heavily to deal with MaterialDrawer's complex hierarchy of built-in drawer item types, restricts function calls and property accesses to the appropriate levels of the DSL hierarchy, and much more. I might cover these in a future article.
Additionally, you can find another article covering the process of writing a DSL here, and another article about more general DSL design here.
But this is it for now, and with that, it's now your time to go and start creating your own DSLs. Good luck!
Márton is a Kotlin enthusiast from Hungary, currently working as an Android developer at AutSoft, while still pursuing a master’s degree in computer engineering. Brags a lot about being ranked third in the top users list of the Kotlin tag on Stack Overflow.
https://zsmb.co/kotlin-dsl-design-with-village-dsl/
This link is broken!
Oops, that was my fault. Should be back up now!