pythonnative 0.21.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.
- pythonnative/__init__.py +14 -3
- pythonnative/animated.py +420 -135
- pythonnative/components.py +519 -235
- pythonnative/events.py +210 -0
- pythonnative/gestures.py +875 -0
- pythonnative/layout.py +463 -149
- pythonnative/mutations.py +130 -0
- pythonnative/native_views/__init__.py +161 -97
- pythonnative/native_views/android.py +1048 -1142
- pythonnative/native_views/base.py +108 -18
- pythonnative/native_views/desktop.py +460 -417
- pythonnative/native_views/ios.py +1918 -1916
- pythonnative/project/android.py +2 -2
- pythonnative/reconciler.py +540 -470
- pythonnative/screen.py +5 -2
- pythonnative/sdk/_components.py +2 -2
- pythonnative/templates/android_template/app/build.gradle +2 -0
- {pythonnative-0.21.0.dist-info → pythonnative-0.22.0.dist-info}/METADATA +5 -2
- {pythonnative-0.21.0.dist-info → pythonnative-0.22.0.dist-info}/RECORD +23 -21
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +0 -129
- {pythonnative-0.21.0.dist-info → pythonnative-0.22.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.21.0.dist-info → pythonnative-0.22.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.21.0.dist-info → pythonnative-0.22.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.21.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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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`.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
[`
|
|
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
|
|
|
@@ -48,32 +57,65 @@ def _ctx() -> Any:
|
|
|
48
57
|
return get_android_context()
|
|
49
58
|
|
|
50
59
|
|
|
51
|
-
def
|
|
52
|
-
|
|
60
|
+
def _density() -> float:
|
|
61
|
+
return float(_ctx().getResources().getDisplayMetrics().density)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _dp(value: float) -> int:
|
|
65
|
+
return int(round(value * _density()))
|
|
53
66
|
|
|
54
|
-
The Android template's helper classes (e.g. ``PNVirtualListView``)
|
|
55
|
-
live in the app's own package, which the ``pn`` CLI relocates to the
|
|
56
|
-
configured ``application_id`` at build time. Deriving the package from
|
|
57
|
-
the runtime ``Context`` (rather than hardcoding the template package)
|
|
58
|
-
keeps these lookups correct for any app id.
|
|
59
67
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
``"PNVirtualListView"`` or ``"PNVirtualListView$Delegate"``.
|
|
68
|
+
def _java_id(jobj: Any) -> int:
|
|
69
|
+
"""Return ``System.identityHashCode(jobj)`` as a stable lookup key.
|
|
63
70
|
|
|
64
|
-
|
|
65
|
-
|
|
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.
|
|
66
75
|
"""
|
|
67
|
-
|
|
68
|
-
return
|
|
76
|
+
System = jclass("java.lang.System")
|
|
77
|
+
return int(System.identityHashCode(jobj))
|
|
69
78
|
|
|
70
79
|
|
|
71
|
-
|
|
72
|
-
|
|
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]] = {}
|
|
73
83
|
|
|
74
84
|
|
|
75
|
-
def
|
|
76
|
-
|
|
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)
|
|
77
119
|
|
|
78
120
|
|
|
79
121
|
def _apply_border(view: Any, props: Dict[str, Any]) -> None:
|
|
@@ -211,11 +253,12 @@ def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None:
|
|
|
211
253
|
"""Apply visual properties shared across many handlers."""
|
|
212
254
|
has_drawable_keys = any(k in props for k in _DRAWABLE_STYLE_KEYS)
|
|
213
255
|
if has_drawable_keys:
|
|
214
|
-
|
|
256
|
+
state = _state_of(view)
|
|
257
|
+
visual_props = dict(state.get("visual") or {})
|
|
215
258
|
for key in _DRAWABLE_STYLE_KEYS:
|
|
216
259
|
if key in props:
|
|
217
260
|
visual_props[key] = props[key]
|
|
218
|
-
|
|
261
|
+
state["visual"] = visual_props
|
|
219
262
|
_apply_border(view, visual_props)
|
|
220
263
|
if "overflow" in props:
|
|
221
264
|
clip = props["overflow"] == "hidden"
|
|
@@ -235,19 +278,299 @@ def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None:
|
|
|
235
278
|
|
|
236
279
|
|
|
237
280
|
# ======================================================================
|
|
238
|
-
#
|
|
281
|
+
# Gesture wiring (MotionEvent -> GestureArbiter -> dispatch_event)
|
|
239
282
|
# ======================================================================
|
|
240
283
|
|
|
241
284
|
|
|
242
|
-
|
|
243
|
-
"""
|
|
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
|
+
|
|
244
411
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
+
|
|
421
|
+
|
|
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.
|
|
249
536
|
"""
|
|
250
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
|
+
|
|
251
574
|
def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
|
|
252
575
|
if native_view is None:
|
|
253
576
|
return
|
|
@@ -305,46 +628,11 @@ class AndroidViewHandler(ViewHandler):
|
|
|
305
628
|
except Exception:
|
|
306
629
|
return (0.0, 0.0)
|
|
307
630
|
|
|
308
|
-
def set_animated_property(
|
|
309
|
-
|
|
310
|
-
native_view: Any,
|
|
311
|
-
prop_name: str,
|
|
312
|
-
value: Any,
|
|
313
|
-
duration_ms: float = 0.0,
|
|
314
|
-
easing: str = "linear",
|
|
315
|
-
) -> None:
|
|
316
|
-
"""Apply ``prop_name`` to ``native_view`` immediately or animated.
|
|
317
|
-
|
|
318
|
-
When ``duration_ms > 0``, the change is wrapped in a
|
|
319
|
-
``ViewPropertyAnimator`` so Choreographer drives the
|
|
320
|
-
per-frame interpolation.
|
|
321
|
-
"""
|
|
631
|
+
def set_animated_property(self, native_view: Any, prop_name: str, value: Any) -> None:
|
|
632
|
+
"""Apply one Python-driven animation frame immediately."""
|
|
322
633
|
if native_view is None:
|
|
323
634
|
return
|
|
324
635
|
try:
|
|
325
|
-
if duration_ms > 0:
|
|
326
|
-
animator = native_view.animate()
|
|
327
|
-
animator.setDuration(int(duration_ms))
|
|
328
|
-
if prop_name == "opacity":
|
|
329
|
-
animator.alpha(float(value))
|
|
330
|
-
elif prop_name == "translate_x":
|
|
331
|
-
animator.translationX(float(_dp(float(value))))
|
|
332
|
-
elif prop_name == "translate_y":
|
|
333
|
-
animator.translationY(float(_dp(float(value))))
|
|
334
|
-
elif prop_name == "scale":
|
|
335
|
-
animator.scaleX(float(value))
|
|
336
|
-
animator.scaleY(float(value))
|
|
337
|
-
elif prop_name == "scale_x":
|
|
338
|
-
animator.scaleX(float(value))
|
|
339
|
-
elif prop_name == "scale_y":
|
|
340
|
-
animator.scaleY(float(value))
|
|
341
|
-
elif prop_name == "rotate":
|
|
342
|
-
animator.rotation(float(value))
|
|
343
|
-
else:
|
|
344
|
-
return
|
|
345
|
-
animator.start()
|
|
346
|
-
return
|
|
347
|
-
# Immediate path.
|
|
348
636
|
if prop_name == "opacity":
|
|
349
637
|
native_view.setAlpha(float(value))
|
|
350
638
|
elif prop_name == "translate_x":
|
|
@@ -365,6 +653,35 @@ class AndroidViewHandler(ViewHandler):
|
|
|
365
653
|
except Exception:
|
|
366
654
|
pass
|
|
367
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
|
+
|
|
368
685
|
|
|
369
686
|
# ======================================================================
|
|
370
687
|
# Flex container handler (shared by Column, Row, View)
|
|
@@ -380,32 +697,43 @@ class FlexContainerHandler(AndroidViewHandler):
|
|
|
380
697
|
The container itself is just a positioning surface.
|
|
381
698
|
"""
|
|
382
699
|
|
|
383
|
-
def
|
|
384
|
-
|
|
385
|
-
_apply_common_visual(fl, props)
|
|
386
|
-
return fl
|
|
387
|
-
|
|
388
|
-
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
389
|
-
_apply_common_visual(native_view, changed)
|
|
700
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
701
|
+
return jclass("android.widget.FrameLayout")(_ctx())
|
|
390
702
|
|
|
391
|
-
def
|
|
392
|
-
|
|
393
|
-
lp = child.getLayoutParams()
|
|
394
|
-
if lp is None:
|
|
395
|
-
lp = FrameLP(0, 0)
|
|
396
|
-
child.setLayoutParams(lp)
|
|
397
|
-
parent.addView(child)
|
|
703
|
+
def insert_child(self, parent: Any, child: Any, index: int) -> None:
|
|
704
|
+
_insert_view(parent, child, index)
|
|
398
705
|
|
|
399
706
|
def remove_child(self, parent: Any, child: Any) -> None:
|
|
400
|
-
|
|
707
|
+
try:
|
|
708
|
+
parent.removeView(child)
|
|
709
|
+
except Exception:
|
|
710
|
+
pass
|
|
401
711
|
|
|
402
|
-
|
|
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)
|
|
403
728
|
FrameLP = jclass("android.widget.FrameLayout$LayoutParams")
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
409
737
|
|
|
410
738
|
|
|
411
739
|
# ======================================================================
|
|
@@ -431,15 +759,10 @@ def _typeface_for(weight: Any, italic: bool) -> Any:
|
|
|
431
759
|
|
|
432
760
|
|
|
433
761
|
class TextHandler(AndroidViewHandler):
|
|
434
|
-
def
|
|
435
|
-
|
|
436
|
-
self._apply(tv, props)
|
|
437
|
-
return tv
|
|
762
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
763
|
+
return jclass("android.widget.TextView")(_ctx())
|
|
438
764
|
|
|
439
|
-
def
|
|
440
|
-
self._apply(native_view, changed)
|
|
441
|
-
|
|
442
|
-
def _apply(self, tv: Any, props: Dict[str, Any]) -> None:
|
|
765
|
+
def _apply(self, tv: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
443
766
|
if "text" in props:
|
|
444
767
|
tv.setText(str(props["text"]) if props["text"] is not None else "")
|
|
445
768
|
if "font_size" in props and props["font_size"] is not None:
|
|
@@ -449,9 +772,10 @@ class TextHandler(AndroidViewHandler):
|
|
|
449
772
|
if any(k in props for k in ("font_family", "font_weight", "italic", "bold")):
|
|
450
773
|
try:
|
|
451
774
|
Typeface = jclass("android.graphics.Typeface")
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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"))
|
|
455
779
|
style = _typeface_for(weight, italic)
|
|
456
780
|
if family:
|
|
457
781
|
base = Typeface.create(str(family), style)
|
|
@@ -470,13 +794,15 @@ class TextHandler(AndroidViewHandler):
|
|
|
470
794
|
try:
|
|
471
795
|
# Android expects letter_spacing as ems (a unitless ratio of font size).
|
|
472
796
|
# Convert from points by dividing by ~font_size; if no font size, use 16.
|
|
473
|
-
|
|
797
|
+
merged = _state_of(tv).get("props") or props
|
|
798
|
+
size = float(merged.get("font_size") or 16.0)
|
|
474
799
|
tv.setLetterSpacing(float(props["letter_spacing"]) / max(size, 1.0))
|
|
475
800
|
except Exception:
|
|
476
801
|
pass
|
|
477
802
|
if "line_height" in props and props["line_height"] is not None:
|
|
478
803
|
try:
|
|
479
|
-
|
|
804
|
+
merged = _state_of(tv).get("props") or props
|
|
805
|
+
size = float(merged.get("font_size") or 16.0)
|
|
480
806
|
tv.setLineSpacing(0.0, float(props["line_height"]) / max(size, 1.0))
|
|
481
807
|
except Exception:
|
|
482
808
|
pass
|
|
@@ -495,15 +821,17 @@ class TextHandler(AndroidViewHandler):
|
|
|
495
821
|
|
|
496
822
|
|
|
497
823
|
class ButtonHandler(AndroidViewHandler):
|
|
498
|
-
def
|
|
824
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
499
825
|
btn = jclass("android.widget.Button")(_ctx())
|
|
500
|
-
self._apply(btn, props)
|
|
501
|
-
return btn
|
|
502
826
|
|
|
503
|
-
|
|
504
|
-
|
|
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
|
|
505
833
|
|
|
506
|
-
def _apply(self, btn: Any, props: Dict[str, Any]) -> None:
|
|
834
|
+
def _apply(self, btn: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
507
835
|
if "title" in props:
|
|
508
836
|
btn.setText(str(props["title"]) if props["title"] is not None else "")
|
|
509
837
|
if "font_size" in props and props["font_size"] is not None:
|
|
@@ -512,88 +840,72 @@ class ButtonHandler(AndroidViewHandler):
|
|
|
512
840
|
btn.setTextColor(parse_color_int(props["color"]))
|
|
513
841
|
if "enabled" in props:
|
|
514
842
|
btn.setEnabled(bool(props["enabled"]))
|
|
515
|
-
if "on_click" in props:
|
|
516
|
-
cb = props["on_click"]
|
|
517
|
-
if cb is not None:
|
|
518
|
-
|
|
519
|
-
class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
|
|
520
|
-
def __init__(self, callback: Callable[[], None]) -> None:
|
|
521
|
-
super().__init__()
|
|
522
|
-
self.callback = callback
|
|
523
|
-
|
|
524
|
-
def onClick(self, view: Any) -> None:
|
|
525
|
-
self.callback()
|
|
526
|
-
|
|
527
|
-
btn.setOnClickListener(ClickProxy(cb))
|
|
528
|
-
else:
|
|
529
|
-
btn.setOnClickListener(None)
|
|
530
843
|
_apply_common_visual(btn, props)
|
|
531
844
|
|
|
532
845
|
|
|
533
|
-
_pn_scrollview_state: Dict[int, Dict[str, Any]] = {}
|
|
534
|
-
|
|
535
|
-
|
|
536
846
|
class ScrollViewHandler(AndroidViewHandler):
|
|
537
847
|
"""Scroll container — wraps a single child whose height is unbounded.
|
|
538
848
|
|
|
539
|
-
Uses ``androidx.core.widget.NestedScrollView``
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
no overflow. That breaks the common case of nesting a small
|
|
543
|
-
fixed-height scroll view inside a screen-level scroll view (the
|
|
544
|
-
outer steals every gesture and the inner never scrolls).
|
|
545
|
-
``NestedScrollView`` implements the standard
|
|
546
|
-
``NestedScrollingParent2`` / ``NestedScrollingChild2`` protocol so
|
|
547
|
-
the outer cooperates with any nested scroll, only consuming
|
|
548
|
-
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.
|
|
549
852
|
|
|
550
853
|
When a ``refresh_control`` prop is provided at creation, the scroll
|
|
551
|
-
view is wrapped in
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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``.
|
|
556
861
|
"""
|
|
557
862
|
|
|
558
|
-
def create(self, props: Dict[str, Any]) -> Any:
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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:
|
|
566
882
|
wrapper = self._wrap_in_refresh(sv)
|
|
567
883
|
if wrapper is not None:
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
target = state["scroll"] if state else parent
|
|
594
|
-
target.removeView(child)
|
|
595
|
-
|
|
596
|
-
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)
|
|
597
909
|
if "shows_scroll_indicator" in props:
|
|
598
910
|
# Only present when ``False`` (hide); a removal restores bars.
|
|
599
911
|
show = props["shows_scroll_indicator"] is not False
|
|
@@ -611,26 +923,69 @@ class ScrollViewHandler(AndroidViewHandler):
|
|
|
611
923
|
sv.setOverScrollMode(mode)
|
|
612
924
|
except Exception:
|
|
613
925
|
pass
|
|
614
|
-
if "
|
|
615
|
-
self.
|
|
926
|
+
if "refresh_control" in props and state.get("refresh") is not None:
|
|
927
|
+
self._apply_refresh(outer, props)
|
|
616
928
|
# ``paging_enabled`` and ``keyboard_dismiss_mode`` have no clean
|
|
617
929
|
# NestedScrollView analogue, so they are intentionally skipped
|
|
618
930
|
# rather than approximated poorly.
|
|
619
931
|
|
|
620
|
-
def
|
|
621
|
-
|
|
622
|
-
|
|
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:
|
|
623
984
|
try:
|
|
624
985
|
if jclass("android.os.Build$VERSION").SDK_INT < 23:
|
|
625
986
|
return
|
|
626
|
-
density = _density()
|
|
627
987
|
|
|
628
988
|
class _ScrollChangeProxy(dynamic_proxy(jclass("android.view.View").OnScrollChangeListener)):
|
|
629
|
-
def __init__(self, callback: Callable[[float, float], None], dens: float) -> None:
|
|
630
|
-
super().__init__()
|
|
631
|
-
self.callback = callback
|
|
632
|
-
self.dens = dens if dens else 1.0
|
|
633
|
-
|
|
634
989
|
def onScrollChange(
|
|
635
990
|
self,
|
|
636
991
|
v: Any,
|
|
@@ -640,11 +995,12 @@ class ScrollViewHandler(AndroidViewHandler):
|
|
|
640
995
|
old_y: int,
|
|
641
996
|
) -> None:
|
|
642
997
|
try:
|
|
643
|
-
|
|
998
|
+
density = _density() or 1.0
|
|
999
|
+
_fire(outer, "on_scroll", {"x": scroll_x / density, "y": scroll_y / density})
|
|
644
1000
|
except Exception:
|
|
645
1001
|
pass
|
|
646
1002
|
|
|
647
|
-
sv.setOnScrollChangeListener(_ScrollChangeProxy(
|
|
1003
|
+
sv.setOnScrollChangeListener(_ScrollChangeProxy())
|
|
648
1004
|
except Exception:
|
|
649
1005
|
pass
|
|
650
1006
|
|
|
@@ -658,11 +1014,22 @@ class ScrollViewHandler(AndroidViewHandler):
|
|
|
658
1014
|
except Exception:
|
|
659
1015
|
return None
|
|
660
1016
|
|
|
661
|
-
def
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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")
|
|
666
1033
|
if srl is None:
|
|
667
1034
|
return
|
|
668
1035
|
spec = props.get("refresh_control")
|
|
@@ -676,26 +1043,6 @@ class ScrollViewHandler(AndroidViewHandler):
|
|
|
676
1043
|
srl.setEnabled(True)
|
|
677
1044
|
except Exception:
|
|
678
1045
|
pass
|
|
679
|
-
state["on_refresh"] = spec.get("on_refresh")
|
|
680
|
-
if not state.get("listener_bound"):
|
|
681
|
-
owner = state
|
|
682
|
-
|
|
683
|
-
class _RefreshProxy(
|
|
684
|
-
dynamic_proxy(jclass("androidx.swiperefreshlayout.widget.SwipeRefreshLayout").OnRefreshListener)
|
|
685
|
-
):
|
|
686
|
-
def onRefresh(self) -> None:
|
|
687
|
-
callback = owner.get("on_refresh")
|
|
688
|
-
if callback is not None:
|
|
689
|
-
try:
|
|
690
|
-
callback()
|
|
691
|
-
except Exception:
|
|
692
|
-
pass
|
|
693
|
-
|
|
694
|
-
try:
|
|
695
|
-
srl.setOnRefreshListener(_RefreshProxy())
|
|
696
|
-
state["listener_bound"] = True
|
|
697
|
-
except Exception:
|
|
698
|
-
pass
|
|
699
1046
|
if spec.get("tint_color"):
|
|
700
1047
|
try:
|
|
701
1048
|
srl.setColorSchemeColors([parse_color_int(spec["tint_color"])])
|
|
@@ -708,28 +1055,55 @@ class ScrollViewHandler(AndroidViewHandler):
|
|
|
708
1055
|
|
|
709
1056
|
|
|
710
1057
|
class TextInputHandler(AndroidViewHandler):
|
|
711
|
-
def
|
|
1058
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
712
1059
|
et = jclass("android.widget.EditText")(_ctx())
|
|
713
1060
|
# Default to single-line so pressing Enter triggers IME_ACTION_DONE
|
|
714
|
-
# (submit / dismiss) instead of inserting a newline.
|
|
715
|
-
#
|
|
716
|
-
# set in props. Without this, every TextInput without an
|
|
717
|
-
# explicit ``multiline`` value falls back to Android's
|
|
718
|
-
# multi-line default and Enter inserts ``\n``.
|
|
1061
|
+
# (submit / dismiss) instead of inserting a newline. ``_apply``
|
|
1062
|
+
# overrides this when ``multiline=True``.
|
|
719
1063
|
try:
|
|
720
1064
|
if not props.get("multiline"):
|
|
721
1065
|
et.setSingleLine(True)
|
|
722
1066
|
except Exception:
|
|
723
1067
|
pass
|
|
724
|
-
self.
|
|
1068
|
+
self._bind_listeners(et, props)
|
|
725
1069
|
return et
|
|
726
1070
|
|
|
727
|
-
def
|
|
728
|
-
|
|
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")
|
|
1099
|
+
|
|
1100
|
+
et.setOnFocusChangeListener(_FocusProxy())
|
|
1101
|
+
except Exception:
|
|
1102
|
+
pass
|
|
729
1103
|
|
|
730
|
-
def _apply(self, et: Any, props: Dict[str, Any]) -> None:
|
|
1104
|
+
def _apply(self, et: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
1105
|
+
state = _state_of(et)
|
|
731
1106
|
if "value" in props:
|
|
732
|
-
key = id(et)
|
|
733
1107
|
incoming = str(props["value"]) if props["value"] is not None else ""
|
|
734
1108
|
try:
|
|
735
1109
|
before = str(et.getText())
|
|
@@ -743,7 +1117,7 @@ class TextInputHandler(AndroidViewHandler):
|
|
|
743
1117
|
selection_end = et.getSelectionEnd()
|
|
744
1118
|
except Exception:
|
|
745
1119
|
pass
|
|
746
|
-
|
|
1120
|
+
state["suppress"] = True
|
|
747
1121
|
try:
|
|
748
1122
|
et.setText(incoming)
|
|
749
1123
|
try:
|
|
@@ -757,7 +1131,7 @@ class TextInputHandler(AndroidViewHandler):
|
|
|
757
1131
|
except Exception:
|
|
758
1132
|
pass
|
|
759
1133
|
finally:
|
|
760
|
-
|
|
1134
|
+
state["suppress"] = False
|
|
761
1135
|
if "placeholder" in props:
|
|
762
1136
|
et.setHint(str(props["placeholder"]) if props["placeholder"] is not None else "")
|
|
763
1137
|
if "placeholder_color" in props and props["placeholder_color"] is not None:
|
|
@@ -770,13 +1144,14 @@ class TextInputHandler(AndroidViewHandler):
|
|
|
770
1144
|
if "color" in props and props["color"] is not None:
|
|
771
1145
|
et.setTextColor(parse_color_int(props["color"]))
|
|
772
1146
|
if any(k in props for k in ("multiline", "secure", "keyboard_type", "auto_capitalize")):
|
|
1147
|
+
merged = state.get("props") or props
|
|
773
1148
|
try:
|
|
774
1149
|
InputType = jclass("android.text.InputType")
|
|
775
1150
|
base = InputType.TYPE_CLASS_TEXT
|
|
776
|
-
if
|
|
1151
|
+
if merged.get("secure"):
|
|
777
1152
|
base = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD
|
|
778
1153
|
else:
|
|
779
|
-
kt =
|
|
1154
|
+
kt = merged.get("keyboard_type")
|
|
780
1155
|
if kt == "email_address":
|
|
781
1156
|
base = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
|
|
782
1157
|
elif kt == "number_pad" or kt == "decimal_pad":
|
|
@@ -787,14 +1162,14 @@ class TextInputHandler(AndroidViewHandler):
|
|
|
787
1162
|
base = InputType.TYPE_CLASS_PHONE
|
|
788
1163
|
elif kt == "url":
|
|
789
1164
|
base = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI
|
|
790
|
-
auto_cap =
|
|
1165
|
+
auto_cap = merged.get("auto_capitalize")
|
|
791
1166
|
if auto_cap == "sentences":
|
|
792
1167
|
base |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
|
|
793
1168
|
elif auto_cap == "words":
|
|
794
1169
|
base |= InputType.TYPE_TEXT_FLAG_CAP_WORDS
|
|
795
1170
|
elif auto_cap == "characters":
|
|
796
1171
|
base |= InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
|
|
797
|
-
if
|
|
1172
|
+
if merged.get("multiline"):
|
|
798
1173
|
base |= InputType.TYPE_TEXT_FLAG_MULTI_LINE
|
|
799
1174
|
et.setSingleLine(False)
|
|
800
1175
|
else:
|
|
@@ -835,49 +1210,12 @@ class TextInputHandler(AndroidViewHandler):
|
|
|
835
1210
|
self._apply_autofill(et, str(props["text_content_type"]))
|
|
836
1211
|
if "clear_button" in props:
|
|
837
1212
|
self._apply_clear_button(et, bool(props.get("clear_button")))
|
|
838
|
-
if "on_focus" in props or "on_blur" in props:
|
|
839
|
-
self._apply_focus_listener(et, props)
|
|
840
|
-
if "on_change" in props:
|
|
841
|
-
key = id(et)
|
|
842
|
-
cb = props["on_change"]
|
|
843
|
-
if cb is not None:
|
|
844
|
-
_pn_text_input_callbacks[key] = cb
|
|
845
|
-
if key not in _pn_text_input_watchers:
|
|
846
|
-
TextWatcher = jclass("android.text.TextWatcher")
|
|
847
|
-
|
|
848
|
-
class ChangeProxy(dynamic_proxy(TextWatcher)):
|
|
849
|
-
def __init__(self, view_key: int) -> None:
|
|
850
|
-
super().__init__()
|
|
851
|
-
self.view_key = view_key
|
|
852
|
-
|
|
853
|
-
def afterTextChanged(self, s: Any) -> None:
|
|
854
|
-
text = str(s)
|
|
855
|
-
if _pn_text_input_suppress_callbacks.get(self.view_key):
|
|
856
|
-
return
|
|
857
|
-
callback = _pn_text_input_callbacks.get(self.view_key)
|
|
858
|
-
if callback is None:
|
|
859
|
-
return
|
|
860
|
-
callback(text)
|
|
861
|
-
|
|
862
|
-
def beforeTextChanged(self, s: Any, start: int, count: int, after: int) -> None:
|
|
863
|
-
pass
|
|
864
|
-
|
|
865
|
-
def onTextChanged(self, s: Any, start: int, before: int, count: int) -> None:
|
|
866
|
-
pass
|
|
867
|
-
|
|
868
|
-
watcher = ChangeProxy(key)
|
|
869
|
-
_pn_text_input_watchers[key] = watcher
|
|
870
|
-
et.addTextChangedListener(watcher)
|
|
871
|
-
else:
|
|
872
|
-
_pn_text_input_callbacks[key] = None
|
|
873
1213
|
if "return_key_type" in props and props["return_key_type"] is not None:
|
|
874
1214
|
# Map the cross-platform ``return_key_type`` to Android's
|
|
875
1215
|
# ``EditorInfo.IME_ACTION_*`` so the soft keyboard renders the
|
|
876
|
-
# right action key
|
|
877
|
-
#
|
|
878
|
-
#
|
|
879
|
-
# direct AOSP equivalents — fall back to ``IME_ACTION_DONE``
|
|
880
|
-
# 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.
|
|
881
1219
|
try:
|
|
882
1220
|
EditorInfo = jclass("android.view.inputmethod.EditorInfo")
|
|
883
1221
|
rkt_mapping = {
|
|
@@ -896,31 +1234,36 @@ class TextInputHandler(AndroidViewHandler):
|
|
|
896
1234
|
et.setImeOptions(action)
|
|
897
1235
|
except Exception:
|
|
898
1236
|
pass
|
|
899
|
-
if
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
# *or* the Enter key on a single-line ``EditText`` dismisses
|
|
903
|
-
# the soft keyboard. Without this the keyboard stays up after
|
|
904
|
-
# ``inputText`` + ``pressKey: Enter`` in Maestro and on smaller
|
|
905
|
-
# screens hides the rest of the layout — and matches React
|
|
906
|
-
# Native's default Android behavior. ``on_submit`` (if any) is
|
|
907
|
-
# fired before dismissal so the callback sees the final text.
|
|
908
|
-
try:
|
|
909
|
-
on_submit_cb = props.get("on_submit")
|
|
910
|
-
EditorListener = jclass("android.widget.TextView$OnEditorActionListener")
|
|
911
|
-
Context = jclass("android.content.Context")
|
|
1237
|
+
if initial or "multiline" in props:
|
|
1238
|
+
self._apply_editor_action(et)
|
|
1239
|
+
_apply_common_visual(et, props)
|
|
912
1240
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
super().__init__()
|
|
916
|
-
self.callback = callback
|
|
1241
|
+
def _apply_editor_action(self, et: Any) -> None:
|
|
1242
|
+
"""Install the IME action listener for submit + keyboard dismissal.
|
|
917
1243
|
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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:
|
|
924
1267
|
try:
|
|
925
1268
|
view.clearFocus()
|
|
926
1269
|
ctx = view.getContext()
|
|
@@ -928,35 +1271,11 @@ class TextInputHandler(AndroidViewHandler):
|
|
|
928
1271
|
imm.hideSoftInputFromWindow(view.getWindowToken(), 0)
|
|
929
1272
|
except Exception:
|
|
930
1273
|
pass
|
|
931
|
-
|
|
1274
|
+
return True
|
|
932
1275
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
elif "on_submit" in props and props["on_submit"] is not None:
|
|
937
|
-
# Multi-line inputs: only install the listener when an explicit
|
|
938
|
-
# ``on_submit`` is provided. Enter inserts a newline by default
|
|
939
|
-
# on multi-line ``EditText`` and we don't want to override that.
|
|
940
|
-
try:
|
|
941
|
-
cb = props["on_submit"]
|
|
942
|
-
EditorListener = jclass("android.widget.TextView$OnEditorActionListener")
|
|
943
|
-
|
|
944
|
-
class SubmitProxy(dynamic_proxy(EditorListener)):
|
|
945
|
-
def __init__(self, callback: Callable[[str], None]) -> None:
|
|
946
|
-
super().__init__()
|
|
947
|
-
self.callback = callback
|
|
948
|
-
|
|
949
|
-
def onEditorAction(self, view: Any, action_id: int, event: Any) -> bool:
|
|
950
|
-
try:
|
|
951
|
-
self.callback(str(view.getText()))
|
|
952
|
-
except Exception:
|
|
953
|
-
pass
|
|
954
|
-
return True
|
|
955
|
-
|
|
956
|
-
et.setOnEditorActionListener(SubmitProxy(cb))
|
|
957
|
-
except Exception:
|
|
958
|
-
pass
|
|
959
|
-
_apply_common_visual(et, props)
|
|
1276
|
+
et.setOnEditorActionListener(SubmitProxy())
|
|
1277
|
+
except Exception:
|
|
1278
|
+
pass
|
|
960
1279
|
|
|
961
1280
|
@staticmethod
|
|
962
1281
|
def _autofill_hint(content_type: str) -> Optional[str]:
|
|
@@ -994,15 +1313,16 @@ class TextInputHandler(AndroidViewHandler):
|
|
|
994
1313
|
# Best-effort drawableEnd "X": shows a system clear icon and wires
|
|
995
1314
|
# a touch listener that clears the field when the icon is tapped.
|
|
996
1315
|
try:
|
|
1316
|
+
state = _state_of(et)
|
|
997
1317
|
if not enabled:
|
|
998
1318
|
et.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
|
|
999
1319
|
return
|
|
1000
1320
|
icon_id = int(getattr(jclass("android.R$drawable"), "ic_menu_close_clear_cancel", 0))
|
|
1001
1321
|
if icon_id:
|
|
1002
1322
|
et.setCompoundDrawablesWithIntrinsicBounds(0, 0, icon_id, 0)
|
|
1003
|
-
|
|
1004
|
-
if key in _pn_text_input_clear_touch:
|
|
1323
|
+
if state.get("clear_bound"):
|
|
1005
1324
|
return
|
|
1325
|
+
state["clear_bound"] = True
|
|
1006
1326
|
|
|
1007
1327
|
class _ClearTouchProxy(dynamic_proxy(jclass("android.view.View").OnTouchListener)):
|
|
1008
1328
|
def onTouch(self, view: Any, event: Any) -> bool:
|
|
@@ -1019,51 +1339,16 @@ class TextInputHandler(AndroidViewHandler):
|
|
|
1019
1339
|
pass
|
|
1020
1340
|
return False
|
|
1021
1341
|
|
|
1022
|
-
|
|
1023
|
-
_pn_text_input_clear_touch[key] = listener
|
|
1024
|
-
et.setOnTouchListener(listener)
|
|
1342
|
+
et.setOnTouchListener(_ClearTouchProxy())
|
|
1025
1343
|
except Exception:
|
|
1026
1344
|
pass
|
|
1027
1345
|
|
|
1028
|
-
def _apply_focus_listener(self, et: Any, props: Dict[str, Any]) -> None:
|
|
1029
|
-
key = id(et)
|
|
1030
|
-
entry = _pn_text_input_focus_callbacks.setdefault(key, {"on_focus": None, "on_blur": None})
|
|
1031
|
-
if "on_focus" in props:
|
|
1032
|
-
entry["on_focus"] = props.get("on_focus")
|
|
1033
|
-
if "on_blur" in props:
|
|
1034
|
-
entry["on_blur"] = props.get("on_blur")
|
|
1035
|
-
if key in _pn_text_input_focus_listeners:
|
|
1036
|
-
return
|
|
1037
|
-
|
|
1038
|
-
class _FocusProxy(dynamic_proxy(jclass("android.view.View").OnFocusChangeListener)):
|
|
1039
|
-
def __init__(self, view_key: int) -> None:
|
|
1040
|
-
super().__init__()
|
|
1041
|
-
self.view_key = view_key
|
|
1042
|
-
|
|
1043
|
-
def onFocusChange(self, view: Any, has_focus: bool) -> None:
|
|
1044
|
-
callbacks = _pn_text_input_focus_callbacks.get(self.view_key) or {}
|
|
1045
|
-
cb = callbacks.get("on_focus") if has_focus else callbacks.get("on_blur")
|
|
1046
|
-
if cb is not None:
|
|
1047
|
-
try:
|
|
1048
|
-
cb()
|
|
1049
|
-
except Exception:
|
|
1050
|
-
pass
|
|
1051
|
-
|
|
1052
|
-
listener = _FocusProxy(key)
|
|
1053
|
-
_pn_text_input_focus_listeners[key] = listener
|
|
1054
|
-
et.setOnFocusChangeListener(listener)
|
|
1055
|
-
|
|
1056
1346
|
|
|
1057
1347
|
class ImageHandler(AndroidViewHandler):
|
|
1058
|
-
def
|
|
1059
|
-
|
|
1060
|
-
self._apply(iv, props)
|
|
1061
|
-
return iv
|
|
1348
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1349
|
+
return jclass("android.widget.ImageView")(_ctx())
|
|
1062
1350
|
|
|
1063
|
-
def
|
|
1064
|
-
self._apply(native_view, changed)
|
|
1065
|
-
|
|
1066
|
-
def _apply(self, iv: Any, props: Dict[str, Any]) -> None:
|
|
1351
|
+
def _apply(self, iv: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
1067
1352
|
if "tint_color" in props and props["tint_color"] is not None:
|
|
1068
1353
|
try:
|
|
1069
1354
|
ColorStateList = jclass("android.content.res.ColorStateList")
|
|
@@ -1137,44 +1422,37 @@ class ImageHandler(AndroidViewHandler):
|
|
|
1137
1422
|
|
|
1138
1423
|
|
|
1139
1424
|
class SwitchHandler(AndroidViewHandler):
|
|
1140
|
-
def
|
|
1425
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1141
1426
|
sw = jclass("android.widget.Switch")(_ctx())
|
|
1142
|
-
self._apply(sw, props)
|
|
1143
|
-
return sw
|
|
1144
|
-
|
|
1145
|
-
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
1146
|
-
self._apply(native_view, changed)
|
|
1147
1427
|
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
class CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)):
|
|
1155
|
-
def __init__(self, callback: Callable[[bool], None]) -> None:
|
|
1156
|
-
super().__init__()
|
|
1157
|
-
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))
|
|
1158
1433
|
|
|
1159
|
-
|
|
1160
|
-
|
|
1434
|
+
sw.setOnCheckedChangeListener(CheckedProxy())
|
|
1435
|
+
return sw
|
|
1161
1436
|
|
|
1162
|
-
|
|
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
|
|
1163
1445
|
_apply_accessibility(sw, props)
|
|
1164
1446
|
|
|
1165
1447
|
|
|
1166
1448
|
class ProgressBarHandler(AndroidViewHandler):
|
|
1167
|
-
def
|
|
1449
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1168
1450
|
style = jclass("android.R$attr").progressBarStyleHorizontal
|
|
1169
1451
|
pb = jclass("android.widget.ProgressBar")(_ctx(), None, 0, style)
|
|
1170
1452
|
pb.setMax(1000)
|
|
1171
|
-
self._apply(pb, props)
|
|
1172
1453
|
return pb
|
|
1173
1454
|
|
|
1174
|
-
def
|
|
1175
|
-
self._apply(native_view, changed)
|
|
1176
|
-
|
|
1177
|
-
def _apply(self, pb: Any, props: Dict[str, Any]) -> None:
|
|
1455
|
+
def _apply(self, pb: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
1178
1456
|
if "value" in props and props["value"] is not None:
|
|
1179
1457
|
pb.setProgress(int(float(props["value"]) * 1000))
|
|
1180
1458
|
if "color" in props and props["color"] is not None:
|
|
@@ -1199,15 +1477,10 @@ class ProgressBarHandler(AndroidViewHandler):
|
|
|
1199
1477
|
|
|
1200
1478
|
|
|
1201
1479
|
class ActivityIndicatorHandler(AndroidViewHandler):
|
|
1202
|
-
def
|
|
1203
|
-
|
|
1204
|
-
self._apply(pb, props)
|
|
1205
|
-
return pb
|
|
1206
|
-
|
|
1207
|
-
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
1208
|
-
self._apply(native_view, changed)
|
|
1480
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1481
|
+
return jclass("android.widget.ProgressBar")(_ctx())
|
|
1209
1482
|
|
|
1210
|
-
def _apply(self, pb: Any, props: Dict[str, Any]) -> None:
|
|
1483
|
+
def _apply(self, pb: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
1211
1484
|
if "animating" in props:
|
|
1212
1485
|
View = jclass("android.view.View")
|
|
1213
1486
|
pb.setVisibility(View.VISIBLE if props["animating"] else View.GONE)
|
|
@@ -1228,11 +1501,8 @@ class ActivityIndicatorHandler(AndroidViewHandler):
|
|
|
1228
1501
|
pass
|
|
1229
1502
|
|
|
1230
1503
|
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
def _make_web_client(store: Dict[str, Any]) -> Any:
|
|
1235
|
-
"""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.
|
|
1236
1506
|
|
|
1237
1507
|
``android.webkit.WebViewClient`` is an abstract *class*, not an
|
|
1238
1508
|
interface, so Chaquopy's ``dynamic_proxy`` may be unable to subclass
|
|
@@ -1240,35 +1510,22 @@ def _make_web_client(store: Dict[str, Any]) -> Any:
|
|
|
1240
1510
|
which case the caller falls back to the default client and page
|
|
1241
1511
|
loading still works.
|
|
1242
1512
|
|
|
1243
|
-
When the proxy succeeds it
|
|
1513
|
+
When the proxy succeeds it fires ``on_navigation_state_change``
|
|
1244
1514
|
(``onPageStarted``), ``on_load`` (``onPageFinished``), evaluates
|
|
1245
1515
|
``inject_javascript`` after each load, and bridges ``on_message``
|
|
1246
1516
|
via a ``pythonnative://`` URL scheme plus a small JS shim installed
|
|
1247
|
-
as ``window.pythonnative.postMessage
|
|
1248
|
-
Java helper is required.
|
|
1517
|
+
as ``window.pythonnative.postMessage``.
|
|
1249
1518
|
"""
|
|
1250
|
-
on_load = store.get("on_load")
|
|
1251
|
-
on_nav = store.get("on_navigation_state_change")
|
|
1252
|
-
inject_js = store.get("inject_javascript")
|
|
1253
|
-
on_message = store.get("on_message")
|
|
1254
1519
|
scheme = "pythonnative://message/"
|
|
1255
1520
|
try:
|
|
1256
1521
|
|
|
1257
1522
|
class _WebClientProxy(dynamic_proxy(jclass("android.webkit.WebViewClient"))):
|
|
1258
1523
|
def onPageStarted(self, view: Any, url: Any, favicon: Any) -> None:
|
|
1259
|
-
|
|
1260
|
-
try:
|
|
1261
|
-
on_nav(str(url))
|
|
1262
|
-
except Exception:
|
|
1263
|
-
pass
|
|
1524
|
+
_fire(wv, "on_navigation_state_change", str(url))
|
|
1264
1525
|
|
|
1265
1526
|
def onPageFinished(self, view: Any, url: Any) -> None:
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
on_load(str(url))
|
|
1269
|
-
except Exception:
|
|
1270
|
-
pass
|
|
1271
|
-
if on_message is not None:
|
|
1527
|
+
_fire(wv, "on_load", str(url))
|
|
1528
|
+
if _has_event(wv, "on_message"):
|
|
1272
1529
|
try:
|
|
1273
1530
|
shim = (
|
|
1274
1531
|
"(function(){window.pythonnative=window.pythonnative||{};"
|
|
@@ -1278,6 +1535,7 @@ def _make_web_client(store: Dict[str, Any]) -> Any:
|
|
|
1278
1535
|
view.evaluateJavascript(shim, None)
|
|
1279
1536
|
except Exception:
|
|
1280
1537
|
pass
|
|
1538
|
+
inject_js = (_state_of(wv).get("props") or {}).get("inject_javascript")
|
|
1281
1539
|
if inject_js:
|
|
1282
1540
|
try:
|
|
1283
1541
|
view.evaluateJavascript(str(inject_js), None)
|
|
@@ -1289,11 +1547,11 @@ def _make_web_client(store: Dict[str, Any]) -> Any:
|
|
|
1289
1547
|
url = request if isinstance(request, str) else str(request.getUrl())
|
|
1290
1548
|
except Exception:
|
|
1291
1549
|
url = ""
|
|
1292
|
-
if
|
|
1550
|
+
if url.startswith(scheme):
|
|
1293
1551
|
try:
|
|
1294
1552
|
from urllib.parse import unquote
|
|
1295
1553
|
|
|
1296
|
-
on_message
|
|
1554
|
+
_fire(wv, "on_message", unquote(url[len(scheme) :]))
|
|
1297
1555
|
except Exception:
|
|
1298
1556
|
pass
|
|
1299
1557
|
return True
|
|
@@ -1305,32 +1563,20 @@ def _make_web_client(store: Dict[str, Any]) -> Any:
|
|
|
1305
1563
|
|
|
1306
1564
|
|
|
1307
1565
|
class WebViewHandler(AndroidViewHandler):
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
def create(self, props: Dict[str, Any]) -> Any:
|
|
1311
|
-
wv = jclass("android.webkit.WebView")(_ctx())
|
|
1312
|
-
_pn_webview_props[id(wv)] = {}
|
|
1313
|
-
self._apply(wv, props, initial=True)
|
|
1314
|
-
return wv
|
|
1315
|
-
|
|
1316
|
-
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
1317
|
-
self._apply(native_view, changed, initial=False)
|
|
1566
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1567
|
+
return jclass("android.webkit.WebView")(_ctx())
|
|
1318
1568
|
|
|
1319
1569
|
def _apply(self, wv: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
store[key] = props[key]
|
|
1324
|
-
|
|
1325
|
-
# Enable JS whenever a callback / injection needs it.
|
|
1326
|
-
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:
|
|
1327
1573
|
try:
|
|
1328
1574
|
wv.getSettings().setJavaScriptEnabled(True)
|
|
1329
1575
|
except Exception:
|
|
1330
1576
|
pass
|
|
1331
1577
|
|
|
1332
|
-
if initial
|
|
1333
|
-
client = _make_web_client(
|
|
1578
|
+
if initial:
|
|
1579
|
+
client = _make_web_client(wv)
|
|
1334
1580
|
if client is not None:
|
|
1335
1581
|
try:
|
|
1336
1582
|
wv.setWebViewClient(client)
|
|
@@ -1370,128 +1616,99 @@ class WebViewHandler(AndroidViewHandler):
|
|
|
1370
1616
|
class SpacerHandler(AndroidViewHandler):
|
|
1371
1617
|
"""Empty layout placeholder used as a flexible gap.
|
|
1372
1618
|
|
|
1373
|
-
All sizing semantics
|
|
1619
|
+
All sizing semantics live in the layout engine — ``Spacer``
|
|
1374
1620
|
behaves identically to a `View` with the same style props (e.g.,
|
|
1375
1621
|
``flex: 1`` for an expanding spacer, ``size`` for a fixed gap).
|
|
1376
1622
|
"""
|
|
1377
1623
|
|
|
1378
|
-
def
|
|
1624
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1379
1625
|
return jclass("android.view.View")(_ctx())
|
|
1380
1626
|
|
|
1381
|
-
def
|
|
1627
|
+
def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
1382
1628
|
pass
|
|
1383
1629
|
|
|
1384
1630
|
|
|
1385
|
-
class SafeAreaViewHandler(
|
|
1631
|
+
class SafeAreaViewHandler(FlexContainerHandler):
|
|
1386
1632
|
"""Safe-area container using FrameLayout with ``fitsSystemWindows``."""
|
|
1387
1633
|
|
|
1388
|
-
def
|
|
1634
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1389
1635
|
fl = jclass("android.widget.FrameLayout")(_ctx())
|
|
1390
1636
|
fl.setFitsSystemWindows(True)
|
|
1391
|
-
_apply_common_visual(fl, props)
|
|
1392
1637
|
return fl
|
|
1393
1638
|
|
|
1394
|
-
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
1395
|
-
_apply_common_visual(native_view, changed)
|
|
1396
|
-
|
|
1397
|
-
def add_child(self, parent: Any, child: Any) -> None:
|
|
1398
|
-
parent.addView(child)
|
|
1399
|
-
|
|
1400
|
-
def remove_child(self, parent: Any, child: Any) -> None:
|
|
1401
|
-
parent.removeView(child)
|
|
1402
|
-
|
|
1403
1639
|
|
|
1404
1640
|
# ======================================================================
|
|
1405
1641
|
# Modal — actually presents a Dialog with the children inside
|
|
1406
1642
|
# ======================================================================
|
|
1407
1643
|
|
|
1408
1644
|
|
|
1409
|
-
_pn_modal_states: Dict[int, dict] = {}
|
|
1410
|
-
_pn_modal_pending: Dict[int, list] = {}
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
1645
|
class ModalHandler(AndroidViewHandler):
|
|
1414
1646
|
"""Real modal presentation backed by an Android `Dialog`.
|
|
1415
1647
|
|
|
1416
1648
|
The on-tree placeholder is a hidden ``View`` (so the layout
|
|
1417
1649
|
engine can ignore it). When ``visible`` flips to ``True``, a
|
|
1418
1650
|
``Dialog`` is created with a ``FrameLayout`` as its content view;
|
|
1419
|
-
the reconciler's ``
|
|
1651
|
+
the reconciler's ``insert_child`` calls are forwarded into that
|
|
1420
1652
|
content view.
|
|
1421
1653
|
"""
|
|
1422
1654
|
|
|
1423
|
-
def
|
|
1655
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1424
1656
|
placeholder = jclass("android.view.View")(_ctx())
|
|
1425
1657
|
placeholder.setVisibility(jclass("android.view.View").GONE)
|
|
1426
|
-
self._apply(placeholder, props, mounting=True)
|
|
1427
1658
|
return placeholder
|
|
1428
1659
|
|
|
1429
|
-
def
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
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:
|
|
1435
1675
|
try:
|
|
1436
|
-
|
|
1676
|
+
dialog.setCanceledOnTouchOutside(props["dismiss_on_backdrop"] is not False)
|
|
1437
1677
|
except Exception:
|
|
1438
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)
|
|
1439
1685
|
else:
|
|
1440
|
-
|
|
1686
|
+
state.setdefault("pending", []).insert(index, child)
|
|
1441
1687
|
|
|
1442
1688
|
def remove_child(self, parent: Any, child: Any) -> None:
|
|
1443
|
-
state =
|
|
1444
|
-
|
|
1689
|
+
state = _state_of(parent)
|
|
1690
|
+
content = state.get("content_view")
|
|
1691
|
+
if content is not None:
|
|
1445
1692
|
try:
|
|
1446
|
-
|
|
1693
|
+
content.removeView(child)
|
|
1447
1694
|
except Exception:
|
|
1448
1695
|
pass
|
|
1449
1696
|
else:
|
|
1450
|
-
buf =
|
|
1697
|
+
buf = state.get("pending")
|
|
1451
1698
|
if buf and child in buf:
|
|
1452
1699
|
buf.remove(child)
|
|
1453
1700
|
|
|
1454
|
-
def insert_child(self, parent: Any, child: Any, index: int) -> None:
|
|
1455
|
-
state = _pn_modal_states.get(id(parent))
|
|
1456
|
-
if state and state.get("content_view") is not None:
|
|
1457
|
-
try:
|
|
1458
|
-
state["content_view"].addView(child, index)
|
|
1459
|
-
except Exception:
|
|
1460
|
-
pass
|
|
1461
|
-
else:
|
|
1462
|
-
_pn_modal_pending.setdefault(id(parent), []).insert(index, child)
|
|
1463
|
-
|
|
1464
1701
|
def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
|
|
1465
1702
|
return
|
|
1466
1703
|
|
|
1467
|
-
def
|
|
1468
|
-
state =
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
# re-render that happens while the modal is open (e.g. an
|
|
1472
|
-
# ``on_show`` callback bumping some state) must NOT be read as
|
|
1473
|
-
# ``visible=False`` and tear the dialog down. So only react to an
|
|
1474
|
-
# explicitly supplied ``visible`` value.
|
|
1475
|
-
if "visible" in props:
|
|
1476
|
-
visible = bool(props["visible"])
|
|
1477
|
-
if visible and state is None:
|
|
1478
|
-
self._present(placeholder, props)
|
|
1479
|
-
elif not visible and state is not None:
|
|
1480
|
-
self._dismiss(placeholder)
|
|
1481
|
-
# Forward live prop updates to an already-presented dialog.
|
|
1482
|
-
state = _pn_modal_states.get(id(placeholder))
|
|
1483
|
-
if state is not None:
|
|
1484
|
-
if "on_dismiss" in props:
|
|
1485
|
-
state["on_dismiss"] = props.get("on_dismiss")
|
|
1486
|
-
dialog = state.get("dialog")
|
|
1487
|
-
if dialog is not None and "dismiss_on_backdrop" in props:
|
|
1488
|
-
try:
|
|
1489
|
-
dialog.setCanceledOnTouchOutside(props["dismiss_on_backdrop"] is not False)
|
|
1490
|
-
except Exception:
|
|
1491
|
-
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)
|
|
1492
1708
|
|
|
1493
|
-
def _present(self, placeholder: Any,
|
|
1709
|
+
def _present(self, placeholder: Any, state: Dict[str, Any]) -> None:
|
|
1494
1710
|
try:
|
|
1711
|
+
props = state.get("props") or {}
|
|
1495
1712
|
Dialog = jclass("android.app.Dialog")
|
|
1496
1713
|
FrameLayout = jclass("android.widget.FrameLayout")
|
|
1497
1714
|
LayoutParams = jclass("android.view.ViewGroup$LayoutParams")
|
|
@@ -1525,57 +1742,36 @@ class ModalHandler(AndroidViewHandler):
|
|
|
1525
1742
|
dialog.setCanceledOnTouchOutside(props.get("dismiss_on_backdrop") is not False)
|
|
1526
1743
|
except Exception:
|
|
1527
1744
|
pass
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
"content_view": content,
|
|
1532
|
-
"on_dismiss": on_dismiss,
|
|
1533
|
-
}
|
|
1534
|
-
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", []):
|
|
1535
1748
|
try:
|
|
1536
1749
|
content.addView(child)
|
|
1537
1750
|
except Exception:
|
|
1538
1751
|
pass
|
|
1539
|
-
on_show = props.get("on_show")
|
|
1540
|
-
if on_show is not None:
|
|
1541
|
-
OnShowListener = jclass("android.content.DialogInterface$OnShowListener")
|
|
1542
1752
|
|
|
1543
|
-
|
|
1544
|
-
def __init__(self, callback: Callable[[], None]) -> None:
|
|
1545
|
-
super().__init__()
|
|
1546
|
-
self.callback = callback
|
|
1753
|
+
OnShowListener = jclass("android.content.DialogInterface$OnShowListener")
|
|
1547
1754
|
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
except Exception:
|
|
1552
|
-
pass
|
|
1755
|
+
class _ShowProxy(dynamic_proxy(OnShowListener)):
|
|
1756
|
+
def onShow(self, di: Any) -> None:
|
|
1757
|
+
_fire(placeholder, "on_show")
|
|
1553
1758
|
|
|
1554
|
-
|
|
1555
|
-
if on_dismiss is not None:
|
|
1556
|
-
OnDismissListener = jclass("android.content.DialogInterface$OnDismissListener")
|
|
1759
|
+
dialog.setOnShowListener(_ShowProxy())
|
|
1557
1760
|
|
|
1558
|
-
|
|
1559
|
-
def __init__(self, callback: Callable[[], None]) -> None:
|
|
1560
|
-
super().__init__()
|
|
1561
|
-
self.callback = callback
|
|
1761
|
+
OnDismissListener = jclass("android.content.DialogInterface$OnDismissListener")
|
|
1562
1762
|
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
except Exception:
|
|
1567
|
-
pass
|
|
1763
|
+
class _DismissProxy(dynamic_proxy(OnDismissListener)):
|
|
1764
|
+
def onDismiss(self, di: Any) -> None:
|
|
1765
|
+
_fire(placeholder, "on_dismiss")
|
|
1568
1766
|
|
|
1569
|
-
|
|
1767
|
+
dialog.setOnDismissListener(_DismissProxy())
|
|
1570
1768
|
dialog.show()
|
|
1571
1769
|
except Exception:
|
|
1572
1770
|
pass
|
|
1573
1771
|
|
|
1574
|
-
def _dismiss(self, placeholder: Any) -> None:
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
return
|
|
1578
|
-
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)
|
|
1579
1775
|
if dialog is not None:
|
|
1580
1776
|
try:
|
|
1581
1777
|
dialog.dismiss()
|
|
@@ -1584,46 +1780,38 @@ class ModalHandler(AndroidViewHandler):
|
|
|
1584
1780
|
|
|
1585
1781
|
|
|
1586
1782
|
class SliderHandler(AndroidViewHandler):
|
|
1587
|
-
def
|
|
1783
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1588
1784
|
sb = jclass("android.widget.SeekBar")(_ctx())
|
|
1589
1785
|
sb.setMax(1000)
|
|
1590
|
-
self._apply(sb, props)
|
|
1591
|
-
return sb
|
|
1592
|
-
|
|
1593
|
-
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
1594
|
-
self._apply(native_view, changed)
|
|
1595
|
-
|
|
1596
|
-
def _apply(self, sb: Any, props: Dict[str, Any]) -> None:
|
|
1597
|
-
min_val = float(props.get("min_value", 0))
|
|
1598
|
-
max_val = float(props.get("max_value", 1))
|
|
1599
|
-
rng = max_val - min_val if max_val != min_val else 1
|
|
1600
|
-
if "value" in props:
|
|
1601
|
-
normalized = (float(props["value"]) - min_val) / rng
|
|
1602
|
-
sb.setProgress(int(normalized * 1000))
|
|
1603
|
-
if "on_change" in props and props["on_change"] is not None:
|
|
1604
|
-
cb = props["on_change"]
|
|
1605
|
-
|
|
1606
|
-
class SeekProxy(dynamic_proxy(jclass("android.widget.SeekBar").OnSeekBarChangeListener)):
|
|
1607
|
-
def __init__(self, callback: Callable[[float], None], mn: float, rn: float) -> None:
|
|
1608
|
-
super().__init__()
|
|
1609
|
-
self.callback = callback
|
|
1610
|
-
self.mn = mn
|
|
1611
|
-
self.rn = rn
|
|
1612
|
-
|
|
1613
|
-
def onProgressChanged(self, seekBar: Any, progress: int, fromUser: bool) -> None:
|
|
1614
|
-
if fromUser:
|
|
1615
|
-
self.callback(self.mn + (progress / 1000.0) * self.rn)
|
|
1616
1786
|
|
|
1617
|
-
|
|
1618
|
-
|
|
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)
|
|
1619
1796
|
|
|
1620
|
-
|
|
1621
|
-
|
|
1797
|
+
def onStartTrackingTouch(self, seekBar: Any) -> None:
|
|
1798
|
+
pass
|
|
1622
1799
|
|
|
1623
|
-
|
|
1800
|
+
def onStopTrackingTouch(self, seekBar: Any) -> None:
|
|
1801
|
+
pass
|
|
1624
1802
|
|
|
1803
|
+
sb.setOnSeekBarChangeListener(SeekProxy())
|
|
1804
|
+
return sb
|
|
1625
1805
|
|
|
1626
|
-
|
|
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)
|
|
1627
1815
|
|
|
1628
1816
|
|
|
1629
1817
|
class TabBarHandler(AndroidViewHandler):
|
|
@@ -1634,71 +1822,67 @@ class TabBarHandler(AndroidViewHandler):
|
|
|
1634
1822
|
"""
|
|
1635
1823
|
|
|
1636
1824
|
_LABEL_VISIBILITY_LABELED = 1
|
|
1637
|
-
_is_material: bool = True
|
|
1638
1825
|
|
|
1639
|
-
def
|
|
1826
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1640
1827
|
try:
|
|
1641
1828
|
bnv = jclass("com.google.android.material.bottomnavigation.BottomNavigationView")(_ctx())
|
|
1642
1829
|
bnv.setBackgroundColor(parse_color_int("#FFFFFF"))
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1830
|
+
try:
|
|
1831
|
+
bnv.setLabelVisibilityMode(self._LABEL_VISIBILITY_LABELED)
|
|
1832
|
+
except Exception:
|
|
1833
|
+
pass
|
|
1646
1834
|
return bnv
|
|
1647
1835
|
except Exception:
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
return ll
|
|
1659
|
-
|
|
1660
|
-
def _configure_material_bar(self, bnv: Any) -> None:
|
|
1661
|
-
"""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))
|
|
1662
1846
|
try:
|
|
1663
|
-
|
|
1847
|
+
state["is_material"] = bool(view.getMenu() is not None)
|
|
1664
1848
|
except Exception:
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
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)
|
|
1670
1866
|
else:
|
|
1671
|
-
self._apply_fallback(
|
|
1672
|
-
|
|
1673
|
-
def _apply_full(self, bnv: Any, props: Dict[str, Any]) -> None:
|
|
1674
|
-
"""Initial creation — all props are present."""
|
|
1675
|
-
items = props.get("items", [])
|
|
1676
|
-
self._set_menu(bnv, items)
|
|
1677
|
-
self._set_active(bnv, props.get("active_tab"), items)
|
|
1678
|
-
cb = props.get("on_tab_select")
|
|
1679
|
-
if cb is not None:
|
|
1680
|
-
self._set_listener(bnv, cb, items)
|
|
1681
|
-
|
|
1682
|
-
def _apply_partial(self, bnv: Any, changed: Dict[str, Any]) -> None:
|
|
1683
|
-
"""Reconciler update — only changed props are present."""
|
|
1684
|
-
prev_items = _android_tabbar_state["items"]
|
|
1685
|
-
|
|
1686
|
-
if "items" in changed:
|
|
1687
|
-
items = changed["items"]
|
|
1688
|
-
self._set_menu(bnv, items)
|
|
1689
|
-
else:
|
|
1690
|
-
items = prev_items
|
|
1867
|
+
self._apply_fallback(view, props)
|
|
1691
1868
|
|
|
1692
|
-
|
|
1693
|
-
|
|
1869
|
+
def _bind_material_listener(self, bnv: Any) -> None:
|
|
1870
|
+
try:
|
|
1871
|
+
listener_cls = jclass("com.google.android.material.navigation.NavigationBarView$OnItemSelectedListener")
|
|
1694
1872
|
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
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
|
|
1699
1884
|
|
|
1700
1885
|
def _set_menu(self, bnv: Any, items: list) -> None:
|
|
1701
|
-
_android_tabbar_state["items"] = items
|
|
1702
1886
|
try:
|
|
1703
1887
|
menu = bnv.getMenu()
|
|
1704
1888
|
menu.clear()
|
|
@@ -1749,33 +1933,11 @@ class TabBarHandler(AndroidViewHandler):
|
|
|
1749
1933
|
pass
|
|
1750
1934
|
break
|
|
1751
1935
|
|
|
1752
|
-
def _set_listener(self, bnv: Any, cb: Callable, items: list) -> None:
|
|
1753
|
-
_android_tabbar_state["callback"] = cb
|
|
1754
|
-
_android_tabbar_state["items"] = items
|
|
1755
|
-
try:
|
|
1756
|
-
listener_cls = jclass("com.google.android.material.navigation.NavigationBarView$OnItemSelectedListener")
|
|
1757
|
-
|
|
1758
|
-
class _TabSelectProxy(dynamic_proxy(listener_cls)):
|
|
1759
|
-
def __init__(self, callback: Callable, tab_items: list) -> None:
|
|
1760
|
-
super().__init__()
|
|
1761
|
-
self.callback = callback
|
|
1762
|
-
self.tab_items = tab_items
|
|
1763
|
-
|
|
1764
|
-
def onNavigationItemSelected(self, menu_item: Any) -> bool:
|
|
1765
|
-
idx = menu_item.getItemId()
|
|
1766
|
-
if 0 <= idx < len(self.tab_items):
|
|
1767
|
-
self.callback(self.tab_items[idx].get("name", ""))
|
|
1768
|
-
return True
|
|
1769
|
-
|
|
1770
|
-
bnv.setOnItemSelectedListener(_TabSelectProxy(cb, items))
|
|
1771
|
-
except Exception:
|
|
1772
|
-
pass
|
|
1773
|
-
|
|
1774
1936
|
def _apply_fallback(self, ll: Any, props: Dict[str, Any]) -> None:
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
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:
|
|
1779
1941
|
ll.removeAllViews()
|
|
1780
1942
|
for item in items:
|
|
1781
1943
|
name = item.get("name", "")
|
|
@@ -1783,96 +1945,131 @@ class TabBarHandler(AndroidViewHandler):
|
|
|
1783
1945
|
btn = jclass("android.widget.Button")(_ctx())
|
|
1784
1946
|
btn.setText(str(title))
|
|
1785
1947
|
btn.setEnabled(name != active)
|
|
1786
|
-
if cb is not None:
|
|
1787
|
-
tab_name = name
|
|
1788
|
-
|
|
1789
|
-
def _make_click(n: str) -> Callable[[], None]:
|
|
1790
|
-
return lambda: cb(n)
|
|
1791
1948
|
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
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
|
|
1796
1953
|
|
|
1797
|
-
|
|
1798
|
-
|
|
1954
|
+
def onClick(self, view: Any) -> None:
|
|
1955
|
+
_fire(ll, "on_tab_select", self.tab_name)
|
|
1799
1956
|
|
|
1800
|
-
|
|
1957
|
+
btn.setOnClickListener(_ClickProxy(name))
|
|
1801
1958
|
ll.addView(btn)
|
|
1802
1959
|
|
|
1803
1960
|
|
|
1804
1961
|
# ======================================================================
|
|
1805
|
-
# Pressable — visual feedback + tap callbacks
|
|
1962
|
+
# Pressable — visual feedback + tap callbacks + gestures
|
|
1806
1963
|
# ======================================================================
|
|
1807
1964
|
|
|
1808
1965
|
|
|
1809
|
-
class PressableHandler(
|
|
1810
|
-
|
|
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:
|
|
1811
1982
|
fl = jclass("android.widget.FrameLayout")(_ctx())
|
|
1812
1983
|
fl.setClickable(True)
|
|
1813
1984
|
fl.setFocusable(True)
|
|
1814
|
-
self.
|
|
1985
|
+
self._bind_press_stream(fl)
|
|
1815
1986
|
return fl
|
|
1816
1987
|
|
|
1817
|
-
def
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
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
|
|
1823
1994
|
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
self.callback = callback
|
|
1828
|
-
|
|
1829
|
-
def onClick(self, view: Any) -> None:
|
|
1830
|
-
self.callback()
|
|
1831
|
-
|
|
1832
|
-
fl.setOnClickListener(PressProxy(cb))
|
|
1833
|
-
if "on_long_press" in props and props["on_long_press"] is not None:
|
|
1834
|
-
cb = props["on_long_press"]
|
|
1835
|
-
|
|
1836
|
-
class LongPressProxy(dynamic_proxy(jclass("android.view.View").OnLongClickListener)):
|
|
1837
|
-
def __init__(self, callback: Callable[[], None]) -> None:
|
|
1838
|
-
super().__init__()
|
|
1839
|
-
self.callback = callback
|
|
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)
|
|
1840
1998
|
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
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"]
|
|
1857
2036
|
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
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:
|
|
1863
2062
|
view.animate().alpha(1.0).setDuration(100).start()
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
def add_child(self, parent: Any, child: Any) -> None:
|
|
1872
|
-
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
|
|
1873
2069
|
|
|
1874
|
-
|
|
1875
|
-
|
|
2070
|
+
fl.setOnTouchListener(_PressTouchProxy())
|
|
2071
|
+
except Exception:
|
|
2072
|
+
pass
|
|
1876
2073
|
|
|
1877
2074
|
|
|
1878
2075
|
# ======================================================================
|
|
@@ -1883,19 +2080,15 @@ class PressableHandler(AndroidViewHandler):
|
|
|
1883
2080
|
class StatusBarHandler(AndroidViewHandler):
|
|
1884
2081
|
"""Apply status-bar background color / style on the host activity."""
|
|
1885
2082
|
|
|
1886
|
-
def
|
|
2083
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1887
2084
|
v = jclass("android.view.View")(_ctx())
|
|
1888
2085
|
v.setVisibility(jclass("android.view.View").GONE)
|
|
1889
|
-
self._apply(props)
|
|
1890
2086
|
return v
|
|
1891
2087
|
|
|
1892
|
-
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
1893
|
-
self._apply(changed)
|
|
1894
|
-
|
|
1895
2088
|
def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
|
|
1896
2089
|
return
|
|
1897
2090
|
|
|
1898
|
-
def _apply(self, props: Dict[str, Any]) -> None:
|
|
2091
|
+
def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
1899
2092
|
try:
|
|
1900
2093
|
ctx = _ctx()
|
|
1901
2094
|
window = ctx.getWindow()
|
|
@@ -1919,174 +2112,8 @@ class StatusBarHandler(AndroidViewHandler):
|
|
|
1919
2112
|
pass
|
|
1920
2113
|
|
|
1921
2114
|
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
# computes the offset from manifest-driven insets.
|
|
1925
|
-
# ======================================================================
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
class KeyboardAvoidingViewHandler(AndroidViewHandler):
|
|
1929
|
-
def create(self, props: Dict[str, Any]) -> Any:
|
|
1930
|
-
fl = jclass("android.widget.FrameLayout")(_ctx())
|
|
1931
|
-
_apply_common_visual(fl, props)
|
|
1932
|
-
return fl
|
|
1933
|
-
|
|
1934
|
-
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
1935
|
-
_apply_common_visual(native_view, changed)
|
|
1936
|
-
|
|
1937
|
-
def add_child(self, parent: Any, child: Any) -> None:
|
|
1938
|
-
parent.addView(child)
|
|
1939
|
-
|
|
1940
|
-
def remove_child(self, parent: Any, child: Any) -> None:
|
|
1941
|
-
parent.removeView(child)
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
# ======================================================================
|
|
1945
|
-
# VirtualList — RecyclerView-backed virtualized list
|
|
1946
|
-
# ======================================================================
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
_pn_recyclerview_state: Dict[int, Any] = {}
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
def _java_id(jobj: Any) -> int:
|
|
1953
|
-
"""Return ``System.identityHashCode(jobj)`` as a stable lookup key.
|
|
1954
|
-
|
|
1955
|
-
Chaquopy's ``JavaObject.__setattr__`` rejects unknown Python attributes,
|
|
1956
|
-
so we cannot stash custom IDs on the Java view wrapper. Instead, we use
|
|
1957
|
-
the JVM's identity hash code, which is stable for the lifetime of the
|
|
1958
|
-
Java object and the same across all Python wrappers that may proxy it.
|
|
1959
|
-
"""
|
|
1960
|
-
System = jclass("java.lang.System")
|
|
1961
|
-
return int(System.identityHashCode(jobj))
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
def _make_recyclerview_delegate(props: Dict[str, Any]) -> Any:
|
|
1965
|
-
Delegate = _pn_runtime_class("PNVirtualListView$Delegate")
|
|
1966
|
-
|
|
1967
|
-
class _Delegate(dynamic_proxy(Delegate)):
|
|
1968
|
-
def __init__(self, initial: Dict[str, Any]) -> None:
|
|
1969
|
-
super().__init__()
|
|
1970
|
-
self.count = int(initial.get("count", 0))
|
|
1971
|
-
self.row_height = float(initial.get("row_height", 44.0))
|
|
1972
|
-
self.mount_row = initial.get("mount_row")
|
|
1973
|
-
self.on_row_press = initial.get("on_row_press")
|
|
1974
|
-
|
|
1975
|
-
def update(self, changed: Dict[str, Any]) -> None:
|
|
1976
|
-
if "count" in changed:
|
|
1977
|
-
self.count = int(changed["count"])
|
|
1978
|
-
if "row_height" in changed and changed["row_height"] is not None:
|
|
1979
|
-
self.row_height = float(changed["row_height"])
|
|
1980
|
-
if "mount_row" in changed:
|
|
1981
|
-
self.mount_row = changed["mount_row"]
|
|
1982
|
-
if "on_row_press" in changed:
|
|
1983
|
-
self.on_row_press = changed["on_row_press"]
|
|
1984
|
-
|
|
1985
|
-
def getCount(self) -> int:
|
|
1986
|
-
return self.count
|
|
1987
|
-
|
|
1988
|
-
def getRowHeightDp(self) -> float:
|
|
1989
|
-
return self.row_height
|
|
1990
|
-
|
|
1991
|
-
def mountRow(self, position: int, container: Any, width_dp: float, height_dp: float) -> None:
|
|
1992
|
-
if self.mount_row is None:
|
|
1993
|
-
return
|
|
1994
|
-
try:
|
|
1995
|
-
self.mount_row(int(position), container, float(width_dp), float(height_dp))
|
|
1996
|
-
except Exception:
|
|
1997
|
-
import traceback as _tb
|
|
1998
|
-
|
|
1999
|
-
_tb.print_exc()
|
|
2000
|
-
|
|
2001
|
-
def onRowPress(self, position: int) -> None:
|
|
2002
|
-
idx = int(position)
|
|
2003
|
-
if idx < 0 or self.on_row_press is None:
|
|
2004
|
-
return
|
|
2005
|
-
try:
|
|
2006
|
-
self.on_row_press(idx)
|
|
2007
|
-
except Exception:
|
|
2008
|
-
import traceback as _tb
|
|
2009
|
-
|
|
2010
|
-
_tb.print_exc()
|
|
2011
|
-
|
|
2012
|
-
return _Delegate(props)
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
class VirtualListHandler(AndroidViewHandler):
|
|
2016
|
-
"""Backed by ``RecyclerView`` through a tiny Android template helper.
|
|
2017
|
-
|
|
2018
|
-
Chaquopy cannot proxy ``RecyclerView.Adapter`` directly because it is an
|
|
2019
|
-
abstract Java class, so the Android template provides
|
|
2020
|
-
``PNVirtualListView``. Python implements that helper's small ``Delegate``
|
|
2021
|
-
interface, while Java owns the adapter/view-holder lifecycle.
|
|
2022
|
-
"""
|
|
2023
|
-
|
|
2024
|
-
def create(self, props: Dict[str, Any]) -> Any:
|
|
2025
|
-
try:
|
|
2026
|
-
PNVirtualListView = _pn_runtime_class("PNVirtualListView")
|
|
2027
|
-
delegate = _make_recyclerview_delegate(props)
|
|
2028
|
-
rv = PNVirtualListView(_ctx(), delegate)
|
|
2029
|
-
if "background_color" in props and props["background_color"] is not None:
|
|
2030
|
-
rv.setBackgroundColor(parse_color_int(props["background_color"]))
|
|
2031
|
-
key = _java_id(rv)
|
|
2032
|
-
_pn_recyclerview_state[key] = delegate
|
|
2033
|
-
return rv
|
|
2034
|
-
except Exception:
|
|
2035
|
-
return self._fallback(props)
|
|
2036
|
-
|
|
2037
|
-
def _fallback(self, props: Dict[str, Any]) -> Any:
|
|
2038
|
-
"""Eagerly mount all rows in a ScrollView (controller init failed).
|
|
2039
|
-
|
|
2040
|
-
Sets each row's LinearLayout.LayoutParams to MATCH_PARENT × row_h_px
|
|
2041
|
-
so cells have a real visual size, and forwards the screen width (in
|
|
2042
|
-
dp) to ``mount_row`` so the layout engine can position child
|
|
2043
|
-
elements.
|
|
2044
|
-
"""
|
|
2045
|
-
n = int(props.get("count", 0))
|
|
2046
|
-
row_h_dp = float(props.get("row_height", 44.0))
|
|
2047
|
-
density = _density()
|
|
2048
|
-
row_h_px = max(1, int(round(row_h_dp * density)))
|
|
2049
|
-
|
|
2050
|
-
try:
|
|
2051
|
-
screen_w_px = float(_ctx().getResources().getDisplayMetrics().widthPixels)
|
|
2052
|
-
screen_w_dp = screen_w_px / density if density else screen_w_px
|
|
2053
|
-
except Exception:
|
|
2054
|
-
screen_w_dp = 0.0
|
|
2055
|
-
|
|
2056
|
-
sv = jclass("android.widget.ScrollView")(_ctx())
|
|
2057
|
-
LinearLayout = jclass("android.widget.LinearLayout")
|
|
2058
|
-
LL_LP = jclass("android.widget.LinearLayout$LayoutParams")
|
|
2059
|
-
ll = LinearLayout(_ctx())
|
|
2060
|
-
ll.setOrientation(LinearLayout.VERTICAL)
|
|
2061
|
-
sv.addView(ll)
|
|
2062
|
-
|
|
2063
|
-
mount = props.get("mount_row")
|
|
2064
|
-
|
|
2065
|
-
for i in range(n):
|
|
2066
|
-
try:
|
|
2067
|
-
cell = jclass("android.widget.FrameLayout")(_ctx())
|
|
2068
|
-
cell.setLayoutParams(LL_LP(LL_LP.MATCH_PARENT, row_h_px))
|
|
2069
|
-
if mount is not None:
|
|
2070
|
-
mount(i, cell, screen_w_dp, row_h_dp)
|
|
2071
|
-
ll.addView(cell)
|
|
2072
|
-
except Exception:
|
|
2073
|
-
continue
|
|
2074
|
-
return sv
|
|
2075
|
-
|
|
2076
|
-
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
2077
|
-
delegate = _pn_recyclerview_state.get(_java_id(native_view))
|
|
2078
|
-
if delegate is None:
|
|
2079
|
-
return
|
|
2080
|
-
delegate.update(changed)
|
|
2081
|
-
if "background_color" in changed and changed["background_color"] is not None:
|
|
2082
|
-
try:
|
|
2083
|
-
native_view.setBackgroundColor(parse_color_int(changed["background_color"]))
|
|
2084
|
-
except Exception:
|
|
2085
|
-
pass
|
|
2086
|
-
try:
|
|
2087
|
-
native_view.notifyDataChanged()
|
|
2088
|
-
except Exception:
|
|
2089
|
-
pass
|
|
2115
|
+
class KeyboardAvoidingViewHandler(FlexContainerHandler):
|
|
2116
|
+
"""Vanilla container; the user-land component computes the offset."""
|
|
2090
2117
|
|
|
2091
2118
|
|
|
2092
2119
|
# ======================================================================
|
|
@@ -2202,31 +2229,43 @@ def _present_alert(
|
|
|
2202
2229
|
# ======================================================================
|
|
2203
2230
|
# Picker — native dropdown / select widget
|
|
2204
2231
|
# ======================================================================
|
|
2205
|
-
#
|
|
2206
|
-
# Renders the PythonNative `Picker` element as an Android ``Spinner``,
|
|
2207
|
-
# which is the platform's standard dropdown widget. The selected item is
|
|
2208
|
-
# pushed to the user's callback via ``OnItemSelectedListener``.
|
|
2209
2232
|
|
|
2210
2233
|
|
|
2211
2234
|
class PickerHandler(AndroidViewHandler):
|
|
2212
2235
|
"""``Picker`` element handler — native ``Spinner`` dropdown."""
|
|
2213
2236
|
|
|
2214
|
-
def
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
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)
|
|
2221
2256
|
|
|
2222
|
-
|
|
2223
|
-
|
|
2257
|
+
def onNothingSelected(self, parent: Any) -> None: # noqa: ARG002
|
|
2258
|
+
pass
|
|
2259
|
+
|
|
2260
|
+
sp.setOnItemSelectedListener(_PickerListener())
|
|
2261
|
+
return sp
|
|
2224
2262
|
|
|
2225
2263
|
def _apply(self, sp: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
2226
|
-
state =
|
|
2264
|
+
state = _state_of(sp)
|
|
2265
|
+
merged = state.get("props") or props
|
|
2227
2266
|
|
|
2228
2267
|
if "items" in props or initial:
|
|
2229
|
-
items = list(
|
|
2268
|
+
items = list(merged.get("items") or [])
|
|
2230
2269
|
labels = []
|
|
2231
2270
|
for item in items:
|
|
2232
2271
|
if isinstance(item, dict):
|
|
@@ -2238,13 +2277,14 @@ class PickerHandler(AndroidViewHandler):
|
|
|
2238
2277
|
adapter = ArrayAdapter(_ctx(), R.layout.simple_spinner_item, labels)
|
|
2239
2278
|
adapter.setDropDownViewResource(R.layout.simple_spinner_dropdown_item)
|
|
2240
2279
|
state["suppress"] = True
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2280
|
+
try:
|
|
2281
|
+
sp.setAdapter(adapter)
|
|
2282
|
+
finally:
|
|
2283
|
+
state["suppress"] = False
|
|
2244
2284
|
|
|
2245
2285
|
if "value" in props or initial:
|
|
2246
|
-
items =
|
|
2247
|
-
value =
|
|
2286
|
+
items = list(merged.get("items") or [])
|
|
2287
|
+
value = merged.get("value")
|
|
2248
2288
|
target_index = -1
|
|
2249
2289
|
for i, item in enumerate(items):
|
|
2250
2290
|
v = item.get("value") if isinstance(item, dict) else item
|
|
@@ -2253,41 +2293,10 @@ class PickerHandler(AndroidViewHandler):
|
|
|
2253
2293
|
break
|
|
2254
2294
|
if target_index >= 0 and sp.getSelectedItemPosition() != target_index:
|
|
2255
2295
|
state["suppress"] = True
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
state["on_change"] = props.get("on_change") if "on_change" in props else state.get("on_change")
|
|
2261
|
-
|
|
2262
|
-
class _PickerListener(dynamic_proxy(jclass("android.widget.AdapterView").OnItemSelectedListener)):
|
|
2263
|
-
def __init__(self, owner_state: Dict[str, Any]) -> None:
|
|
2264
|
-
super().__init__()
|
|
2265
|
-
self._owner_state = owner_state
|
|
2266
|
-
|
|
2267
|
-
def onItemSelected(
|
|
2268
|
-
self,
|
|
2269
|
-
parent: Any,
|
|
2270
|
-
view: Any, # noqa: ARG002
|
|
2271
|
-
position: int,
|
|
2272
|
-
id_: int, # noqa: ARG002
|
|
2273
|
-
) -> None:
|
|
2274
|
-
if self._owner_state.get("suppress"):
|
|
2275
|
-
return
|
|
2276
|
-
items = self._owner_state.get("items") or []
|
|
2277
|
-
if 0 <= position < len(items):
|
|
2278
|
-
item = items[position]
|
|
2279
|
-
v = item.get("value") if isinstance(item, dict) else item
|
|
2280
|
-
cb = self._owner_state.get("on_change")
|
|
2281
|
-
if cb is not None:
|
|
2282
|
-
try:
|
|
2283
|
-
cb(v)
|
|
2284
|
-
except Exception:
|
|
2285
|
-
pass
|
|
2286
|
-
|
|
2287
|
-
def onNothingSelected(self, parent: Any) -> None: # noqa: ARG002
|
|
2288
|
-
pass
|
|
2289
|
-
|
|
2290
|
-
sp.setOnItemSelectedListener(_PickerListener(state))
|
|
2296
|
+
try:
|
|
2297
|
+
sp.setSelection(target_index, False)
|
|
2298
|
+
finally:
|
|
2299
|
+
state["suppress"] = False
|
|
2291
2300
|
|
|
2292
2301
|
|
|
2293
2302
|
# ======================================================================
|
|
@@ -2299,65 +2308,42 @@ class CheckboxHandler(AndroidViewHandler):
|
|
|
2299
2308
|
"""``Checkbox`` element handler — native ``CheckBox`` widget.
|
|
2300
2309
|
|
|
2301
2310
|
Programmatic ``value`` updates are wrapped in a per-view
|
|
2302
|
-
"suppress" guard
|
|
2303
|
-
|
|
2311
|
+
"suppress" guard so pushing a new state via ``setChecked`` never
|
|
2312
|
+
re-fires the user's ``on_change``.
|
|
2304
2313
|
"""
|
|
2305
2314
|
|
|
2306
|
-
def
|
|
2315
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
2307
2316
|
cb = jclass("android.widget.CheckBox")(_ctx())
|
|
2308
|
-
self._state: Dict[int, Dict[str, Any]] = getattr(self, "_state", {})
|
|
2309
|
-
self._state[id(cb)] = {"on_change": None, "suppress": False}
|
|
2310
|
-
self._apply(cb, props, initial=True)
|
|
2311
|
-
return cb
|
|
2312
2317
|
|
|
2313
|
-
|
|
2314
|
-
|
|
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))
|
|
2315
2323
|
|
|
2316
|
-
|
|
2317
|
-
|
|
2324
|
+
cb.setOnCheckedChangeListener(_CheckedProxy())
|
|
2325
|
+
return cb
|
|
2318
2326
|
|
|
2327
|
+
def _apply(self, cb: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
2328
|
+
state = _state_of(cb)
|
|
2319
2329
|
if "label" in props:
|
|
2320
2330
|
cb.setText(str(props["label"]) if props["label"] is not None else "")
|
|
2321
|
-
|
|
2322
2331
|
if "value" in props:
|
|
2323
2332
|
state["suppress"] = True
|
|
2324
2333
|
try:
|
|
2325
2334
|
cb.setChecked(bool(props["value"]))
|
|
2326
2335
|
finally:
|
|
2327
2336
|
state["suppress"] = False
|
|
2328
|
-
|
|
2329
2337
|
if "disabled" in props:
|
|
2330
2338
|
# ``disabled`` is only present when truthy; a removal (``None``)
|
|
2331
2339
|
# re-enables the control.
|
|
2332
2340
|
cb.setEnabled(not bool(props["disabled"]))
|
|
2333
|
-
|
|
2334
2341
|
if "color" in props and props["color"] is not None:
|
|
2335
2342
|
try:
|
|
2336
2343
|
ColorStateList = jclass("android.content.res.ColorStateList")
|
|
2337
2344
|
cb.setButtonTintList(ColorStateList.valueOf(parse_color_int(props["color"])))
|
|
2338
2345
|
except Exception:
|
|
2339
2346
|
pass
|
|
2340
|
-
|
|
2341
|
-
if "on_change" in props or initial:
|
|
2342
|
-
state["on_change"] = props.get("on_change") if "on_change" in props else state.get("on_change")
|
|
2343
|
-
|
|
2344
|
-
class _CheckboxCheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)):
|
|
2345
|
-
def __init__(self, owner_state: Dict[str, Any]) -> None:
|
|
2346
|
-
super().__init__()
|
|
2347
|
-
self._owner_state = owner_state
|
|
2348
|
-
|
|
2349
|
-
def onCheckedChanged(self, button: Any, is_checked: bool) -> None:
|
|
2350
|
-
if self._owner_state.get("suppress"):
|
|
2351
|
-
return
|
|
2352
|
-
callback = self._owner_state.get("on_change")
|
|
2353
|
-
if callback is not None:
|
|
2354
|
-
try:
|
|
2355
|
-
callback(bool(is_checked))
|
|
2356
|
-
except Exception:
|
|
2357
|
-
pass
|
|
2358
|
-
|
|
2359
|
-
cb.setOnCheckedChangeListener(_CheckboxCheckedProxy(state))
|
|
2360
|
-
|
|
2361
2347
|
_apply_accessibility(cb, props)
|
|
2362
2348
|
|
|
2363
2349
|
|
|
@@ -2372,70 +2358,34 @@ class SegmentedControlHandler(AndroidViewHandler):
|
|
|
2372
2358
|
Android has no ``UISegmentedControl`` equivalent, so the control is
|
|
2373
2359
|
built from a horizontal ``LinearLayout`` holding one ``Button`` per
|
|
2374
2360
|
segment. The selected segment is filled with the ``tint_color`` (or
|
|
2375
|
-
a default accent); the rest are drawn outlined.
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
``on_change``. The control owns its own subviews, so
|
|
2379
|
-
``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.
|
|
2380
2364
|
"""
|
|
2381
2365
|
|
|
2382
2366
|
_DEFAULT_ACCENT = "#007AFF"
|
|
2383
2367
|
|
|
2384
|
-
def
|
|
2368
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
2385
2369
|
LinearLayout = jclass("android.widget.LinearLayout")
|
|
2386
2370
|
ll = LinearLayout(_ctx())
|
|
2387
2371
|
ll.setOrientation(LinearLayout.HORIZONTAL)
|
|
2388
|
-
self._state: Dict[int, Dict[str, Any]] = getattr(self, "_state", {})
|
|
2389
|
-
self._state[id(ll)] = {
|
|
2390
|
-
"segments": [],
|
|
2391
|
-
"selected_index": 0,
|
|
2392
|
-
"on_change": None,
|
|
2393
|
-
"tint_color": None,
|
|
2394
|
-
"enabled": True,
|
|
2395
|
-
"buttons": [],
|
|
2396
|
-
"suppress": False,
|
|
2397
|
-
}
|
|
2398
|
-
self._apply(ll, props, initial=True)
|
|
2399
2372
|
return ll
|
|
2400
2373
|
|
|
2401
|
-
def
|
|
2402
|
-
self._apply(native_view, changed, initial=False)
|
|
2403
|
-
|
|
2404
|
-
def add_child(self, parent: Any, child: Any) -> None:
|
|
2405
|
-
# SegmentedControl renders its own segment buttons.
|
|
2374
|
+
def insert_child(self, parent: Any, child: Any, index: int) -> None:
|
|
2406
2375
|
return
|
|
2407
2376
|
|
|
2408
2377
|
def remove_child(self, parent: Any, child: Any) -> None:
|
|
2409
2378
|
return
|
|
2410
2379
|
|
|
2411
|
-
def _default_state(self) -> Dict[str, Any]:
|
|
2412
|
-
return {
|
|
2413
|
-
"segments": [],
|
|
2414
|
-
"selected_index": 0,
|
|
2415
|
-
"on_change": None,
|
|
2416
|
-
"tint_color": None,
|
|
2417
|
-
"enabled": True,
|
|
2418
|
-
"buttons": [],
|
|
2419
|
-
"suppress": False,
|
|
2420
|
-
}
|
|
2421
|
-
|
|
2422
2380
|
def _apply(self, ll: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
2423
|
-
state =
|
|
2424
|
-
|
|
2425
|
-
if "on_change" in props:
|
|
2426
|
-
state["on_change"] = props.get("on_change")
|
|
2427
|
-
if "tint_color" in props:
|
|
2428
|
-
state["tint_color"] = props.get("tint_color")
|
|
2429
|
-
if "enabled" in props:
|
|
2430
|
-
# ``enabled`` is only present when ``False``; a removal (``None``)
|
|
2431
|
-
# re-enables the control.
|
|
2432
|
-
state["enabled"] = props["enabled"] is not False
|
|
2381
|
+
state = _state_of(ll)
|
|
2382
|
+
merged = state.get("props") or props
|
|
2433
2383
|
|
|
2434
2384
|
segments_changed = False
|
|
2435
2385
|
if "segments" in props or initial:
|
|
2436
|
-
raw =
|
|
2386
|
+
raw = merged.get("segments")
|
|
2437
2387
|
new_segments = [str(s) for s in raw] if raw else []
|
|
2438
|
-
if initial or new_segments != state
|
|
2388
|
+
if initial or new_segments != state.get("segments"):
|
|
2439
2389
|
state["segments"] = new_segments
|
|
2440
2390
|
segments_changed = True
|
|
2441
2391
|
|
|
@@ -2445,7 +2395,7 @@ class SegmentedControlHandler(AndroidViewHandler):
|
|
|
2445
2395
|
if segments_changed:
|
|
2446
2396
|
self._rebuild(ll, state)
|
|
2447
2397
|
else:
|
|
2448
|
-
self._restyle(state)
|
|
2398
|
+
self._restyle(ll, state)
|
|
2449
2399
|
|
|
2450
2400
|
_apply_accessibility(ll, props)
|
|
2451
2401
|
|
|
@@ -2457,7 +2407,7 @@ class SegmentedControlHandler(AndroidViewHandler):
|
|
|
2457
2407
|
state["buttons"] = []
|
|
2458
2408
|
LL_LP = jclass("android.widget.LinearLayout$LayoutParams")
|
|
2459
2409
|
restyle = self._restyle
|
|
2460
|
-
for index, label in enumerate(state
|
|
2410
|
+
for index, label in enumerate(state.get("segments") or []):
|
|
2461
2411
|
btn = jclass("android.widget.Button")(_ctx())
|
|
2462
2412
|
btn.setText(str(label))
|
|
2463
2413
|
try:
|
|
@@ -2466,37 +2416,33 @@ class SegmentedControlHandler(AndroidViewHandler):
|
|
|
2466
2416
|
pass
|
|
2467
2417
|
# Equal-width segments: zero base width + weight 1, full height.
|
|
2468
2418
|
btn.setLayoutParams(LL_LP(0, LL_LP.MATCH_PARENT, 1.0))
|
|
2469
|
-
|
|
2419
|
+
enabled = (state.get("props") or {}).get("enabled") is not False
|
|
2420
|
+
btn.setEnabled(enabled)
|
|
2470
2421
|
|
|
2471
2422
|
class _SegmentClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
|
|
2472
|
-
def __init__(self,
|
|
2423
|
+
def __init__(self, seg_index: int) -> None:
|
|
2473
2424
|
super().__init__()
|
|
2474
|
-
self._owner_state = owner_state
|
|
2475
2425
|
self._seg_index = seg_index
|
|
2476
|
-
self._container = container
|
|
2477
2426
|
|
|
2478
2427
|
def onClick(self, view: Any) -> None:
|
|
2479
|
-
|
|
2428
|
+
st = _state_of(ll)
|
|
2429
|
+
if (st.get("props") or {}).get("enabled") is False:
|
|
2480
2430
|
return
|
|
2481
|
-
|
|
2482
|
-
restyle(
|
|
2483
|
-
|
|
2484
|
-
if cb is not None:
|
|
2485
|
-
try:
|
|
2486
|
-
cb(self._seg_index)
|
|
2487
|
-
except Exception:
|
|
2488
|
-
pass
|
|
2431
|
+
st["selected_index"] = self._seg_index
|
|
2432
|
+
restyle(ll, st)
|
|
2433
|
+
_fire(ll, "on_change", self._seg_index)
|
|
2489
2434
|
|
|
2490
|
-
btn.setOnClickListener(_SegmentClickProxy(
|
|
2435
|
+
btn.setOnClickListener(_SegmentClickProxy(index))
|
|
2491
2436
|
ll.addView(btn)
|
|
2492
2437
|
state["buttons"].append(btn)
|
|
2493
|
-
self._restyle(state)
|
|
2494
|
-
|
|
2495
|
-
def _restyle(self, state: Dict[str, Any]) -> None:
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
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 []):
|
|
2500
2446
|
self._style_segment(btn, i == selected, accent, enabled)
|
|
2501
2447
|
|
|
2502
2448
|
def _style_segment(self, btn: Any, selected: bool, accent: Any, enabled: bool) -> None:
|
|
@@ -2559,85 +2505,50 @@ class DatePickerHandler(AndroidViewHandler):
|
|
|
2559
2505
|
date→time flow (``"datetime"``). Values are parsed / formatted with
|
|
2560
2506
|
``java.util.Calendar`` + ``java.text.SimpleDateFormat`` using
|
|
2561
2507
|
per-mode ISO patterns, and the confirmed value is reported through
|
|
2562
|
-
``on_change
|
|
2508
|
+
the ``on_change`` event.
|
|
2563
2509
|
"""
|
|
2564
2510
|
|
|
2565
2511
|
_PATTERNS = {"date": "yyyy-MM-dd", "time": "HH:mm", "datetime": "yyyy-MM-dd'T'HH:mm"}
|
|
2566
2512
|
_PLACEHOLDERS = {"date": "Select date", "time": "Select time", "datetime": "Select date & time"}
|
|
2567
2513
|
|
|
2568
|
-
def
|
|
2514
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
2569
2515
|
btn = jclass("android.widget.Button")(_ctx())
|
|
2570
2516
|
try:
|
|
2571
2517
|
btn.setAllCaps(False)
|
|
2572
2518
|
except Exception:
|
|
2573
2519
|
pass
|
|
2574
|
-
|
|
2575
|
-
self._state[id(btn)] = {
|
|
2576
|
-
"value": None,
|
|
2577
|
-
"mode": "date",
|
|
2578
|
-
"on_change": None,
|
|
2579
|
-
"minimum": None,
|
|
2580
|
-
"maximum": None,
|
|
2581
|
-
"enabled": True,
|
|
2582
|
-
}
|
|
2583
|
-
self._apply(btn, props, initial=True)
|
|
2584
|
-
return btn
|
|
2520
|
+
open_dialog = self._open_dialog
|
|
2585
2521
|
|
|
2586
|
-
|
|
2587
|
-
|
|
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)
|
|
2588
2528
|
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
id(btn),
|
|
2592
|
-
{"value": None, "mode": "date", "on_change": None, "minimum": None, "maximum": None, "enabled": True},
|
|
2593
|
-
)
|
|
2529
|
+
btn.setOnClickListener(_DateTriggerProxy())
|
|
2530
|
+
return btn
|
|
2594
2531
|
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
if "on_change" in props:
|
|
2598
|
-
state["on_change"] = props.get("on_change")
|
|
2599
|
-
if "minimum" in props:
|
|
2600
|
-
state["minimum"] = props.get("minimum")
|
|
2601
|
-
if "maximum" in props:
|
|
2602
|
-
state["maximum"] = props.get("maximum")
|
|
2532
|
+
def _apply(self, btn: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
2533
|
+
state = _state_of(btn)
|
|
2603
2534
|
if "enabled" in props:
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
state["value"] = props.get("value") if "value" in props else state.get("value")
|
|
2608
|
-
|
|
2609
|
-
self._refresh_label(btn, state)
|
|
2610
|
-
if initial:
|
|
2611
|
-
self._attach_trigger(btn, state)
|
|
2612
|
-
|
|
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)
|
|
2613
2538
|
_apply_accessibility(btn, props)
|
|
2614
2539
|
|
|
2615
2540
|
def _refresh_label(self, btn: Any, state: Dict[str, Any]) -> None:
|
|
2616
|
-
|
|
2541
|
+
merged = state.get("props") or {}
|
|
2542
|
+
value = merged.get("value")
|
|
2617
2543
|
if value:
|
|
2618
2544
|
btn.setText(str(value))
|
|
2619
2545
|
else:
|
|
2620
|
-
btn.setText(self._PLACEHOLDERS.get(
|
|
2621
|
-
|
|
2622
|
-
def _attach_trigger(self, btn: Any, state: Dict[str, Any]) -> None:
|
|
2623
|
-
open_dialog = self._open_dialog
|
|
2624
|
-
|
|
2625
|
-
class _DateTriggerProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
|
|
2626
|
-
def __init__(self, owner_state: Dict[str, Any], trigger: Any) -> None:
|
|
2627
|
-
super().__init__()
|
|
2628
|
-
self._owner_state = owner_state
|
|
2629
|
-
self._trigger = trigger
|
|
2630
|
-
|
|
2631
|
-
def onClick(self, view: Any) -> None:
|
|
2632
|
-
if not self._owner_state.get("enabled", True):
|
|
2633
|
-
return
|
|
2634
|
-
open_dialog(self._trigger, self._owner_state)
|
|
2635
|
-
|
|
2636
|
-
btn.setOnClickListener(_DateTriggerProxy(state, btn))
|
|
2546
|
+
btn.setText(self._PLACEHOLDERS.get(str(merged.get("mode", "date")), "Select"))
|
|
2637
2547
|
|
|
2638
2548
|
def _open_dialog(self, btn: Any, state: Dict[str, Any]) -> None:
|
|
2639
|
-
|
|
2640
|
-
|
|
2549
|
+
merged = state.get("props") or {}
|
|
2550
|
+
mode = str(merged.get("mode", "date"))
|
|
2551
|
+
cal = self._parse_to_calendar(merged.get("value"), mode)
|
|
2641
2552
|
if mode == "time":
|
|
2642
2553
|
self._open_time(btn, state, cal)
|
|
2643
2554
|
elif mode == "datetime":
|
|
@@ -2705,10 +2616,11 @@ class DatePickerHandler(AndroidViewHandler):
|
|
|
2705
2616
|
|
|
2706
2617
|
def _apply_min_max(self, dialog: Any, state: Dict[str, Any]) -> None:
|
|
2707
2618
|
try:
|
|
2708
|
-
|
|
2619
|
+
merged = state.get("props") or {}
|
|
2620
|
+
mode = str(merged.get("mode", "date"))
|
|
2709
2621
|
picker = dialog.getDatePicker()
|
|
2710
|
-
minimum =
|
|
2711
|
-
maximum =
|
|
2622
|
+
minimum = merged.get("minimum")
|
|
2623
|
+
maximum = merged.get("maximum")
|
|
2712
2624
|
if minimum:
|
|
2713
2625
|
picker.setMinDate(self._parse_to_calendar(minimum, mode).getTimeInMillis())
|
|
2714
2626
|
if maximum:
|
|
@@ -2736,22 +2648,18 @@ class DatePickerHandler(AndroidViewHandler):
|
|
|
2736
2648
|
return str(fmt.format(cal.getTime()))
|
|
2737
2649
|
|
|
2738
2650
|
def _commit(self, btn: Any, state: Dict[str, Any], cal: Any) -> None:
|
|
2739
|
-
|
|
2651
|
+
merged = state.get("props") or {}
|
|
2652
|
+
mode = str(merged.get("mode", "date"))
|
|
2740
2653
|
try:
|
|
2741
2654
|
iso = self._format_calendar(cal, mode)
|
|
2742
2655
|
except Exception:
|
|
2743
2656
|
return
|
|
2744
|
-
|
|
2657
|
+
merged["value"] = iso
|
|
2745
2658
|
try:
|
|
2746
2659
|
btn.setText(iso)
|
|
2747
2660
|
except Exception:
|
|
2748
2661
|
pass
|
|
2749
|
-
|
|
2750
|
-
if cb is not None:
|
|
2751
|
-
try:
|
|
2752
|
-
cb(iso)
|
|
2753
|
-
except Exception:
|
|
2754
|
-
pass
|
|
2662
|
+
_fire(btn, "on_change", iso)
|
|
2755
2663
|
|
|
2756
2664
|
|
|
2757
2665
|
# ======================================================================
|
|
@@ -2782,7 +2690,6 @@ def register_handlers(registry: Any) -> None:
|
|
|
2782
2690
|
registry.register("Pressable", PressableHandler())
|
|
2783
2691
|
registry.register("StatusBar", StatusBarHandler())
|
|
2784
2692
|
registry.register("KeyboardAvoidingView", KeyboardAvoidingViewHandler())
|
|
2785
|
-
registry.register("VirtualList", VirtualListHandler())
|
|
2786
2693
|
registry.register("Picker", PickerHandler())
|
|
2787
2694
|
registry.register("Checkbox", CheckboxHandler())
|
|
2788
2695
|
registry.register("SegmentedControl", SegmentedControlHandler())
|
|
@@ -2809,7 +2716,6 @@ __all__ = [
|
|
|
2809
2716
|
"PressableHandler",
|
|
2810
2717
|
"StatusBarHandler",
|
|
2811
2718
|
"KeyboardAvoidingViewHandler",
|
|
2812
|
-
"VirtualListHandler",
|
|
2813
2719
|
"PickerHandler",
|
|
2814
2720
|
"CheckboxHandler",
|
|
2815
2721
|
"SegmentedControlHandler",
|