pythonnative 0.7.0__py3-none-any.whl → 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,832 @@
1
+ """Android native view handlers (Chaquopy / Java bridge).
2
+
3
+ Each handler class maps a PythonNative element type to an Android widget,
4
+ implementing view creation, property updates, and child management.
5
+
6
+ This module is only imported on Android at runtime; desktop tests inject
7
+ a mock registry via :func:`~.set_registry` and never trigger this import.
8
+ """
9
+
10
+ from typing import Any, Callable, Dict
11
+
12
+ from java import dynamic_proxy, jclass
13
+
14
+ from ..utils import get_android_context
15
+ from .base import CONTAINER_KEYS, LAYOUT_KEYS, ViewHandler, is_vertical, parse_color_int, resolve_padding
16
+
17
+ # ======================================================================
18
+ # Shared helpers
19
+ # ======================================================================
20
+
21
+
22
+ def _ctx() -> Any:
23
+ return get_android_context()
24
+
25
+
26
+ def _density() -> float:
27
+ return float(_ctx().getResources().getDisplayMetrics().density)
28
+
29
+
30
+ def _dp(value: float) -> int:
31
+ return int(value * _density())
32
+
33
+
34
+ def _apply_layout(view: Any, props: Dict[str, Any]) -> None:
35
+ """Apply common layout properties (child-level flex props) to an Android view."""
36
+ lp = view.getLayoutParams()
37
+ LayoutParams = jclass("android.widget.LinearLayout$LayoutParams")
38
+ ViewGroupLP = jclass("android.view.ViewGroup$LayoutParams")
39
+ Gravity = jclass("android.view.Gravity")
40
+ needs_set = False
41
+
42
+ if lp is None:
43
+ lp = LayoutParams(ViewGroupLP.WRAP_CONTENT, ViewGroupLP.WRAP_CONTENT)
44
+ needs_set = True
45
+
46
+ if "width" in props and props["width"] is not None:
47
+ lp.width = _dp(float(props["width"]))
48
+ needs_set = True
49
+ if "height" in props and props["height"] is not None:
50
+ lp.height = _dp(float(props["height"]))
51
+ needs_set = True
52
+
53
+ flex = props.get("flex")
54
+ flex_grow = props.get("flex_grow")
55
+ weight = None
56
+ if flex is not None:
57
+ weight = float(flex)
58
+ elif flex_grow is not None:
59
+ weight = float(flex_grow)
60
+ if weight is not None:
61
+ try:
62
+ lp.weight = weight
63
+ needs_set = True
64
+ except Exception:
65
+ pass
66
+
67
+ if "margin" in props and props["margin"] is not None:
68
+ left, top, right, bottom = resolve_padding(props["margin"])
69
+ try:
70
+ lp.setMargins(_dp(left), _dp(top), _dp(right), _dp(bottom))
71
+ needs_set = True
72
+ except Exception:
73
+ pass
74
+
75
+ if "align_self" in props and props["align_self"] is not None:
76
+ align_map = {
77
+ "flex_start": Gravity.START | Gravity.TOP,
78
+ "leading": Gravity.START | Gravity.TOP,
79
+ "center": Gravity.CENTER,
80
+ "flex_end": Gravity.END | Gravity.BOTTOM,
81
+ "trailing": Gravity.END | Gravity.BOTTOM,
82
+ "stretch": Gravity.FILL,
83
+ }
84
+ g = align_map.get(props["align_self"])
85
+ if g is not None:
86
+ lp.gravity = g
87
+ needs_set = True
88
+
89
+ if needs_set:
90
+ view.setLayoutParams(lp)
91
+
92
+ if "min_width" in props and props["min_width"] is not None:
93
+ view.setMinimumWidth(_dp(float(props["min_width"])))
94
+ if "min_height" in props and props["min_height"] is not None:
95
+ view.setMinimumHeight(_dp(float(props["min_height"])))
96
+
97
+
98
+ def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None:
99
+ """Apply visual properties shared across many handlers."""
100
+ if "background_color" in props and props["background_color"] is not None:
101
+ view.setBackgroundColor(parse_color_int(props["background_color"]))
102
+ if "overflow" in props:
103
+ clip = props["overflow"] == "hidden"
104
+ try:
105
+ view.setClipChildren(clip)
106
+ view.setClipToPadding(clip)
107
+ except Exception:
108
+ pass
109
+
110
+
111
+ def _apply_flex_container(container: Any, props: Dict[str, Any]) -> None:
112
+ """Apply flex container properties to a LinearLayout.
113
+
114
+ Handles spacing, padding, alignment, justification, background, and overflow.
115
+ """
116
+ LinearLayout = jclass("android.widget.LinearLayout")
117
+ Gravity = jclass("android.view.Gravity")
118
+
119
+ if "flex_direction" in props:
120
+ vertical = is_vertical(props["flex_direction"])
121
+ container.setOrientation(LinearLayout.VERTICAL if vertical else LinearLayout.HORIZONTAL)
122
+
123
+ direction = props.get("flex_direction", "column")
124
+ vertical = is_vertical(direction)
125
+
126
+ if "spacing" in props and props["spacing"]:
127
+ px = _dp(float(props["spacing"]))
128
+ GradientDrawable = jclass("android.graphics.drawable.GradientDrawable")
129
+ d = GradientDrawable()
130
+ d.setColor(0x00000000)
131
+ d.setSize(1 if vertical else px, px if vertical else 1)
132
+ container.setShowDividers(LinearLayout.SHOW_DIVIDER_MIDDLE)
133
+ container.setDividerDrawable(d)
134
+
135
+ if "padding" in props:
136
+ left, top, right, bottom = resolve_padding(props["padding"])
137
+ container.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom))
138
+
139
+ gravity = 0
140
+ ai = props.get("align_items") or props.get("alignment")
141
+ if ai:
142
+ if vertical:
143
+ cross_map = {
144
+ "stretch": Gravity.FILL_HORIZONTAL,
145
+ "fill": Gravity.FILL_HORIZONTAL,
146
+ "flex_start": Gravity.START,
147
+ "leading": Gravity.START,
148
+ "start": Gravity.START,
149
+ "center": Gravity.CENTER_HORIZONTAL,
150
+ "flex_end": Gravity.END,
151
+ "trailing": Gravity.END,
152
+ "end": Gravity.END,
153
+ }
154
+ else:
155
+ cross_map = {
156
+ "stretch": Gravity.FILL_VERTICAL,
157
+ "fill": Gravity.FILL_VERTICAL,
158
+ "flex_start": Gravity.TOP,
159
+ "top": Gravity.TOP,
160
+ "center": Gravity.CENTER_VERTICAL,
161
+ "flex_end": Gravity.BOTTOM,
162
+ "bottom": Gravity.BOTTOM,
163
+ }
164
+ gravity |= cross_map.get(ai, 0)
165
+
166
+ jc = props.get("justify_content")
167
+ if jc:
168
+ if vertical:
169
+ main_map = {
170
+ "flex_start": Gravity.TOP,
171
+ "center": Gravity.CENTER_VERTICAL,
172
+ "flex_end": Gravity.BOTTOM,
173
+ }
174
+ else:
175
+ main_map = {
176
+ "flex_start": Gravity.START,
177
+ "center": Gravity.CENTER_HORIZONTAL,
178
+ "flex_end": Gravity.END,
179
+ }
180
+ gravity |= main_map.get(jc, 0)
181
+
182
+ if gravity:
183
+ container.setGravity(gravity)
184
+
185
+ _apply_common_visual(container, props)
186
+
187
+
188
+ # ======================================================================
189
+ # Flex container handler (shared by Column, Row, View)
190
+ # ======================================================================
191
+
192
+
193
+ class FlexContainerHandler(ViewHandler):
194
+ """Unified handler for flex layout containers (Column, Row, View).
195
+
196
+ All three element types use ``LinearLayout`` with orientation
197
+ determined by the ``flex_direction`` prop.
198
+ """
199
+
200
+ def create(self, props: Dict[str, Any]) -> Any:
201
+ ll = jclass("android.widget.LinearLayout")(_ctx())
202
+ direction = props.get("flex_direction", "column")
203
+ LinearLayout = jclass("android.widget.LinearLayout")
204
+ ll.setOrientation(LinearLayout.VERTICAL if is_vertical(direction) else LinearLayout.HORIZONTAL)
205
+ _apply_flex_container(ll, props)
206
+ _apply_layout(ll, props)
207
+ return ll
208
+
209
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
210
+ if changed.keys() & CONTAINER_KEYS:
211
+ _apply_flex_container(native_view, changed)
212
+ if changed.keys() & LAYOUT_KEYS:
213
+ _apply_layout(native_view, changed)
214
+
215
+ def add_child(self, parent: Any, child: Any) -> None:
216
+ parent.addView(child)
217
+
218
+ def remove_child(self, parent: Any, child: Any) -> None:
219
+ parent.removeView(child)
220
+
221
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
222
+ parent.addView(child, index)
223
+
224
+
225
+ # ======================================================================
226
+ # Leaf handlers
227
+ # ======================================================================
228
+
229
+
230
+ class TextHandler(ViewHandler):
231
+ def create(self, props: Dict[str, Any]) -> Any:
232
+ tv = jclass("android.widget.TextView")(_ctx())
233
+ self._apply(tv, props)
234
+ _apply_layout(tv, props)
235
+ return tv
236
+
237
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
238
+ self._apply(native_view, changed)
239
+ if changed.keys() & LAYOUT_KEYS:
240
+ _apply_layout(native_view, changed)
241
+
242
+ def _apply(self, tv: Any, props: Dict[str, Any]) -> None:
243
+ if "text" in props:
244
+ tv.setText(str(props["text"]))
245
+ if "font_size" in props and props["font_size"] is not None:
246
+ tv.setTextSize(float(props["font_size"]))
247
+ if "color" in props and props["color"] is not None:
248
+ tv.setTextColor(parse_color_int(props["color"]))
249
+ if "background_color" in props and props["background_color"] is not None:
250
+ tv.setBackgroundColor(parse_color_int(props["background_color"]))
251
+ if "bold" in props and props["bold"]:
252
+ tv.setTypeface(tv.getTypeface(), 1) # Typeface.BOLD = 1
253
+ if "max_lines" in props and props["max_lines"] is not None:
254
+ tv.setMaxLines(int(props["max_lines"]))
255
+ if "text_align" in props:
256
+ Gravity = jclass("android.view.Gravity")
257
+ mapping = {"left": Gravity.START, "center": Gravity.CENTER, "right": Gravity.END}
258
+ tv.setGravity(mapping.get(props["text_align"], Gravity.START))
259
+
260
+
261
+ class ButtonHandler(ViewHandler):
262
+ def create(self, props: Dict[str, Any]) -> Any:
263
+ btn = jclass("android.widget.Button")(_ctx())
264
+ self._apply(btn, props)
265
+ _apply_layout(btn, props)
266
+ return btn
267
+
268
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
269
+ self._apply(native_view, changed)
270
+ if changed.keys() & LAYOUT_KEYS:
271
+ _apply_layout(native_view, changed)
272
+
273
+ def _apply(self, btn: Any, props: Dict[str, Any]) -> None:
274
+ if "title" in props:
275
+ btn.setText(str(props["title"]))
276
+ if "font_size" in props and props["font_size"] is not None:
277
+ btn.setTextSize(float(props["font_size"]))
278
+ if "color" in props and props["color"] is not None:
279
+ btn.setTextColor(parse_color_int(props["color"]))
280
+ if "background_color" in props and props["background_color"] is not None:
281
+ btn.setBackgroundColor(parse_color_int(props["background_color"]))
282
+ if "enabled" in props:
283
+ btn.setEnabled(bool(props["enabled"]))
284
+ if "on_click" in props:
285
+ cb = props["on_click"]
286
+ if cb is not None:
287
+
288
+ class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
289
+ def __init__(self, callback: Callable[[], None]) -> None:
290
+ super().__init__()
291
+ self.callback = callback
292
+
293
+ def onClick(self, view: Any) -> None:
294
+ self.callback()
295
+
296
+ btn.setOnClickListener(ClickProxy(cb))
297
+ else:
298
+ btn.setOnClickListener(None)
299
+
300
+
301
+ class ScrollViewHandler(ViewHandler):
302
+ def create(self, props: Dict[str, Any]) -> Any:
303
+ sv = jclass("android.widget.ScrollView")(_ctx())
304
+ if "background_color" in props and props["background_color"] is not None:
305
+ sv.setBackgroundColor(parse_color_int(props["background_color"]))
306
+ _apply_layout(sv, props)
307
+ return sv
308
+
309
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
310
+ if "background_color" in changed and changed["background_color"] is not None:
311
+ native_view.setBackgroundColor(parse_color_int(changed["background_color"]))
312
+ if changed.keys() & LAYOUT_KEYS:
313
+ _apply_layout(native_view, changed)
314
+
315
+ def add_child(self, parent: Any, child: Any) -> None:
316
+ parent.addView(child)
317
+
318
+ def remove_child(self, parent: Any, child: Any) -> None:
319
+ parent.removeView(child)
320
+
321
+
322
+ class TextInputHandler(ViewHandler):
323
+ def create(self, props: Dict[str, Any]) -> Any:
324
+ et = jclass("android.widget.EditText")(_ctx())
325
+ self._apply(et, props)
326
+ _apply_layout(et, props)
327
+ return et
328
+
329
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
330
+ self._apply(native_view, changed)
331
+ if changed.keys() & LAYOUT_KEYS:
332
+ _apply_layout(native_view, changed)
333
+
334
+ def _apply(self, et: Any, props: Dict[str, Any]) -> None:
335
+ if "value" in props:
336
+ et.setText(str(props["value"]))
337
+ if "placeholder" in props:
338
+ et.setHint(str(props["placeholder"]))
339
+ if "font_size" in props and props["font_size"] is not None:
340
+ et.setTextSize(float(props["font_size"]))
341
+ if "color" in props and props["color"] is not None:
342
+ et.setTextColor(parse_color_int(props["color"]))
343
+ if "background_color" in props and props["background_color"] is not None:
344
+ et.setBackgroundColor(parse_color_int(props["background_color"]))
345
+ if "secure" in props and props["secure"]:
346
+ InputType = jclass("android.text.InputType")
347
+ et.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD)
348
+ if "on_change" in props:
349
+ cb = props["on_change"]
350
+ if cb is not None:
351
+ TextWatcher = jclass("android.text.TextWatcher")
352
+
353
+ class ChangeProxy(dynamic_proxy(TextWatcher)):
354
+ def __init__(self, callback: Callable[[str], None]) -> None:
355
+ super().__init__()
356
+ self.callback = callback
357
+
358
+ def afterTextChanged(self, s: Any) -> None:
359
+ self.callback(str(s))
360
+
361
+ def beforeTextChanged(self, s: Any, start: int, count: int, after: int) -> None:
362
+ pass
363
+
364
+ def onTextChanged(self, s: Any, start: int, before: int, count: int) -> None:
365
+ pass
366
+
367
+ et.addTextChangedListener(ChangeProxy(cb))
368
+
369
+
370
+ class ImageHandler(ViewHandler):
371
+ def create(self, props: Dict[str, Any]) -> Any:
372
+ iv = jclass("android.widget.ImageView")(_ctx())
373
+ self._apply(iv, props)
374
+ _apply_layout(iv, props)
375
+ return iv
376
+
377
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
378
+ self._apply(native_view, changed)
379
+ if changed.keys() & LAYOUT_KEYS:
380
+ _apply_layout(native_view, changed)
381
+
382
+ def _apply(self, iv: Any, props: Dict[str, Any]) -> None:
383
+ if "background_color" in props and props["background_color"] is not None:
384
+ iv.setBackgroundColor(parse_color_int(props["background_color"]))
385
+ if "source" in props and props["source"]:
386
+ self._load_source(iv, props["source"])
387
+ if "scale_type" in props and props["scale_type"]:
388
+ ScaleType = jclass("android.widget.ImageView$ScaleType")
389
+ mapping = {
390
+ "cover": ScaleType.CENTER_CROP,
391
+ "contain": ScaleType.FIT_CENTER,
392
+ "stretch": ScaleType.FIT_XY,
393
+ "center": ScaleType.CENTER,
394
+ }
395
+ st = mapping.get(props["scale_type"])
396
+ if st:
397
+ iv.setScaleType(st)
398
+
399
+ def _load_source(self, iv: Any, source: str) -> None:
400
+ try:
401
+ if source.startswith(("http://", "https://")):
402
+ Thread = jclass("java.lang.Thread")
403
+ Runnable = jclass("java.lang.Runnable")
404
+ URL = jclass("java.net.URL")
405
+ BitmapFactory = jclass("android.graphics.BitmapFactory")
406
+ Handler = jclass("android.os.Handler")
407
+ Looper = jclass("android.os.Looper")
408
+ handler = Handler(Looper.getMainLooper())
409
+
410
+ class LoadTask(dynamic_proxy(Runnable)):
411
+ def __init__(self, image_view: Any, url_str: str, main_handler: Any) -> None:
412
+ super().__init__()
413
+ self.image_view = image_view
414
+ self.url_str = url_str
415
+ self.main_handler = main_handler
416
+
417
+ def run(self) -> None:
418
+ try:
419
+ url = URL(self.url_str)
420
+ stream = url.openStream()
421
+ bitmap = BitmapFactory.decodeStream(stream)
422
+ stream.close()
423
+
424
+ class SetImage(dynamic_proxy(Runnable)):
425
+ def __init__(self, view: Any, bmp: Any) -> None:
426
+ super().__init__()
427
+ self.view = view
428
+ self.bmp = bmp
429
+
430
+ def run(self) -> None:
431
+ self.view.setImageBitmap(self.bmp)
432
+
433
+ self.main_handler.post(SetImage(self.image_view, bitmap))
434
+ except Exception:
435
+ pass
436
+
437
+ Thread(LoadTask(iv, source, handler)).start()
438
+ else:
439
+ ctx = _ctx()
440
+ res = ctx.getResources()
441
+ pkg = ctx.getPackageName()
442
+ res_name = source.rsplit(".", 1)[0] if "." in source else source
443
+ res_id = res.getIdentifier(res_name, "drawable", pkg)
444
+ if res_id != 0:
445
+ iv.setImageResource(res_id)
446
+ except Exception:
447
+ pass
448
+
449
+
450
+ class SwitchHandler(ViewHandler):
451
+ def create(self, props: Dict[str, Any]) -> Any:
452
+ sw = jclass("android.widget.Switch")(_ctx())
453
+ self._apply(sw, props)
454
+ _apply_layout(sw, props)
455
+ return sw
456
+
457
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
458
+ self._apply(native_view, changed)
459
+
460
+ def _apply(self, sw: Any, props: Dict[str, Any]) -> None:
461
+ if "value" in props:
462
+ sw.setChecked(bool(props["value"]))
463
+ if "on_change" in props and props["on_change"] is not None:
464
+ cb = props["on_change"]
465
+
466
+ class CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)):
467
+ def __init__(self, callback: Callable[[bool], None]) -> None:
468
+ super().__init__()
469
+ self.callback = callback
470
+
471
+ def onCheckedChanged(self, button: Any, checked: bool) -> None:
472
+ self.callback(checked)
473
+
474
+ sw.setOnCheckedChangeListener(CheckedProxy(cb))
475
+
476
+
477
+ class ProgressBarHandler(ViewHandler):
478
+ def create(self, props: Dict[str, Any]) -> Any:
479
+ style = jclass("android.R$attr").progressBarStyleHorizontal
480
+ pb = jclass("android.widget.ProgressBar")(_ctx(), None, 0, style)
481
+ pb.setMax(1000)
482
+ self._apply(pb, props)
483
+ _apply_layout(pb, props)
484
+ return pb
485
+
486
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
487
+ self._apply(native_view, changed)
488
+
489
+ def _apply(self, pb: Any, props: Dict[str, Any]) -> None:
490
+ if "value" in props:
491
+ pb.setProgress(int(float(props["value"]) * 1000))
492
+
493
+
494
+ class ActivityIndicatorHandler(ViewHandler):
495
+ def create(self, props: Dict[str, Any]) -> Any:
496
+ pb = jclass("android.widget.ProgressBar")(_ctx())
497
+ if not props.get("animating", True):
498
+ pb.setVisibility(jclass("android.view.View").GONE)
499
+ _apply_layout(pb, props)
500
+ return pb
501
+
502
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
503
+ View = jclass("android.view.View")
504
+ if "animating" in changed:
505
+ native_view.setVisibility(View.VISIBLE if changed["animating"] else View.GONE)
506
+
507
+
508
+ class WebViewHandler(ViewHandler):
509
+ def create(self, props: Dict[str, Any]) -> Any:
510
+ wv = jclass("android.webkit.WebView")(_ctx())
511
+ if "url" in props and props["url"]:
512
+ wv.loadUrl(str(props["url"]))
513
+ _apply_layout(wv, props)
514
+ return wv
515
+
516
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
517
+ if "url" in changed and changed["url"]:
518
+ native_view.loadUrl(str(changed["url"]))
519
+
520
+
521
+ class SpacerHandler(ViewHandler):
522
+ def create(self, props: Dict[str, Any]) -> Any:
523
+ v = jclass("android.view.View")(_ctx())
524
+ if "size" in props and props["size"] is not None:
525
+ px = _dp(float(props["size"]))
526
+ lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px)
527
+ v.setLayoutParams(lp)
528
+ if "flex" in props and props["flex"] is not None:
529
+ lp = v.getLayoutParams()
530
+ if lp is None:
531
+ lp = jclass("android.widget.LinearLayout$LayoutParams")(0, 0)
532
+ lp.weight = float(props["flex"])
533
+ v.setLayoutParams(lp)
534
+ return v
535
+
536
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
537
+ if "size" in changed and changed["size"] is not None:
538
+ px = _dp(float(changed["size"]))
539
+ lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px)
540
+ native_view.setLayoutParams(lp)
541
+
542
+
543
+ class SafeAreaViewHandler(ViewHandler):
544
+ """Safe-area container using FrameLayout with ``fitsSystemWindows``."""
545
+
546
+ def create(self, props: Dict[str, Any]) -> Any:
547
+ fl = jclass("android.widget.FrameLayout")(_ctx())
548
+ fl.setFitsSystemWindows(True)
549
+ if "background_color" in props and props["background_color"] is not None:
550
+ fl.setBackgroundColor(parse_color_int(props["background_color"]))
551
+ if "padding" in props:
552
+ left, top, right, bottom = resolve_padding(props["padding"])
553
+ fl.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom))
554
+ return fl
555
+
556
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
557
+ if "background_color" in changed and changed["background_color"] is not None:
558
+ native_view.setBackgroundColor(parse_color_int(changed["background_color"]))
559
+
560
+ def add_child(self, parent: Any, child: Any) -> None:
561
+ parent.addView(child)
562
+
563
+ def remove_child(self, parent: Any, child: Any) -> None:
564
+ parent.removeView(child)
565
+
566
+
567
+ class ModalHandler(ViewHandler):
568
+ def create(self, props: Dict[str, Any]) -> Any:
569
+ placeholder = jclass("android.view.View")(_ctx())
570
+ placeholder.setVisibility(jclass("android.view.View").GONE)
571
+ return placeholder
572
+
573
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
574
+ pass
575
+
576
+ def add_child(self, parent: Any, child: Any) -> None:
577
+ pass
578
+
579
+
580
+ class SliderHandler(ViewHandler):
581
+ def create(self, props: Dict[str, Any]) -> Any:
582
+ sb = jclass("android.widget.SeekBar")(_ctx())
583
+ sb.setMax(1000)
584
+ self._apply(sb, props)
585
+ _apply_layout(sb, props)
586
+ return sb
587
+
588
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
589
+ self._apply(native_view, changed)
590
+
591
+ def _apply(self, sb: Any, props: Dict[str, Any]) -> None:
592
+ min_val = float(props.get("min_value", 0))
593
+ max_val = float(props.get("max_value", 1))
594
+ rng = max_val - min_val if max_val != min_val else 1
595
+ if "value" in props:
596
+ normalized = (float(props["value"]) - min_val) / rng
597
+ sb.setProgress(int(normalized * 1000))
598
+ if "on_change" in props and props["on_change"] is not None:
599
+ cb = props["on_change"]
600
+
601
+ class SeekProxy(dynamic_proxy(jclass("android.widget.SeekBar").OnSeekBarChangeListener)):
602
+ def __init__(self, callback: Callable[[float], None], mn: float, rn: float) -> None:
603
+ super().__init__()
604
+ self.callback = callback
605
+ self.mn = mn
606
+ self.rn = rn
607
+
608
+ def onProgressChanged(self, seekBar: Any, progress: int, fromUser: bool) -> None:
609
+ if fromUser:
610
+ self.callback(self.mn + (progress / 1000.0) * self.rn)
611
+
612
+ def onStartTrackingTouch(self, seekBar: Any) -> None:
613
+ pass
614
+
615
+ def onStopTrackingTouch(self, seekBar: Any) -> None:
616
+ pass
617
+
618
+ sb.setOnSeekBarChangeListener(SeekProxy(cb, min_val, rng))
619
+
620
+
621
+ _android_tabbar_state: dict = {"callback": None, "items": []}
622
+
623
+
624
+ class TabBarHandler(ViewHandler):
625
+ """Native tab bar using ``BottomNavigationView`` from Material Components.
626
+
627
+ Falls back to a horizontal ``LinearLayout`` with ``Button`` children
628
+ when Material Components is unavailable.
629
+ """
630
+
631
+ _is_material: bool = True
632
+
633
+ def create(self, props: Dict[str, Any]) -> Any:
634
+ try:
635
+ bnv = jclass("com.google.android.material.bottomnavigation.BottomNavigationView")(_ctx())
636
+ bnv.setBackgroundColor(parse_color_int("#FFFFFF"))
637
+ ViewGroupLP = jclass("android.view.ViewGroup$LayoutParams")
638
+ LayoutParams = jclass("android.widget.LinearLayout$LayoutParams")
639
+ lp = LayoutParams(ViewGroupLP.MATCH_PARENT, ViewGroupLP.WRAP_CONTENT)
640
+ bnv.setLayoutParams(lp)
641
+ self._is_material = True
642
+ self._apply_full(bnv, props)
643
+ return bnv
644
+ except Exception:
645
+ self._is_material = False
646
+ return self._create_fallback(props)
647
+
648
+ def _create_fallback(self, props: Dict[str, Any]) -> Any:
649
+ """Horizontal LinearLayout with Button children as a tab-bar fallback."""
650
+ LinearLayout = jclass("android.widget.LinearLayout")
651
+ ll = LinearLayout(_ctx())
652
+ ll.setOrientation(LinearLayout.HORIZONTAL)
653
+ ll.setBackgroundColor(parse_color_int("#F8F8F8"))
654
+ self._apply_fallback(ll, props)
655
+ return ll
656
+
657
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
658
+ if self._is_material:
659
+ self._apply_partial(native_view, changed)
660
+ else:
661
+ self._apply_fallback(native_view, changed)
662
+
663
+ def _apply_full(self, bnv: Any, props: Dict[str, Any]) -> None:
664
+ """Initial creation — all props are present."""
665
+ items = props.get("items", [])
666
+ self._set_menu(bnv, items)
667
+ self._set_active(bnv, props.get("active_tab"), items)
668
+ cb = props.get("on_tab_select")
669
+ if cb is not None:
670
+ self._set_listener(bnv, cb, items)
671
+
672
+ def _apply_partial(self, bnv: Any, changed: Dict[str, Any]) -> None:
673
+ """Reconciler update — only changed props are present."""
674
+ prev_items = _android_tabbar_state["items"]
675
+
676
+ if "items" in changed:
677
+ items = changed["items"]
678
+ self._set_menu(bnv, items)
679
+ else:
680
+ items = prev_items
681
+
682
+ if "active_tab" in changed:
683
+ self._set_active(bnv, changed["active_tab"], items)
684
+
685
+ if "on_tab_select" in changed:
686
+ cb = changed["on_tab_select"]
687
+ if cb is not None:
688
+ self._set_listener(bnv, cb, items)
689
+
690
+ def _set_menu(self, bnv: Any, items: list) -> None:
691
+ _android_tabbar_state["items"] = items
692
+ try:
693
+ menu = bnv.getMenu()
694
+ menu.clear()
695
+ for i, item in enumerate(items):
696
+ title = item.get("title", item.get("name", ""))
697
+ menu.add(0, i, i, str(title))
698
+ except Exception:
699
+ pass
700
+
701
+ def _set_active(self, bnv: Any, active: Any, items: list) -> None:
702
+ if active and items:
703
+ for i, item in enumerate(items):
704
+ if item.get("name") == active:
705
+ try:
706
+ bnv.setSelectedItemId(i)
707
+ except Exception:
708
+ pass
709
+ break
710
+
711
+ def _set_listener(self, bnv: Any, cb: Callable, items: list) -> None:
712
+ _android_tabbar_state["callback"] = cb
713
+ _android_tabbar_state["items"] = items
714
+ try:
715
+ listener_cls = jclass("com.google.android.material.navigation.NavigationBarView$OnItemSelectedListener")
716
+
717
+ class _TabSelectProxy(dynamic_proxy(listener_cls)):
718
+ def __init__(self, callback: Callable, tab_items: list) -> None:
719
+ super().__init__()
720
+ self.callback = callback
721
+ self.tab_items = tab_items
722
+
723
+ def onNavigationItemSelected(self, menu_item: Any) -> bool:
724
+ idx = menu_item.getItemId()
725
+ if 0 <= idx < len(self.tab_items):
726
+ self.callback(self.tab_items[idx].get("name", ""))
727
+ return True
728
+
729
+ bnv.setOnItemSelectedListener(_TabSelectProxy(cb, items))
730
+ except Exception:
731
+ pass
732
+
733
+ def _apply_fallback(self, ll: Any, props: Dict[str, Any]) -> None:
734
+ items = props.get("items", [])
735
+ active = props.get("active_tab")
736
+ cb = props.get("on_tab_select")
737
+ if "items" in props:
738
+ ll.removeAllViews()
739
+ for item in items:
740
+ name = item.get("name", "")
741
+ title = item.get("title", name)
742
+ btn = jclass("android.widget.Button")(_ctx())
743
+ btn.setText(str(title))
744
+ btn.setEnabled(name != active)
745
+ if cb is not None:
746
+ tab_name = name
747
+
748
+ def _make_click(n: str) -> Callable[[], None]:
749
+ return lambda: cb(n)
750
+
751
+ class _ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
752
+ def __init__(self, callback: Callable[[], None]) -> None:
753
+ super().__init__()
754
+ self.callback = callback
755
+
756
+ def onClick(self, view: Any) -> None:
757
+ self.callback()
758
+
759
+ btn.setOnClickListener(_ClickProxy(_make_click(tab_name)))
760
+ ll.addView(btn)
761
+
762
+
763
+ class PressableHandler(ViewHandler):
764
+ def create(self, props: Dict[str, Any]) -> Any:
765
+ fl = jclass("android.widget.FrameLayout")(_ctx())
766
+ fl.setClickable(True)
767
+ self._apply(fl, props)
768
+ return fl
769
+
770
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
771
+ self._apply(native_view, changed)
772
+
773
+ def _apply(self, fl: Any, props: Dict[str, Any]) -> None:
774
+ if "on_press" in props and props["on_press"] is not None:
775
+ cb = props["on_press"]
776
+
777
+ class PressProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
778
+ def __init__(self, callback: Callable[[], None]) -> None:
779
+ super().__init__()
780
+ self.callback = callback
781
+
782
+ def onClick(self, view: Any) -> None:
783
+ self.callback()
784
+
785
+ fl.setOnClickListener(PressProxy(cb))
786
+ if "on_long_press" in props and props["on_long_press"] is not None:
787
+ cb = props["on_long_press"]
788
+
789
+ class LongPressProxy(dynamic_proxy(jclass("android.view.View").OnLongClickListener)):
790
+ def __init__(self, callback: Callable[[], None]) -> None:
791
+ super().__init__()
792
+ self.callback = callback
793
+
794
+ def onLongClick(self, view: Any) -> bool:
795
+ self.callback()
796
+ return True
797
+
798
+ fl.setOnLongClickListener(LongPressProxy(cb))
799
+
800
+ def add_child(self, parent: Any, child: Any) -> None:
801
+ parent.addView(child)
802
+
803
+ def remove_child(self, parent: Any, child: Any) -> None:
804
+ parent.removeView(child)
805
+
806
+
807
+ # ======================================================================
808
+ # Registration
809
+ # ======================================================================
810
+
811
+
812
+ def register_handlers(registry: Any) -> None:
813
+ """Register all Android view handlers with the given registry."""
814
+ flex = FlexContainerHandler()
815
+ registry.register("Text", TextHandler())
816
+ registry.register("Button", ButtonHandler())
817
+ registry.register("Column", flex)
818
+ registry.register("Row", flex)
819
+ registry.register("View", flex)
820
+ registry.register("ScrollView", ScrollViewHandler())
821
+ registry.register("TextInput", TextInputHandler())
822
+ registry.register("Image", ImageHandler())
823
+ registry.register("Switch", SwitchHandler())
824
+ registry.register("ProgressBar", ProgressBarHandler())
825
+ registry.register("ActivityIndicator", ActivityIndicatorHandler())
826
+ registry.register("WebView", WebViewHandler())
827
+ registry.register("Spacer", SpacerHandler())
828
+ registry.register("SafeAreaView", SafeAreaViewHandler())
829
+ registry.register("Modal", ModalHandler())
830
+ registry.register("Slider", SliderHandler())
831
+ registry.register("TabBar", TabBarHandler())
832
+ registry.register("Pressable", PressableHandler())