Window.softinput_mode = "below_target"` causes viewport jump and `TextInput` focus/cursor issues on Android
Software Versions
- Python: 3.14.2
- OS: Android
- Kivy: 3.0.0
- Kivy installation method: Buildozer
Description
When using Window.softinput_mode = "below_target" on Android, there appear to be two related issues with the soft keyboard and TextInput handling:
- The whole Kivy window/content briefly moves upward after the Android soft keyboard has already disappeared, then returns to its normal position.
- Tapping inside an already-focused
TextInputto move the cursor or select text causes theTextInputto lose focus and dismisses the keyboard.
Both issues seem related to the viewport translation applied by the below_target soft input mode.
Reproduction code
from kivy.app import App
from kivy.core.window import Window
from kivy.lang import Builder
Window.softinput_mode = "below_target"
KV = """
BoxLayout:
orientation: "vertical"
padding: "20dp"
spacing: "20dp"
Widget:
TextInput:
size_hint_y: None
height: "48dp"
text: "Tap here, then tap between these characters"
multiline: False
Widget:
"""
class TestApp(App):
def build(self):
return Builder.load_string(KV)
TestApp().run()https://github.com/user-attachments/assets/d1b95bc7-bb53-459b-94b3-781c7a8ab017
Reproduction steps
Issue 1: viewport jumps upward after keyboard dismissal
- Build and run the app on Android.
- Tap the
TextInput. - The Android soft keyboard appears.
- Tap outside the
TextInputto dismiss the keyboard. - Observe the content/window movement.
Issue 2: tapping inside focused TextInput dismisses keyboard
- Build and run the app on Android.
- Tap the
TextInput. - The Android soft keyboard appears.
- Tap inside the same
TextInput, between characters, to move the cursor or select text. - Observe that the
TextInputloses focus and the keyboard is dismissed.
Actual behavior
Issue 1
After tapping outside the TextInput:
- The keyboard disappears.
- The whole Kivy window/content briefly moves upward, as if Kivy still thinks the keyboard is visible.
- The window/content then moves back down to its normal position.
This creates a visible flicker/jump.
Issue 2
When tapping inside an already-focused TextInput:
- the
TextInputloses focus, - the Android soft keyboard is dismissed,
- the cursor cannot reliably be moved between characters,
- text selection does not work reliably.
Expected behavior
Issue 1
When the keyboard is dismissed, the window/content should return directly to its normal position.
There should be no additional upward movement after the keyboard is no longer visible.
Issue 2
When tapping inside an already-focused TextInput:
- the
TextInputshould remain focused, - the keyboard should remain visible,
- the cursor should move to the tapped position,
- text selection should work normally.
Environment
- Platform: Android
Window.softinput_mode:"below_target"- Build system: Buildozer / python-for-android
- Bootstrap: SDL3
- Kivy version:
master - Android target API:
36 - Android minimum API:
24 - Orientation: portrait
Suspected cause
The issue appears to be related to the below_target branch inside WindowBase.update_viewport() in:
kivy/core/window/__init__.pyThe below_target mode applies a viewport/content translation to keep the focused target above the keyboard. However, during keyboard hide or subsequent taps inside the already-focused TextInput, the viewport offset and touch coordinates may become temporarily inconsistent.
Possible causes:
keyboard_heightremains non-zero briefly after the Android keyboard visually disappears._system_keyboard.targetmay still point to the previousTextInputduring keyboard dismissal.- The keyboard hide animation / viewport animation may still apply an upward offset after the keyboard is gone.
- Touch coordinates may not correctly account for the viewport translation applied by
below_target. - A tap visually inside the translated
TextInputmay be interpreted as outside the widget, causing focus to be cleared.
This would explain both symptoms:
- Kivy briefly moves the content upward after the keyboard disappears because
below_targetstill applies a positive viewport offset. - Taps inside the focused
TextInputmay be misinterpreted as outside it, causing focus loss and keyboard dismissal.
Possible fix direction
The below_target viewport logic may need additional guards before applying upward movement, especially when:
- the keyboard is hiding,
keyboard_heightis transitioning to0,_system_keyboard.targetisNone,- the target is no longer focused,
- the keyboard target is stale,
- or touch coordinate handling does not account for the current viewport offset.
It may also be necessary to ensure that touch coordinates are transformed consistently when below_target has shifted the viewport.
Impact
This significantly affects Android text input usability when using Window.softinput_mode = "below_target":
- UI visibly jumps after keyboard dismissal.
- Cursor placement inside
TextInputis unreliable. - Text selection is unreliable.
- Editing existing text becomes difficult or impossible.
- The keyboard can dismiss unexpectedly while interacting with the focused
TextInput.
Below is a patch I made of an earlier window version that worked perfectly with the current window version (all in kivy3.0.0). I replaced the current __init__.py file with an earlier version and the problem disappeard.
--- .buildozer/android/platform/build-arm64-v8a_armeabi-v7a/build/python-installs/market/arm64-v8a/kivy/core/window/__init__.py 2026-05-17 21:27:34
+++ .buildozer/android/platform/build-arm64-v8a_armeabi-v7a/build/python-installs/market/arm64-v8a/kivy/core/window/__init__.py.bak 2026-08-04 18:27:55
@@ -23,8 +23,8 @@
from kivy.event import EventDispatcher
from kivy.properties import ListProperty, ObjectProperty, AliasProperty, \
NumericProperty, OptionProperty, StringProperty, BooleanProperty, \
- ColorProperty
-from kivy.utils import platform, reify, deprecated, pi_version
+ ColorProperty, DictProperty
+from kivy.utils import platform, pi_version
from kivy.context import get_current_context
from kivy.uix.behaviors import FocusBehavior
from kivy.setupconfig import USE_SDL3
@@ -35,7 +35,6 @@
# late import
VKeyboard = None
-android = None
Animation = None
@@ -683,23 +682,18 @@
def _upd_kbd_height(self, *kargs):
self._keyboard_changed = not self._keyboard_changed
self._animate_content()
-
- def _get_ios_kheight(self):
- import ios
- return ios.get_kheight()
- def _get_android_kheight(self):
- if USE_SDL3: # Placeholder until the SDL3 bootstrap supports this
- return 0
- global android
- if not android:
- import android
- return android.get_keyboard_height()
+ def _refresh_safe_area(self, *args):
+ """Update Window.safe_area from kivy.mobile.get_safe_area()."""
+ if platform not in {'ios', 'android'}:
+ return
+ from kivy.mobile import get_safe_area
+ self.safe_area = get_safe_area()
def _get_kivy_vkheight(self):
mode = Config.get('kivy', 'keyboard_mode')
if (
- mode in ['dock', 'systemanddock']
+ mode in {'dock', 'systemanddock'}
and self._vkeyboard_cls is not None
):
for w in self.children:
@@ -715,21 +709,24 @@
return 0
def _get_kheight(self):
- if platform == 'android':
- return self._get_android_kheight()
- elif platform == 'ios':
- return self._get_ios_kheight()
+ if platform in {'android', 'ios'}:
+ from kivy.mobile import get_keyboard_height
+ return get_keyboard_height()
return self._get_kivy_vkheight()
keyboard_height = AliasProperty(_get_kheight, bind=('_keyboard_changed',))
'''Returns the height of the softkeyboard/IME on mobile platforms.
Will return 0 if not on mobile platform or if IME is not active.
- .. note:: This property returns 0 with SDL3 on Android, but setting
- Window.softinput_mode does work.
+ On iOS and Android the value is read from :mod:`kivy.mobile`
+ (``get_keyboard_height``), which reports the live IME inset height.
.. versionadded:: 1.9.0
+ .. versionchanged:: 3.0.0
+ The Android/iOS height is now sourced from :mod:`kivy.mobile` instead of
+ the legacy ``android`` module.
+
:attr:`keyboard_height` is a read-only
:class:`~kivy.properties.AliasProperty` and defaults to 0.
'''
@@ -755,6 +752,37 @@
:class:`~kivy.properties.NumericProperty` and defaults to 0.
'''
+ safe_area = DictProperty(
+ {"top": 0.0, "left": 0.0, "bottom": 0.0, "right": 0.0}
+ )
+ '''Safe-area insets in layout points for the current device and orientation.
+
+ Covers areas of the screen that should not be obscured by app content:
+ the status bar / Dynamic Island (top), the home indicator (bottom), and
+ the notch / rounded-corner overhang (left / right in landscape).
+
+ The dictionary always contains the keys ``"top"``, ``"left"``,
+ ``"bottom"``, and ``"right"``. All values are in the same coordinate
+ system as Kivy layout (UIKit points on iOS).
+
+ The property is refreshed automatically whenever the window size or
+ rotation changes. On desktop platforms it remains
+ ``{"top": 0, "left": 0, "bottom": 0, "right": 0}``.
+
+ Usage example::
+
+ from kivy.core.window import Window
+
+ def on_safe_area(window, insets):
+ print("top inset:", insets["top"])
+
+ Window.bind(safe_area=on_safe_area)
+
+ .. versionadded:: 3.0.0
+
+ :attr:`safe_area` is a :class:`~kivy.properties.DictProperty`.
+ '''
+
def _set_system_size(self, size):
self._size = size
@@ -1062,7 +1090,7 @@
'on_touch_move', 'on_touch_up', 'on_mouse_down',
'on_mouse_move', 'on_mouse_up', 'on_keyboard', 'on_key_down',
'on_key_up', 'on_textinput', 'on_drop_begin', 'on_drop_file',
- 'on_dropfile', 'on_drop_text', 'on_drop_end', 'on_request_close',
+ 'on_drop_text', 'on_drop_end', 'on_request_close',
'on_cursor_enter', 'on_cursor_leave', 'on_joy_axis',
'on_joy_hat', 'on_joy_ball', 'on_joy_button_down',
'on_joy_button_up', 'on_memorywarning', 'on_textedit',
@@ -1148,10 +1176,6 @@
if 'shape_image' not in kwargs:
kwargs['shape_image'] = Config.get('kivy', 'window_shape')
- self.fbind(
- 'on_drop_file',
- lambda win, filename, *args: win.dispatch('on_dropfile', filename)
- )
super(WindowBase, self).__init__(**kwargs)
# bind all the properties that need to recreate the window
@@ -1161,6 +1185,27 @@
self.bind(softinput_mode=lambda *dt: self.update_viewport(),
keyboard_height=lambda *dt: self.update_viewport())
+
+ # Refresh on both size and rotation: on Android/SDL the OS orientation
+ # change arrives as a window resize (size), not a rotation property
+ # change, so binding rotation alone would leave safe_area stale.
+ self.bind(size=self._refresh_safe_area,
+ rotation=self._refresh_safe_area)
+ self._refresh_safe_area() # populate on startup
+ # getRootWindowInsets() can still be null this early; re-read on the
+ # next frame so the first value isn't stuck at all-zeros.
+ Clock.schedule_once(self._refresh_safe_area, 0)
+
+ if platform in {'ios', 'android'}:
+ from kivy.mobile import subscribe_keyboard_height
+ # subscribe_keyboard_height fires on every IME height change; route
+ # it through trigger_keyboard_height for parity with iOS. That is a
+ # 0.5s Clock trigger, so keyboard_height lags briefly but always
+ # re-reads the live value (a shorter/zero-delay trigger is a
+ # possible future refinement).
+ subscribe_keyboard_height(
+ lambda h: self.trigger_keyboard_height()
+ )
self.bind(show_cursor=lambda *dt: self._set_cursor_state(dt[1]))
@@ -2042,11 +2087,11 @@
# TODO If just CMD+w is pressed, only the window should be closed.
is_osx = platform == 'darwin'
if key == 27 and platform == 'android':
- from android import mActivity
- mActivity.moveTaskToBack(True)
+ from kivy.mobile._platform.android import move_task_to_back
+ move_task_to_back()
return True
elif WindowBase.on_keyboard.exit_on_escape:
- if key == 27 or all([is_osx, key in [113, 119], modifier == 1024]):
+ if key == 27 or all([is_osx, key in {113, 119}, modifier == 1024]):
if not self.dispatch('on_request_close', source='keyboard'):
stopTouchApp()
self.close()
@@ -2158,12 +2203,6 @@
.. versionchanged:: 2.1.0
Renamed from `on_dropfile` to `on_drop_file`.
'''
- pass
-
- @deprecated(msg='Deprecated in 2.1.0, use on_drop_file event instead. '
- 'Event on_dropfile will be removed in the next two '
- 'releases.')
- def on_dropfile(self, filename):
pass
def on_drop_text(self, text, x, y, *args):Source: kivy/kivy