Skip to content

Instantly share code, notes, and snippets.

@Shilo
Last active February 19, 2025 05:35
Show Gist options
  • Save Shilo/0af20532bb959bbf3d93d61efdccbc4c to your computer and use it in GitHub Desktop.
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.
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"
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