pythonnative 0.26.0__py3-none-any.whl → 0.28.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.
@@ -14,20 +14,38 @@ is forwarded through
14
14
  view's reconciler-assigned tag.
15
15
 
16
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
17
+ `pythonnative.layout`: container handlers create hit-test-aware
18
+ ``UIView`` subclasses (see ``_PNInteractiveViewCTypes``), the engine
19
+ computes per-child frames in points, and
19
20
  [`set_frame`][pythonnative.native_views.ios.IOSViewHandler.set_frame]
20
21
  applies those frames via UIKit's classic ``frame`` property (with Auto
21
22
  Layout disabled).
22
23
 
23
24
  **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.
25
+ rotation, tap, long-press, swipe, fling) configured from the
26
+ serialized gesture specs, so recognition runs fully natively. The
27
+ composition metadata emitted by
28
+ [`serialize_gestures`][pythonnative.gestures.serialize_gestures] is
29
+ honored natively too: the shared delegate answers
30
+ ``shouldRecognizeSimultaneously...`` from each spec's
31
+ ``"simultaneous"`` set, and ``"wait_for"`` relationships map onto
32
+ ``requireGestureRecognizerToFail:``.
33
+
34
+ **Interaction props**: ``pointer_events`` and ``hit_slop`` are
35
+ implemented by overriding ``pointInside:withEvent:`` and
36
+ ``hitTest:withEvent:`` on the container subclass; ``z_index`` maps to
37
+ ``layer.zPosition``. Per-corner radii (``border_top_left_radius`` and
38
+ friends) use ``layer.maskedCorners`` when one radius is shared and a
39
+ ``CAShapeLayer`` mask when radii differ.
26
40
 
27
41
  **Animations**: ``timing`` and ``spring`` specs are driven by UIKit
28
42
  block animations with completion callbacks reported back through
29
43
  ``pythonnative.animated.native_animation_completed``; ``decay`` falls
30
- back to the Python ticker.
44
+ back to the Python ticker. Python-driven frames arriving through
45
+ ``set_animated_property`` accept floats for opacity and transform
46
+ channels, numeric degrees for ``rotate``, and ``"#RRGGBB"`` /
47
+ ``"#AARRGGBB"`` strings for ``background_color`` and ``color``;
48
+ unknown properties are ignored.
31
49
 
32
50
  This module is only imported on iOS at runtime. Desktop tests inject a
33
51
  mock registry via
@@ -205,6 +223,10 @@ _add_method = _libobjc.class_addMethod
205
223
  _add_method.restype = _ct.c_bool
206
224
  _add_method.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_char_p]
207
225
 
226
+ _get_method_imp = _libobjc.class_getMethodImplementation
227
+ _get_method_imp.restype = _ct.c_void_p
228
+ _get_method_imp.argtypes = [_ct.c_void_p, _ct.c_void_p]
229
+
208
230
  _objc_msgSend = _libobjc.objc_msgSend
209
231
 
210
232
  _SEL_ALLOC = _sel_reg(b"alloc")
@@ -226,6 +248,11 @@ _SEL_TEXT_VIEW_DID_END = _sel_reg(b"textViewDidEndEditing:")
226
248
  _SEL_TEXT_VIEW_DID_CHANGE = _sel_reg(b"textViewDidChange:")
227
249
 
228
250
  _NS_OBJECT_CLS = _get_cls(b"NSObject")
251
+ _UIVIEW_CLS = _get_cls(b"UIView")
252
+
253
+
254
+ class _PNCGPoint(_ct.Structure):
255
+ _fields_ = [("x", _ct.c_double), ("y", _ct.c_double)]
229
256
 
230
257
 
231
258
  # ======================================================================
@@ -422,6 +449,139 @@ def _clamp_layer_corner_radius(layer: Any, width: float, height: float) -> None:
422
449
  diagnostics.swallowed("ios._clamp_layer_corner_radius")
423
450
 
424
451
 
