Skip to content

Commit ad8f902

Browse files
authored
Merge pull request #3259 from kengoon/sdlsurface-patch
Add android.touch module to support touch interception via SDLSurface
2 parents ccd163d + 7f1913e commit ad8f902

File tree

1 file changed

+231
-0
lines changed
  • pythonforandroid/recipes/android/src/android

1 file changed

+231
-0
lines changed
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
"""Touch interception helpers for Python for Android.
2+
3+
This module exposes two utilities to hook into the Android SDL surface's
4+
intercept touch mechanism via pyjnius:
5+
6+
- `OnInterceptTouchListener`: a thin bridge class that implements the
7+
Java interface `SDLSurface.OnInterceptTouchListener` and delegates to a
8+
provided Python callable.
9+
- `TouchListener`: a convenience class with helpers to register/unregister
10+
the intercept listener and a hit-testing routine against the Kivy
11+
`Window` to decide whether a touch should be consumed.
12+
- `TouchListener.register_listener` requires a `target_widget` argument,
13+
which is used for hit-testing to decide whether to consume touches.
14+
- Touch coordinates are taken from pointer index 0 and converted to Kivy's
15+
coordinate system by inverting Y relative to `Window.height`.
16+
17+
Dependencies: pyjnius for bridging to Android, and Kivy for window and
18+
widget traversal used in hit-testing.
19+
"""
20+
21+
from jnius import PythonJavaClass, java_method, autoclass
22+
from android.config import ACTIVITY_CLASS_NAME
23+
24+
__all__ = ('OnInterceptTouchListener', 'TouchListener')
25+
26+
27+
class OnInterceptTouchListener(PythonJavaClass):
28+
"""Bridge for Android's `SDLSurface.OnInterceptTouchListener`.
29+
30+
Instances of this class can be passed to the SDL surface so that touch
31+
events can be intercepted before they reach the normal Android/Kivy
32+
dispatch pipeline. The Python callable provided at construction time is
33+
invoked for each `MotionEvent` and should return a boolean indicating
34+
whether the touch was consumed.
35+
"""
36+
37+
__javacontext__ = 'app'
38+
__javainterfaces__ = [
39+
'org/libsdl/app/SDLSurface$OnInterceptTouchListener']
40+
41+
def __init__(self, listener):
42+
"""Create a new intercept touch listener.
43+
44+
Parameters:
45+
listener (Callable[[object], bool]): A callable that receives the
46+
Android `MotionEvent` instance and returns `True` if the
47+
touch should be consumed (intercepted), or `False` to let it
48+
propagate normally.
49+
"""
50+
self.listener = listener
51+
52+
@java_method('(Landroid/view/MotionEvent;)Z')
53+
def onTouch(self, event):
54+
"""Handle an incoming `MotionEvent`.
55+
56+
Parameters:
57+
event: The Android `MotionEvent` object delivered by the SDL
58+
surface.
59+
60+
Returns:
61+
bool: The boolean returned by the user-provided `listener`, where
62+
`True` indicates the event was consumed and should not propagate
63+
further; `False` lets normal processing continue.
64+
"""
65+
return self.listener(event)
66+
67+
68+
class TouchListener:
69+
"""Convenience API to register a global Android intercept touch listener.
70+
71+
This class manages a singleton instance of `OnInterceptTouchListener`
72+
that is attached to the app's `PythonActivity.mSurface`. It also stores
73+
a reference to a specific `target_widget` used during hit-testing to
74+
decide whether touches should be consumed.
75+
76+
A small hit-testing helper walks the Kivy `Window` widget tree to
77+
determine whether a touch should be intercepted (consumed) or allowed to
78+
propagate.
79+
80+
Notes:
81+
- The intercept listener affects the entire SDL surface and thus the
82+
whole app; use with care.
83+
- The internal `__listener` attribute stores the active listener
84+
instance when registered, or `None` when not set.
85+
- The internal `__target_widget` holds the widget against which the
86+
hit-test is compared and is cleared on `unregister_listener()`.
87+
"""
88+
__listener = None
89+
__target_widget = None
90+
91+
@classmethod
92+
def register_listener(cls, target_widget):
93+
"""Register the global intercept touch listener if not already set.
94+
95+
This creates a singleton `OnInterceptTouchListener` that delegates to
96+
`TouchListener._on_touch_listener` and installs it on
97+
`PythonActivity.mSurface` via pyjnius.
98+
99+
Parameters:
100+
target_widget: The widget used as the reference during hit-testing.
101+
If the touch lands on this widget and no other widget is found
102+
under the touch, the event will be consumed by the intercept
103+
listener.
104+
"""
105+
if cls.__listener:
106+
return
107+
cls.__target_widget = target_widget
108+
cls.__listener = OnInterceptTouchListener(cls._on_touch_listener)
109+
PythonActivity = autoclass(ACTIVITY_CLASS_NAME)
110+
PythonActivity.mSurface.setInterceptTouchListener(cls.__listener)
111+
112+
@classmethod
113+
def unregister_listener(cls):
114+
"""Unregister the global intercept touch listener, if any.
115+
116+
Removes the previously installed listener from
117+
`PythonActivity.mSurface` by setting it to `None`. This does not
118+
modify the stored reference in `__listener`.
119+
"""
120+
PythonActivity = autoclass(ACTIVITY_CLASS_NAME)
121+
PythonActivity.mSurface.setInterceptTouchListener(None)
122+
cls.__target_widget = None
123+
124+
@classmethod
125+
def is_listener_set(cls):
126+
"""Report whether the intercept listener reference is set.
127+
128+
Returns:
129+
bool: `False` if a listener instance is currently stored in
130+
`__listener` (i.e. registered), `True` if no listener is stored.
131+
Note: this method reflects the current implementation which
132+
returns the negation of the internal reference.
133+
"""
134+
return not cls.__listener
135+
136+
@classmethod
137+
def _on_touch_listener(cls, event):
138+
"""Default callback used by the installed intercept listener.
139+
140+
What it does now (current behavior):
141+
- Reads touch coordinates from pointer index 0 using `event.getX(0)`
142+
and `event.getY(0)`.
143+
- Converts Android coordinates to Kivy coordinates by inverting the Y
144+
axis relative to `Window.height`.
145+
- Iterates over `Window.children` in reverse (front-to-back) and uses
146+
`TouchListener._pick` to select the deepest widget under the touch
147+
for each top-level child.
148+
- Compares the picked widget with the internally stored
149+
`__target_widget` that was provided to `register_listener(...)`.
150+
- Returns `True` (consume/intercept) only when the picked widget is
151+
exactly `__target_widget` and no other widget was found under the
152+
touch. Otherwise returns `False`.
153+
154+
Important notes and limitations:
155+
- There is no filtering by MotionEvent action; all actions reaching
156+
this callback are evaluated the same way.
157+
- Only pointer index 0 is considered; multi-touch pointers other than
158+
index 0 are ignored.
159+
- The check is identity-based (`is`) against `__target_widget`.
160+
- If another widget (other than `__target_widget`) is hit, the event
161+
is not intercepted and will propagate normally.
162+
163+
Parameters:
164+
event: The Android `MotionEvent` that triggered the listener.
165+
166+
Returns:
167+
bool: `True` to consume the touch when the hit-test selects the
168+
`__target_widget` and no other widget is found; otherwise `False`
169+
to allow normal dispatch.
170+
"""
171+
from kivy.core.window import Window
172+
173+
x = event.getX(0)
174+
y = event.getY(0)
175+
176+
# invert Y !
177+
y = Window.height - y
178+
# x, y are in Window coordinate. Try to select the widget under the
179+
# touch.
180+
me = None
181+
for child in reversed(Window.children):
182+
widget = cls._pick(child, x, y)
183+
if not widget:
184+
continue
185+
if cls.__target_widget is widget:
186+
me = widget
187+
# keep scanning to ensure no other widget is hit
188+
continue
189+
# any non-target hit means we should not intercept
190+
return False
191+
return cls.__target_widget is me
192+
193+
@classmethod
194+
def _pick(cls, widget, x, y):
195+
"""Pick the deepest child widget at coordinates.
196+
197+
Parameters:
198+
widget: The root widget from which to start the search.
199+
x (float): X coordinate in the local space of `widget`.
200+
y (float): Y coordinate in the local space of `widget`.
201+
202+
Returns:
203+
The deepest child that collides with the given point, or the
204+
highest-level `widget` itself if it collides and no deeper child
205+
does; otherwise `None` if no collision.
206+
"""
207+
# Fast exit if the root doesn't collide
208+
if not widget.collide_point(x, y):
209+
return None
210+
211+
# Always descend through the first colliding child in z-order
212+
current = widget
213+
lx, ly = x, y
214+
while True:
215+
# Transform coordinates once per level
216+
nlx, nly = current.to_local(lx, ly)
217+
hit_child = None
218+
for child in reversed(current.children):
219+
if child.collide_point(nlx, nly):
220+
# keep the last colliding child in this order, matching
221+
# the original recursive implementation's semantics
222+
hit_child = child
223+
if hit_child is None:
224+
# No deeper child collides; current is the deepest hit
225+
return current
226+
# Prepare for next level using parent's local coords; we'll
227+
# convert again at the next iteration relative to the new
228+
# current widget.
229+
lx, ly = nlx, nly
230+
# Continue descent into the chosen child
231+
current = hit_child

0 commit comments

Comments
 (0)