Touch Targets

small controls a thumb can still hit, at the size they were drawn.

▶ Run in browser

Tags: ui touch mobile layout accessibility

A finger is blunter than a cursor. A control a player aims at is therefore hit by a finger anywhere in its rect grown, evenly about its centre, to the theme’s min_hit_size on each axis, while it stays laid out and drawn at the size its author chose. The number is 44 engine units by default; an engine unit is one density-independent pixel (an iOS point, an Android dp), so 44 is a fingertip on every screen. A mouse and a pen are precise and hit the control’s own rect.

Control.touch_target says which controls grow: Button, CheckBox, Slider and DropDown do, a Label does not, since it is read rather than aimed at. min_hit_size belongs to the theme, reached through any control’s get_theme(). Assign a number to change it for every control, 0 to switch the growth off, or None for the default again.

The form is a VBoxContainer with sizing = SizingMode.CONTENT, which hands each row exactly its measured height, so the rows sit closer together than a fingertip is wide. The green outline round each control is the area a finger hits it over. Where two grown areas overlap, the control whose centre is nearer the thumb takes the tap. Switching modes changes only the outlines: the row heights in the readout stay the same in all three, because the hit area grows and the layout does not.

The three mode buttons along the bottom are action buttons in a ButtonGroup: Button("Fingertip", action="fingertip", group=modes) shows the action’s key beside its name, and a click presses that key, so the keys 1, 2 and 3 and the buttons run the same @on_input handler.

Source

  1# /// simvx
  2# tags = ["ui", "touch", "mobile", "layout", "accessibility"]
  3# web = { width = 900, height = 620, responsive = true }
  4# ///
  5"""Touch Targets: small controls a thumb can still hit, at the size they were drawn.
  6
  7A finger is blunter than a cursor. A control a player aims at is therefore hit
  8by a finger anywhere in its rect grown, evenly about its centre, to the theme's
  9`min_hit_size` on each axis, while it stays laid out and drawn at the size its
 10author chose. The number is 44 engine units by default; an engine unit is one
 11density-independent pixel (an iOS point, an Android dp), so 44 is a fingertip
 12on every screen. A mouse and a pen are precise and hit the control's own rect.
 13
 14`Control.touch_target` says which controls grow: `Button`, `CheckBox`,
 15`Slider` and `DropDown` do, a `Label` does not, since it is read rather than
 16aimed at. `min_hit_size` belongs to the theme, reached through any control's
 17`get_theme()`. Assign a number to change it for every control, `0` to switch
 18the growth off, or `None` for the default again.
 19
 20The form is a `VBoxContainer` with `sizing = SizingMode.CONTENT`, which hands
 21each row exactly its measured height, so the rows sit closer together than a
 22fingertip is wide. The green outline round each control is the area a finger
 23hits it over. Where two grown areas overlap, the control whose centre is nearer
 24the thumb takes the tap. Switching modes changes only the outlines: the row
 25heights in the readout stay the same in all three, because the hit area grows
 26and the layout does not.
 27
 28The three mode buttons along the bottom are action buttons in a `ButtonGroup`:
 29`Button("Fingertip", action="fingertip", group=modes)` shows the action's key
 30beside its name, and a click presses that key, so the keys 1, 2 and 3 and the
 31buttons run the same `@on_input` handler.
 32"""
 33
 34from simvx.core import (
 35    Anchor,
 36    Button,
 37    ButtonGroup,
 38    CheckBox,
 39    Colour,
 40    DropDown,
 41    HBoxContainer,
 42    Key,
 43    Label,
 44    Node,
 45    SizingMode,
 46    Slider,
 47    VBoxContainer,
 48    on_input,
 49)
 50from simvx.graphics import App
 51
 52# Each mode's name is also its action; the value is what min_hit_size is set to.
 53MODES = {"fingertip": None, "generous": 60.0, "off": 0.0}
 54OUTLINE = (0.45, 0.8, 0.45, 0.8)
 55
 56
 57class TouchTargetsDemo(Node):
 58    dynamic = True  # the outlines follow the layout every frame
 59    input_context = {"fingertip": [Key.KEY_1], "generous": [Key.KEY_2], "off": [Key.KEY_3]}
 60
 61    def on_ready(self):
 62        self.start_presses = 0
 63        column = self.add_child(VBoxContainer(anchor=Anchor.TOP_LEFT, size=(320, 0), inset=(40, 16), separation=12))
 64        column.add_child(Label("Touch targets", font_size=22))
 65        self.form = column.add_child(VBoxContainer(separation=6))
 66        self.form.sizing = SizingMode.CONTENT
 67        self.start = self.form.add_child(Button("Start game", on_press=self.on_start))
 68        self.form.add_child(CheckBox("Full screen"))
 69        self.form.add_child(Slider(0, 100, value=70))
 70        self.form.add_child(DropDown(items=["Low", "Medium", "High"], selected_index=1))
 71        self.form.add_child(Label("A label is read, not aimed at: it never grows"))
 72        self.readout = column.add_child(Label(self.readout_text(), font_size=13))
 73        self.readout.text_colour = Colour.hex("#8AB4F8")
 74
 75        caption = column.add_child(
 76            Label(
 77                "Nothing here changes size between the modes.\n"
 78                "What changes is the green outline, the area\n"
 79                "a finger lands on a control over. Where two\n"
 80                "outlines overlap, the nearer centre wins.",
 81                font_size=12,
 82            )
 83        )
 84        caption.text_colour = Colour.hex("#9A9AA6")
 85
 86        self.modes = ButtonGroup()
 87        bar = self.add_child(HBoxContainer(anchor=Anchor.BOTTOM_WIDE, size=(0, 36), inset=16, separation=8))
 88        bar.sizing = SizingMode.FILL
 89        self.mode_buttons = {
 90            name: bar.add_child(Button(name.capitalize(), action=name, group=self.modes)) for name in MODES
 91        }
 92        self.set_mode("fingertip")
 93
 94    def on_start(self):
 95        self.start_presses += 1
 96
 97    @on_input(tuple(MODES))
 98    def on_mode(self, event):
 99        self.set_mode(event.action)
100
101    def set_mode(self, name: str):
102        # The theme is shared, so this changes the hit size of every control.
103        self.form.get_theme().min_hit_size = MODES[name]
104        self.mode_buttons[name].checked = True
105
106    def on_update(self, dt):
107        self.readout.text = self.readout_text()
108
109    def readout_text(self) -> str:
110        width, height = self.viewport_size
111        heights = " ".join(f"{row.size_y:.0f}" for row in self.form.children)
112        return (
113            f"{width:.0f} x {height:.0f} units   min_hit_size: {self.form.get_theme().min_hit_size:.0f}\n"
114            f"row heights: {heights}   start pressed: {self.start_presses}"
115        )
116
117    def on_draw(self, renderer):
118        # The grown rect as the hit test sees it at ui_scale 1: the control's rect
119        # widened on each axis to min_hit_size, never shrunk.
120        hit = self.form.get_theme().min_hit_size
121        for control in (*self.form.children, *self.mode_buttons.values()):
122            if control.touch_target:
123                x, y, w, h = control.get_global_rect()
124                gx, gy = max(0.0, (hit - w) / 2), max(0.0, (hit - h) / 2)
125                renderer.rect((x - gx, y - gy), (w + 2 * gx, h + 2 * gy), stroke=OUTLINE)
126
127
128if __name__ == "__main__":
129    App(title="SimVX Touch Targets", width=900, height=620).run(TouchTargetsDemo())