Touch Targets¶
small controls a thumb can still hit, at the size they were drawn.
▶ Run in browserTags: 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())