Skip to content

Commit 7af4340

Browse files
committed
fix: make ScrollView on_scroll fire via raw-libobjc delegate
1 parent 5399d8b commit 7af4340

2 files changed

Lines changed: 89 additions & 37 deletions

File tree

src/pythonnative/native_views/ios.py

Lines changed: 65 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1266,30 +1266,55 @@ def _apply(self, btn: Any, props: Dict[str, Any]) -> None:
12661266
btn.addTarget_action_forControlEvents_(handler, SEL("onTap:"), 1 << 6)
12671267

12681268

1269-
# Maps ``id(delegate)`` -> ``{"on_scroll": cb, "delegate": obj}`` so the
1270-
# UIScrollViewDelegate can forward ``scrollViewDidScroll:`` back to Python.
1271-
_pn_scroll_delegate_map: dict = {}
1269+
# ``scrollViewDidScroll:`` hands the delegate a ``UIScrollView*``. rubicon's
1270+
# ``@objc_method`` FFI bridge is unreliable for delegate callbacks that take
1271+
# ObjC object arguments on arm64 (see the module header note) — on the arm64
1272+
# simulator the callback simply never reaches Python, so ``on_scroll`` would
1273+
# silently never fire. Exactly like the UITabBar delegate, we therefore build
1274+
# the delegate class with raw libobjc and dispatch through a CFUNCTYPE IMP.
1275+
#
1276+
# We read ``contentOffset`` off the *retained rubicon* scroll view we already
1277+
# hold (keyed by the delegate instance pointer) rather than off the raw
1278+
# callback argument: that sidesteps both the object-arg marshaling issue and
1279+
# the CGPoint struct-return ABI quirks of calling ``contentOffset`` via raw
1280+
# ``objc_msgSend``.
1281+
_pn_scroll_imp_map: Dict[int, Dict[str, Any]] = {}
12721282

1283+
_SCROLL_IMP_TYPE = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
12731284

1274-
class _PNScrollDelegate(NSObject): # type: ignore[valid-type]
1275-
@objc_method
1276-
def scrollViewDidScroll_(self, scroll_view: object) -> None:
1277-
info = _pn_scroll_delegate_map.get(id(self))
1278-
if not info:
1279-
return
1280-
cb = info.get("on_scroll")
1281-
if cb is None:
1282-
return
1283-
try:
1284-
offset = scroll_view.contentOffset
1285-
x = float(offset.x)
1286-
y = float(offset.y)
1287-
except Exception:
1288-
return
1289-
try:
1290-
cb(x, y)
1291-
except Exception:
1292-
pass
1285+
1286+
def _scroll_did_scroll_imp(self_ptr: int, _cmd_ptr: int, _scroll_view_ptr: int) -> None:
1287+
"""Raw C callback for ``scrollViewDidScroll:``."""
1288+
info = _pn_scroll_imp_map.get(self_ptr)
1289+
if not info:
1290+
return
1291+
cb = info.get("on_scroll")
1292+
sv = info.get("sv")
1293+
if cb is None or sv is None:
1294+
return
1295+
try:
1296+
offset = sv.contentOffset
1297+
x = float(offset.x)
1298+
y = float(offset.y)
1299+
except Exception:
1300+
return
1301+
try:
1302+
cb(x, y)
1303+
except Exception:
1304+
pass
1305+
1306+
1307+
_scroll_imp_ref = _SCROLL_IMP_TYPE(_scroll_did_scroll_imp)
1308+
1309+
_PN_SCROLL_DELEGATE_CLS = _alloc_cls(_NS_OBJECT_CLS, b"_PNScrollDelegateCTypes", 0)
1310+
if _PN_SCROLL_DELEGATE_CLS:
1311+
_add_method(
1312+
_PN_SCROLL_DELEGATE_CLS,
1313+
_sel_reg(b"scrollViewDidScroll:"),
1314+
_ct.cast(_scroll_imp_ref, _ct.c_void_p),
1315+
b"v@:@",
1316+
)
1317+
_reg_cls(_PN_SCROLL_DELEGATE_CLS)
12931318

12941319

12951320
class ScrollViewHandler(IOSViewHandler):
@@ -1361,21 +1386,26 @@ def _apply_scroll_props(self, sv: Any, props: Dict[str, Any]) -> None:
13611386
self._wire_scroll(sv, props["on_scroll"])
13621387

