Last active
February 19, 2025 05:35
-
-
Save Shilo/0af20532bb959bbf3d93d61efdccbc4c to your computer and use it in GitHub Desktop.
Godot 4 container that automatically sets margins based on touch screen safe area.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class_name SafeAreaContainer extends MarginContainer | |
var _is_updating: bool | |
var _safe_area: Rect2i | |
var _mobile_orientation_listener_timer: Timer | |
@export var left: bool = true: | |
set(value): | |
left = value | |
update() | |
@export var top: bool = true: | |
set(value): | |
top = value | |
update() | |
@export var right: bool = true: | |
set(value): | |
right = value | |
update() | |
@export var bottom: bool = true: | |
set(value): | |
bottom = value | |
update() | |
@export_group("Device Settings") | |
@export var android_corner_margin_dp: Vector2i = Vector2i(24, 24): | |
set(value): | |
android_corner_margin_dp = value | |
if _is_android(): | |
update() | |
@export var mobile_orientation_check_duration: float = 2.0: | |
set(value): | |
mobile_orientation_check_duration = value | |
if is_inside_tree() and _is_mobile_orientation_half_sensor(): | |
_start_mobile_orientation_listener() | |
@export var allow_non_touchscreen: bool = false: | |
set(value): | |
allow_non_touchscreen = value | |
if not DisplayServer.is_touchscreen_available(): | |
update() | |
var _margin_left: int | |
var margin_left: int: | |
set(value): | |
_margin_left = value | |
add_theme_constant_override(&"margin_left", _margin_left) | |
update() | |
var _margin_top: int | |
var margin_top: int: | |
set(value): | |
_margin_top = value | |
add_theme_constant_override(&"margin_top", _margin_top) | |
update() | |
var _margin_right: int | |
var margin_right: int: | |
set(value): | |
_margin_right = value | |
add_theme_constant_override(&"margin_right", _margin_right) | |
update() | |
var _margin_bottom: int | |
var margin_bottom: int: | |
set(value): | |
_margin_bottom = value | |
add_theme_constant_override(&"margin_bottom", _margin_bottom) | |
update() | |
func _exit_tree() -> void: | |
update() | |
func _ready() -> void: | |
_margin_left = get_theme_constant(&"margin_left") | |
_margin_top = get_theme_constant(&"margin_top") | |
_margin_right = get_theme_constant(&"margin_right") | |
_margin_bottom = get_theme_constant(&"margin_bottom") | |
update() | |
resized.connect(update) | |
# Listen for window size change, for any content scale mode changes. | |
get_tree().root.size_changed.connect(update) | |
if _is_mobile_orientation_half_sensor(): | |
_safe_area = DisplayServer.get_display_safe_area() | |
_start_mobile_orientation_listener() | |
func update() -> void: | |
if _is_updating or not is_inside_tree() or not _is_device_allowed(): | |
return | |
if not _is_safe_area_allowed(): | |
# In case of Android and cutouts are not detected, reset margins to theme margins. | |
# This can happen for waterfall cutouts or split screen mode. | |
_set_theme_margins.call_deferred(0, 0, 0, 0) | |
return | |
# Defer update in case that update() is called multiple times in same frame. | |
_is_updating = true | |
(func(): | |
_set_theme_margin_rect(_get_global_safe_area()) | |
_is_updating = false | |
).call_deferred() | |
func _set_theme_margin_rect(margin_rect: Rect2) -> void: | |
var global_rect: Rect2 = get_global_rect() | |
_set_theme_margins( | |
max(roundi(margin_rect.position.x - global_rect.position.x), 0), | |
max(roundi(margin_rect.position.y - global_rect.position.y), 0), | |
max(roundi(global_rect.end.x - margin_rect.end.x), 0), | |
max(roundi(global_rect.end.y - margin_rect.end.y), 0) | |
) | |
func _set_theme_margins(a_left: int, a_top: int, a_right: int, a_bottom: int) -> void: | |
add_theme_constant_override(&"margin_left", a_left + _margin_left) | |
add_theme_constant_override(&"margin_top", a_top + _margin_top) | |
add_theme_constant_override(&"margin_right", a_right + _margin_right) | |
add_theme_constant_override(&"margin_bottom", a_bottom + _margin_bottom) | |
func _get_global_safe_area() -> Rect2: | |
_safe_area = DisplayServer.get_display_safe_area() | |
var safe_area := Rect2(_safe_area) | |
var window: Window = get_tree().root | |
if _is_android(): | |
safe_area = _add_android_corner_margins(safe_area) | |
if not left: | |
var end_x: float = safe_area.end.x | |
safe_area.position.x = 0 | |
safe_area.end.x = end_x | |
if not top: | |
var end_y: float = safe_area.end.y | |
safe_area.position.y = 0 | |
safe_area.end.y = end_y | |
if not right or not bottom: | |
var screen_size: Vector2 = DisplayServer.screen_get_size() | |
if not right: | |
safe_area.end.x = screen_size.x | |
if not bottom: | |
safe_area.end.y = screen_size.y | |
safe_area.position -= Vector2(window.position) | |
safe_area = window.get_screen_transform().affine_inverse() * safe_area | |
return safe_area | |
func _add_android_corner_margins(safe_area: Rect2) -> Rect2: | |
if not android_corner_margin_dp: | |
return safe_area | |
var corner_margins := Vector2i( | |
roundi(_dp_to_px(android_corner_margin_dp.x)), | |
roundi(_dp_to_px(android_corner_margin_dp.y)) | |
) | |
var screen_size = DisplayServer.screen_get_size() | |
var safe_area_end_x: float = safe_area.end.x | |
var safe_area_end_y: float = safe_area.end.y | |
safe_area.position.x = max(safe_area.position.x, corner_margins.x) | |
safe_area.position.y = max(safe_area.position.y, corner_margins.y) | |
safe_area.end.x = min(safe_area_end_x, screen_size.x - corner_margins.x) | |
safe_area.end.y = min(safe_area_end_y, screen_size.y - corner_margins.y) | |
return safe_area | |
func _start_mobile_orientation_listener() -> void: | |
if _mobile_orientation_listener_timer: | |
remove_child(_mobile_orientation_listener_timer) | |
_mobile_orientation_listener_timer.queue_free() | |
_mobile_orientation_listener_timer = Timer.new() | |
add_child(_mobile_orientation_listener_timer) | |
_mobile_orientation_listener_timer.timeout.connect(_on_mobile_orientation_listener_tick) | |
_mobile_orientation_listener_timer.start(max(mobile_orientation_check_duration, 1.0 / 60)) | |
func _on_mobile_orientation_listener_tick() -> void: | |
if DisplayServer.get_display_safe_area() != _safe_area: | |
update() | |
static func _is_mobile_orientation_half_sensor() -> bool: | |
if not DisplayServer.is_touchscreen_available(): | |
return false | |
var orientation: DisplayServer.ScreenOrientation = DisplayServer.screen_get_orientation() | |
return orientation == DisplayServer.ScreenOrientation.SCREEN_SENSOR_LANDSCAPE or\ | |
orientation == DisplayServer.ScreenOrientation.SCREEN_SENSOR_PORTRAIT | |
func _is_device_allowed() -> bool: | |
return allow_non_touchscreen or DisplayServer.is_touchscreen_available() | |
static func _dp_to_px(dp: int) -> float: | |
return dp * (DisplayServer.screen_get_dpi() / 160.0) | |
static func _is_safe_area_allowed() -> bool: | |
# When Android and Godot detects no cutouts, don't allow safe area margins | |
# because Godot API returns incorrect DisplayServer.get_display_safe_area() otherwise. | |
return not (_is_android() and DisplayServer.get_display_cutouts().size() == 0) | |
static func _is_android() -> bool: | |
return OS.get_name() == "Android" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using Godot; | |
using System; | |
[GlobalClass] | |
public partial class SafeAreaContainer : MarginContainer | |
{ | |
private bool _isUpdating; | |
private Rect2I _safeArea; | |
private Timer _mobileOrientationListenerTimer; | |
private bool _left = true; | |
private bool _top = true; | |
private bool _right = true; | |
private bool _bottom = true; | |
private bool _allowNonTouchscreen; | |
private Vector2I _androidCornerMarginDp = new(24, 24); | |
private float _mobileOrientationCheckDuration = 2.0f; | |
private int _marginLeft; | |
private int _marginTop; | |
private int _marginRight; | |
private int _marginBottom; | |
[Export] | |
public bool Left | |
{ | |
get => _left; | |
set | |
{ | |
_left = value; | |
Update(); | |
} | |
} | |
[Export] | |
public bool Top | |
{ | |
get => _top; | |
set | |
{ | |
_top = value; | |
Update(); | |
} | |
} | |
[Export] | |
public bool Right | |
{ | |
get => _right; | |
set | |
{ | |
_right = value; | |
Update(); | |
} | |
} | |
[Export] | |
public bool Bottom | |
{ | |
get => _bottom; | |
set | |
{ | |
_bottom = value; | |
Update(); | |
} | |
} | |
[ExportGroup("Device Settings")] | |
[Export] | |
public Vector2I AndroidCornerMarginDp | |
{ | |
get => _androidCornerMarginDp; | |
set | |
{ | |
_androidCornerMarginDp = value; | |
if (IsAndroid()) | |
Update(); | |
} | |
} | |
[Export] | |
public float MobileOrientationCheckDuration | |
{ | |
get => _mobileOrientationCheckDuration; | |
set | |
{ | |
_mobileOrientationCheckDuration = value; | |
if (IsInsideTree() && IsMobileOrientationHalfSensor()) | |
StartMobileOrientationListener(); | |
} | |
} | |
[Export] | |
public bool AllowNonTouchscreen | |
{ | |
get => _allowNonTouchscreen; | |
set | |
{ | |
_allowNonTouchscreen = value; | |
if (!DisplayServer.IsTouchscreenAvailable()) | |
Update(); | |
} | |
} | |
public int MarginLeft | |
{ | |
get => _marginLeft; | |
set | |
{ | |
_marginLeft = value; | |
AddThemeConstantOverride("margin_left", _marginLeft); | |
Update(); | |
} | |
} | |
public int MarginTop | |
{ | |
get => _marginTop; | |
set | |
{ | |
_marginTop = value; | |
AddThemeConstantOverride("margin_top", _marginTop); | |
Update(); | |
} | |
} | |
public int MarginRight | |
{ | |
get => _marginRight; | |
set | |
{ | |
_marginRight = value; | |
AddThemeConstantOverride("margin_right", _marginRight); | |
Update(); | |
} | |
} | |
public int MarginBottom | |
{ | |
get => _marginBottom; | |
set | |
{ | |
_marginBottom = value; | |
AddThemeConstantOverride("margin_bottom", _marginBottom); | |
Update(); | |
} | |
} | |
public override void _EnterTree() | |
{ | |
Update(); | |
} | |
public override void _Ready() | |
{ | |
_marginLeft = GetThemeConstant("margin_left"); | |
_marginTop = GetThemeConstant("margin_top"); | |
_marginRight = GetThemeConstant("margin_right"); | |
_marginBottom = GetThemeConstant("margin_bottom"); | |
Update(); | |
Resized += Update; | |
// Listen for window size change, for any content scale mode changes. | |
GetTree().Root.SizeChanged += Update; | |
if (IsMobileOrientationHalfSensor()) | |
{ | |
_safeArea = DisplayServer.GetDisplaySafeArea(); | |
StartMobileOrientationListener(); | |
} | |
} | |
public void Update() | |
{ | |
if (_isUpdating || !IsInsideTree() || !IsDeviceAllowed()) | |
return; | |
if (!IsSafeAreaAllowed()) | |
{ | |
// In case of Android and cutouts are not detected, reset margins to theme margins. | |
// This can happen for waterfall cutouts or split screen mode. | |
CallDeferred(MethodName.SetThemeMargins, 0, 0, 0, 0); | |
return; | |
} | |
// Defer update in case that Update() is called multiple times in same frame. | |
_isUpdating = true; | |
Callable.From(() => | |
{ | |
SetThemeMarginRect(GetGlobalSafeArea()); | |
_isUpdating = false; | |
}).CallDeferred(); | |
} | |
private void SetThemeMarginRect(Rect2 marginRect) | |
{ | |
Rect2 globalRect = GetGlobalRect(); | |
SetThemeMargins( | |
Math.Max(Mathf.RoundToInt(marginRect.Position.X - globalRect.Position.X), 0), | |
Math.Max(Mathf.RoundToInt(marginRect.Position.Y - globalRect.Position.Y), 0), | |
Math.Max(Mathf.RoundToInt(globalRect.End.X - marginRect.End.X), 0), | |
Math.Max(Mathf.RoundToInt(globalRect.End.Y - marginRect.End.Y), 0) | |
); | |
} | |
private void SetThemeMargins(int left, int top, int right, int bottom) | |
{ | |
AddThemeConstantOverride("margin_left", left + _marginLeft); | |
AddThemeConstantOverride("margin_top", top + _marginTop); | |
AddThemeConstantOverride("margin_right", right + _marginRight); | |
AddThemeConstantOverride("margin_bottom", bottom + _marginBottom); | |
} | |
private Rect2 GetGlobalSafeArea() | |
{ | |
_safeArea = DisplayServer.GetDisplaySafeArea(); | |
Rect2 safeArea = _safeArea; | |
Window window = GetTree().Root; | |
if (IsAndroid()) | |
safeArea = AddAndroidCornerMargins(safeArea); | |
if (!Left) | |
{ | |
float endX = safeArea.End.X; | |
safeArea.Position = new Vector2(0, safeArea.Position.Y); | |
safeArea.End = new Vector2(endX, safeArea.End.Y); | |
} | |
if (!Top) | |
{ | |
float endY = safeArea.End.Y; | |
safeArea.Position = new Vector2(safeArea.Position.X, 0); | |
safeArea.End = new Vector2(safeArea.End.X, endY); | |
} | |
if (!Right || !Bottom) | |
{ | |
Vector2 screenSize = DisplayServer.ScreenGetSize(); | |
if (!Right) | |
safeArea.End = new Vector2(screenSize.X, safeArea.End.Y); | |
if (!Bottom) | |
safeArea.End = new Vector2(safeArea.End.X, screenSize.Y); | |
} | |
safeArea.Position -= window.Position; | |
safeArea = window.GetScreenTransform().AffineInverse() * safeArea; | |
return safeArea; | |
} | |
private Rect2 AddAndroidCornerMargins(Rect2 safeArea) | |
{ | |
if (AndroidCornerMarginDp == Vector2I.Zero) | |
return safeArea; | |
Vector2I cornerMargins = new Vector2I( | |
Mathf.RoundToInt(DpToPx(AndroidCornerMarginDp.X)), | |
Mathf.RoundToInt(DpToPx(AndroidCornerMarginDp.Y)) | |
); | |
Vector2 screenSize = DisplayServer.ScreenGetSize(); | |
float safeAreaEndX = safeArea.End.X; | |
float safeAreaEndY = safeArea.End.Y; | |
safeArea.Position = new Vector2( | |
Math.Max(safeArea.Position.X, cornerMargins.X), | |
Math.Max(safeArea.Position.Y, cornerMargins.Y)); | |
safeArea.End = new Vector2( | |
Math.Min(safeAreaEndX, screenSize.X - cornerMargins.X), | |
Math.Min(safeAreaEndY, screenSize.Y - cornerMargins.Y)); | |
return safeArea; | |
} | |
private void StartMobileOrientationListener() | |
{ | |
if (_mobileOrientationListenerTimer != null) | |
{ | |
RemoveChild(_mobileOrientationListenerTimer); | |
_mobileOrientationListenerTimer.QueueFree(); | |
} | |
_mobileOrientationListenerTimer = new Timer(); | |
AddChild(_mobileOrientationListenerTimer); | |
_mobileOrientationListenerTimer.Timeout += OnMobileOrientationListenerTick; | |
_mobileOrientationListenerTimer.Start(Math.Max(MobileOrientationCheckDuration, 1.0 / 60.0)); | |
} | |
private void OnMobileOrientationListenerTick() | |
{ | |
if (DisplayServer.GetDisplaySafeArea() != _safeArea) | |
Update(); | |
} | |
private static bool IsMobileOrientationHalfSensor() | |
{ | |
if (!DisplayServer.IsTouchscreenAvailable()) | |
return false; | |
return DisplayServer.ScreenGetOrientation() is DisplayServer.ScreenOrientation.SensorLandscape | |
or DisplayServer.ScreenOrientation.SensorPortrait; | |
} | |
private bool IsDeviceAllowed() => AllowNonTouchscreen || DisplayServer.IsTouchscreenAvailable(); | |
private static float DpToPx(int dp) => dp * (DisplayServer.ScreenGetDpi() / 160.0f); | |
// When Android and Godot detects no cutouts, don't allow safe area margins | |
// because Godot API returns incorrect DisplayServer.GetDisplaySafeArea() otherwise. | |
private static bool IsSafeAreaAllowed() => !(IsAndroid() && DisplayServer.GetDisplayCutouts().Count == 0); | |
private static bool IsAndroid() => OS.GetName() == "Android"; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment