Design systems on Android · Series · Part 5 of 6

From atoms to screens: layers, a screen template and pages on Android

Khairul Alam·October 2026·~7 min read
Atomic designCustom viewsLayoutParamsArchitecture

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:

Part 5 in 60 seconds
  • 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 LayoutParams let XML children pick a region with app: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:

  1. Does it only describe look and feel, with no view? perceptual.
  2. Does it stand alone? atom.
  3. Is it atoms combined into one small unit with a single purpose? molecule.
  4. Is it a distinct section of a screen? organism.
  5. Is it a screen skeleton with no content of its own? template.
ExampleLayerWhy
MdsTonePerceptualA vocabulary of tones. No view.
MdsButtonAtomOne job, stands alone.
MdsTextFieldMoleculeA label, an input and a support line acting as one control.
MdsCardOrganismA container for a section of content.
MdsScreenTemplateTemplateThe skeleton of a whole screen.
SignInPagePageA 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:

Headertitle + subtitle (heading) Contentscrolls, fills the space Footerpinned, hidden until used Headerapp:mdsTemplateTitle, app:mdsTemplateSubtitle Content (default slot)any child without a slot attribute Footerapp:layout_mdsTemplateSlot="footer"
A template is structure without content. The page decides what goes in each region.

Named regions in plain XML

The goal is that a page reads like a description of its content, with no wiring:

sample/src/main/res/layout/page_sign_in.xml (excerpt)
<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:

templates/MdsScreenTemplate.kt (excerpt)
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 SlotLayoutParams

The 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:

templates/MdsScreenTemplate.kt (excerpt)
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:

sample/.../pages/SignInPage.kt (excerpt)
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

  1. Open the Pages tab and launch the sign-in page. Submit it empty, then with a bad email.
  2. Add a third button to the footer of page_sign_in.xml with the slot attribute. Notice that no Kotlin changes.
  3. Remove the Patterns check from SignInPage. The template and components stay the same, only behaviour changed.
  4. Write a new template, "MdsTwoPaneTemplate", by copying the slot technique. Decide which layer docs it needs.
TermIn plain words
TemplateA screen skeleton with regions but no content.
PageA template filled with real content, in the app.
SlotA named region of a template that children can target.
LayoutParamsPer-child instructions that the parent reads.
InsetsThe space taken by system bars, which edge-to-edge apps must pad for.
← Back to all writing