Skip to content

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)

A sign-up form on a phone: the two empty required fields and a field with an expired code are outlined in red, each with its message under it

  • help= is the supporting text under the field, in the theme's secondary text colour.
  • required=True marks 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 error colour, 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 and segmented buttons on a phone: a row of filter chips with one selected, chips with several selected that wrap onto a second line, and two segmented buttons

  • chips() selects one at a time by default. A tap on the selected chip clears it, so on_change gets "". With multiple=True, each chip turns on and off by itself, and on_change gets the list of selected words. Chips wrap onto another line when the screen runs out of width.
  • required=True always 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 with multiple=True cannot be turned off, and when selected= 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 pass multiple=True (add required=True to keep at least one).
  • on_change(value) runs after each tap, with what get_value() gives. set_value() selects from your code and does not call it.
  • A check marks what is selected, as Material does. check=False leaves it out; Spotify's and WhatsApp's filters have none. icons= puts an icon before each option's words: one name per option, or None.
  • 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:

tracks.set_items(new_items)

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:

lead.stream("Thinking about that...", speed="fast")

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,
)
chats { badge-position: end; badge-background-color: #21C063; badge-color: #0B141A; }

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 a describe is filled from the item. Dotted fields ({author.name}) reach into nested data.
  • A command that takes an argument receives the row's item -- the same dict on_click receives. 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 shows active_icon in the button's active-color when the field is on, and icon when 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)
post_like { active-color: #FF3040; }

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)
photos { height: auto; }
photos_row { padding: 1px; }
tile { width: 100%; object-fit: cover; }

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)

Two phone screens: a mail row half-way through a swipe with a green Archive strip behind it, and a task list with drag handles

  • swipe_right is the finger moving right; its action shows at the left edge, which is where mail apps put Archive. swipe_left shows 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, and on_swipe only runs once the snackbar has gone without it (4 seconds). Your code never has to put anything back.
  • keep=True slides the row back instead of taking it away. That is what "mark as read" or "remind me tomorrow" wants; change the row with update_item() from on_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=True alone 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)
profile_tabs { active-color: #000000; color: #8E8E8E; border-color: #DBDBDB; }

A profile on a phone: a header, a strip of three icon tabs and a grid of photos three across

  • 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, or flex-grow: 1 in a column, the pages fill it.
  • The strip: background-color (transparent by default -- the screen shows through), color for the other tabs, active-color for the one on screen, indicator-color for the line under it (the active colour by default), border-color for a line under the strip and text-transform: none to 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)
monthly { color: #3B5BDB; }
budget { stroke-width: 10px; }

Four charts on a phone: blue bars by month, a green line with the area under it shaded, a donut with its legend, and a ring at 72%

  • Items are dicts read by x (the label, "label" by default) and y (the number, "value"), or plain numbers. Rows from db.query() or a JSON response work as they come: chart(rows, kind="bar", x="month", y="total"). An item's color key 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=True shades 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 value out of max, with the percentage in the middle unless center= says something else ("45 min").
  • set_items(items) redraws with new data, set_value(v) moves a ring, and on_click receives 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)

Three phone screens: a page with pictures, one of them open on black, and the same picture zoomed in on a boat

  • 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=True grows out of its place on the screen and goes back into it. The whole picture is shown, not the part a object-fit: cover thumbnail kept.
  • view_image(src) opens any picture from a function -- the row of a list that was tapped, a photo just taken. src is a file packaged with the app, a file on the phone or an address, as for set_src(); describe= says what it is to a screen reader, as on image().
  • zoom=True takes the place of command=: a tap does one thing. Both together, zoom=True on an avatar() or in a row= template stop the build with U2046 -- in a row, give the picture command=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:

device("responsive")

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-wrap and gap;
  • justify-content, align-items and align-self;
  • flex-grow, flex-shrink and flex-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.

chips { display: flex; flex-direction: row; justify-content: flex-start; gap: 8px; }

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.