452
+ # ----------------------------------------------------------------------
453
+ # Per-corner radii
454
+ # ----------------------------------------------------------------------
455
+ #
456
+ # When every rounded corner shares one radius the cheap path applies:
457
+ # ``layer.cornerRadius`` restricted to the rounded corners through
458
+ # ``layer.maskedCorners``. When the radii differ, UIKit has no direct
459
+ # API, so the view's layer gets a ``CAShapeLayer`` mask built from a
460
+ # ``UIBezierPath`` with one arc per corner; the mask depends on the
461
+ # view's bounds, so ``set_frame`` rebuilds it on every frame change via
462
+ # ``_update_corner_mask``.
463
+
464
+ # Order matches ``_MASKED_CORNER_BITS``: top-left, top-right,
465
+ # bottom-left, bottom-right.
466
+ _CORNER_RADIUS_KEYS = (
467
+ "border_top_left_radius",
468
+ "border_top_right_radius",
469
+ "border_bottom_left_radius",
470
+ "border_bottom_right_radius",
471
+ )
472
+ # CACornerMask bits: kCALayerMinXMinYCorner (top-left) = 1,
473
+ # MaxXMinY (top-right) = 2, MinXMaxY (bottom-left) = 4,
474
+ # MaxXMaxY (bottom-right) = 8.
475
+ _MASKED_CORNER_BITS = (1, 2, 4, 8)
476
+ _ALL_MASKED_CORNERS = 15
477
+
478
+ # id(view) -> (tl, tr, bl, br) radii for views on the shape-mask path.
479
+ _pn_corner_radii_map: dict = {}
480
+
481
+
482
+ def _apply_corner_radii(view: Any, merged: Dict[str, Any]) -> None:
483
+ """Apply per-corner radii from the *merged* prop set.
484
+
485
+ Unspecified corners inherit the uniform ``border_radius`` (else 0),
486
+ matching the Android backend. With every per-corner key absent the
487
+ entry is dropped and the uniform path owns the corners again.
488
+ """
489
+ if not any(merged.get(k) is not None for k in _CORNER_RADIUS_KEYS):
490
+ if _pn_corner_radii_map.pop(id(view), None) is not None:
491
+ try:
492
+ view.layer.setMask_(None)
493
+ view.layer.setMaskedCorners_(_ALL_MASKED_CORNERS)
494
+ except Exception:
495
+ diagnostics.swallowed("ios._apply_corner_radii")
496
+ _apply_view_border(view, merged)
497
+ return
498
+ base = float(merged.get("border_radius") or 0.0)
499
+ radii = tuple(
500
+ max(0.0, _safe_finite(merged[k], base)) if merged.get(k) is not None else max(0.0, base)
501
+ for k in _CORNER_RADIUS_KEYS
502
+ )
503
+ distinct = {r for r in radii if r > 0.0}
504
+ if len(distinct) <= 1:
505
+ # One shared radius (or all zero): plain cornerRadius limited
506
+ # to the rounded corners.
507
+ _pn_corner_radii_map.pop(id(view), None)
508
+ value = distinct.pop() if distinct else 0.0
509
+ mask = 0
510
+ for bit, r in zip(_MASKED_CORNER_BITS, radii):
511
+ if r > 0.0:
512
+ mask |= bit
513
+ try:
514
+ view.layer.setMask_(None)
515
+ view.layer.setCornerRadius_(value)
516
+ view.layer.setMaskedCorners_(mask if mask else _ALL_MASKED_CORNERS)
517
+ view.layer.setMasksToBounds_(True)
518
+ except Exception:
519
+ diagnostics.swallowed("ios._apply_corner_radii")
520
+ # Route the shared value through the frame-time pill clamp.
521
+ _pn_view_border_radius_map[id(view)] = value
522
+ return
523
+ # Differing radii: shape-layer mask, rebuilt at frame time.
524
+ _pn_view_border_radius_map.pop(id(view), None)
525
+ _pn_corner_radii_map[id(view)] = radii
526
+ try:
527
+ view.layer.setCornerRadius_(0.0)
528
+ view.layer.setMaskedCorners_(_ALL_MASKED_CORNERS)
529
+ except Exception:
530
+ diagnostics.swallowed("ios._apply_corner_radii")
531
+ try:
532
+ bounds = view.bounds
533
+ w = float(bounds.size.width)
534
+ h = float(bounds.size.height)
535
+ if w > 0.0 and h > 0.0:
536
+ _update_corner_mask(view, w, h)
537
+ except Exception:
538
+ diagnostics.swallowed("ios._apply_corner_radii")
539
+
540
+
541
+ def _update_corner_mask(view: Any, width: float, height: float) -> None:
542
+ """(Re)build the CAShapeLayer corner mask for the current bounds."""
543
+ radii = _pn_corner_radii_map.get(id(view))
544
+ if radii is None or width <= 0.0 or height <= 0.0:
545
+ return
546
+ tl, tr, bl, br = radii
547
+ # CSS-style scaling so adjacent radii never overlap along an edge.
548
+ scale = 1.0
549
+ for pair_sum, extent in ((tl + tr, width), (bl + br, width), (tl + bl, height), (tr + br, height)):
550
+ if pair_sum > extent > 0.0:
551
+ scale = min(scale, extent / pair_sum)
552
+ tl, tr, bl, br = (r * scale for r in radii)
553
+ try:
554
+ path = ObjCClass("UIBezierPath").bezierPath()
555
+ half_pi = math.pi / 2.0
556
+ path.moveToPoint_((tl, 0.0))
557
+ path.addLineToPoint_((width - tr, 0.0))
558
+ if tr > 0.0:
559
+ path.addArcWithCenter_radius_startAngle_endAngle_clockwise_((width - tr, tr), tr, -half_pi, 0.0, True)
560
+ path.addLineToPoint_((width, height - br))
561
+ if br > 0.0:
562
+ path.addArcWithCenter_radius_startAngle_endAngle_clockwise_(
563
+ (width - br, height - br), br, 0.0, half_pi, True
564
+ )
565
+ path.addLineToPoint_((bl, height))
566
+ if bl > 0.0:
567
+ path.addArcWithCenter_radius_startAngle_endAngle_clockwise_((bl, height - bl), bl, half_pi, math.pi, True)
568
+ path.addLineToPoint_((0.0, tl))
569
+ if tl > 0.0:
570
+ path.addArcWithCenter_radius_startAngle_endAngle_clockwise_((tl, tl), tl, math.pi, math.pi + half_pi, True)
571
+ path.closePath()
572
+ # On raw-registered outbound wrappers rubicon sometimes resolves
573
+ # ``CGPath`` as a bound method rather than a property.
574
+ cgpath = path.CGPath
575
+ if callable(cgpath):
576
+ cgpath = cgpath()
577
+ shape = ObjCClass("CAShapeLayer").layer()
578
+ shape.setFrame_(((0.0, 0.0), (width, height)))
579
+ shape.setPath_(cgpath)
580
+ view.layer.setMask_(shape)
581
+ except Exception:
582
+ diagnostics.swallowed("ios._update_corner_mask")
583
+
584
+
425
585
  def _apply_shadow(view: Any, props: Dict[str, Any]) -> None:
