pythonnative 0.20.0__py3-none-any.whl → 0.22.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.
Files changed (33) hide show
  1. pythonnative/__init__.py +14 -3
  2. pythonnative/animated.py +420 -135
  3. pythonnative/cli/pn.py +450 -956
  4. pythonnative/components.py +519 -235
  5. pythonnative/events.py +210 -0
  6. pythonnative/gestures.py +875 -0
  7. pythonnative/layout.py +463 -149
  8. pythonnative/mutations.py +130 -0
  9. pythonnative/native_views/__init__.py +161 -97
  10. pythonnative/native_views/android.py +1050 -1124
  11. pythonnative/native_views/base.py +108 -18
  12. pythonnative/native_views/desktop.py +460 -417
  13. pythonnative/native_views/ios.py +1918 -1916
  14. pythonnative/project/__init__.py +68 -0
  15. pythonnative/project/android.py +504 -0
  16. pythonnative/project/builder.py +555 -0
  17. pythonnative/project/config.py +642 -0
  18. pythonnative/project/doctor.py +233 -0
  19. pythonnative/project/icons.py +247 -0
  20. pythonnative/project/ios.py +344 -0
  21. pythonnative/project/permissions.py +343 -0
  22. pythonnative/project/runtime_assets.py +272 -0
  23. pythonnative/reconciler.py +540 -470
  24. pythonnative/screen.py +5 -2
  25. pythonnative/sdk/_components.py +2 -2
  26. pythonnative/templates/android_template/app/build.gradle +2 -0
  27. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/METADATA +10 -2
  28. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/RECORD +32 -21
  29. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +0 -129
  30. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/WHEEL +0 -0
  31. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/entry_points.txt +0 -0
  32. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/licenses/LICENSE +0 -0
  33. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/top_level.txt +0 -0
@@ -6,14 +6,27 @@ and frame application. Handlers are registered with the
6
6
  [`NativeViewRegistry`][pythonnative.native_views.NativeViewRegistry] by
7
7
  [`register_handlers`][pythonnative.native_views.android.register_handlers].
8
8
 
9
- Layout is owned by the pure-Python flex engine in
10
- [`pythonnative.layout`][pythonnative.layout]: container handlers create
11
- plain `FrameLayout`s, the engine computes per-child frames, and
9
+ **Batched protocol**: the registry applies the reconciler's mutation
10
+ ops; handlers receive callable-free props. User callbacks never reach
11
+ this module every interaction (clicks, text changes, scrolls,
12
+ gestures) is forwarded through
13
+ [`dispatch_event`][pythonnative.events.dispatch_event] keyed by the
14
+ view's reconciler-assigned tag.
15
+
16
+ **Layout** is owned by the pure-Python flex engine in
17
+ `pythonnative.layout`: container handlers create plain `FrameLayout`s,
18
+ the engine computes per-child frames, and
12
19
  [`set_frame`][pythonnative.native_views.android.AndroidViewHandler.set_frame]
13
- applies those frames via per-child `MarginLayoutParams`. Handlers
14
- therefore only deal with *visual* props — text, colors, callbacks — and
15
- ignore everything in
16
- [`pythonnative.layout.LAYOUT_STYLE_KEYS`][pythonnative.layout.LAYOUT_STYLE_KEYS].
20
+ applies those frames via per-child `MarginLayoutParams`.
21
+
22
+ **Gestures** feed raw ``MotionEvent`` streams into the shared
23
+ pure-Python [`GestureArbiter`][pythonnative.gestures.GestureArbiter],
24
+ so gesture semantics match the desktop preview exactly.
25
+
26
+ **Animations**: ``timing`` animations on transform/opacity/color props
27
+ are driven natively by ``ObjectAnimator`` (Choreographer-paced, no
28
+ Python per-frame work); springs and decay fall back to the Python
29
+ ticker.
17
30
 
18
31
  This module is only imported on Android at runtime. Desktop tests
19
32
  inject a mock registry via
@@ -22,20 +35,16 @@ trigger this import path.
22
35
  """
23
36
 
24
37
  import math
38
+ import time
25
39
  from typing import Any, Callable, Dict, Optional, Tuple
26
40
 
27
41
  from java import dynamic_proxy, jclass
28
42
 
43
+ from ..events import dispatch_event, event_names
44
+ from ..gestures import make_arbiter
29
45
  from ..utils import get_android_context
30
46
  from .base import ViewHandler, _safe_max, parse_color_int
31
47
 
32
- _pn_text_input_watchers: dict = {}
33
- _pn_text_input_callbacks: dict = {}
34
- _pn_text_input_suppress_callbacks: dict = {}
35
- _pn_text_input_focus_listeners: dict = {}
36
- _pn_text_input_focus_callbacks: dict = {}
37
- _pn_text_input_clear_touch: dict = {}
38
- _pn_view_visual_props: dict = {}
39
48
  _DRAWABLE_STYLE_KEYS = ("background_color", "border_radius", "border_width", "border_color")
40
49
 
41
50
 
@@ -56,6 +65,59 @@ def _dp(value: float) -> int:
56
65
  return int(round(value * _density()))
57
66
 
58
67
 
68
+ def _java_id(jobj: Any) -> int:
69
+ """Return ``System.identityHashCode(jobj)`` as a stable lookup key.
70
+
71
+ Chaquopy's ``JavaObject.__setattr__`` rejects unknown Python
72
+ attributes, so per-view bookkeeping can't live on the wrapper.
73
+ The JVM identity hash is stable for the lifetime of the Java
74
+ object and identical across every Python wrapper that proxies it.
75
+ """
76
+ System = jclass("java.lang.System")
77
+ return int(System.identityHashCode(jobj))
78
+
79
+
80
+ # Tag table: java identity -> reconciler tag, and tag -> per-view state.
81
+ _view_tags: Dict[int, int] = {}
82
+ _view_state: Dict[int, Dict[str, Any]] = {}
83
+
84
+
85
+ def _remember(view: Any, tag: int) -> None:
86
+ _view_tags[_java_id(view)] = tag
87
+ _view_state[tag] = {"props": {}}
88
+
89
+
90
+ def _tag_of(view: Any) -> Optional[int]:
91
+ return _view_tags.get(_java_id(view))
92
+
93
+
94
+ def _state_of(view: Any) -> Dict[str, Any]:
95
+ tag = _tag_of(view)
96
+ if tag is None:
97
+ return {}
98
+ return _view_state.setdefault(tag, {"props": {}})
99
+
100
+
101
+ def _forget(view: Any) -> None:
102
+ tag = _view_tags.pop(_java_id(view), None)
103
+ if tag is not None:
104
+ _view_state.pop(tag, None)
105
+
106
+
107
+ def _fire(view: Any, name: str, *args: Any) -> bool:
108
+ """Dispatch event ``name`` for ``view`` through the tag registry."""
109
+ tag = _tag_of(view)
110
+ if tag is None:
111
+ return False
112
+ return dispatch_event(tag, name, *args)
113
+
114
+
115
+ def _has_event(view: Any, name: str) -> bool:
116
+ """Whether the element wired a callback named ``name`` this render."""
117
+ merged = _state_of(view).get("props") or {}
118
+ return name in event_names(merged)
119
+
120
+
59
121
  def _apply_border(view: Any, props: Dict[str, Any]) -> None:
60
122
  """Apply border_radius / border_width / border_color via a GradientDrawable.
61
123
 
@@ -191,11 +253,12 @@ def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None:
191
253
  """Apply visual properties shared across many handlers."""
192
254
  has_drawable_keys = any(k in props for k in _DRAWABLE_STYLE_KEYS)
193
255
  if has_drawable_keys:
194
- visual_props = dict(_pn_view_visual_props.get(id(view), {}))
256
+ state = _state_of(view)
257
+ visual_props = dict(state.get("visual") or {})
195
258
  for key in _DRAWABLE_STYLE_KEYS:
196
259
  if key in props:
197
260
  visual_props[key] = props[key]
198
- _pn_view_visual_props[id(view)] = visual_props
261
+ state["visual"] = visual_props
199
262
  _apply_border(view, visual_props)
200
263
  if "overflow" in props:
201
264
  clip = props["overflow"] == "hidden"
@@ -215,19 +278,299 @@ def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None:
215
278
 
216
279
 
217
280
  # ======================================================================
218
- # Base class with shared frame/measure implementations
281
+ # Gesture wiring (MotionEvent -> GestureArbiter -> dispatch_event)
219
282
  # ======================================================================
220
283
 
221
284
 
222
- class AndroidViewHandler(ViewHandler):
223
- """Base class providing the shared `set_frame` / measure contract.
285
+ def _wire_gestures(view: Any, specs: Any) -> None:
286
+ """Feed ``MotionEvent`` streams on ``view`` into a `GestureArbiter`.
287
+
288
+ The arbiter emits ``(gesture_index, payload)`` pairs which are
289
+ forwarded as ``gesture:<i>`` events for this view's tag. Long
290
+ presses are polled with a main-looper ``Handler``. When a pan
291
+ activates, the parent is asked not to intercept so an enclosing
292
+ ScrollView can't steal the drag.
293
+ """
294
+ state = _state_of(view)
295
+ if not isinstance(specs, (list, tuple)) or not specs:
296
+ state["arbiter"] = None
297
+ return
298
+
299
+ def _emit(index: int, payload: Dict[str, Any]) -> None:
300
+ _fire(view, f"gesture:{index}", payload)
301
+
302
+ arbiter = make_arbiter([s for s in specs if isinstance(s, dict)], _emit)
303
+ state["arbiter"] = arbiter
304
+ if state.get("gestures_bound"):
305
+ return
306
+ state["gestures_bound"] = True
307
+ _bind_touch_stream(view, state)
308
+
309
+
310
+ def _schedule_arbiter_poll(view: Any, state: Dict[str, Any]) -> None:
311
+ """Schedule a main-looper poll at the arbiter's next deadline (long-press)."""
312
+ arbiter = state.get("arbiter")
313
+ if arbiter is None:
314
+ return
315
+ deadline = arbiter.next_deadline()
316
+ if deadline is None:
317
+ return
318
+ delay_ms = max(1, int((deadline - time.monotonic()) * 1000.0))
319
+ try:
320
+ Handler = jclass("android.os.Handler")
321
+ Looper = jclass("android.os.Looper")
322
+ Runnable = jclass("java.lang.Runnable")
323
+ handler = state.get("poll_handler")
324
+ if handler is None:
325
+ handler = Handler(Looper.getMainLooper())
326
+ state["poll_handler"] = handler
327
+
328
+ class _PollRunnable(dynamic_proxy(Runnable)):
329
+ def run(self) -> None:
330
+ live = state.get("arbiter")
331
+ if live is not None:
332
+ live.poll(time.monotonic())
333
+ _schedule_arbiter_poll(view, state)
334
+
335
+ handler.postDelayed(_PollRunnable(), delay_ms)
336
+ except Exception:
337
+ pass
338
+
339
+
340
+ def _bind_touch_stream(view: Any, state: Dict[str, Any]) -> None:
341
+ """Install one ``OnTouchListener`` that forwards every pointer to the arbiter."""
342
+ try:
343
+ OnTouchListener = jclass("android.view.View$OnTouchListener")
344
+
345
+ class _GestureTouchProxy(dynamic_proxy(OnTouchListener)):
346
+ def onTouch(self, v: Any, event: Any) -> bool:
347
+ return bool(_feed_motion_event(v, state, event))
348
+
349
+ view.setOnTouchListener(_GestureTouchProxy())
350
+ except Exception:
351
+ pass
352
+
353
+
354
+ def _feed_motion_event(view: Any, state: Dict[str, Any], event: Any) -> bool:
355
+ """Translate one ``MotionEvent`` into arbiter pointer calls.
356
+
357
+ Returns ``True`` to keep receiving the stream while any gesture
358
+ spec is wired.
359
+ """
360
+ arbiter = state.get("arbiter")
361
+ if arbiter is None:
362
+ return False
363
+ try:
364
+ action = int(event.getActionMasked())
365
+ t = time.monotonic()
366
+ density = _density() or 1.0
367
+ if action in (0, 5): # DOWN / POINTER_DOWN
368
+ idx = int(event.getActionIndex())
369
+ pid = int(event.getPointerId(idx))
370
+ arbiter.pointer_down(pid, float(event.getX(idx)) / density, float(event.getY(idx)) / density, t)
371
+ _schedule_arbiter_poll(view, state)
372
+ elif action == 2: # MOVE
373
+ for i in range(int(event.getPointerCount())):
374
+ pid = int(event.getPointerId(i))
375
+ arbiter.pointer_move(pid, float(event.getX(i)) / density, float(event.getY(i)) / density, t)
376
+ if arbiter.has_active_pan():
377
+ try:
378
+ parent = view.getParent()
379
+ if parent is not None:
380
+ parent.requestDisallowInterceptTouchEvent(True)
381
+ except Exception:
382
+ pass
383
+ elif action in (1, 6): # UP / POINTER_UP
384
+ idx = int(event.getActionIndex())
385
+ pid = int(event.getPointerId(idx))
386
+ arbiter.pointer_up(pid, float(event.getX(idx)) / density, float(event.getY(idx)) / density, t)
387
+ elif action == 3: # CANCEL
388
+ arbiter.cancel(t)
389
+ return True
390
+ except Exception:
391
+ return True
392
+
393
+
394
+ # ======================================================================
395
+ # Native-driven animations (ObjectAnimator)
396
+ # ======================================================================
397
+
398
+ _native_anims: Dict[int, Dict[str, Any]] = {}
399
+
400
+
401
+ def _interpolator_for(easing: str) -> Any:
402
+ mapping = {
403
+ "linear": "android.view.animation.LinearInterpolator",
404
+ "ease_in": "android.view.animation.AccelerateInterpolator",
405
+ "ease_out": "android.view.animation.DecelerateInterpolator",
406
+ "ease_in_out": "android.view.animation.AccelerateDecelerateInterpolator",
407
+ }
408
+ cls = mapping.get(easing, "android.view.animation.AccelerateDecelerateInterpolator")
409
+ return jclass(cls)()
410
+
411
+
412
+ _ANIMATABLE_FLOAT_PROPS = {
413
+ "opacity": ("alpha", 1.0),
414
+ "translate_x": ("translationX", None), # dp -> px scaling
415
+ "translate_y": ("translationY", None),
416
+ "scale_x": ("scaleX", 1.0),
417
+ "scale_y": ("scaleY", 1.0),
418
+ "rotate": ("rotation", 1.0),
419
+ }
420
+
224
421
 
