Skip to content

Commit 70f9224

Browse files
authored
Handling system bars and Edge-to-Edge enforcement (android 15+) (#3278)
* Add update_system_ui for status and navigation bar colors Implement update_system_ui function for Android UI customization. * Update apis.rst * Update utils.py * Update utils.py * Update code quality and readbility * Create a _global_listener to prevent garbage collection * Update utils.py * Set _global_listener to None in utils.py Initialize _global_listener to None. * Move existing logic to display_cutout * Bug fixes * Update docs * Update docs for right version of the func * Just just
1 parent 74b559a commit 70f9224

3 files changed

Lines changed: 249 additions & 24 deletions

File tree

doc/source/apis.rst

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,40 @@ Working on Android
55
This page gives details on accessing Android APIs and managing other
66
interactions on Android.
77

8+
Handling system bars and Edge-to-Edge enforcement
9+
-------------------------------------------------
10+
11+
**Egde-to-Edge is enforced on all android apis >=35 by default i.e. Android 15 and above.**
12+
13+
You can control the overall layout and system bars appearance in following ways::
14+
15+
from android.display_cutout import update_system_ui
16+
17+
update_system_ui(
18+
"#0f62fe", # status_bar_color: hex color code or rgba (tuple or list) values
19+
[0.059, 0.384, 0.996, 1.000], # navigation_bar_color: hex color code or rgba (tuple or list) values
20+
"Light", # icon_style: "Dark" means dark icons will be drawn, "Light" means light icons will be drawn, Literal["Dark", or "Light"]
21+
True, # pad_status: Adds a padding to top of content_view, Will take effect on Android 15+
22+
True, # pad_nav: Adds a padding to bottom of content_view, Will take effect on Android 15+
23+
)
24+
25+
26+
Handling Immersive Mode
27+
~~~~~~~~~~~~~~~~~~~~~~~
28+
29+
Immersive mode allows your application to hide the system bars (status bar and navigation bar) for a true full-screen experience, commonly used in games or media players.
30+
31+
You can control the immersive mode behavior and how system bars reappear using the following way::
32+
33+
from android.display_cutout import set_immersive_mode
34+
35+
set_immersive_mode(
36+
False, # hide_status: True explicitly targets hiding the top status bar, False leaves it visible
37+
False, # hide_nav: True explicitly targets hiding the bottom navigation bar/gestural pill, False leaves it visible
38+
True, # remove_contrast: True disables the default system-enforced background scrim/contrast behind the bars, ensuring complete transparency when they peek
39+
)
40+
41+
842
Storage paths
943
-------------
1044

pythonforandroid/bootstraps/_sdl_common/build/templates/strings.tmpl.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<style name="KivySupportCutout">
44
<item name="android:windowNoTitle">true</item>
55
<!-- Display cutout is an area on some devices that extends into the display surface -->
6-
{% if args.display_cutout != 'never'%}
6+
{% if args.display_cutout != 'never' %}
77
<item name="android:windowLayoutInDisplayCutoutMode">{{ args.display_cutout }}</item>
88
<item name="android:windowTranslucentStatus">true</item>
99
<item name="android:windowTranslucentNavigation">true</item>
Lines changed: 214 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,77 @@
1-
from jnius import autoclass
1+
from android import mActivity
2+
from android.runnable import run_on_ui_thread
3+
from jnius import autoclass, java_method, PythonJavaClass
4+
from typing import Literal
5+
26
from kivy.core.window import Window
37

4-
from android import mActivity
8+
__all__ = (
9+
"get_cutout_pos",
10+
"get_cutout_size",
11+
"get_width_of_bar",
12+
"get_height_of_bar",
13+
"get_size_of_bar",
14+
"get_width_of_bar",
15+
"get_cutout_mode",
16+
"update_system_ui",
17+
)
18+
19+
Color = autoclass("android.graphics.Color")
20+
Build_VERSION = autoclass("android.os.Build$VERSION")
21+
WindowInsetsType = autoclass("android.view.WindowInsets$Type")
22+
View = autoclass("android.view.View")
23+
window = mActivity.getWindow()
24+
decor_view = window.getDecorView()
25+
content_view = window.findViewById(autoclass("android.R$id").content)
26+
27+
28+
def parse_color(value):
29+
if isinstance(value, str):
30+
return Color.parseColor(value)
31+
elif isinstance(value, (list, tuple)) and len(value) == 4:
32+
r, g, b, a = value
33+
return Color.argb(a, r, g, b)
34+
else:
35+
raise ValueError("Color must be hex string or RGBA tuple")
36+
37+
38+
# Oops!! android 15+ needs a listener
39+
if Build_VERSION.SDK_INT >= 35:
40+
41+
class InsetsListener(PythonJavaClass):
42+
__javainterfaces__ = ["android/view/View$OnApplyWindowInsetsListener"]
43+
__javacontext__ = "app"
44+
45+
def __init__(self, status_color, navigation_color, pad_status, pad_nav):
46+
super().__init__()
47+
self.status_color = status_color
48+
self.navigation_color = navigation_color
49+
self.pad_status = pad_status
50+
self.pad_nav = pad_nav
551

6-
__all__ = ('get_cutout_pos', 'get_cutout_size', 'get_width_of_bar',
7-
'get_height_of_bar', 'get_size_of_bar', 'get_width_of_bar',
8-
'get_cutout_mode')
52+
@java_method(
53+
"(Landroid/view/View;Landroid/view/WindowInsets;)Landroid/view/WindowInsets;"
54+
)
55+
def onApplyWindowInsets(self, view, insets):
56+
try:
57+
status_insets = insets.getInsets(WindowInsetsType.statusBars())
58+
nav_insets = insets.getInsets(WindowInsetsType.navigationBars())
59+
60+
top_pad = status_insets.top if self.pad_status else 0
61+
bottom_pad = nav_insets.bottom if self.pad_nav else 0
62+
63+
content_view.setPadding(0, top_pad, 0, bottom_pad)
64+
content_view.setBackgroundColor(self.status_color)
65+
66+
window.setNavigationBarColor(self.navigation_color)
67+
except Exception as e:
68+
print("Insets error:", e)
69+
import traceback
70+
71+
traceback.print_exc()
72+
return insets
73+
74+
_global_listener: InsetsListener = None
975

1076

1177
def _core_cutout():
@@ -17,7 +83,7 @@ def _core_cutout():
1783

1884
def get_cutout_pos():
1985
"""Get position of the display-cutout.
20-
Returns integer for each positions (xy)
86+
Returns integer for each positions (xy)
2187
"""
2288
try:
2389
cutout = _core_cutout()
@@ -29,36 +95,37 @@ def get_cutout_pos():
2995

3096
def get_cutout_size():
3197
"""Get the size (xy) of the front camera.
32-
Returns size with float values
98+
Returns size with float values
3399
"""
34100
try:
35101
cutout = _core_cutout()
36102
return float(cutout.width()), float(cutout.height())
37103
except Exception:
38104
# Doesn't have a camera builtin with the display
39-
return 0., 0.
105+
return 0.0, 0.0
40106

41107

42108
def get_height_of_bar(bar_target=None):
43109
"""Get the height of either statusbar or navigationbar
44-
bar_target = status or navigation and defaults to status
110+
bar_target = status or navigation and defaults to status
45111
"""
46-
bar_target = bar_target or 'status'
112+
bar_target = bar_target or "status"
47113

48-
if bar_target not in ('status', 'navigation'):
114+
if bar_target not in ("status", "navigation"):
49115
raise Exception("bar_target must be 'status' or 'navigation'")
50116

51117
try:
52-
displayMetrics = autoclass('android.util.DisplayMetrics')
118+
displayMetrics = autoclass("android.util.DisplayMetrics")
53119
mActivity.getWindowManager().getDefaultDisplay().getMetrics(displayMetrics())
54120
resources = mActivity.getResources()
55-
resourceId = resources.getIdentifier(f'{bar_target}_bar_height', 'dimen',
56-
'android')
121+
resourceId = resources.getIdentifier(
122+
f"{bar_target}_bar_height", "dimen", "android"
123+
)
57124

58125
return float(max(resources.getDimensionPixelSize(resourceId), 0))
59126
except Exception:
60127
# Getting the size is not supported on older Androids
61-
return 0.
128+
return 0.0
62129

63130

64131
def get_width_of_bar(bar_target=None):
@@ -68,32 +135,156 @@ def get_width_of_bar(bar_target=None):
68135

69136
def get_size_of_bar(bar_target=None):
70137
"""Get the size of either statusbar or navigationbar
71-
bar_target = status or navigation and defaults to status
138+
bar_target = status or navigation and defaults to status
72139
"""
73140
return get_width_of_bar(), get_height_of_bar(bar_target)
74141

75142

76143
def get_heights_of_both_bars():
77144
"""Return heights of both bars"""
78-
return get_height_of_bar('status'), get_height_of_bar('navigation')
145+
return get_height_of_bar("status"), get_height_of_bar("navigation")
79146

80147

81148
def get_cutout_mode():
82149
"""Return mode for cutout supported applications"""
83-
BuildVersion = autoclass('android.os.Build$VERSION')
150+
BuildVersion = autoclass("android.os.Build$VERSION")
84151
cutout_modes = {}
85152

86153
if BuildVersion.SDK_INT >= 28:
87-
LayoutParams = autoclass('android.view.WindowManager$LayoutParams')
154+
LayoutParams = autoclass("android.view.WindowManager$LayoutParams")
88155
window = mActivity.getWindow()
89156
layout_params = window.getAttributes()
90157
cutout_mode = layout_params.layoutInDisplayCutoutMode
91-
cutout_modes.update({LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT: 'default',
92-
LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES: 'shortEdges'})
158+
cutout_modes.update(
159+
{
160+
LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT: "default",
161+
LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES: "shortEdges",
162+
}
163+
)
93164

94165
if BuildVersion.SDK_INT >= 30:
95-
cutout_modes[LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS] = 'always'
166+
cutout_modes[LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS] = "always"
96167

97-
return cutout_modes.get(cutout_mode, 'never')
168+
return cutout_modes.get(cutout_mode, "never")
98169

99170
return None
171+
172+
173+
@run_on_ui_thread
174+
def set_immersive_mode(hide_nav=True, hide_status=True, disable_contrast=True):
175+
"""
176+
Configures Android UI visibility, optionally hiding the navigation and status bars,
177+
and optionally disabling system bar contrast on Android Q (API 29+).
178+
"""
179+
if not mActivity:
180+
return
181+
182+
window = mActivity.getWindow()
183+
decor_view = window.getDecorView()
184+
185+
ui_flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
186+
187+
if hide_nav:
188+
ui_flags |= View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
189+
ui_flags |= View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
190+
191+
if hide_status:
192+
ui_flags |= View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
193+
ui_flags |= View.SYSTEM_UI_FLAG_FULLSCREEN
194+
195+
if hide_nav or hide_status:
196+
ui_flags |= View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
197+
198+
decor_view.setSystemUiVisibility(ui_flags)
199+
200+
if disable_contrast and Build_VERSION.SDK_INT >= 29:
201+
window.setNavigationBarContrastEnforced(False)
202+
window.setStatusBarContrastEnforced(False)
203+
204+
205+
@run_on_ui_thread
206+
def update_system_ui(
207+
status_bar_color: list[float] | str,
208+
navigation_bar_color: list[float] | str,
209+
icon_style: Literal["Light", "Dark"] = "Dark",
210+
pad_status: bool = True,
211+
pad_nav: bool = False,
212+
) -> None:
213+
"""
214+
Provides control of colors for the status and navigation bar and also handle insets padding on Android 15 and above.
215+
216+
For `status_bar_color` and `navigation_bar_color` either provide a hex color code or rgba (tuple or list) values.
217+
`pad_status` and `pad_nav` will take effect only above Android 15.
218+
IF `icon_style` IS `Dark` THE ICONS WILL BE DARK.
219+
IF `icon_style` IS `Light` THE ICONS WILL BE LIGHT.
220+
221+
Adapted from https://github.com/CarbonKivy/CarbonKivy/blob/39e360314a3885f3b462add4475e6c609b5bef53/carbonkivy/utils.py#L43
222+
"""
223+
224+
try:
225+
WindowCompat = autoclass("androidx.core.view.WindowCompat")
226+
inset_controller = WindowCompat.getInsetsController(window, decor_view)
227+
except Exception:
228+
inset_controller = None
229+
230+
status_color_int = parse_color(status_bar_color)
231+
navigation_color_int = parse_color(navigation_bar_color)
232+
233+
# Beleive me, I once drew `dark icons over dark` and `light icons over light` but this won't happen ever again!
234+
if Build_VERSION.SDK_INT >= 30:
235+
# API 30+ (Android 10+)
236+
if inset_controller and "WindowInsetsControllerCompat" in str(
237+
type(inset_controller)
238+
):
239+
# Compat wrapper (AndroidX)
240+
# I suggest to include androidx in builds, it actually helps!
241+
if icon_style == "Light":
242+
inset_controller.setAppearanceLightStatusBars(False)
243+
inset_controller.setAppearanceLightNavigationBars(False)
244+
else:
245+
inset_controller.setAppearanceLightStatusBars(True)
246+
inset_controller.setAppearanceLightNavigationBars(True)
247+
else:
248+
# Platform controller
249+
controller = window.getInsetsController()
250+
WindowInsetsController = autoclass("android.view.WindowInsetsController")
251+
if icon_style == "Light":
252+
controller.setSystemBarsAppearance(
253+
0,
254+
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
255+
| WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS,
256+
)
257+
else:
258+
controller.setSystemBarsAppearance(
259+
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
260+
| WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS,
261+
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
262+
| WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS,
263+
)
264+
else:
265+
# Legacy flags for API 23–29
266+
# Yepp, python3.14 with ndk 28c doesn't support building for android <= 11 with 32 bit armeabi-v7a cpu so this may never be called but who knows??
267+
visibility_flags = decor_view.getSystemUiVisibility()
268+
269+
if icon_style == "Light":
270+
visibility_flags &= ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
271+
if Build_VERSION.SDK_INT >= 26:
272+
visibility_flags &= ~View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
273+
else:
274+
visibility_flags |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
275+
if Build_VERSION.SDK_INT >= 26:
276+
visibility_flags |= View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
277+
278+
decor_view.setSystemUiVisibility(visibility_flags)
279+
280+
if Build_VERSION.SDK_INT >= 35:
281+
# I don't know why but sometimes pyjnius failed to find invoke, maybe due to garbage collection and so I made a reference
282+
global _global_listener
283+
_global_listener = InsetsListener(
284+
status_color_int, navigation_color_int, pad_status, pad_nav
285+
)
286+
decor_view.setOnApplyWindowInsetsListener(_global_listener)
287+
decor_view.requestApplyInsets()
288+
else:
289+
window.setStatusBarColor(status_color_int)
290+
window.setNavigationBarColor(navigation_color_int)

0 commit comments

Comments
 (0)