#9363·kivy

Window.softinput_mode = "below_target"` causes viewport jump and `TextInput` focus/cursor issues on Android

Author: kengoonCreated Aug 14, 2026Updated Aug 14, 2026

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:

  1. The whole Kivy window/content briefly moves upward after the Android soft keyboard has already disappeared, then returns to its normal position.
  2. Tapping inside an already-focused TextInput to move the cursor or select text causes the TextInput to lose focus and dismisses the keyboard.

Both issues seem related to the viewport translation applied by the below_target soft input mode.

Reproduction code

python
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

  1. Build and run the app on Android.
  2. Tap the TextInput.
  3. The Android soft keyboard appears.
  4. Tap outside the TextInput to dismiss the keyboard.
  5. Observe the content/window movement.

Issue 2: tapping inside focused TextInput dismisses keyboard

  1. Build and run the app on Android.
  2. Tap the TextInput.
  3. The Android soft keyboard appears.
  4. Tap inside the same TextInput, between characters, to move the cursor or select text.
  5. Observe that the TextInput loses focus and the keyboard is dismissed.

Actual behavior

Issue 1

After tapping outside the TextInput:

  1. The keyboard disappears.
  2. The whole Kivy window/content briefly moves upward, as if Kivy still thinks the keyboard is visible.
  3. 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 TextInput loses 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 TextInput should 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:

plain
kivy/core/window/__init__.py

The 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_height remains non-zero briefly after the Android keyboard visually disappears.
  • _system_keyboard.target may still point to the previous TextInput during 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 TextInput may be interpreted as outside the widget, causing focus to be cleared.

This would explain both symptoms:

  1. Kivy briefly moves the content upward after the keyboard disappears because below_target still applies a positive viewport offset.
  2. Taps inside the focused TextInput may 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_height is transitioning to 0,
  • _system_keyboard.target is None,
  • 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 TextInput is 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.

patch
--- .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):