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