Components and layouts¶
Text, buttons and inputs¶
title = label("Library", variant="title", 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.
A switch, a checkbox and a slider call on_change too, with what
get_value() gives: "true" or "false", or the slider's number as text.
(New in 1.12.0 -- before, only text fields called it.)
def private_account(value):
storage.set("private", value)
toast("Private" if value == "true" else "Public")
private = inputs("Private account", type="switch", on_change=private_account,
screen=settings)
private.set_value(storage.get("private"))
It runs whenever the value changes with the screen showing -- a tap, a drag,
or a set_value() from one of your callbacks. The value the screen opens
with is not a change: the saved preference put back above does not show the
toast when the settings open.
Fields with help and errors¶
New in 1.12.0. A line of words under a text field: what to type, or what is wrong with what was typed.
name = inputs("Full name", id="name", required=True, screen=signup)
email = inputs("Email", id="email", help="We only use it to sign you in",
required="Enter your email", screen=signup)
password = inputs("Password", id="password", type="password",
help="At least 8 characters", screen=signup)
def create():
if not validate(name, email):
return
if "@" not in email.get_value():
email.set_error("That does not look like an email address")
return
if len(password.get_value()) < 8:
password.set_error("Use 8 characters or more")
return
toast("Welcome, " + name.get_value())
button("Create account", command=create, screen=signup)

help=is the supporting text under the field, in the theme's secondary text colour.required=Truemarks a field that may not be left empty. Give it words instead --required="Enter your email"-- to say something better than "Required". Spaces alone do not fill a field.validate(name, email)checks every field you give it and returns whether they all passed. Each empty required field shows its message -- all of them at once, not one a tap -- and the first one takes the cursor.name.validate()checks one field.field.set_error("...")shows your own message, for the rules only your app knows: an address with no@, a password too short, a name the server says is taken.set_error("")clears it.- An error takes the place of the help text, in the theme's
errorcolour, and the field's outline (or its underline) turns the same colour. Typing in the field clears it and the help comes back. error="..."in the call starts the field with an error already showing -- a form reopened with what the server refused.
They work on the fields somebody types in: text, password, search,
number and textarea. On a switch, a slider or a date the build stops with
U2044. The three texts are written into the layout, so they can be text, a
constant declared above or t("key"); a message worked out while the app
runs goes through set_error().
The line under the field is a polite live region and the field carries the
error as its own, so a screen reader can say it when it appears and when the
field is reached. hide() and show() take the words with the field. A
field that uses none of this is the plain field it always was. On the phone it is ApkpyField, added to
the project only when an app uses it; the Previewer draws from the same rules
in field_rules.py.
Chips and segmented buttons¶
New in 1.12.0. Options that stay selected: the filters over an inbox, the genres over a music app, Day / Week / Month over a chart.
def show(kind):
shown = []
for mail in MAIL:
if kind == "Unread" and mail["unread"] != "yes":
continue
shown.append(mail)
inbox.set_items(shown)
chips(["All", "Unread", "Starred"], selected="All", required=True,
on_change=show, id="filters", screen=home)
def retag(selected): # a list, with multiple=True
if "Kotlin" in selected:
toast("Kotlin is on")
chips(["Python", "Kotlin", "Java"], multiple=True, selected=["Python"],
icons=["code", None, None], on_change=retag, screen=post)
period = segmented(["Day", "Week", "Month"], selected="Week",
on_change=show_period, screen=insights)

chips()selects one at a time by default. A tap on the selected chip clears it, soon_changegets"". Withmultiple=True, each chip turns on and off by itself, andon_changegets the list of selected words. Chips wrap onto another line when the screen runs out of width.required=Truealways keeps one selected -- a filter bar with an "All", as in a chat or a music app. A tap on the selected chip does nothing, the last chip left on withmultiple=Truecannot be turned off, and whenselected=names none, the first starts selected.segmented()is two to five segments in one outlined pill, sharing the width equally. One segment is always selected, and it's the first when you don't say which, unless you passmultiple=True(addrequired=Trueto keep at least one).on_change(value)runs after each tap, with whatget_value()gives.set_value()selects from your code and does not call it.- A check marks what is selected, as Material does.
check=Falseleaves it out; Spotify's and WhatsApp's filters have none.icons=puts an icon before each option's words: one name per option, orNone. - Each option is 48dp tall to the finger and smaller to the eye: a chip is 32dp and a segment 40dp. A screen reader reads each option as a checkbox (a radio button when only one can be on) and says whether it is checked.
- The selection survives a rotation on the phone, like a switch's state.
CSS on the id, or on chips { } / segmented { } for every one:
| Property | What it colours |
|---|---|
color |
the words (and a chip's icons) |
background-color |
an unselected option's fill; see-through by default |
border-color |
the outline; transparent for none |
active-color |
a selected option's words and check |
indicator-color |
a selected option's fill |
border-radius |
the corners: 8px on a chip, a full pill on a segmented button |
font-size |
the words, 14px by default |
With a Theme, the colours follow it. Selected options take the same
colour as the bottom bar's indicator, and they switch with the rest of the
app in dark mode. The build checks the contrast of both states (U2035).
The options have to be written out: a list of words, or the name of one at
the top of the file. A segmented button with one option or more than five,
an option twice, or a selected= that is not an option stops the build with
U2043. On the phone it is ApkpyChoice, added to the project only when an
app uses it; the Previewer draws from the same numbers in choice_rules.py.
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")
A tapped row reaches on_click whole -- every column the query returned,
plus title and subtitle -- so the callback can read the key it needs:
def remove(item):
db.execute("DELETE FROM tracks WHERE id = ?", [item["id"]])
tracks = list_view([], on_click=remove, screen=home)
tracks.set_items(db.query("SELECT id, title, artist FROM tracks"),
title="title", subtitle="artist")
Before 1.11.0 the phone handed over only the text shown, and item["id"]
was that text: the DELETE matched nothing, on the phone only.
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.
Bubbles¶
A messenger draws the other way: each message a bubble that hugs its text,
yours on the right. Give the rows a kind with variant=, and style each kind
with id:kind:
thread = virtual_collection(
messages,
variant="{kind}",
template={
"day": {"meta": "{text}"},
"them": {"subtitle": "{text}", "meta": "{time}"},
"mine": {"subtitle": "{text}", "meta": "{time} ✓✓"},
},
id="thread", item_height="auto", screen=chat,
)
thread { item-background-color: #202C33; item-border-radius: 10px; gap: 6px;
subtitle-lines: 30; }
thread:them { align-self: flex-start; max-width: 80%; }
thread:mine { align-self: flex-end; max-width: 80%; item-background-color: #005C4B; }
thread:day { align-self: center; meta-color: #8696A0; }
align-self puts a kind of row at the start, the end or the centre;
max-width (pixels or a percentage) is as wide as it may grow, and a short
message is narrower. item-border-radius rounds every row and gap spaces
them. Each kind can have its own item-background-color, title-color,
subtitle-color and meta-color. A collection that sets none of it is drawn
as it always was.
A list of conversations¶
Messaging apps all draw a chat list the same way: the time level with the
name, and the unread count under the time, round.
badge-position: end does that:
chats = virtual_collection(
rows,
template={"avatar": "{name}", "title": "{name}", "subtitle": "{last}",
"meta": "{time}", "badge": "{unread}"},
id="chats", item_height="auto", on_click=open_chat, screen=home,
)
A row with an empty unread shows no badge. Without badge-position, the badge
follows the subtitle, as before.
Rows built from components¶
The slots above draw a chat list or a track list. A feed post -- a header, a
photo, a row of actions, a caption -- a comment or a product card is a small
layout of its own, repeated for every item. Write it once, as a function that
builds one row, and pass it as row=:
POSTS = [
{"user": "mara.vale", "face": "mara.png", "picture": "lisbon.jpg",
"likes": "1,284", "caption": "Last light over the river"},
# ... what your server or database returns
]
def like(item):
toast("You liked " + item["user"] + "'s post")
def post_row(row):
head = container(id="post_head", parent=row)
avatar("{face}", size=34, id="post_face", parent=head, describe="{user}")
label("{user}", id="post_user", parent=head)
image("{picture}", id="post_pic", parent=row, aspect_ratio="1:1", describe="")
actions = container(id="post_actions", parent=row)
button("", icon="favorite_border", describe="Like", parent=actions,
command=lambda item: like(item))
button("", icon="send", describe="Share", parent=actions,
command=lambda: toast("Share"))
label("{likes} likes", id="post_likes", parent=row)
label("{caption}", id="post_caption", parent=row)
feed = virtual_collection(POSTS, row=post_row, id="feed", screen=home)
feed_row { background-color: #FFFFFF; padding: 0px 0px 8px 0px; }
post_head { display: flex; flex-direction: row; align-items: center; gap: 10px; }
- The function is called once, with the row as its only argument. What it builds is the template; every item gets a copy.
{field}in a text, a picture's source or adescribeis filled from the item. Dotted fields ({author.name}) reach into nested data.- A
commandthat takes an argument receives the row's item -- the same dicton_clickreceives. One that takes none is called as it is. - The row is styled as
<id>_row; everything inside it by its own id, like anywhere else. Rows take the height of what they hold. - Pictures can be web addresses, files on the phone or files in the app's folder; the ones the items name are packaged with the app.
set_items(),append_items()and the rest work as they do for slot rows, and a phone recycles the rows: only the ones on screen exist.
On the phone the template becomes a layout of its own, inflated by a RecyclerView; in the Previewer the visible rows are drawn from the same components.
A row that changes: the red heart¶
A row is drawn from its item, so to change a row, change its item.
update_item(id, changes) patches one item -- found by its id field -- and
draws that row again. Two arguments read a field to decide how a component
looks:
visible="{field}"on any component shows it only when the field is on.active="{field}"on a button showsactive_iconin the button'sactive-colorwhen the field is on, andiconwhen it is off.
A field is off when it is empty, false, 0, no, off, none or null;
anything else is on.
POSTS = [
{"id": "p1", "user": "mara.vale", "likes": 1284, "liked": "", "sponsored": ""},
{"id": "p2", "user": "northline", "likes": 3410, "liked": "yes", "sponsored": "yes"},
]
def like(item):
if item["liked"] == "yes":
posts.update_item(item["id"], {"liked": "", "likes": int(item["likes"]) - 1})
else:
posts.update_item(item["id"], {"liked": "yes", "likes": int(item["likes"]) + 1})
def post_row(row):
label("{user}", id="post_user", parent=row)
label("Sponsored", id="post_ad", parent=row, visible="{sponsored}")
button("", icon="favorite_border", active_icon="favorite", active="{liked}",
describe="Like", id="post_like", parent=row,
command=lambda item: like(item))
label("{likes} likes", id="post_likes", parent=row)
posts = virtual_collection(POSTS, row=post_row, id="posts", screen=home)
The heart turns red and the count goes up on that post alone, and back again
on the next tap. The phone draws both icons at build time, the lit one in
active-color.
A grid of photos¶
New in 1.12.0 (height: auto). The same rows can fill a grid: layout="grid" with columns=. A profile's
photo grid is one picture per row, square, a pixel apart:
def tile(row):
image("{picture}", id="tile", parent=row, aspect_ratio="1:1", describe="",
command=lambda item: open_photo(item))
photos = virtual_collection(PHOTOS, row=tile, layout="grid", columns=3,
id="photos", screen=profile)
height: auto makes the collection as tall as its items, so it scrolls with
the screen -- the header above it goes up as the grid does, which is how a
profile reads. Without it a collection keeps its own scroll, at 480dp in a
scrolling screen. With height: auto every item is laid out, so it is for
tens of items, not thousands.
Rows that swipe and move¶
New in 1.12.0. Swipe a message to archive it, drag a task to reorder it:
def archive(item):
db_archive(item["id"])
def delete(item):
db_delete(item["id"])
def moved(item, index):
toast(item["title"] + " is number " + str(index + 1))
inbox = virtual_collection(
MAIL, row=mail_row, id="inbox", screen=home,
swipe_right=swipe("archive", "Archive", on_swipe=archive, undo="Conversation archived"),
swipe_left=swipe("delete", "Delete", on_swipe=delete),
)
todo = virtual_collection(TASKS, row=task_row, id="todo", screen=tasks,
on_reorder=moved)

swipe_rightis the finger moving right; its action shows at the left edge, which is where mail apps put Archive.swipe_leftshows at the right edge.- Each
swipe(icon, text, on_swipe=...)draws a strip in a colour, with the icon and the words in white. By default the strip is red for an icon that takes something away (delete,block,close…) and the theme's primary colour for the rest.color=sets your own. - Past 40% of the row's width, letting go completes the swipe. Short of it, the row slides back.
- The row leaves the list and
on_swipe(item)runs. undo="..."shows that message in a snackbar with Undo. The row comes back if Undo is pressed, andon_swipeonly runs once the snackbar has gone without it (4 seconds). Your code never has to put anything back.keep=Trueslides the row back instead of taking it away. That is what "mark as read" or "remind me tomorrow" wants; change the row withupdate_item()fromon_swipe.- Reordering: a long press lifts a row, and dropping it moves it.
on_reorder(item, index)gets the row and its new position (a number, starting at 0).reorder=Truealone lets people reorder without telling you. - Every gesture is also an accessibility action on the row: TalkBack offers Archive, Delete, Move up and Move down. Nobody needs to perform the gesture, and you write nothing extra for it. In the Previewer, a right-click on a row opens the same list.
They are for a list: layout="grid" with a swipe or a reorder stops the build
with U2042. On the phone it is Android's ItemTouchHelper (ApkpyGestures,
added to the project only when a list uses it); the Previewer drags rows with
the mouse, from the same numbers in gesture_rules.py.
Tabs that swipe¶
New in 1.12.0. A profile has Posts, Reels and Tagged over the same space; a chat app has
Chats, Status and Calls. pager() is the strip of tabs and the pages under
it, and each tab() is one page -- what it holds goes inside it with
parent=:
tabs = pager(id="profile_tabs", screen=profile)
posts = tab(icon="grid_on", describe="Posts", parent=tabs)
reels = tab(icon="slideshow", describe="Reels", parent=tabs)
tagged = tab(icon="person_pin", describe="Tagged", parent=tabs)
virtual_collection(PHOTOS, row=tile, layout="grid", columns=3,
id="photos", parent=posts)
label("Photos of you", parent=tagged)

- A tap on a tab slides to its page, and so does a swipe: past a fifth of the width, or a quick flick, it goes to the next page; less, it springs back.
tabs.select(2)does it from code, sliding as a tap does.- A tab shows its text, its icon, or the icon above the text. An icon-only tab
needs
describe=: it is what a screen reader says. - Without a height of its own the pager is as tall as the page on screen, so a
long grid and a short page share it in a scrolling screen. With a
height, orflex-grow: 1in a column, the pages fill it. - The strip:
background-color(transparent by default -- the screen shows through),colorfor the other tabs,active-colorfor the one on screen,indicator-colorfor the line under it (the active colour by default),border-colorfor a line under the strip andtext-transform: noneto stop Material's capitals. Each page is styled by its tab's id, like a container.
On the phone the strip is Material's TabLayout and the pages sit in
ApkpyPager, a class ApkPy adds to the project only when a screen has a pager.
A tab() without a pager() as its parent stops the build with U2040.
Charts¶
New in 1.12.0. chart() draws bars, a line, a donut or a ring natively -- a Canvas on the
phone, the same shapes in the Previewer, from the same numbers.
SPENDING = [{"label": "Apr", "value": 820}, {"label": "May", "value": 1140},
{"label": "Jun", "value": 960}, {"label": "Jul", "value": 1284}]
CATEGORIES = [{"label": "Rent", "value": 900}, {"label": "Groceries", "value": 320},
{"label": "Travel", "value": 210}]
def show_month(item):
toast(item["label"] + ": " + str(item["value"]))
monthly = chart(SPENDING, kind="bar", id="monthly", on_click=show_month, screen=home)
chart(steps, kind="line", fill=True, id="steps", screen=home)
chart(CATEGORIES, kind="donut", center="$1,430", id="split", screen=home)
budget = chart(kind="ring", value=72, max=100, id="budget", screen=home)

- Items are dicts read by
x(the label,"label"by default) andy(the number,"value"), or plain numbers. Rows fromdb.query()or a JSON response work as they come:chart(rows, kind="bar", x="month", y="total"). An item'scolorkey colours its bar or slice. - Bars and a line get an axis that ends on a round number (0, 500, 1K,
1.5K), gridlines, and a label under each item -- every second or third one
when they would not fit.
fill=Trueshades the area under a line. - A donut lists its slices under it, each with its colour and number, and
center=writes in its hole. - A ring shows one
valueout ofmax, with the percentage in the middle unlesscenter=says something else ("45 min"). set_items(items)redraws with new data,set_value(v)moves a ring, andon_clickreceives the item of the bar, point or slice that was tapped.
The stylesheet, on the chart's id or on chart { }:
| Property | What it colours or sizes |
|---|---|
color |
the bars, the line, a ring's arc and a donut's first slice -- the theme's primary by default |
track-color |
the unfilled part of a ring |
label-color |
the axis numbers, the labels under the items, a donut's numbers |
grid-color |
the gridlines |
title-color |
a ring's or a donut's centre and a donut's legend labels |
stroke-width |
a line's or a ring's thickness (a donut's too) |
border-radius |
the bars' top corners |
height |
the drawing: 200 for bars and lines, 180 for a donut (its legend comes on top), 160 for a ring |
font-size |
the labels |
On the phone the chart is ApkpyChart, a view ApkPy adds to the project only
when a screen has one. A kind other than the four stops the build with
U2041.
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.
A card's title is white and its subtitle grey unless the component says otherwise, on a dark shelf or a light one:
recent { title-color: #FFFFFF; subtitle-color: #B3B3B3; }
mixes { color: #111111; } /* the title, when title-color is not given */
The page's body colour does not reach a card. Until 1.11.0 the phone painted
white and grey whatever the stylesheet said, and the Previewer took the body's
text colour instead -- dark titles on a dark shelf, on the desktop only.
Pictures¶
An image() or avatar() can be tapped, and an image can change what it shows:
story = image("story1.jpg", id="story", screen=viewer, describe="Story",
command=next_story)
avatar("face.png", size=62, id="ring", screen=home, describe="mara",
command=open_story)
def next_story():
story.set_src("story2.jpg")
set_src() takes a file from the app's folder or a URL, as image() does; the
files it names are packaged with the app. A border-color and border-width
on a round avatar draw a ring around the picture, not over it.
A picture that opens and zooms¶
New in 1.12.0. zoom=True makes a tap open the picture over the whole
screen, on black, where it can be looked at properly:
image("harbour.jpg", id="hero", zoom=True, describe="A harbour at dusk", screen=home)
def open_photo(item):
view_image(item["photo"])
list_view(PHOTOS, on_click=open_photo, screen=home)

- In the viewer, two fingers zoom the picture up to four times, about the point between them; a double tap zooms in on the spot and, again, back out; a drag moves a zoomed picture, and stops at its edges.
- It closes with Back, with the cross in the corner, or by dragging a picture that is not zoomed down and letting go.
- A picture opened with
zoom=Truegrows out of its place on the screen and goes back into it. The whole picture is shown, not the part aobject-fit: coverthumbnail kept. view_image(src)opens any picture from a function -- the row of a list that was tapped, a photo just taken.srcis a file packaged with the app, a file on the phone or an address, as forset_src();describe=says what it is to a screen reader, as onimage().zoom=Truetakes the place ofcommand=: a tap does one thing. Both together,zoom=Trueon anavatar()or in arow=template stop the build withU2046-- in a row, give the picturecommand=lambda item: view_image(item["photo"]).
In the Previewer the mouse wheel is the two fingers, and Escape is Back. On
the phone the viewer is ApkpyZoom, added to the project only when an app
uses it; both follow the same arithmetic in zoom_rules.py.
Icons¶
icon= takes any of the 2,000+ Material Icons, by the name fonts.google.com/icons
shows with spaces as underscores: favorite_border, chat_bubble_outline,
cameraswitch, add_comment. The glyphs ship with ApkPy (Apache 2.0), and
the phone gets the same paths as vector drawables. To find one:
from apkpy_lib import icons
icons.search("heart") # ['favorite', 'favorite_border', 'heart_broken', ...]
A name that is not in the set draws a plain circle and reports U2015.
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.
Floating over the screen¶
position: absolute on a screen's own component takes it out of the column
and puts it on a layer over the content, fixed while the content scrolls --
a floating button, a composer or a player bar pinned to the bottom. Inside a
container with position: relative, it is placed in that container instead.
home = Screen(id="home", scroll=True)
label("Stories", id="title", screen=home)
button("", id="fab", icon="add", describe="New story", screen=home,
command=new_story)
fab { position: absolute; right: 20px; bottom: 24px;
width: 56px; height: 56px; border-radius: 28px; }
An absolute box with no width is as wide as its content, so right: 12px
puts a column of buttons against the right edge; with both left and right
it stretches between them. On a screen with a bottom bar the layer ends above
the bar. A tap that lands on none of its components reaches the content below.
In the Previewer the layer is drawn the same way, but a desktop widget cannot be see-through: a transparent component over a picture, the camera or a map shows its parent's colour where the phone shows what is behind it.
Rows that start at the start¶
A display: flex row with no justify-content centres what it holds, and has
since the first release -- a row of buttons under a form looks right that way.
Chips, a caption made of two labels, an avatar with a name: write where the row
starts.
A Theme gives the body a 12px gap and a 12px padding, and the body is
folded into every container. A row that should be tight -- icons inside a pill,
a list of actions under a photo -- says so with gap: 0px.
Aligning one child¶
align-self moves one child across its parent. On a screen's own component
it is what puts your reply on the right of a chat:
their_message { max-width: 260px; align-self: flex-start; }
my_message { max-width: 260px; align-self: flex-end; }
A child narrower than the screen is otherwise centred.
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.