426
586
  """Apply shadow_color/shadow_offset/shadow_opacity/shadow_radius via the view's layer.
427
587
 
@@ -666,13 +826,234 @@ def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None:
666
826
  view.setAlpha_(float(props["opacity"]))
667
827
  except Exception:
668
828
  diagnostics.swallowed("ios._apply_common_visual")
829
+ if "z_index" in props:
830
+ # A removed prop (None) restores the default plane.
831
+ try:
832
+ z = props["z_index"]
833
+ view.layer.setZPosition_(float(z) if z is not None else 0.0)
834
+ except Exception:
835
+ diagnostics.swallowed("ios._apply_common_visual")
836
+ if "pointer_events" in props or "hit_slop" in props:
837
+ _apply_interaction_props(view, props)
669
838
  _apply_view_border(view, props)
670
839
  _apply_side_borders(view, props)
840
+ # Per-corner radii read the merged prop set (an update may carry
841
+ # only one key); a lone border_radius change must also re-run the
842
+ # per-corner path when corner keys are active, or the uniform
843
+ # applier above would stomp the masked corners.
844
+ merged = _state_of(view).get("props") or props
845
+ if any(k in props for k in _CORNER_RADIUS_KEYS) or (
846
+ "border_radius" in props and any(merged.get(k) is not None for k in _CORNER_RADIUS_KEYS)
847
+ ):
848
+ _apply_corner_radii(view, merged)
671
849
  _apply_shadow(view, props)
672
850
  _apply_transform(view, props)
673
851
  _apply_accessibility(view, props)
674
852
 
675
853
 
854
+ # ======================================================================
855
+ # Interaction props: pointer_events / hit_slop (raw libobjc subclass)
856
+ # ======================================================================
857
+ #
858
+ # ``pointer_events`` and ``hit_slop`` need to change how UIKit
859
+ # hit-tests a view, which requires overriding ``pointInside:withEvent:``
860
+ # and ``hitTest:withEvent:``. Like ``_PNPortalViewCTypes``, the
861
+ # ``_PNInteractiveViewCTypes`` class is registered with raw libobjc
862
+ # (CFUNCTYPE IMPs) rather than rubicon's ``@objc_method``, for the same
863
+ # arm64 FFI reliability reasons documented in the module header.
864
+ #
865
+ # Container handlers (View / Row / Column, Pressable, SafeAreaView,
866
+ # KeyboardAvoidingView) *always* instantiate this subclass. That is the
867
+ # low-risk choice: with no entry in ``_pn_interaction_state`` both
868
+ # overrides reproduce stock UIView behavior exactly, and it means the
869
+ # props can appear, change, or disappear on any later render without
870
+ # recreating the view. Leaf handlers (labels, controls) keep their
871
+ # stock classes; if the props show up there, ``_apply_interaction_props``
872
+ # warns once and the props are ignored.
873
+ #
874
+ # The overrides read per-view state synchronously from
875
+ # ``_pn_interaction_state``, a Python dict keyed by the raw view
876
+ # pointer (the ``self`` of every IMP), following the same routing
877
+ # pattern as ``_pn_action_handlers``.
878
+ #
879
+ # Semantics (matching React Native and the Android backend):
880
+ #
881
+ # - ``"none"``: the view and its whole subtree are transparent to
882
+ # touches (``hitTest`` returns nil, ``pointInside`` returns NO).
883
+ # - ``"box_none"``: the view itself never receives touches, but
884
+ # subviews still do (``hitTest`` returns the subview result and
885
+ # converts a self result to nil).
886
+ # - ``"box_only"``: the view receives touches, subviews never do.
887
+ # - ``hit_slop`` expands ``pointInside`` by the slop on each edge.
888
+ # As on every platform, the expanded area only works where the
889
+ # parent's own bounds still contain the point.
890
+
891
+ # Maps view ptr -> {"pointer_events": mode or None,
892
+ # "hit_slop": (top, left, bottom, right) or None}.
893
+ _pn_interaction_state: Dict[int, Dict[str, Any]] = {}
894
+
895
+ _POINTER_EVENT_MODES = ("none", "box_none", "box_only")
896
+
897
+
898
+ def _hit_slop_insets(value: Any) -> Optional[Tuple[float, float, float, float]]:
899
+ """Normalize a ``hit_slop`` prop into ``(top, left, bottom, right)`` points.
900
+
901
+ Accepts a uniform number or a dict with any of ``top`` / ``left`` /
902
+ ``bottom`` / ``right``. Returns ``None`` for absent or unparseable
903
+ values.
904
+ """
905
+ if value is None:
906
+ return None
907
+ if isinstance(value, dict):
908
+ try:
909
+ return (
910
+ float(value.get("top", 0.0) or 0.0),
911
+ float(value.get("left", 0.0) or 0.0),
912
+ float(value.get("bottom", 0.0) or 0.0),
913
+ float(value.get("right", 0.0) or 0.0),
914
+ )
915
+ except Exception:
916
+ return None
917
+ try:
918
+ uniform = float(value)
919
+ except Exception:
920
+ return None
921
+ return (uniform, uniform, uniform, uniform)
922
+
923
+
924
+ _INTERACTIVE_POINT_INSIDE_TYPE = _ct.CFUNCTYPE(_ct.c_bool, _ct.c_void_p, _ct.c_void_p, _PNCGPoint, _ct.c_void_p)
925
+ _INTERACTIVE_HITTEST_TYPE = _ct.CFUNCTYPE(_ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _PNCGPoint, _ct.c_void_p)
926
+
927
+ _SEL_HITTEST_WITH_EVENT = _sel_reg(b"hitTest:withEvent:")
928
+
929
+ # UIView's own ``hitTest:withEvent:`` IMP, called directly (the
930
+ # superclass is known to be exactly UIView, so this is equivalent to a
931
+ # ``objc_msgSendSuper`` call without the extra struct plumbing). Bound
932
+ # after the class registration below.
933
+ _uiview_hittest_imp: Any = None
934
+
935
+
936
+ def _interactive_point_inside_imp(self_ptr: int, _cmd_ptr: int, point: Any, _event_ptr: int) -> bool:
937
+ """``pointInside:withEvent:`` honoring pointer_events and hit_slop."""
938
+ try:
939
+ info = _pn_interaction_state.get(int(self_ptr or 0)) or {}
940
+ if info.get("pointer_events") == "none":
941
+ return False
942
+ top = left = bottom = right = 0.0
943
+ slop = info.get("hit_slop")
944
+ if slop is not None:
945
+ top, left, bottom, right = slop
946
+ view = ObjCInstance(_ct.c_void_p(self_ptr))
947
+ bounds = view.bounds
948
+ w = float(bounds.size.width)
949
+ h = float(bounds.size.height)
950
+ px = float(point.x)
951
+ py = float(point.y)
952
+ return (-left <= px <= w + right) and (-top <= py <= h + bottom)
953
+ except Exception:
954
+ return False
955
+
956
+
957
+ def _interactive_hittest_imp(self_ptr: int, _cmd_ptr: int, point: Any, event_ptr: int) -> int:
958
+ """``hitTest:withEvent:`` honoring the pointer_events mode.
959
+
960
+ Delegates to UIView's stock implementation (which consults our
961
+ ``pointInside:`` override, so hit_slop is already accounted for),
962
+ then filters the result per the mode.
963
+ """
964
+ try:
965
+ info = _pn_interaction_state.get(int(self_ptr or 0)) or {}
966
+ mode = info.get("pointer_events")
967
+ if mode == "none":
968
+ return 0
969
+ if _uiview_hittest_imp is None:
970
+ return 0
971
+ result = int(_uiview_hittest_imp(self_ptr, _SEL_HITTEST_WITH_EVENT, point, event_ptr) or 0)
972
+ if mode == "box_none" and result == int(self_ptr or 0):
973
+ return 0
974
+ if mode == "box_only":
975
+ return int(self_ptr or 0) if result else 0
976
+ return result
977
+ except Exception:
978
+ return 0
979
+
980
+
981
+ _interactive_point_inside_imp_ref = _INTERACTIVE_POINT_INSIDE_TYPE(_interactive_point_inside_imp)
982
+ _interactive_hittest_imp_ref = _INTERACTIVE_HITTEST_TYPE(_interactive_hittest_imp)
983
+
984
+ _PN_INTERACTIVE_VIEW_CLS = _alloc_cls(_UIVIEW_CLS, b"_PNInteractiveViewCTypes", 0) if _UIVIEW_CLS else None
985
+ if _PN_INTERACTIVE_VIEW_CLS:
986
+ _add_method(
987
+ _PN_INTERACTIVE_VIEW_CLS,
988
+ _sel_reg(b"pointInside:withEvent:"),
989
+ _ct.cast(_interactive_point_inside_imp_ref, _ct.c_void_p),
990
+ b"c@:{CGPoint=dd}@",
991
+ )
992
+ _add_method(
993
+ _PN_INTERACTIVE_VIEW_CLS,
994
+ _SEL_HITTEST_WITH_EVENT,
995
+ _ct.cast(_interactive_hittest_imp_ref, _ct.c_void_p),
996
+ b"@@:{CGPoint=dd}@",
997
+ )
998
+ _reg_cls(_PN_INTERACTIVE_VIEW_CLS)
999
+ try:
1000
+ _raw_hittest = _get_method_imp(_UIVIEW_CLS, _SEL_HITTEST_WITH_EVENT)
1001
+ if _raw_hittest:
1002
+ _uiview_hittest_imp = _ct.cast(_ct.c_void_p(_raw_hittest), _INTERACTIVE_HITTEST_TYPE)
1003
+ except Exception:
1004
+ _uiview_hittest_imp = None
1005
+
1006
+
1007
+ def _new_container_view() -> Any:
1008
+ """Instantiate the hit-test-aware container class (UIView fallback).
1009
+
1010
+ When the ``_PNInteractiveViewCTypes`` registration is unavailable
1011
+ the container degrades to a plain ``UIView``: everything works
1012
+ except ``pointer_events`` / ``hit_slop``, which warn when applied.
1013
+ """
1014
+ view = None
1015
+ if _PN_INTERACTIVE_VIEW_CLS:
1016
+ try:
1017
+ view = ObjCClass("_PNInteractiveViewCTypes").alloc().init()
1018
+ except Exception:
1019
+ view = None
1020
+ if view is None:
1021
+ view = ObjCClass("UIView").alloc().init()
1022
+ view.setTranslatesAutoresizingMaskIntoConstraints_(True)
1023
+ return view
1024
+
1025
+
1026
+ def _apply_interaction_props(view: Any, props: Dict[str, Any]) -> None:
1027
+ """Record pointer_events / hit_slop where the ObjC overrides read them."""
1028
+ ptr = _objc_ptr(view)
1029
+ if ptr is None:
1030
+ return
1031
+ entry = _pn_interaction_state.setdefault(ptr, {})
1032
+ if "pointer_events" in props:
1033
+ mode = props["pointer_events"]
1034
+ entry["pointer_events"] = mode if mode in _POINTER_EVENT_MODES else None
1035
+ if "hit_slop" in props:
1036
+ entry["hit_slop"] = _hit_slop_insets(props["hit_slop"])
1037
+ if entry.get("pointer_events") is None and entry.get("hit_slop") is None:
1038
+ # Both props removed (or "auto"): drop the entry so the
1039
+ # overrides take their stock-behavior fast path.
1040
+ _pn_interaction_state.pop(ptr, None)
1041
+ return
1042
+ is_custom = False
1043
+ try:
1044
+ if _PN_INTERACTIVE_VIEW_CLS:
1045
+ is_custom = bool(view.isKindOfClass_(ObjCClass("_PNInteractiveViewCTypes")))
1046
+ except Exception:
1047
+ is_custom = False
1048
+ if not is_custom:
1049
+ diagnostics.warn_once(
1050
+ "pointer_events / hit_slop only work on container elements "
1051
+ "(View, Row, Column, Pressable) on iOS; this element's native "
1052
+ "class doesn't override hit-testing, so the props are ignored",
1053
+ key=f"interaction_props:{_tag_of(view)}",
1054
+ )
1055
+
1056
+
676
1057
  # ======================================================================
677
1058
  # Retention + shared targets
678
1059
  # ======================================================================
@@ -684,17 +1065,42 @@ _pn_retained_views: list = []
684
1065
  # Simultaneous-recognition gesture delegate (raw libobjc)
685
1066
  # ======================================================================
686
1067
  #
687
- # All PythonNative recognizers on a view should recognize together
688
- # (press feedback + pan + pinch...), matching the GestureArbiter's
689
- # semantics on Android/desktop. UIKit defaults to exclusivity, so every
690
- # recognizer we create gets this delegate, which answers YES to
691
- # ``gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer:``.
1068
+ # UIKit defaults to recognizer exclusivity, so every recognizer we
1069
+ # create gets this delegate. The answer to
1070
+ # ``gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer:``
1071
+ # mirrors the GestureArbiter's semantics on Android/desktop:
1072
+ #
1073
+ # - Recognizers wired by ``_wire_gestures`` carry an entry in
1074
+ # ``_pn_gesture_relations`` (view pointer, spec index, and the spec's
1075
+ # ``"simultaneous"`` index set). Two recognizers on the *same* view
1076
+ # recognize together only when each is in the other's set; a spec
1077
+ # without the key means "simultaneous with everything" (back-compat
1078
+ # for hand-built spec dicts).
1079
+ # - Any pairing involving an unknown recognizer (Pressable's built-in
1080
+ # press trio, a scroll view's pan, recognizers from another view)
1081
+ # keeps the historical always-YES behavior.
692
1082
 
693
1083
  _GESTURE_SIMUL_TYPE = _ct.CFUNCTYPE(_ct.c_bool, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
694
1084
 
1085
+ # Maps recognizer ptr -> (view ptr, spec index, simultaneous set or None).
1086
+ _pn_gesture_relations: Dict[int, Tuple[int, int, Optional[frozenset]]] = {}
1087
+
695
1088
 
696
1089
  def _gesture_simul_imp(self_ptr: int, cmd_ptr: int, g1_ptr: int, g2_ptr: int) -> bool:
697
- return True
1090
+ try:
1091
+ a = _pn_gesture_relations.get(int(g1_ptr or 0))
1092
+ b = _pn_gesture_relations.get(int(g2_ptr or 0))
1093
+ if a is None or b is None:
1094
+ return True
1095
+ if a[0] != b[0]:
1096
+ return True
1097
+ sim_a = a[2]
1098
+ sim_b = b[2]
1099
+ if sim_a is None or sim_b is None:
1100
+ return True
1101
+ return b[1] in sim_a and a[1] in sim_b
1102
+ except Exception:
1103
+ return True
698
1104
 
699
1105
 
700
1106
  _gesture_simul_imp_ref = _GESTURE_SIMUL_TYPE(_gesture_simul_imp)
@@ -912,6 +1318,7 @@ def _make_gesture_handler(
912
1318
  kind: str,
913
1319
  index: int,
914
1320
  direction: Optional[str] = None,
1321
+ n_pointers: Optional[int] = None,
915
1322
  ) -> Any:
916
1323
  """Build the action closure emitting one ``gesture:<i>`` payload."""
917
1324
 
@@ -962,6 +1369,13 @@ def _make_gesture_handler(
962
1369
  # per-recognizer direction is the actual swipe direction.
963
1370
  payload["state"] = "ended"
964
1371
  payload["direction"] = direction
1372
+ elif kind == "fling":
1373
+ # Same discrete shape as swipe. ``numberOfTouches`` is often
1374
+ # 0 by recognition time, so report the configured pointer
1375
+ # requirement (which UIKit enforced) instead.
1376
+ payload["state"] = "ended"
1377
+ payload["direction"] = direction
1378
+ payload["pointer_count"] = int(n_pointers or 1)
965
1379
  elif kind == "tap":
966
1380
  payload["state"] = "ended"
967
1381
 
@@ -973,9 +1387,17 @@ def _make_gesture_handler(
973
1387
  def _make_recognizer(kind: str, spec: Dict[str, Any]) -> List[Tuple[Any, Optional[str]]]:
974
1388
  """Build the UIGestureRecognizer(s) for one serialized gesture spec.
