If you’re an Android developer, chances are you’ve written a looooot of screens
that show a list of something or another. Chances are you’ll also write a lot
more. And my guess is that they all look pretty similar - an activity with a
layout that has a RecyclerView
in it. Maybe it’s wrapped in a FrameLayout
or something to house a FAB. Then you have an Adapter
. And that Adapter
has a custom ViewHolder
. And that ViewHolder
references another new layout
file. And you probably have a List
of something being passed into the
Adapter
. Oh and you probably have some type of interface
callback between
the Activity
and the Adapter
. It probably has a method that sounds something
like fun itemClicked(myItem: MyItem)
in it.
That there boilerplate makes me sleepy just typing it out. But good news, boys and girls, we can avoid all (most…) of that by using a custom activity template.
What is an activity template?
The templates that I’m talking about are the same ones you see when you go to
create a new file and choose to go through the IDE’s wizard for creating a
new activity or fragment or service or whatever you want. It provides you
with a little wizard where you fill in a few values and it generates a whole
bunch of code, including multiple source files (which is key for our
RecyclerView
example). They’re even smart enough to do things like add your
new activity into your AndroidManifest
file. Nifty, right?
What makes up a custom template?
All of Android Studios built-in wizard-style templates can be found in
(YOUR_ANDROID_STUDIO_LOCATION)/plugins/android/lib/templates
. They utilize the
FreeMarker language, which is a
templating language used to generate HTML/Source files/emails etc. Its syntax
is pretty basic: You use ${blah}
to denote dynamic text that references a
function or a variable. Everything else is normal static text.
Each template lives in its own folder and has 4 core components:
a template.xml
file:
This is where all of the user facing components are declared - i.e. what the templates name will be, what category it’ll be in, what kind of fields it expects from the user and so on. It’s also the entrance point to the rest of your template.
a globals.xml.ftl
file:
This file declares global variables that can be used throughout the template.
For example - your Activity
layout file name may go here so it can be reused
in other components.
A root
directory:
The root
directory is what ultimately contains your template code - so if
you’re creating an activity template this is where your ‘skeleton’ activity
would go. This is where the actual Java or Kotlin code lives.
- Note that the
root
directory (almost) always contains asrc
directory which contains anapp_package
directory which then ACTUALLY contains your template code. The idea is that theroot
directory is supposed to have a list of directories that roughly emulates your output directory, but with all that middle nonsense replaced with app_package for convenience.
And finally, the recipe.xml.ftl
file:
This is the file that actually ties everything together and declares what code will be generated. The bulk of the recipe file has code blocks that look something like this:
<instantiate from="src/app_package/Adapter.kt.ftl"
to="${escapeXmlAttribute(srcOut)}/${activityName}Adapter.kt" />
Which is saying “Create a file based off the skeleton located at src/app_package/Adapter.kt.ftl (filling in any necessary information provided by the user) and place it in the users source directory with the name (whatever the user put in)Adapter.kt”
Creating a new template
Onto the meat of the subject - writing our own template. Each template lives in
its own folder, so you need to create a new folder and put it in the activities
directory. You can name the folder whatever you want - for the example below
I named mine RecyclerviewActivity
.
Now we want to create the template.xml
file:
<template
format="4"
revision="1"
name="Recyclerview Activity"
description="Create an activity hooked up to a recyclerview." >
<category value="Activity" />
<parameter
id="activityName"
name="Activity Name"
type="string"
constraints="class|unique|nonempty"
default="BallerFeature"
help="Name of your new Activity" />
<globals file="globals.xml.ftl" />
<execute file="recipe.xml.ftl" />
</template>
Most of this is pretty self explanatory - the name/description params in the root template tag will be what the user sees when they open the wizard. The category determines what bucket the wizard is placed in.
The interesting piece is the parameter
value - this is what the user will
actually be asked to fill in. You can provide defaults and help text and all
that jazz. You can even add constraints like “Must be a valid Java class
identifier” or “Must be a unique class name”. The id
value is how you can
refer to the value in the rest of the template.
The last two lines declare where our global variables live, and finally tell the template wizard to run the recipe file, which we’ll create later on.
Next we want to create our globals.xml.ftl
file (the one we referenced above):
<?xml version="1.0"?>
<globals>
<global id="resOut" value="${resDir}" />
<global id="srcOut" value="${srcDir}/${slashedPackageName(packageName)}" />
<global id="listItem" value="${classToResource(activityName)}_list_item" />
<global id="adapter" value="${activityName}Adapter" />
<global id="viewholder" value="${activityName}ViewHolder" />
<global id="activityLayout" value="activity_${classToResource(activityName)}" />
<!-- These values are all necessary to utilize the manifest merging code
included below -->
<#include "../common/common_globals.xml.ftl" />
<global id="parentActivityClass" value=""/>
<global id="excludeMenu" type="boolean" value="true" />
<global id="generateActivityTitle" type="boolean" value="false" />
<global id="hasNoActionBar" type="boolean" value="false" />
<global id="isLauncher" type="boolean" value="false" />
<global id="activityClass" value="${activityName}Activity" />
</globals>
As previously mentioned, these variables can be referenced throughout the
template. We’re declaring several variables that will be used throughout our
skeleton files - the name of our list item layout, the adapter class and so on.
They utilize the activityName
we got from the user above.
The <#include />
line is being used to include a bunch of common globals in
the activities folder - we don’t actually care about any of those values, but to
get manifest merging to work properly we need to include them. The same goes for
the rest of the variables below that line.
Next up on the chopping block is our actual skeleton files - these go in the new
root/src/app_package/
directory.
First we create our recyclerview adapter skeleton Adapter.kt.ftl
:
package ${packageName}
import android.support.v7.widget.RecyclerView
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
class ${adapter}: RecyclerView.Adapter<${adapter}.${viewholder}>() {
override fun onBindViewHolder(holder: ${activityName}ViewHolder?, position: Int) {
TODO("not implemented")
}
override fun getItemCount(): Int {
TODO("not implemented")
}
override fun onCreateViewHolder(parent: ViewGroup,
viewType: Int): ${viewholder} {
val view = LayoutInflater.from(parent.context).inflate(R.layout.${listItem}, parent, false)
return ${viewholder}(view)
}
class ${viewholder}(root: View): RecyclerView.ViewHolder(root)
interface Callback {
fun itemClicked()
}
}
It’s utilizing the adapter
variable we declared above in the globals.xml.ftl
file as the class name, and the listItem
value we declared in our
globals.xml.ftl
file as the name for the layout file used by the ViewHolder
.
It’s also utilizing the viewholder
variable simlarly declared in our globals
file to name the ViewHolder
class.
Next we create our list item layout skeleton List_Item.xml.ftl
:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello, World!"/>
</LinearLayout>
Nothing too crazy going on there.
Next up is our Activity
layout skeleton, Activity_Layout.xml.ftl
:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.v7.widget.RecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
Pretty boring.
Last up is our Activity
skeleton, Activity.kt.ftl
:
package ${packageName}
import android.os.Bundle
import android.support.v7.app.AppCompatActivity
class ${activityName}Activity: AppCompatActivity(), ${adapter}.Callback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.${activityLayout})
}
override fun itemClicked() {
TODO("not implemented")
}
}
It also utilizes the activityName
we got from the user. It implements the
Callback
we declared in the Adapter.kt.ftl
skeleton and sets the
contentView
to the layout file declared in Activity_Layout.xml.ftl
.
Finally, to tie it all together, we create the recipe.xml.ftl
file:
<?xml version="1.0"?>
<recipe>
<#include "../common/recipe_manifest.xml.ftl" />
<instantiate from="src/app_package/Activity.kt.ftl"
to="${escapeXmlAttribute(srcOut)}/${activityName}Activity.kt" />
<instantiate from="src/app_package/Activity_Layout.xml.ftl"
to="${escapeXmlAttribute(resOut)}/layout/${activityLayout}.xml"/>
<instantiate from="src/app_package/List_Item.xml.ftl"
to="${escapeXmlAttribute(resOut)}/layout/${listItem}.xml" />
<instantiate from="src/app_package/Adapter.kt.ftl"
to="${escapeXmlAttribute(srcOut)}/${adapter}.kt" />
<open file="${srcOut}/${activityName}Activity.kt"/>
<open file="${resOut}/layout/${activityLayout}.xml" />
<open file="${srcOut}/${adapter}.kt" />
<open file="${resOut}/layout/${listItem}.xml" />
</recipe>
It instantiates four files - first it utilizes the Activity.kt.ftl
skeleton
file to create the actual activity implementation. The newly created file is
placed wherever srcOut
points to and is named activityName
Activity - so
whatever the user input as the activity name followed by Activity.
It then does the same instantiation dance for Activity_layout.xml.ftl
,
List_Item.xml.ftl
, and Adapter.kt.ftl
But before it instantiates any of the above classes, there’s an <#include />
line. That’s how we get our newly created activity to be added to the
AndroidManifest
file. The included file utilizes the <merge />
tag under the
hood to create a new AndroidManifest
file and merge it into the existing one.
Luckily this code is all included in the common
directory so we don’t need to
write any of it ourselves. 👍 to laziness. This merging code is why we needed
all those extra params in the globals.xml.ftl
file - it expects those
globals to be present and throws a series of (brutally) opaque errors if it
can’t find them.
And that’s it! If you navigate to File -> New -> Activity
you should see
Recyclerview Activity
as an option. Boilerplate generated!
You can find some (admittedly old) documentation here.