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
pythonnative/native_views/ios.py
CHANGED
|
@@ -6,14 +6,28 @@ frame application. Handlers are registered with the
|
|
|
6
6
|
[`NativeViewRegistry`][pythonnative.native_views.NativeViewRegistry] by
|
|
7
7
|
[`register_handlers`][pythonnative.native_views.ios.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 (taps, text edits, scrolls, gestures)
|
|
12
|
+
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 `UIView`s, the
|
|
18
|
+
engine computes per-child frames in points, and
|
|
12
19
|
[`set_frame`][pythonnative.native_views.ios.IOSViewHandler.set_frame]
|
|
13
20
|
applies those frames via UIKit's classic ``frame`` property (with Auto
|
|
14
|
-
Layout disabled).
|
|
15
|
-
|
|
16
|
-
|
|
21
|
+
Layout disabled).
|
|
22
|
+
|
|
23
|
+
**Gestures** attach real ``UIGestureRecognizer`` instances (pan, pinch,
|
|
24
|
+
rotation, tap, long-press, swipe) configured from the serialized
|
|
25
|
+
gesture specs, so recognition runs fully natively.
|
|
26
|
+
|
|
27
|
+
**Animations**: ``timing`` and ``spring`` specs are driven by UIKit
|
|
28
|
+
block animations with completion callbacks reported back through
|
|
29
|
+
``pythonnative.animated.native_animation_completed``; ``decay`` falls
|
|
30
|
+
back to the Python ticker.
|
|
17
31
|
|
|
18
32
|
This module is only imported on iOS at runtime. Desktop tests inject a
|
|
19
33
|
mock registry via
|
|
@@ -26,8 +40,9 @@ import math
|
|
|
26
40
|
import threading
|
|
27
41
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
28
42
|
|
|
29
|
-
from rubicon.objc import SEL, ObjCClass, objc_method
|
|
43
|
+
from rubicon.objc import SEL, ObjCClass, ObjCInstance, objc_method
|
|
30
44
|
|
|
45
|
+
from ..events import dispatch_event, event_names
|
|
31
46
|
from . import _tripwire_log
|
|
32
47
|
from .base import ViewHandler, _safe_max, parse_color_int
|
|
33
48
|
|
|
@@ -91,6 +106,66 @@ def _objc_ptr(obj: Any) -> Optional[int]:
|
|
|
91
106
|
return None
|
|
92
107
|
|
|
93
108
|
|
|
109
|
+
# ======================================================================
|
|
110
|
+
# Tag table (ObjC pointer -> reconciler tag -> per-view state)
|
|
111
|
+
# ======================================================================
|
|
112
|
+
|
|
113
|
+
_view_tags: Dict[int, int] = {}
|
|
114
|
+
_view_state: Dict[int, Dict[str, Any]] = {}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _remember(view: Any, tag: int) -> None:
|
|
118
|
+
ptr = _objc_ptr(view)
|
|
119
|
+
if ptr is not None:
|
|
120
|
+
_view_tags[ptr] = tag
|
|
121
|
+
_view_state[tag] = {"props": {}}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _tag_of(view: Any) -> Optional[int]:
|
|
125
|
+
ptr = _objc_ptr(view)
|
|
126
|
+
if ptr is None:
|
|
127
|
+
return None
|
|
128
|
+
return _view_tags.get(ptr)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _state_of(view: Any) -> Dict[str, Any]:
|
|
132
|
+
tag = _tag_of(view)
|
|
133
|
+
if tag is None:
|
|
134
|
+
return {}
|
|
135
|
+
return _view_state.setdefault(tag, {"props": {}})
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _forget(view: Any) -> None:
|
|
139
|
+
ptr = _objc_ptr(view)
|
|
140
|
+
if ptr is None:
|
|
141
|
+
return
|
|
142
|
+
tag = _view_tags.pop(ptr, None)
|
|
143
|
+
if tag is not None:
|
|
144
|
+
_view_state.pop(tag, None)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _fire(view: Any, name: str, *args: Any) -> bool:
|
|
148
|
+
"""Dispatch event ``name`` for ``view`` through the tag registry."""
|
|
149
|
+
tag = _tag_of(view)
|
|
150
|
+
if tag is None:
|
|
151
|
+
return False
|
|
152
|
+
return dispatch_event(tag, name, *args)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _fire_ptr(view_ptr: int, name: str, *args: Any) -> bool:
|
|
156
|
+
"""Dispatch event ``name`` for the raw ObjC pointer ``view_ptr``."""
|
|
157
|
+
tag = _view_tags.get(int(view_ptr or 0))
|
|
158
|
+
if tag is None:
|
|
159
|
+
return False
|
|
160
|
+
return dispatch_event(tag, name, *args)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _has_event(view: Any, name: str) -> bool:
|
|
164
|
+
"""Whether the element wired a callback named ``name`` this render."""
|
|
165
|
+
merged = _state_of(view).get("props") or {}
|
|
166
|
+
return name in event_names(merged)
|
|
167
|
+
|
|
168
|
+
|
|
94
169
|
# ======================================================================
|
|
95
170
|
# Raw libobjc helpers
|
|
96
171
|
# ======================================================================
|
|
@@ -105,8 +180,8 @@ def _objc_ptr(obj: Any) -> Optional[int]:
|
|
|
105
180
|
# new ObjC class via ``objc_allocateClassPair``, attach plain
|
|
106
181
|
# CFUNCTYPE-wrapped Python functions as ``IMP``s, and dispatch via
|
|
107
182
|
# ``objc_msgSend``. Every delegate that takes ObjC object arguments
|
|
108
|
-
# beyond
|
|
109
|
-
#
|
|
183
|
+
# beyond plain integers should use this pattern (UITabBar's selection
|
|
184
|
+
# delegate and UIScrollView's scroll delegate both do).
|
|
110
185
|
|
|
111
186
|
_libobjc = _ct.cdll.LoadLibrary("libobjc.A.dylib")
|
|
112
187
|
|
|
@@ -135,10 +210,7 @@ _SEL_ALLOC = _sel_reg(b"alloc")
|
|
|
135
210
|
_SEL_INIT = _sel_reg(b"init")
|
|
136
211
|
_SEL_RETAIN = _sel_reg(b"retain")
|
|
137
212
|
_SEL_SET_DELEGATE = _sel_reg(b"setDelegate:")
|
|
138
|
-
_SEL_SET_DATA_SOURCE = _sel_reg(b"setDataSource:")
|
|
139
213
|
_SEL_TAG = _sel_reg(b"tag")
|
|
140
|
-
_SEL_ROW = _sel_reg(b"row")
|
|
141
|
-
_SEL_DESELECT_ROW = _sel_reg(b"deselectRowAtIndexPath:animated:")
|
|
142
214
|
_SEL_TEXT = _sel_reg(b"text")
|
|
143
215
|
_SEL_UTF8STRING = _sel_reg(b"UTF8String")
|
|
144
216
|
_SEL_ADD_TARGET_ACTION_EVENTS = _sel_reg(b"addTarget:action:forControlEvents:")
|
|
@@ -150,6 +222,7 @@ _SEL_TEXT_FIELD_DID_BEGIN = _sel_reg(b"textFieldDidBeginEditing:")
|
|
|
150
222
|
_SEL_TEXT_FIELD_DID_END = _sel_reg(b"textFieldDidEndEditing:")
|
|
151
223
|
_SEL_TEXT_VIEW_DID_BEGIN = _sel_reg(b"textViewDidBeginEditing:")
|
|
152
224
|
_SEL_TEXT_VIEW_DID_END = _sel_reg(b"textViewDidEndEditing:")
|
|
225
|
+
_SEL_TEXT_VIEW_DID_CHANGE = _sel_reg(b"textViewDidChange:")
|
|
153
226
|
|
|
154
227
|
_NS_OBJECT_CLS = _get_cls(b"NSObject")
|
|
155
228
|
|
|
@@ -309,14 +382,7 @@ def _make_transform(spec: Any) -> Any:
|
|
|
309
382
|
Each dict has exactly one of ``rotate`` (degrees), ``scale`` (uniform),
|
|
310
383
|
``scale_x``, ``scale_y``, ``translate_x``, ``translate_y``.
|
|
311
384
|
"""
|
|
312
|
-
try:
|
|
313
|
-
from rubicon.objc.api import objc_const # noqa: F401
|
|
314
|
-
except Exception:
|
|
315
|
-
pass
|
|
316
|
-
# rubicon-objc doesn't expose CGAffineTransformIdentity directly;
|
|
317
|
-
# we reconstruct it via the C struct.
|
|
318
385
|
ct = _ct
|
|
319
|
-
libc = ct.cdll.LoadLibrary("/usr/lib/libobjc.A.dylib") # noqa: F841
|
|
320
386
|
|
|
321
387
|
class CGAffineTransform(ct.Structure):
|
|
322
388
|
_fields_ = [
|
|
@@ -331,14 +397,10 @@ def _make_transform(spec: Any) -> Any:
|
|
|
331
397
|
coregraphics = ct.cdll.LoadLibrary(
|
|
332
398
|
"/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics",
|
|
333
399
|
)
|
|
334
|
-
coregraphics.CGAffineTransformMakeIdentity = getattr(coregraphics, "CGAffineTransformMakeIdentity", None)
|
|
335
|
-
coregraphics.CGAffineTransformRotate = coregraphics.CGAffineTransformRotate
|
|
336
400
|
coregraphics.CGAffineTransformRotate.restype = CGAffineTransform
|
|
337
401
|
coregraphics.CGAffineTransformRotate.argtypes = [CGAffineTransform, ct.c_double]
|
|
338
|
-
coregraphics.CGAffineTransformScale = coregraphics.CGAffineTransformScale
|
|
339
402
|
coregraphics.CGAffineTransformScale.restype = CGAffineTransform
|
|
340
403
|
coregraphics.CGAffineTransformScale.argtypes = [CGAffineTransform, ct.c_double, ct.c_double]
|
|
341
|
-
coregraphics.CGAffineTransformTranslate = coregraphics.CGAffineTransformTranslate
|
|
342
404
|
coregraphics.CGAffineTransformTranslate.restype = CGAffineTransform
|
|
343
405
|
coregraphics.CGAffineTransformTranslate.argtypes = [CGAffineTransform, ct.c_double, ct.c_double]
|
|
344
406
|
|
|
@@ -409,7 +471,7 @@ def _apply_transform(view: Any, props: Dict[str, Any]) -> None:
|
|
|
409
471
|
# running.
|
|
410
472
|
_tripwire_log(
|
|
411
473
|
"set_transform:nan",
|
|
412
|
-
f"[set_transform:nan] spec={spec!r} ->
|
|
474
|
+
f"[set_transform:nan] spec={spec!r} -> (a={a!r}, b={b!r}, c={c!r}, d={d!r}, tx={tx!r}, ty={ty!r})",
|
|
413
475
|
)
|
|
414
476
|
view.setTransform_((1.0, 0.0, 0.0, 1.0, 0.0, 0.0))
|
|
415
477
|
return
|
|
@@ -481,149 +543,341 @@ def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None:
|
|
|
481
543
|
_apply_accessibility(view, props)
|
|
482
544
|
|
|
483
545
|
|
|
484
|
-
#
|
|
485
|
-
#
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
"translate_y",
|
|
490
|
-
"scale",
|
|
491
|
-
"scale_x",
|
|
492
|
-
"scale_y",
|
|
493
|
-
"rotate", # degrees
|
|
494
|
-
"background_color",
|
|
495
|
-
}
|
|
546
|
+
# ======================================================================
|
|
547
|
+
# Retention + shared targets
|
|
548
|
+
# ======================================================================
|
|
549
|
+
|
|
550
|
+
_pn_retained_views: list = []
|
|
496
551
|
|
|
497
552
|
|
|
498
553
|
# ======================================================================
|
|
499
|
-
#
|
|
554
|
+
# Simultaneous-recognition gesture delegate (raw libobjc)
|
|
500
555
|
# ======================================================================
|
|
556
|
+
#
|
|
557
|
+
# All PythonNative recognizers on a view should recognize together
|
|
558
|
+
# (press feedback + pan + pinch...), matching the GestureArbiter's
|
|
559
|
+
# semantics on Android/desktop. UIKit defaults to exclusivity, so every
|
|
560
|
+
# recognizer we create gets this delegate, which answers YES to
|
|
561
|
+
# ``gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer:``.
|
|
501
562
|
|
|
563
|
+
_GESTURE_SIMUL_TYPE = _ct.CFUNCTYPE(_ct.c_bool, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
|
|
502
564
|
|
|
503
|
-
class IOSViewHandler(ViewHandler):
|
|
504
|
-
"""Base class providing the shared `set_frame` / measure contract.
|
|
505
565
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
566
|
+
def _gesture_simul_imp(self_ptr: int, cmd_ptr: int, g1_ptr: int, g2_ptr: int) -> bool:
|
|
567
|
+
return True
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
_gesture_simul_imp_ref = _GESTURE_SIMUL_TYPE(_gesture_simul_imp)
|
|
571
|
+
|
|
572
|
+
_PN_GESTURE_DELEGATE_CLS = _alloc_cls(_NS_OBJECT_CLS, b"_PNGestureDelegateCTypes", 0)
|
|
573
|
+
if _PN_GESTURE_DELEGATE_CLS:
|
|
574
|
+
_add_method(
|
|
575
|
+
_PN_GESTURE_DELEGATE_CLS,
|
|
576
|
+
_sel_reg(b"gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer:"),
|
|
577
|
+
_ct.cast(_gesture_simul_imp_ref, _ct.c_void_p),
|
|
578
|
+
b"c@:@@",
|
|
579
|
+
)
|
|
580
|
+
_reg_cls(_PN_GESTURE_DELEGATE_CLS)
|
|
581
|
+
|
|
582
|
+
_pn_gesture_delegate_ptr: Any = None
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _shared_gesture_delegate_ptr() -> Any:
|
|
586
|
+
global _pn_gesture_delegate_ptr
|
|
587
|
+
if _pn_gesture_delegate_ptr is None and _PN_GESTURE_DELEGATE_CLS:
|
|
588
|
+
_objc_msgSend.restype = _ct.c_void_p
|
|
589
|
+
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
|
|
590
|
+
raw = _objc_msgSend(_PN_GESTURE_DELEGATE_CLS, _SEL_ALLOC)
|
|
591
|
+
raw = _objc_msgSend(raw, _SEL_INIT)
|
|
592
|
+
raw = _objc_msgSend(raw, _SEL_RETAIN)
|
|
593
|
+
_pn_gesture_delegate_ptr = raw
|
|
594
|
+
return _pn_gesture_delegate_ptr
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _set_recognizer_delegate(recognizer: Any) -> None:
|
|
598
|
+
delegate = _shared_gesture_delegate_ptr()
|
|
599
|
+
if delegate is None:
|
|
600
|
+
return
|
|
601
|
+
try:
|
|
602
|
+
_objc_msgSend.restype = None
|
|
603
|
+
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
|
|
604
|
+
rec_ptr = recognizer.ptr if hasattr(recognizer, "ptr") else recognizer
|
|
605
|
+
_objc_msgSend(rec_ptr, _SEL_SET_DELEGATE, delegate)
|
|
606
|
+
except Exception:
|
|
607
|
+
pass
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
# ======================================================================
|
|
611
|
+
# Native gesture wiring (UIGestureRecognizer -> dispatch_event)
|
|
612
|
+
# ======================================================================
|
|
613
|
+
#
|
|
614
|
+
# Recognizer *actions* must not go through rubicon's ``@objc_method``
|
|
615
|
+
# bridge: on iOS 18.x the action invocation dies inside UIKit/rubicon
|
|
616
|
+
# marshaling (``NSMapGet: map table argument is NULL``) and never
|
|
617
|
+
# reaches Python. Exactly like the scroll/tab-bar delegates, we route
|
|
618
|
+
# every action through one raw libobjc target class whose CFUNCTYPE IMP
|
|
619
|
+
# receives the recognizer *pointer* and looks up a Python closure keyed
|
|
620
|
+
# by that pointer. The closure then reads state/location off the
|
|
621
|
+
# retained rubicon recognizer object (outbound rubicon calls are fine).
|
|
622
|
+
|
|
623
|
+
# Maps recognizer ptr -> zero-arg Python handler closure.
|
|
624
|
+
_pn_action_handlers: Dict[int, Any] = {}
|
|
625
|
+
|
|
626
|
+
_ACTION_IMP_TYPE = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _action_imp(_self_ptr: int, _cmd_ptr: int, sender_ptr: int) -> None:
|
|
630
|
+
"""Raw C callback for every PythonNative recognizer action."""
|
|
631
|
+
handler = _pn_action_handlers.get(int(sender_ptr or 0))
|
|
632
|
+
if handler is None:
|
|
633
|
+
return
|
|
634
|
+
try:
|
|
635
|
+
handler()
|
|
636
|
+
except Exception:
|
|
637
|
+
pass
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
_action_imp_ref = _ACTION_IMP_TYPE(_action_imp)
|
|
641
|
+
|
|
642
|
+
_SEL_ON_ACTION = _sel_reg(b"onPNAction:")
|
|
643
|
+
|
|
644
|
+
_PN_ACTION_TARGET_CLS = _alloc_cls(_NS_OBJECT_CLS, b"_PNActionTargetCTypes", 0)
|
|
645
|
+
if _PN_ACTION_TARGET_CLS:
|
|
646
|
+
_add_method(
|
|
647
|
+
_PN_ACTION_TARGET_CLS,
|
|
648
|
+
_SEL_ON_ACTION,
|
|
649
|
+
_ct.cast(_action_imp_ref, _ct.c_void_p),
|
|
650
|
+
b"v@:@",
|
|
651
|
+
)
|
|
652
|
+
_reg_cls(_PN_ACTION_TARGET_CLS)
|
|
653
|
+
|
|
654
|
+
_pn_action_target_ptr: Any = None
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _shared_action_target_ptr() -> Any:
|
|
658
|
+
global _pn_action_target_ptr
|
|
659
|
+
if _pn_action_target_ptr is None and _PN_ACTION_TARGET_CLS:
|
|
660
|
+
_objc_msgSend.restype = _ct.c_void_p
|
|
661
|
+
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
|
|
662
|
+
raw = _objc_msgSend(_PN_ACTION_TARGET_CLS, _SEL_ALLOC)
|
|
663
|
+
raw = _objc_msgSend(raw, _SEL_INIT)
|
|
664
|
+
raw = _objc_msgSend(raw, _SEL_RETAIN)
|
|
665
|
+
_pn_action_target_ptr = raw
|
|
666
|
+
return _pn_action_target_ptr
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _recognizer_ptr(rec: Any) -> int:
|
|
670
|
+
ptr = rec.ptr if hasattr(rec, "ptr") else rec
|
|
671
|
+
return int(getattr(ptr, "value", ptr) or 0)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _register_action(rec: Any, handler: Any) -> None:
|
|
675
|
+
"""Bind ``handler`` to ``rec`` via the shared raw action target."""
|
|
676
|
+
target_ptr = _shared_action_target_ptr()
|
|
677
|
+
if target_ptr is None:
|
|
678
|
+
return
|
|
679
|
+
_objc_msgSend.restype = None
|
|
680
|
+
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
|
|
681
|
+
rec_ptr = rec.ptr if hasattr(rec, "ptr") else rec
|
|
682
|
+
_objc_msgSend(rec_ptr, _sel_reg(b"addTarget:action:"), target_ptr, _SEL_ON_ACTION)
|
|
683
|
+
_pn_action_handlers[_recognizer_ptr(rec)] = handler
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _register_control_action(control: Any, events_mask: int, handler: Any) -> None:
|
|
687
|
+
"""Bind ``handler`` to a ``UIControl`` event via the shared raw target.
|
|
688
|
+
|
|
689
|
+
The UIControl counterpart of ``_register_action``: control events
|
|
690
|
+
(TouchUpInside, ValueChanged, ...) must not be delivered through
|
|
691
|
+
rubicon's ``@objc_method`` bridge either — the trampoline's ``sender``
|
|
692
|
+
marshaling is what crashed UISwitch toggles on iOS 18.x (the action
|
|
693
|
+
fired, but touching the marshaled ``sender`` segfaulted). The raw IMP
|
|
694
|
+
receives only the sender *pointer*; ``handler`` closures read any
|
|
695
|
+
control state they need from the retained rubicon wrapper they
|
|
696
|
+
captured at wiring time (outbound rubicon calls are fine).
|
|
510
697
|
"""
|
|
698
|
+
target_ptr = _shared_action_target_ptr()
|
|
699
|
+
if target_ptr is None:
|
|
700
|
+
return
|
|
701
|
+
_objc_msgSend.restype = None
|
|
702
|
+
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_ulong]
|
|
703
|
+
ctl_ptr = control.ptr if hasattr(control, "ptr") else control
|
|
704
|
+
_objc_msgSend(ctl_ptr, _SEL_ADD_TARGET_ACTION_EVENTS, target_ptr, _SEL_ON_ACTION, events_mask)
|
|
705
|
+
_pn_action_handlers[_recognizer_ptr(control)] = handler
|
|
511
706
|
|
|
512
|
-
|
|
513
|
-
|
|
707
|
+
|
|
708
|
+
# UIGestureRecognizerState -> GestureEvent.state
|
|
709
|
+
_GSTATE = {1: "began", 2: "changed", 3: "ended", 4: "cancelled", 5: "cancelled"}
|
|
710
|
+
|
|
711
|
+
_SWIPE_DIRECTIONS = {"right": 1, "left": 2, "up": 4, "down": 8}
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _make_gesture_handler(
|
|
715
|
+
rec: Any,
|
|
716
|
+
view: Any,
|
|
717
|
+
kind: str,
|
|
718
|
+
index: int,
|
|
719
|
+
direction: Optional[str] = None,
|
|
720
|
+
) -> Any:
|
|
721
|
+
"""Build the action closure emitting one ``gesture:<i>`` payload."""
|
|
722
|
+
|
|
723
|
+
def handler() -> None:
|
|
724
|
+
try:
|
|
725
|
+
raw_state = int(rec.state)
|
|
726
|
+
except Exception:
|
|
727
|
+
raw_state = 3
|
|
728
|
+
state = _GSTATE.get(raw_state)
|
|
729
|
+
if state is None:
|
|
514
730
|
return
|
|
731
|
+
|
|
732
|
+
payload: Dict[str, Any] = {"kind": kind, "state": state}
|
|
515
733
|
try:
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
734
|
+
location = rec.locationInView_(view)
|
|
735
|
+
payload["x"] = float(location.x)
|
|
736
|
+
payload["y"] = float(location.y)
|
|
737
|
+
except Exception:
|
|
738
|
+
pass
|
|
739
|
+
try:
|
|
740
|
+
payload["pointer_count"] = int(rec.numberOfTouches)
|
|
741
|
+
except Exception:
|
|
742
|
+
pass
|
|
743
|
+
|
|
744
|
+
if kind == "pan":
|
|
523
745
|
try:
|
|
524
|
-
|
|
746
|
+
translation = rec.translationInView_(view)
|
|
747
|
+
payload["translation_x"] = float(translation.x)
|
|
748
|
+
payload["translation_y"] = float(translation.y)
|
|
749
|
+
velocity = rec.velocityInView_(view)
|
|
750
|
+
payload["velocity_x"] = float(velocity.x)
|
|
751
|
+
payload["velocity_y"] = float(velocity.y)
|
|
525
752
|
except Exception:
|
|
526
753
|
pass
|
|
754
|
+
elif kind == "pinch":
|
|
527
755
|
try:
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
# Expand the parent UIScrollView's contentSize whenever a
|
|
535
|
-
# child's frame extends past the visible bounds, so the
|
|
536
|
-
# scroll view can actually scroll to reveal it.
|
|
537
|
-
if "UIScrollView" in parent_cls:
|
|
538
|
-
bounds = parent.bounds
|
|
539
|
-
content_w = max(float(bounds.size.width), frame_x + frame_w)
|
|
540
|
-
content_h = max(float(bounds.size.height), frame_y + frame_h)
|
|
541
|
-
parent.setContentSize_((content_w, content_h))
|
|
756
|
+
payload["scale"] = float(rec.scale)
|
|
757
|
+
except Exception:
|
|
758
|
+
pass
|
|
759
|
+
elif kind == "rotation":
|
|
760
|
+
try:
|
|
761
|
+
payload["rotation"] = float(rec.rotation)
|
|
542
762
|
except Exception:
|
|
543
763
|
pass
|
|
764
|
+
elif kind == "swipe":
|
|
765
|
+
# Discrete: UIKit only calls us on recognition, and only the
|
|
766
|
+
# recognizer whose direction matched fires — so the bound
|
|
767
|
+
# per-recognizer direction is the actual swipe direction.
|
|
768
|
+
payload["state"] = "ended"
|
|
769
|
+
payload["direction"] = direction
|
|
770
|
+
elif kind == "tap":
|
|
771
|
+
payload["state"] = "ended"
|
|
772
|
+
|
|
773
|
+
_fire(view, f"gesture:{index}", payload)
|
|
774
|
+
|
|
775
|
+
return handler
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _make_recognizer(kind: str, spec: Dict[str, Any]) -> List[Tuple[Any, Optional[str]]]:
|
|
779
|
+
"""Build the UIGestureRecognizer(s) for one serialized gesture spec.
|
|
780
|
+
|
|
781
|
+
Swipe with ``direction="any"`` needs one recognizer per direction
|
|
782
|
+
(UIKit constraint), so this returns ``(recognizer, direction)``
|
|
783
|
+
pairs; ``direction`` is ``None`` for non-swipe kinds.
|
|
784
|
+
"""
|
|
785
|
+
out: List[Tuple[Any, Optional[str]]] = []
|
|
786
|
+
if kind == "tap":
|
|
787
|
+
rec = ObjCClass("UITapGestureRecognizer").alloc().init()
|
|
788
|
+
try:
|
|
789
|
+
rec.setNumberOfTapsRequired_(max(1, int(spec.get("n_taps", 1))))
|
|
544
790
|
except Exception:
|
|
545
791
|
pass
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
native_view: Any,
|
|
550
|
-
max_width: float,
|
|
551
|
-
max_height: float,
|
|
552
|
-
) -> Tuple[float, float]:
|
|
792
|
+
out.append((rec, None))
|
|
793
|
+
elif kind == "long_press":
|
|
794
|
+
rec = ObjCClass("UILongPressGestureRecognizer").alloc().init()
|
|
553
795
|
try:
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
size = native_view.sizeThatFits_((mw, mh))
|
|
557
|
-
w = float(size.width)
|
|
558
|
-
h = float(size.height)
|
|
559
|
-
if math.isfinite(max_width):
|
|
560
|
-
w = min(w, max_width)
|
|
561
|
-
return (w, h)
|
|
796
|
+
rec.setMinimumPressDuration_(float(spec.get("min_duration_ms", 500.0)) / 1000.0)
|
|
797
|
+
rec.setAllowableMovement_(float(spec.get("max_distance", 12.0)))
|
|
562
798
|
except Exception:
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
native_view: Any,
|
|
568
|
-
prop_name: str,
|
|
569
|
-
value: Any,
|
|
570
|
-
duration_ms: float = 0.0,
|
|
571
|
-
easing: str = "linear",
|
|
572
|
-
) -> None:
|
|
573
|
-
"""Apply ``prop_name`` to ``native_view`` immediately or animated.
|
|
574
|
-
|
|
575
|
-
Used by ``Animated.View`` to bypass the reconciler when the
|
|
576
|
-
bound `Animated.Value` ticks. When
|
|
577
|
-
``duration_ms > 0``, the change is wrapped in
|
|
578
|
-
``UIView.animate(withDuration:)`` so UIKit interpolates between
|
|
579
|
-
the current and target value at 60 FPS without further Python
|
|
580
|
-
involvement.
|
|
581
|
-
|
|
582
|
-
Args:
|
|
583
|
-
native_view: The target ``UIView``-derived instance.
|
|
584
|
-
prop_name: One of ``opacity``, ``translate_x``,
|
|
585
|
-
``translate_y``, ``scale``, ``scale_x``, ``scale_y``,
|
|
586
|
-
``rotate`` (degrees), ``background_color``.
|
|
587
|
-
value: The new property value.
|
|
588
|
-
duration_ms: Optional UIKit animation duration in ms; ``0``
|
|
589
|
-
applies the change immediately.
|
|
590
|
-
easing: Easing curve name (``linear``, ``ease_in``,
|
|
591
|
-
``ease_out``, ``ease_in_out``).
|
|
592
|
-
"""
|
|
593
|
-
if native_view is None:
|
|
594
|
-
return
|
|
799
|
+
pass
|
|
800
|
+
out.append((rec, None))
|
|
801
|
+
elif kind == "pan":
|
|
802
|
+
rec = ObjCClass("UIPanGestureRecognizer").alloc().init()
|
|
595
803
|
try:
|
|
596
|
-
|
|
804
|
+
rec.setMinimumNumberOfTouches_(max(1, int(spec.get("min_pointers", 1))))
|
|
597
805
|
except Exception:
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
806
|
+
pass
|
|
807
|
+
out.append((rec, None))
|
|
808
|
+
elif kind == "swipe":
|
|
809
|
+
direction = str(spec.get("direction", "any"))
|
|
810
|
+
directions = [direction] if direction in _SWIPE_DIRECTIONS else list(_SWIPE_DIRECTIONS)
|
|
811
|
+
for d in directions:
|
|
812
|
+
rec = ObjCClass("UISwipeGestureRecognizer").alloc().init()
|
|
602
813
|
try:
|
|
603
|
-
|
|
814
|
+
rec.setDirection_(_SWIPE_DIRECTIONS[d])
|
|
604
815
|
except Exception:
|
|
605
816
|
pass
|
|
606
|
-
|
|
817
|
+
out.append((rec, d))
|
|
818
|
+
elif kind == "pinch":
|
|
819
|
+
out.append((ObjCClass("UIPinchGestureRecognizer").alloc().init(), None))
|
|
820
|
+
elif kind == "rotation":
|
|
821
|
+
out.append((ObjCClass("UIRotationGestureRecognizer").alloc().init(), None))
|
|
822
|
+
return out
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _wire_gestures(view: Any, specs: Any) -> None:
|
|
826
|
+
"""Attach native recognizers for the serialized gesture specs.
|
|
827
|
+
|
|
828
|
+
Re-wiring on update removes previously attached PythonNative
|
|
829
|
+
recognizers first (configuration changes are rare; correctness
|
|
830
|
+
beats incremental patching here).
|
|
831
|
+
"""
|
|
832
|
+
state = _state_of(view)
|
|
833
|
+
for rec in state.get("gesture_recognizers") or []:
|
|
607
834
|
try:
|
|
608
|
-
|
|
609
|
-
options = {
|
|
610
|
-
"linear": 1 << 16,
|
|
611
|
-
"ease_in": 1 << 17,
|
|
612
|
-
"ease_out": 1 << 18,
|
|
613
|
-
"ease_in_out": 0,
|
|
614
|
-
}.get(easing, 0)
|
|
615
|
-
UIView.animateWithDuration_delay_options_animations_completion_(
|
|
616
|
-
duration_ms / 1000.0,
|
|
617
|
-
0.0,
|
|
618
|
-
options,
|
|
619
|
-
lambda: applier(native_view),
|
|
620
|
-
None,
|
|
621
|
-
)
|
|
835
|
+
view.removeGestureRecognizer_(rec)
|
|
622
836
|
except Exception:
|
|
837
|
+
pass
|
|
838
|
+
_pn_action_handlers.pop(_recognizer_ptr(rec), None)
|
|
839
|
+
state["gesture_recognizers"] = []
|
|
840
|
+
if not isinstance(specs, (list, tuple)) or not specs:
|
|
841
|
+
return
|
|
842
|
+
|
|
843
|
+
try:
|
|
844
|
+
view.setUserInteractionEnabled_(True)
|
|
845
|
+
except Exception:
|
|
846
|
+
pass
|
|
847
|
+
|
|
848
|
+
recognizers: List[Any] = []
|
|
849
|
+
for i, spec in enumerate(specs):
|
|
850
|
+
if not isinstance(spec, dict):
|
|
851
|
+
continue
|
|
852
|
+
kind = str(spec.get("kind", ""))
|
|
853
|
+
if not kind:
|
|
854
|
+
continue
|
|
855
|
+
for rec, direction in _make_recognizer(kind, spec):
|
|
623
856
|
try:
|
|
624
|
-
|
|
857
|
+
rec.setCancelsTouchesInView_(False)
|
|
625
858
|
except Exception:
|
|
626
859
|
pass
|
|
860
|
+
_set_recognizer_delegate(rec)
|
|
861
|
+
try:
|
|
862
|
+
view.addGestureRecognizer_(rec)
|
|
863
|
+
rec.retain()
|
|
864
|
+
recognizers.append(rec)
|
|
865
|
+
except Exception:
|
|
866
|
+
continue
|
|
867
|
+
_register_action(rec, _make_gesture_handler(rec, view, kind, i, direction))
|
|
868
|
+
|
|
869
|
+
state["gesture_recognizers"] = recognizers
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
# ======================================================================
|
|
873
|
+
# Native-driven animations (UIView block animations)
|
|
874
|
+
# ======================================================================
|
|
875
|
+
|
|
876
|
+
_native_anims: Dict[int, Dict[str, Any]] = {}
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _is_main_thread() -> bool:
|
|
880
|
+
return threading.current_thread() is threading.main_thread()
|
|
627
881
|
|
|
628
882
|
|
|
629
883
|
def _animated_applier_for(prop: str, value: Any) -> Optional[Callable[[Any], None]]:
|
|
@@ -641,7 +895,7 @@ def _animated_applier_for(prop: str, value: Any) -> Optional[Callable[[Any], Non
|
|
|
641
895
|
|
|
642
896
|
return _apply
|
|
643
897
|
if prop in ("translate_x", "translate_y", "scale", "scale_x", "scale_y", "rotate"):
|
|
644
|
-
spec = {prop: value}
|
|
898
|
+
spec = {prop: value}
|
|
645
899
|
|
|
646
900
|
def _apply(view: Any) -> None:
|
|
647
901
|
_apply_transform(view, {"transform": [spec]})
|
|
@@ -650,355 +904,284 @@ def _animated_applier_for(prop: str, value: Any) -> Optional[Callable[[Any], Non
|
|
|
650
904
|
return None
|
|
651
905
|
|
|
652
906
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
# ======================================================================
|
|
907
|
+
def _spring_parameters(spec: Dict[str, Any]) -> Tuple[float, float, float]:
|
|
908
|
+
"""Translate physics params into UIKit's (duration, damping_ratio, velocity).
|
|
656
909
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
910
|
+
UIKit's spring API takes a damping *ratio* (0..1) and a velocity
|
|
911
|
+
normalized to the total travel distance. We derive both from the
|
|
912
|
+
stiffness/damping/mass spec and approximate the settle duration
|
|
913
|
+
from the envelope decay.
|
|
914
|
+
"""
|
|
915
|
+
stiffness = max(1e-3, float(spec.get("stiffness", 100.0)))
|
|
916
|
+
damping = max(1e-3, float(spec.get("damping", 10.0)))
|
|
917
|
+
mass = max(1e-3, float(spec.get("mass", 1.0)))
|
|
918
|
+
omega0 = math.sqrt(stiffness / mass)
|
|
919
|
+
zeta = damping / (2.0 * math.sqrt(stiffness * mass))
|
|
920
|
+
damping_ratio = max(0.05, min(1.0, zeta))
|
|
921
|
+
if zeta < 1.0:
|
|
922
|
+
duration = min(10.0, max(0.15, 4.0 / max(0.05, zeta * omega0)))
|
|
923
|
+
else:
|
|
924
|
+
duration = min(10.0, max(0.15, 4.0 / omega0))
|
|
925
|
+
distance = abs(float(spec.get("to", 0.0)) - float(spec.get("from", 0.0)))
|
|
926
|
+
v0 = float(spec.get("initial_velocity", 0.0))
|
|
927
|
+
norm_velocity = (v0 / distance) if distance > 1e-9 else 0.0
|
|
928
|
+
return duration, damping_ratio, norm_velocity
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
_EASING_OPTIONS = {
|
|
932
|
+
"linear": 1 << 16, # UIViewAnimationOptionCurveLinear
|
|
933
|
+
"ease_in": 1 << 17,
|
|
934
|
+
"ease_out": 1 << 18,
|
|
935
|
+
"ease_in_out": 0,
|
|
936
|
+
"ease": 0,
|
|
937
|
+
}
|
|
660
938
|
|
|
661
939
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
if cb is not None:
|
|
671
|
-
cb()
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
_pn_tf_change_callback_map: dict = {}
|
|
675
|
-
_pn_tf_submit_callback_map: dict = {}
|
|
676
|
-
_pn_tf_focus_callback_map: dict = {}
|
|
677
|
-
_pn_tf_blur_callback_map: dict = {}
|
|
678
|
-
_pn_tf_raw_target_map: dict = {}
|
|
679
|
-
_pn_tv_raw_target_map: dict = {}
|
|
680
|
-
_PN_TEXTFIELD_TARGET_CLS: Optional[int] = None
|
|
681
|
-
_textfield_edit_imp_ref: Any = None
|
|
682
|
-
_textfield_submit_imp_ref: Any = None
|
|
683
|
-
_textfield_should_return_imp_ref: Any = None
|
|
684
|
-
_textfield_did_begin_imp_ref: Any = None
|
|
685
|
-
_textfield_did_end_imp_ref: Any = None
|
|
940
|
+
def _start_native_animation(view: Any, anim_id: int, prop_name: str, spec: Dict[str, Any]) -> bool:
|
|
941
|
+
"""Run a ``timing`` / ``spring`` spec as a UIView block animation."""
|
|
942
|
+
applier_from = _animated_applier_for(prop_name, spec.get("from"))
|
|
943
|
+
applier_to = _animated_applier_for(prop_name, spec.get("to"))
|
|
944
|
+
if applier_to is None:
|
|
945
|
+
return False
|
|
946
|
+
UIView = ObjCClass("UIView")
|
|
947
|
+
kind = str(spec.get("kind", "timing"))
|
|
686
948
|
|
|
949
|
+
def _completion(finished: bool) -> None:
|
|
950
|
+
_native_anims.pop(anim_id, None)
|
|
951
|
+
try:
|
|
952
|
+
from ..animated import native_animation_completed
|
|
687
953
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
try:
|
|
692
|
-
_objc_msgSend.restype = _ct.c_void_p
|
|
693
|
-
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
|
|
694
|
-
nsstring_ptr = _objc_msgSend(_ct.c_void_p(sender_ptr), _SEL_TEXT)
|
|
695
|
-
if not nsstring_ptr:
|
|
696
|
-
return ""
|
|
697
|
-
_objc_msgSend.restype = _ct.c_char_p
|
|
698
|
-
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
|
|
699
|
-
raw = _objc_msgSend(_ct.c_void_p(nsstring_ptr), _SEL_UTF8STRING)
|
|
700
|
-
if not raw:
|
|
701
|
-
return ""
|
|
702
|
-
return raw.decode("utf-8", errors="replace")
|
|
703
|
-
except Exception:
|
|
704
|
-
return ""
|
|
954
|
+
native_animation_completed(anim_id, bool(finished))
|
|
955
|
+
except Exception:
|
|
956
|
+
pass
|
|
705
957
|
|
|
958
|
+
def _animations() -> None:
|
|
959
|
+
try:
|
|
960
|
+
applier_to(view)
|
|
961
|
+
except Exception:
|
|
962
|
+
pass
|
|
706
963
|
|
|
707
|
-
def _textfield_on_edit_imp(self_ptr: int, _cmd: int, sender_ptr: int) -> None:
|
|
708
|
-
cb = _pn_tf_change_callback_map.get(int(self_ptr))
|
|
709
|
-
if cb is None:
|
|
710
|
-
return
|
|
711
|
-
text = _textfield_text(int(sender_ptr or 0))
|
|
712
964
|
try:
|
|
713
|
-
|
|
965
|
+
# Snap to the starting value so the animation covers the full
|
|
966
|
+
# declared range even if the view was somewhere else.
|
|
967
|
+
if applier_from is not None:
|
|
968
|
+
applier_from(view)
|
|
969
|
+
_native_anims[anim_id] = {"view": view, "prop": prop_name}
|
|
970
|
+
if kind == "spring":
|
|
971
|
+
duration, damping_ratio, velocity = _spring_parameters(spec)
|
|
972
|
+
UIView.animateWithDuration_delay_usingSpringWithDamping_initialSpringVelocity_options_animations_completion_(
|
|
973
|
+
duration,
|
|
974
|
+
0.0,
|
|
975
|
+
damping_ratio,
|
|
976
|
+
velocity,
|
|
977
|
+
1 << 1, # AllowUserInteraction
|
|
978
|
+
_animations,
|
|
979
|
+
_completion,
|
|
980
|
+
)
|
|
981
|
+
else:
|
|
982
|
+
duration = max(0.0, float(spec.get("duration_ms", 300.0))) / 1000.0
|
|
983
|
+
options = _EASING_OPTIONS.get(str(spec.get("easing", "ease_in_out")), 0) | (1 << 1)
|
|
984
|
+
UIView.animateWithDuration_delay_options_animations_completion_(
|
|
985
|
+
duration,
|
|
986
|
+
float(spec.get("delay_ms", 0.0) or 0.0) / 1000.0,
|
|
987
|
+
options,
|
|
988
|
+
_animations,
|
|
989
|
+
_completion,
|
|
990
|
+
)
|
|
991
|
+
return True
|
|
714
992
|
except Exception:
|
|
715
|
-
|
|
716
|
-
|
|
993
|
+
_native_anims.pop(anim_id, None)
|
|
994
|
+
return False
|
|
717
995
|
|
|
718
|
-
def _textfield_on_submit_imp(self_ptr: int, _cmd: int, sender_ptr: int) -> None:
|
|
719
|
-
cb = _pn_tf_submit_callback_map.get(int(self_ptr))
|
|
720
|
-
if cb is None:
|
|
721
|
-
return
|
|
722
|
-
text = _textfield_text(int(sender_ptr or 0))
|
|
723
|
-
try:
|
|
724
|
-
cb(text)
|
|
725
|
-
except Exception:
|
|
726
|
-
pass
|
|
727
996
|
|
|
997
|
+
# ======================================================================
|
|
998
|
+
# Base class with shared frame/measure/animation implementations
|
|
999
|
+
# ======================================================================
|
|
728
1000
|
|
|
729
|
-
def _textfield_should_return_imp(self_ptr: int, _cmd: int, tf_ptr: int) -> bool:
|
|
730
|
-
"""``UITextFieldDelegate.textFieldShouldReturn:`` — dismiss the keyboard.
|
|
731
1001
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
1002
|
+
class IOSViewHandler(ViewHandler):
|
|
1003
|
+
"""Base class providing the shared protocol implementation.
|
|
1004
|
+
|
|
1005
|
+
Subclasses implement
|
|
1006
|
+
[`_build`][pythonnative.native_views.ios.IOSViewHandler._build]
|
|
1007
|
+
(construct the UIKit view) and
|
|
1008
|
+
[`_apply`][pythonnative.native_views.ios.IOSViewHandler._apply]
|
|
1009
|
+
(apply visual props); the base class owns tag registration,
|
|
1010
|
+
gesture wiring, frame application via classic ``CGRect``
|
|
1011
|
+
positioning (Auto Layout off), intrinsic measurement via
|
|
1012
|
+
``sizeThatFits:``, and the animation hooks.
|
|
737
1013
|
"""
|
|
738
|
-
try:
|
|
739
|
-
_objc_msgSend.restype = None
|
|
740
|
-
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
|
|
741
|
-
_objc_msgSend(_ct.c_void_p(int(tf_ptr or 0)), _SEL_RESIGN_FIRST_RESPONDER)
|
|
742
|
-
except Exception:
|
|
743
|
-
pass
|
|
744
|
-
return True
|
|
745
1014
|
|
|
1015
|
+
def create(self, tag: int, props: Dict[str, Any]) -> Any:
|
|
1016
|
+
view = self._build(props)
|
|
1017
|
+
_remember(view, tag)
|
|
1018
|
+
_state_of(view)["props"] = dict(props)
|
|
1019
|
+
self._apply(view, props, initial=True)
|
|
1020
|
+
if props.get("gestures"):
|
|
1021
|
+
_wire_gestures(view, props.get("gestures"))
|
|
1022
|
+
return view
|
|
1023
|
+
|
|
1024
|
+
def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None:
|
|
1025
|
+
state = _state_of(native_view)
|
|
1026
|
+
merged = state.setdefault("props", {})
|
|
1027
|
+
merged.update(changed_props)
|
|
1028
|
+
self._apply(native_view, changed_props, initial=False)
|
|
1029
|
+
if "gestures" in changed_props:
|
|
1030
|
+
_wire_gestures(native_view, changed_props.get("gestures"))
|
|
1031
|
+
|
|
1032
|
+
def destroy(self, native_view: Any) -> None:
|
|
1033
|
+
self._teardown(native_view)
|
|
1034
|
+
state = _state_of(native_view)
|
|
1035
|
+
for rec in state.get("gesture_recognizers") or []:
|
|
1036
|
+
_pn_action_handlers.pop(_recognizer_ptr(rec), None)
|
|
1037
|
+
# Controls register their own pointer as the action-handler key
|
|
1038
|
+
# (see _register_control_action); drop it with the view.
|
|
1039
|
+
_pn_action_handlers.pop(_recognizer_ptr(native_view), None)
|
|
1040
|
+
try:
|
|
1041
|
+
native_view.removeFromSuperview()
|
|
1042
|
+
except Exception:
|
|
1043
|
+
pass
|
|
1044
|
+
_forget(native_view)
|
|
746
1045
|
|
|
747
|
-
def
|
|
748
|
-
|
|
749
|
-
cb = _pn_tf_focus_callback_map.get(int(self_ptr))
|
|
750
|
-
if cb is None:
|
|
751
|
-
return
|
|
752
|
-
try:
|
|
753
|
-
cb()
|
|
754
|
-
except Exception:
|
|
755
|
-
pass
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
def _textfield_did_end_imp(self_ptr: int, _cmd: int, sender_ptr: int) -> None:
|
|
759
|
-
"""``textFieldDidEndEditing:`` / ``textViewDidEndEditing:`` -> ``on_blur``."""
|
|
760
|
-
cb = _pn_tf_blur_callback_map.get(int(self_ptr))
|
|
761
|
-
if cb is None:
|
|
762
|
-
return
|
|
763
|
-
try:
|
|
764
|
-
cb()
|
|
765
|
-
except Exception:
|
|
766
|
-
pass
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
def _ensure_textfield_target_class() -> Optional[int]:
|
|
770
|
-
global _PN_TEXTFIELD_TARGET_CLS
|
|
771
|
-
global _textfield_edit_imp_ref, _textfield_submit_imp_ref, _textfield_should_return_imp_ref
|
|
772
|
-
global _textfield_did_begin_imp_ref, _textfield_did_end_imp_ref
|
|
773
|
-
if _PN_TEXTFIELD_TARGET_CLS is not None:
|
|
774
|
-
return _PN_TEXTFIELD_TARGET_CLS
|
|
775
|
-
existing = _get_cls(b"PNTextFieldActionTarget")
|
|
776
|
-
if existing:
|
|
777
|
-
_PN_TEXTFIELD_TARGET_CLS = int(existing)
|
|
778
|
-
return _PN_TEXTFIELD_TARGET_CLS
|
|
779
|
-
cls = _alloc_cls(_NS_OBJECT_CLS, b"PNTextFieldActionTarget", 0)
|
|
780
|
-
if not cls:
|
|
781
|
-
return None
|
|
782
|
-
action_type = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
|
|
783
|
-
bool_type = _ct.CFUNCTYPE(_ct.c_bool, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
|
|
784
|
-
_textfield_edit_imp_ref = action_type(_textfield_on_edit_imp)
|
|
785
|
-
_textfield_submit_imp_ref = action_type(_textfield_on_submit_imp)
|
|
786
|
-
_textfield_should_return_imp_ref = bool_type(_textfield_should_return_imp)
|
|
787
|
-
_textfield_did_begin_imp_ref = action_type(_textfield_did_begin_imp)
|
|
788
|
-
_textfield_did_end_imp_ref = action_type(_textfield_did_end_imp)
|
|
789
|
-
_add_method(cls, _SEL_ON_EDIT, _ct.cast(_textfield_edit_imp_ref, _ct.c_void_p), b"v@:@")
|
|
790
|
-
_add_method(cls, _SEL_ON_SUBMIT, _ct.cast(_textfield_submit_imp_ref, _ct.c_void_p), b"v@:@")
|
|
791
|
-
_add_method(
|
|
792
|
-
cls,
|
|
793
|
-
_SEL_TEXT_FIELD_SHOULD_RETURN,
|
|
794
|
-
_ct.cast(_textfield_should_return_imp_ref, _ct.c_void_p),
|
|
795
|
-
b"c@:@",
|
|
796
|
-
)
|
|
797
|
-
# ``textFieldDidBeginEditing:`` / ``textViewDidBeginEditing:`` both
|
|
798
|
-
# share the focus IMP; the end-editing pair shares the blur IMP. The
|
|
799
|
-
# same target object is wired as both UITextFieldDelegate and
|
|
800
|
-
# UITextViewDelegate so on_focus / on_blur work for single- and
|
|
801
|
-
# multi-line inputs alike.
|
|
802
|
-
_add_method(cls, _SEL_TEXT_FIELD_DID_BEGIN, _ct.cast(_textfield_did_begin_imp_ref, _ct.c_void_p), b"v@:@")
|
|
803
|
-
_add_method(cls, _SEL_TEXT_FIELD_DID_END, _ct.cast(_textfield_did_end_imp_ref, _ct.c_void_p), b"v@:@")
|
|
804
|
-
_add_method(cls, _SEL_TEXT_VIEW_DID_BEGIN, _ct.cast(_textfield_did_begin_imp_ref, _ct.c_void_p), b"v@:@")
|
|
805
|
-
_add_method(cls, _SEL_TEXT_VIEW_DID_END, _ct.cast(_textfield_did_end_imp_ref, _ct.c_void_p), b"v@:@")
|
|
806
|
-
_reg_cls(cls)
|
|
807
|
-
_PN_TEXTFIELD_TARGET_CLS = int(cls)
|
|
808
|
-
return _PN_TEXTFIELD_TARGET_CLS
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
def _new_textfield_target() -> Optional[int]:
|
|
812
|
-
cls = _ensure_textfield_target_class()
|
|
813
|
-
if not cls:
|
|
814
|
-
return None
|
|
815
|
-
_objc_msgSend.restype = _ct.c_void_p
|
|
816
|
-
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
|
|
817
|
-
raw = _objc_msgSend(_ct.c_void_p(cls), _SEL_ALLOC)
|
|
818
|
-
raw = _objc_msgSend(_ct.c_void_p(raw), _SEL_INIT)
|
|
819
|
-
raw = _objc_msgSend(_ct.c_void_p(raw), _SEL_RETAIN)
|
|
820
|
-
return int(raw) if raw else None
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
def _attach_textfield_raw_target(tf: Any, props: Dict[str, Any]) -> None:
|
|
824
|
-
tf_ptr = _objc_ptr(tf)
|
|
825
|
-
if not tf_ptr:
|
|
826
|
-
return
|
|
827
|
-
target_ptr = _pn_tf_raw_target_map.get(id(tf))
|
|
828
|
-
if target_ptr is None:
|
|
829
|
-
target_ptr = _new_textfield_target()
|
|
830
|
-
if not target_ptr:
|
|
831
|
-
return
|
|
832
|
-
_pn_tf_raw_target_map[id(tf)] = target_ptr
|
|
833
|
-
_pn_retained_views.append(target_ptr)
|
|
834
|
-
_objc_msgSend.restype = None
|
|
835
|
-
_objc_msgSend.argtypes = [
|
|
836
|
-
_ct.c_void_p,
|
|
837
|
-
_ct.c_void_p,
|
|
838
|
-
_ct.c_void_p,
|
|
839
|
-
_ct.c_void_p,
|
|
840
|
-
_ct.c_ulong,
|
|
841
|
-
]
|
|
842
|
-
_objc_msgSend(
|
|
843
|
-
_ct.c_void_p(tf_ptr),
|
|
844
|
-
_SEL_ADD_TARGET_ACTION_EVENTS,
|
|
845
|
-
_ct.c_void_p(target_ptr),
|
|
846
|
-
_SEL_ON_EDIT,
|
|
847
|
-
1 << 17,
|
|
848
|
-
)
|
|
849
|
-
_objc_msgSend(
|
|
850
|
-
_ct.c_void_p(tf_ptr),
|
|
851
|
-
_SEL_ADD_TARGET_ACTION_EVENTS,
|
|
852
|
-
_ct.c_void_p(target_ptr),
|
|
853
|
-
_SEL_ON_SUBMIT,
|
|
854
|
-
1 << 6,
|
|
855
|
-
)
|
|
856
|
-
# Wire the same object as the UITextFieldDelegate so its
|
|
857
|
-
# ``textFieldShouldReturn:`` runs and resigns first responder
|
|
858
|
-
# — without this iOS keeps the keyboard up after Return.
|
|
859
|
-
_objc_msgSend.restype = None
|
|
860
|
-
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
|
|
861
|
-
_objc_msgSend(
|
|
862
|
-
_ct.c_void_p(tf_ptr),
|
|
863
|
-
_SEL_SET_DELEGATE,
|
|
864
|
-
_ct.c_void_p(target_ptr),
|
|
865
|
-
)
|
|
866
|
-
if "on_change" in props:
|
|
867
|
-
_pn_tf_change_callback_map[int(target_ptr)] = props["on_change"]
|
|
868
|
-
if "on_submit" in props:
|
|
869
|
-
_pn_tf_submit_callback_map[int(target_ptr)] = props["on_submit"]
|
|
870
|
-
if "on_focus" in props:
|
|
871
|
-
_pn_tf_focus_callback_map[int(target_ptr)] = props["on_focus"]
|
|
872
|
-
if "on_blur" in props:
|
|
873
|
-
_pn_tf_blur_callback_map[int(target_ptr)] = props["on_blur"]
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
def _attach_textview_raw_target(tv: Any, props: Dict[str, Any]) -> None:
|
|
877
|
-
"""Wire ``tv`` as a UITextViewDelegate for ``on_focus`` / ``on_blur``.
|
|
878
|
-
|
|
879
|
-
Unlike ``UITextField`` (a ``UIControl`` driven by target-action), a
|
|
880
|
-
``UITextView`` only exposes focus/blur through its delegate's
|
|
881
|
-
``textViewDidBeginEditing:`` / ``textViewDidEndEditing:``. We reuse
|
|
882
|
-
the same raw ``PNTextFieldActionTarget`` class — it implements those
|
|
883
|
-
selectors too — and set it as the text view's delegate.
|
|
884
|
-
"""
|
|
885
|
-
tv_ptr = _objc_ptr(tv)
|
|
886
|
-
if not tv_ptr:
|
|
887
|
-
return
|
|
888
|
-
target_ptr = _pn_tv_raw_target_map.get(id(tv))
|
|
889
|
-
if target_ptr is None:
|
|
890
|
-
target_ptr = _new_textfield_target()
|
|
891
|
-
if not target_ptr:
|
|
892
|
-
return
|
|
893
|
-
_pn_tv_raw_target_map[id(tv)] = target_ptr
|
|
894
|
-
_pn_retained_views.append(target_ptr)
|
|
895
|
-
_objc_msgSend.restype = None
|
|
896
|
-
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
|
|
897
|
-
_objc_msgSend(_ct.c_void_p(tv_ptr), _SEL_SET_DELEGATE, _ct.c_void_p(target_ptr))
|
|
898
|
-
if "on_focus" in props:
|
|
899
|
-
_pn_tf_focus_callback_map[int(target_ptr)] = props["on_focus"]
|
|
900
|
-
if "on_blur" in props:
|
|
901
|
-
_pn_tf_blur_callback_map[int(target_ptr)] = props["on_blur"]
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
_pn_switch_handler_map: dict = {}
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
class _PNSwitchTarget(NSObject): # type: ignore[valid-type]
|
|
908
|
-
_callback: Optional[Callable[[bool], None]] = None
|
|
909
|
-
|
|
910
|
-
@objc_method
|
|
911
|
-
def onToggle_(self, sender: object) -> None:
|
|
912
|
-
if self._callback is not None:
|
|
913
|
-
try:
|
|
914
|
-
self._callback(bool(sender.isOn()))
|
|
915
|
-
except Exception:
|
|
916
|
-
pass
|
|
917
|
-
|
|
1046
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1047
|
+
raise NotImplementedError
|
|
918
1048
|
|
|
919
|
-
|
|
1049
|
+
def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
1050
|
+
_apply_common_visual(view, props)
|
|
920
1051
|
|
|
1052
|
+
def _teardown(self, native_view: Any) -> None:
|
|
1053
|
+
"""Subclass hook for extra cleanup before the view is released."""
|
|
921
1054
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1055
|
+
def insert_child(self, parent: Any, child: Any, index: int) -> None:
|
|
1056
|
+
try:
|
|
1057
|
+
child.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
1058
|
+
except Exception:
|
|
1059
|
+
pass
|
|
1060
|
+
try:
|
|
1061
|
+
count = len(list(parent.subviews or []))
|
|
1062
|
+
except Exception:
|
|
1063
|
+
count = index
|
|
1064
|
+
try:
|
|
1065
|
+
parent.insertSubview_atIndex_(child, max(0, min(index, count)))
|
|
1066
|
+
except Exception:
|
|
928
1067
|
try:
|
|
929
|
-
|
|
1068
|
+
parent.addSubview_(child)
|
|
930
1069
|
except Exception:
|
|
931
1070
|
pass
|
|
932
1071
|
|
|
1072
|
+
def remove_child(self, parent: Any, child: Any) -> None:
|
|
1073
|
+
try:
|
|
1074
|
+
child.removeFromSuperview()
|
|
1075
|
+
except Exception:
|
|
1076
|
+
pass
|
|
933
1077
|
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
class _PNPressableTarget(NSObject): # type: ignore[valid-type]
|
|
938
|
-
@objc_method
|
|
939
|
-
def onTouchDown_(self, sender: object) -> None:
|
|
940
|
-
info = _pn_pressable_state.get(id(self))
|
|
941
|
-
if not info:
|
|
942
|
-
return
|
|
943
|
-
view = info.get("view")
|
|
944
|
-
opacity = info.get("pressed_opacity", 0.6)
|
|
945
|
-
if view is not None:
|
|
946
|
-
try:
|
|
947
|
-
UIView = ObjCClass("UIView")
|
|
948
|
-
UIView.animateWithDuration_animations_(0.05, lambda: view.setAlpha_(float(opacity)))
|
|
949
|
-
except Exception:
|
|
950
|
-
pass
|
|
951
|
-
|
|
952
|
-
@objc_method
|
|
953
|
-
def onTouchUp_(self, sender: object) -> None:
|
|
954
|
-
info = _pn_pressable_state.get(id(self))
|
|
955
|
-
if not info:
|
|
1078
|
+
def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
|
|
1079
|
+
if native_view is None:
|
|
956
1080
|
return
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1081
|
+
try:
|
|
1082
|
+
frame_x = _safe_finite(x, 0.0)
|
|
1083
|
+
frame_y = _safe_finite(y, 0.0)
|
|
1084
|
+
frame_w = max(0.0, _safe_finite(width, 0.0))
|
|
1085
|
+
frame_h = max(0.0, _safe_finite(height, 0.0))
|
|
1086
|
+
native_view.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
1087
|
+
native_view.setFrame_(((frame_x, frame_y), (frame_w, frame_h)))
|
|
1088
|
+
_clamp_view_corner_radius(native_view, frame_w, frame_h)
|
|
960
1089
|
try:
|
|
961
|
-
|
|
962
|
-
UIView.animateWithDuration_animations_(0.1, lambda: view.setAlpha_(1.0))
|
|
1090
|
+
_clamp_layer_corner_radius(native_view.layer, frame_w, frame_h)
|
|
963
1091
|
except Exception:
|
|
964
1092
|
pass
|
|
965
|
-
if cb is not None:
|
|
966
1093
|
try:
|
|
967
|
-
|
|
1094
|
+
parent = native_view.superview
|
|
1095
|
+
parent_cls = ""
|
|
1096
|
+
try:
|
|
1097
|
+
parent_cls = str(parent.objc_class.name) if parent is not None else ""
|
|
1098
|
+
except Exception:
|
|
1099
|
+
parent_cls = ""
|
|
1100
|
+
# Expand the parent UIScrollView's contentSize whenever a
|
|
1101
|
+
# child's frame extends past the visible bounds, so the
|
|
1102
|
+
# scroll view can actually scroll to reveal it.
|
|
1103
|
+
if "UIScrollView" in parent_cls:
|
|
1104
|
+
bounds = parent.bounds
|
|
1105
|
+
content_w = max(float(bounds.size.width), frame_x + frame_w)
|
|
1106
|
+
content_h = max(float(bounds.size.height), frame_y + frame_h)
|
|
1107
|
+
parent.setContentSize_((content_w, content_h))
|
|
968
1108
|
except Exception:
|
|
969
1109
|
pass
|
|
1110
|
+
except Exception:
|
|
1111
|
+
pass
|
|
970
1112
|
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1113
|
+
def measure_intrinsic(
|
|
1114
|
+
self,
|
|
1115
|
+
native_view: Any,
|
|
1116
|
+
max_width: float,
|
|
1117
|
+
max_height: float,
|
|
1118
|
+
) -> Tuple[float, float]:
|
|
1119
|
+
try:
|
|
1120
|
+
mw = _safe_max(max_width, fallback=10000.0)
|
|
1121
|
+
mh = _safe_max(max_height, fallback=10000.0)
|
|
1122
|
+
size = native_view.sizeThatFits_((mw, mh))
|
|
1123
|
+
w = float(size.width)
|
|
1124
|
+
h = float(size.height)
|
|
1125
|
+
if math.isfinite(max_width):
|
|
1126
|
+
w = min(w, max_width)
|
|
1127
|
+
return (w, h)
|
|
1128
|
+
except Exception:
|
|
1129
|
+
return (0.0, 0.0)
|
|
983
1130
|
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
if not info:
|
|
1131
|
+
def set_animated_property(self, native_view: Any, prop_name: str, value: Any) -> None:
|
|
1132
|
+
"""Apply one Python-driven animation frame immediately."""
|
|
1133
|
+
if native_view is None:
|
|
988
1134
|
return
|
|
989
|
-
# UILongPressGestureRecognizer fires on state Began (state==1).
|
|
990
1135
|
try:
|
|
991
|
-
|
|
1136
|
+
applier = _animated_applier_for(prop_name, value)
|
|
992
1137
|
except Exception:
|
|
993
|
-
state = 1
|
|
994
|
-
if state != 1:
|
|
995
1138
|
return
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1139
|
+
if applier is None:
|
|
1140
|
+
return
|
|
1141
|
+
try:
|
|
1142
|
+
applier(native_view)
|
|
1143
|
+
except Exception:
|
|
1144
|
+
pass
|
|
1145
|
+
|
|
1146
|
+
def start_animation(
|
|
1147
|
+
self,
|
|
1148
|
+
native_view: Any,
|
|
1149
|
+
anim_id: int,
|
|
1150
|
+
prop_name: str,
|
|
1151
|
+
spec: Dict[str, Any],
|
|
1152
|
+
) -> bool:
|
|
1153
|
+
"""Drive ``timing`` / ``spring`` specs with UIKit block animations.
|
|
1154
|
+
|
|
1155
|
+
``decay`` (and any unknown kind) returns ``False`` so the Python
|
|
1156
|
+
ticker integrates the exact physics. Off-main-thread starts also
|
|
1157
|
+
fall back — UIKit animation APIs are main-thread-only.
|
|
1158
|
+
"""
|
|
1159
|
+
if native_view is None or not isinstance(spec, dict):
|
|
1160
|
+
return False
|
|
1161
|
+
if str(spec.get("kind", "")) not in ("timing", "spring"):
|
|
1162
|
+
return False
|
|
1163
|
+
if not _is_main_thread():
|
|
1164
|
+
return False
|
|
1165
|
+
return _start_native_animation(native_view, anim_id, prop_name, spec)
|
|
1166
|
+
|
|
1167
|
+
def cancel_animation(self, native_view: Any, anim_id: int) -> Any:
|
|
1168
|
+
entry = _native_anims.pop(anim_id, None)
|
|
1169
|
+
if entry is None:
|
|
1170
|
+
return None
|
|
1171
|
+
view = entry.get("view")
|
|
1172
|
+
prop = str(entry.get("prop", ""))
|
|
1173
|
+
value: Any = None
|
|
1174
|
+
try:
|
|
1175
|
+
presentation = view.layer.presentationLayer()
|
|
1176
|
+
if presentation is not None and prop == "opacity":
|
|
1177
|
+
value = float(presentation.opacity)
|
|
1178
|
+
except Exception:
|
|
1179
|
+
value = None
|
|
1180
|
+
try:
|
|
1181
|
+
view.layer.removeAllAnimations()
|
|
1182
|
+
except Exception:
|
|
1183
|
+
pass
|
|
1184
|
+
return value
|
|
1002
1185
|
|
|
1003
1186
|
|
|
1004
1187
|
# ======================================================================
|
|
@@ -1014,32 +1197,11 @@ class FlexContainerHandler(IOSViewHandler):
|
|
|
1014
1197
|
[`set_frame`][pythonnative.native_views.ios.IOSViewHandler.set_frame].
|
|
1015
1198
|
"""
|
|
1016
1199
|
|
|
1017
|
-
def
|
|
1200
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1018
1201
|
v = ObjCClass("UIView").alloc().init()
|
|
1019
1202
|
v.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
1020
|
-
_apply_common_visual(v, props)
|
|
1021
1203
|
return v
|
|
1022
1204
|
|
|
1023
|
-
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
1024
|
-
_apply_common_visual(native_view, changed)
|
|
1025
|
-
|
|
1026
|
-
def add_child(self, parent: Any, child: Any) -> None:
|
|
1027
|
-
try:
|
|
1028
|
-
child.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
1029
|
-
except Exception:
|
|
1030
|
-
pass
|
|
1031
|
-
parent.addSubview_(child)
|
|
1032
|
-
|
|
1033
|
-
def remove_child(self, parent: Any, child: Any) -> None:
|
|
1034
|
-
child.removeFromSuperview()
|
|
1035
|
-
|
|
1036
|
-
def insert_child(self, parent: Any, child: Any, index: int) -> None:
|
|
1037
|
-
try:
|
|
1038
|
-
child.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
1039
|
-
except Exception:
|
|
1040
|
-
pass
|
|
1041
|
-
parent.insertSubview_atIndex_(child, index)
|
|
1042
|
-
|
|
1043
1205
|
|
|
1044
1206
|
# ======================================================================
|
|
1045
1207
|
# Leaf handlers
|
|
@@ -1047,16 +1209,12 @@ class FlexContainerHandler(IOSViewHandler):
|
|
|
1047
1209
|
|
|
1048
1210
|
|
|
1049
1211
|
class TextHandler(IOSViewHandler):
|
|
1050
|
-
def
|
|
1212
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1051
1213
|
label = ObjCClass("UILabel").alloc().init()
|
|
1052
1214
|
label.setNumberOfLines_(0)
|
|
1053
1215
|
label.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
1054
|
-
self._apply(label, props)
|
|
1055
1216
|
return label
|
|
1056
1217
|
|
|
1057
|
-
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
1058
|
-
self._apply(native_view, changed)
|
|
1059
|
-
|
|
1060
1218
|
def _font_for(self, size: float, weight: Any, family: Optional[str], italic: bool) -> Any:
|
|
1061
1219
|
"""Resolve a UIFont from family/weight/italic/size keys."""
|
|
1062
1220
|
size = float(size)
|
|
@@ -1113,23 +1271,24 @@ class TextHandler(IOSViewHandler):
|
|
|
1113
1271
|
pass
|
|
1114
1272
|
return font
|
|
1115
1273
|
|
|
1116
|
-
def _apply(self, label: Any, props: Dict[str, Any]) -> None:
|
|
1274
|
+
def _apply(self, label: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
1117
1275
|
if "text" in props:
|
|
1118
1276
|
label.setText_(str(props["text"]) if props["text"] is not None else "")
|
|
1119
1277
|
# Font requires combining size + weight + family + italic + bold.
|
|
1120
1278
|
font_keys_present = any(k in props for k in ("font_size", "font_weight", "font_family", "italic", "bold"))
|
|
1121
1279
|
if font_keys_present:
|
|
1280
|
+
merged = _state_of(label).get("props") or props
|
|
1122
1281
|
current = label.font
|
|
1123
1282
|
try:
|
|
1124
1283
|
current_size = float(current.pointSize) if current is not None else 17.0
|
|
1125
1284
|
except Exception:
|
|
1126
1285
|
current_size = 17.0
|
|
1127
|
-
size = float(
|
|
1128
|
-
weight =
|
|
1129
|
-
if weight is None and
|
|
1286
|
+
size = float(merged.get("font_size", current_size)) if merged.get("font_size") is not None else current_size
|
|
1287
|
+
weight = merged.get("font_weight")
|
|
1288
|
+
if weight is None and merged.get("bold"):
|
|
1130
1289
|
weight = "bold"
|
|
1131
|
-
family =
|
|
1132
|
-
italic = bool(
|
|
1290
|
+
family = merged.get("font_family")
|
|
1291
|
+
italic = bool(merged.get("italic"))
|
|
1133
1292
|
label.setFont_(self._font_for(size, weight, family, italic))
|
|
1134
1293
|
if "color" in props and props["color"] is not None:
|
|
1135
1294
|
label.setTextColor_(_uicolor(props["color"]))
|
|
@@ -1141,7 +1300,8 @@ class TextHandler(IOSViewHandler):
|
|
|
1141
1300
|
mapping = {"left": 0, "center": 1, "right": 2, "natural": 4, "justify": 3}
|
|
1142
1301
|
label.setTextAlignment_(mapping.get(props["text_align"], 0))
|
|
1143
1302
|
if "letter_spacing" in props or "line_height" in props or "text_decoration" in props:
|
|
1144
|
-
|
|
1303
|
+
merged = _state_of(label).get("props") or props
|
|
1304
|
+
self._apply_attributed(label, merged)
|
|
1145
1305
|
_apply_view_border(label, props)
|
|
1146
1306
|
_apply_shadow(label, props)
|
|
1147
1307
|
_apply_transform(label, props)
|
|
@@ -1199,7 +1359,7 @@ class TextHandler(IOSViewHandler):
|
|
|
1199
1359
|
|
|
1200
1360
|
|
|
1201
1361
|
class ButtonHandler(IOSViewHandler):
|
|
1202
|
-
def
|
|
1362
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1203
1363
|
# ``UIButtonTypeSystem`` (1) gives us a properly-sized button
|
|
1204
1364
|
# with intrinsicContentSize derived from the title; the default
|
|
1205
1365
|
# ``UIButtonTypeCustom`` returns CGSizeZero from sizeThatFits_,
|
|
@@ -1208,12 +1368,9 @@ class ButtonHandler(IOSViewHandler):
|
|
|
1208
1368
|
btn.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
1209
1369
|
btn.retain()
|
|
1210
1370
|
_pn_retained_views.append(btn)
|
|
1211
|
-
|
|
1371
|
+
_register_control_action(btn, 1 << 6, lambda: _fire(btn, "on_click")) # TouchUpInside
|
|
1212
1372
|
return btn
|
|
1213
1373
|
|
|
1214
|
-
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
1215
|
-
self._apply(native_view, changed)
|
|
1216
|
-
|
|
1217
1374
|
def measure_intrinsic(
|
|
1218
1375
|
self,
|
|
1219
1376
|
native_view: Any,
|
|
@@ -1232,7 +1389,7 @@ class ButtonHandler(IOSViewHandler):
|
|
|
1232
1389
|
except Exception:
|
|
1233
1390
|
return (44.0, 32.0)
|
|
1234
1391
|
|
|
1235
|
-
def _apply(self, btn: Any, props: Dict[str, Any]) -> None:
|
|
1392
|
+
def _apply(self, btn: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
1236
1393
|
if "title" in props:
|
|
1237
1394
|
btn.setTitle_forState_(str(props["title"]) if props["title"] is not None else "", 0)
|
|
1238
1395
|
if "font_size" in props and props["font_size"] is not None:
|
|
@@ -1255,15 +1412,6 @@ class ButtonHandler(IOSViewHandler):
|
|
|
1255
1412
|
btn.setAlpha_(float(props["opacity"]))
|
|
1256
1413
|
except Exception:
|
|
1257
1414
|
pass
|
|
1258
|
-
if "on_click" in props:
|
|
1259
|
-
existing = _pn_btn_handler_map.get(id(btn))
|
|
1260
|
-
if existing is not None:
|
|
1261
|
-
_pn_btn_callback_map[id(existing)] = props["on_click"]
|
|
1262
|
-
else:
|
|
1263
|
-
handler = _PNButtonTarget.new()
|
|
1264
|
-
_pn_btn_handler_map[id(btn)] = handler
|
|
1265
|
-
_pn_btn_callback_map[id(handler)] = props["on_click"]
|
|
1266
|
-
btn.addTarget_action_forControlEvents_(handler, SEL("onTap:"), 1 << 6)
|
|
1267
1415
|
|
|
1268
1416
|
|
|
1269
1417
|
# ``scrollViewDidScroll:`` hands the delegate a ``UIScrollView*``. rubicon's
|
|
@@ -1288,9 +1436,8 @@ def _scroll_did_scroll_imp(self_ptr: int, _cmd_ptr: int, _scroll_view_ptr: int)
|
|
|
1288
1436
|
info = _pn_scroll_imp_map.get(self_ptr)
|
|
1289
1437
|
if not info:
|
|
1290
1438
|
return
|
|
1291
|
-
cb = info.get("on_scroll")
|
|
1292
1439
|
sv = info.get("sv")
|
|
1293
|
-
if
|
|
1440
|
+
if sv is None:
|
|
1294
1441
|
return
|
|
1295
1442
|
try:
|
|
1296
1443
|
offset = sv.contentOffset
|
|
@@ -1298,10 +1445,7 @@ def _scroll_did_scroll_imp(self_ptr: int, _cmd_ptr: int, _scroll_view_ptr: int)
|
|
|
1298
1445
|
y = float(offset.y)
|
|
1299
1446
|
except Exception:
|
|
1300
1447
|
return
|
|
1301
|
-
|
|
1302
|
-
cb(x, y)
|
|
1303
|
-
except Exception:
|
|
1304
|
-
pass
|
|
1448
|
+
_fire(sv, "on_scroll", {"x": x, "y": y})
|
|
1305
1449
|
|
|
1306
1450
|
|
|
1307
1451
|
_scroll_imp_ref = _SCROLL_IMP_TYPE(_scroll_did_scroll_imp)
|
|
@@ -1325,36 +1469,25 @@ class ScrollViewHandler(IOSViewHandler):
|
|
|
1325
1469
|
`UIScrollView.contentSize` whenever a child frame extends beyond
|
|
1326
1470
|
the visible bounds.
|
|
1327
1471
|
|
|
1328
|
-
|
|
1329
|
-
``
|
|
1330
|
-
|
|
1472
|
+
Scroll offsets are reported as ``on_scroll`` events with a
|
|
1473
|
+
``{"x": pts, "y": pts}`` payload. Imperative commands:
|
|
1474
|
+
``scroll_to_offset`` / ``scroll_to_end`` / ``get_scroll_offset``.
|
|
1475
|
+
|
|
1476
|
+
When ``refresh_control`` is provided in props, a
|
|
1477
|
+
``UIRefreshControl`` is attached and pull-to-refresh fires the
|
|
1478
|
+
``on_refresh`` event.
|
|
1331
1479
|
"""
|
|
1332
1480
|
|
|
1333
|
-
def
|
|
1481
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1334
1482
|
sv = ObjCClass("UIScrollView").alloc().init()
|
|
1335
1483
|
sv.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
1336
|
-
|
|
1337
|
-
self._apply_refresh(sv, props)
|
|
1338
|
-
self._apply_scroll_props(sv, props)
|
|
1484
|
+
self._wire_scroll(sv)
|
|
1339
1485
|
return sv
|
|
1340
1486
|
|
|
1341
|
-
def
|
|
1342
|
-
_apply_common_visual(
|
|
1343
|
-
if "refresh_control" in
|
|
1344
|
-
self._apply_refresh(
|
|
1345
|
-
self._apply_scroll_props(native_view, changed)
|
|
1346
|
-
|
|
1347
|
-
def add_child(self, parent: Any, child: Any) -> None:
|
|
1348
|
-
try:
|
|
1349
|
-
child.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
1350
|
-
except Exception:
|
|
1351
|
-
pass
|
|
1352
|
-
parent.addSubview_(child)
|
|
1353
|
-
|
|
1354
|
-
def remove_child(self, parent: Any, child: Any) -> None:
|
|
1355
|
-
child.removeFromSuperview()
|
|
1356
|
-
|
|
1357
|
-
def _apply_scroll_props(self, sv: Any, props: Dict[str, Any]) -> None:
|
|
1487
|
+
def _apply(self, sv: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
1488
|
+
_apply_common_visual(sv, props)
|
|
1489
|
+
if "refresh_control" in props:
|
|
1490
|
+
self._apply_refresh(sv, props)
|
|
1358
1491
|
# ``shows_scroll_indicator`` is present only when False; a removed
|
|
1359
1492
|
# prop (None) restores both indicators.
|
|
1360
1493
|
if "shows_scroll_indicator" in props:
|
|
@@ -1382,34 +1515,70 @@ class ScrollViewHandler(IOSViewHandler):
|
|
|
1382
1515
|
sv.setKeyboardDismissMode_(mapping.get(props["keyboard_dismiss_mode"], 0))
|
|
1383
1516
|
except Exception:
|
|
1384
1517
|
pass
|
|
1385
|
-
if "on_scroll" in props:
|
|
1386
|
-
self._wire_scroll(sv, props["on_scroll"])
|
|
1387
1518
|
|
|
1388
|
-
def
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1519
|
+
def command(self, native_view: Any, name: str, args: Dict[str, Any]) -> Any:
|
|
1520
|
+
if name == "scroll_to_offset":
|
|
1521
|
+
x = float(args.get("x", 0.0) or 0.0)
|
|
1522
|
+
y = float(args.get("y", 0.0) or 0.0)
|
|
1523
|
+
animated = args.get("animated", True) is not False
|
|
1524
|
+
try:
|
|
1525
|
+
native_view.setContentOffset_animated_((x, y), animated)
|
|
1526
|
+
except Exception:
|
|
1527
|
+
pass
|
|
1528
|
+
return None
|
|
1529
|
+
if name == "scroll_to_end":
|
|
1530
|
+
animated = args.get("animated", True) is not False
|
|
1531
|
+
try:
|
|
1532
|
+
content = native_view.contentSize
|
|
1533
|
+
bounds = native_view.bounds
|
|
1534
|
+
target_y = max(0.0, float(content.height) - float(bounds.size.height))
|
|
1535
|
+
target_x = max(0.0, float(content.width) - float(bounds.size.width))
|
|
1536
|
+
horizontal = float(content.width) > float(bounds.size.width) and float(content.height) <= float(
|
|
1537
|
+
bounds.size.height
|
|
1538
|
+
)
|
|
1539
|
+
offset = (target_x, 0.0) if horizontal else (0.0, target_y)
|
|
1540
|
+
native_view.setContentOffset_animated_(offset, animated)
|
|
1541
|
+
except Exception:
|
|
1542
|
+
pass
|
|
1543
|
+
return None
|
|
1544
|
+
if name == "get_scroll_offset":
|
|
1545
|
+
try:
|
|
1546
|
+
offset = native_view.contentOffset
|
|
1547
|
+
return {"x": float(offset.x), "y": float(offset.y)}
|
|
1548
|
+
except Exception:
|
|
1549
|
+
return {"x": 0.0, "y": 0.0}
|
|
1550
|
+
return None
|
|
1551
|
+
|
|
1552
|
+
def _wire_scroll(self, sv: Any) -> None:
|
|
1553
|
+
if not _PN_SCROLL_DELEGATE_CLS:
|
|
1554
|
+
return
|
|
1555
|
+
_objc_msgSend.restype = _ct.c_void_p
|
|
1556
|
+
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
|
|
1557
|
+
d = _objc_msgSend(_PN_SCROLL_DELEGATE_CLS, _SEL_ALLOC)
|
|
1558
|
+
d = _objc_msgSend(d, _SEL_INIT)
|
|
1559
|
+
d = _objc_msgSend(d, _SEL_RETAIN)
|
|
1560
|
+
delegate_ptr = int(d)
|
|
1561
|
+
_pn_scroll_imp_map[delegate_ptr] = {"sv": sv}
|
|
1562
|
+
_objc_msgSend.restype = None
|
|
1563
|
+
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
|
|
1564
|
+
sv_ptr = sv.ptr if hasattr(sv, "ptr") else sv
|
|
1565
|
+
_objc_msgSend(sv_ptr, _SEL_SET_DELEGATE, _ct.c_void_p(delegate_ptr))
|
|
1409
1566
|
|
|
1410
1567
|
def _apply_refresh(self, sv: Any, props: Dict[str, Any]) -> None:
|
|
1411
1568
|
spec = props.get("refresh_control")
|
|
1412
1569
|
if not spec:
|
|
1570
|
+
# Prop removed (screen reuse can recycle this scroll view
|
|
1571
|
+
# for a refresh-less screen): detach so no phantom pull
|
|
1572
|
+
# gesture survives.
|
|
1573
|
+
try:
|
|
1574
|
+
existing = sv.refreshControl
|
|
1575
|
+
if existing is not None:
|
|
1576
|
+
existing.endRefreshing()
|
|
1577
|
+
_pn_action_handlers.pop(_recognizer_ptr(existing), None)
|
|
1578
|
+
sv.setRefreshControl_(None)
|
|
1579
|
+
sv.setAlwaysBounceVertical_(False)
|
|
1580
|
+
except Exception:
|
|
1581
|
+
pass
|
|
1413
1582
|
return
|
|
1414
1583
|
try:
|
|
1415
1584
|
existing = sv.refreshControl
|
|
@@ -1418,16 +1587,16 @@ class ScrollViewHandler(IOSViewHandler):
|
|
|
1418
1587
|
rc.retain()
|
|
1419
1588
|
_pn_retained_views.append(rc)
|
|
1420
1589
|
sv.setRefreshControl_(rc)
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1590
|
+
_register_control_action(rc, 1 << 12, lambda: _fire(sv, "on_refresh")) # ValueChanged
|
|
1591
|
+
# Without this, a scroll view whose content fits its
|
|
1592
|
+
# bounds never engages the pan gesture, making the
|
|
1593
|
+
# refresh control unreachable by a pull (RN's ScrollView
|
|
1594
|
+
# bounces vertically by default for the same reason).
|
|
1595
|
+
try:
|
|
1596
|
+
sv.setAlwaysBounceVertical_(True)
|
|
1597
|
+
except Exception:
|
|
1598
|
+
pass
|
|
1426
1599
|
existing = rc
|
|
1427
|
-
cb = spec.get("on_refresh") if isinstance(spec, dict) else None
|
|
1428
|
-
target = _pn_btn_handler_map.get(id(existing))
|
|
1429
|
-
if target is not None and cb is not None:
|
|
1430
|
-
_pn_btn_callback_map[id(target)] = cb
|
|
1431
1600
|
refreshing = bool(spec.get("refreshing")) if isinstance(spec, dict) else False
|
|
1432
1601
|
if refreshing:
|
|
1433
1602
|
existing.beginRefreshing()
|
|
@@ -1437,101 +1606,377 @@ class ScrollViewHandler(IOSViewHandler):
|
|
|
1437
1606
|
pass
|
|
1438
1607
|
|
|
1439
1608
|
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
# the symbol (e.g. ``UITextContentTypeOneTimeCode`` == ``"one-time-code"``),
|
|
1443
|
-
# so we read the real constants out of UIKit via ``objc_const`` instead of
|
|
1444
|
-
# hardcoding their string values.
|
|
1445
|
-
_TEXT_CONTENT_TYPE_SYMBOLS = {
|
|
1446
|
-
"username": "UITextContentTypeUsername",
|
|
1447
|
-
"password": "UITextContentTypePassword",
|
|
1448
|
-
"new_password": "UITextContentTypeNewPassword",
|
|
1449
|
-
"one_time_code": "UITextContentTypeOneTimeCode",
|
|
1450
|
-
"email": "UITextContentTypeEmailAddress",
|
|
1451
|
-
"email_address": "UITextContentTypeEmailAddress",
|
|
1452
|
-
"name": "UITextContentTypeName",
|
|
1453
|
-
"url": "UITextContentTypeURL",
|
|
1454
|
-
"telephone": "UITextContentTypeTelephoneNumber",
|
|
1455
|
-
"telephone_number": "UITextContentTypeTelephoneNumber",
|
|
1456
|
-
"phone": "UITextContentTypeTelephoneNumber",
|
|
1457
|
-
"phone_number": "UITextContentTypeTelephoneNumber",
|
|
1458
|
-
}
|
|
1459
|
-
_pn_text_content_type_cache: Dict[str, Any] = {}
|
|
1609
|
+
class ImageHandler(IOSViewHandler):
|
|
1610
|
+
"""`UIImageView` with async URL loading via NSURLSession."""
|
|
1460
1611
|
|
|
1612
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1613
|
+
iv = ObjCClass("UIImageView").alloc().init()
|
|
1614
|
+
iv.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
1615
|
+
iv.setClipsToBounds_(True)
|
|
1616
|
+
iv.setContentMode_(1) # ScaleAspectFit
|
|
1617
|
+
return iv
|
|
1461
1618
|
|
|
1462
|
-
def
|
|
1463
|
-
|
|
1619
|
+
def _apply(self, iv: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
1620
|
+
if "tint_color" in props and props["tint_color"] is not None:
|
|
1621
|
+
try:
|
|
1622
|
+
iv.setTintColor_(_uicolor(props["tint_color"]))
|
|
1623
|
+
except Exception:
|
|
1624
|
+
pass
|
|
1625
|
+
if "source" in props and props["source"]:
|
|
1626
|
+
self._load_source(iv, str(props["source"]))
|
|
1627
|
+
if "scale_type" in props and props["scale_type"]:
|
|
1628
|
+
# UIViewContentMode: ScaleToFill=0, ScaleAspectFit=1,
|
|
1629
|
+
# ScaleAspectFill=2, Center=4.
|
|
1630
|
+
mapping = {"cover": 2, "contain": 1, "stretch": 0, "center": 4}
|
|
1631
|
+
iv.setContentMode_(mapping.get(props["scale_type"], 1))
|
|
1632
|
+
_apply_common_visual(iv, props)
|
|
1464
1633
|
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1634
|
+
def measure_intrinsic(
|
|
1635
|
+
self,
|
|
1636
|
+
native_view: Any,
|
|
1637
|
+
max_width: float,
|
|
1638
|
+
max_height: float,
|
|
1639
|
+
) -> Tuple[float, float]:
|
|
1640
|
+
try:
|
|
1641
|
+
img = native_view.image
|
|
1642
|
+
if img is not None:
|
|
1643
|
+
size = img.size
|
|
1644
|
+
w, h = float(size.width), float(size.height)
|
|
1645
|
+
if math.isfinite(max_width) and w > max_width > 0:
|
|
1646
|
+
scale = max_width / w
|
|
1647
|
+
w, h = max_width, h * scale
|
|
1648
|
+
return (w, h)
|
|
1649
|
+
except Exception:
|
|
1650
|
+
pass
|
|
1651
|
+
return (0.0, 0.0)
|
|
1652
|
+
|
|
1653
|
+
def _load_source(self, iv: Any, source: str) -> None:
|
|
1654
|
+
try:
|
|
1655
|
+
if source.startswith(("http://", "https://")):
|
|
1656
|
+
self._load_async(iv, source)
|
|
1657
|
+
else:
|
|
1658
|
+
# Bundle resource or absolute file path.
|
|
1659
|
+
UIImage = ObjCClass("UIImage")
|
|
1660
|
+
image = UIImage.imageNamed_(source)
|
|
1661
|
+
if image is None:
|
|
1662
|
+
image = UIImage.imageWithContentsOfFile_(source)
|
|
1663
|
+
if image:
|
|
1664
|
+
iv.setImage_(image)
|
|
1665
|
+
except Exception:
|
|
1666
|
+
pass
|
|
1667
|
+
|
|
1668
|
+
def _load_async(self, iv: Any, source: str) -> None:
|
|
1669
|
+
"""Asynchronously load a remote image off the main thread.
|
|
1670
|
+
|
|
1671
|
+
Uses ``NSURLSession.sharedSession.dataTaskWithURL:completionHandler:``
|
|
1672
|
+
so the main thread is never blocked. The completion handler
|
|
1673
|
+
runs on a background queue; the image is set back on the main
|
|
1674
|
+
queue so UIKit accepts it without threading warnings. The
|
|
1675
|
+
latest requested URI wins if several loads race.
|
|
1676
|
+
"""
|
|
1677
|
+
state = _state_of(iv)
|
|
1678
|
+
state["pending_uri"] = source
|
|
1679
|
+
try:
|
|
1680
|
+
iv.retain()
|
|
1681
|
+
_pn_retained_views.append(iv)
|
|
1682
|
+
NSURL = ObjCClass("NSURL")
|
|
1683
|
+
NSURLSession = ObjCClass("NSURLSession")
|
|
1684
|
+
UIImage = ObjCClass("UIImage")
|
|
1685
|
+
url = NSURL.URLWithString_(source)
|
|
1686
|
+
session = NSURLSession.sharedSession
|
|
1687
|
+
|
|
1688
|
+
def completion(data: Any, response: Any, error: Any) -> None:
|
|
1689
|
+
if error is not None or data is None:
|
|
1690
|
+
return
|
|
1691
|
+
try:
|
|
1692
|
+
image = UIImage.imageWithData_(data)
|
|
1693
|
+
if image is None:
|
|
1694
|
+
return
|
|
1695
|
+
|
|
1696
|
+
def apply() -> None:
|
|
1697
|
+
try:
|
|
1698
|
+
if _state_of(iv).get("pending_uri") == source:
|
|
1699
|
+
iv.setImage_(image)
|
|
1700
|
+
except Exception:
|
|
1701
|
+
pass
|
|
1702
|
+
|
|
1703
|
+
from ..runtime import call_on_main_thread
|
|
1704
|
+
|
|
1705
|
+
call_on_main_thread(apply)
|
|
1706
|
+
except Exception:
|
|
1707
|
+
pass
|
|
1708
|
+
|
|
1709
|
+
task = session.dataTaskWithURL_completionHandler_(url, completion)
|
|
1710
|
+
task.resume()
|
|
1711
|
+
except Exception:
|
|
1712
|
+
pass
|
|
1713
|
+
|
|
1714
|
+
|
|
1715
|
+
# ----------------------------------------------------------------------
|
|
1716
|
+
# TextInput — raw libobjc target/delegate
|
|
1717
|
+
# ----------------------------------------------------------------------
|
|
1718
|
+
#
|
|
1719
|
+
# UITextField control events and UITextField/UITextView delegate
|
|
1720
|
+
# callbacks all pass ObjC object arguments, which rubicon's
|
|
1721
|
+
# ``@objc_method`` trampoline handles unreliably on arm64 (see the
|
|
1722
|
+
# module header). The change/submit/focus/blur path is therefore built
|
|
1723
|
+
# on a raw ``PNTextFieldActionTarget`` class registered with libobjc,
|
|
1724
|
+
# exactly like the scroll and tab-bar delegates.
|
|
1725
|
+
#
|
|
1726
|
+
# One target instance is allocated per input view; ``_pn_tf_target_map``
|
|
1727
|
+
# maps the target's raw pointer (the ``self`` of every IMP) back to the
|
|
1728
|
+
# owning input view so the IMPs can consult per-view state (suppress
|
|
1729
|
+
# flag, max_length) and fire through the tag-based event channel.
|
|
1730
|
+
|
|
1731
|
+
_pn_tf_target_map: Dict[int, Any] = {}
|
|
1732
|
+
_PN_TEXTFIELD_TARGET_CLS: Optional[int] = None
|
|
1733
|
+
_textfield_imp_refs: List[Any] = []
|
|
1734
|
+
|
|
1735
|
+
|
|
1736
|
+
def _input_text(view_ptr: int) -> str:
|
|
1737
|
+
"""Read ``text`` from a UITextField/UITextView via raw objc_msgSend."""
|
|
1738
|
+
if not view_ptr:
|
|
1739
|
+
return ""
|
|
1475
1740
|
try:
|
|
1476
|
-
|
|
1741
|
+
_objc_msgSend.restype = _ct.c_void_p
|
|
1742
|
+
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
|
|
1743
|
+
nsstring_ptr = _objc_msgSend(_ct.c_void_p(view_ptr), _SEL_TEXT)
|
|
1744
|
+
if not nsstring_ptr:
|
|
1745
|
+
return ""
|
|
1746
|
+
_objc_msgSend.restype = _ct.c_char_p
|
|
1747
|
+
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
|
|
1748
|
+
raw = _objc_msgSend(_ct.c_void_p(nsstring_ptr), _SEL_UTF8STRING)
|
|
1749
|
+
if not raw:
|
|
1750
|
+
return ""
|
|
1751
|
+
return raw.decode("utf-8", errors="replace")
|
|
1752
|
+
except Exception:
|
|
1753
|
+
return ""
|
|
1477
1754
|
|
|
1478
|
-
|
|
1479
|
-
|
|
1755
|
+
|
|
1756
|
+
def _input_state_for_target(self_ptr: int) -> Tuple[Optional[Any], Dict[str, Any]]:
|
|
1757
|
+
view = _pn_tf_target_map.get(int(self_ptr))
|
|
1758
|
+
if view is None:
|
|
1759
|
+
return None, {}
|
|
1760
|
+
return view, _state_of(view)
|
|
1761
|
+
|
|
1762
|
+
|
|
1763
|
+
def _tf_on_change_imp(self_ptr: int, _cmd: int, sender_ptr: int) -> None:
|
|
1764
|
+
view, state = _input_state_for_target(self_ptr)
|
|
1765
|
+
if view is None or state.get("suppress"):
|
|
1766
|
+
return
|
|
1767
|
+
text = _input_text(int(sender_ptr or 0))
|
|
1768
|
+
max_length = state.get("max_length")
|
|
1769
|
+
if isinstance(max_length, int) and max_length >= 0 and len(text) > max_length:
|
|
1770
|
+
text = text[:max_length]
|
|
1771
|
+
state["suppress"] = True
|
|
1772
|
+
try:
|
|
1773
|
+
view.setText_(text)
|
|
1774
|
+
except Exception:
|
|
1775
|
+
pass
|
|
1776
|
+
finally:
|
|
1777
|
+
state["suppress"] = False
|
|
1778
|
+
_fire(view, "on_change", text)
|
|
1779
|
+
|
|
1780
|
+
|
|
1781
|
+
def _tf_on_submit_imp(self_ptr: int, _cmd: int, sender_ptr: int) -> None:
|
|
1782
|
+
view, state = _input_state_for_target(self_ptr)
|
|
1783
|
+
if view is None:
|
|
1784
|
+
return
|
|
1785
|
+
_fire(view, "on_submit", _input_text(int(sender_ptr or 0)))
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
def _tf_should_return_imp(self_ptr: int, _cmd: int, tf_ptr: int) -> bool:
|
|
1789
|
+
"""``textFieldShouldReturn:`` — dismiss the keyboard on Return.
|
|
1790
|
+
|
|
1791
|
+
iOS doesn't dismiss the keyboard on Return by default; the standard
|
|
1792
|
+
pattern is for the delegate to resign first responder and return
|
|
1793
|
+
YES, matching React Native's TextInput behavior.
|
|
1794
|
+
"""
|
|
1795
|
+
try:
|
|
1796
|
+
_objc_msgSend.restype = None
|
|
1797
|
+
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
|
|
1798
|
+
_objc_msgSend(_ct.c_void_p(int(tf_ptr or 0)), _SEL_RESIGN_FIRST_RESPONDER)
|
|
1480
1799
|
except Exception:
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1800
|
+
pass
|
|
1801
|
+
return True
|
|
1802
|
+
|
|
1803
|
+
|
|
1804
|
+
def _tf_did_begin_imp(self_ptr: int, _cmd: int, sender_ptr: int) -> None:
|
|
1805
|
+
view, _state = _input_state_for_target(self_ptr)
|
|
1806
|
+
if view is not None:
|
|
1807
|
+
_fire(view, "on_focus")
|
|
1808
|
+
|
|
1809
|
+
|
|
1810
|
+
def _tf_did_end_imp(self_ptr: int, _cmd: int, sender_ptr: int) -> None:
|
|
1811
|
+
view, _state = _input_state_for_target(self_ptr)
|
|
1812
|
+
if view is not None:
|
|
1813
|
+
_fire(view, "on_blur")
|
|
1814
|
+
|
|
1815
|
+
|
|
1816
|
+
def _ensure_textfield_target_class() -> Optional[int]:
|
|
1817
|
+
global _PN_TEXTFIELD_TARGET_CLS
|
|
1818
|
+
if _PN_TEXTFIELD_TARGET_CLS is not None:
|
|
1819
|
+
return _PN_TEXTFIELD_TARGET_CLS
|
|
1820
|
+
existing = _get_cls(b"PNTextFieldActionTarget")
|
|
1821
|
+
if existing:
|
|
1822
|
+
_PN_TEXTFIELD_TARGET_CLS = int(existing)
|
|
1823
|
+
return _PN_TEXTFIELD_TARGET_CLS
|
|
1824
|
+
cls = _alloc_cls(_NS_OBJECT_CLS, b"PNTextFieldActionTarget", 0)
|
|
1825
|
+
if not cls:
|
|
1826
|
+
return None
|
|
1827
|
+
action_type = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
|
|
1828
|
+
bool_type = _ct.CFUNCTYPE(_ct.c_bool, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
|
|
1829
|
+
change_imp = action_type(_tf_on_change_imp)
|
|
1830
|
+
submit_imp = action_type(_tf_on_submit_imp)
|
|
1831
|
+
should_return_imp = bool_type(_tf_should_return_imp)
|
|
1832
|
+
begin_imp = action_type(_tf_did_begin_imp)
|
|
1833
|
+
end_imp = action_type(_tf_did_end_imp)
|
|
1834
|
+
# CFUNCTYPE objects must outlive the registered class.
|
|
1835
|
+
_textfield_imp_refs.extend([change_imp, submit_imp, should_return_imp, begin_imp, end_imp])
|
|
1836
|
+
_add_method(cls, _SEL_ON_EDIT, _ct.cast(change_imp, _ct.c_void_p), b"v@:@")
|
|
1837
|
+
_add_method(cls, _SEL_ON_SUBMIT, _ct.cast(submit_imp, _ct.c_void_p), b"v@:@")
|
|
1838
|
+
_add_method(cls, _SEL_TEXT_FIELD_SHOULD_RETURN, _ct.cast(should_return_imp, _ct.c_void_p), b"c@:@")
|
|
1839
|
+
# ``textFieldDidBeginEditing:`` / ``textViewDidBeginEditing:`` share
|
|
1840
|
+
# the focus IMP; the end-editing pair shares the blur IMP, and
|
|
1841
|
+
# ``textViewDidChange:`` shares the change IMP. The same target is
|
|
1842
|
+
# wired as both UITextFieldDelegate and UITextViewDelegate so every
|
|
1843
|
+
# event works for single- and multi-line inputs alike.
|
|
1844
|
+
_add_method(cls, _SEL_TEXT_FIELD_DID_BEGIN, _ct.cast(begin_imp, _ct.c_void_p), b"v@:@")
|
|
1845
|
+
_add_method(cls, _SEL_TEXT_FIELD_DID_END, _ct.cast(end_imp, _ct.c_void_p), b"v@:@")
|
|
1846
|
+
_add_method(cls, _SEL_TEXT_VIEW_DID_BEGIN, _ct.cast(begin_imp, _ct.c_void_p), b"v@:@")
|
|
1847
|
+
_add_method(cls, _SEL_TEXT_VIEW_DID_END, _ct.cast(end_imp, _ct.c_void_p), b"v@:@")
|
|
1848
|
+
_add_method(cls, _SEL_TEXT_VIEW_DID_CHANGE, _ct.cast(change_imp, _ct.c_void_p), b"v@:@")
|
|
1849
|
+
_reg_cls(cls)
|
|
1850
|
+
_PN_TEXTFIELD_TARGET_CLS = int(cls)
|
|
1851
|
+
return _PN_TEXTFIELD_TARGET_CLS
|
|
1852
|
+
|
|
1853
|
+
|
|
1854
|
+
def _attach_input_target(view: Any, *, is_field: bool) -> None:
|
|
1855
|
+
"""Allocate the raw target and wire control events + delegate."""
|
|
1856
|
+
cls = _ensure_textfield_target_class()
|
|
1857
|
+
view_ptr = _objc_ptr(view)
|
|
1858
|
+
if not cls or not view_ptr:
|
|
1859
|
+
return
|
|
1860
|
+
_objc_msgSend.restype = _ct.c_void_p
|
|
1861
|
+
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
|
|
1862
|
+
raw = _objc_msgSend(_ct.c_void_p(cls), _SEL_ALLOC)
|
|
1863
|
+
raw = _objc_msgSend(_ct.c_void_p(raw), _SEL_INIT)
|
|
1864
|
+
raw = _objc_msgSend(_ct.c_void_p(raw), _SEL_RETAIN)
|
|
1865
|
+
if not raw:
|
|
1866
|
+
return
|
|
1867
|
+
target_ptr = int(raw)
|
|
1868
|
+
_pn_tf_target_map[target_ptr] = view
|
|
1869
|
+
_pn_retained_views.append(target_ptr)
|
|
1870
|
+
_state_of(view)["tf_target_ptr"] = target_ptr
|
|
1871
|
+
if is_field:
|
|
1872
|
+
_objc_msgSend.restype = None
|
|
1873
|
+
_objc_msgSend.argtypes = [
|
|
1874
|
+
_ct.c_void_p,
|
|
1875
|
+
_ct.c_void_p,
|
|
1876
|
+
_ct.c_void_p,
|
|
1877
|
+
_ct.c_void_p,
|
|
1878
|
+
_ct.c_ulong,
|
|
1879
|
+
]
|
|
1880
|
+
# UIControlEventEditingChanged / UIControlEventEditingDidEndOnExit.
|
|
1881
|
+
_objc_msgSend(
|
|
1882
|
+
_ct.c_void_p(view_ptr),
|
|
1883
|
+
_SEL_ADD_TARGET_ACTION_EVENTS,
|
|
1884
|
+
_ct.c_void_p(target_ptr),
|
|
1885
|
+
_SEL_ON_EDIT,
|
|
1886
|
+
1 << 17,
|
|
1887
|
+
)
|
|
1888
|
+
_objc_msgSend(
|
|
1889
|
+
_ct.c_void_p(view_ptr),
|
|
1890
|
+
_SEL_ADD_TARGET_ACTION_EVENTS,
|
|
1891
|
+
_ct.c_void_p(target_ptr),
|
|
1892
|
+
_SEL_ON_SUBMIT,
|
|
1893
|
+
1 << 19,
|
|
1894
|
+
)
|
|
1895
|
+
# The delegate carries shouldReturn / focus / blur for UITextField
|
|
1896
|
+
# and change / focus / blur for UITextView.
|
|
1897
|
+
_objc_msgSend.restype = None
|
|
1898
|
+
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
|
|
1899
|
+
_objc_msgSend(_ct.c_void_p(view_ptr), _SEL_SET_DELEGATE, _ct.c_void_p(target_ptr))
|
|
1484
1900
|
|
|
1485
1901
|
|
|
1486
1902
|
class TextInputHandler(IOSViewHandler):
|
|
1487
|
-
|
|
1903
|
+
"""Single-line `UITextField` or multiline `UITextView`.
|
|
1904
|
+
|
|
1905
|
+
The view class is chosen at creation time from the ``multiline``
|
|
1906
|
+
prop. Programmatic ``value`` updates set a suppress flag so the
|
|
1907
|
+
change events do not echo back into ``on_change``.
|
|
1908
|
+
"""
|
|
1909
|
+
|
|
1910
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1488
1911
|
if props.get("multiline"):
|
|
1489
1912
|
tv = ObjCClass("UITextView").alloc().init()
|
|
1490
1913
|
tv.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
1914
|
+
tv.setFont_(UIFont.systemFontOfSize_(17.0))
|
|
1491
1915
|
tv.setBackgroundColor_(_uicolor("#FFFFFF"))
|
|
1492
|
-
self._apply_textview(tv, props)
|
|
1493
1916
|
return tv
|
|
1494
1917
|
tf = ObjCClass("UITextField").alloc().init()
|
|
1495
|
-
tf.setBorderStyle_(2) # RoundedRect
|
|
1496
1918
|
tf.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
1497
|
-
|
|
1919
|
+
tf.setBorderStyle_(3) # UITextBorderStyleRoundedRect
|
|
1498
1920
|
return tf
|
|
1499
1921
|
|
|
1500
|
-
def
|
|
1501
|
-
|
|
1922
|
+
def create(self, tag: int, props: Dict[str, Any]) -> Any:
|
|
1923
|
+
view = super().create(tag, props)
|
|
1924
|
+
view.retain()
|
|
1925
|
+
_pn_retained_views.append(view)
|
|
1926
|
+
_attach_input_target(view, is_field=not props.get("multiline"))
|
|
1927
|
+
return view
|
|
1928
|
+
|
|
1929
|
+
def _teardown(self, native_view: Any) -> None:
|
|
1930
|
+
target_ptr = _state_of(native_view).get("tf_target_ptr")
|
|
1931
|
+
if target_ptr is not None:
|
|
1932
|
+
_pn_tf_target_map.pop(int(target_ptr), None)
|
|
1933
|
+
|
|
1934
|
+
def _is_field(self, view: Any) -> bool:
|
|
1502
1935
|
try:
|
|
1503
|
-
|
|
1936
|
+
return bool(view.isKindOfClass_(ObjCClass("UITextField")))
|
|
1504
1937
|
except Exception:
|
|
1505
|
-
|
|
1506
|
-
if "UITextView" in cls_name:
|
|
1507
|
-
self._apply_textview(native_view, changed)
|
|
1508
|
-
else:
|
|
1509
|
-
self._apply_textfield(native_view, changed)
|
|
1938
|
+
return True
|
|
1510
1939
|
|
|
1511
|
-
def
|
|
1940
|
+
def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
1941
|
+
state = _state_of(view)
|
|
1942
|
+
is_field = self._is_field(view)
|
|
1943
|
+
if "max_length" in props:
|
|
1944
|
+
state["max_length"] = props["max_length"] if props["max_length"] is None else int(props["max_length"])
|
|
1945
|
+
if "value" in props and props["value"] is not None:
|
|
1946
|
+
current = str(view.text) if view.text is not None else ""
|
|
1947
|
+
new = str(props["value"])
|
|
1948
|
+
if current != new:
|
|
1949
|
+
state["suppress"] = True
|
|
1950
|
+
try:
|
|
1951
|
+
view.setText_(new)
|
|
1952
|
+
finally:
|
|
1953
|
+
state["suppress"] = False
|
|
1954
|
+
if "placeholder" in props and is_field:
|
|
1955
|
+
view.setPlaceholder_(str(props["placeholder"]) if props["placeholder"] is not None else "")
|
|
1956
|
+
if "placeholder_color" in props and props["placeholder_color"] is not None and is_field:
|
|
1957
|
+
try:
|
|
1958
|
+
NSAttributedString = ObjCClass("NSAttributedString")
|
|
1959
|
+
merged = state.get("props") or props
|
|
1960
|
+
p = str(merged.get("placeholder", "") or "")
|
|
1961
|
+
attr = NSAttributedString.alloc().initWithString_attributes_(
|
|
1962
|
+
p,
|
|
1963
|
+
{"NSColor": _uicolor(props["placeholder_color"])},
|
|
1964
|
+
)
|
|
1965
|
+
view.setAttributedPlaceholder_(attr)
|
|
1966
|
+
except Exception:
|
|
1967
|
+
pass
|
|
1512
1968
|
if "font_size" in props and props["font_size"] is not None:
|
|
1513
1969
|
view.setFont_(UIFont.systemFontOfSize_(float(props["font_size"])))
|
|
1514
1970
|
if "color" in props and props["color"] is not None:
|
|
1515
1971
|
view.setTextColor_(_uicolor(props["color"]))
|
|
1516
1972
|
if "background_color" in props and props["background_color"] is not None:
|
|
1517
1973
|
view.setBackgroundColor_(_uicolor(props["background_color"]))
|
|
1518
|
-
if "secure" in props
|
|
1519
|
-
try:
|
|
1520
|
-
view.setSecureTextEntry_(True)
|
|
1521
|
-
except Exception:
|
|
1522
|
-
pass
|
|
1523
|
-
if "auto_capitalize" in props:
|
|
1524
|
-
mapping = {"none": 0, "words": 1, "sentences": 2, "characters": 3}
|
|
1525
|
-
try:
|
|
1526
|
-
view.setAutocapitalizationType_(mapping.get(props["auto_capitalize"], 2))
|
|
1527
|
-
except Exception:
|
|
1528
|
-
pass
|
|
1529
|
-
if "auto_correct" in props:
|
|
1974
|
+
if "secure" in props:
|
|
1530
1975
|
try:
|
|
1531
|
-
view.
|
|
1976
|
+
view.setSecureTextEntry_(bool(props["secure"]))
|
|
1532
1977
|
except Exception:
|
|
1533
1978
|
pass
|
|
1534
|
-
if "keyboard_type" in props:
|
|
1979
|
+
if "keyboard_type" in props and props["keyboard_type"] is not None:
|
|
1535
1980
|
mapping = {
|
|
1536
1981
|
"default": 0,
|
|
1537
1982
|
"ascii": 1,
|
|
@@ -1546,7 +1991,19 @@ class TextInputHandler(IOSViewHandler):
|
|
|
1546
1991
|
view.setKeyboardType_(mapping.get(props["keyboard_type"], 0))
|
|
1547
1992
|
except Exception:
|
|
1548
1993
|
pass
|
|
1549
|
-
if "
|
|
1994
|
+
if "auto_capitalize" in props and props["auto_capitalize"] is not None:
|
|
1995
|
+
# UITextAutocapitalizationType: none=0, words=1, sentences=2, all=3.
|
|
1996
|
+
mapping = {"none": 0, "words": 1, "sentences": 2, "characters": 3}
|
|
1997
|
+
try:
|
|
1998
|
+
view.setAutocapitalizationType_(mapping.get(props["auto_capitalize"], 2))
|
|
1999
|
+
except Exception:
|
|
2000
|
+
pass
|
|
2001
|
+
if "auto_correct" in props:
|
|
2002
|
+
try:
|
|
2003
|
+
view.setAutocorrectionType_(1 if props["auto_correct"] else 0)
|
|
2004
|
+
except Exception:
|
|
2005
|
+
pass
|
|
2006
|
+
if "return_key_type" in props and props["return_key_type"] is not None:
|
|
1550
2007
|
mapping = {
|
|
1551
2008
|
"default": 0,
|
|
1552
2009
|
"go": 1,
|
|
@@ -1574,6 +2031,28 @@ class TextInputHandler(IOSViewHandler):
|
|
|
1574
2031
|
view.setTextContentType_(_ui_text_content_type(str(tct)) if tct is not None else None)
|
|
1575
2032
|
except Exception:
|
|
1576
2033
|
pass
|
|
2034
|
+
if "clear_button" in props and is_field:
|
|
2035
|
+
try:
|
|
2036
|
+
view.setClearButtonMode_(1 if props["clear_button"] else 0) # 1 = WhileEditing
|
|
2037
|
+
except Exception:
|
|
2038
|
+
pass
|
|
2039
|
+
# ``editable`` is present only when False (read-only). A removed
|
|
2040
|
+
# prop arrives as None on update, which means "editable again".
|
|
2041
|
+
if "editable" in props:
|
|
2042
|
+
editable = props["editable"]
|
|
2043
|
+
resolved = True if editable is None else bool(editable)
|
|
2044
|
+
try:
|
|
2045
|
+
if is_field:
|
|
2046
|
+
view.setEnabled_(resolved)
|
|
2047
|
+
else:
|
|
2048
|
+
view.setEditable_(resolved)
|
|
2049
|
+
except Exception:
|
|
2050
|
+
pass
|
|
2051
|
+
if "auto_focus" in props and props["auto_focus"]:
|
|
2052
|
+
try:
|
|
2053
|
+
view.becomeFirstResponder()
|
|
2054
|
+
except Exception:
|
|
2055
|
+
pass
|
|
1577
2056
|
_apply_view_border(view, props)
|
|
1578
2057
|
_apply_shadow(view, props)
|
|
1579
2058
|
_apply_transform(view, props)
|
|
@@ -1584,197 +2063,312 @@ class TextInputHandler(IOSViewHandler):
|
|
|
1584
2063
|
except Exception:
|
|
1585
2064
|
pass
|
|
1586
2065
|
|
|
1587
|
-
def
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
2066
|
+
def measure_intrinsic(
|
|
2067
|
+
self,
|
|
2068
|
+
native_view: Any,
|
|
2069
|
+
max_width: float,
|
|
2070
|
+
max_height: float,
|
|
2071
|
+
) -> Tuple[float, float]:
|
|
2072
|
+
w, h = super().measure_intrinsic(native_view, max_width, max_height)
|
|
2073
|
+
return (max(w, 100.0), max(h, 36.0))
|
|
2074
|
+
|
|
2075
|
+
def command(self, native_view: Any, name: str, args: Dict[str, Any]) -> Any:
|
|
2076
|
+
if name == "focus":
|
|
1593
2077
|
try:
|
|
1594
|
-
|
|
1595
|
-
p = str(props.get("placeholder", "") or "")
|
|
1596
|
-
attr = NSAttributedString.alloc().initWithString_attributes_(
|
|
1597
|
-
p,
|
|
1598
|
-
{"NSColor": _uicolor(props["placeholder_color"])},
|
|
1599
|
-
)
|
|
1600
|
-
tf.setAttributedPlaceholder_(attr)
|
|
2078
|
+
native_view.becomeFirstResponder()
|
|
1601
2079
|
except Exception:
|
|
1602
2080
|
pass
|
|
1603
|
-
|
|
2081
|
+
return None
|
|
2082
|
+
if name == "blur":
|
|
1604
2083
|
try:
|
|
1605
|
-
|
|
2084
|
+
native_view.resignFirstResponder()
|
|
1606
2085
|
except Exception:
|
|
1607
2086
|
pass
|
|
1608
|
-
|
|
2087
|
+
return None
|
|
2088
|
+
if name == "clear":
|
|
2089
|
+
state = _state_of(native_view)
|
|
2090
|
+
state["suppress"] = True
|
|
2091
|
+
try:
|
|
2092
|
+
native_view.setText_("")
|
|
2093
|
+
finally:
|
|
2094
|
+
state["suppress"] = False
|
|
2095
|
+
return None
|
|
2096
|
+
if name == "get_value":
|
|
1609
2097
|
try:
|
|
1610
|
-
|
|
2098
|
+
return str(native_view.text) if native_view.text is not None else ""
|
|
1611
2099
|
except Exception:
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
2100
|
+
return ""
|
|
2101
|
+
return None
|
|
2102
|
+
|
|
2103
|
+
|
|
2104
|
+
class SwitchHandler(IOSViewHandler):
|
|
2105
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
2106
|
+
sw = ObjCClass("UISwitch").alloc().init()
|
|
2107
|
+
sw.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
2108
|
+
sw.retain()
|
|
2109
|
+
_pn_retained_views.append(sw)
|
|
2110
|
+
|
|
2111
|
+
def _on_toggle() -> None:
|
|
2112
|
+
if _state_of(sw).get("suppress"):
|
|
2113
|
+
return
|
|
1617
2114
|
try:
|
|
1618
|
-
|
|
2115
|
+
value = bool(sw.isOn())
|
|
1619
2116
|
except Exception:
|
|
1620
|
-
|
|
1621
|
-
|
|
2117
|
+
return
|
|
2118
|
+
_fire(sw, "on_change", value)
|
|
2119
|
+
|
|
2120
|
+
_register_control_action(sw, 1 << 12, _on_toggle) # ValueChanged
|
|
2121
|
+
return sw
|
|
2122
|
+
|
|
2123
|
+
def _apply(self, sw: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
2124
|
+
state = _state_of(sw)
|
|
2125
|
+
if "value" in props:
|
|
2126
|
+
new_val = bool(props["value"])
|
|
2127
|
+
if bool(sw.isOn()) != new_val:
|
|
2128
|
+
state["suppress"] = True
|
|
2129
|
+
try:
|
|
2130
|
+
sw.setOn_animated_(new_val, not initial)
|
|
2131
|
+
finally:
|
|
2132
|
+
state["suppress"] = False
|
|
2133
|
+
_apply_accessibility(sw, props)
|
|
2134
|
+
|
|
2135
|
+
def measure_intrinsic(
|
|
2136
|
+
self,
|
|
2137
|
+
native_view: Any,
|
|
2138
|
+
max_width: float,
|
|
2139
|
+
max_height: float,
|
|
2140
|
+
) -> Tuple[float, float]:
|
|
2141
|
+
return (51.0, 31.0)
|
|
2142
|
+
|
|
2143
|
+
|
|
2144
|
+
class SliderHandler(IOSViewHandler):
|
|
2145
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
2146
|
+
sl = ObjCClass("UISlider").alloc().init()
|
|
2147
|
+
sl.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
2148
|
+
sl.retain()
|
|
2149
|
+
_pn_retained_views.append(sl)
|
|
2150
|
+
|
|
2151
|
+
def _on_slide() -> None:
|
|
2152
|
+
if _state_of(sl).get("suppress"):
|
|
2153
|
+
return
|
|
1622
2154
|
try:
|
|
1623
|
-
|
|
2155
|
+
value = float(sl.value)
|
|
1624
2156
|
except Exception:
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
2157
|
+
return
|
|
2158
|
+
_fire(sl, "on_change", value)
|
|
2159
|
+
|
|
2160
|
+
_register_control_action(sl, 1 << 12, _on_slide) # ValueChanged
|
|
2161
|
+
return sl
|
|
2162
|
+
|
|
2163
|
+
def _apply(self, sl: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
2164
|
+
state = _state_of(sl)
|
|
2165
|
+
if "min_value" in props and props["min_value"] is not None:
|
|
2166
|
+
sl.setMinimumValue_(float(props["min_value"]))
|
|
2167
|
+
if "max_value" in props and props["max_value"] is not None:
|
|
2168
|
+
sl.setMaximumValue_(float(props["max_value"]))
|
|
2169
|
+
if "value" in props and props["value"] is not None:
|
|
2170
|
+
new_val = float(props["value"])
|
|
2171
|
+
if abs(float(sl.value) - new_val) > 1e-9:
|
|
2172
|
+
state["suppress"] = True
|
|
2173
|
+
try:
|
|
2174
|
+
sl.setValue_animated_(new_val, not initial)
|
|
2175
|
+
finally:
|
|
2176
|
+
state["suppress"] = False
|
|
2177
|
+
_apply_accessibility(sl, props)
|
|
2178
|
+
|
|
2179
|
+
def measure_intrinsic(
|
|
2180
|
+
self,
|
|
2181
|
+
native_view: Any,
|
|
2182
|
+
max_width: float,
|
|
2183
|
+
max_height: float,
|
|
2184
|
+
) -> Tuple[float, float]:
|
|
2185
|
+
w = max_width if math.isfinite(max_width) else 200.0
|
|
2186
|
+
return (max(w, 100.0), 34.0)
|
|
2187
|
+
|
|
2188
|
+
|
|
2189
|
+
class ActivityIndicatorHandler(IOSViewHandler):
|
|
2190
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
2191
|
+
# Style: 100=medium, 101=large (iOS 13+).
|
|
2192
|
+
style = 101 if props.get("size") == "large" else 100
|
|
2193
|
+
ai = ObjCClass("UIActivityIndicatorView").alloc().initWithActivityIndicatorStyle_(style)
|
|
2194
|
+
ai.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
2195
|
+
ai.setHidesWhenStopped_(True)
|
|
2196
|
+
return ai
|
|
1632
2197
|
|
|
1633
|
-
def
|
|
1634
|
-
if "
|
|
1635
|
-
|
|
1636
|
-
if "auto_focus" in props and props["auto_focus"]:
|
|
1637
|
-
try:
|
|
1638
|
-
tv.becomeFirstResponder()
|
|
1639
|
-
except Exception:
|
|
1640
|
-
pass
|
|
1641
|
-
# ``editable`` (present only when False) maps to UITextView's own
|
|
1642
|
-
# ``editable`` flag — there is no ``enabled`` on a non-UIControl.
|
|
1643
|
-
if "editable" in props:
|
|
1644
|
-
editable = props["editable"]
|
|
2198
|
+
def _apply(self, ai: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
2199
|
+
if "size" in props and props["size"] is not None and not initial:
|
|
2200
|
+
style = 101 if props["size"] == "large" else 100
|
|
1645
2201
|
try:
|
|
1646
|
-
|
|
2202
|
+
ai.setActivityIndicatorViewStyle_(style)
|
|
1647
2203
|
except Exception:
|
|
1648
2204
|
pass
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
2205
|
+
if "color" in props and props["color"] is not None:
|
|
2206
|
+
ai.setColor_(_uicolor(props["color"]))
|
|
2207
|
+
animating = props.get("animating")
|
|
2208
|
+
if "animating" in props or initial:
|
|
2209
|
+
should_animate = True if animating is None else bool(animating)
|
|
2210
|
+
if should_animate:
|
|
2211
|
+
ai.startAnimating()
|
|
2212
|
+
else:
|
|
2213
|
+
ai.stopAnimating()
|
|
2214
|
+
_apply_accessibility(ai, props)
|
|
1657
2215
|
|
|
2216
|
+
def measure_intrinsic(
|
|
2217
|
+
self,
|
|
2218
|
+
native_view: Any,
|
|
2219
|
+
max_width: float,
|
|
2220
|
+
max_height: float,
|
|
2221
|
+
) -> Tuple[float, float]:
|
|
2222
|
+
try:
|
|
2223
|
+
size = native_view.intrinsicContentSize()
|
|
2224
|
+
return (float(size.width), float(size.height))
|
|
2225
|
+
except Exception:
|
|
2226
|
+
return (20.0, 20.0)
|
|
1658
2227
|
|
|
1659
|
-
class ImageHandler(IOSViewHandler):
|
|
1660
|
-
def create(self, props: Dict[str, Any]) -> Any:
|
|
1661
|
-
iv = ObjCClass("UIImageView").alloc().init()
|
|
1662
|
-
iv.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
1663
|
-
self._apply(iv, props)
|
|
1664
|
-
return iv
|
|
1665
2228
|
|
|
1666
|
-
|
|
1667
|
-
|
|
2229
|
+
class PressableHandler(IOSViewHandler):
|
|
2230
|
+
"""Touchable container with press feedback.
|
|
2231
|
+
|
|
2232
|
+
A `UILongPressGestureRecognizer` with ``minimumPressDuration=0``
|
|
2233
|
+
tracks the raw touch for ``on_press_in`` / ``on_press_out`` and the
|
|
2234
|
+
pressed-opacity feedback; a tap recognizer fires ``on_press`` and a
|
|
2235
|
+
standard long-press recognizer fires ``on_long_press``. All three
|
|
2236
|
+
share the simultaneous-recognition delegate. Declarative
|
|
2237
|
+
``gestures`` from props are attached on top by the base class.
|
|
2238
|
+
"""
|
|
1668
2239
|
|
|
1669
|
-
def
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
2240
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
2241
|
+
v = ObjCClass("UIView").alloc().init()
|
|
2242
|
+
v.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
2243
|
+
v.setUserInteractionEnabled_(True)
|
|
2244
|
+
self._wire_press(v)
|
|
2245
|
+
return v
|
|
2246
|
+
|
|
2247
|
+
def _wire_press(self, view: Any) -> None:
|
|
2248
|
+
UITap = ObjCClass("UITapGestureRecognizer")
|
|
2249
|
+
UILong = ObjCClass("UILongPressGestureRecognizer")
|
|
2250
|
+
|
|
2251
|
+
tap = UITap.alloc().init()
|
|
2252
|
+
longp = UILong.alloc().init()
|
|
2253
|
+
# Zero-duration long press == raw touch-down / touch-up tracking.
|
|
2254
|
+
touch = UILong.alloc().init()
|
|
2255
|
+
touch.setMinimumPressDuration_(0.0)
|
|
2256
|
+
|
|
2257
|
+
def on_tap() -> None:
|
|
2258
|
+
_fire(view, "on_press")
|
|
2259
|
+
|
|
2260
|
+
def on_long() -> None:
|
|
2261
|
+
# UILongPressGestureRecognizer fires on every state
|
|
2262
|
+
# transition; only Began (1) counts as the trigger.
|
|
1673
2263
|
try:
|
|
1674
|
-
|
|
2264
|
+
state = int(longp.state)
|
|
1675
2265
|
except Exception:
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
2266
|
+
state = 1
|
|
2267
|
+
if state == 1:
|
|
2268
|
+
_fire(view, "on_long_press")
|
|
2269
|
+
|
|
2270
|
+
def on_touch() -> None:
|
|
2271
|
+
try:
|
|
2272
|
+
state = int(touch.state)
|
|
2273
|
+
except Exception:
|
|
2274
|
+
return
|
|
2275
|
+
if state == 1: # began
|
|
2276
|
+
_press_feedback(view, True)
|
|
2277
|
+
_fire(view, "on_press_in")
|
|
2278
|
+
elif state in (3, 4, 5): # ended / cancelled / failed
|
|
2279
|
+
_press_feedback(view, False)
|
|
2280
|
+
_fire(view, "on_press_out")
|
|
2281
|
+
|
|
2282
|
+
for rec, handler in ((tap, on_tap), (longp, on_long), (touch, on_touch)):
|
|
1687
2283
|
try:
|
|
1688
|
-
|
|
2284
|
+
rec.setCancelsTouchesInView_(False)
|
|
1689
2285
|
except Exception:
|
|
1690
2286
|
pass
|
|
2287
|
+
_set_recognizer_delegate(rec)
|
|
2288
|
+
view.addGestureRecognizer_(rec)
|
|
2289
|
+
rec.retain()
|
|
2290
|
+
_register_action(rec, handler)
|
|
1691
2291
|
|
|
1692
|
-
def
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
else:
|
|
1697
|
-
UIImage = ObjCClass("UIImage")
|
|
1698
|
-
image = UIImage.imageNamed_(source)
|
|
1699
|
-
if image:
|
|
1700
|
-
iv.setImage_(image)
|
|
1701
|
-
except Exception:
|
|
1702
|
-
pass
|
|
2292
|
+
def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
2293
|
+
_apply_common_visual(view, props)
|
|
2294
|
+
if "enabled" in props:
|
|
2295
|
+
view.setUserInteractionEnabled_(props["enabled"] is not False)
|
|
1703
2296
|
|
|
1704
|
-
def _load_async(self, iv: Any, source: str) -> None:
|
|
1705
|
-
"""Asynchronously load a remote image off the main thread.
|
|
1706
2297
|
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
2298
|
+
# ----------------------------------------------------------------------
|
|
2299
|
+
# UITextContentType constants
|
|
2300
|
+
# ----------------------------------------------------------------------
|
|
2301
|
+
#
|
|
2302
|
+
# ``textContentType`` powers iOS AutoFill (passwords, OTP codes, etc.).
|
|
2303
|
+
# The values are NSString *constants*, not literals, so we read the real
|
|
2304
|
+
# constants out of UIKit via ``objc_const`` instead of hardcoding their
|
|
2305
|
+
# string values.
|
|
2306
|
+
_TEXT_CONTENT_TYPE_SYMBOLS = {
|
|
2307
|
+
"username": "UITextContentTypeUsername",
|
|
2308
|
+
"password": "UITextContentTypePassword",
|
|
2309
|
+
"new_password": "UITextContentTypeNewPassword",
|
|
2310
|
+
"one_time_code": "UITextContentTypeOneTimeCode",
|
|
2311
|
+
"email": "UITextContentTypeEmailAddress",
|
|
2312
|
+
"email_address": "UITextContentTypeEmailAddress",
|
|
2313
|
+
"name": "UITextContentTypeName",
|
|
2314
|
+
"url": "UITextContentTypeURL",
|
|
2315
|
+
"telephone": "UITextContentTypeTelephoneNumber",
|
|
2316
|
+
"telephone_number": "UITextContentTypeTelephoneNumber",
|
|
2317
|
+
"phone": "UITextContentTypeTelephoneNumber",
|
|
2318
|
+
"phone_number": "UITextContentTypeTelephoneNumber",
|
|
2319
|
+
}
|
|
2320
|
+
_pn_text_content_type_cache: Dict[str, Any] = {}
|
|
1721
2321
|
|
|
1722
|
-
def completion(data: Any, response: Any, error: Any) -> None:
|
|
1723
|
-
if error is not None or data is None:
|
|
1724
|
-
return
|
|
1725
|
-
try:
|
|
1726
|
-
image = UIImage.imageWithData_(data)
|
|
1727
|
-
if image is None:
|
|
1728
|
-
return
|
|
1729
2322
|
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
iv.setImage_(image)
|
|
1733
|
-
except Exception:
|
|
1734
|
-
pass
|
|
2323
|
+
def _ui_text_content_type(name: str) -> Any:
|
|
2324
|
+
"""Resolve a content-type name to its ``UITextContentType`` constant.
|
|
1735
2325
|
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
2326
|
+
Returns the NSString constant (an ``ObjCInstance``) for a known name,
|
|
2327
|
+
or ``None`` for an unknown name / lookup failure (in which case the
|
|
2328
|
+
caller should simply leave the content type unset).
|
|
2329
|
+
"""
|
|
2330
|
+
symbol = _TEXT_CONTENT_TYPE_SYMBOLS.get(name.strip().lower())
|
|
2331
|
+
if not symbol:
|
|
2332
|
+
return None
|
|
2333
|
+
if symbol in _pn_text_content_type_cache:
|
|
2334
|
+
return _pn_text_content_type_cache[symbol]
|
|
2335
|
+
value = None
|
|
2336
|
+
try:
|
|
2337
|
+
from rubicon.objc.api import objc_const
|
|
1739
2338
|
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
except Exception:
|
|
1747
|
-
pass
|
|
2339
|
+
uikit = _ct.cdll.LoadLibrary("/System/Library/Frameworks/UIKit.framework/UIKit")
|
|
2340
|
+
value = objc_const(uikit, symbol)
|
|
2341
|
+
except Exception:
|
|
2342
|
+
value = None
|
|
2343
|
+
_pn_text_content_type_cache[symbol] = value
|
|
2344
|
+
return value
|
|
1748
2345
|
|
|
1749
|
-
task = session.dataTaskWithURL_completionHandler_(url, completion)
|
|
1750
|
-
task.resume()
|
|
1751
|
-
except Exception:
|
|
1752
|
-
pass
|
|
1753
2346
|
|
|
2347
|
+
# ----------------------------------------------------------------------
|
|
2348
|
+
# Pressable feedback
|
|
2349
|
+
# ----------------------------------------------------------------------
|
|
1754
2350
|
|
|
1755
|
-
class SwitchHandler(IOSViewHandler):
|
|
1756
|
-
def create(self, props: Dict[str, Any]) -> Any:
|
|
1757
|
-
sw = ObjCClass("UISwitch").alloc().init()
|
|
1758
|
-
sw.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
1759
|
-
self._apply(sw, props)
|
|
1760
|
-
return sw
|
|
1761
2351
|
|
|
1762
|
-
|
|
1763
|
-
|
|
2352
|
+
def _press_feedback(view: Any, pressed: bool) -> None:
|
|
2353
|
+
"""Animate the pressed-opacity visual feedback."""
|
|
2354
|
+
try:
|
|
2355
|
+
merged = _state_of(view).get("props") or {}
|
|
2356
|
+
if pressed:
|
|
2357
|
+
opacity = float(merged.get("pressed_opacity", 0.6))
|
|
2358
|
+
duration = 0.05
|
|
2359
|
+
else:
|
|
2360
|
+
raw = merged.get("opacity")
|
|
2361
|
+
opacity = float(raw) if raw is not None else 1.0
|
|
2362
|
+
duration = 0.1
|
|
2363
|
+
UIView = ObjCClass("UIView")
|
|
2364
|
+
UIView.animateWithDuration_animations_(duration, lambda: view.setAlpha_(opacity))
|
|
2365
|
+
except Exception:
|
|
2366
|
+
pass
|
|
1764
2367
|
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
if "on_change" in props:
|
|
1770
|
-
existing = _pn_switch_handler_map.get(id(sw))
|
|
1771
|
-
if existing is not None:
|
|
1772
|
-
existing._callback = props["on_change"]
|
|
1773
|
-
else:
|
|
1774
|
-
handler = _PNSwitchTarget.new()
|
|
1775
|
-
handler._callback = props["on_change"]
|
|
1776
|
-
_pn_switch_handler_map[id(sw)] = handler
|
|
1777
|
-
sw.addTarget_action_forControlEvents_(handler, SEL("onToggle:"), 1 << 12)
|
|
2368
|
+
|
|
2369
|
+
# ======================================================================
|
|
2370
|
+
# ProgressBar
|
|
2371
|
+
# ======================================================================
|
|
1778
2372
|
|
|
1779
2373
|
|
|
1780
2374
|
class ProgressBarHandler(IOSViewHandler):
|
|
@@ -1782,17 +2376,15 @@ class ProgressBarHandler(IOSViewHandler):
|
|
|
1782
2376
|
|
|
1783
2377
|
``UIProgressView`` has no indeterminate mode, so when
|
|
1784
2378
|
``indeterminate`` is set the handler instead creates an animating
|
|
1785
|
-
``UIActivityIndicatorView
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
view (a deliberate, safe limitation).
|
|
2379
|
+
``UIActivityIndicatorView``. The view type is chosen at create
|
|
2380
|
+
time; toggling ``indeterminate`` on an existing bar keeps the
|
|
2381
|
+
original view (a deliberate, safe limitation).
|
|
1789
2382
|
"""
|
|
1790
2383
|
|
|
1791
|
-
def
|
|
2384
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1792
2385
|
if props.get("indeterminate"):
|
|
1793
2386
|
ai = ObjCClass("UIActivityIndicatorView").alloc().init()
|
|
1794
2387
|
ai.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
1795
|
-
self._apply_indeterminate(ai, props)
|
|
1796
2388
|
try:
|
|
1797
2389
|
ai.startAnimating()
|
|
1798
2390
|
except Exception:
|
|
@@ -1800,86 +2392,61 @@ class ProgressBarHandler(IOSViewHandler):
|
|
|
1800
2392
|
return ai
|
|
1801
2393
|
pv = ObjCClass("UIProgressView").alloc().init()
|
|
1802
2394
|
pv.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
1803
|
-
self._apply_determinate(pv, props)
|
|
1804
2395
|
return pv
|
|
1805
2396
|
|
|
1806
|
-
def
|
|
2397
|
+
def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
1807
2398
|
try:
|
|
1808
|
-
cls_name = str(
|
|
2399
|
+
cls_name = str(view.objc_class.name)
|
|
1809
2400
|
except Exception:
|
|
1810
2401
|
cls_name = ""
|
|
1811
2402
|
if "UIActivityIndicatorView" in cls_name:
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
if "value" in props and props["value"] is not None:
|
|
1818
|
-
try:
|
|
1819
|
-
pv.setProgress_(float(props["value"]))
|
|
1820
|
-
except Exception:
|
|
1821
|
-
pass
|
|
1822
|
-
if "color" in props and props["color"] is not None:
|
|
1823
|
-
try:
|
|
1824
|
-
pv.setProgressTintColor_(_uicolor(props["color"]))
|
|
1825
|
-
except Exception:
|
|
1826
|
-
pass
|
|
1827
|
-
if "track_color" in props and props["track_color"] is not None:
|
|
2403
|
+
if "color" in props and props["color"] is not None:
|
|
2404
|
+
try:
|
|
2405
|
+
view.setColor_(_uicolor(props["color"]))
|
|
2406
|
+
except Exception:
|
|
2407
|
+
pass
|
|
1828
2408
|
try:
|
|
1829
|
-
|
|
2409
|
+
view.startAnimating()
|
|
1830
2410
|
except Exception:
|
|
1831
2411
|
pass
|
|
2412
|
+
else:
|
|
2413
|
+
if "value" in props and props["value"] is not None:
|
|
2414
|
+
try:
|
|
2415
|
+
view.setProgress_(float(props["value"]))
|
|
2416
|
+
except Exception:
|
|
2417
|
+
pass
|
|
2418
|
+
if "color" in props and props["color"] is not None:
|
|
2419
|
+
try:
|
|
2420
|
+
view.setProgressTintColor_(_uicolor(props["color"]))
|
|
2421
|
+
except Exception:
|
|
2422
|
+
pass
|
|
2423
|
+
if "track_color" in props and props["track_color"] is not None:
|
|
2424
|
+
try:
|
|
2425
|
+
view.setTrackTintColor_(_uicolor(props["track_color"]))
|
|
2426
|
+
except Exception:
|
|
2427
|
+
pass
|
|
2428
|
+
_apply_accessibility(view, props)
|
|
1832
2429
|
|
|
1833
|
-
def
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
2430
|
+
def measure_intrinsic(
|
|
2431
|
+
self,
|
|
2432
|
+
native_view: Any,
|
|
2433
|
+
max_width: float,
|
|
2434
|
+
max_height: float,
|
|
2435
|
+
) -> Tuple[float, float]:
|
|
1839
2436
|
try:
|
|
1840
|
-
|
|
2437
|
+
cls_name = str(native_view.objc_class.name)
|
|
1841
2438
|
except Exception:
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
ai = ObjCClass("UIActivityIndicatorView").alloc().init()
|
|
1848
|
-
ai.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
1849
|
-
self._apply_style(ai, props)
|
|
1850
|
-
self._apply_color(ai, props)
|
|
1851
|
-
if props.get("animating", True):
|
|
1852
|
-
ai.startAnimating()
|
|
1853
|
-
return ai
|
|
1854
|
-
|
|
1855
|
-
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
1856
|
-
self._apply_style(native_view, changed)
|
|
1857
|
-
self._apply_color(native_view, changed)
|
|
1858
|
-
if "animating" in changed:
|
|
1859
|
-
if changed["animating"]:
|
|
1860
|
-
native_view.startAnimating()
|
|
1861
|
-
else:
|
|
1862
|
-
native_view.stopAnimating()
|
|
1863
|
-
|
|
1864
|
-
def _apply_style(self, ai: Any, props: Dict[str, Any]) -> None:
|
|
1865
|
-
if "size" in props and props["size"] is not None:
|
|
1866
|
-
# UIActivityIndicatorViewStyle (iOS 13+): medium = 100, large = 101.
|
|
1867
|
-
style = 101 if props["size"] == "large" else 100
|
|
1868
|
-
try:
|
|
1869
|
-
ai.setActivityIndicatorViewStyle_(style)
|
|
1870
|
-
except Exception:
|
|
1871
|
-
pass
|
|
2439
|
+
cls_name = ""
|
|
2440
|
+
if "UIActivityIndicatorView" in cls_name:
|
|
2441
|
+
return (20.0, 20.0)
|
|
2442
|
+
w = max_width if math.isfinite(max_width) else 200.0
|
|
2443
|
+
return (max(w, 40.0), 4.0)
|
|
1872
2444
|
|
|
1873
|
-
def _apply_color(self, ai: Any, props: Dict[str, Any]) -> None:
|
|
1874
|
-
if "color" in props and props["color"] is not None:
|
|
1875
|
-
try:
|
|
1876
|
-
ai.setColor_(_uicolor(props["color"]))
|
|
1877
|
-
except Exception:
|
|
1878
|
-
pass
|
|
1879
2445
|
|
|
2446
|
+
# ======================================================================
|
|
2447
|
+
# WebView — WKWebView with navigation + script-message delegates
|
|
2448
|
+
# ======================================================================
|
|
1880
2449
|
|
|
1881
|
-
# Maps ``id(delegate)`` -> ``{"on_load", "on_nav", "on_message", "inject_js"}``.
|
|
1882
|
-
_pn_webview_state: dict = {}
|
|
1883
2450
|
# WKWebView.scrollView isn't auto-detected as a property by rubicon, so it
|
|
1884
2451
|
# must be declared once (lazily, to avoid forcing a WebKit load at import).
|
|
1885
2452
|
_pn_wkwebview_declared = False
|
|
@@ -1896,75 +2463,110 @@ def _webview_url(webview: Any) -> str:
|
|
|
1896
2463
|
return ""
|
|
1897
2464
|
|
|
1898
2465
|
|
|
1899
|
-
|
|
1900
|
-
|
|
2466
|
+
# WKNavigationDelegate + WKScriptMessageHandler bridge. WebKit passes
|
|
2467
|
+
# object arguments (``WKNavigation*`` / ``WKScriptMessage*``) to these
|
|
2468
|
+
# delegate callbacks, which rubicon's ``@objc_method`` FFI bridge
|
|
2469
|
+
# mismarshals on iOS 18.x — the app dies with EXC_BAD_ACCESS inside
|
|
2470
|
+
# ``objc_msgSend`` (see the module header note). Like the scroll and
|
|
2471
|
+
# tab-bar delegates we therefore build the class with raw libobjc and
|
|
2472
|
+
# CFUNCTYPE IMPs, keep per-delegate state keyed by the delegate
|
|
2473
|
+
# *pointer*, and only touch the retained rubicon webview from Python.
|
|
1901
2474
|
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
load, and surfaces ``window.webkit.messageHandlers.pythonnative``
|
|
1905
|
-
posts through ``on_message``.
|
|
1906
|
-
"""
|
|
2475
|
+
# Maps delegate ptr -> {"view": rubicon WKWebView, "inject_js": str|None}.
|
|
2476
|
+
_pn_webview_state: Dict[int, Dict[str, Any]] = {}
|
|
1907
2477
|
|
|
1908
|
-
|
|
1909
|
-
def webView_didFinishNavigation_(self, webview: object, navigation: object) -> None:
|
|
1910
|
-
info = _pn_webview_state.get(id(self))
|
|
1911
|
-
if not info:
|
|
1912
|
-
return
|
|
1913
|
-
js = info.get("inject_js")
|
|
1914
|
-
if js:
|
|
1915
|
-
try:
|
|
1916
|
-
webview.evaluateJavaScript_completionHandler_(str(js), None)
|
|
1917
|
-
except Exception:
|
|
1918
|
-
pass
|
|
1919
|
-
cb = info.get("on_load")
|
|
1920
|
-
if cb is not None:
|
|
1921
|
-
try:
|
|
1922
|
-
cb(_webview_url(webview))
|
|
1923
|
-
except Exception:
|
|
1924
|
-
pass
|
|
2478
|
+
_WEBVIEW_IMP_TYPE = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
|
|
1925
2479
|
|
|
1926
|
-
@objc_method
|
|
1927
|
-
def webView_didStartProvisionalNavigation_(self, webview: object, navigation: object) -> None:
|
|
1928
|
-
info = _pn_webview_state.get(id(self))
|
|
1929
|
-
if not info:
|
|
1930
|
-
return
|
|
1931
|
-
cb = info.get("on_nav")
|
|
1932
|
-
if cb is not None:
|
|
1933
|
-
try:
|
|
1934
|
-
cb(_webview_url(webview))
|
|
1935
|
-
except Exception:
|
|
1936
|
-
pass
|
|
1937
2480
|
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
raw = message.body
|
|
1949
|
-
body = str(raw) if raw is not None else ""
|
|
1950
|
-
except Exception:
|
|
1951
|
-
body = ""
|
|
2481
|
+
def _webview_did_finish_imp(self_ptr: int, _cmd_ptr: int, _webview_ptr: int, _nav_ptr: int) -> None:
|
|
2482
|
+
"""Raw C callback for ``webView:didFinishNavigation:``."""
|
|
2483
|
+
info = _pn_webview_state.get(int(self_ptr or 0))
|
|
2484
|
+
if not info:
|
|
2485
|
+
return
|
|
2486
|
+
wv = info.get("view")
|
|
2487
|
+
if wv is None:
|
|
2488
|
+
return
|
|
2489
|
+
js = info.get("inject_js")
|
|
2490
|
+
if js:
|
|
1952
2491
|
try:
|
|
1953
|
-
|
|
2492
|
+
wv.evaluateJavaScript_completionHandler_(str(js), None)
|
|
1954
2493
|
except Exception:
|
|
1955
2494
|
pass
|
|
2495
|
+
_fire(wv, "on_load", _webview_url(wv))
|
|
2496
|
+
|
|
2497
|
+
|
|
2498
|
+
def _webview_did_start_imp(self_ptr: int, _cmd_ptr: int, _webview_ptr: int, _nav_ptr: int) -> None:
|
|
2499
|
+
"""Raw C callback for ``webView:didStartProvisionalNavigation:``."""
|
|
2500
|
+
info = _pn_webview_state.get(int(self_ptr or 0))
|
|
2501
|
+
if not info:
|
|
2502
|
+
return
|
|
2503
|
+
wv = info.get("view")
|
|
2504
|
+
if wv is not None:
|
|
2505
|
+
_fire(wv, "on_navigation_state_change", _webview_url(wv))
|
|
2506
|
+
|
|
2507
|
+
|
|
2508
|
+
def _webview_script_message_imp(self_ptr: int, _cmd_ptr: int, _controller_ptr: int, message_ptr: int) -> None:
|
|
2509
|
+
"""Raw C callback for ``userContentController:didReceiveScriptMessage:``."""
|
|
2510
|
+
info = _pn_webview_state.get(int(self_ptr or 0))
|
|
2511
|
+
if not info:
|
|
2512
|
+
return
|
|
2513
|
+
wv = info.get("view")
|
|
2514
|
+
if wv is None:
|
|
2515
|
+
return
|
|
2516
|
+
body = ""
|
|
2517
|
+
try:
|
|
2518
|
+
# Wrapping the raw pointer ourselves (outbound rubicon call) is
|
|
2519
|
+
# safe; it's the @objc_method *callback* marshaling that breaks.
|
|
2520
|
+
message = ObjCInstance(_ct.c_void_p(message_ptr))
|
|
2521
|
+
raw = message.body
|
|
2522
|
+
body = str(raw) if raw is not None else ""
|
|
2523
|
+
except Exception:
|
|
2524
|
+
body = ""
|
|
2525
|
+
_fire(wv, "on_message", body)
|
|
2526
|
+
|
|
2527
|
+
|
|
2528
|
+
_webview_did_finish_imp_ref = _WEBVIEW_IMP_TYPE(_webview_did_finish_imp)
|
|
2529
|
+
_webview_did_start_imp_ref = _WEBVIEW_IMP_TYPE(_webview_did_start_imp)
|
|
2530
|
+
_webview_script_message_imp_ref = _WEBVIEW_IMP_TYPE(_webview_script_message_imp)
|
|
2531
|
+
|
|
2532
|
+
_PN_WEBVIEW_DELEGATE_CLS = _alloc_cls(_NS_OBJECT_CLS, b"_PNWebViewDelegateCTypes", 0)
|
|
2533
|
+
if _PN_WEBVIEW_DELEGATE_CLS:
|
|
2534
|
+
for sel_name, imp_ref in (
|
|
2535
|
+
(b"webView:didFinishNavigation:", _webview_did_finish_imp_ref),
|
|
2536
|
+
(b"webView:didStartProvisionalNavigation:", _webview_did_start_imp_ref),
|
|
2537
|
+
(b"userContentController:didReceiveScriptMessage:", _webview_script_message_imp_ref),
|
|
2538
|
+
):
|
|
2539
|
+
_add_method(
|
|
2540
|
+
_PN_WEBVIEW_DELEGATE_CLS,
|
|
2541
|
+
_sel_reg(sel_name),
|
|
2542
|
+
_ct.cast(imp_ref, _ct.c_void_p),
|
|
2543
|
+
b"v@:@@",
|
|
2544
|
+
)
|
|
2545
|
+
_reg_cls(_PN_WEBVIEW_DELEGATE_CLS)
|
|
2546
|
+
|
|
2547
|
+
|
|
2548
|
+
def _new_webview_delegate_ptr() -> Optional[int]:
|
|
2549
|
+
"""Alloc/init/retain one raw ``_PNWebViewDelegateCTypes`` instance."""
|
|
2550
|
+
if not _PN_WEBVIEW_DELEGATE_CLS:
|
|
2551
|
+
return None
|
|
2552
|
+
_objc_msgSend.restype = _ct.c_void_p
|
|
2553
|
+
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
|
|
2554
|
+
d = _objc_msgSend(_PN_WEBVIEW_DELEGATE_CLS, _SEL_ALLOC)
|
|
2555
|
+
d = _objc_msgSend(d, _SEL_INIT)
|
|
2556
|
+
d = _objc_msgSend(d, _SEL_RETAIN)
|
|
2557
|
+
return int(d) if d else None
|
|
1956
2558
|
|
|
1957
2559
|
|
|
1958
2560
|
class WebViewHandler(IOSViewHandler):
|
|
1959
|
-
def
|
|
2561
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1960
2562
|
global _pn_wkwebview_declared
|
|
1961
2563
|
WKWebView = ObjCClass("WKWebView")
|
|
1962
2564
|
WKWebViewConfiguration = ObjCClass("WKWebViewConfiguration")
|
|
1963
2565
|
if not _pn_wkwebview_declared:
|
|
1964
2566
|
# Some WebKit @property declarations aren't auto-detected by
|
|
1965
2567
|
# rubicon's runtime introspection (same class of issue as
|
|
1966
|
-
# UIView.superview above); declare the ones we read so
|
|
1967
|
-
# access returns the object instead of a
|
|
2568
|
+
# UIView.superview above); declare the ones we read so
|
|
2569
|
+
# attribute access returns the object instead of a method.
|
|
1968
2570
|
for cls, prop in (
|
|
1969
2571
|
(WKWebView, "scrollView"),
|
|
1970
2572
|
(WKWebView, "URL"),
|
|
@@ -1976,58 +2578,57 @@ class WebViewHandler(IOSViewHandler):
|
|
|
1976
2578
|
pass
|
|
1977
2579
|
_pn_wkwebview_declared = True
|
|
1978
2580
|
config = WKWebViewConfiguration.alloc().init()
|
|
1979
|
-
|
|
1980
|
-
delegate.
|
|
1981
|
-
_pn_retained_views.append(delegate)
|
|
1982
|
-
_pn_webview_state[id(delegate)] = {
|
|
1983
|
-
"on_load": props.get("on_load"),
|
|
1984
|
-
"on_nav": props.get("on_navigation_state_change"),
|
|
1985
|
-
"on_message": props.get("on_message"),
|
|
1986
|
-
"inject_js": props.get("inject_javascript"),
|
|
1987
|
-
}
|
|
2581
|
+
delegate_ptr = _new_webview_delegate_ptr()
|
|
2582
|
+
delegate = ObjCInstance(_ct.c_void_p(delegate_ptr)) if delegate_ptr else None
|
|
1988
2583
|
# Register the message handler up front so page JS calling
|
|
1989
|
-
# ``window.webkit.messageHandlers.pythonnative.postMessage(x)``
|
|
1990
|
-
# reach ``on_message`` even if it
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
2584
|
+
# ``window.webkit.messageHandlers.pythonnative.postMessage(x)``
|
|
2585
|
+
# can reach ``on_message`` even if it's wired in a later render.
|
|
2586
|
+
if delegate is not None:
|
|
2587
|
+
try:
|
|
2588
|
+
config.userContentController.addScriptMessageHandler_name_(delegate, "pythonnative")
|
|
2589
|
+
except Exception:
|
|
2590
|
+
pass
|
|
1995
2591
|
wv = WKWebView.alloc().initWithFrame_configuration_(((0, 0), (0, 0)), config)
|
|
1996
|
-
wv.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2592
|
+
wv.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
2593
|
+
if delegate is not None:
|
|
2594
|
+
try:
|
|
2595
|
+
wv.setNavigationDelegate_(delegate)
|
|
2596
|
+
except Exception:
|
|
2597
|
+
pass
|
|
2598
|
+
if delegate_ptr:
|
|
2599
|
+
_pn_webview_state[delegate_ptr] = {"view": wv, "inject_js": None}
|
|
2600
|
+
self._delegate_ids[_objc_ptr(wv) or 0] = delegate_ptr
|
|
2004
2601
|
return wv
|
|
2005
2602
|
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
if
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
def
|
|
2603
|
+
_delegate_ids: Dict[int, int] = {}
|
|
2604
|
+
|
|
2605
|
+
def create(self, tag: int, props: Dict[str, Any]) -> Any:
|
|
2606
|
+
view = super().create(tag, props)
|
|
2607
|
+
delegate_id = self._delegate_ids.pop(_objc_ptr(view) or 0, None)
|
|
2608
|
+
if delegate_id is not None:
|
|
2609
|
+
_state_of(view)["webview_delegate_id"] = delegate_id
|
|
2610
|
+
if props.get("inject_javascript"):
|
|
2611
|
+
_pn_webview_state[delegate_id]["inject_js"] = props["inject_javascript"]
|
|
2612
|
+
return view
|
|
2613
|
+
|
|
2614
|
+
def _teardown(self, native_view: Any) -> None:
|
|
2615
|
+
delegate_id = _state_of(native_view).get("webview_delegate_id")
|
|
2616
|
+
if delegate_id is not None:
|
|
2617
|
+
_pn_webview_state.pop(delegate_id, None)
|
|
2618
|
+
|
|
2619
|
+
def _apply(self, wv: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
2620
|
+
delegate_id = _state_of(wv).get("webview_delegate_id")
|
|
2621
|
+
if delegate_id is not None and "inject_javascript" in props:
|
|
2622
|
+
info = _pn_webview_state.get(delegate_id)
|
|
2623
|
+
if info is not None:
|
|
2624
|
+
info["inject_js"] = props["inject_javascript"]
|
|
2023
2625
|
# ``html`` wins over ``url`` (matches the component contract).
|
|
2024
2626
|
if "html" in props and props["html"]:
|
|
2025
2627
|
try:
|
|
2026
2628
|
wv.loadHTMLString_baseURL_(str(props["html"]), None)
|
|
2027
|
-
return
|
|
2028
2629
|
except Exception:
|
|
2029
2630
|
pass
|
|
2030
|
-
|
|
2631
|
+
elif "url" in props and props["url"]:
|
|
2031
2632
|
try:
|
|
2032
2633
|
NSURL = ObjCClass("NSURL")
|
|
2033
2634
|
NSURLRequest = ObjCClass("NSURLRequest")
|
|
@@ -2035,8 +2636,6 @@ class WebViewHandler(IOSViewHandler):
|
|
|
2035
2636
|
wv.loadRequest_(NSURLRequest.requestWithURL_(url_obj))
|
|
2036
2637
|
except Exception:
|
|
2037
2638
|
pass
|
|
2038
|
-
|
|
2039
|
-
def _apply_scroll_enabled(self, wv: Any, props: Dict[str, Any]) -> None:
|
|
2040
2639
|
if "scroll_enabled" in props:
|
|
2041
2640
|
enabled = props["scroll_enabled"]
|
|
2042
2641
|
try:
|
|
@@ -2044,6 +2643,38 @@ class WebViewHandler(IOSViewHandler):
|
|
|
2044
2643
|
except Exception:
|
|
2045
2644
|
pass
|
|
2046
2645
|
|
|
2646
|
+
def command(self, native_view: Any, name: str, args: Dict[str, Any]) -> Any:
|
|
2647
|
+
if name == "eval_js":
|
|
2648
|
+
try:
|
|
2649
|
+
native_view.evaluateJavaScript_completionHandler_(str(args.get("source", "")), None)
|
|
2650
|
+
except Exception:
|
|
2651
|
+
pass
|
|
2652
|
+
return None
|
|
2653
|
+
if name == "reload":
|
|
2654
|
+
try:
|
|
2655
|
+
native_view.reload()
|
|
2656
|
+
except Exception:
|
|
2657
|
+
pass
|
|
2658
|
+
return None
|
|
2659
|
+
if name == "go_back":
|
|
2660
|
+
try:
|
|
2661
|
+
native_view.goBack()
|
|
2662
|
+
except Exception:
|
|
2663
|
+
pass
|
|
2664
|
+
return None
|
|
2665
|
+
if name == "go_forward":
|
|
2666
|
+
try:
|
|
2667
|
+
native_view.goForward()
|
|
2668
|
+
except Exception:
|
|
2669
|
+
pass
|
|
2670
|
+
return None
|
|
2671
|
+
return None
|
|
2672
|
+
|
|
2673
|
+
|
|
2674
|
+
# ======================================================================
|
|
2675
|
+
# Spacer / SafeAreaView
|
|
2676
|
+
# ======================================================================
|
|
2677
|
+
|
|
2047
2678
|
|
|
2048
2679
|
class SpacerHandler(IOSViewHandler):
|
|
2049
2680
|
"""Empty layout placeholder used as a flexible gap.
|
|
@@ -2052,35 +2683,23 @@ class SpacerHandler(IOSViewHandler):
|
|
|
2052
2683
|
behaves identically to a `View` with the same style props.
|
|
2053
2684
|
"""
|
|
2054
2685
|
|
|
2055
|
-
def
|
|
2686
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
2056
2687
|
v = ObjCClass("UIView").alloc().init()
|
|
2057
2688
|
v.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
2058
2689
|
return v
|
|
2059
2690
|
|
|
2060
|
-
def
|
|
2691
|
+
def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
2061
2692
|
pass
|
|
2062
2693
|
|
|
2063
2694
|
|
|
2064
2695
|
class SafeAreaViewHandler(IOSViewHandler):
|
|
2065
|
-
|
|
2696
|
+
"""Plain container; safe-area insets are applied by the layout engine."""
|
|
2697
|
+
|
|
2698
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
2066
2699
|
v = ObjCClass("UIView").alloc().init()
|
|
2067
2700
|
v.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
2068
|
-
_apply_common_visual(v, props)
|
|
2069
2701
|
return v
|
|
2070
2702
|
|
|
2071
|
-
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
2072
|
-
_apply_common_visual(native_view, changed)
|
|
2073
|
-
|
|
2074
|
-
def add_child(self, parent: Any, child: Any) -> None:
|
|
2075
|
-
try:
|
|
2076
|
-
child.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
2077
|
-
except Exception:
|
|
2078
|
-
pass
|
|
2079
|
-
parent.addSubview_(child)
|
|
2080
|
-
|
|
2081
|
-
def remove_child(self, parent: Any, child: Any) -> None:
|
|
2082
|
-
child.removeFromSuperview()
|
|
2083
|
-
|
|
2084
2703
|
|
|
2085
2704
|
# ======================================================================
|
|
2086
2705
|
# Modal — actually presents a UIViewController
|
|
@@ -2091,88 +2710,76 @@ class ModalHandler(IOSViewHandler):
|
|
|
2091
2710
|
"""Real modal presentation backed by a presented `UIViewController`.
|
|
2092
2711
|
|
|
2093
2712
|
The on-tree placeholder is a hidden ``UIView`` (so the layout
|
|
2094
|
-
engine can ignore it). When ``visible`` flips to ``True``, a
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2713
|
+
engine can ignore it). When ``visible`` flips to ``True``, a fresh
|
|
2714
|
+
``UIViewController`` is allocated, its view is configured as the
|
|
2715
|
+
container into which the modal's children mount, and the controller
|
|
2716
|
+
is presented from the topmost view controller.
|
|
2098
2717
|
|
|
2099
2718
|
Children are added to the *content view* of the presented
|
|
2100
2719
|
controller, not the on-tree placeholder, so the reconciler's
|
|
2101
|
-
``
|
|
2720
|
+
``insert_child`` / ``remove_child`` calls are forwarded there.
|
|
2102
2721
|
"""
|
|
2103
2722
|
|
|
2104
|
-
def
|
|
2723
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
2105
2724
|
v = ObjCClass("UIView").alloc().init()
|
|
2106
2725
|
v.setHidden_(True)
|
|
2107
|
-
v._pn_modal_state = None
|
|
2108
|
-
self._apply(v, props, mounting=True)
|
|
2109
2726
|
return v
|
|
2110
2727
|
|
|
2111
|
-
def
|
|
2112
|
-
|
|
2728
|
+
def _apply(self, placeholder: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
2729
|
+
state = _state_of(placeholder)
|
|
2730
|
+
merged = state.get("props") or props
|
|
2731
|
+
visible = bool(merged.get("visible", False))
|
|
2732
|
+
presented = state.get("modal") is not None
|
|
2733
|
+
if visible and not presented:
|
|
2734
|
+
self._present(placeholder, merged)
|
|
2735
|
+
elif not visible and presented:
|
|
2736
|
+
self._dismiss(placeholder)
|
|
2737
|
+
|
|
2738
|
+
def _teardown(self, native_view: Any) -> None:
|
|
2739
|
+
if _state_of(native_view).get("modal") is not None:
|
|
2740
|
+
self._dismiss(native_view)
|
|
2113
2741
|
|
|
2114
|
-
def
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
if
|
|
2742
|
+
def insert_child(self, parent: Any, child: Any, index: int) -> None:
|
|
2743
|
+
state = _state_of(parent)
|
|
2744
|
+
modal = state.get("modal")
|
|
2745
|
+
if modal is not None:
|
|
2118
2746
|
try:
|
|
2119
2747
|
child.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
2120
2748
|
except Exception:
|
|
2121
2749
|
pass
|
|
2122
|
-
|
|
2750
|
+
try:
|
|
2751
|
+
content = modal["content_view"]
|
|
2752
|
+
count = len(list(content.subviews or []))
|
|
2753
|
+
content.insertSubview_atIndex_(child, max(0, min(index, count)))
|
|
2754
|
+
except Exception:
|
|
2755
|
+
pass
|
|
2123
2756
|
else:
|
|
2124
|
-
|
|
2125
|
-
buf
|
|
2126
|
-
buf.append(child)
|
|
2757
|
+
buf = state.setdefault("pending_children", [])
|
|
2758
|
+
buf.insert(max(0, min(index, len(buf))), child)
|
|
2127
2759
|
|
|
2128
2760
|
def remove_child(self, parent: Any, child: Any) -> None:
|
|
2129
2761
|
try:
|
|
2130
2762
|
child.removeFromSuperview()
|
|
2131
2763
|
except Exception:
|
|
2132
2764
|
pass
|
|
2133
|
-
buf =
|
|
2765
|
+
buf = _state_of(parent).get("pending_children")
|
|
2134
2766
|
if buf and child in buf:
|
|
2135
2767
|
buf.remove(child)
|
|
2136
2768
|
|
|
2137
|
-
def insert_child(self, parent: Any, child: Any, index: int) -> None:
|
|
2138
|
-
state = _pn_modal_states.get(id(parent))
|
|
2139
|
-
if state and state.get("content_view") is not None:
|
|
2140
|
-
try:
|
|
2141
|
-
child.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
2142
|
-
except Exception:
|
|
2143
|
-
pass
|
|
2144
|
-
state["content_view"].insertSubview_atIndex_(child, index)
|
|
2145
|
-
else:
|
|
2146
|
-
buf = _pn_modal_pending.setdefault(id(parent), [])
|
|
2147
|
-
buf.insert(index, child)
|
|
2148
|
-
|
|
2149
2769
|
def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
|
|
2150
2770
|
# Modal is a virtual placeholder — not rendered inline.
|
|
2151
2771
|
return
|
|
2152
2772
|
|
|
2153
|
-
def
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
for key, value in props.items():
|
|
2161
|
-
if value is None:
|
|
2162
|
-
merged.pop(key, None)
|
|
2163
|
-
else:
|
|
2164
|
-
merged[key] = value
|
|
2165
|
-
visible = bool(merged.get("visible", False))
|
|
2166
|
-
state = _pn_modal_states.get(id(placeholder))
|
|
2167
|
-
if visible and state is None:
|
|
2168
|
-
self._present(placeholder, merged)
|
|
2169
|
-
elif not visible and state is not None:
|
|
2170
|
-
self._dismiss(placeholder)
|
|
2171
|
-
elif visible and state is not None:
|
|
2172
|
-
# Already presented; refresh the on_dismiss callback.
|
|
2173
|
-
state["on_dismiss"] = merged.get("on_dismiss")
|
|
2773
|
+
def measure_intrinsic(
|
|
2774
|
+
self,
|
|
2775
|
+
native_view: Any,
|
|
2776
|
+
max_width: float,
|
|
2777
|
+
max_height: float,
|
|
2778
|
+
) -> Tuple[float, float]:
|
|
2779
|
+
return (0.0, 0.0)
|
|
2174
2780
|
|
|
2175
2781
|
def _present(self, placeholder: Any, props: Dict[str, Any]) -> None:
|
|
2782
|
+
state = _state_of(placeholder)
|
|
2176
2783
|
try:
|
|
2177
2784
|
UIViewController = ObjCClass("UIViewController")
|
|
2178
2785
|
UIApplication = ObjCClass("UIApplication")
|
|
@@ -2190,7 +2797,6 @@ class ModalHandler(IOSViewHandler):
|
|
|
2190
2797
|
content.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
2191
2798
|
controller.view.addSubview_(content)
|
|
2192
2799
|
controller.view.setBackgroundColor_(_uicolor("#66000000" if is_overlay else "#FFFFFF"))
|
|
2193
|
-
# Stretch the content view to the controller's view.
|
|
2194
2800
|
try:
|
|
2195
2801
|
bounds = controller.view.bounds
|
|
2196
2802
|
content.setFrame_(((0, 0), (bounds.size.width, bounds.size.height)))
|
|
@@ -2199,165 +2805,56 @@ class ModalHandler(IOSViewHandler):
|
|
|
2199
2805
|
pass
|
|
2200
2806
|
|
|
2201
2807
|
# UIModalPresentationStyle: fullScreen=0, pageSheet=1,
|
|
2202
|
-
# formSheet=2, overCurrentContext=6 (
|
|
2808
|
+
# formSheet=2, overCurrentContext=6 (the dimmed overlay).
|
|
2203
2809
|
style_map = {"full_screen": 0, "page_sheet": 1, "form_sheet": 2, "overlay": 6}
|
|
2204
2810
|
style_int = 6 if is_overlay else style_map.get(presentation_style, 1)
|
|
2205
2811
|
try:
|
|
2206
2812
|
controller.setModalPresentationStyle_(style_int)
|
|
2207
2813
|
except Exception:
|
|
2208
2814
|
pass
|
|
2209
|
-
#
|
|
2210
|
-
#
|
|
2211
|
-
#
|
|
2815
|
+
# For sheet styles, ``dismiss_on_backdrop=False`` locks
|
|
2816
|
+
# interactive (swipe / outside-tap) dismissal so the modal
|
|
2817
|
+
# stays put until ``visible`` is driven back to False.
|
|
2212
2818
|
if not is_overlay and props.get("dismiss_on_backdrop") is False:
|
|
2213
2819
|
try:
|
|
2214
2820
|
controller.setModalInPresentation_(True)
|
|
2215
2821
|
except Exception:
|
|
2216
2822
|
pass
|
|
2217
2823
|
|
|
2218
|
-
on_show = props.get("on_show")
|
|
2219
|
-
|
|
2220
2824
|
def _on_present_complete() -> None:
|
|
2221
|
-
|
|
2222
|
-
try:
|
|
2223
|
-
on_show()
|
|
2224
|
-
except Exception:
|
|
2225
|
-
pass
|
|
2825
|
+
_fire(placeholder, "on_show")
|
|
2226
2826
|
|
|
2227
|
-
|
|
2827
|
+
state["modal"] = {
|
|
2228
2828
|
"controller": controller,
|
|
2229
2829
|
"content_view": content,
|
|
2230
|
-
"on_dismiss": props.get("on_dismiss"),
|
|
2231
2830
|
# Keep the completion block alive past this call.
|
|
2232
2831
|
"on_show": _on_present_complete,
|
|
2233
2832
|
}
|
|
2234
|
-
|
|
2235
|
-
for child in _pn_modal_pending.pop(id(placeholder), []):
|
|
2833
|
+
for child in state.pop("pending_children", []):
|
|
2236
2834
|
try:
|
|
2835
|
+
child.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
2237
2836
|
content.addSubview_(child)
|
|
2238
2837
|
except Exception:
|
|
2239
2838
|
pass
|
|
2240
2839
|
|
|
2241
|
-
top = UIApplication.sharedApplication
|
|
2242
|
-
while top is not None and top.presentedViewController is not None:
|
|
2243
|
-
top = top.presentedViewController
|
|
2840
|
+
top = _top_view_controller_for_alert(UIApplication.sharedApplication)
|
|
2244
2841
|
if top is not None:
|
|
2245
2842
|
top.presentViewController_animated_completion_(controller, True, _on_present_complete)
|
|
2246
2843
|
except Exception:
|
|
2247
|
-
|
|
2844
|
+
state.pop("modal", None)
|
|
2248
2845
|
|
|
2249
2846
|
def _dismiss(self, placeholder: Any) -> None:
|
|
2250
|
-
state =
|
|
2251
|
-
|
|
2847
|
+
state = _state_of(placeholder)
|
|
2848
|
+
modal = state.pop("modal", None)
|
|
2849
|
+
if modal is None:
|
|
2252
2850
|
return
|
|
2253
|
-
controller =
|
|
2254
|
-
on_dismiss = state.get("on_dismiss")
|
|
2851
|
+
controller = modal.get("controller")
|
|
2255
2852
|
if controller is not None:
|
|
2256
2853
|
try:
|
|
2257
2854
|
controller.dismissViewControllerAnimated_completion_(True, None)
|
|
2258
2855
|
except Exception:
|
|
2259
2856
|
pass
|
|
2260
|
-
|
|
2261
|
-
try:
|
|
2262
|
-
on_dismiss()
|
|
2263
|
-
except Exception:
|
|
2264
|
-
pass
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
_pn_modal_states: Dict[int, dict] = {}
|
|
2268
|
-
_pn_modal_pending: Dict[int, List[Any]] = {}
|
|
2269
|
-
# Accumulated (non-None) props per modal placeholder, so presentation can
|
|
2270
|
-
# read config props set on a render prior to the one that flips ``visible``.
|
|
2271
|
-
_pn_modal_props: Dict[int, dict] = {}
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
class SliderHandler(IOSViewHandler):
|
|
2275
|
-
def create(self, props: Dict[str, Any]) -> Any:
|
|
2276
|
-
sl = ObjCClass("UISlider").alloc().init()
|
|
2277
|
-
sl.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
2278
|
-
self._apply(sl, props)
|
|
2279
|
-
return sl
|
|
2280
|
-
|
|
2281
|
-
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
2282
|
-
self._apply(native_view, changed)
|
|
2283
|
-
|
|
2284
|
-
def _apply(self, sl: Any, props: Dict[str, Any]) -> None:
|
|
2285
|
-
if "min_value" in props:
|
|
2286
|
-
sl.setMinimumValue_(float(props["min_value"]))
|
|
2287
|
-
if "max_value" in props:
|
|
2288
|
-
sl.setMaximumValue_(float(props["max_value"]))
|
|
2289
|
-
if "value" in props:
|
|
2290
|
-
sl.setValue_(float(props["value"]))
|
|
2291
|
-
_apply_accessibility(sl, props)
|
|
2292
|
-
if "on_change" in props:
|
|
2293
|
-
existing = _pn_slider_handler_map.get(id(sl))
|
|
2294
|
-
if existing is not None:
|
|
2295
|
-
existing._callback = props["on_change"]
|
|
2296
|
-
else:
|
|
2297
|
-
handler = _PNSliderTarget.new()
|
|
2298
|
-
handler._callback = props["on_change"]
|
|
2299
|
-
_pn_slider_handler_map[id(sl)] = handler
|
|
2300
|
-
sl.addTarget_action_forControlEvents_(handler, SEL("onSlide:"), 1 << 12)
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
# ======================================================================
|
|
2304
|
-
# Pressable — visual touch feedback + tap/long-press callbacks
|
|
2305
|
-
# ======================================================================
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
class PressableHandler(IOSViewHandler):
|
|
2309
|
-
def create(self, props: Dict[str, Any]) -> Any:
|
|
2310
|
-
v = ObjCClass("UIView").alloc().init()
|
|
2311
|
-
v.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
2312
|
-
v.setUserInteractionEnabled_(True)
|
|
2313
|
-
target = _PNPressableTarget.new()
|
|
2314
|
-
target.retain()
|
|
2315
|
-
_pn_retained_views.append(target)
|
|
2316
|
-
# UITapGestureRecognizer for press (single tap) — provides
|
|
2317
|
-
# touchDown/up via UIControl events on subclassed UIControl,
|
|
2318
|
-
# but since this is a UIView we register a tap gesture for
|
|
2319
|
-
# on_press and a long-press gesture for on_long_press.
|
|
2320
|
-
try:
|
|
2321
|
-
UITapGestureRecognizer = ObjCClass("UITapGestureRecognizer")
|
|
2322
|
-
tap = UITapGestureRecognizer.alloc().initWithTarget_action_(target, SEL("onTouchUp:"))
|
|
2323
|
-
v.addGestureRecognizer_(tap)
|
|
2324
|
-
UILongPressGestureRecognizer = ObjCClass("UILongPressGestureRecognizer")
|
|
2325
|
-
longp = UILongPressGestureRecognizer.alloc().initWithTarget_action_(target, SEL("onLongPress:"))
|
|
2326
|
-
v.addGestureRecognizer_(longp)
|
|
2327
|
-
except Exception:
|
|
2328
|
-
pass
|
|
2329
|
-
_pn_pressable_state[id(target)] = {
|
|
2330
|
-
"view": v,
|
|
2331
|
-
"on_press": props.get("on_press"),
|
|
2332
|
-
"on_long_press": props.get("on_long_press"),
|
|
2333
|
-
"pressed_opacity": float(props.get("pressed_opacity", 0.6)),
|
|
2334
|
-
}
|
|
2335
|
-
# Stash the target id so update() can reach it.
|
|
2336
|
-
v._pn_press_target_id = id(target)
|
|
2337
|
-
_apply_common_visual(v, props)
|
|
2338
|
-
return v
|
|
2339
|
-
|
|
2340
|
-
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
2341
|
-
target_id = getattr(native_view, "_pn_press_target_id", None)
|
|
2342
|
-
if target_id is not None and target_id in _pn_pressable_state:
|
|
2343
|
-
info = _pn_pressable_state[target_id]
|
|
2344
|
-
if "on_press" in changed:
|
|
2345
|
-
info["on_press"] = changed["on_press"]
|
|
2346
|
-
if "on_long_press" in changed:
|
|
2347
|
-
info["on_long_press"] = changed["on_long_press"]
|
|
2348
|
-
if "pressed_opacity" in changed and changed["pressed_opacity"] is not None:
|
|
2349
|
-
info["pressed_opacity"] = float(changed["pressed_opacity"])
|
|
2350
|
-
_apply_common_visual(native_view, changed)
|
|
2351
|
-
|
|
2352
|
-
def add_child(self, parent: Any, child: Any) -> None:
|
|
2353
|
-
try:
|
|
2354
|
-
child.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
2355
|
-
except Exception:
|
|
2356
|
-
pass
|
|
2357
|
-
parent.addSubview_(child)
|
|
2358
|
-
|
|
2359
|
-
def remove_child(self, parent: Any, child: Any) -> None:
|
|
2360
|
-
child.removeFromSuperview()
|
|
2857
|
+
_fire(placeholder, "on_dismiss")
|
|
2361
2858
|
|
|
2362
2859
|
|
|
2363
2860
|
# ======================================================================
|
|
@@ -2368,471 +2865,122 @@ class PressableHandler(IOSViewHandler):
|
|
|
2368
2865
|
class StatusBarHandler(IOSViewHandler):
|
|
2369
2866
|
"""Apply status-bar style/visibility to the key window.
|
|
2370
2867
|
|
|
2371
|
-
Status bar configuration on iOS is a per-view-controller value;
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2868
|
+
Status bar configuration on iOS is a per-view-controller value; we
|
|
2869
|
+
use the legacy UIApplication setters which still work on iOS 13+
|
|
2870
|
+
(with ``UIViewControllerBasedStatusBarAppearance`` set to ``NO`` in
|
|
2871
|
+
Info.plist for full effect). The placeholder view is hidden and
|
|
2872
|
+
contributes nothing to the layout.
|
|
2376
2873
|
"""
|
|
2377
2874
|
|
|
2378
|
-
def
|
|
2875
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
2379
2876
|
v = ObjCClass("UIView").alloc().init()
|
|
2380
2877
|
v.setHidden_(True)
|
|
2381
|
-
self._apply(props)
|
|
2382
2878
|
return v
|
|
2383
2879
|
|
|
2384
|
-
def
|
|
2385
|
-
self._apply(changed)
|
|
2386
|
-
|
|
2387
|
-
def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
|
|
2388
|
-
return
|
|
2389
|
-
|
|
2390
|
-
def _apply(self, props: Dict[str, Any]) -> None:
|
|
2880
|
+
def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
2391
2881
|
try:
|
|
2392
2882
|
UIApplication = ObjCClass("UIApplication")
|
|
2393
2883
|
app = UIApplication.sharedApplication
|
|
2394
2884
|
if "hidden" in props and props["hidden"] is not None:
|
|
2395
2885
|
app.setStatusBarHidden_animated_(bool(props["hidden"]), True)
|
|
2396
2886
|
if "bar_style" in props and props["bar_style"] is not None:
|
|
2397
|
-
#
|
|
2398
|
-
# 3 = darkContent (iOS 13+).
|
|
2887
|
+
# 1 = lightContent, 3 = darkContent (iOS 13+).
|
|
2399
2888
|
mapping = {"default": 3, "light": 1, "dark": 3}
|
|
2400
2889
|
app.setStatusBarStyle_animated_(mapping.get(props["bar_style"], 0), True)
|
|
2401
2890
|
except Exception:
|
|
2402
2891
|
pass
|
|
2403
2892
|
|
|
2404
|
-
|
|
2405
|
-
# ======================================================================
|
|
2406
|
-
# KeyboardAvoidingView — wraps children and offsets them by the keyboard
|
|
2407
|
-
# ======================================================================
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
_pn_keyboard_observer: Any = None
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
class _PNKeyboardObserver(NSObject): # type: ignore[valid-type]
|
|
2414
|
-
@objc_method
|
|
2415
|
-
def keyboardWillShow_(self, notification: object) -> None:
|
|
2416
|
-
try:
|
|
2417
|
-
info = notification.userInfo
|
|
2418
|
-
kbd_frame = info.objectForKey_("UIKeyboardFrameEndUserInfoKey")
|
|
2419
|
-
# Frame is wrapped in NSValue; the Python side reads
|
|
2420
|
-
# CGRectValue() which returns a tuple of structs.
|
|
2421
|
-
rect = kbd_frame.CGRectValue
|
|
2422
|
-
height = float(rect.size.height)
|
|
2423
|
-
except Exception:
|
|
2424
|
-
height = 0.0
|
|
2425
|
-
from .. import platform_metrics
|
|
2426
|
-
|
|
2427
|
-
platform_metrics.set_keyboard_height(height)
|
|
2428
|
-
|
|
2429
|
-
@objc_method
|
|
2430
|
-
def keyboardWillHide_(self, notification: object) -> None:
|
|
2431
|
-
from .. import platform_metrics
|
|
2432
|
-
|
|
2433
|
-
platform_metrics.set_keyboard_height(0.0)
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
def _ensure_keyboard_observer() -> None:
|
|
2437
|
-
global _pn_keyboard_observer
|
|
2438
|
-
if _pn_keyboard_observer is not None:
|
|
2439
|
-
return
|
|
2440
|
-
try:
|
|
2441
|
-
observer = _PNKeyboardObserver.new()
|
|
2442
|
-
observer.retain()
|
|
2443
|
-
_pn_keyboard_observer = observer
|
|
2444
|
-
NSNotificationCenter = ObjCClass("NSNotificationCenter")
|
|
2445
|
-
center = NSNotificationCenter.defaultCenter
|
|
2446
|
-
center.addObserver_selector_name_object_(
|
|
2447
|
-
observer,
|
|
2448
|
-
SEL("keyboardWillShow:"),
|
|
2449
|
-
"UIKeyboardWillShowNotification",
|
|
2450
|
-
None,
|
|
2451
|
-
)
|
|
2452
|
-
center.addObserver_selector_name_object_(
|
|
2453
|
-
observer,
|
|
2454
|
-
SEL("keyboardWillHide:"),
|
|
2455
|
-
"UIKeyboardWillHideNotification",
|
|
2456
|
-
None,
|
|
2457
|
-
)
|
|
2458
|
-
except Exception:
|
|
2459
|
-
pass
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
class KeyboardAvoidingViewHandler(IOSViewHandler):
|
|
2463
|
-
"""Container that listens to the system keyboard and re-publishes its height.
|
|
2464
|
-
|
|
2465
|
-
The actual layout shift is implemented in user-land by the
|
|
2466
|
-
[`KeyboardAvoidingView`][pythonnative.KeyboardAvoidingView]
|
|
2467
|
-
component, which subscribes to ``platform_metrics.subscribe`` via
|
|
2468
|
-
[`use_keyboard_height`][pythonnative.use_keyboard_height] and
|
|
2469
|
-
applies the offset as bottom padding. The native handler is just
|
|
2470
|
-
a vanilla UIView that ensures the observer is installed.
|
|
2471
|
-
"""
|
|
2472
|
-
|
|
2473
|
-
def create(self, props: Dict[str, Any]) -> Any:
|
|
2474
|
-
_ensure_keyboard_observer()
|
|
2475
|
-
v = ObjCClass("UIView").alloc().init()
|
|
2476
|
-
v.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
2477
|
-
_apply_common_visual(v, props)
|
|
2478
|
-
return v
|
|
2479
|
-
|
|
2480
|
-
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
2481
|
-
_apply_common_visual(native_view, changed)
|
|
2482
|
-
|
|
2483
|
-
def add_child(self, parent: Any, child: Any) -> None:
|
|
2484
|
-
try:
|
|
2485
|
-
child.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
2486
|
-
except Exception:
|
|
2487
|
-
pass
|
|
2488
|
-
parent.addSubview_(child)
|
|
2489
|
-
|
|
2490
|
-
def remove_child(self, parent: Any, child: Any) -> None:
|
|
2491
|
-
child.removeFromSuperview()
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
# ======================================================================
|
|
2495
|
-
# VirtualList — UITableView-backed virtualized list
|
|
2496
|
-
# ======================================================================
|
|
2497
|
-
#
|
|
2498
|
-
# We register a raw libobjc class ``_PNTableSourceCTypes`` rather than
|
|
2499
|
-
# using rubicon-objc's ``@objc_method`` because UIKit invokes
|
|
2500
|
-
# ``tableView:cellForRowAtIndexPath:`` with a tagged-pointer
|
|
2501
|
-
# NSIndexPath that crashes inside CPython's ``_ctypes.O_get`` when
|
|
2502
|
-
# rubicon-objc's FFI closure tries to wrap it as a PyObject*.
|
|
2503
|
-
#
|
|
2504
|
-
# Each UITableView gets its own dataSource instance; per-instance
|
|
2505
|
-
# state lives in ``_pn_table_state`` keyed by the dataSource's raw
|
|
2506
|
-
# pointer (the integer value passed as ``self`` to every IMP).
|
|
2507
|
-
|
|
2508
|
-
_pn_table_state: Dict[int, dict] = {}
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
_PN_CELL_REUSE_ID = "PNCell"
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
_TABLE_NUM_SECTIONS_TYPE = _ct.CFUNCTYPE(_ct.c_long, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
|
|
2515
|
-
_TABLE_NUM_ROWS_TYPE = _ct.CFUNCTYPE(_ct.c_long, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_long)
|
|
2516
|
-
_TABLE_HEIGHT_TYPE = _ct.CFUNCTYPE(_ct.c_double, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
|
|
2517
|
-
_TABLE_CELL_TYPE = _ct.CFUNCTYPE(_ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
|
|
2518
|
-
_TABLE_DID_SELECT_TYPE = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
def _table_num_sections_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int) -> int:
|
|
2522
|
-
return 1
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
def _table_num_rows_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int, section: int) -> int:
|
|
2526
|
-
try:
|
|
2527
|
-
info = _pn_table_state.get(int(self_ptr))
|
|
2528
|
-
return int(info.get("count", 0)) if info else 0
|
|
2529
|
-
except Exception:
|
|
2530
|
-
import traceback as _tb
|
|
2531
|
-
|
|
2532
|
-
print("[VirtualList][iOS] _table_num_rows_imp raised:")
|
|
2533
|
-
_tb.print_exc()
|
|
2534
|
-
return 0
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
def _table_height_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int, ip_ptr: int) -> float:
|
|
2538
|
-
try:
|
|
2539
|
-
info = _pn_table_state.get(int(self_ptr))
|
|
2540
|
-
return float(info.get("row_height", 44.0)) if info else 44.0
|
|
2541
|
-
except Exception:
|
|
2542
|
-
import traceback as _tb
|
|
2543
|
-
|
|
2544
|
-
print("[VirtualList][iOS] _table_height_imp raised:")
|
|
2545
|
-
_tb.print_exc()
|
|
2546
|
-
return 44.0
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
def _table_cell_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int, ip_ptr: int) -> int:
|
|
2550
|
-
"""Build (or reuse) a cell for ``tableView:cellForRowAtIndexPath:``.
|
|
2551
|
-
|
|
2552
|
-
``ip_ptr`` is read raw via ``[indexPath row]`` to avoid the
|
|
2553
|
-
rubicon-objc tagged-pointer crash. The table view itself is a
|
|
2554
|
-
real heap object so we can wrap it as an ObjCInstance for the
|
|
2555
|
-
convenience of dequeue / cell allocation. We retain the freshly
|
|
2556
|
-
allocated cell explicitly so it survives past the Python wrapper
|
|
2557
|
-
going out of scope at the end of this frame.
|
|
2558
|
-
"""
|
|
2559
|
-
import traceback as _tb
|
|
2560
|
-
|
|
2561
|
-
try:
|
|
2562
|
-
_objc_msgSend.restype = _ct.c_long
|
|
2563
|
-
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
|
|
2564
|
-
row = int(_objc_msgSend(_ct.c_void_p(ip_ptr), _SEL_ROW))
|
|
2565
|
-
except Exception:
|
|
2566
|
-
print("[VirtualList][iOS] _table_cell_imp: indexPath.row read raised:")
|
|
2567
|
-
_tb.print_exc()
|
|
2568
|
-
row = 0
|
|
2569
|
-
|
|
2570
|
-
try:
|
|
2571
|
-
from rubicon.objc import ObjCInstance
|
|
2572
|
-
|
|
2573
|
-
UITableViewCell = ObjCClass("UITableViewCell")
|
|
2574
|
-
tv = ObjCInstance(_ct.c_void_p(tv_ptr))
|
|
2575
|
-
info = _pn_table_state.get(int(self_ptr))
|
|
2576
|
-
row_h = float(info.get("row_height", 44.0)) if info else 44.0
|
|
2577
|
-
|
|
2578
|
-
try:
|
|
2579
|
-
tv_bounds = tv.bounds
|
|
2580
|
-
cell_w = float(tv_bounds.size.width)
|
|
2581
|
-
except Exception:
|
|
2582
|
-
cell_w = 0.0
|
|
2583
|
-
if cell_w <= 0:
|
|
2584
|
-
try:
|
|
2585
|
-
screen = ObjCClass("UIScreen").mainScreen()
|
|
2586
|
-
cell_w = float(screen.bounds.size.width)
|
|
2587
|
-
except Exception:
|
|
2588
|
-
cell_w = 320.0
|
|
2589
|
-
|
|
2590
|
-
cell = tv.dequeueReusableCellWithIdentifier_(_PN_CELL_REUSE_ID)
|
|
2591
|
-
if cell is None or (hasattr(cell, "ptr") and cell.ptr.value == 0):
|
|
2592
|
-
cell = UITableViewCell.alloc().initWithStyle_reuseIdentifier_(0, _PN_CELL_REUSE_ID)
|
|
2593
|
-
transparent = _uicolor("#00000000")
|
|
2594
|
-
cell.setBackgroundColor_(transparent)
|
|
2595
|
-
cell.contentView.setBackgroundColor_(transparent)
|
|
2596
|
-
cell.retain() # offset the Python wrapper's release on __del__
|
|
2597
|
-
|
|
2598
|
-
try:
|
|
2599
|
-
cell.setFrame_(((0, 0), (cell_w, row_h)))
|
|
2600
|
-
cell.contentView.setFrame_(((0, 0), (cell_w, row_h)))
|
|
2601
|
-
except Exception:
|
|
2602
|
-
print("[VirtualList][iOS] _table_cell_imp: cell pre-size raised:")
|
|
2603
|
-
_tb.print_exc()
|
|
2604
|
-
|
|
2605
|
-
try:
|
|
2606
|
-
existing_subs = cell.contentView.subviews
|
|
2607
|
-
if callable(existing_subs):
|
|
2608
|
-
existing_subs = existing_subs()
|
|
2609
|
-
for sub in list(existing_subs):
|
|
2610
|
-
sub.removeFromSuperview()
|
|
2611
|
-
except Exception:
|
|
2612
|
-
print("[VirtualList][iOS] _table_cell_imp: strip prior subviews raised:")
|
|
2613
|
-
_tb.print_exc()
|
|
2614
|
-
|
|
2615
|
-
if info is not None:
|
|
2616
|
-
mount = info.get("mount_row")
|
|
2617
|
-
if mount is not None:
|
|
2618
|
-
try:
|
|
2619
|
-
mount(row, cell.contentView, cell_w, row_h)
|
|
2620
|
-
except Exception:
|
|
2621
|
-
print(f"[VirtualList][iOS] _table_cell_imp mount_row({row}) raised:")
|
|
2622
|
-
_tb.print_exc()
|
|
2623
|
-
|
|
2624
|
-
cell_ptr = cell.ptr.value
|
|
2625
|
-
return int(cell_ptr) if cell_ptr is not None else 0
|
|
2626
|
-
except Exception:
|
|
2627
|
-
print(f"[VirtualList][iOS] _table_cell_imp raised for row={row}:")
|
|
2628
|
-
_tb.print_exc()
|
|
2629
|
-
try:
|
|
2630
|
-
UITableViewCell = ObjCClass("UITableViewCell")
|
|
2631
|
-
cell = UITableViewCell.alloc().initWithStyle_reuseIdentifier_(0, _PN_CELL_REUSE_ID)
|
|
2632
|
-
cell.retain()
|
|
2633
|
-
return int(cell.ptr.value)
|
|
2634
|
-
except Exception:
|
|
2635
|
-
return 0
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
def _table_did_select_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int, ip_ptr: int) -> None:
|
|
2639
|
-
try:
|
|
2640
|
-
_objc_msgSend.restype = _ct.c_long
|
|
2641
|
-
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
|
|
2642
|
-
row = int(_objc_msgSend(_ct.c_void_p(ip_ptr), _SEL_ROW))
|
|
2643
|
-
except Exception:
|
|
2644
|
-
import traceback as _tb
|
|
2645
|
-
|
|
2646
|
-
print("[VirtualList][iOS] _table_did_select_imp: indexPath.row read raised:")
|
|
2647
|
-
_tb.print_exc()
|
|
2893
|
+
def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
|
|
2648
2894
|
return
|
|
2649
2895
|
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
print("[VirtualList][iOS] _table_did_select_imp: deselect raised:")
|
|
2658
|
-
_tb.print_exc()
|
|
2659
|
-
|
|
2660
|
-
try:
|
|
2661
|
-
info = _pn_table_state.get(int(self_ptr))
|
|
2662
|
-
if info is None:
|
|
2663
|
-
return
|
|
2664
|
-
cb = info.get("on_row_press")
|
|
2665
|
-
if cb is not None:
|
|
2666
|
-
cb(row)
|
|
2667
|
-
except Exception:
|
|
2668
|
-
import traceback as _tb
|
|
2896
|
+
def measure_intrinsic(
|
|
2897
|
+
self,
|
|
2898
|
+
native_view: Any,
|
|
2899
|
+
max_width: float,
|
|
2900
|
+
max_height: float,
|
|
2901
|
+
) -> Tuple[float, float]:
|
|
2902
|
+
return (0.0, 0.0)
|
|
2669
2903
|
|
|
2670
|
-
print("[VirtualList][iOS] _table_did_select_imp: on_row_press raised:")
|
|
2671
|
-
_tb.print_exc()
|
|
2672
2904
|
|
|
2905
|
+
# ======================================================================
|
|
2906
|
+
# KeyboardAvoidingView — publishes the keyboard height to Python
|
|
2907
|
+
# ======================================================================
|
|
2673
2908
|
|
|
2674
|
-
_table_num_sections_imp_ref = _TABLE_NUM_SECTIONS_TYPE(_table_num_sections_imp)
|
|
2675
|
-
_table_num_rows_imp_ref = _TABLE_NUM_ROWS_TYPE(_table_num_rows_imp)
|
|
2676
|
-
_table_height_imp_ref = _TABLE_HEIGHT_TYPE(_table_height_imp)
|
|
2677
|
-
_table_cell_imp_ref = _TABLE_CELL_TYPE(_table_cell_imp)
|
|
2678
|
-
_table_did_select_imp_ref = _TABLE_DID_SELECT_TYPE(_table_did_select_imp)
|
|
2679
2909
|
|
|
2910
|
+
_pn_keyboard_observer: Any = None
|
|
2680
2911
|
|
|
2681
|
-
_PN_TABLE_SOURCE_CLS = _alloc_cls(_NS_OBJECT_CLS, b"_PNTableSourceCTypes", 0)
|
|
2682
|
-
if _PN_TABLE_SOURCE_CLS:
|
|
2683
|
-
_add_method(
|
|
2684
|
-
_PN_TABLE_SOURCE_CLS,
|
|
2685
|
-
_sel_reg(b"numberOfSectionsInTableView:"),
|
|
2686
|
-
_ct.cast(_table_num_sections_imp_ref, _ct.c_void_p),
|
|
2687
|
-
b"q@:@",
|
|
2688
|
-
)
|
|
2689
|
-
_add_method(
|
|
2690
|
-
_PN_TABLE_SOURCE_CLS,
|
|
2691
|
-
_sel_reg(b"tableView:numberOfRowsInSection:"),
|
|
2692
|
-
_ct.cast(_table_num_rows_imp_ref, _ct.c_void_p),
|
|
2693
|
-
b"q@:@q",
|
|
2694
|
-
)
|
|
2695
|
-
_add_method(
|
|
2696
|
-
_PN_TABLE_SOURCE_CLS,
|
|
2697
|
-
_sel_reg(b"tableView:heightForRowAtIndexPath:"),
|
|
2698
|
-
_ct.cast(_table_height_imp_ref, _ct.c_void_p),
|
|
2699
|
-
b"d@:@@",
|
|
2700
|
-
)
|
|
2701
|
-
_add_method(
|
|
2702
|
-
_PN_TABLE_SOURCE_CLS,
|
|
2703
|
-
_sel_reg(b"tableView:cellForRowAtIndexPath:"),
|
|
2704
|
-
_ct.cast(_table_cell_imp_ref, _ct.c_void_p),
|
|
2705
|
-
b"@@:@@",
|
|
2706
|
-
)
|
|
2707
|
-
_add_method(
|
|
2708
|
-
_PN_TABLE_SOURCE_CLS,
|
|
2709
|
-
_sel_reg(b"tableView:didSelectRowAtIndexPath:"),
|
|
2710
|
-
_ct.cast(_table_did_select_imp_ref, _ct.c_void_p),
|
|
2711
|
-
b"v@:@@",
|
|
2712
|
-
)
|
|
2713
|
-
_reg_cls(_PN_TABLE_SOURCE_CLS)
|
|
2714
2912
|
|
|
2913
|
+
class _PNKeyboardObserver(NSObject): # type: ignore[valid-type]
|
|
2914
|
+
@objc_method
|
|
2915
|
+
def keyboardWillShow_(self, notification: object) -> None:
|
|
2916
|
+
try:
|
|
2917
|
+
info = notification.userInfo
|
|
2918
|
+
kbd_frame = info.objectForKey_("UIKeyboardFrameEndUserInfoKey")
|
|
2919
|
+
# Frame is wrapped in NSValue; CGRectValue unwraps the rect.
|
|
2920
|
+
rect = kbd_frame.CGRectValue
|
|
2921
|
+
height = float(rect.size.height)
|
|
2922
|
+
except Exception:
|
|
2923
|
+
height = 0.0
|
|
2924
|
+
from .. import platform_metrics
|
|
2715
2925
|
|
|
2716
|
-
|
|
2717
|
-
"""Allocate and retain a fresh ``_PNTableSourceCTypes`` instance.
|
|
2926
|
+
platform_metrics.set_keyboard_height(height)
|
|
2718
2927
|
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
"""
|
|
2723
|
-
if not _PN_TABLE_SOURCE_CLS:
|
|
2724
|
-
raise RuntimeError("_PNTableSourceCTypes class registration failed")
|
|
2725
|
-
_objc_msgSend.restype = _ct.c_void_p
|
|
2726
|
-
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
|
|
2727
|
-
raw = _objc_msgSend(_PN_TABLE_SOURCE_CLS, _SEL_ALLOC)
|
|
2728
|
-
raw = _objc_msgSend(raw, _SEL_INIT)
|
|
2729
|
-
raw = _objc_msgSend(raw, _SEL_RETAIN)
|
|
2730
|
-
return int(raw) if raw is not None else 0
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
class VirtualListHandler(IOSViewHandler):
|
|
2734
|
-
"""Backed by ``UITableView``; rows are mounted on demand from Python.
|
|
2735
|
-
|
|
2736
|
-
Expects props:
|
|
2737
|
-
|
|
2738
|
-
- ``count``: total number of rows.
|
|
2739
|
-
- ``row_height``: fixed row height in points (variable heights
|
|
2740
|
-
would require a per-row measurement pass; out of scope for v1).
|
|
2741
|
-
- ``mount_row``: callable ``(row_index, content_view) -> None``
|
|
2742
|
-
that inserts the row's native view into ``content_view``. The
|
|
2743
|
-
Python side computes this by mounting a fresh sub-tree using
|
|
2744
|
-
its own reconciler, then calling ``add_child`` on the supplied
|
|
2745
|
-
content view.
|
|
2746
|
-
- ``on_row_press``: optional ``(row_index) -> None`` callback.
|
|
2747
|
-
"""
|
|
2928
|
+
@objc_method
|
|
2929
|
+
def keyboardWillHide_(self, notification: object) -> None:
|
|
2930
|
+
from .. import platform_metrics
|
|
2748
2931
|
|
|
2749
|
-
|
|
2750
|
-
import traceback as _tb
|
|
2932
|
+
platform_metrics.set_keyboard_height(0.0)
|
|
2751
2933
|
|
|
2752
|
-
try:
|
|
2753
|
-
UITableView = ObjCClass("UITableView")
|
|
2754
|
-
tv = UITableView.alloc().initWithFrame_style_(((0, 0), (0, 0)), 0)
|
|
2755
|
-
except Exception:
|
|
2756
|
-
print("[VirtualList][iOS] UITableView alloc raised:")
|
|
2757
|
-
_tb.print_exc()
|
|
2758
|
-
raise
|
|
2759
|
-
try:
|
|
2760
|
-
tv.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
2761
|
-
tv.setBackgroundColor_(_uicolor(props.get("background_color") or "#FFFFFF"))
|
|
2762
|
-
except Exception:
|
|
2763
|
-
print("[VirtualList][iOS] tv basic setup raised:")
|
|
2764
|
-
_tb.print_exc()
|
|
2765
|
-
try:
|
|
2766
|
-
tv.setSeparatorStyle_(0) # None by default; users can opt in.
|
|
2767
|
-
except Exception:
|
|
2768
|
-
print("[VirtualList][iOS] setSeparatorStyle_ raised:")
|
|
2769
|
-
_tb.print_exc()
|
|
2770
2934
|
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2935
|
+
def _ensure_keyboard_observer() -> None:
|
|
2936
|
+
global _pn_keyboard_observer
|
|
2937
|
+
if _pn_keyboard_observer is not None:
|
|
2938
|
+
return
|
|
2939
|
+
try:
|
|
2940
|
+
observer = _PNKeyboardObserver.new()
|
|
2941
|
+
observer.retain()
|
|
2942
|
+
_pn_keyboard_observer = observer
|
|
2943
|
+
center = ObjCClass("NSNotificationCenter").defaultCenter
|
|
2944
|
+
center.addObserver_selector_name_object_(
|
|
2945
|
+
observer,
|
|
2946
|
+
SEL("keyboardWillShow:"),
|
|
2947
|
+
"UIKeyboardWillShowNotification",
|
|
2948
|
+
None,
|
|
2949
|
+
)
|
|
2950
|
+
center.addObserver_selector_name_object_(
|
|
2951
|
+
observer,
|
|
2952
|
+
SEL("keyboardWillHide:"),
|
|
2953
|
+
"UIKeyboardWillHideNotification",
|
|
2954
|
+
None,
|
|
2955
|
+
)
|
|
2956
|
+
except Exception:
|
|
2957
|
+
pass
|
|
2779
2958
|
|
|
2780
|
-
_pn_table_state[source_ptr] = {
|
|
2781
|
-
"count": int(props.get("count", 0)),
|
|
2782
|
-
"row_height": float(props.get("row_height", 44.0)),
|
|
2783
|
-
"mount_row": props.get("mount_row"),
|
|
2784
|
-
"on_row_press": props.get("on_row_press"),
|
|
2785
|
-
}
|
|
2786
2959
|
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
|
|
2790
|
-
tv_ptr = tv.ptr if hasattr(tv, "ptr") else tv
|
|
2791
|
-
_objc_msgSend(tv_ptr, _SEL_SET_DATA_SOURCE, _ct.c_void_p(source_ptr))
|
|
2792
|
-
_objc_msgSend(tv_ptr, _SEL_SET_DELEGATE, _ct.c_void_p(source_ptr))
|
|
2793
|
-
except Exception:
|
|
2794
|
-
print("[VirtualList][iOS] raw setDataSource:/setDelegate: raised:")
|
|
2795
|
-
_tb.print_exc()
|
|
2796
|
-
raise
|
|
2960
|
+
class KeyboardAvoidingViewHandler(IOSViewHandler):
|
|
2961
|
+
"""Container that listens to the system keyboard and re-publishes its height.
|
|
2797
2962
|
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2963
|
+
The actual layout shift is implemented in user-land by the
|
|
2964
|
+
[`KeyboardAvoidingView`][pythonnative.KeyboardAvoidingView]
|
|
2965
|
+
component, which subscribes via
|
|
2966
|
+
[`use_keyboard_height`][pythonnative.use_keyboard_height] and
|
|
2967
|
+
applies the offset as bottom padding. The native handler is just a
|
|
2968
|
+
vanilla UIView that ensures the observer is installed.
|
|
2969
|
+
"""
|
|
2804
2970
|
|
|
2805
|
-
def
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
if "count" in changed:
|
|
2811
|
-
info["count"] = int(changed["count"])
|
|
2812
|
-
if "row_height" in changed and changed["row_height"] is not None:
|
|
2813
|
-
info["row_height"] = float(changed["row_height"])
|
|
2814
|
-
if "mount_row" in changed:
|
|
2815
|
-
info["mount_row"] = changed["mount_row"]
|
|
2816
|
-
if "on_row_press" in changed:
|
|
2817
|
-
info["on_row_press"] = changed["on_row_press"]
|
|
2818
|
-
if "background_color" in changed and changed["background_color"] is not None:
|
|
2819
|
-
native_view.setBackgroundColor_(_uicolor(changed["background_color"]))
|
|
2820
|
-
try:
|
|
2821
|
-
native_view.reloadData()
|
|
2822
|
-
except Exception:
|
|
2823
|
-
pass
|
|
2971
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
2972
|
+
_ensure_keyboard_observer()
|
|
2973
|
+
v = ObjCClass("UIView").alloc().init()
|
|
2974
|
+
v.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
2975
|
+
return v
|
|
2824
2976
|
|
|
2825
2977
|
|
|
2826
2978
|
# ======================================================================
|
|
2827
|
-
# UITabBar
|
|
2979
|
+
# TabBar — UITabBar with a raw ctypes delegate
|
|
2828
2980
|
# ======================================================================
|
|
2829
2981
|
#
|
|
2830
|
-
#
|
|
2831
|
-
#
|
|
2832
|
-
|
|
2833
|
-
_pn_tabbar_state: dict = {"callback": None, "items": []}
|
|
2834
|
-
_pn_tabbar_delegate_installed: bool = False
|
|
2835
|
-
_pn_tabbar_delegate_ptr: Any = None
|
|
2982
|
+
# ``tabBar:didSelectItem:`` passes the UITabBarItem as an ObjC object;
|
|
2983
|
+
# see the module header for why we sidestep rubicon-objc here.
|
|
2836
2984
|
|
|
2837
2985
|
_DELEGATE_IMP_TYPE = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
|
|
2838
2986
|
|
|
@@ -2842,35 +2990,38 @@ def _tabbar_did_select_imp(self_ptr: int, cmd_ptr: int, tabbar_ptr: int, item_pt
|
|
|
2842
2990
|
try:
|
|
2843
2991
|
_objc_msgSend.restype = _ct.c_long
|
|
2844
2992
|
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
|
|
2845
|
-
|
|
2993
|
+
index: int = _objc_msgSend(item_ptr, _SEL_TAG)
|
|
2846
2994
|
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2995
|
+
tag = _view_tags.get(int(tabbar_ptr or 0))
|
|
2996
|
+
state = _view_state.get(tag) if tag is not None else None
|
|
2997
|
+
items = (state or {}).get("props", {}).get("items") or []
|
|
2998
|
+
if 0 <= index < len(items):
|
|
2999
|
+
_fire_ptr(int(tabbar_ptr), "on_tab_select", items[index].get("name", ""))
|
|
2851
3000
|
except Exception:
|
|
2852
3001
|
pass
|
|
2853
3002
|
|
|
2854
3003
|
|
|
2855
3004
|
_tabbar_imp_ref = _DELEGATE_IMP_TYPE(_tabbar_did_select_imp)
|
|
2856
3005
|
|
|
2857
|
-
|
|
2858
|
-
if
|
|
3006
|
+
_PN_TABBAR_DELEGATE_CLS = _alloc_cls(_NS_OBJECT_CLS, b"_PNTabBarDelegateCTypes", 0)
|
|
3007
|
+
if _PN_TABBAR_DELEGATE_CLS:
|
|
2859
3008
|
_add_method(
|
|
2860
|
-
|
|
3009
|
+
_PN_TABBAR_DELEGATE_CLS,
|
|
2861
3010
|
_sel_reg(b"tabBar:didSelectItem:"),
|
|
2862
3011
|
_ct.cast(_tabbar_imp_ref, _ct.c_void_p),
|
|
2863
3012
|
b"v@:@@",
|
|
2864
3013
|
)
|
|
2865
|
-
_reg_cls(
|
|
3014
|
+
_reg_cls(_PN_TABBAR_DELEGATE_CLS)
|
|
3015
|
+
|
|
3016
|
+
_pn_tabbar_delegate_ptr: Any = None
|
|
2866
3017
|
|
|
2867
3018
|
|
|
2868
3019
|
def _ensure_tabbar_delegate(tab_bar: Any) -> None:
|
|
2869
3020
|
global _pn_tabbar_delegate_ptr
|
|
2870
|
-
if _pn_tabbar_delegate_ptr is None and
|
|
3021
|
+
if _pn_tabbar_delegate_ptr is None and _PN_TABBAR_DELEGATE_CLS:
|
|
2871
3022
|
_objc_msgSend.restype = _ct.c_void_p
|
|
2872
3023
|
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
|
|
2873
|
-
raw = _objc_msgSend(
|
|
3024
|
+
raw = _objc_msgSend(_PN_TABBAR_DELEGATE_CLS, _SEL_ALLOC)
|
|
2874
3025
|
raw = _objc_msgSend(raw, _SEL_INIT)
|
|
2875
3026
|
raw = _objc_msgSend(raw, _SEL_RETAIN)
|
|
2876
3027
|
_pn_tabbar_delegate_ptr = raw
|
|
@@ -2885,12 +3036,12 @@ def _ensure_tabbar_delegate(tab_bar: Any) -> None:
|
|
|
2885
3036
|
class TabBarHandler(IOSViewHandler):
|
|
2886
3037
|
"""Native tab bar using ``UITabBar``.
|
|
2887
3038
|
|
|
2888
|
-
Each tab is a ``UITabBarItem`` with a ``tag`` matching its index
|
|
2889
|
-
|
|
2890
|
-
|
|
3039
|
+
Each tab is a ``UITabBarItem`` with a ``tag`` matching its index in
|
|
3040
|
+
the items list. A raw ctypes delegate forwards selection events
|
|
3041
|
+
into the ``on_tab_select`` channel.
|
|
2891
3042
|
"""
|
|
2892
3043
|
|
|
2893
|
-
def
|
|
3044
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
2894
3045
|
from .. import platform_metrics
|
|
2895
3046
|
|
|
2896
3047
|
initial_h = platform_metrics.ios_tab_bar_height()
|
|
@@ -2898,12 +3049,9 @@ class TabBarHandler(IOSViewHandler):
|
|
|
2898
3049
|
tab_bar.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
2899
3050
|
tab_bar.retain()
|
|
2900
3051
|
_pn_retained_views.append(tab_bar)
|
|
2901
|
-
|
|
3052
|
+
_ensure_tabbar_delegate(tab_bar)
|
|
2902
3053
|
return tab_bar
|
|
2903
3054
|
|
|
2904
|
-
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
2905
|
-
self._apply_partial(native_view, changed)
|
|
2906
|
-
|
|
2907
3055
|
def measure_intrinsic(
|
|
2908
3056
|
self,
|
|
2909
3057
|
native_view: Any,
|
|
@@ -2913,26 +3061,16 @@ class TabBarHandler(IOSViewHandler):
|
|
|
2913
3061
|
from .. import platform_metrics
|
|
2914
3062
|
|
|
2915
3063
|
w = max_width if math.isfinite(max_width) else 320.0
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
items =
|
|
2921
|
-
|
|
2922
|
-
self._set_active(tab_bar, props.get("active_tab"), items)
|
|
2923
|
-
self._set_callback(tab_bar, props.get("on_tab_select"), items)
|
|
2924
|
-
|
|
2925
|
-
def _apply_partial(self, tab_bar: Any, changed: Dict[str, Any]) -> None:
|
|
2926
|
-
prev_items = _pn_tabbar_state["items"]
|
|
2927
|
-
if "items" in changed:
|
|
2928
|
-
items = changed["items"]
|
|
3064
|
+
return (w, platform_metrics.ios_tab_bar_height())
|
|
3065
|
+
|
|
3066
|
+
def _apply(self, tab_bar: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
3067
|
+
merged = _state_of(tab_bar).get("props") or props
|
|
3068
|
+
items = merged.get("items") or []
|
|
3069
|
+
if "items" in props:
|
|
2929
3070
|
self._set_bar_items(tab_bar, items)
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
self._set_active(tab_bar, changed["active_tab"], items)
|
|
2934
|
-
if "on_tab_select" in changed:
|
|
2935
|
-
self._set_callback(tab_bar, changed["on_tab_select"], items)
|
|
3071
|
+
if "active_tab" in props or "items" in props:
|
|
3072
|
+
self._set_active(tab_bar, merged.get("active_tab"), items)
|
|
3073
|
+
_apply_accessibility(tab_bar, props)
|
|
2936
3074
|
|
|
2937
3075
|
def _set_bar_items(self, tab_bar: Any, items: list) -> None:
|
|
2938
3076
|
UITabBarItem = ObjCClass("UITabBarItem")
|
|
@@ -2943,14 +3081,16 @@ class TabBarHandler(IOSViewHandler):
|
|
|
2943
3081
|
image = self._resolve_icon(UIImage, item.get("icon"))
|
|
2944
3082
|
bar_item = UITabBarItem.alloc().initWithTitle_image_tag_(str(title), image, i)
|
|
2945
3083
|
bar_items.append(bar_item)
|
|
2946
|
-
|
|
3084
|
+
try:
|
|
3085
|
+
tab_bar.setItems_animated_(bar_items, False)
|
|
3086
|
+
except Exception:
|
|
3087
|
+
pass
|
|
2947
3088
|
|
|
2948
3089
|
def _resolve_icon(self, UIImage: Any, icon: Any) -> Any:
|
|
2949
3090
|
"""Resolve a tab icon spec to a UIImage, or return None.
|
|
2950
3091
|
|
|
2951
3092
|
Accepts a bare string (treated as an SF Symbol name) or a dict
|
|
2952
|
-
of the form ``{"ios": "house.fill", "android": "..."}``.
|
|
2953
|
-
Symbols are looked up via ``UIImage.systemImageNamed:``; names
|
|
3093
|
+
of the form ``{"ios": "house.fill", "android": "..."}``. Names
|
|
2954
3094
|
that don't resolve produce a text-only tab.
|
|
2955
3095
|
"""
|
|
2956
3096
|
if icon is None:
|
|
@@ -2981,11 +3121,6 @@ class TabBarHandler(IOSViewHandler):
|
|
|
2981
3121
|
pass
|
|
2982
3122
|
break
|
|
2983
3123
|
|
|
2984
|
-
def _set_callback(self, tab_bar: Any, cb: Any, items: list) -> None:
|
|
2985
|
-
_pn_tabbar_state["callback"] = cb
|
|
2986
|
-
_pn_tabbar_state["items"] = items
|
|
2987
|
-
_ensure_tabbar_delegate(tab_bar)
|
|
2988
|
-
|
|
2989
3124
|
|
|
2990
3125
|
# ======================================================================
|
|
2991
3126
|
# Alert / Picker imperative helpers
|
|
@@ -3028,8 +3163,8 @@ def _top_view_controller_for_alert(app: Any) -> Any:
|
|
|
3028
3163
|
except Exception:
|
|
3029
3164
|
return None
|
|
3030
3165
|
|
|
3031
|
-
# If the root is a navigation controller, presenting from the
|
|
3032
|
-
# controller gives UIKit the most specific
|
|
3166
|
+
# If the root is a navigation controller, presenting from the
|
|
3167
|
+
# visible controller gives UIKit the most specific context.
|
|
3033
3168
|
try:
|
|
3034
3169
|
visible = getattr(top, "visibleViewController", None)
|
|
3035
3170
|
if visible is not None:
|
|
@@ -3064,11 +3199,11 @@ def _present_alert(
|
|
|
3064
3199
|
Returns immediately; the alert appears on the next main-loop tick.
|
|
3065
3200
|
|
|
3066
3201
|
``buttons`` is a list of ``{"label": str, "style":
|
|
3067
|
-
"default"|"cancel"|"destructive"}`` dicts
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3202
|
+
"default"|"cancel"|"destructive"}`` dicts. When the user picks
|
|
3203
|
+
button ``i`` the helper invokes ``on_result(i)`` exactly once. A
|
|
3204
|
+
dismiss (e.g. swipe-to-cancel on iPad) delivers ``-1``.
|
|
3205
|
+
``on_result`` always runs on the main thread; if it needs to wake
|
|
3206
|
+
an asyncio.Future, use
|
|
3072
3207
|
[`pythonnative.runtime.resolve_future`][pythonnative.runtime.resolve_future]
|
|
3073
3208
|
to hop back onto the loop thread.
|
|
3074
3209
|
"""
|
|
@@ -3126,22 +3261,16 @@ def _present_alert(
|
|
|
3126
3261
|
|
|
3127
3262
|
|
|
3128
3263
|
# ======================================================================
|
|
3129
|
-
# Picker —
|
|
3264
|
+
# Picker — action-sheet dropdown
|
|
3130
3265
|
# ======================================================================
|
|
3131
3266
|
#
|
|
3132
|
-
# The PythonNative `Picker`
|
|
3133
|
-
#
|
|
3134
|
-
#
|
|
3135
|
-
#
|
|
3136
|
-
# choices; for very large lists, paginate or use a custom navigator.
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
_pn_picker_state: dict = {}
|
|
3140
|
-
# Maps ``id(target)`` -> ``id(button)`` so the single shared
|
|
3141
|
-
# ``_PNPickerTarget`` class can look up per-instance picker state on tap.
|
|
3142
|
-
_pn_picker_target_to_button: dict = {}
|
|
3267
|
+
# The PythonNative `Picker` renders as a `UIButton` whose tap presents
|
|
3268
|
+
# a native action sheet (``UIAlertController``) listing the options.
|
|
3269
|
+
# Selecting a row fires ``on_change(value)``. Action sheets are the
|
|
3270
|
+
# standard iOS dropdown pattern for a small-to-medium set of choices.
|
|
3143
3271
|
|
|
3144
3272
|
|
|
3273
|
+
# Maps ``id(target)`` -> owning Picker button.
|
|
3145
3274
|
def _picker_button_title(props: Dict[str, Any]) -> str:
|
|
3146
3275
|
"""Render the selected label, falling back to the placeholder."""
|
|
3147
3276
|
items = props.get("items") or []
|
|
@@ -3152,85 +3281,46 @@ def _picker_button_title(props: Dict[str, Any]) -> str:
|
|
|
3152
3281
|
return str(props.get("placeholder") or "Select…")
|
|
3153
3282
|
|
|
3154
3283
|
|
|
3155
|
-
|
|
3156
|
-
"""
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
instance picker state is looked up in
|
|
3161
|
-
:data:`_pn_picker_target_to_button` / :data:`_pn_picker_state`.
|
|
3162
|
-
"""
|
|
3284
|
+
def _present_picker_sheet(btn: Any) -> None:
|
|
3285
|
+
"""Present the option action-sheet for a Picker button."""
|
|
3286
|
+
merged = _state_of(btn).get("props") or {}
|
|
3287
|
+
items = [item for item in (merged.get("items") or []) if isinstance(item, dict)]
|
|
3288
|
+
placeholder = merged.get("placeholder") or "Select…"
|
|
3163
3289
|
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
bid = _pn_picker_target_to_button.get(id(self))
|
|
3167
|
-
if bid is None:
|
|
3168
|
-
return
|
|
3169
|
-
state = _pn_picker_state.get(bid)
|
|
3170
|
-
if not state:
|
|
3171
|
-
return
|
|
3172
|
-
items = list(state.get("items") or [])
|
|
3173
|
-
on_change = state.get("on_change")
|
|
3174
|
-
placeholder = state.get("placeholder") or "Select…"
|
|
3175
|
-
|
|
3176
|
-
def _make_press(value: Any) -> Callable[[], None]:
|
|
3177
|
-
def _press() -> None:
|
|
3178
|
-
if on_change is not None:
|
|
3179
|
-
try:
|
|
3180
|
-
on_change(value)
|
|
3181
|
-
except Exception:
|
|
3182
|
-
pass
|
|
3183
|
-
|
|
3184
|
-
return _press
|
|
3185
|
-
|
|
3186
|
-
buttons: List[Dict[str, Any]] = []
|
|
3187
|
-
for item in items:
|
|
3188
|
-
if not isinstance(item, dict):
|
|
3189
|
-
continue
|
|
3190
|
-
label = str(item.get("label", item.get("value", "")))
|
|
3191
|
-
buttons.append({"label": label, "on_press": _make_press(item.get("value"))})
|
|
3192
|
-
buttons.append({"label": "Cancel", "style": "cancel"})
|
|
3193
|
-
_present_alert(title=str(placeholder), message=None, buttons=buttons, style="action_sheet")
|
|
3290
|
+
buttons: List[Dict[str, Any]] = [{"label": str(item.get("label", item.get("value", "")))} for item in items]
|
|
3291
|
+
buttons.append({"label": "Cancel", "style": "cancel"})
|
|
3194
3292
|
|
|
3293
|
+
def _on_result(index: int) -> None:
|
|
3294
|
+
if 0 <= index < len(items):
|
|
3295
|
+
_fire(btn, "on_change", items[index].get("value"))
|
|
3195
3296
|
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3297
|
+
_present_alert(
|
|
3298
|
+
title=str(placeholder),
|
|
3299
|
+
message=None,
|
|
3300
|
+
buttons=buttons,
|
|
3301
|
+
style="action_sheet",
|
|
3302
|
+
on_result=_on_result,
|
|
3303
|
+
)
|
|
3203
3304
|
|
|
3204
3305
|
|
|
3205
3306
|
class PickerHandler(IOSViewHandler):
|
|
3206
3307
|
"""``Picker`` element handler — native action-sheet dropdown."""
|
|
3207
3308
|
|
|
3208
|
-
def
|
|
3209
|
-
|
|
3210
|
-
btn = UIButton.buttonWithType_(1) # UIButtonTypeSystem
|
|
3309
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
3310
|
+
btn = ObjCClass("UIButton").buttonWithType_(1) # UIButtonTypeSystem
|
|
3211
3311
|
btn.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
"on_change": props.get("on_change"),
|
|
3216
|
-
"placeholder": props.get("placeholder") or "Select…",
|
|
3217
|
-
"value": props.get("value"),
|
|
3218
|
-
}
|
|
3219
|
-
target = _picker_make_target(bid)
|
|
3220
|
-
_pn_picker_state[bid]["target"] = target
|
|
3221
|
-
btn.addTarget_action_forControlEvents_(target, SEL("onTap:"), 1 << 6) # touchUpInside
|
|
3222
|
-
btn.setTitle_forState_(_picker_button_title(props), 0)
|
|
3223
|
-
_apply_accessibility(btn, props)
|
|
3312
|
+
btn.retain()
|
|
3313
|
+
_pn_retained_views.append(btn)
|
|
3314
|
+
_register_control_action(btn, 1 << 6, lambda: _present_picker_sheet(btn)) # TouchUpInside
|
|
3224
3315
|
return btn
|
|
3225
3316
|
|
|
3226
|
-
def
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
_apply_accessibility(native_view, changed)
|
|
3317
|
+
def _apply(self, btn: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
3318
|
+
merged = _state_of(btn).get("props") or props
|
|
3319
|
+
try:
|
|
3320
|
+
btn.setTitle_forState_(_picker_button_title(merged), 0)
|
|
3321
|
+
except Exception:
|
|
3322
|
+
pass
|
|
3323
|
+
_apply_accessibility(btn, props)
|
|
3234
3324
|
|
|
3235
3325
|
def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
|
|
3236
3326
|
try:
|
|
@@ -3247,13 +3337,10 @@ class PickerHandler(IOSViewHandler):
|
|
|
3247
3337
|
# ======================================================================
|
|
3248
3338
|
|
|
3249
3339
|
|
|
3250
|
-
|
|
3251
|
-
# Maps ``id(target)`` -> ``id(button)`` for per-instance checkbox lookup.
|
|
3252
|
-
_pn_checkbox_target_to_button: dict = {}
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
def _checkbox_set_image(btn: Any, state: Dict[str, Any]) -> None:
|
|
3340
|
+
def _checkbox_set_image(btn: Any) -> None:
|
|
3256
3341
|
"""Set the box image from the current checked state (tinted when checked)."""
|
|
3342
|
+
state = _state_of(btn)
|
|
3343
|
+
merged = state.get("props") or {}
|
|
3257
3344
|
checked = bool(state.get("value"))
|
|
3258
3345
|
try:
|
|
3259
3346
|
UIImage = ObjCClass("UIImage")
|
|
@@ -3261,7 +3348,7 @@ def _checkbox_set_image(btn: Any, state: Dict[str, Any]) -> None:
|
|
|
3261
3348
|
image = UIImage.systemImageNamed_(name)
|
|
3262
3349
|
if image is None:
|
|
3263
3350
|
return
|
|
3264
|
-
color =
|
|
3351
|
+
color = merged.get("color")
|
|
3265
3352
|
if checked and color is not None:
|
|
3266
3353
|
try:
|
|
3267
3354
|
tinted = image.imageWithTintColor_(_uicolor(color))
|
|
@@ -3274,54 +3361,67 @@ def _checkbox_set_image(btn: Any, state: Dict[str, Any]) -> None:
|
|
|
3274
3361
|
pass
|
|
3275
3362
|
|
|
3276
3363
|
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
_checkbox_set_image(btn, state)
|
|
3291
|
-
cb = state.get("on_change")
|
|
3292
|
-
if cb is not None:
|
|
3293
|
-
try:
|
|
3294
|
-
cb(new_value)
|
|
3295
|
-
except Exception:
|
|
3296
|
-
pass
|
|
3364
|
+
def _checkbox_toggle(btn: Any) -> None:
|
|
3365
|
+
"""Flip a Checkbox button's checked state and fire ``on_change``."""
|
|
3366
|
+
state = _state_of(btn)
|
|
3367
|
+
merged = state.get("props") or {}
|
|
3368
|
+
if merged.get("disabled"):
|
|
3369
|
+
return
|
|
3370
|
+
new_value = not bool(state.get("value"))
|
|
3371
|
+
# Optimistic local flip so the box feels instant even if the
|
|
3372
|
+
# app's re-render is a frame behind; the authoritative ``value``
|
|
3373
|
+
# prop re-syncs it on the next update.
|
|
3374
|
+
state["value"] = new_value
|
|
3375
|
+
_checkbox_set_image(btn)
|
|
3376
|
+
_fire(btn, "on_change", new_value)
|
|
3297
3377
|
|
|
3298
3378
|
|
|
3299
3379
|
class CheckboxHandler(IOSViewHandler):
|
|
3300
|
-
def
|
|
3301
|
-
|
|
3302
|
-
btn = UIButton.buttonWithType_(0) # UIButtonTypeCustom
|
|
3380
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
3381
|
+
btn = ObjCClass("UIButton").buttonWithType_(0) # UIButtonTypeCustom
|
|
3303
3382
|
btn.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
3304
3383
|
btn.retain()
|
|
3305
3384
|
_pn_retained_views.append(btn)
|
|
3306
|
-
|
|
3307
|
-
_pn_checkbox_state[bid] = {
|
|
3308
|
-
"value": bool(props.get("value")),
|
|
3309
|
-
"on_change": props.get("on_change"),
|
|
3310
|
-
"color": props.get("color"),
|
|
3311
|
-
"disabled": bool(props.get("disabled")),
|
|
3312
|
-
"view": btn,
|
|
3313
|
-
}
|
|
3314
|
-
target = _PNCheckboxTarget.new()
|
|
3315
|
-
target.retain()
|
|
3316
|
-
_pn_retained_views.append(target)
|
|
3317
|
-
_pn_checkbox_target_to_button[id(target)] = bid
|
|
3318
|
-
_pn_checkbox_state[bid]["target"] = target
|
|
3319
|
-
btn.addTarget_action_forControlEvents_(target, SEL("onToggle:"), 1 << 6) # touchUpInside
|
|
3320
|
-
self._apply(btn, props)
|
|
3385
|
+
_register_control_action(btn, 1 << 6, lambda: _checkbox_toggle(btn)) # TouchUpInside
|
|
3321
3386
|
return btn
|
|
3322
3387
|
|
|
3323
|
-
def
|
|
3324
|
-
|
|
3388
|
+
def _apply(self, btn: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
3389
|
+
state = _state_of(btn)
|
|
3390
|
+
if initial:
|
|
3391
|
+
# UIButtonTypeCustom defaults to a white title and inherits
|
|
3392
|
+
# no useful tint, so both the label and the SF Symbol box
|
|
3393
|
+
# are invisible on light backgrounds without an explicit
|
|
3394
|
+
# color.
|
|
3395
|
+
try:
|
|
3396
|
+
btn.setTitleColor_forState_(_uicolor("#111111"), 0)
|
|
3397
|
+
btn.setTintColor_(_uicolor("#111111"))
|
|
3398
|
+
except Exception:
|
|
3399
|
+
pass
|
|
3400
|
+
if "value" in props:
|
|
3401
|
+
state["value"] = bool(props["value"])
|
|
3402
|
+
if "label" in props:
|
|
3403
|
+
label = props["label"]
|
|
3404
|
+
try:
|
|
3405
|
+
btn.setTitle_forState_(str(label) if label is not None else "", 0)
|
|
3406
|
+
except Exception:
|
|
3407
|
+
pass
|
|
3408
|
+
# An image-bearing custom button is not exposed to the
|
|
3409
|
+
# accessibility tree by title alone; mirror the label
|
|
3410
|
+
# explicitly (an accessibility_label prop still wins below).
|
|
3411
|
+
try:
|
|
3412
|
+
btn.setAccessibilityLabel_(str(label) if label is not None else "")
|
|
3413
|
+
except Exception:
|
|
3414
|
+
pass
|
|
3415
|
+
if "disabled" in props:
|
|
3416
|
+
# ``disabled`` is present only when True; a removed prop
|
|
3417
|
+
# (None) re-enables the control.
|
|
3418
|
+
disabled = bool(props["disabled"]) if props["disabled"] is not None else False
|
|
3419
|
+
try:
|
|
3420
|
+
btn.setEnabled_(not disabled)
|
|
3421
|
+
except Exception:
|
|
3422
|
+
pass
|
|
3423
|
+
_checkbox_set_image(btn)
|
|
3424
|
+
_apply_accessibility(btn, props)
|
|
3325
3425
|
|
|
3326
3426
|
def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
|
|
3327
3427
|
try:
|
|
@@ -3336,110 +3436,40 @@ class CheckboxHandler(IOSViewHandler):
|
|
|
3336
3436
|
except Exception:
|
|
3337
3437
|
return (28.0, 28.0)
|
|
3338
3438
|
|
|
3339
|
-
def _apply(self, btn: Any, props: Dict[str, Any]) -> None:
|
|
3340
|
-
state = _pn_checkbox_state.setdefault(id(btn), {"view": btn})
|
|
3341
|
-
if "value" in props:
|
|
3342
|
-
state["value"] = bool(props["value"])
|
|
3343
|
-
if "on_change" in props:
|
|
3344
|
-
state["on_change"] = props["on_change"]
|
|
3345
|
-
if "color" in props:
|
|
3346
|
-
state["color"] = props["color"]
|
|
3347
|
-
if "label" in props:
|
|
3348
|
-
label = props["label"]
|
|
3349
|
-
btn.setTitle_forState_(str(label) if label is not None else "", 0)
|
|
3350
|
-
if "disabled" in props:
|
|
3351
|
-
# ``disabled`` is present only when True; a removed prop (None)
|
|
3352
|
-
# re-enables the control.
|
|
3353
|
-
disabled = bool(props["disabled"]) if props["disabled"] is not None else False
|
|
3354
|
-
state["disabled"] = disabled
|
|
3355
|
-
try:
|
|
3356
|
-
btn.setEnabled_(not disabled)
|
|
3357
|
-
except Exception:
|
|
3358
|
-
pass
|
|
3359
|
-
_checkbox_set_image(btn, state)
|
|
3360
|
-
_apply_accessibility(btn, props)
|
|
3361
|
-
|
|
3362
3439
|
|
|
3363
3440
|
# ======================================================================
|
|
3364
3441
|
# SegmentedControl — native UISegmentedControl
|
|
3365
3442
|
# ======================================================================
|
|
3366
3443
|
|
|
3367
3444
|
|
|
3368
|
-
_pn_segmented_state: dict = {}
|
|
3369
|
-
# Maps ``id(target)`` -> ``id(control)`` for per-instance lookup on change.
|
|
3370
|
-
_pn_segmented_target_to_control: dict = {}
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
class _PNSegmentedTarget(NSObject): # type: ignore[valid-type]
|
|
3374
|
-
@objc_method
|
|
3375
|
-
def onChange_(self, sender: object) -> None:
|
|
3376
|
-
cid = _pn_segmented_target_to_control.get(id(self))
|
|
3377
|
-
if cid is None:
|
|
3378
|
-
return
|
|
3379
|
-
state = _pn_segmented_state.get(cid)
|
|
3380
|
-
if not state or state.get("suppress"):
|
|
3381
|
-
return
|
|
3382
|
-
cb = state.get("on_change")
|
|
3383
|
-
if cb is None:
|
|
3384
|
-
return
|
|
3385
|
-
try:
|
|
3386
|
-
index = int(sender.selectedSegmentIndex)
|
|
3387
|
-
except Exception:
|
|
3388
|
-
return
|
|
3389
|
-
try:
|
|
3390
|
-
cb(index)
|
|
3391
|
-
except Exception:
|
|
3392
|
-
pass
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
3445
|
class SegmentedControlHandler(IOSViewHandler):
|
|
3396
|
-
def
|
|
3446
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
3397
3447
|
UISegmentedControl = ObjCClass("UISegmentedControl")
|
|
3398
3448
|
segments = [str(s) for s in (props.get("segments") or [])]
|
|
3399
3449
|
control = UISegmentedControl.alloc().initWithItems_(segments)
|
|
3400
3450
|
control.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
3401
3451
|
control.retain()
|
|
3402
3452
|
_pn_retained_views.append(control)
|
|
3403
|
-
cid = id(control)
|
|
3404
|
-
_pn_segmented_state[cid] = {
|
|
3405
|
-
"segments": list(segments),
|
|
3406
|
-
"selected_index": 0,
|
|
3407
|
-
"on_change": props.get("on_change"),
|
|
3408
|
-
"suppress": False,
|
|
3409
|
-
}
|
|
3410
|
-
target = _PNSegmentedTarget.new()
|
|
3411
|
-
target.retain()
|
|
3412
|
-
_pn_retained_views.append(target)
|
|
3413
|
-
_pn_segmented_target_to_control[id(target)] = cid
|
|
3414
|
-
_pn_segmented_state[cid]["target"] = target
|
|
3415
|
-
control.addTarget_action_forControlEvents_(target, SEL("onChange:"), 1 << 12) # ValueChanged
|
|
3416
|
-
self._apply(control, props)
|
|
3417
|
-
return control
|
|
3418
3453
|
|
|
3419
|
-
|
|
3420
|
-
|
|
3454
|
+
def _on_change() -> None:
|
|
3455
|
+
if _state_of(control).get("suppress"):
|
|
3456
|
+
return
|
|
3457
|
+
try:
|
|
3458
|
+
index = int(control.selectedSegmentIndex)
|
|
3459
|
+
except Exception:
|
|
3460
|
+
return
|
|
3461
|
+
_fire(control, "on_change", index)
|
|
3421
3462
|
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
mw = _safe_max(max_width, fallback=10000.0)
|
|
3425
|
-
mh = _safe_max(max_height, fallback=10000.0)
|
|
3426
|
-
size = native_view.sizeThatFits_((mw, mh))
|
|
3427
|
-
w = float(size.width)
|
|
3428
|
-
if math.isfinite(max_width):
|
|
3429
|
-
w = min(w, max_width)
|
|
3430
|
-
return (max(w, 0.0), max(float(size.height), 0.0))
|
|
3431
|
-
except Exception:
|
|
3432
|
-
return (0.0, 0.0)
|
|
3463
|
+
_register_control_action(control, 1 << 12, _on_change) # ValueChanged
|
|
3464
|
+
return control
|
|
3433
3465
|
|
|
3434
|
-
def _apply(self, control: Any, props: Dict[str, Any]) -> None:
|
|
3435
|
-
state =
|
|
3436
|
-
|
|
3437
|
-
state["on_change"] = props["on_change"]
|
|
3466
|
+
def _apply(self, control: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
3467
|
+
state = _state_of(control)
|
|
3468
|
+
merged = state.get("props") or props
|
|
3438
3469
|
rebuilt = False
|
|
3439
|
-
if "segments" in props and props["segments"] is not None:
|
|
3470
|
+
if "segments" in props and props["segments"] is not None and not initial:
|
|
3440
3471
|
new_segments = [str(s) for s in props["segments"]]
|
|
3441
3472
|
if new_segments != state.get("segments"):
|
|
3442
|
-
state["segments"] = new_segments
|
|
3443
3473
|
state["suppress"] = True
|
|
3444
3474
|
try:
|
|
3445
3475
|
control.removeAllSegments()
|
|
@@ -3450,14 +3480,14 @@ class SegmentedControlHandler(IOSViewHandler):
|
|
|
3450
3480
|
finally:
|
|
3451
3481
|
state["suppress"] = False
|
|
3452
3482
|
rebuilt = True
|
|
3453
|
-
if "
|
|
3454
|
-
state["
|
|
3483
|
+
if "segments" in props and props["segments"] is not None:
|
|
3484
|
+
state["segments"] = [str(s) for s in props["segments"]]
|
|
3455
3485
|
# Apply the selection when it changed or after a segment rebuild
|
|
3456
3486
|
# (rebuilding resets the control to "no segment selected").
|
|
3457
|
-
if rebuilt or ("selected_index" in props and props["selected_index"] is not None):
|
|
3487
|
+
if rebuilt or ("selected_index" in props and props["selected_index"] is not None) or initial:
|
|
3458
3488
|
state["suppress"] = True
|
|
3459
3489
|
try:
|
|
3460
|
-
control.setSelectedSegmentIndex_(int(
|
|
3490
|
+
control.setSelectedSegmentIndex_(int(merged.get("selected_index", 0) or 0))
|
|
3461
3491
|
except Exception:
|
|
3462
3492
|
pass
|
|
3463
3493
|
finally:
|
|
@@ -3473,8 +3503,8 @@ class SegmentedControlHandler(IOSViewHandler):
|
|
|
3473
3503
|
except Exception:
|
|
3474
3504
|
pass
|
|
3475
3505
|
if "enabled" in props:
|
|
3476
|
-
# ``enabled`` is present only when False; a removed prop
|
|
3477
|
-
# re-enables the control.
|
|
3506
|
+
# ``enabled`` is present only when False; a removed prop
|
|
3507
|
+
# (None) re-enables the control.
|
|
3478
3508
|
enabled = props["enabled"]
|
|
3479
3509
|
try:
|
|
3480
3510
|
control.setEnabled_(True if enabled is None else bool(enabled))
|
|
@@ -3482,6 +3512,18 @@ class SegmentedControlHandler(IOSViewHandler):
|
|
|
3482
3512
|
pass
|
|
3483
3513
|
_apply_accessibility(control, props)
|
|
3484
3514
|
|
|
3515
|
+
def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
|
|
3516
|
+
try:
|
|
3517
|
+
mw = _safe_max(max_width, fallback=10000.0)
|
|
3518
|
+
mh = _safe_max(max_height, fallback=10000.0)
|
|
3519
|
+
size = native_view.sizeThatFits_((mw, mh))
|
|
3520
|
+
w = float(size.width)
|
|
3521
|
+
if math.isfinite(max_width):
|
|
3522
|
+
w = min(w, max_width)
|
|
3523
|
+
return (max(w, 0.0), max(float(size.height), 0.0))
|
|
3524
|
+
except Exception:
|
|
3525
|
+
return (0.0, 0.0)
|
|
3526
|
+
|
|
3485
3527
|
|
|
3486
3528
|
# ======================================================================
|
|
3487
3529
|
# DatePicker — native UIDatePicker (compact style on iOS 13.4+)
|
|
@@ -3489,10 +3531,7 @@ class SegmentedControlHandler(IOSViewHandler):
|
|
|
3489
3531
|
|
|
3490
3532
|
|
|
3491
3533
|
_DATE_PICKER_FORMATS = {"date": "yyyy-MM-dd", "time": "HH:mm", "datetime": "yyyy-MM-dd'T'HH:mm"}
|
|
3492
|
-
_pn_date_formatters:
|
|
3493
|
-
_pn_datepicker_state: dict = {}
|
|
3494
|
-
# Maps ``id(target)`` -> ``id(picker)`` for per-instance lookup on change.
|
|
3495
|
-
_pn_datepicker_target_to_picker: dict = {}
|
|
3534
|
+
_pn_date_formatters: Dict[str, Any] = {}
|
|
3496
3535
|
|
|
3497
3536
|
|
|
3498
3537
|
def _date_formatter(mode: str) -> Any:
|
|
@@ -3501,8 +3540,7 @@ def _date_formatter(mode: str) -> Any:
|
|
|
3501
3540
|
cached = _pn_date_formatters.get(fmt)
|
|
3502
3541
|
if cached is not None:
|
|
3503
3542
|
return cached
|
|
3504
|
-
|
|
3505
|
-
formatter = NSDateFormatter.alloc().init()
|
|
3543
|
+
formatter = ObjCClass("NSDateFormatter").alloc().init()
|
|
3506
3544
|
formatter.setDateFormat_(fmt)
|
|
3507
3545
|
# A fixed POSIX locale keeps fixed-format parsing deterministic
|
|
3508
3546
|
# (24-hour clock, no calendar/locale surprises) per Apple guidance.
|
|
@@ -3516,31 +3554,8 @@ def _date_formatter(mode: str) -> Any:
|
|
|
3516
3554
|
return formatter
|
|
3517
3555
|
|
|
3518
3556
|
|
|
3519
|
-
class _PNDatePickerTarget(NSObject): # type: ignore[valid-type]
|
|
3520
|
-
@objc_method
|
|
3521
|
-
def onChange_(self, sender: object) -> None:
|
|
3522
|
-
pid = _pn_datepicker_target_to_picker.get(id(self))
|
|
3523
|
-
if pid is None:
|
|
3524
|
-
return
|
|
3525
|
-
state = _pn_datepicker_state.get(pid)
|
|
3526
|
-
if not state or state.get("suppress"):
|
|
3527
|
-
return
|
|
3528
|
-
cb = state.get("on_change")
|
|
3529
|
-
if cb is None:
|
|
3530
|
-
return
|
|
3531
|
-
mode = state.get("mode", "date")
|
|
3532
|
-
try:
|
|
3533
|
-
iso = str(_date_formatter(mode).stringFromDate_(sender.date))
|
|
3534
|
-
except Exception:
|
|
3535
|
-
return
|
|
3536
|
-
try:
|
|
3537
|
-
cb(iso)
|
|
3538
|
-
except Exception:
|
|
3539
|
-
pass
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
3557
|
class DatePickerHandler(IOSViewHandler):
|
|
3543
|
-
def
|
|
3558
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
3544
3559
|
picker = ObjCClass("UIDatePicker").alloc().init()
|
|
3545
3560
|
picker.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
3546
3561
|
picker.retain()
|
|
@@ -3551,44 +3566,26 @@ class DatePickerHandler(IOSViewHandler):
|
|
|
3551
3566
|
picker.setPreferredDatePickerStyle_(2) # UIDatePickerStyleCompact
|
|
3552
3567
|
except Exception:
|
|
3553
3568
|
pass
|
|
3554
|
-
pid = id(picker)
|
|
3555
|
-
_pn_datepicker_state[pid] = {
|
|
3556
|
-
"mode": props.get("mode", "date"),
|
|
3557
|
-
"on_change": props.get("on_change"),
|
|
3558
|
-
"suppress": False,
|
|
3559
|
-
}
|
|
3560
|
-
target = _PNDatePickerTarget.new()
|
|
3561
|
-
target.retain()
|
|
3562
|
-
_pn_retained_views.append(target)
|
|
3563
|
-
_pn_datepicker_target_to_picker[id(target)] = pid
|
|
3564
|
-
_pn_datepicker_state[pid]["target"] = target
|
|
3565
|
-
picker.addTarget_action_forControlEvents_(target, SEL("onChange:"), 1 << 12) # ValueChanged
|
|
3566
|
-
self._apply(picker, props)
|
|
3567
|
-
return picker
|
|
3568
3569
|
|
|
3569
|
-
|
|
3570
|
-
|
|
3570
|
+
def _on_change() -> None:
|
|
3571
|
+
state = _state_of(picker)
|
|
3572
|
+
if state.get("suppress"):
|
|
3573
|
+
return
|
|
3574
|
+
mode = (state.get("props") or {}).get("mode", "date")
|
|
3575
|
+
try:
|
|
3576
|
+
iso = str(_date_formatter(mode).stringFromDate_(picker.date))
|
|
3577
|
+
except Exception:
|
|
3578
|
+
return
|
|
3579
|
+
_fire(picker, "on_change", iso)
|
|
3571
3580
|
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
mw = _safe_max(max_width, fallback=10000.0)
|
|
3575
|
-
mh = _safe_max(max_height, fallback=10000.0)
|
|
3576
|
-
size = native_view.sizeThatFits_((mw, mh))
|
|
3577
|
-
w = float(size.width)
|
|
3578
|
-
if math.isfinite(max_width):
|
|
3579
|
-
w = min(w, max_width)
|
|
3580
|
-
return (max(w, 0.0), max(float(size.height), 0.0))
|
|
3581
|
-
except Exception:
|
|
3582
|
-
return (0.0, 0.0)
|
|
3581
|
+
_register_control_action(picker, 1 << 12, _on_change) # ValueChanged
|
|
3582
|
+
return picker
|
|
3583
3583
|
|
|
3584
|
-
def _apply(self, picker: Any, props: Dict[str, Any]) -> None:
|
|
3585
|
-
state =
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
mode = state.get("mode", "date")
|
|
3584
|
+
def _apply(self, picker: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
3585
|
+
state = _state_of(picker)
|
|
3586
|
+
merged = state.get("props") or props
|
|
3587
|
+
mode = str(merged.get("mode", "date") or "date")
|
|
3589
3588
|
if "mode" in props and props["mode"] is not None:
|
|
3590
|
-
mode = str(props["mode"])
|
|
3591
|
-
state["mode"] = mode
|
|
3592
3589
|
mode_map = {"time": 0, "date": 1, "datetime": 2}
|
|
3593
3590
|
try:
|
|
3594
3591
|
picker.setDatePickerMode_(mode_map.get(mode, 1))
|
|
@@ -3612,8 +3609,8 @@ class DatePickerHandler(IOSViewHandler):
|
|
|
3612
3609
|
finally:
|
|
3613
3610
|
state["suppress"] = False
|
|
3614
3611
|
if "enabled" in props:
|
|
3615
|
-
# ``enabled`` is present only when False; a removed prop
|
|
3616
|
-
# re-enables the picker.
|
|
3612
|
+
# ``enabled`` is present only when False; a removed prop
|
|
3613
|
+
# (None) re-enables the picker.
|
|
3617
3614
|
enabled = props["enabled"]
|
|
3618
3615
|
try:
|
|
3619
3616
|
picker.setEnabled_(True if enabled is None else bool(enabled))
|
|
@@ -3631,6 +3628,18 @@ class DatePickerHandler(IOSViewHandler):
|
|
|
3631
3628
|
except Exception:
|
|
3632
3629
|
pass
|
|
3633
3630
|
|
|
3631
|
+
def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
|
|
3632
|
+
try:
|
|
3633
|
+
mw = _safe_max(max_width, fallback=10000.0)
|
|
3634
|
+
mh = _safe_max(max_height, fallback=10000.0)
|
|
3635
|
+
size = native_view.sizeThatFits_((mw, mh))
|
|
3636
|
+
w = float(size.width)
|
|
3637
|
+
if math.isfinite(max_width):
|
|
3638
|
+
w = min(w, max_width)
|
|
3639
|
+
return (max(w, 0.0), max(float(size.height), 0.0))
|
|
3640
|
+
except Exception:
|
|
3641
|
+
return (0.0, 0.0)
|
|
3642
|
+
|
|
3634
3643
|
|
|
3635
3644
|
# ======================================================================
|
|
3636
3645
|
# Registration
|
|
@@ -3640,12 +3649,11 @@ class DatePickerHandler(IOSViewHandler):
|
|
|
3640
3649
|
def register_handlers(registry: Any) -> None:
|
|
3641
3650
|
"""Register all iOS view handlers with the given registry."""
|
|
3642
3651
|
flex = FlexContainerHandler()
|
|
3643
|
-
registry.register("
|
|
3644
|
-
registry.register("Button", ButtonHandler())
|
|
3652
|
+
registry.register("View", flex)
|
|
3645
3653
|
registry.register("Column", flex)
|
|
3646
3654
|
registry.register("Row", flex)
|
|
3647
|
-
registry.register("
|
|
3648
|
-
registry.register("
|
|
3655
|
+
registry.register("Text", TextHandler())
|
|
3656
|
+
registry.register("Button", ButtonHandler())
|
|
3649
3657
|
registry.register("TextInput", TextInputHandler())
|
|
3650
3658
|
registry.register("Image", ImageHandler())
|
|
3651
3659
|
registry.register("Switch", SwitchHandler())
|
|
@@ -3653,6 +3661,7 @@ def register_handlers(registry: Any) -> None:
|
|
|
3653
3661
|
registry.register("ActivityIndicator", ActivityIndicatorHandler())
|
|
3654
3662
|
registry.register("WebView", WebViewHandler())
|
|
3655
3663
|
registry.register("Spacer", SpacerHandler())
|
|
3664
|
+
registry.register("ScrollView", ScrollViewHandler())
|
|
3656
3665
|
registry.register("SafeAreaView", SafeAreaViewHandler())
|
|
3657
3666
|
registry.register("Modal", ModalHandler())
|
|
3658
3667
|
registry.register("Slider", SliderHandler())
|
|
@@ -3660,7 +3669,6 @@ def register_handlers(registry: Any) -> None:
|
|
|
3660
3669
|
registry.register("Pressable", PressableHandler())
|
|
3661
3670
|
registry.register("StatusBar", StatusBarHandler())
|
|
3662
3671
|
registry.register("KeyboardAvoidingView", KeyboardAvoidingViewHandler())
|
|
3663
|
-
registry.register("VirtualList", VirtualListHandler())
|
|
3664
3672
|
registry.register("Picker", PickerHandler())
|
|
3665
3673
|
registry.register("Checkbox", CheckboxHandler())
|
|
3666
3674
|
registry.register("SegmentedControl", SegmentedControlHandler())
|
|
@@ -3687,15 +3695,9 @@ __all__ = [
|
|
|
3687
3695
|
"PressableHandler",
|
|
3688
3696
|
"StatusBarHandler",
|
|
3689
3697
|
"KeyboardAvoidingViewHandler",
|
|
3690
|
-
"VirtualListHandler",
|
|
3691
3698
|
"PickerHandler",
|
|
3692
3699
|
"CheckboxHandler",
|
|
3693
3700
|
"SegmentedControlHandler",
|
|
3694
3701
|
"DatePickerHandler",
|
|
3695
3702
|
"register_handlers",
|
|
3696
3703
|
]
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
# Avoid an unused-import warning from threading; it's available for
|
|
3700
|
-
# future delegate use (e.g., Camera/Location callbacks).
|
|
3701
|
-
_ = threading
|