225
- All Android handlers go through `set_frame` to apply the layout
226
- engine's computed frames as `MarginLayoutParams` mutations.
227
- Container handlers inherit the default `add_child` /
228
- `remove_child` implementations; leaves leave them as no-ops.
422
+ def _read_animated_value(view: Any, prop_name: str) -> Any:
423
+ """Read the current (presentation) value of an animatable property."""
424
+ try:
425
+ density = _density() or 1.0
426
+ if prop_name == "opacity":
427
+ return float(view.getAlpha())
428
+ if prop_name == "translate_x":
429
+ return float(view.getTranslationX()) / density
430
+ if prop_name == "translate_y":
431
+ return float(view.getTranslationY()) / density
432
+ if prop_name in ("scale", "scale_x"):
433
+ return float(view.getScaleX())
434
+ if prop_name == "scale_y":
435
+ return float(view.getScaleY())
436
+ if prop_name == "rotate":
437
+ return float(view.getRotation())
438
+ except Exception:
439
+ pass
440
+ return None
441
+
442
+
443
+ def _make_end_listener(anim_id: int) -> Any:
444
+ """Build an ``Animator.AnimatorListener`` that reports completion to Python."""
445
+ AnimatorListener = jclass("android.animation.Animator$AnimatorListener")
446
+
447
+ class _EndProxy(dynamic_proxy(AnimatorListener)):
448
+ def __init__(self) -> None:
449
+ super().__init__()
450
+ self._cancelled = False
451
+
452
+ def onAnimationStart(self, animation: Any) -> None:
453
+ pass
454
+
455
+ def onAnimationRepeat(self, animation: Any) -> None:
456
+ pass
457
+
458
+ def onAnimationCancel(self, animation: Any) -> None:
459
+ self._cancelled = True
460
+
461
+ def onAnimationEnd(self, animation: Any) -> None:
462
+ entry = _native_anims.pop(anim_id, None)
463
+ if entry is None:
464
+ return
465
+ try:
466
+ from ..animated import native_animation_completed
467
+
468
+ native_animation_completed(anim_id, not self._cancelled)
469
+ except Exception:
470
+ pass
471
+
472
+ return _EndProxy()
473
+
474
+
475
+ def _start_native_timing(view: Any, anim_id: int, prop_name: str, spec: Dict[str, Any]) -> bool:
476
+ """Drive a ``timing`` spec with ``ObjectAnimator``. Returns success."""
477
+ try:
478
+ ObjectAnimator = jclass("android.animation.ObjectAnimator")
479
+ from_val = float(spec.get("from", 0.0))
480
+ to_val = float(spec.get("to", 0.0))
481
+ duration = max(0, int(float(spec.get("duration_ms", 300.0))))
482
+ density = _density() or 1.0
483
+
484
+ if prop_name == "background_color":
485
+ animator = ObjectAnimator.ofArgb(
486
+ view,
487
+ "backgroundColor",
488
+ parse_color_int(int(from_val)),
489
+ parse_color_int(int(to_val)),
490
+ )
491
+ elif prop_name == "scale":
492
+ AnimatorSet = jclass("android.animation.AnimatorSet")
493
+ sx = ObjectAnimator.ofFloat(view, "scaleX", from_val, to_val)
494
+ sy = ObjectAnimator.ofFloat(view, "scaleY", from_val, to_val)
495
+ group = AnimatorSet()
496
+ group.playTogether([sx, sy])
497
+ group.setDuration(duration)
498
+ group.setInterpolator(_interpolator_for(str(spec.get("easing", "ease_in_out"))))
499
+ group.addListener(_make_end_listener(anim_id))
500
+ _native_anims[anim_id] = {"animator": group, "view": view, "prop": prop_name}
501
+ group.start()
502
+ return True
503
+ else:
504
+ java_prop, scale = _ANIMATABLE_FLOAT_PROPS.get(prop_name, (None, None))
505
+ if java_prop is None:
506
+ return False
507
+ factor = density if scale is None else scale
508
+ animator = ObjectAnimator.ofFloat(view, java_prop, from_val * factor, to_val * factor)
509
+
510
+ animator.setDuration(duration)
511
+ animator.setInterpolator(_interpolator_for(str(spec.get("easing", "ease_in_out"))))
512
+ animator.addListener(_make_end_listener(anim_id))
513
+ _native_anims[anim_id] = {"animator": animator, "view": view, "prop": prop_name}
514
+ animator.start()
515
+ return True
516
+ except Exception:
517
+ _native_anims.pop(anim_id, None)
518
+ return False
519
+
520
+
521
+ # ======================================================================
522
+ # Base class with shared frame/measure/animation implementations
523
+ # ======================================================================
524
+
525
+
526
+ class AndroidViewHandler(ViewHandler):
527
+ """Base class providing the shared protocol implementation.
528
+
529
+ Subclasses implement
530
+ [`_build`][pythonnative.native_views.android.AndroidViewHandler._build]
531
+ (construct the widget) and
532
+ [`_apply`][pythonnative.native_views.android.AndroidViewHandler._apply]
533
+ (apply visual props); the base class owns tag registration,
534
+ gesture wiring, frame application, intrinsic measurement, and the
535
+ animation hooks.
229
536
  """
230
537
 
538
+ def create(self, tag: int, props: Dict[str, Any]) -> Any:
539
+ view = self._build(props)
540
+ _remember(view, tag)
541
+ _state_of(view)["props"] = dict(props)
542
+ self._apply(view, props, initial=True)
543
+ if props.get("gestures"):
544
+ _wire_gestures(view, props.get("gestures"))
545
+ return view
546
+
547
+ def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None:
548
+ state = _state_of(native_view)
549
+ merged = state.setdefault("props", {})
550
+ merged.update(changed_props)
551
+ self._apply(native_view, changed_props, initial=False)
552
+ if "gestures" in changed_props:
553
+ _wire_gestures(native_view, changed_props.get("gestures"))
554
+
555
+ def destroy(self, native_view: Any) -> None:
556
+ self._teardown(native_view)
557
+ try:
558
+ parent = native_view.getParent()
559
+ if parent is not None:
560
+ parent.removeView(native_view)
561
+ except Exception:
562
+ pass
563
+ _forget(native_view)
564
+
565
+ def _build(self, props: Dict[str, Any]) -> Any:
566
+ raise NotImplementedError
567
+
568
+ def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
569
+ _apply_common_visual(view, props)
570
+
571
+ def _teardown(self, native_view: Any) -> None:
572
+ """Subclass hook for extra cleanup before the view is released."""
573
+
231
574
  def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
232
575
  if native_view is None:
233
576
  return
@@ -285,46 +628,11 @@ class AndroidViewHandler(ViewHandler):
285
628
  except Exception:
286
629
  return (0.0, 0.0)
287
630
 
288
- def set_animated_property(
289
- self,
290
- native_view: Any,
291
- prop_name: str,
292
- value: Any,
293
- duration_ms: float = 0.0,
294
- easing: str = "linear",
295
- ) -> None:
296
- """Apply ``prop_name`` to ``native_view`` immediately or animated.
297
-
298
- When ``duration_ms > 0``, the change is wrapped in a
299
- ``ViewPropertyAnimator`` so Choreographer drives the
300
- per-frame interpolation.
301
- """
631
+ def set_animated_property(self, native_view: Any, prop_name: str, value: Any) -> None:
632
+ """Apply one Python-driven animation frame immediately."""
302
633
  if native_view is None:
303
634
  return
304
635
  try:
305
- if duration_ms > 0:
306
- animator = native_view.animate()
307
- animator.setDuration(int(duration_ms))
308
- if prop_name == "opacity":
309
- animator.alpha(float(value))
310
- elif prop_name == "translate_x":
311
- animator.translationX(float(_dp(float(value))))
312
- elif prop_name == "translate_y":
313
- animator.translationY(float(_dp(float(value))))
314
- elif prop_name == "scale":
315
- animator.scaleX(float(value))
316
- animator.scaleY(float(value))
317
- elif prop_name == "scale_x":
318
- animator.scaleX(float(value))
319
- elif prop_name == "scale_y":
320
- animator.scaleY(float(value))
321
- elif prop_name == "rotate":
322
- animator.rotation(float(value))
323
- else:
324
- return
325
- animator.start()
326
- return
327
- # Immediate path.
328
636
  if prop_name == "opacity":
329
637
  native_view.setAlpha(float(value))
330
638
  elif prop_name == "translate_x":
@@ -345,6 +653,35 @@ class AndroidViewHandler(ViewHandler):
345
653
  except Exception:
346
654
  pass
347
655
 
656
+ def start_animation(
657
+ self,
658
+ native_view: Any,
659
+ anim_id: int,
660
+ prop_name: str,
661
+ spec: Dict[str, Any],
662
+ ) -> bool:
663
+ """Run ``timing`` specs on ``ObjectAnimator``; reject the rest.
664
+
665
+ Springs and decay need the exact physics integration the
666
+ Python ticker implements, so they return ``False`` and fall
667
+ back rather than approximating with an interpolator.
668
+ """
669
+ if native_view is None or not isinstance(spec, dict):
670
+ return False
671
+ if str(spec.get("kind", "")) != "timing":
672
+ return False
673
+ return _start_native_timing(native_view, anim_id, prop_name, spec)
674
+
675
+ def cancel_animation(self, native_view: Any, anim_id: int) -> Any:
676
+ entry = _native_anims.pop(anim_id, None)
677
+ if entry is None:
678
+ return None
679
+ try:
680
+ entry["animator"].cancel()
681
+ except Exception:
682
+ pass
683
+ return _read_animated_value(entry.get("view"), str(entry.get("prop", "")))
684
+
348
685
 
349
686
  # ======================================================================
350
687
  # Flex container handler (shared by Column, Row, View)
@@ -360,32 +697,43 @@ class FlexContainerHandler(AndroidViewHandler):
360
697
  The container itself is just a positioning surface.
361
698
  """
362
699
 
363
- def create(self, props: Dict[str, Any]) -> Any:
364
- fl = jclass("android.widget.FrameLayout")(_ctx())
365
- _apply_common_visual(fl, props)
366
- return fl
367
-
368
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
369
- _apply_common_visual(native_view, changed)
700
+ def _build(self, props: Dict[str, Any]) -> Any:
701
+ return jclass("android.widget.FrameLayout")(_ctx())
370
702
 
371
- def add_child(self, parent: Any, child: Any) -> None:
372
- FrameLP = jclass("android.widget.FrameLayout$LayoutParams")
373
- lp = child.getLayoutParams()
374
- if lp is None:
375
- lp = FrameLP(0, 0)
376
- child.setLayoutParams(lp)
377
- parent.addView(child)
703
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
704
+ _insert_view(parent, child, index)
378
705
 
379
706
  def remove_child(self, parent: Any, child: Any) -> None:
380
- parent.removeView(child)
707
+ try:
708
+ parent.removeView(child)
709
+ except Exception:
710
+ pass
381
711
 
382
- def insert_child(self, parent: Any, child: Any, index: int) -> None:
712
+
713
+ def _insert_view(parent: Any, child: Any, index: int) -> None:
714
+ """Move-aware indexed insert into any ``ViewGroup``."""
715
+ try:
716
+ current_parent = child.getParent()
717
+ count = int(parent.getChildCount())
718
+ if current_parent is not None and _java_id(current_parent) == _java_id(parent):
719
+ current_index = int(parent.indexOfChild(child))
720
+ target = max(0, min(index, count - 1))
721
+ if current_index == target:
722
+ return
723
+ parent.removeView(child)
724
+ parent.addView(child, max(0, min(target, int(parent.getChildCount()))))
725
+ return
726
+ if current_parent is not None:
727
+ current_parent.removeView(child)
383
728
  FrameLP = jclass("android.widget.FrameLayout$LayoutParams")
384
- lp = child.getLayoutParams()
385
- if lp is None:
386
- lp = FrameLP(0, 0)
387
- child.setLayoutParams(lp)
388
- parent.addView(child, index)
729
+ if child.getLayoutParams() is None:
730
+ child.setLayoutParams(FrameLP(0, 0))
731
+ parent.addView(child, max(0, min(index, int(parent.getChildCount()))))
732
+ except Exception:
733
+ try:
734
+ parent.addView(child)
735
+ except Exception:
736
+ pass
389
737
 
390
738
 
391
739
  # ======================================================================
@@ -411,15 +759,10 @@ def _typeface_for(weight: Any, italic: bool) -> Any:
411
759
 
412
760
 
413
761
  class TextHandler(AndroidViewHandler):
414
- def create(self, props: Dict[str, Any]) -> Any:
415
- tv = jclass("android.widget.TextView")(_ctx())
416
- self._apply(tv, props)
417
- return tv
762
+ def _build(self, props: Dict[str, Any]) -> Any:
763
+ return jclass("android.widget.TextView")(_ctx())
418
764
 
419
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
420
- self._apply(native_view, changed)
421
-
422
- def _apply(self, tv: Any, props: Dict[str, Any]) -> None:
765
+ def _apply(self, tv: Any, props: Dict[str, Any], initial: bool) -> None:
423
766
  if "text" in props:
424
767
  tv.setText(str(props["text"]) if props["text"] is not None else "")
425
768
  if "font_size" in props and props["font_size"] is not None:
@@ -429,9 +772,10 @@ class TextHandler(AndroidViewHandler):
429
772
  if any(k in props for k in ("font_family", "font_weight", "italic", "bold")):
430
773
  try:
431
774
  Typeface = jclass("android.graphics.Typeface")
432
- family = props.get("font_family")
433
- weight = props.get("font_weight") or ("bold" if props.get("bold") else None)
434
- italic = bool(props.get("italic"))
775
+ merged = _state_of(tv).get("props") or props
776
+ family = merged.get("font_family")
777
+ weight = merged.get("font_weight") or ("bold" if merged.get("bold") else None)
778
+ italic = bool(merged.get("italic"))
435
779
  style = _typeface_for(weight, italic)
436
780
  if family:
437
781
  base = Typeface.create(str(family), style)
@@ -450,13 +794,15 @@ class TextHandler(AndroidViewHandler):
450
794
  try:
451
795
  # Android expects letter_spacing as ems (a unitless ratio of font size).
452
796
  # Convert from points by dividing by ~font_size; if no font size, use 16.
453
- size = float(props.get("font_size") or 16.0)
797
+ merged = _state_of(tv).get("props") or props
798
+ size = float(merged.get("font_size") or 16.0)
454
799
  tv.setLetterSpacing(float(props["letter_spacing"]) / max(size, 1.0))
455
800
  except Exception:
456
801
  pass
457
802
  if "line_height" in props and props["line_height"] is not None:
458
803
  try:
459
- size = float(props.get("font_size") or 16.0)
804
+ merged = _state_of(tv).get("props") or props
805
+ size = float(merged.get("font_size") or 16.0)
460
806
  tv.setLineSpacing(0.0, float(props["line_height"]) / max(size, 1.0))
461
807
  except Exception:
462
808
  pass
@@ -475,15 +821,17 @@ class TextHandler(AndroidViewHandler):
475
821
 
476
822
 
477
823
  class ButtonHandler(AndroidViewHandler):
478
- def create(self, props: Dict[str, Any]) -> Any:
824
+ def _build(self, props: Dict[str, Any]) -> Any:
479
825
  btn = jclass("android.widget.Button")(_ctx())
480
- self._apply(btn, props)
481
- return btn
482
826
 
483
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
484
- self._apply(native_view, changed)
827
+ class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
828
+ def onClick(self, view: Any) -> None:
829
+ _fire(view, "on_click")
830
+
831
+ btn.setOnClickListener(ClickProxy())
832
+ return btn
485
833
 
486
- def _apply(self, btn: Any, props: Dict[str, Any]) -> None:
834
+ def _apply(self, btn: Any, props: Dict[str, Any], initial: bool) -> None:
487
835
  if "title" in props:
488
836
  btn.setText(str(props["title"]) if props["title"] is not None else "")
489
837
  if "font_size" in props and props["font_size"] is not None:
@@ -492,88 +840,72 @@ class ButtonHandler(AndroidViewHandler):
492
840
  btn.setTextColor(parse_color_int(props["color"]))
493
841
  if "enabled" in props:
494
842
  btn.setEnabled(bool(props["enabled"]))
495
- if "on_click" in props:
496
- cb = props["on_click"]
497
- if cb is not None:
498
-
499
- class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
500
- def __init__(self, callback: Callable[[], None]) -> None:
501
- super().__init__()
502
- self.callback = callback
503
-
504
- def onClick(self, view: Any) -> None:
505
- self.callback()
506
-
507
- btn.setOnClickListener(ClickProxy(cb))
508
- else:
509
- btn.setOnClickListener(None)
510
843
  _apply_common_visual(btn, props)
511
844
 
512
845
 
513
- _pn_scrollview_state: Dict[int, Dict[str, Any]] = {}
514
-
515
-
516
846
  class ScrollViewHandler(AndroidViewHandler):
517
847
  """Scroll container — wraps a single child whose height is unbounded.
518
848
 
519
- Uses ``androidx.core.widget.NestedScrollView`` rather than the
520
- framework ``android.widget.ScrollView`` because the framework
521
- ScrollView always intercepts vertical gestures, even when it has
522
- no overflow. That breaks the common case of nesting a small
523
- fixed-height scroll view inside a screen-level scroll view (the
524
- outer steals every gesture and the inner never scrolls).
525
- ``NestedScrollView`` implements the standard
526
- ``NestedScrollingParent2`` / ``NestedScrollingChild2`` protocol so
527
- the outer cooperates with any nested scroll, only consuming
528
- leftover scroll when its child reaches its limit.
849
+ Uses ``androidx.core.widget.NestedScrollView`` (vertical) or
850
+ ``android.widget.HorizontalScrollView`` so nested scroll views
851
+ cooperate instead of fighting over gestures.
529
852
 
530
853
  When a ``refresh_control`` prop is provided at creation, the scroll
