Design systems on Android · Series · Part 5 of 6
From atoms to screens: layers, a screen template and pages on Android
Components are the vocabulary. Screens are the sentences, and they are where design systems usually leak: every page re-invents its header, its scrolling area and where the primary action sits. This part looks at how the example library organises components into layers, and builds the piece that most systems skip, a screen template with named regions that pages fill in.
Working code. Everything in this post exists and runs in the reference repository: github.com/liconrepo/MobileDesignSystem. Clone it and follow along; the docs are also published at liconrepo.github.io/MobileDesignSystem.
Files for this part:
- Package names follow the layers:
perceptual,functional.atoms,molecules,organisms,templates. Pages live in the app. - A short decision list tells contributors where a new component goes.
- A template fixes a screen's structure (header, scrolling content, pinned footer). A page fills it.
- Custom
LayoutParamslet XML children pick a region withapp:layout_mdsTemplateSlot="footer".
Layers, and a way to choose between them
Part 1 introduced the five layers. The useful part is making the choice mechanical, so two contributors reach the same answer. The repo's docs/layers.md uses this list:
- Does it only describe look and feel, with no view? perceptual.
- Does it stand alone? atom.
- Is it atoms combined into one small unit with a single purpose? molecule.
- Is it a distinct section of a screen? organism.
- Is it a screen skeleton with no content of its own? template.
| Example | Layer | Why |
|---|---|---|
MdsTone | Perceptual | A vocabulary of tones. No view. |
MdsButton | Atom | One job, stands alone. |
MdsTextField | Molecule | A label, an input and a support line acting as one control. |
MdsCard | Organism | A container for a section of content. |
MdsScreenTemplate | Template | The skeleton of a whole screen. |
SignInPage | Page | A template with real content. App code. |
The classification is for people, not for the compiler. Do not let it become bureaucracy: if a component sits awkwardly between two layers, pick the lower one and move on. The value is the shared vocabulary, which Part 1 called one of the main benefits of a design language.
Why a template at all
Look at any app's list of screens. Each has a title, a body that scrolls, and often a primary button pinned at the bottom. Each is hand-built with a ScrollView, a constraint layout and some padding, so the title sits 4dp higher on one screen than on another, and the pinned button overlaps content on small phones. A template turns that skeleton into a component with rules:
- The header is a heading for screen readers.
- The middle scrolls and fills the available space.
- The footer is pinned, separated by a hairline, and only appears when it has something in it.
- Spacing comes from tokens, so every screen agrees.
Named regions in plain XML
The goal is that a page reads like a description of its content, with no wiring:
<io.github.liconrepo.mds.templates.MdsScreenTemplate
android:layout_width="match_parent"
android:layout_height="match_parent"
app:mdsTemplateSubtitle="Sign in to pick up where you left off."
app:mdsTemplateTitle="Welcome back">
<io.github.liconrepo.mds.functional.molecules.MdsTextField
android:id="@+id/fieldEmail"
app:mdsLabel="Email" ... />
<io.github.liconrepo.mds.functional.molecules.MdsTextField
android:id="@+id/fieldPassword"
app:mdsLabel="Password" ... />
<io.github.liconrepo.mds.functional.atoms.MdsButton
android:id="@+id/btnSignIn"
android:text="Sign in"
app:layout_mdsTemplateSlot="footer" />
</io.github.liconrepo.mds.templates.MdsScreenTemplate>Children declared in XML land in the content region by default. A child tagged with layout_mdsTemplateSlot="footer" is pinned. Android layouts normally put every child directly inside the parent, so making this work needs two techniques: custom layout params, and an addView override that redirects children.
Custom LayoutParams carry the slot
The layout_* attributes of a child are read by its parent, through a LayoutParams class the parent supplies. The template defines its own, which remembers which region the child asked for:
internal enum class Slot { Content, Footer }
public class SlotLayoutParams : LayoutParams {
internal var slot: Slot = Slot.Content
public constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
context.withStyledAttributes(attrs, R.styleable.MdsScreenTemplate_Layout) {
slot = Slot.entries[getInt(R.styleable.MdsScreenTemplate_Layout_layout_mdsTemplateSlot, 0)]
}
}
...
}
override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams = SlotLayoutParams(context, attrs)
override fun checkLayoutParams(p: ViewGroup.LayoutParams?): Boolean = p is SlotLayoutParamsThe layout inflater calls generateLayoutParams(attrs) for each child, which is how the attribute reaches the parent. The checkLayoutParams override makes sure children added in code with some other params type get converted rather than crashing.
addView redirects children into regions
Internally the template is a vertical LinearLayout with four parts: header, a scroll view, a divider and a footer region. Its own children are those four views. Anything the page adds must go into the scroll content or the footer instead:
override fun addView(child: View, index: Int, params: ViewGroup.LayoutParams) {
if (!structured) return super.addView(child, index, params) // while building our own parts
val slot = (params as? SlotLayoutParams)?.slot ?: Slot.Content
// Copy into plain LinearLayout params so weight, gravity and margins survive the move.
val plain = if (params is LinearLayout.LayoutParams) LayoutParams(params) else LayoutParams(params)
when (slot) {
Slot.Content -> content.addView(child, index, plain)
Slot.Footer -> {
footerRegion.addView(child, index, plain)
footerRegion.visibility = VISIBLE
divider.visibility = VISIBLE
}
}
}The structured flag is the same idea as the ready flag from Part 4. Until the template has assembled its own four parts, addView behaves normally. After that, it redirects. Copying the params into a plain LinearLayout.LayoutParams matters: without it, margins and weights set in XML are lost as the child moves to a different parent.
For pages that build actions in code, there is an explicit door too: template.addFooterView(view).
A page is an ordinary Activity
With the template in place, the page's code is only behaviour. The layout describes structure; the Kotlin handles validation and results:
private fun submit() {
val email = binding.fieldEmail.text?.toString().orEmpty().trim()
val password = binding.fieldPassword.text?.toString().orEmpty()
binding.fieldEmail.errorText = when {
email.isEmpty() -> getString(R.string.error_email_empty)
!Patterns.EMAIL_ADDRESS.matcher(email).matches() -> getString(R.string.error_email_invalid)
else -> null
}
binding.fieldPassword.errorText =
if (password.length < 8) getString(R.string.error_password_short) else null
...
}Notice what the page does not contain: colours, paddings, or text sizes. If the design changes, the pages usually do not.
The host concerns (edge-to-edge and inset padding, plus applying the saved brand) live in a small PageActivity base class in the sample. That is deliberate. Window handling is an app decision, so it stays in the app and not in the library.
The gallery mirrors the layers
The sample app has one tab per layer, in order: Perceptual, Atoms, Molecules, Organisms, Templates, Pages. The tab bar is itself made of MdsChips. Each tab is built once on first visit and kept, so scroll position and typed text survive switching. It makes the gallery a map of the architecture: a new contributor can walk the layers in the order they are built.
Try it yourself
- Open the Pages tab and launch the sign-in page. Submit it empty, then with a bad email.
- Add a third button to the footer of
page_sign_in.xmlwith the slot attribute. Notice that no Kotlin changes. - Remove the
Patternscheck fromSignInPage. The template and components stay the same, only behaviour changed. - Write a new template, "MdsTwoPaneTemplate", by copying the slot technique. Decide which layer docs it needs.
| Term | In plain words |
|---|---|
| Template | A screen skeleton with regions but no content. |
| Page | A template filled with real content, in the app. |
| Slot | A named region of a template that children can target. |
| LayoutParams | Per-child instructions that the parent reads. |
| Insets | The space taken by system bars, which edge-to-edge apps must pad for. |