975
1389
 
976
- Swipe with ``direction="any"`` needs one recognizer per direction
977
- (UIKit constraint), so this returns ``(recognizer, direction)``
978
- pairs; ``direction`` is ``None`` for non-swipe kinds.
1390
+ Swipe and fling with ``direction="any"`` need one recognizer per
1391
+ direction (a ``UISwipeGestureRecognizer`` reports no resolved
1392
+ direction, only its configured mask), so this returns
1393
+ ``(recognizer, direction)`` pairs; ``direction`` is ``None`` for
1394
+ the other kinds.
1395
+
1396
+ Fling maps to ``UISwipeGestureRecognizer`` with
1397
+ ``numberOfTouchesRequired``. The spec's ``min_velocity`` can't be
1398
+ enforced: UIKit exposes no velocity knob on swipe recognizers, so
1399
+ iOS uses the system's own swipe velocity threshold (a documented
1400
+ approximation; Android and desktop honor the exact value).
979
1401
  """
980
1402
  out: List[Tuple[Any, Optional[str]]] = []
981
1403
  if kind == "tap":
@@ -1010,6 +1432,18 @@ def _make_recognizer(kind: str, spec: Dict[str, Any]) -> List[Tuple[Any, Optiona
1010
1432
  except Exception:
1011
1433
  diagnostics.swallowed("ios._make_recognizer")
1012
1434
  out.append((rec, d))
1435
+ elif kind == "fling":
1436
+ direction = str(spec.get("direction", "any"))
1437
+ directions = [direction] if direction in _SWIPE_DIRECTIONS else list(_SWIPE_DIRECTIONS)
1438
+ n_pointers = max(1, int(spec.get("n_pointers", 1)))
1439
+ for d in directions:
1440
+ rec = ObjCClass("UISwipeGestureRecognizer").alloc().init()
1441
+ try:
1442
+ rec.setDirection_(_SWIPE_DIRECTIONS[d])
1443
+ rec.setNumberOfTouchesRequired_(n_pointers)
1444
+ except Exception:
1445
+ diagnostics.swallowed("ios._make_recognizer")
1446
+ out.append((rec, d))
1013
1447
  elif kind == "pinch":
1014
1448
  out.append((ObjCClass("UIPinchGestureRecognizer").alloc().init(), None))
1015
1449
  elif kind == "rotation":
@@ -1023,6 +1457,15 @@ def _wire_gestures(view: Any, specs: Any) -> None:
1023
1457
  Re-wiring on update removes previously attached PythonNative
1024
1458
  recognizers first (configuration changes are rare; correctness
1025
1459
  beats incremental patching here).
1460
+
1461
+ Composition metadata is applied in two passes: while creating each
1462
+ recognizer its ``(view, index, simultaneous-set)`` triple is
1463
+ recorded in ``_pn_gesture_relations`` for the shared delegate, and
1464
+ once every recognizer exists, each spec's ``"wait_for"`` indices
1465
+ are wired with ``requireGestureRecognizerToFail:`` (every
1466
+ recognizer of the waiting spec requires every recognizer of each
1467
+ target spec to fail; a spec can own several recognizers, e.g. an
1468
+ any-direction swipe).
1026
1469
  """