531
- view is wrapped in an ``androidx.swiperefreshlayout.widget.SwipeRefreshLayout``
532
- (the returned view is the wrapper, and child management forwards
533
- into the inner scroll view) so pull-to-refresh matches the iOS
534
- ``UIRefreshControl`` path. Without ``refresh_control`` the bare
535
- scroll view is returned unchanged.
854
+ view is wrapped in a ``SwipeRefreshLayout`` (the returned view is
855
+ the wrapper, and child management forwards into the inner scroll
856
+ view) so pull-to-refresh matches the iOS ``UIRefreshControl`` path.
857
+
858
+ Scroll offsets are reported as ``on_scroll`` events with a
859
+ ``{"x": pts, "y": pts}`` payload. Imperative commands:
860
+ ``scroll_to_offset`` / ``scroll_to_end`` / ``get_scroll_offset``.
536
861
  """
537
862
 
538
- def create(self, props: Dict[str, Any]) -> Any:
539
- try:
540
- sv = jclass("androidx.core.widget.NestedScrollView")(_ctx())
541
- except Exception:
542
- sv = jclass("android.widget.ScrollView")(_ctx())
543
- _apply_common_visual(sv, props)
544
- self._apply_scroll_props(sv, props)
545
- if props.get("refresh_control"):
863
+ def create(self, tag: int, props: Dict[str, Any]) -> Any:
864
+ horizontal = props.get("scroll_axis") == "horizontal"
865
+ if horizontal:
866
+ sv = jclass("android.widget.HorizontalScrollView")(_ctx())
867
+ else:
868
+ try:
869
+ sv = jclass("androidx.core.widget.NestedScrollView")(_ctx())
870
+ except Exception:
871
+ sv = jclass("android.widget.ScrollView")(_ctx())
872
+
873
+ # Vertical scroll views are *always* wrapped in a (disabled)
874
+ # SwipeRefreshLayout. Wrapping later is impossible — the
875
+ # reconciler may reuse this view for a screen that adds a
876
+ # ``refresh_control`` prop afterwards (e.g. navigation swapping
877
+ # screens of the same shape), and re-parenting a mounted view
878
+ # mid-update is not safe. ``_apply_refresh`` simply toggles the
879
+ # wrapper's enabled state as the prop comes and goes.
880
+ outer = sv
881
+ if not horizontal:
546
882
  wrapper = self._wrap_in_refresh(sv)
547
883
  if wrapper is not None:
548
- _pn_scrollview_state[id(wrapper)] = {
549
- "scroll": sv,
550
- "refresh": wrapper,
551
- "on_refresh": None,
552
- "listener_bound": False,
553
- }
554
- self._apply_refresh(wrapper, props)
555
- return wrapper
556
- return sv
557
-
558
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
559
- state = _pn_scrollview_state.get(id(native_view))
560
- scroll = state["scroll"] if state else native_view
561
- _apply_common_visual(scroll, changed)
562
- self._apply_scroll_props(scroll, changed)
563
- if state is not None and "refresh_control" in changed:
564
- self._apply_refresh(native_view, changed)
565
-
566
- def add_child(self, parent: Any, child: Any) -> None:
567
- state = _pn_scrollview_state.get(id(parent))
568
- target = state["scroll"] if state else parent
569
- target.addView(child)
570
-
571
- def remove_child(self, parent: Any, child: Any) -> None:
572
- state = _pn_scrollview_state.get(id(parent))
573
- target = state["scroll"] if state else parent
574
- target.removeView(child)
575
-
576
- def _apply_scroll_props(self, sv: Any, props: Dict[str, Any]) -> None:
884
+ outer = wrapper
885
+
886
+ _remember(outer, tag)
887
+ state = _state_of(outer)
888
+ state["props"] = dict(props)
889
+ state["scroll"] = sv
890
+ state["horizontal"] = horizontal
891
+ if outer is not sv:
892
+ state["refresh"] = outer
893
+ self._bind_refresh_listener(outer)
894
+ if not props.get("refresh_control"):
895
+ try:
896
+ outer.setEnabled(False)
897
+ except Exception:
898
+ pass
899
+ self._bind_scroll_listener(outer, sv)
900
+ self._apply(outer, props, initial=True)
901
+ if props.get("gestures"):
902
+ _wire_gestures(outer, props.get("gestures"))
903
+ return outer
904
+
905
+ def _apply(self, outer: Any, props: Dict[str, Any], initial: bool) -> None:
906
+ state = _state_of(outer)
907
+ sv = state.get("scroll", outer)
908
+ _apply_common_visual(sv, props)
577
909
  if "shows_scroll_indicator" in props:
578
910
  # Only present when ``False`` (hide); a removal restores bars.
579
911
  show = props["shows_scroll_indicator"] is not False
@@ -591,26 +923,69 @@ class ScrollViewHandler(AndroidViewHandler):
591
923
  sv.setOverScrollMode(mode)
592
924
  except Exception:
593
925
  pass
594
- if "on_scroll" in props:
595
- self._apply_on_scroll(sv, props.get("on_scroll"))
926
+ if "refresh_control" in props and state.get("refresh") is not None:
927
+ self._apply_refresh(outer, props)
596
928
  # ``paging_enabled`` and ``keyboard_dismiss_mode`` have no clean
597
929
  # NestedScrollView analogue, so they are intentionally skipped
598
930
  # rather than approximated poorly.
599
931
 
600
- def _apply_on_scroll(self, sv: Any, cb: Optional[Callable[[float, float], None]]) -> None:
601
- if cb is None:
602
- return
932
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
933
+ sv = _state_of(parent).get("scroll", parent)
934
+ _insert_view(sv, child, index)
935
+
936
+ def remove_child(self, parent: Any, child: Any) -> None:
937
+ sv = _state_of(parent).get("scroll", parent)
938
+ try:
939
+ sv.removeView(child)
940
+ except Exception:
941
+ pass
942
+
943
+ def command(self, native_view: Any, name: str, args: Dict[str, Any]) -> Any:
944
+ state = _state_of(native_view)
945
+ sv = state.get("scroll", native_view)
946
+ density = _density() or 1.0
947
+ if name == "scroll_to_offset":
948
+ x_px = int(float(args.get("x", 0.0) or 0.0) * density)
949
+ y_px = int(float(args.get("y", 0.0) or 0.0) * density)
950
+ animated = args.get("animated", True) is not False
951
+ try:
952
+ if animated and hasattr(sv, "smoothScrollTo"):
953
+ sv.smoothScrollTo(x_px, y_px)
954
+ else:
955
+ sv.scrollTo(x_px, y_px)
956
+ except Exception:
957
+ pass
958
+ return None
959
+ if name == "scroll_to_end":
960
+ try:
961
+ child = sv.getChildAt(0) if int(sv.getChildCount()) > 0 else None
962
+ if child is None:
963
+ return None
964
+ if state.get("horizontal"):
965
+ target_x = max(0, int(child.getWidth()) - int(sv.getWidth()))
966
+ sv.smoothScrollTo(target_x, 0)
967
+ else:
968
+ target_y = max(0, int(child.getHeight()) - int(sv.getHeight()))
969
+ sv.smoothScrollTo(0, target_y)
970
+ except Exception:
971
+ pass
972
+ return None
973
+ if name == "get_scroll_offset":
974
+ try:
975
+ return {
976
+ "x": float(sv.getScrollX()) / density,
977
+ "y": float(sv.getScrollY()) / density,
978
+ }
979
+ except Exception:
980
+ return {"x": 0.0, "y": 0.0}
981
+ return None
982
+
983
+ def _bind_scroll_listener(self, outer: Any, sv: Any) -> None:
603
984
  try:
604
985
  if jclass("android.os.Build$VERSION").SDK_INT < 23:
605
986
  return
606
- density = _density()
607
987
 
608
988
  class _ScrollChangeProxy(dynamic_proxy(jclass("android.view.View").OnScrollChangeListener)):
609
- def __init__(self, callback: Callable[[float, float], None], dens: float) -> None:
610
- super().__init__()
611
- self.callback = callback
612
- self.dens = dens if dens else 1.0
613
-
614
989
  def onScrollChange(
615
990
  self,
616
991
  v: Any,
@@ -620,11 +995,12 @@ class ScrollViewHandler(AndroidViewHandler):
620
995
  old_y: int,
621
996
  ) -> None:
622
997
  try:
623
- self.callback(scroll_x / self.dens, scroll_y / self.dens)
998
+ density = _density() or 1.0
999
+ _fire(outer, "on_scroll", {"x": scroll_x / density, "y": scroll_y / density})
624
1000
  except Exception:
625
1001
  pass
626
1002
 
627
- sv.setOnScrollChangeListener(_ScrollChangeProxy(cb, density))
1003
+ sv.setOnScrollChangeListener(_ScrollChangeProxy())
628
1004
  except Exception:
629
1005
  pass
630
1006
 
@@ -638,11 +1014,22 @@ class ScrollViewHandler(AndroidViewHandler):
638
1014
  except Exception:
639
1015
  return None
640
1016
 
641
- def _apply_refresh(self, wrapper: Any, props: Dict[str, Any]) -> None:
642
- state = _pn_scrollview_state.get(id(wrapper))
643
- if state is None:
644
- return
645
- srl = state.get("refresh")
1017
+ def _bind_refresh_listener(self, outer: Any) -> None:
1018
+ try:
1019
+ srl = _state_of(outer).get("refresh")
1020
+
1021
+ class _RefreshProxy(
1022
+ dynamic_proxy(jclass("androidx.swiperefreshlayout.widget.SwipeRefreshLayout").OnRefreshListener)
1023
+ ):
1024
+ def onRefresh(self) -> None:
1025
+ _fire(outer, "on_refresh")
1026
+
1027
+ srl.setOnRefreshListener(_RefreshProxy())
1028
+ except Exception:
1029
+ pass
1030
+
1031
+ def _apply_refresh(self, outer: Any, props: Dict[str, Any]) -> None:
1032
+ srl = _state_of(outer).get("refresh")
646
1033
  if srl is None:
647
1034
  return
648
1035
  spec = props.get("refresh_control")
@@ -656,26 +1043,6 @@ class ScrollViewHandler(AndroidViewHandler):
656
1043
  srl.setEnabled(True)
657
1044
  except Exception:
658
1045
  pass
659
- state["on_refresh"] = spec.get("on_refresh")
660
- if not state.get("listener_bound"):
661
- owner = state
662
-
663
- class _RefreshProxy(
664
- dynamic_proxy(jclass("androidx.swiperefreshlayout.widget.SwipeRefreshLayout").OnRefreshListener)
665
- ):
666
- def onRefresh(self) -> None:
667
- callback = owner.get("on_refresh")
668
- if callback is not None:
669
- try:
670
- callback()
671
- except Exception:
672
- pass
673
-
674
- try:
675
- srl.setOnRefreshListener(_RefreshProxy())
676
- state["listener_bound"] = True
677
- except Exception:
678
- pass
679
1046
  if spec.get("tint_color"):
680
1047
  try:
681
1048
  srl.setColorSchemeColors([parse_color_int(spec["tint_color"])])
@@ -688,28 +1055,55 @@ class ScrollViewHandler(AndroidViewHandler):
688
1055
 
689
1056
 
690
1057
  class TextInputHandler(AndroidViewHandler):
691
- def create(self, props: Dict[str, Any]) -> Any:
1058
+ def _build(self, props: Dict[str, Any]) -> Any:
692
1059
  et = jclass("android.widget.EditText")(_ctx())
693
1060
  # Default to single-line so pressing Enter triggers IME_ACTION_DONE
694
- # (submit / dismiss) instead of inserting a newline. The
695
- # ``_apply`` path will override this if ``multiline=True`` is
696
- # set in props. Without this, every TextInput without an
697
- # explicit ``multiline`` value falls back to Android's
698
- # multi-line default and Enter inserts ``\n``.
1061
+ # (submit / dismiss) instead of inserting a newline. ``_apply``
1062
+ # overrides this when ``multiline=True``.
699
1063
  try:
700
1064
  if not props.get("multiline"):
701
1065
  et.setSingleLine(True)
702
1066
  except Exception:
703
1067
  pass
704
- self._apply(et, props)
1068
+ self._bind_listeners(et, props)
705
1069
  return et
706
1070
 
707
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
708
- self._apply(native_view, changed)
1071
+ def _bind_listeners(self, et: Any, props: Dict[str, Any]) -> None:
1072
+ # Text watcher: dispatches on_change unless a programmatic
1073
+ # setText is in flight. State lookups happen at dispatch time,
1074
+ # after the registry has assigned the tag.
1075
+ try:
1076
+ TextWatcher = jclass("android.text.TextWatcher")
1077
+
1078
+ class ChangeProxy(dynamic_proxy(TextWatcher)):
1079
+ def afterTextChanged(self, s: Any) -> None:
1080
+ st = _state_of(et)
1081
+ if st.get("suppress"):
1082
+ return
1083
+ _fire(et, "on_change", str(s))
1084
+
1085
+ def beforeTextChanged(self, s: Any, start: int, count: int, after: int) -> None:
1086
+ pass
1087
+
1088
+ def onTextChanged(self, s: Any, start: int, before: int, count: int) -> None:
1089
+ pass
1090
+
1091
+ et.addTextChangedListener(ChangeProxy())
1092
+ except Exception:
1093
+ pass
1094
+ try:
1095
+
1096
+ class _FocusProxy(dynamic_proxy(jclass("android.view.View").OnFocusChangeListener)):
1097
+ def onFocusChange(self, view: Any, has_focus: bool) -> None:
1098
+ _fire(view, "on_focus" if has_focus else "on_blur")
709
1099
 
710
- def _apply(self, et: Any, props: Dict[str, Any]) -> None:
1100
+ et.setOnFocusChangeListener(_FocusProxy())
1101
+ except Exception:
1102
+ pass
1103
+
1104
+ def _apply(self, et: Any, props: Dict[str, Any], initial: bool) -> None:
1105
+ state = _state_of(et)
711
1106
  if "value" in props:
712
- key = id(et)
713
1107
  incoming = str(props["value"]) if props["value"] is not None else ""
714
1108
  try:
715
1109
  before = str(et.getText())
@@ -723,7 +1117,7 @@ class TextInputHandler(AndroidViewHandler):
723
1117
  selection_end = et.getSelectionEnd()
724
1118
  except Exception:
725
1119
  pass
726
- _pn_text_input_suppress_callbacks[key] = True
1120
+ state["suppress"] = True
727
1121
  try:
728
1122
  et.setText(incoming)
729
1123
  try:
@@ -737,7 +1131,7 @@ class TextInputHandler(AndroidViewHandler):
737
1131
  except Exception:
738
1132
  pass
739
1133
  finally:
740
- _pn_text_input_suppress_callbacks[key] = False
1134
+ state["suppress"] = False
741
1135
  if "placeholder" in props:
742
1136
  et.setHint(str(props["placeholder"]) if props["placeholder"] is not None else "")
743
1137
  if "placeholder_color" in props and props["placeholder_color"] is not None:
@@ -750,13 +1144,14 @@ class TextInputHandler(AndroidViewHandler):
750
1144
  if "color" in props and props["color"] is not None:
751
1145
  et.setTextColor(parse_color_int(props["color"]))
752
1146
  if any(k in props for k in ("multiline", "secure", "keyboard_type", "auto_capitalize")):
1147
+ merged = state.get("props") or props
753
1148
  try:
754
1149
  InputType = jclass("android.text.InputType")
755
1150
  base = InputType.TYPE_CLASS_TEXT
756
- if props.get("secure"):
1151
+ if merged.get("secure"):
757
1152
  base = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD
758
1153
  else:
759
- kt = props.get("keyboard_type")
1154
+ kt = merged.get("keyboard_type")
760
1155
  if kt == "email_address":
761
1156
  base = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
762
1157
  elif kt == "number_pad" or kt == "decimal_pad":
@@ -767,14 +1162,14 @@ class TextInputHandler(AndroidViewHandler):
767
1162
  base = InputType.TYPE_CLASS_PHONE
768
1163
  elif kt == "url":
769
1164
  base = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI
770
- auto_cap = props.get("auto_capitalize")
1165
+ auto_cap = merged.get("auto_capitalize")
771
1166
  if auto_cap == "sentences":
772
1167
  base |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
773
1168
  elif auto_cap == "words":
774
1169
  base |= InputType.TYPE_TEXT_FLAG_CAP_WORDS
775
1170
  elif auto_cap == "characters":
776
1171
  base |= InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
777
- if props.get("multiline"):
1172
+ if merged.get("multiline"):
778
1173
  base |= InputType.TYPE_TEXT_FLAG_MULTI_LINE
779
1174
  et.setSingleLine(False)
780
1175
  else:
@@ -815,49 +1210,12 @@ class TextInputHandler(AndroidViewHandler):
815
1210
  self._apply_autofill(et, str(props["text_content_type"]))
816
1211
  if "clear_button" in props:
817
1212
  self._apply_clear_button(et, bool(props.get("clear_button")))
818
- if "on_focus" in props or "on_blur" in props:
819
- self._apply_focus_listener(et, props)
820
- if "on_change" in props:
821
- key = id(et)
822
- cb = props["on_change"]
823
- if cb is not None:
824
- _pn_text_input_callbacks[key] = cb
825
- if key not in _pn_text_input_watchers:
826
- TextWatcher = jclass("android.text.TextWatcher")
827
-
828
- class ChangeProxy(dynamic_proxy(TextWatcher)):
829
- def __init__(self, view_key: int) -> None:
830
- super().__init__()
831
- self.view_key = view_key
832
-
833
- def afterTextChanged(self, s: Any) -> None:
834
- text = str(s)
835
- if _pn_text_input_suppress_callbacks.get(self.view_key):
836
- return
837
- callback = _pn_text_input_callbacks.get(self.view_key)
838
- if callback is None:
839
- return
840
- callback(text)
841
-
842
- def beforeTextChanged(self, s: Any, start: int, count: int, after: int) -> None:
843
- pass
844
-
845
- def onTextChanged(self, s: Any, start: int, before: int, count: int) -> None:
846
- pass
847
-
848
- watcher = ChangeProxy(key)
849
- _pn_text_input_watchers[key] = watcher
850
- et.addTextChangedListener(watcher)
851
- else:
852
- _pn_text_input_callbacks[key] = None
853
1213
  if "return_key_type" in props and props["return_key_type"] is not None:
854
1214
  # Map the cross-platform ``return_key_type`` to Android's
855
1215
  # ``EditorInfo.IME_ACTION_*`` so the soft keyboard renders the
856
- # right action key (Done / Go / Search / Send / Next), which
857
- # is what triggers the ``OnEditorActionListener`` below. iOS
858
- # has a richer set (Google / Yahoo / Join / Route) with no
859
- # direct AOSP equivalents — fall back to ``IME_ACTION_DONE``
860
- # for those so the keyboard at least dismisses cleanly.
1216
+ # right action key. iOS has a richer set (Google / Yahoo /
1217
+ # Join / Route) with no direct AOSP equivalents — fall back
1218
+ # to ``IME_ACTION_DONE`` for those.
861
1219
  try:
862
1220
  EditorInfo = jclass("android.view.inputmethod.EditorInfo")
863
1221
  rkt_mapping = {
@@ -876,31 +1234,36 @@ class TextInputHandler(AndroidViewHandler):
876
1234
  et.setImeOptions(action)
877
1235
  except Exception:
878
1236
  pass
879
- if not props.get("multiline"):
880
- # Always install an editor-action listener on single-line
881
- # inputs so pressing the IME action key (Done / Go / etc.)
882
- # *or* the Enter key on a single-line ``EditText`` dismisses
883
- # the soft keyboard. Without this the keyboard stays up after
884
- # ``inputText`` + ``pressKey: Enter`` in Maestro and on smaller
885
- # screens hides the rest of the layout — and matches React
886
- # Native's default Android behavior. ``on_submit`` (if any) is
887
- # fired before dismissal so the callback sees the final text.
888
- try:
889
- on_submit_cb = props.get("on_submit")
890
- EditorListener = jclass("android.widget.TextView$OnEditorActionListener")
891
- Context = jclass("android.content.Context")
1237
+ if initial or "multiline" in props:
1238
+ self._apply_editor_action(et)
1239
+ _apply_common_visual(et, props)
892
1240
 
893
- class SubmitProxy(dynamic_proxy(EditorListener)):
894
- def __init__(self, callback: Optional[Callable[[str], None]]) -> None:
895
- super().__init__()
896
- self.callback = callback
1241
+ def _apply_editor_action(self, et: Any) -> None:
1242
+ """Install the IME action listener for submit + keyboard dismissal.
897
1243
 