13631388
def _wire_scroll(self, sv: Any, on_scroll: Any) -> None:
1364-
delegate_id = getattr(sv, "_pn_scroll_delegate_id", None)
1365-
if delegate_id is None:
1366-
delegate = _PNScrollDelegate.new()
1367-
delegate.retain()
1368-
_pn_retained_views.append(delegate)
1369-
delegate_id = id(delegate)
1370-
sv._pn_scroll_delegate_id = delegate_id
1371-
_pn_scroll_delegate_map[delegate_id] = {"on_scroll": on_scroll, "delegate": delegate}
1372-
try:
1373-
sv.setDelegate_(delegate)
1374-
except Exception:
1375-
pass
1389+
delegate_ptr = getattr(sv, "_pn_scroll_delegate_ptr", None)
1390+
if delegate_ptr is None:
1391+
if not _PN_SCROLL_DELEGATE_CLS:
1392+
return
1393+
_objc_msgSend.restype = _ct.c_void_p
1394+
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
1395+
d = _objc_msgSend(_PN_SCROLL_DELEGATE_CLS, _SEL_ALLOC)
1396+
d = _objc_msgSend(d, _SEL_INIT)
1397+
d = _objc_msgSend(d, _SEL_RETAIN)
1398+
delegate_ptr = int(d)
1399+
sv._pn_scroll_delegate_ptr = delegate_ptr
1400+
_pn_scroll_imp_map[delegate_ptr] = {"on_scroll": on_scroll, "sv": sv}
1401+
_objc_msgSend.restype = None
1402+
_objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
1403+
sv_ptr = sv.ptr if hasattr(sv, "ptr") else sv
1404+
_objc_msgSend(sv_ptr, _SEL_SET_DELEGATE, _ct.c_void_p(delegate_ptr))
13761405
else:
1377-
info = _pn_scroll_delegate_map.setdefault(delegate_id, {})
1406+
info = _pn_scroll_imp_map.setdefault(delegate_ptr, {})
13781407
info["on_scroll"] = on_scroll
1408+
info["sv"] = sv
13791409

13801410
def _apply_refresh(self, sv: Any, props: Dict[str, Any]) -> None:
13811411
spec = props.get("refresh_control")

tests/e2e/flows/components/scroll_view.yaml

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,37 @@ appId: ${APP_ID}
2323
DEMO_TITLE: ScrollView
2424
- assertVisible: "ScrollRow 1"
2525
- assertVisible: "Scrolled: OFF"
26+
# Target the last row: the inner box is a fixed 400 dp on every device, so
27+
# the early rows (~1-21) are on-screen without scrolling. Asserting a row
28+
# that *is* already visible would let Maestro short-circuit with zero
29+
# swipes (it does exactly that for "ScrollRow 20" on iOS), and then
30+
# on_scroll never fires. The last row is always below the fold, forcing a
31+
# real scroll on both platforms.
2632
- scrollUntilVisible:
27-
element: "ScrollRow 20"
33+
element: "ScrollRow 30"
2834
direction: DOWN
2935
timeout: 20000
3036
visibilityPercentage: 100
3137
centerElement: false
32-
- assertVisible: "ScrollRow 20"
38+
- assertVisible: "ScrollRow 30"
3339
# on_scroll fired during the swipe(s), flipping the outer readout.
3440
- assertVisible: "Scrolled: ON"
41+
# The inner scroll's height plus the "Scrolled" readout push the demo's
42+
# "Back to list" button (rendered below the card by demo_screen) below the
43+
# fold on Android's shorter emulator viewport. close_demo taps it without
44+
# scrolling, so surface it first here. Android's NestedScrollView bubbles
45+
# the over-scroll to the outer page once the inner list bottoms out. iOS
46+
# keeps the button on-screen (its UIScrollView doesn't bubble to the parent,
47+
# and close_demo already worked there), so this is scoped to Android only.
48+
- runFlow:
49+
when:
50+
platform: Android
51+
commands:
52+
- scrollUntilVisible:
53+
element:
54+
text: "Back to list"
55+
direction: DOWN
56+
timeout: 20000
3557
- runFlow:
3658
file: ../../helpers/close_demo.yaml
3759
env:

0 commit comments

Comments
 (0)