1027
1470
  state = _state_of(view)
1028
1471
  for rec in state.get("gesture_recognizers") or []:
@@ -1031,6 +1474,7 @@ def _wire_gestures(view: Any, specs: Any) -> None:
1031
1474
  except Exception:
1032
1475
  diagnostics.swallowed("ios._wire_gestures")
1033
1476
  _pn_action_handlers.pop(_recognizer_ptr(rec), None)
1477
+ _pn_gesture_relations.pop(_recognizer_ptr(rec), None)
1034
1478
  state["gesture_recognizers"] = []
1035
1479
  if not isinstance(specs, (list, tuple)) or not specs:
1036
1480
  return
@@ -1040,13 +1484,18 @@ def _wire_gestures(view: Any, specs: Any) -> None:
1040
1484
  except Exception:
1041
1485
  diagnostics.swallowed("ios._wire_gestures")
1042
1486
 
1487
+ view_ptr = _objc_ptr(view) or 0
1043
1488
  recognizers: List[Any] = []
1489
+ recs_by_index: Dict[int, List[Any]] = {}
1044
1490
  for i, spec in enumerate(specs):
1045
1491
  if not isinstance(spec, dict):
1046
1492
  continue
1047
1493
  kind = str(spec.get("kind", ""))
1048
1494
  if not kind:
1049
1495
  continue
1496
+ sim = spec.get("simultaneous")
1497
+ sim_set = None if sim is None else frozenset(int(s) for s in sim)
1498
+ n_pointers = max(1, int(spec.get("n_pointers", 1) or 1)) if kind == "fling" else None
1050
1499
  for rec, direction in _make_recognizer(kind, spec):
1051
1500
  try:
1052
1501
  rec.setCancelsTouchesInView_(False)
@@ -1059,7 +1508,21 @@ def _wire_gestures(view: Any, specs: Any) -> None:
1059
1508
  recognizers.append(rec)
1060
1509
  except Exception:
1061
1510
  continue
1062
- _register_action(rec, _make_gesture_handler(rec, view, kind, i, direction))
1511
+ _pn_gesture_relations[_recognizer_ptr(rec)] = (view_ptr, i, sim_set)
1512
+ recs_by_index.setdefault(i, []).append(rec)
1513
+ _register_action(rec, _make_gesture_handler(rec, view, kind, i, direction, n_pointers))
1514
+
1515
+ for i, spec in enumerate(specs):
1516
+ if not isinstance(spec, dict):
1517
+ continue
1518
+ for target_index in spec.get("wait_for") or ():
1519
+ targets = recs_by_index.get(int(target_index)) or []
1520
+ for waiter in recs_by_index.get(i) or []:
1521
+ for target in targets:
1522
+ try:
1523
+ waiter.requireGestureRecognizerToFail_(target)
1524
+ except Exception:
1525
+ diagnostics.swallowed("ios._wire_gestures")
1063
1526
 