898
- def onEditorAction(self, view: Any, action_id: int, event: Any) -> bool:
899
- if self.callback is not None:
900
- try:
901
- self.callback(str(view.getText()))
902
- except Exception:
903
- pass
1244
+ Single-line inputs always dismiss the keyboard on the action
1245
+ key (matching React Native's Android default) and fire
1246
+ ``on_submit`` first. Multi-line inputs only consume the action
1247
+ when an ``on_submit`` handler exists — otherwise Enter inserts
1248
+ a newline.
1249
+ """
1250
+ try:
1251
+ EditorListener = jclass("android.widget.TextView$OnEditorActionListener")
1252
+ Context = jclass("android.content.Context")
1253
+
1254
+ class SubmitProxy(dynamic_proxy(EditorListener)):
1255
+ def onEditorAction(self, view: Any, action_id: int, event: Any) -> bool:
1256
+ merged = _state_of(view).get("props") or {}
1257
+ multiline = bool(merged.get("multiline"))
1258
+ has_submit = _has_event(view, "on_submit")
1259
+ if multiline and not has_submit:
1260
+ return False
1261
+ if has_submit:
1262
+ try:
1263
+ _fire(view, "on_submit", str(view.getText()))
1264
+ except Exception:
1265
+ pass
1266
+ if not multiline:
904
1267
  try:
905
1268
  view.clearFocus()
906
1269
  ctx = view.getContext()
@@ -908,35 +1271,11 @@ class TextInputHandler(AndroidViewHandler):
908
1271
  imm.hideSoftInputFromWindow(view.getWindowToken(), 0)
909
1272
  except Exception:
910
1273
  pass
911
- return True
1274
+ return True
912
1275
 
913
- et.setOnEditorActionListener(SubmitProxy(on_submit_cb))
914
- except Exception:
915
- pass
916
- elif "on_submit" in props and props["on_submit"] is not None:
917
- # Multi-line inputs: only install the listener when an explicit
918
- # ``on_submit`` is provided. Enter inserts a newline by default
919
- # on multi-line ``EditText`` and we don't want to override that.
920
- try:
921
- cb = props["on_submit"]
922
- EditorListener = jclass("android.widget.TextView$OnEditorActionListener")
923
-
924
- class SubmitProxy(dynamic_proxy(EditorListener)):
925
- def __init__(self, callback: Callable[[str], None]) -> None:
926
- super().__init__()
927
- self.callback = callback
928
-
929
- def onEditorAction(self, view: Any, action_id: int, event: Any) -> bool:
930
- try:
931
- self.callback(str(view.getText()))
932
- except Exception:
933
- pass
934
- return True
935
-
936
- et.setOnEditorActionListener(SubmitProxy(cb))
937
- except Exception:
938
- pass
939
- _apply_common_visual(et, props)
1276
+ et.setOnEditorActionListener(SubmitProxy())
1277
+ except Exception:
1278
+ pass
940
1279
 
941
1280
  @staticmethod
942
1281
  def _autofill_hint(content_type: str) -> Optional[str]:
@@ -974,15 +1313,16 @@ class TextInputHandler(AndroidViewHandler):
974
1313
  # Best-effort drawableEnd "X": shows a system clear icon and wires
975
1314
  # a touch listener that clears the field when the icon is tapped.
976
1315
  try:
1316
+ state = _state_of(et)
977
1317
  if not enabled:
978
1318
  et.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
979
1319
  return
980
1320
  icon_id = int(getattr(jclass("android.R$drawable"), "ic_menu_close_clear_cancel", 0))
981
1321
  if icon_id:
982
1322
  et.setCompoundDrawablesWithIntrinsicBounds(0, 0, icon_id, 0)
983
- key = id(et)
984
- if key in _pn_text_input_clear_touch:
1323
+ if state.get("clear_bound"):
985
1324
  return
1325
+ state["clear_bound"] = True
986
1326
 
987
1327
  class _ClearTouchProxy(dynamic_proxy(jclass("android.view.View").OnTouchListener)):
988
1328
  def onTouch(self, view: Any, event: Any) -> bool:
@@ -999,51 +1339,16 @@ class TextInputHandler(AndroidViewHandler):
999
1339
  pass
1000
1340
  return False
1001
1341
 
1002
- listener = _ClearTouchProxy()
1003
- _pn_text_input_clear_touch[key] = listener
1004
- et.setOnTouchListener(listener)
1342
+ et.setOnTouchListener(_ClearTouchProxy())
1005
1343
  except Exception:
1006
1344
  pass
1007
1345
 
1008
- def _apply_focus_listener(self, et: Any, props: Dict[str, Any]) -> None:
1009
- key = id(et)
1010
- entry = _pn_text_input_focus_callbacks.setdefault(key, {"on_focus": None, "on_blur": None})
1011
- if "on_focus" in props:
1012
- entry["on_focus"] = props.get("on_focus")
1013
- if "on_blur" in props:
1014
- entry["on_blur"] = props.get("on_blur")
1015
- if key in _pn_text_input_focus_listeners:
1016
- return
1017
-
1018
- class _FocusProxy(dynamic_proxy(jclass("android.view.View").OnFocusChangeListener)):
1019
- def __init__(self, view_key: int) -> None:
1020
- super().__init__()
1021
- self.view_key = view_key
1022
-
1023
- def onFocusChange(self, view: Any, has_focus: bool) -> None:
1024
- callbacks = _pn_text_input_focus_callbacks.get(self.view_key) or {}
1025
- cb = callbacks.get("on_focus") if has_focus else callbacks.get("on_blur")
1026
- if cb is not None:
1027
- try:
1028
- cb()
1029
- except Exception:
1030
- pass
1031
-
1032
- listener = _FocusProxy(key)
1033
- _pn_text_input_focus_listeners[key] = listener
1034
- et.setOnFocusChangeListener(listener)
1035
-
1036
1346
 
1037
1347
  class ImageHandler(AndroidViewHandler):
1038
- def create(self, props: Dict[str, Any]) -> Any:
1039
- iv = jclass("android.widget.ImageView")(_ctx())
1040
- self._apply(iv, props)
1041
- return iv
1348
+ def _build(self, props: Dict[str, Any]) -> Any:
1349
+ return jclass("android.widget.ImageView")(_ctx())
1042
1350
 
1043
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1044
- self._apply(native_view, changed)
1045
-
1046
- def _apply(self, iv: Any, props: Dict[str, Any]) -> None:
1351
+ def _apply(self, iv: Any, props: Dict[str, Any], initial: bool) -> None:
1047
1352
  if "tint_color" in props and props["tint_color"] is not None:
1048
1353
  try:
1049
1354
  ColorStateList = jclass("android.content.res.ColorStateList")
@@ -1117,44 +1422,37 @@ class ImageHandler(AndroidViewHandler):
1117
1422
 
1118
1423
 
1119
1424
  class SwitchHandler(AndroidViewHandler):
1120
- def create(self, props: Dict[str, Any]) -> Any:
1425
+ def _build(self, props: Dict[str, Any]) -> Any:
1121
1426
  sw = jclass("android.widget.Switch")(_ctx())
1122
- self._apply(sw, props)
1123
- return sw
1124
1427
 
1125
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1126
- self._apply(native_view, changed)
1127
-
1128
- def _apply(self, sw: Any, props: Dict[str, Any]) -> None:
1129
- if "value" in props:
1130
- sw.setChecked(bool(props["value"]))
1131
- if "on_change" in props and props["on_change"] is not None:
1132
- cb = props["on_change"]
1133
-
1134
- class CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)):
1135
- def __init__(self, callback: Callable[[bool], None]) -> None:
1136
- super().__init__()
1137
- self.callback = callback
1428
+ class CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)):
1429
+ def onCheckedChanged(self, button: Any, checked: bool) -> None:
1430
+ if _state_of(button).get("suppress"):
1431
+ return
1432
+ _fire(button, "on_change", bool(checked))
1138
1433
 
1139
- def onCheckedChanged(self, button: Any, checked: bool) -> None:
1140
- self.callback(checked)
1434
+ sw.setOnCheckedChangeListener(CheckedProxy())
1435
+ return sw
1141
1436
 
1142
- sw.setOnCheckedChangeListener(CheckedProxy(cb))
1437
+ def _apply(self, sw: Any, props: Dict[str, Any], initial: bool) -> None:
1438
+ if "value" in props:
1439
+ state = _state_of(sw)
1440
+ state["suppress"] = True
1441
+ try:
1442
+ sw.setChecked(bool(props["value"]))
1443
+ finally:
1444
+ state["suppress"] = False
1143
1445
  _apply_accessibility(sw, props)
1144
1446
 
1145
1447
 
1146
1448
  class ProgressBarHandler(AndroidViewHandler):
1147
- def create(self, props: Dict[str, Any]) -> Any:
1449
+ def _build(self, props: Dict[str, Any]) -> Any:
1148
1450
  style = jclass("android.R$attr").progressBarStyleHorizontal
1149
1451
  pb = jclass("android.widget.ProgressBar")(_ctx(), None, 0, style)
1150
1452
  pb.setMax(1000)
1151
- self._apply(pb, props)
1152
1453
  return pb
1153
1454
 
1154
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1155
- self._apply(native_view, changed)
1156
-
1157
- def _apply(self, pb: Any, props: Dict[str, Any]) -> None:
1455
+ def _apply(self, pb: Any, props: Dict[str, Any], initial: bool) -> None:
1158
1456
  if "value" in props and props["value"] is not None:
1159
1457
  pb.setProgress(int(float(props["value"]) * 1000))
1160
1458
  if "color" in props and props["color"] is not None:
@@ -1179,15 +1477,10 @@ class ProgressBarHandler(AndroidViewHandler):
1179
1477
 
1180
1478
 
1181
1479
  class ActivityIndicatorHandler(AndroidViewHandler):
1182
- def create(self, props: Dict[str, Any]) -> Any:
1183
- pb = jclass("android.widget.ProgressBar")(_ctx())
1184
- self._apply(pb, props)
1185
- return pb
1186
-
1187
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1188
- self._apply(native_view, changed)
1480
+ def _build(self, props: Dict[str, Any]) -> Any:
1481
+ return jclass("android.widget.ProgressBar")(_ctx())
1189
1482
 
1190
- def _apply(self, pb: Any, props: Dict[str, Any]) -> None:
1483
+ def _apply(self, pb: Any, props: Dict[str, Any], initial: bool) -> None:
1191
1484
  if "animating" in props:
1192
1485
  View = jclass("android.view.View")
1193
1486
  pb.setVisibility(View.VISIBLE if props["animating"] else View.GONE)
@@ -1208,11 +1501,8 @@ class ActivityIndicatorHandler(AndroidViewHandler):
1208
1501
  pass
1209
1502
 
1210
1503
 
1211
- _pn_webview_props: Dict[int, Dict[str, Any]] = {}
1212
-
1213
-
1214
- def _make_web_client(store: Dict[str, Any]) -> Any:
1215
- """Best-effort ``WebViewClient`` proxy driving the WebView callbacks.
1504
+ def _make_web_client(wv: Any) -> Any:
1505
+ """Best-effort ``WebViewClient`` proxy driving the WebView events.
1216
1506
 
1217
1507
  ``android.webkit.WebViewClient`` is an abstract *class*, not an
1218
1508
  interface, so Chaquopy's ``dynamic_proxy`` may be unable to subclass
@@ -1220,35 +1510,22 @@ def _make_web_client(store: Dict[str, Any]) -> Any:
1220
1510
  which case the caller falls back to the default client and page
1221
1511
  loading still works.
1222
1512
 
1223
- When the proxy succeeds it drives ``on_navigation_state_change``
1513
+ When the proxy succeeds it fires ``on_navigation_state_change``
1224
1514
  (``onPageStarted``), ``on_load`` (``onPageFinished``), evaluates
1225
1515
  ``inject_javascript`` after each load, and bridges ``on_message``
1226
1516
  via a ``pythonnative://`` URL scheme plus a small JS shim installed
1227
- as ``window.pythonnative.postMessage`` — so no ``@JavascriptInterface``
1228
- Java helper is required.
1517
+ as ``window.pythonnative.postMessage``.
1229
1518
  """
1230
- on_load = store.get("on_load")
1231
- on_nav = store.get("on_navigation_state_change")
1232
- inject_js = store.get("inject_javascript")
1233
- on_message = store.get("on_message")
1234
1519
  scheme = "pythonnative://message/"
1235
1520
  try:
1236
1521
 
1237
1522
  class _WebClientProxy(dynamic_proxy(jclass("android.webkit.WebViewClient"))):
1238
1523
  def onPageStarted(self, view: Any, url: Any, favicon: Any) -> None:
1239
- if on_nav is not None:
1240
- try:
1241
- on_nav(str(url))
1242
- except Exception:
1243
- pass
1524
+ _fire(wv, "on_navigation_state_change", str(url))
1244
1525
 
1245
1526
  def onPageFinished(self, view: Any, url: Any) -> None:
1246
- if on_load is not None:
1247
- try:
1248
- on_load(str(url))
1249
- except Exception:
1250
- pass
1251
- if on_message is not None:
1527
+ _fire(wv, "on_load", str(url))
1528
+ if _has_event(wv, "on_message"):
1252
1529
  try:
1253
1530
  shim = (
1254
1531
  "(function(){window.pythonnative=window.pythonnative||{};"
@@ -1258,6 +1535,7 @@ def _make_web_client(store: Dict[str, Any]) -> Any:
1258
1535
  view.evaluateJavascript(shim, None)
1259
1536
  except Exception:
1260
1537
  pass
1538
+ inject_js = (_state_of(wv).get("props") or {}).get("inject_javascript")
1261
1539
  if inject_js:
1262
1540
  try:
1263
1541
  view.evaluateJavascript(str(inject_js), None)
@@ -1269,11 +1547,11 @@ def _make_web_client(store: Dict[str, Any]) -> Any:
1269
1547
  url = request if isinstance(request, str) else str(request.getUrl())
1270
1548
  except Exception:
1271
1549
  url = ""
1272
- if on_message is not None and url.startswith(scheme):
1550
+ if url.startswith(scheme):
1273
1551
  try:
1274
1552
  from urllib.parse import unquote
1275
1553
 
1276
- on_message(unquote(url[len(scheme) :]))
1554
+ _fire(wv, "on_message", unquote(url[len(scheme) :]))
1277
1555
  except Exception:
1278
1556
  pass
1279
1557
  return True
@@ -1285,32 +1563,20 @@ def _make_web_client(store: Dict[str, Any]) -> Any:
1285
1563
 
1286
1564
 
1287
1565
  class WebViewHandler(AndroidViewHandler):
1288
- _CLIENT_KEYS = ("on_load", "on_navigation_state_change", "inject_javascript", "on_message")
1289
-
1290
- def create(self, props: Dict[str, Any]) -> Any:
1291
- wv = jclass("android.webkit.WebView")(_ctx())
1292
- _pn_webview_props[id(wv)] = {}
1293
- self._apply(wv, props, initial=True)
1294
- return wv
1295
-
1296
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1297
- self._apply(native_view, changed, initial=False)
1566
+ def _build(self, props: Dict[str, Any]) -> Any:
1567
+ return jclass("android.webkit.WebView")(_ctx())
1298
1568
 
1299
1569
  def _apply(self, wv: Any, props: Dict[str, Any], initial: bool) -> None:
1300
- store = _pn_webview_props.setdefault(id(wv), {})
1301
- for key in self._CLIENT_KEYS:
1302
- if key in props:
1303
- store[key] = props[key]
1304
-
1305
- # Enable JS whenever a callback / injection needs it.
1306
- if any(store.get(k) for k in self._CLIENT_KEYS):
1570
+ merged = _state_of(wv).get("props") or props
1571
+ needs_js = bool(merged.get("inject_javascript")) or bool(event_names(merged))
1572
+ if needs_js:
1307
1573
  try:
1308
1574
  wv.getSettings().setJavaScriptEnabled(True)
1309
1575
  except Exception:
1310
1576
  pass
1311
1577
 
1312
- if initial or any(k in props for k in self._CLIENT_KEYS):
1313
- client = _make_web_client(store)
1578
+ if initial:
1579
+ client = _make_web_client(wv)
1314
1580
  if client is not None:
1315
1581
  try:
1316
1582
  wv.setWebViewClient(client)
@@ -1350,128 +1616,99 @@ class WebViewHandler(AndroidViewHandler):
1350
1616
  class SpacerHandler(AndroidViewHandler):
1351
1617
  """Empty layout placeholder used as a flexible gap.
1352
1618
 
1353
- All sizing semantics now live in the layout engine — ``Spacer``
1619
+ All sizing semantics live in the layout engine — ``Spacer``
1354
1620
  behaves identically to a `View` with the same style props (e.g.,
1355
1621
  ``flex: 1`` for an expanding spacer, ``size`` for a fixed gap).
1356
1622
  """
1357
1623
 
1358
- def create(self, props: Dict[str, Any]) -> Any:
1624
+ def _build(self, props: Dict[str, Any]) -> Any:
1359
1625
  return jclass("android.view.View")(_ctx())
1360
1626
 
1361
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1627
+ def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
1362
1628
  pass
1363
1629
 
1364
1630
 
1365
- class SafeAreaViewHandler(AndroidViewHandler):
1631
+ class SafeAreaViewHandler(FlexContainerHandler):
1366
1632
  """Safe-area container using FrameLayout with ``fitsSystemWindows``."""
1367
1633
 
1368
- def create(self, props: Dict[str, Any]) -> Any:
1634
+ def _build(self, props: Dict[str, Any]) -> Any:
1369
1635
  fl = jclass("android.widget.FrameLayout")(_ctx())
1370
1636
  fl.setFitsSystemWindows(True)
1371
- _apply_common_visual(fl, props)
1372
1637
  return fl
1373
1638
 
1374
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1375
- _apply_common_visual(native_view, changed)
1376
-
1377
- def add_child(self, parent: Any, child: Any) -> None:
1378
- parent.addView(child)
1379
-
1380
- def remove_child(self, parent: Any, child: Any) -> None:
1381
- parent.removeView(child)
1382
-
1383
1639
 
1384
1640
  # ======================================================================
1385
1641
  # Modal — actually presents a Dialog with the children inside
1386
1642
  # ======================================================================
1387
1643
 
1388
1644
 
1389
- _pn_modal_states: Dict[int, dict] = {}
1390
- _pn_modal_pending: Dict[int, list] = {}
1391
-
1392
-
1393
1645
  class ModalHandler(AndroidViewHandler):
1394
1646
  """Real modal presentation backed by an Android `Dialog`.
1395
1647
 
1396
1648
  The on-tree placeholder is a hidden ``View`` (so the layout
1397
1649
  engine can ignore it). When ``visible`` flips to ``True``, a
1398
1650
  ``Dialog`` is created with a ``FrameLayout`` as its content view;
1399
- the reconciler's ``add_child`` calls are forwarded into that
1651
+ the reconciler's ``insert_child`` calls are forwarded into that
1400
1652
  content view.
1401
1653
  """
1402
1654
 
1403
- def create(self, props: Dict[str, Any]) -> Any:
1655
+ def _build(self, props: Dict[str, Any]) -> Any:
1404
1656
  placeholder = jclass("android.view.View")(_ctx())
1405
1657
  placeholder.setVisibility(jclass("android.view.View").GONE)
1406
- self._apply(placeholder, props, mounting=True)
1407
1658
  return placeholder
1408
1659
 
1409
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1410
- self._apply(native_view, changed, mounting=False)
1411
-
1412
- def add_child(self, parent: Any, child: Any) -> None:
1413
- state = _pn_modal_states.get(id(parent))
1414
- if state and state.get("content_view") is not None:
1660
+ def _apply(self, placeholder: Any, props: Dict[str, Any], initial: bool) -> None:
1661
+ state = _state_of(placeholder)
1662
+ # ``update`` only delivers the *changed* props. When ``visible`` is
1663
+ # not among them the presentation state must be left untouched: a
1664
+ # re-render that happens while the modal is open (e.g. an
1665
+ # ``on_show`` callback bumping some state) must NOT be read as
1666
+ # ``visible=False`` and tear the dialog down.
1667
+ if "visible" in props:
1668
+ visible = bool(props["visible"])
1669
+ if visible and state.get("dialog") is None:
1670
+ self._present(placeholder, state)
1671
+ elif not visible and state.get("dialog") is not None:
1672
+ self._dismiss(placeholder, state)
1673
+ dialog = state.get("dialog")
1674
+ if dialog is not None and "dismiss_on_backdrop" in props:
1415
1675
  try:
1416
- state["content_view"].addView(child)
1676
+ dialog.setCanceledOnTouchOutside(props["dismiss_on_backdrop"] is not False)
1417
1677
  except Exception:
1418
1678
  pass
1679
+
1680
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
1681
+ state = _state_of(parent)
1682
+ content = state.get("content_view")
1683
+ if content is not None:
1684
+ _insert_view(content, child, index)
1419
1685
  else:
1420
- _pn_modal_pending.setdefault(id(parent), []).append(child)
1686
+ state.setdefault("pending", []).insert(index, child)
1421
1687
 
1422
1688
  def remove_child(self, parent: Any, child: Any) -> None:
1423
- state = _pn_modal_states.get(id(parent))
1424
- if state and state.get("content_view") is not None:
1689
+ state = _state_of(parent)
1690
+ content = state.get("content_view")
1691
+ if content is not None:
1425
1692
  try:
1426
- state["content_view"].removeView(child)
1693
+ content.removeView(child)
1427
1694
  except Exception:
1428
1695
  pass
1429
1696
  else:
1430
- buf = _pn_modal_pending.get(id(parent))
1697
+ buf = state.get("pending")
1431
1698
  if buf and child in buf:
1432
1699
  buf.remove(child)
1433
1700
 
1434
- def insert_child(self, parent: Any, child: Any, index: int) -> None:
1435
- state = _pn_modal_states.get(id(parent))
1436
- if state and state.get("content_view") is not None:
1437
- try:
1438
- state["content_view"].addView(child, index)
1439
- except Exception:
1440
- pass
1441
- else:
1442
- _pn_modal_pending.setdefault(id(parent), []).insert(index, child)
1443
-
1444
1701
  def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
1445
1702
  return
1446
1703
 
1447
- def _apply(self, placeholder: Any, props: Dict[str, Any], *, mounting: bool) -> None:
1448
- state = _pn_modal_states.get(id(placeholder))
1449
- # ``update`` only delivers the *changed* props. When ``visible`` is
1450
- # not among them the presentation state must be left untouched: a
1451
- # re-render that happens while the modal is open (e.g. an
1452
- # ``on_show`` callback bumping some state) must NOT be read as
1453
- # ``visible=False`` and tear the dialog down. So only react to an
1454
- # explicitly supplied ``visible`` value.
1455
- if "visible" in props:
1456
- visible = bool(props["visible"])
1457
- if visible and state is None:
1458
- self._present(placeholder, props)
1459
- elif not visible and state is not None:
1460
- self._dismiss(placeholder)
1461
- # Forward live prop updates to an already-presented dialog.
1462
- state = _pn_modal_states.get(id(placeholder))
1463
- if state is not None:
1464
- if "on_dismiss" in props:
1465
- state["on_dismiss"] = props.get("on_dismiss")
1466
- dialog = state.get("dialog")
1467
- if dialog is not None and "dismiss_on_backdrop" in props:
1468
- try:
1469
- dialog.setCanceledOnTouchOutside(props["dismiss_on_backdrop"] is not False)
1470
- except Exception:
1471
- pass
1704
+ def _teardown(self, placeholder: Any) -> None:
1705
+ state = _state_of(placeholder)
1706
+ if state.get("dialog") is not None:
1707
+ self._dismiss(placeholder, state)
1472
1708
 
1473
- def _present(self, placeholder: Any, props: Dict[str, Any]) -> None:
1709
+ def _present(self, placeholder: Any, state: Dict[str, Any]) -> None:
1474
1710
  try:
1711
+ props = state.get("props") or {}
1475
1712
  Dialog = jclass("android.app.Dialog")
1476
1713
  FrameLayout = jclass("android.widget.FrameLayout")
1477
1714
  LayoutParams = jclass("android.view.ViewGroup$LayoutParams")
@@ -1505,57 +1742,36 @@ class ModalHandler(AndroidViewHandler):
1505
1742
  dialog.setCanceledOnTouchOutside(props.get("dismiss_on_backdrop") is not False)
1506
1743
  except Exception:
1507
1744
  pass
1508
- on_dismiss = props.get("on_dismiss")
1509
- _pn_modal_states[id(placeholder)] = {
1510
- "dialog": dialog,
1511
- "content_view": content,
1512
- "on_dismiss": on_dismiss,
1513
- }
1514
- for child in _pn_modal_pending.pop(id(placeholder), []):
1745
+ state["dialog"] = dialog
1746
+ state["content_view"] = content
1747
+ for child in state.pop("pending", []):
1515
1748
  try:
1516
1749
  content.addView(child)
1517
1750
  except Exception:
1518
1751
  pass
1519
- on_show = props.get("on_show")
1520
- if on_show is not None:
1521
- OnShowListener = jclass("android.content.DialogInterface$OnShowListener")
1522
1752
 
1523
- class _ShowProxy(dynamic_proxy(OnShowListener)):
1524
- def __init__(self, callback: Callable[[], None]) -> None:
1525
- super().__init__()
1526
- self.callback = callback
1753
+ OnShowListener = jclass("android.content.DialogInterface$OnShowListener")
1527
1754
 
1528
- def onShow(self, di: Any) -> None:
1529
- try:
1530
- self.callback()
1531
- except Exception:
1532
- pass
1755
+ class _ShowProxy(dynamic_proxy(OnShowListener)):
1756
+ def onShow(self, di: Any) -> None:
1757
+ _fire(placeholder, "on_show")
1533
1758
 
1534
- dialog.setOnShowListener(_ShowProxy(on_show))
1535
- if on_dismiss is not None:
1536
- OnDismissListener = jclass("android.content.DialogInterface$OnDismissListener")
1759
+ dialog.setOnShowListener(_ShowProxy())
1537
1760
 
1538
- class _DismissProxy(dynamic_proxy(OnDismissListener)):
1539
- def __init__(self, callback: Callable[[], None]) -> None:
1540
- super().__init__()
1541
- self.callback = callback
1761
+ OnDismissListener = jclass("android.content.DialogInterface$OnDismissListener")
1542
1762
 
1543
- def onDismiss(self, di: Any) -> None:
1544
- try:
1545
- self.callback()
1546
- except Exception:
1547
- pass
1763
+ class _DismissProxy(dynamic_proxy(OnDismissListener)):
1764
+ def onDismiss(self, di: Any) -> None:
1765
+ _fire(placeholder, "on_dismiss")
1548
1766
 
1549
- dialog.setOnDismissListener(_DismissProxy(on_dismiss))
1767
+ dialog.setOnDismissListener(_DismissProxy())
1550
1768
  dialog.show()
1551
1769
  except Exception:
1552
1770
  pass
1553
1771
 
1554
- def _dismiss(self, placeholder: Any) -> None:
1555
- state = _pn_modal_states.pop(id(placeholder), None)
1556
- if state is None:
1557
- return
1558
- dialog = state.get("dialog")
1772
+ def _dismiss(self, placeholder: Any, state: Dict[str, Any]) -> None:
1773
+ dialog = state.pop("dialog", None)
1774
+ state.pop("content_view", None)
1559
1775
  if dialog is not None:
1560
1776
  try:
1561
1777
  dialog.dismiss()
@@ -1564,46 +1780,38 @@ class ModalHandler(AndroidViewHandler):
1564
1780
 
1565
1781
 
1566
1782
  class SliderHandler(AndroidViewHandler):
1567
- def create(self, props: Dict[str, Any]) -> Any:
1783
+ def _build(self, props: Dict[str, Any]) -> Any:
1568
1784
  sb = jclass("android.widget.SeekBar")(_ctx())
1569
1785
  sb.setMax(1000)
1570
- self._apply(sb, props)
1571
- return sb
1572
-
1573
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1574
- self._apply(native_view, changed)
1575
-
1576
- def _apply(self, sb: Any, props: Dict[str, Any]) -> None:
1577
- min_val = float(props.get("min_value", 0))
1578
- max_val = float(props.get("max_value", 1))
1579
- rng = max_val - min_val if max_val != min_val else 1
1580
- if "value" in props:
1581
- normalized = (float(props["value"]) - min_val) / rng
1582
- sb.setProgress(int(normalized * 1000))
1583
- if "on_change" in props and props["on_change"] is not None:
1584
- cb = props["on_change"]
1585
-
1586
- class SeekProxy(dynamic_proxy(jclass("android.widget.SeekBar").OnSeekBarChangeListener)):
1587
- def __init__(self, callback: Callable[[float], None], mn: float, rn: float) -> None:
1588
- super().__init__()
1589
- self.callback = callback
1590
- self.mn = mn
1591
- self.rn = rn
1592
-
1593
- def onProgressChanged(self, seekBar: Any, progress: int, fromUser: bool) -> None:
1594
- if fromUser:
1595
- self.callback(self.mn + (progress / 1000.0) * self.rn)
1596
1786
 
1597
- def onStartTrackingTouch(self, seekBar: Any) -> None:
1598
- pass
1787
+ class SeekProxy(dynamic_proxy(jclass("android.widget.SeekBar").OnSeekBarChangeListener)):
1788
+ def onProgressChanged(self, seekBar: Any, progress: int, fromUser: bool) -> None:
1789
+ if not fromUser:
1790
+ return
1791
+ merged = _state_of(seekBar).get("props") or {}
1792
+ mn = float(merged.get("min_value", 0))
1793
+ mx = float(merged.get("max_value", 1))
1794
+ rng = mx - mn if mx != mn else 1
1795
+ _fire(seekBar, "on_change", mn + (progress / 1000.0) * rng)
1599
1796
 
1600
- def onStopTrackingTouch(self, seekBar: Any) -> None:
1601
- pass
1797
+ def onStartTrackingTouch(self, seekBar: Any) -> None:
1798
+ pass
1602
1799
 
1603
- sb.setOnSeekBarChangeListener(SeekProxy(cb, min_val, rng))
1800
+ def onStopTrackingTouch(self, seekBar: Any) -> None:
1801
+ pass
1604
1802
 
1803
+ sb.setOnSeekBarChangeListener(SeekProxy())
1804
+ return sb
1605
1805
 
1606
- _android_tabbar_state: dict = {"callback": None, "items": []}
1806
+ def _apply(self, sb: Any, props: Dict[str, Any], initial: bool) -> None:
1807
+ merged = _state_of(sb).get("props") or props
1808
+ min_val = float(merged.get("min_value", 0))
1809
+ max_val = float(merged.get("max_value", 1))
1810
+ rng = max_val - min_val if max_val != min_val else 1
1811
+ if "value" in props and props["value"] is not None:
1812
+ normalized = (float(props["value"]) - min_val) / rng
1813
+ sb.setProgress(int(normalized * 1000))
1814
+ _apply_accessibility(sb, props)
1607
1815
 
1608
1816
 
1609
1817
  class TabBarHandler(AndroidViewHandler):
@@ -1614,71 +1822,67 @@ class TabBarHandler(AndroidViewHandler):
1614
1822
  """
1615
1823
 
1616
1824
  _LABEL_VISIBILITY_LABELED = 1
1617
- _is_material: bool = True
1618
1825
 
1619
- def create(self, props: Dict[str, Any]) -> Any:
1826
+ def _build(self, props: Dict[str, Any]) -> Any:
1620
1827
  try:
1621
1828
  bnv = jclass("com.google.android.material.bottomnavigation.BottomNavigationView")(_ctx())
1622
1829
  bnv.setBackgroundColor(parse_color_int("#FFFFFF"))
1623
- self._configure_material_bar(bnv)
1624
- self._is_material = True
1625
- self._apply_full(bnv, props)
1830
+ try:
1831
+ bnv.setLabelVisibilityMode(self._LABEL_VISIBILITY_LABELED)
1832
+ except Exception:
1833
+ pass
1626
1834
  return bnv
1627
1835
  except Exception:
1628
- self._is_material = False
1629
- return self._create_fallback(props)
1630
-
1631
- def _create_fallback(self, props: Dict[str, Any]) -> Any:
1632
- """Horizontal LinearLayout with Button children as a tab-bar fallback."""
1633
- LinearLayout = jclass("android.widget.LinearLayout")
1634
- ll = LinearLayout(_ctx())
1635
- ll.setOrientation(LinearLayout.HORIZONTAL)
1636
- ll.setBackgroundColor(parse_color_int("#F8F8F8"))
1637
- self._apply_fallback(ll, props)
1638
- return ll
1639
-
1640
- def _configure_material_bar(self, bnv: Any) -> None:
1641
- """Keep text visible for every tab, including 4+ item bars."""
1836
+ LinearLayout = jclass("android.widget.LinearLayout")
1837
+ ll = LinearLayout(_ctx())
1838
+ ll.setOrientation(LinearLayout.HORIZONTAL)
1839
+ ll.setBackgroundColor(parse_color_int("#F8F8F8"))
1840
+ return ll
1841
+
1842
+ def create(self, tag: int, props: Dict[str, Any]) -> Any:
1843
+ view = super().create(tag, props)
1844
+ state = _state_of(view)
1845
+ state["is_material"] = "LinearLayout" not in str(type(view))
1642
1846
  try:
1643
- bnv.setLabelVisibilityMode(self._LABEL_VISIBILITY_LABELED)
1847
+ state["is_material"] = bool(view.getMenu() is not None)
1644
1848
  except Exception:
1645
- pass
1646
-
1647
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1648
- if self._is_material:
1649
- self._apply_partial(native_view, changed)
1650
- else:
1651
- self._apply_fallback(native_view, changed)
1652
-
1653
- def _apply_full(self, bnv: Any, props: Dict[str, Any]) -> None:
1654
- """Initial creation all props are present."""
1655
- items = props.get("items", [])
1656
- self._set_menu(bnv, items)
1657
- self._set_active(bnv, props.get("active_tab"), items)
1658
- cb = props.get("on_tab_select")
1659
- if cb is not None:
1660
- self._set_listener(bnv, cb, items)
1661
-
1662
- def _apply_partial(self, bnv: Any, changed: Dict[str, Any]) -> None:
1663
- """Reconciler update — only changed props are present."""
1664
- prev_items = _android_tabbar_state["items"]
1665
-
1666
- if "items" in changed:
1667
- items = changed["items"]
1668
- self._set_menu(bnv, items)
1849
+ state["is_material"] = False
1850
+ if state["is_material"]:
1851
+ self._bind_material_listener(view)
1852
+ # Re-run the items now that we know which flavor we hold.
1853
+ self._apply(view, props, initial=True)
1854
+ return view
1855
+
1856
+ def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
1857
+ state = _state_of(view)
1858
+ if "is_material" not in state:
1859
+ return # create() re-invokes once flavor detection is done.
1860
+ if state.get("is_material"):
1861
+ if "items" in props:
1862
+ self._set_menu(view, props["items"] or [])
1863
+ if "active_tab" in props:
1864
+ items = (state.get("props") or {}).get("items") or []
1865
+ self._set_active(view, props["active_tab"], items)
1669
1866
  else:
1670
- items = prev_items
1867
+ self._apply_fallback(view, props)
1671
1868
 
1672
- if "active_tab" in changed:
1673
- self._set_active(bnv, changed["active_tab"], items)
1869
+ def _bind_material_listener(self, bnv: Any) -> None:
1870
+ try:
1871
+ listener_cls = jclass("com.google.android.material.navigation.NavigationBarView$OnItemSelectedListener")
1674
1872
 
1675
- if "on_tab_select" in changed:
1676
- cb = changed["on_tab_select"]
1677
- if cb is not None:
1678
- self._set_listener(bnv, cb, items)
1873
+ class _TabSelectProxy(dynamic_proxy(listener_cls)):
1874
+ def onNavigationItemSelected(self, menu_item: Any) -> bool:
1875
+ idx = menu_item.getItemId()
1876
+ items = (_state_of(bnv).get("props") or {}).get("items") or []
1877
+ if 0 <= idx < len(items):
1878
+ _fire(bnv, "on_tab_select", items[idx].get("name", ""))
1879
+ return True
1880
+
1881
+ bnv.setOnItemSelectedListener(_TabSelectProxy())
1882
+ except Exception:
1883
+ pass
1679
1884
 
1680
1885
  def _set_menu(self, bnv: Any, items: list) -> None:
1681
- _android_tabbar_state["items"] = items
1682
1886
  try:
1683
1887
  menu = bnv.getMenu()
1684
1888
  menu.clear()
@@ -1729,33 +1933,11 @@ class TabBarHandler(AndroidViewHandler):
1729
1933
  pass
1730
1934
  break
1731
1935
 
1732
- def _set_listener(self, bnv: Any, cb: Callable, items: list) -> None:
1733
- _android_tabbar_state["callback"] = cb
1734
- _android_tabbar_state["items"] = items
1735
- try:
1736
- listener_cls = jclass("com.google.android.material.navigation.NavigationBarView$OnItemSelectedListener")
1737
-
1738
- class _TabSelectProxy(dynamic_proxy(listener_cls)):
1739
- def __init__(self, callback: Callable, tab_items: list) -> None:
1740
- super().__init__()
1741
- self.callback = callback
1742
- self.tab_items = tab_items
1743
-
1744
- def onNavigationItemSelected(self, menu_item: Any) -> bool:
1745
- idx = menu_item.getItemId()
1746
- if 0 <= idx < len(self.tab_items):
1747
- self.callback(self.tab_items[idx].get("name", ""))
1748
- return True
1749
-
1750
- bnv.setOnItemSelectedListener(_TabSelectProxy(cb, items))
1751
- except Exception:
1752
- pass
1753
-
1754
1936
  def _apply_fallback(self, ll: Any, props: Dict[str, Any]) -> None:
1755
- items = props.get("items", [])
1756
- active = props.get("active_tab")
1757
- cb = props.get("on_tab_select")
1758
- if "items" in props:
1937
+ merged = _state_of(ll).get("props") or props
1938
+ items = merged.get("items", []) or []
1939
+ active = merged.get("active_tab")
1940
+ if "items" in props or "active_tab" in props:
1759
1941
  ll.removeAllViews()
1760
1942
  for item in items:
1761
1943
  name = item.get("name", "")
@@ -1763,96 +1945,131 @@ class TabBarHandler(AndroidViewHandler):
1763
1945
  btn = jclass("android.widget.Button")(_ctx())
1764
1946
  btn.setText(str(title))
1765
1947
  btn.setEnabled(name != active)
1766
- if cb is not None:
1767
- tab_name = name
1768
-
1769
- def _make_click(n: str) -> Callable[[], None]:
1770
- return lambda: cb(n)
1771
1948
 
1772
- class _ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
1773
- def __init__(self, callback: Callable[[], None]) -> None:
1774
- super().__init__()
1775
- self.callback = callback
1949
+ class _ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
1950
+ def __init__(self, tab_name: str) -> None:
1951
+ super().__init__()
1952
+ self.tab_name = tab_name
1776
1953
 
1777
- def onClick(self, view: Any) -> None:
1778
- self.callback()
1954
+ def onClick(self, view: Any) -> None:
1955
+ _fire(ll, "on_tab_select", self.tab_name)
1779
1956
 
1780
- btn.setOnClickListener(_ClickProxy(_make_click(tab_name)))
1957
+ btn.setOnClickListener(_ClickProxy(name))
1781
1958
  ll.addView(btn)
1782
1959
 
1783
1960
 
1784
1961
  # ======================================================================
1785
- # Pressable — visual feedback + tap callbacks
1962
+ # Pressable — visual feedback + tap callbacks + gestures
1786
1963
  # ======================================================================
1787
1964
 
1788
1965
 
1789
- class PressableHandler(AndroidViewHandler):
1790
- def create(self, props: Dict[str, Any]) -> Any:
1966
+ class PressableHandler(FlexContainerHandler):
1967
+ """Container that dispatches press events through one touch stream.
1968
+
1969
+ A single ``OnTouchListener`` drives the entire interaction:
1970
+ ``on_press_in`` at finger-down (plus the opacity dip),
1971
+ ``on_long_press`` on a 500 ms hold, ``on_press`` on a clean
1972
+ release, and ``on_press_out`` when the finger lifts or the touch
1973
+ cancels. The same stream feeds the gesture arbiter when
1974
+ ``gestures`` are attached, so press feedback and pan/pinch
1975
+ recognition coexist on one view.
1976
+ """
1977
+
1978
+ _LONG_PRESS_MS = 500
1979
+ _TAP_SLOP_DP = 12.0
1980
+
1981
+ def _build(self, props: Dict[str, Any]) -> Any:
1791
1982
  fl = jclass("android.widget.FrameLayout")(_ctx())
1792
1983
  fl.setClickable(True)
1793
1984
  fl.setFocusable(True)
1794
- self._apply(fl, props)
1985
+ self._bind_press_stream(fl)
1795
1986
  return fl
1796
1987
 
1797
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1798
- self._apply(native_view, changed)
1988
+ def create(self, tag: int, props: Dict[str, Any]) -> Any:
1989
+ view = super().create(tag, props)
1990
+ # Press handling owns the touch listener; gesture specs are fed
1991
+ # from inside the press stream rather than a second listener.
1992
+ _state_of(view)["gestures_bound"] = True
1993
+ return view
1799
1994
 
1800
- def _apply(self, fl: Any, props: Dict[str, Any]) -> None:
1801
- if "on_press" in props and props["on_press"] is not None:
1802
- cb = props["on_press"]
1995
+ def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None:
1996
+ _state_of(native_view)["gestures_bound"] = True
1997
+ super().update(native_view, changed_props)
1803
1998
 
1804
- class PressProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
1805
- def __init__(self, callback: Callable[[], None]) -> None:
1806
- super().__init__()
1807
- self.callback = callback
1808
-
1809
- def onClick(self, view: Any) -> None:
1810
- self.callback()
1811
-
1812
- fl.setOnClickListener(PressProxy(cb))
1813
- if "on_long_press" in props and props["on_long_press"] is not None:
1814
- cb = props["on_long_press"]
1815
-
1816
- class LongPressProxy(dynamic_proxy(jclass("android.view.View").OnLongClickListener)):
1817
- def __init__(self, callback: Callable[[], None]) -> None:
1818
- super().__init__()
1819
- self.callback = callback
1820
-
1821
- def onLongClick(self, view: Any) -> bool:
1822
- self.callback()
1823
- return True
1824
-
1825
- fl.setOnLongClickListener(LongPressProxy(cb))
1826
- # Press feedback via OnTouchListener that fades the alpha.
1827
- if "pressed_opacity" in props or "on_press" in props:
1828
- try:
1829
- pressed_opacity = float(props.get("pressed_opacity", 0.6))
1830
- OnTouchListener = jclass("android.view.View$OnTouchListener")
1831
- MotionEvent = jclass("android.view.MotionEvent") # noqa: F841
1832
-
1833
- class _TouchProxy(dynamic_proxy(OnTouchListener)):
1834
- def __init__(self, opacity: float) -> None:
1835
- super().__init__()
1836
- self.opacity = opacity
1999
+ def _bind_press_stream(self, fl: Any) -> None:
2000
+ try:
2001
+ OnTouchListener = jclass("android.view.View$OnTouchListener")
2002
+ Handler = jclass("android.os.Handler")
2003
+ Looper = jclass("android.os.Looper")
2004
+ Runnable = jclass("java.lang.Runnable")
2005
+ handler = Handler(Looper.getMainLooper())
2006
+ slop = self._TAP_SLOP_DP
2007
+ long_ms = self._LONG_PRESS_MS
2008
+
2009
+ class _PressTouchProxy(dynamic_proxy(OnTouchListener)):
2010
+ def onTouch(self, view: Any, event: Any) -> bool:
2011
+ state = _state_of(view)
2012
+ _feed_motion_event(view, state, event)
2013
+ action = int(event.getActionMasked())
2014
+ density = _density() or 1.0
2015
+ x_dp = float(event.getX()) / density
2016
+ y_dp = float(event.getY()) / density
2017
+ if action == 0: # DOWN
2018
+ press = {
2019
+ "down": (x_dp, y_dp),
2020
+ "moved": False,
2021
+ "long_fired": False,
2022
+ "seq": state.get("press_seq", 0) + 1,
2023
+ }
2024
+ state["press"] = press
2025
+ state["press_seq"] = press["seq"]
2026
+ _fire(view, "on_press_in")
2027
+ merged = state.get("props") or {}
2028
+ opacity = float(merged.get("pressed_opacity", 0.6))
2029
+ if opacity < 1.0:
2030
+ try:
2031
+ view.animate().alpha(opacity).setDuration(50).start()
2032
+ except Exception:
2033
+ pass
2034
+ if _has_event(view, "on_long_press"):
2035
+ seq = press["seq"]
1837
2036
 
1838
- def onTouch(self, view: Any, event: Any) -> bool:
1839
- action = event.getAction()
1840
- if action == 0: # ACTION_DOWN
1841
- view.animate().alpha(self.opacity).setDuration(50).start()
1842
- elif action in (1, 3): # ACTION_UP / ACTION_CANCEL
2037
+ class _LongRunnable(dynamic_proxy(Runnable)):
2038
+ def run(self) -> None:
2039
+ live = _state_of(view).get("press")
2040
+ if live is None or live.get("seq") != seq:
2041
+ return
2042
+ if live.get("moved") or live.get("long_fired"):
2043
+ return
2044
+ live["long_fired"] = True
2045
+ _fire(view, "on_long_press")
2046
+
2047
+ handler.postDelayed(_LongRunnable(), long_ms)
2048
+ return True
2049
+ press = state.get("press")
2050
+ if press is None:
2051
+ return True
2052
+ if action == 2: # MOVE
2053
+ dx = x_dp - press["down"][0]
2054
+ dy = y_dp - press["down"][1]
2055
+ if math.hypot(dx, dy) > slop:
2056
+ press["moved"] = True
2057
+ return True
2058
+ if action in (1, 3): # UP / CANCEL
2059
+ state["press"] = None
2060
+ _fire(view, "on_press_out")
2061
+ try:
1843
2062
  view.animate().alpha(1.0).setDuration(100).start()
1844
- return False
1845
-
1846
- fl.setOnTouchListener(_TouchProxy(pressed_opacity))
1847
- except Exception:
1848
- pass
1849
- _apply_common_visual(fl, props)
1850
-
1851
- def add_child(self, parent: Any, child: Any) -> None:
1852
- parent.addView(child)
2063
+ except Exception:
2064
+ pass
2065
+ if action == 1 and not press["moved"] and not press["long_fired"]:
2066
+ _fire(view, "on_press")
2067
+ return True
2068
+ return True
1853
2069
 
1854
- def remove_child(self, parent: Any, child: Any) -> None:
1855
- parent.removeView(child)
2070
+ fl.setOnTouchListener(_PressTouchProxy())
2071
+ except Exception:
2072
+ pass
1856
2073
 
1857
2074
 
1858
2075
  # ======================================================================
@@ -1863,19 +2080,15 @@ class PressableHandler(AndroidViewHandler):
1863
2080
  class StatusBarHandler(AndroidViewHandler):
1864
2081
  """Apply status-bar background color / style on the host activity."""
1865
2082
 
1866
- def create(self, props: Dict[str, Any]) -> Any:
2083
+ def _build(self, props: Dict[str, Any]) -> Any:
1867
2084
  v = jclass("android.view.View")(_ctx())
1868
2085
  v.setVisibility(jclass("android.view.View").GONE)
1869
- self._apply(props)
1870
2086
  return v
1871
2087
 
1872
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1873
- self._apply(changed)
1874
-
1875
2088
  def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
1876
2089
  return
1877
2090
 
1878
- def _apply(self, props: Dict[str, Any]) -> None:
2091
+ def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
1879
2092
  try:
1880
2093
  ctx = _ctx()
1881
2094
  window = ctx.getWindow()
@@ -1899,174 +2112,8 @@ class StatusBarHandler(AndroidViewHandler):
1899
2112
  pass
1900
2113
 
1901
2114
 
1902
- # ======================================================================
1903
- # KeyboardAvoidingView — vanilla container; the user-land component
1904
- # computes the offset from manifest-driven insets.
1905
- # ======================================================================
1906
-
1907
-
1908
- class KeyboardAvoidingViewHandler(AndroidViewHandler):
1909
- def create(self, props: Dict[str, Any]) -> Any:
1910
- fl = jclass("android.widget.FrameLayout")(_ctx())
1911
- _apply_common_visual(fl, props)
1912
- return fl
1913
-
1914
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1915
- _apply_common_visual(native_view, changed)
1916
-
1917
- def add_child(self, parent: Any, child: Any) -> None:
1918
- parent.addView(child)
1919
-
1920
- def remove_child(self, parent: Any, child: Any) -> None:
1921
- parent.removeView(child)
1922
-
1923
-
1924
- # ======================================================================
1925
- # VirtualList — RecyclerView-backed virtualized list
1926
- # ======================================================================
1927
-
1928
-
1929
- _pn_recyclerview_state: Dict[int, Any] = {}
1930
-
1931
-
1932
- def _java_id(jobj: Any) -> int:
1933
- """Return ``System.identityHashCode(jobj)`` as a stable lookup key.
1934
-
1935
- Chaquopy's ``JavaObject.__setattr__`` rejects unknown Python attributes,
1936
- so we cannot stash custom IDs on the Java view wrapper. Instead, we use
1937
- the JVM's identity hash code, which is stable for the lifetime of the
1938
- Java object and the same across all Python wrappers that may proxy it.
1939
- """
1940
- System = jclass("java.lang.System")
1941
- return int(System.identityHashCode(jobj))
1942
-
1943
-
1944
- def _make_recyclerview_delegate(props: Dict[str, Any]) -> Any:
1945
- Delegate = jclass("com.pythonnative.android_template.PNVirtualListView$Delegate")
1946
-
1947
- class _Delegate(dynamic_proxy(Delegate)):
1948
- def __init__(self, initial: Dict[str, Any]) -> None:
1949
- super().__init__()
1950
- self.count = int(initial.get("count", 0))
1951
- self.row_height = float(initial.get("row_height", 44.0))
1952
- self.mount_row = initial.get("mount_row")
1953
- self.on_row_press = initial.get("on_row_press")
1954
-
1955
- def update(self, changed: Dict[str, Any]) -> None:
1956
- if "count" in changed:
1957
- self.count = int(changed["count"])
1958
- if "row_height" in changed and changed["row_height"] is not None:
1959
- self.row_height = float(changed["row_height"])
1960
- if "mount_row" in changed:
1961
- self.mount_row = changed["mount_row"]
1962
- if "on_row_press" in changed:
1963
- self.on_row_press = changed["on_row_press"]
1964
-
1965
- def getCount(self) -> int:
1966
- return self.count
1967
-
1968
- def getRowHeightDp(self) -> float:
1969
- return self.row_height
1970
-
1971
- def mountRow(self, position: int, container: Any, width_dp: float, height_dp: float) -> None:
1972
- if self.mount_row is None:
1973
- return
1974
- try:
1975
- self.mount_row(int(position), container, float(width_dp), float(height_dp))
1976
- except Exception:
1977
- import traceback as _tb
1978
-
1979
- _tb.print_exc()
1980
-
1981
- def onRowPress(self, position: int) -> None:
1982
- idx = int(position)
1983
- if idx < 0 or self.on_row_press is None:
1984
- return
1985
- try:
1986
- self.on_row_press(idx)
1987
- except Exception:
1988
- import traceback as _tb
1989
-
1990
- _tb.print_exc()
1991
-
1992
- return _Delegate(props)
1993
-
1994
-
1995
- class VirtualListHandler(AndroidViewHandler):
1996
- """Backed by ``RecyclerView`` through a tiny Android template helper.
1997
-
1998
- Chaquopy cannot proxy ``RecyclerView.Adapter`` directly because it is an
1999
- abstract Java class, so the Android template provides
2000
- ``PNVirtualListView``. Python implements that helper's small ``Delegate``
2001
- interface, while Java owns the adapter/view-holder lifecycle.
2002
- """
2003
-
2004
- def create(self, props: Dict[str, Any]) -> Any:
2005
- try:
2006
- PNVirtualListView = jclass("com.pythonnative.android_template.PNVirtualListView")
2007
- delegate = _make_recyclerview_delegate(props)
2008
- rv = PNVirtualListView(_ctx(), delegate)
2009
- if "background_color" in props and props["background_color"] is not None:
2010
- rv.setBackgroundColor(parse_color_int(props["background_color"]))
2011
- key = _java_id(rv)
2012
- _pn_recyclerview_state[key] = delegate
2013
- return rv
2014
- except Exception:
2015
- return self._fallback(props)
2016
-
2017
- def _fallback(self, props: Dict[str, Any]) -> Any:
2018
- """Eagerly mount all rows in a ScrollView (controller init failed).
2019
-
2020
- Sets each row's LinearLayout.LayoutParams to MATCH_PARENT × row_h_px
2021
- so cells have a real visual size, and forwards the screen width (in
2022
- dp) to ``mount_row`` so the layout engine can position child
2023
- elements.
2024
- """
2025
- n = int(props.get("count", 0))
2026
- row_h_dp = float(props.get("row_height", 44.0))
2027
- density = _density()
2028
- row_h_px = max(1, int(round(row_h_dp * density)))
2029
-
2030
- try:
2031
- screen_w_px = float(_ctx().getResources().getDisplayMetrics().widthPixels)
2032
- screen_w_dp = screen_w_px / density if density else screen_w_px
2033
- except Exception:
2034
- screen_w_dp = 0.0
2035
-
2036
- sv = jclass("android.widget.ScrollView")(_ctx())
2037
- LinearLayout = jclass("android.widget.LinearLayout")
2038
- LL_LP = jclass("android.widget.LinearLayout$LayoutParams")
2039
- ll = LinearLayout(_ctx())
2040
- ll.setOrientation(LinearLayout.VERTICAL)
2041
- sv.addView(ll)
2042
-
2043
- mount = props.get("mount_row")
2044
-
2045
- for i in range(n):
2046
- try:
2047
- cell = jclass("android.widget.FrameLayout")(_ctx())
2048
- cell.setLayoutParams(LL_LP(LL_LP.MATCH_PARENT, row_h_px))
2049
- if mount is not None:
2050
- mount(i, cell, screen_w_dp, row_h_dp)
2051
- ll.addView(cell)
2052
- except Exception:
2053
- continue
2054
- return sv
2055
-
2056
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2057
- delegate = _pn_recyclerview_state.get(_java_id(native_view))
2058
- if delegate is None:
2059
- return
2060
- delegate.update(changed)
2061
- if "background_color" in changed and changed["background_color"] is not None:
2062
- try:
2063
- native_view.setBackgroundColor(parse_color_int(changed["background_color"]))
2064
- except Exception:
2065
- pass
2066
- try:
2067
- native_view.notifyDataChanged()
2068
- except Exception:
2069
- pass
2115
+ class KeyboardAvoidingViewHandler(FlexContainerHandler):
2116
+ """Vanilla container; the user-land component computes the offset."""
2070
2117
 
2071
2118
 
2072
2119
  # ======================================================================
@@ -2182,31 +2229,43 @@ def _present_alert(
2182
2229
  # ======================================================================
2183
2230
  # Picker — native dropdown / select widget
2184
2231
  # ======================================================================
2185
- #
2186
- # Renders the PythonNative `Picker` element as an Android ``Spinner``,
2187
- # which is the platform's standard dropdown widget. The selected item is
2188
- # pushed to the user's callback via ``OnItemSelectedListener``.
2189
2232
 
2190
2233
 
2191
2234
  class PickerHandler(AndroidViewHandler):
2192
2235
  """``Picker`` element handler — native ``Spinner`` dropdown."""
2193
2236
 
2194
- def create(self, props: Dict[str, Any]) -> Any:
2195
- Spinner = jclass("android.widget.Spinner")
2196
- sp = Spinner(_ctx())
2197
- self._state: Dict[int, Dict[str, Any]] = getattr(self, "_state", {})
2198
- self._state[id(sp)] = {"items": [], "on_change": None, "suppress": False}
2199
- self._apply(sp, props, initial=True)
2200
- return sp
2237
+ def _build(self, props: Dict[str, Any]) -> Any:
2238
+ sp = jclass("android.widget.Spinner")(_ctx())
2239
+
2240
+ class _PickerListener(dynamic_proxy(jclass("android.widget.AdapterView").OnItemSelectedListener)):
2241
+ def onItemSelected(
2242
+ self,
2243
+ parent: Any,
2244
+ view: Any, # noqa: ARG002
2245
+ position: int,
2246
+ id_: int, # noqa: ARG002
2247
+ ) -> None:
2248
+ state = _state_of(parent)
2249
+ if state.get("suppress"):
2250
+ return
2251
+ items = (state.get("props") or {}).get("items") or []
2252
+ if 0 <= position < len(items):
2253
+ item = items[position]
2254
+ v = item.get("value") if isinstance(item, dict) else item
2255
+ _fire(parent, "on_change", v)
2201
2256
 
2202
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2203
- self._apply(native_view, changed, initial=False)
2257
+ def onNothingSelected(self, parent: Any) -> None: # noqa: ARG002
2258
+ pass
2259
+
2260
+ sp.setOnItemSelectedListener(_PickerListener())
2261
+ return sp
2204
2262
 
2205
2263
  def _apply(self, sp: Any, props: Dict[str, Any], initial: bool) -> None:
2206
- state = self._state.setdefault(id(sp), {"items": [], "on_change": None, "suppress": False})
2264
+ state = _state_of(sp)
2265
+ merged = state.get("props") or props
2207
2266
 
2208
2267
  if "items" in props or initial:
2209
- items = list(props.get("items") or state.get("items") or [])
2268
+ items = list(merged.get("items") or [])
2210
2269
  labels = []
2211
2270
  for item in items:
2212
2271
  if isinstance(item, dict):
@@ -2218,13 +2277,14 @@ class PickerHandler(AndroidViewHandler):
2218
2277
  adapter = ArrayAdapter(_ctx(), R.layout.simple_spinner_item, labels)
2219
2278
  adapter.setDropDownViewResource(R.layout.simple_spinner_dropdown_item)
2220
2279
  state["suppress"] = True
2221
- sp.setAdapter(adapter)
2222
- state["suppress"] = False
2223
- state["items"] = items
2280
+ try:
2281
+ sp.setAdapter(adapter)
2282
+ finally:
2283
+ state["suppress"] = False
2224
2284
 
2225
2285
  if "value" in props or initial:
2226
- items = state["items"]
2227
- value = props.get("value") if "value" in props else None
2286
+ items = list(merged.get("items") or [])
2287
+ value = merged.get("value")
2228
2288
  target_index = -1
2229
2289
  for i, item in enumerate(items):
2230
2290
  v = item.get("value") if isinstance(item, dict) else item
@@ -2233,41 +2293,10 @@ class PickerHandler(AndroidViewHandler):
2233
2293
  break
2234
2294
  if target_index >= 0 and sp.getSelectedItemPosition() != target_index:
2235
2295
  state["suppress"] = True
2236
- sp.setSelection(target_index, False)
2237
- state["suppress"] = False
2238
-
2239
- if "on_change" in props or initial:
2240
- state["on_change"] = props.get("on_change") if "on_change" in props else state.get("on_change")
2241
-
2242
- class _PickerListener(dynamic_proxy(jclass("android.widget.AdapterView").OnItemSelectedListener)):
2243
- def __init__(self, owner_state: Dict[str, Any]) -> None:
2244
- super().__init__()
2245
- self._owner_state = owner_state
2246
-
2247
- def onItemSelected(
2248
- self,
2249
- parent: Any,
2250
- view: Any, # noqa: ARG002
2251
- position: int,
2252
- id_: int, # noqa: ARG002
2253
- ) -> None:
2254
- if self._owner_state.get("suppress"):
2255
- return
2256
- items = self._owner_state.get("items") or []
2257
- if 0 <= position < len(items):
2258
- item = items[position]
2259
- v = item.get("value") if isinstance(item, dict) else item
2260
- cb = self._owner_state.get("on_change")
2261
- if cb is not None:
2262
- try:
2263
- cb(v)
2264
- except Exception:
2265
- pass
2266
-
2267
- def onNothingSelected(self, parent: Any) -> None: # noqa: ARG002
2268
- pass
2269
-
2270
- sp.setOnItemSelectedListener(_PickerListener(state))
2296
+ try:
2297
+ sp.setSelection(target_index, False)
2298
+ finally:
2299
+ state["suppress"] = False
2271
2300
 
2272
2301
 
2273
2302
  # ======================================================================
@@ -2279,65 +2308,42 @@ class CheckboxHandler(AndroidViewHandler):
2279
2308
  """``Checkbox`` element handler — native ``CheckBox`` widget.
2280
2309
 
2281
2310
  Programmatic ``value`` updates are wrapped in a per-view
2282
- "suppress" guard (mirroring ``PickerHandler``) so pushing a new
2283
- state via ``setChecked`` never re-fires the user's ``on_change``.
2311
+ "suppress" guard so pushing a new state via ``setChecked`` never
2312
+ re-fires the user's ``on_change``.
2284
2313
  """
2285
2314
 
2286
- def create(self, props: Dict[str, Any]) -> Any:
2315
+ def _build(self, props: Dict[str, Any]) -> Any:
2287
2316
  cb = jclass("android.widget.CheckBox")(_ctx())
2288
- self._state: Dict[int, Dict[str, Any]] = getattr(self, "_state", {})
2289
- self._state[id(cb)] = {"on_change": None, "suppress": False}
2290
- self._apply(cb, props, initial=True)
2291
- return cb
2292
2317
 
2293
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2294
- self._apply(native_view, changed, initial=False)
2318
+ class _CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)):
2319
+ def onCheckedChanged(self, button: Any, is_checked: bool) -> None:
2320
+ if _state_of(button).get("suppress"):
2321
+ return
2322
+ _fire(button, "on_change", bool(is_checked))
2295
2323
 
2296
- def _apply(self, cb: Any, props: Dict[str, Any], initial: bool) -> None:
2297
- state = self._state.setdefault(id(cb), {"on_change": None, "suppress": False})
2324
+ cb.setOnCheckedChangeListener(_CheckedProxy())
2325
+ return cb
2298
2326
 
2327
+ def _apply(self, cb: Any, props: Dict[str, Any], initial: bool) -> None:
2328
+ state = _state_of(cb)
2299
2329
  if "label" in props:
2300
2330
  cb.setText(str(props["label"]) if props["label"] is not None else "")
2301
-
2302
2331
  if "value" in props:
2303
2332
  state["suppress"] = True
2304
2333
  try:
2305
2334
  cb.setChecked(bool(props["value"]))
2306
2335
  finally:
2307
2336
  state["suppress"] = False
2308
-
2309
2337
  if "disabled" in props:
2310
2338
  # ``disabled`` is only present when truthy; a removal (``None``)
2311
2339
  # re-enables the control.
2312
2340
  cb.setEnabled(not bool(props["disabled"]))
2313
-
2314
2341
  if "color" in props and props["color"] is not None:
2315
2342
  try:
2316
2343
  ColorStateList = jclass("android.content.res.ColorStateList")
2317
2344
  cb.setButtonTintList(ColorStateList.valueOf(parse_color_int(props["color"])))
2318
2345
  except Exception:
2319
2346
  pass
2320
-
2321
- if "on_change" in props or initial:
2322
- state["on_change"] = props.get("on_change") if "on_change" in props else state.get("on_change")
2323
-
2324
- class _CheckboxCheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)):
2325
- def __init__(self, owner_state: Dict[str, Any]) -> None:
2326
- super().__init__()
2327
- self._owner_state = owner_state
2328
-
2329
- def onCheckedChanged(self, button: Any, is_checked: bool) -> None:
2330
- if self._owner_state.get("suppress"):
2331
- return
2332
- callback = self._owner_state.get("on_change")
2333
- if callback is not None:
2334
- try:
2335
- callback(bool(is_checked))
2336
- except Exception:
2337
- pass
2338
-
2339
- cb.setOnCheckedChangeListener(_CheckboxCheckedProxy(state))
2340
-
2341
2347
  _apply_accessibility(cb, props)
2342
2348
 
2343
2349
 
@@ -2352,70 +2358,34 @@ class SegmentedControlHandler(AndroidViewHandler):
2352
2358
  Android has no ``UISegmentedControl`` equivalent, so the control is
2353
2359
  built from a horizontal ``LinearLayout`` holding one ``Button`` per
2354
2360
  segment. The selected segment is filled with the ``tint_color`` (or
2355
- a default accent); the rest are drawn outlined. Selection state and
2356
- the change callback live in a per-view dict, and a "suppress" guard
2357
- keeps programmatic ``selected_index`` updates from re-firing
2358
- ``on_change``. The control owns its own subviews, so
2359
- ``add_child`` / ``remove_child`` are intentional no-ops.
2361
+ a default accent); the rest are drawn outlined. The control owns
2362
+ its own subviews, so ``insert_child`` / ``remove_child`` are
2363
+ intentional no-ops.
2360
2364
  """
2361
2365
 
2362
2366
  _DEFAULT_ACCENT = "#007AFF"
2363
2367
 
2364
- def create(self, props: Dict[str, Any]) -> Any:
2368
+ def _build(self, props: Dict[str, Any]) -> Any:
2365
2369
  LinearLayout = jclass("android.widget.LinearLayout")
2366
2370
  ll = LinearLayout(_ctx())
2367
2371
  ll.setOrientation(LinearLayout.HORIZONTAL)
2368
- self._state: Dict[int, Dict[str, Any]] = getattr(self, "_state", {})
2369
- self._state[id(ll)] = {
2370
- "segments": [],
2371
- "selected_index": 0,
2372
- "on_change": None,
2373
- "tint_color": None,
2374
- "enabled": True,
2375
- "buttons": [],
2376
- "suppress": False,
2377
- }
2378
- self._apply(ll, props, initial=True)
2379
2372
  return ll
2380
2373
 
2381
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2382
- self._apply(native_view, changed, initial=False)
2383
-
2384
- def add_child(self, parent: Any, child: Any) -> None:
2385
- # SegmentedControl renders its own segment buttons.
2374
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
2386
2375
  return
2387
2376
 
2388
2377
  def remove_child(self, parent: Any, child: Any) -> None:
2389
2378
  return
2390
2379
 
2391
- def _default_state(self) -> Dict[str, Any]:
2392
- return {
2393
- "segments": [],
2394
- "selected_index": 0,
2395
- "on_change": None,
2396
- "tint_color": None,
2397
- "enabled": True,
2398
- "buttons": [],
2399
- "suppress": False,
2400
- }
2401
-
2402
2380
  def _apply(self, ll: Any, props: Dict[str, Any], initial: bool) -> None:
2403
- state = self._state.setdefault(id(ll), self._default_state())
2404
-
2405
- if "on_change" in props:
2406
- state["on_change"] = props.get("on_change")
2407
- if "tint_color" in props:
2408
- state["tint_color"] = props.get("tint_color")
2409
- if "enabled" in props:
2410
- # ``enabled`` is only present when ``False``; a removal (``None``)
2411
- # re-enables the control.
2412
- state["enabled"] = props["enabled"] is not False
2381
+ state = _state_of(ll)
2382
+ merged = state.get("props") or props
2413
2383
 
2414
2384
  segments_changed = False
2415
2385
  if "segments" in props or initial:
2416
- raw = props.get("segments")
2386
+ raw = merged.get("segments")
2417
2387
  new_segments = [str(s) for s in raw] if raw else []
2418
- if initial or new_segments != state["segments"]:
2388
+ if initial or new_segments != state.get("segments"):
2419
2389
  state["segments"] = new_segments
2420
2390
  segments_changed = True
2421
2391
 
@@ -2425,7 +2395,7 @@ class SegmentedControlHandler(AndroidViewHandler):
2425
2395
  if segments_changed:
2426
2396
  self._rebuild(ll, state)
2427
2397
  else:
2428
- self._restyle(state)
2398
+ self._restyle(ll, state)
2429
2399
 
2430
2400
  _apply_accessibility(ll, props)
2431
2401
 
@@ -2437,7 +2407,7 @@ class SegmentedControlHandler(AndroidViewHandler):
2437
2407
  state["buttons"] = []
2438
2408
  LL_LP = jclass("android.widget.LinearLayout$LayoutParams")
2439
2409
  restyle = self._restyle
2440
- for index, label in enumerate(state["segments"]):
2410
+ for index, label in enumerate(state.get("segments") or []):
2441
2411
  btn = jclass("android.widget.Button")(_ctx())
2442
2412
  btn.setText(str(label))
2443
2413
  try:
@@ -2446,37 +2416,33 @@ class SegmentedControlHandler(AndroidViewHandler):
2446
2416
  pass
2447
2417
  # Equal-width segments: zero base width + weight 1, full height.
2448
2418
  btn.setLayoutParams(LL_LP(0, LL_LP.MATCH_PARENT, 1.0))
2449
- btn.setEnabled(bool(state["enabled"]))
2419
+ enabled = (state.get("props") or {}).get("enabled") is not False
2420
+ btn.setEnabled(enabled)
2450
2421
 
2451
2422
  class _SegmentClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
2452
- def __init__(self, owner_state: Dict[str, Any], seg_index: int, container: Any) -> None:
2423
+ def __init__(self, seg_index: int) -> None:
2453
2424
  super().__init__()
2454
- self._owner_state = owner_state
2455
2425
  self._seg_index = seg_index
2456
- self._container = container
2457
2426
 
2458
2427
  def onClick(self, view: Any) -> None:
2459
- if self._owner_state.get("suppress") or not self._owner_state.get("enabled", True):
2428
+ st = _state_of(ll)
2429
+ if (st.get("props") or {}).get("enabled") is False:
2460
2430
  return
2461
- self._owner_state["selected_index"] = self._seg_index
2462
- restyle(self._owner_state)
2463
- cb = self._owner_state.get("on_change")
2464
- if cb is not None:
2465
- try:
2466
- cb(self._seg_index)
2467
- except Exception:
2468
- pass
2431
+ st["selected_index"] = self._seg_index
2432
+ restyle(ll, st)
2433
+ _fire(ll, "on_change", self._seg_index)
2469
2434
 
2470
- btn.setOnClickListener(_SegmentClickProxy(state, index, ll))
2435
+ btn.setOnClickListener(_SegmentClickProxy(index))
2471
2436
  ll.addView(btn)
2472
2437
  state["buttons"].append(btn)
2473
- self._restyle(state)
2474
-
2475
- def _restyle(self, state: Dict[str, Any]) -> None:
2476
- accent = state.get("tint_color") or self._DEFAULT_ACCENT
2477
- selected = state.get("selected_index", 0)
2478
- enabled = bool(state.get("enabled", True))
2479
- for i, btn in enumerate(state.get("buttons", [])):
2438
+ self._restyle(ll, state)
2439
+
2440
+ def _restyle(self, ll: Any, state: Dict[str, Any]) -> None:
2441
+ merged = state.get("props") or {}
2442
+ accent = merged.get("tint_color") or self._DEFAULT_ACCENT
2443
+ selected = state.get("selected_index", int(merged.get("selected_index", 0) or 0))
2444
+ enabled = merged.get("enabled") is not False
2445
+ for i, btn in enumerate(state.get("buttons") or []):
2480
2446
  self._style_segment(btn, i == selected, accent, enabled)
2481
2447
 
2482
2448
  def _style_segment(self, btn: Any, selected: bool, accent: Any, enabled: bool) -> None:
@@ -2539,85 +2505,50 @@ class DatePickerHandler(AndroidViewHandler):
2539
2505
  date→time flow (``"datetime"``). Values are parsed / formatted with
2540
2506
  ``java.util.Calendar`` + ``java.text.SimpleDateFormat`` using
2541
2507
  per-mode ISO patterns, and the confirmed value is reported through
2542
- ``on_change``.
2508
+ the ``on_change`` event.
2543
2509
  """
2544
2510
 
2545
2511
  _PATTERNS = {"date": "yyyy-MM-dd", "time": "HH:mm", "datetime": "yyyy-MM-dd'T'HH:mm"}
2546
2512
  _PLACEHOLDERS = {"date": "Select date", "time": "Select time", "datetime": "Select date & time"}
2547
2513
 
2548
- def create(self, props: Dict[str, Any]) -> Any:
2514
+ def _build(self, props: Dict[str, Any]) -> Any:
2549
2515
  btn = jclass("android.widget.Button")(_ctx())
2550
2516
  try:
2551
2517
  btn.setAllCaps(False)
2552
2518
  except Exception:
2553
2519
  pass
2554
- self._state: Dict[int, Dict[str, Any]] = getattr(self, "_state", {})
2555
- self._state[id(btn)] = {
2556
- "value": None,
2557
- "mode": "date",
2558
- "on_change": None,
2559
- "minimum": None,
2560
- "maximum": None,
2561
- "enabled": True,
2562
- }
2563
- self._apply(btn, props, initial=True)
2564
- return btn
2520
+ open_dialog = self._open_dialog
2565
2521
 
2566
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2567
- self._apply(native_view, changed, initial=False)
2522
+ class _DateTriggerProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
2523
+ def onClick(self, view: Any) -> None:
2524
+ st = _state_of(view)
2525
+ if (st.get("props") or {}).get("enabled") is False:
2526
+ return
2527
+ open_dialog(view, st)
2568
2528
 
2569
- def _apply(self, btn: Any, props: Dict[str, Any], initial: bool) -> None:
2570
- state = self._state.setdefault(
2571
- id(btn),
2572
- {"value": None, "mode": "date", "on_change": None, "minimum": None, "maximum": None, "enabled": True},
2573
- )
2529
+ btn.setOnClickListener(_DateTriggerProxy())
2530
+ return btn
2574
2531
 
2575
- if "mode" in props and props["mode"]:
2576
- state["mode"] = str(props["mode"])
2577
- if "on_change" in props:
2578
- state["on_change"] = props.get("on_change")
2579
- if "minimum" in props:
2580
- state["minimum"] = props.get("minimum")
2581
- if "maximum" in props:
2582
- state["maximum"] = props.get("maximum")
2532
+ def _apply(self, btn: Any, props: Dict[str, Any], initial: bool) -> None:
2533
+ state = _state_of(btn)
2583
2534
  if "enabled" in props:
2584
- state["enabled"] = props["enabled"] is not False
2585
- btn.setEnabled(bool(state["enabled"]))
2586
- if "value" in props or initial:
2587
- state["value"] = props.get("value") if "value" in props else state.get("value")
2588
-
2589
- self._refresh_label(btn, state)
2590
- if initial:
2591
- self._attach_trigger(btn, state)
2592
-
2535
+ btn.setEnabled(props["enabled"] is not False)
2536
+ if "value" in props or "mode" in props or initial:
2537
+ self._refresh_label(btn, state)
2593
2538
  _apply_accessibility(btn, props)
2594
2539
 
2595
2540
  def _refresh_label(self, btn: Any, state: Dict[str, Any]) -> None:
2596
- value = state.get("value")
2541
+ merged = state.get("props") or {}
2542
+ value = merged.get("value")
2597
2543
  if value:
2598
2544
  btn.setText(str(value))
2599
2545
  else:
2600
- btn.setText(self._PLACEHOLDERS.get(state.get("mode", "date"), "Select"))
2601
-
2602
- def _attach_trigger(self, btn: Any, state: Dict[str, Any]) -> None:
2603
- open_dialog = self._open_dialog
2604
-
2605
- class _DateTriggerProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
2606
- def __init__(self, owner_state: Dict[str, Any], trigger: Any) -> None:
2607
- super().__init__()
2608
- self._owner_state = owner_state
2609
- self._trigger = trigger
2610
-
2611
- def onClick(self, view: Any) -> None:
2612
- if not self._owner_state.get("enabled", True):
2613
- return
2614
- open_dialog(self._trigger, self._owner_state)
2615
-
2616
- btn.setOnClickListener(_DateTriggerProxy(state, btn))
2546
+ btn.setText(self._PLACEHOLDERS.get(str(merged.get("mode", "date")), "Select"))
2617
2547
 
2618
2548
  def _open_dialog(self, btn: Any, state: Dict[str, Any]) -> None:
2619
- mode = state.get("mode", "date")
2620
- cal = self._parse_to_calendar(state.get("value"), mode)
2549
+ merged = state.get("props") or {}
2550
+ mode = str(merged.get("mode", "date"))
2551
+ cal = self._parse_to_calendar(merged.get("value"), mode)
2621
2552
  if mode == "time":
2622
2553
  self._open_time(btn, state, cal)
2623
2554
  elif mode == "datetime":
@@ -2685,10 +2616,11 @@ class DatePickerHandler(AndroidViewHandler):
2685
2616
 
2686
2617
  def _apply_min_max(self, dialog: Any, state: Dict[str, Any]) -> None:
2687
2618
  try:
2688
- mode = state.get("mode", "date")
2619
+ merged = state.get("props") or {}
2620
+ mode = str(merged.get("mode", "date"))
2689
2621
  picker = dialog.getDatePicker()
2690
- minimum = state.get("minimum")
2691
- maximum = state.get("maximum")
2622
+ minimum = merged.get("minimum")
2623
+ maximum = merged.get("maximum")
2692
2624
  if minimum:
2693
2625
  picker.setMinDate(self._parse_to_calendar(minimum, mode).getTimeInMillis())
2694
2626
  if maximum:
@@ -2716,22 +2648,18 @@ class DatePickerHandler(AndroidViewHandler):
2716
2648
  return str(fmt.format(cal.getTime()))
2717
2649
 
2718
2650
  def _commit(self, btn: Any, state: Dict[str, Any], cal: Any) -> None:
2719
- mode = state.get("mode", "date")
2651
+ merged = state.get("props") or {}
2652
+ mode = str(merged.get("mode", "date"))
2720
2653
  try:
2721
2654
  iso = self._format_calendar(cal, mode)
2722
2655
  except Exception:
2723
2656
  return
2724
- state["value"] = iso
2657
+ merged["value"] = iso
2725
2658
  try:
2726
2659
  btn.setText(iso)
2727
2660
  except Exception:
2728
2661
  pass
2729
- cb = state.get("on_change")
2730
- if cb is not None:
2731
- try:
2732
- cb(iso)
2733
- except Exception:
2734
- pass
2662
+ _fire(btn, "on_change", iso)
2735
2663
 
2736
2664
 
2737
2665
  # ======================================================================
@@ -2762,7 +2690,6 @@ def register_handlers(registry: Any) -> None:
2762
2690
  registry.register("Pressable", PressableHandler())
2763
2691
  registry.register("StatusBar", StatusBarHandler())
2764
2692
  registry.register("KeyboardAvoidingView", KeyboardAvoidingViewHandler())
2765
- registry.register("VirtualList", VirtualListHandler())
2766
2693
  registry.register("Picker", PickerHandler())
2767
2694
  registry.register("Checkbox", CheckboxHandler())
2768
2695
  registry.register("SegmentedControl", SegmentedControlHandler())
@@ -2789,7 +2716,6 @@ __all__ = [
2789
2716
  "PressableHandler",
2790
2717
  "StatusBarHandler",
2791
2718
  "KeyboardAvoidingViewHandler",
2792
- "VirtualListHandler",
2793
2719
  "PickerHandler",
2794
2720
  "CheckboxHandler",
2795
2721
  "SegmentedControlHandler",