Components and layouts¶
Text, buttons and inputs¶
title = label("Library", variant="headline", screen=home)
search = inputs(
"Search tracks",
type="search",
on_change=lambda query: filter_tracks(query),
screen=home,
)
button(
"Continue",
variant="filled",
icon="arrow_forward",
command=open_library,
screen=home,
)
Button variants are filled, outlined, tonal, text, danger and icon.
Input types include text, password, search, number, textarea, select, switch, checkbox, range, radio, date and time.
Containers and composition¶
panel = container(id="panel", screen=home)
label("Account", variant="title", parent=panel)
inputs("Email", type="text", parent=panel)
button("Save", variant="filled", parent=panel)
Style the parent to control its children:
panel {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
background-color: var(--surface);
border-radius: var(--radius);
}
Cards¶
Use a ready-made semantic card:
from apkpy_lib import card, card_action
premium = card(
title="Premium",
subtitle="Offline listening and high-quality audio",
image="headphones.jpg",
content="Available across your signed-in devices.",
actions=[
card_action("Learn more", variant="text", command=show_details),
card_action("Try it", variant="filled", command=start_trial),
],
variant="elevated",
screen=home,
)
Or compose any supported child manually:
custom = card(id="custom_card", variant="outlined", screen=home)
label("Custom content", variant="title", parent=custom)
button("Open", variant="text", parent=custom)
Lists¶
Plain and rich rows use the same list_view:
tracks = list_view(
[
{
"title": "Midnight Drive",
"subtitle": "Nova",
"image": "cover.jpg",
"src": "track.mp3",
}
],
rich=True,
on_click=lambda item: audio.play_background(
item["src"],
title=item["title"],
artist=item["subtitle"],
art=item["image"],
),
screen=home,
)
Update it later:
Database and HTTP JSON can be mapped directly:
rows = db.query("SELECT title, artist FROM tracks ORDER BY title")
tracks.set_items(rows, title="title", subtitle="artist")
Settings rows¶
A list_view shows rows it owns and fills from data. When the rows are the
screen -- a settings list, an account page, a menu -- write them out with
list_row:
prefs = container(id="prefs", screen=you)
list_row("Default model", subtitle="Answers when you do not pick one",
icon="settings", trailing="Opus 5", trailing_icon="chevron_right",
id="pref_model", parent=prefs,
command=lambda: model_sheet.open())
list_row("Appearance", icon="image", trailing="Dark",
trailing_icon="chevron_right", parent=prefs,
command=lambda: theme_sheet.open())
list_row("Notifications", icon="bell", trailing="Off",
trailing_icon="chevron_right", parent=prefs,
command=lambda: toast("Nothing to notify you about yet"))
Everything except the label is optional. The label sits at the leading edge
with the icon beside it, the subtitle goes underneath, and trailing /
trailing_icon are pinned to the right. The text block takes whatever the
icon and the trailing pieces leave, so a long label is cut with an ellipsis
rather than pushing the chevron off the screen.
A row is tapped like a button. command= runs a function, and
screen.on_click_navigate(button=the_row, to=other_screen) opens a screen --
which is what a settings row usually wants.
Three texts, three setters: set_value() changes the label,
set_trailing() the value on the right and set_subtitle() the second line.
A slot only exists if the row declared it, so pass trailing="" or
subtitle="" for one you intend to fill later.
pref_model = list_row("Default model", subtitle="", trailing="Opus 5",
trailing_icon="chevron_right", id="pref_model",
parent=prefs)
def refresh():
pref_model.set_trailing(storage.get("model", "Opus 5"))
lifecycle(settings, on_resume=refresh)
you.on_click_navigate(button=pref_model, to=model_screen)
Reading the value back in on_resume is what makes the row show the choice
after you come back from the screen that changed it.
Grouping rows with hairlines¶
Give the container that holds them a divider-color and the rows are
separated by a hairline, drawn between them and never at the edges. Let the
group own the surface and the corner radius, and the rows carry no box of
their own:
prefs {
background-color: var(--surface);
border-radius: 16px;
padding: 0px;
divider-color: var(--border);
divider-inset: 58px; /* start the line past the icon column */
}
list_row {
background-color: #00000000;
border-radius: 0px;
padding: 0px 18px;
min-height: 60px;
subtitle-color: var(--text-secondary);
trailing-color: var(--text-secondary);
icon-color: var(--text-secondary);
}
Rows stacked in a container sit flush against each other, so the hairline
lands on the seam. divider-width sets the thickness and defaults to 1px.
Dividers work on any container, not only ones holding rows.
An empty state in the middle¶
flex-grow: 1 on a column child gives it whatever its siblings leave. Put the
greeting in one and the composer after it, and you get the screen every
assistant app opens on -- the welcome in the middle, the input at the bottom:
hero = container(id="hero", screen=chat)
label("Ora", id="mark", parent=hero)
label("Back in action", id="greeting", parent=hero)
composer = container(id="composer", screen=chat)
inputs(placeholder="How can I help you today?", id="field",
type="textarea", parent=composer)
hero {
flex-grow: 1; /* take what the composer leaves */
justify-content: center; /* along the column */
align-items: center; /* across it */
background-color: #00000000;
}
justify-content and align-items are the two CSS words for the two halves
android:gravity already had. center, flex-start and flex-end on either
axis. A column that names neither still centres horizontally, which it always
did, so nothing already written moves.
Text that arrives¶
An answer appearing all at once is the one thing that never happens when you
talk to an assistant. stream() types it in:
And for a chat, add the row empty and stream into one of its fields:
thread.merge_items([{"id": reply_id, "author": "Ora", "message": ""}])
thread.stream_item(reply_id, "message", answer)
speed is slow, normal or fast. instant puts the whole thing there at
once, and so does a theme with motion="none" whatever the call said --
somebody who turned animations off did not ask to watch text type itself.
The rate lives in one table both runtimes read, so the phone and the desktop
type at the same speed. Text arrives a few characters per tick rather than one
character every few milliseconds, because a Handler and a Tk after both stop
being accurate below about 10ms and a rate the runtime cannot keep is a rate
that differs between them.
A thread that reads as a conversation¶
A collection row is a card by default, which is right for a feed and wrong for a chat. Take the surface away and the turn becomes text on the page:
/* No height: the thread takes what is left, which is what pins a composer
under it to the bottom of the screen instead of leaving it mid-air. */
thread {
item-background-color: #00000000;
item-border-color: #00000000;
title-color: var(--text-secondary); /* who is speaking, quietly */
subtitle-color: var(--text); /* what they said, loudly */
subtitle-lines: 12;
}
Leave height off and the collection takes the space its siblings do not, so
anything after it sits at the bottom of the screen. Give it a height and it
stops there, which is what you want inside a scrolling page.
Drop meta and badge from the template= as well -- a timestamp on the
right and a pill under the text are what make a chat read as a notification
feed.
Rich text, Markdown and trees¶
Use rich_text() for exact inline spans, markdown() for structured documents
and tree_view() for recursive expandable data. Android generates native
selectable text and a recycled hierarchy rather than a WebView.
rich_text(
[
{"text": "Status: ", "bold": True},
{"text": "ready", "bold": True, "color": "#22C55E"},
],
screen=home,
)
markdown("## Notes\n\n- [x] Native text", screen=home)
tree_view(
[{
"title": "Workspace",
"children": [{"title": "Release notes"}],
}],
screen=home,
)
See the complete native rich-content guide.
Carousels and grids¶
carousel(albums, on_click=open_album, screen=home)
grid(categories, cols=2, on_click=open_category, screen=home)
Rich items can contain title, subtitle, image and application-specific fields such as src.
Responsive layouts¶
Describe how the same component tree rearranges:
profile_panel = container(id="profile_panel")
details_panel = container(id="details_panel")
responsive(
mobile=column(profile_panel, details_panel),
tablet=row(profile_panel, details_panel),
breakpoint=600,
screen=home,
)
The Android build chooses the appropriate layout for the available width. In the Previewer:
Resize the window to test the breakpoint.
CSS flex and grid¶
ApkPy supports the layout properties needed for application interfaces, including:
display,flex-direction,flex-wrapandgap;justify-content,align-itemsandalign-self;flex-grow,flex-shrinkandflex-basis;- grid columns/rows, spans and gaps;
- width, height, min/max sizes, margins and padding;
- relative/absolute positioning, offsets and z-index.
Use responsive composition for major structural changes and CSS for sizing/alignment inside a structure.
Accessibility¶
Accessibility fails quietly. An image with no description is announced as nothing at all; text at 3:1 against its background is unreadable for a good share of people and looks fine to whoever chose the colours. Neither shows up in a build, a test or a screenshot — so ApkPy says it during the build.
Describing what has no words¶
image("shelf.png", id="shelf", screen=home, describe="Aisle 4, third shelf")
image("divider.png", id="rule", screen=home, describe="") # decoration
button("", id="settings", screen=home, icon="settings", describe="Settings")
describe= becomes android:contentDescription. An empty description is a
decision, not an omission: it marks the element as decorative and TalkBack
skips it, instead of announcing a file name. A button with words already
announces those words and needs nothing.
What the build tells you¶
A U2035 report lists what it found and lets the build finish — every app in
existence has an image somebody forgot to describe, and refusing to build over
it would only teach people to switch the check off.
home.photo (image): nothing to announce. Add describe="...", or describe=""
if it is decoration.
home.save (button): text is 3.90:1 against its background; 16sp needs 4.5:1.
home.tiny (button): height is 32dp, under the 48dp a fingertip needs.
The numbers are WCAG's and Material's, not opinions:
| Minimum | |
|---|---|
| Body text | 4.5:1 |
| Large text — 18pt (24sp), or 14pt bold (18.7sp) | 3:1 |
| Anything you tap | 48dp |
Large text is measured in points, not in sp
WCAG says 18pt, or 14pt bold; Android sizes text in sp, and 1pt is 1.333sp at the default density. So the thresholds are 24sp and 18.7sp — writing them as 18 and 14 would let 18sp body text pass at 3:1 when it needs 4.5:1.
Text is already emitted in sp, so it grows when someone has enlarged the
system font — nothing to do there.