1064
1527
  state["gesture_recognizers"] = recognizers
1065
1528
 
@@ -1076,6 +1539,16 @@ def _is_main_thread() -> bool:
1076
1539
 
1077
1540
 
1078
1541
  def _animated_applier_for(prop: str, value: Any) -> Optional[Callable[[Any], None]]:
1542
+ """Build the per-frame applier for one animated property.
1543
+
1544
+ Color-valued frames (``background_color`` and ``color``) arrive as
1545
+ ``"#RRGGBB"`` / ``"#AARRGGBB"`` strings from ``AnimatedInterpolation``
1546
+ color outputs and go through the same ``_uicolor`` parsing the
1547
+ ordinary update path uses. ``rotate`` receives numeric degrees,
1548
+ which is what ``_apply_transform`` expects for bare numbers. An
1549
+ unknown property returns ``None`` and the frame is silently
1550
+ ignored.
1551
+ """
1079
1552
  if prop == "opacity":
1080
1553
  v = float(value)
1081
1554
 
@@ -1088,6 +1561,18 @@ def _animated_applier_for(prop: str, value: Any) -> Optional[Callable[[Any], Non
1088
1561
  def _apply(view: Any) -> None:
1089
1562
  view.setBackgroundColor_(_uicolor(value))
1090
1563
 
1564
+ return _apply
1565
+ if prop == "color":
1566
+
1567
+ def _apply(view: Any) -> None:
1568
+ color = _uicolor(value)
1569
+ try:
1570
+ view.setTextColor_(color)
1571
+ except Exception:
1572
+ # UIButton has no setTextColor:; views with neither
1573
+ # selector raise into the caller's swallow.
1574
+ view.setTitleColor_forState_(color, 0)
1575
+
1091
1576
  return _apply
1092
1577
  if prop in ("translate_x", "translate_y", "scale", "scale_x", "scale_y", "rotate"):
1093
1578
  spec = {prop: value}
@@ -1229,11 +1714,14 @@ class IOSViewHandler(ViewHandler):
1229
1714
  state = _state_of(native_view)
1230
1715
  for rec in state.get("gesture_recognizers") or []:
1231
1716
  _pn_action_handlers.pop(_recognizer_ptr(rec), None)
1717
+ _pn_gesture_relations.pop(_recognizer_ptr(rec), None)
1232
1718
  # Controls register their own pointer as the action-handler key
1233
1719
  # (see _register_control_action); drop it with the view.
1234
1720
  _pn_action_handlers.pop(_recognizer_ptr(native_view), None)
1235
1721
  _pn_last_control_event_ts.pop(_recognizer_ptr(native_view), None)
1722
+ _pn_interaction_state.pop(_objc_ptr(native_view) or 0, None)
1236
1723
  _pn_view_border_radius_map.pop(id(native_view), None)
1724
+ _pn_corner_radii_map.pop(id(native_view), None)
1237
1725
  _pn_side_border_map.pop(id(native_view), None)
1238
1726
  try:
1239
1727
  native_view.removeFromSuperview()
@@ -1284,6 +1772,9 @@ class IOSViewHandler(ViewHandler):
1284
1772
  native_view.setTranslatesAutoresizingMaskIntoConstraints_(True)
1285
1773
  native_view.setFrame_(((frame_x, frame_y), (frame_w, frame_h)))
1286
1774
  _clamp_view_corner_radius(native_view, frame_w, frame_h)
1775
+ # The per-corner shape mask is bounds-dependent; rebuild it
1776
+ # whenever the frame changes.
1777
+ _update_corner_mask(native_view, frame_w, frame_h)
1287
1778
  _update_side_border_layers(native_view, frame_w, frame_h)
1288
1779
  try:
1289
1780
  _clamp_layer_corner_radius(native_view.layer, frame_w, frame_h)
@@ -1328,7 +1819,14 @@ class IOSViewHandler(ViewHandler):
1328
1819
  return (0.0, 0.0)
1329
1820
 
1330
1821
  def set_animated_property(self, native_view: Any, prop_name: str, value: Any) -> None:
1331
- """Apply one Python-driven animation frame immediately."""
1822
+ """Apply one Python-driven animation frame immediately.
1823
+
1824
+ Accepts floats for ``opacity`` and the transform channels,
1825
+ numeric degrees for ``rotate``, and ``"#RRGGBB"`` /
1826
+ ``"#AARRGGBB"`` strings for ``background_color`` and ``color``
1827
+ (see ``_animated_applier_for``). Unknown properties are
1828
+ silently ignored.
1829
+ """
1332
1830
  if native_view is None:
1333
1831
  return
1334
1832
  try:
@@ -1389,17 +1887,18 @@ class IOSViewHandler(ViewHandler):
1389
1887
 
1390
1888
 
1391
1889
  class FlexContainerHandler(IOSViewHandler):
1392
- """Container for flex layout, a bare `UIView`.
1890
+ """Container for flex layout, a hit-test-aware ``UIView`` subclass.
1393
1891
 
1394
1892
  All flex semantics (direction, alignment, distribution, padding)
1395
1893
  are computed by the layout engine and applied via
1396
1894
  [`set_frame`][pythonnative.native_views.ios.IOSViewHandler.set_frame].
1895
+ The view is a ``_PNInteractiveViewCTypes`` instance so
1896
+ ``pointer_events`` and ``hit_slop`` can be applied on any render
1897
+ without recreating the view (see the interaction-props section).
1397
1898
  """
1398
1899
 
1399
1900
  def _build(self, props: Dict[str, Any]) -> Any:
1400
- v = ObjCClass("UIView").alloc().init()
1401
- v.setTranslatesAutoresizingMaskIntoConstraints_(True)
1402
- return v
1901
+ return _new_container_view()
1403
1902
 
1404
1903
 
1405
1904
  # ======================================================================
@@ -2586,8 +3085,7 @@ class PressableHandler(IOSViewHandler):
2586
3085
  """
2587
3086
 
2588
3087
  def _build(self, props: Dict[str, Any]) -> Any:
2589
- v = ObjCClass("UIView").alloc().init()
2590
- v.setTranslatesAutoresizingMaskIntoConstraints_(True)
3088
+ v = _new_container_view()
2591
3089
  v.setUserInteractionEnabled_(True)
2592
3090
  self._wire_press(v)
2593
3091
  return v
@@ -3044,9 +3542,7 @@ class SafeAreaViewHandler(IOSViewHandler):
3044
3542
  """Plain container; safe-area insets are applied by the layout engine."""
3045
3543
 
3046
3544
  def _build(self, props: Dict[str, Any]) -> Any:
3047
- v = ObjCClass("UIView").alloc().init()
3048
- v.setTranslatesAutoresizingMaskIntoConstraints_(True)
3049
- return v
3545
+ return _new_container_view()
3050
3546
 
3051
3547
 
3052
3548
  # ======================================================================
@@ -3220,10 +3716,6 @@ class ModalHandler(IOSViewHandler):
3220
3716
  # other delegate classes in this module.
3221
3717
 
3222
3718
 
3223
- class _PNCGPoint(_ct.Structure):
3224
- _fields_ = [("x", _ct.c_double), ("y", _ct.c_double)]
3225
-
3226
-
3227
3719
  _PORTAL_POINT_INSIDE_TYPE = _ct.CFUNCTYPE(_ct.c_bool, _ct.c_void_p, _ct.c_void_p, _PNCGPoint, _ct.c_void_p)
3228
3720
 
3229
3721
 
@@ -3261,7 +3753,6 @@ def _portal_point_inside_imp(self_ptr: int, _cmd_ptr: int, point: Any, _event_pt
3261
3753
 
3262
3754
  _portal_point_inside_imp_ref = _PORTAL_POINT_INSIDE_TYPE(_portal_point_inside_imp)
3263
3755
 
3264
- _UIVIEW_CLS = _get_cls(b"UIView")
3265
3756
  _PN_PORTAL_VIEW_CLS = _alloc_cls(_UIVIEW_CLS, b"_PNPortalViewCTypes", 0) if _UIVIEW_CLS else None
3266
3757
  if _PN_PORTAL_VIEW_CLS:
3267
3758
  _add_method(
@@ -3497,9 +3988,7 @@ class KeyboardAvoidingViewHandler(IOSViewHandler):
3497
3988
 
3498
3989
  def _build(self, props: Dict[str, Any]) -> Any:
3499
3990
  _ensure_keyboard_observer()
3500
- v = ObjCClass("UIView").alloc().init()
3501
- v.setTranslatesAutoresizingMaskIntoConstraints_(True)
3502
- return v
3991
+ return _new_container_view()
3503
3992
 
3504
3993
 
3505
3994
  # ======================================================================