Skip to content

Instantly share code, notes, and snippets.

@Shilo
Last active March 23, 2025 22:48
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.
# Also account for android corner radius.
_set_theme_margin_rect.call_deferred(_get_global_corner_area())
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:
left = roundi(a_left / scale.x)
right = roundi(a_right / scale.x)
top = roundi(a_top / scale.y)
bottom = roundi(a_bottom / scale.y)
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 _get_global_corner_area() -> Rect2:
var corner_area: Rect2 = Rect2(DisplayServer.screen_get_position(), DisplayServer.screen_get_size())
var window: Window = get_tree().root
if _is_android():
corner_area = _add_android_corner_margins(corner_area)
corner_area.position -= Vector2(window.position)
corner_area = window.get_screen_transform().affine_inverse() * corner_area
return corner_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()
if mobile_orientation_check_duration <= 0:
_mobile_orientation_listener_timer = null
return
_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(mobile_orientation_check_duration)
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;
/// <summary>
/// Margins for Mobile safe area that accounts for cutouts.
/// Also accounts for corner radius.
/// </summary>
[GlobalClass]
public partial class SafeAreaContainer : MarginContainer
{
[Export] private bool _left = true;
[Export] private bool _top = true;
[Export] private bool _right = true;
[Export] private bool _bottom = true;
[ExportGroup("Device Settings")] [Export]
private Vector2I _androidCornerMarginDp = new(24, 24);
[Export] private float _mobileOrientationCheckDuration = 2.0f;
[Export] private bool _allowNonTouchscreen;
private bool _isUpdating;
private Rect2I _safeArea;
private Timer _mobileOrientationListenerTimer;
private int _marginLeft;
private int _marginTop;
private int _marginRight;
private int _marginBottom;
// ReSharper disable MemberCanBePrivate.Global
public bool Left
{
get => this._left;
set
{
this._left = value;
this.Update();
}
}
public bool Top
{
get => this._top;
set
{
this._top = value;
this.Update();
}
}
public bool Right
{
get => this._right;
set
{
this._right = value;
this.Update();
}
}
public bool Bottom
{
get => this._bottom;
set
{
this._bottom = value;
this.Update();
}
}
public Vector2I AndroidCornerMarginDp
{
get => this._androidCornerMarginDp;
set
{
this._androidCornerMarginDp = value;
if (IsAndroid())
this.Update();
}
}
public float MobileOrientationCheckDuration
{
get => this._mobileOrientationCheckDuration;
set
{
this._mobileOrientationCheckDuration = value;
if (this.IsInsideTree() && IsMobileOrientationHalfSensor())
this.StartMobileOrientationListener();
}
}
public bool AllowNonTouchscreen
{
get => this._allowNonTouchscreen;
set
{
this._allowNonTouchscreen = value;
if (!DisplayServer.IsTouchscreenAvailable())
this.Update();
}
}
public int MarginLeft
{
get => this._marginLeft;
set
{
this._marginLeft = value;
this.AddThemeConstantOverride("margin_left", this._marginLeft);
this.Update();
}
}
public int MarginTop
{
get => this._marginTop;
set
{
this._marginTop = value;
this.AddThemeConstantOverride("margin_top", this._marginTop);
this.Update();
}
}
public int MarginRight
{
get => this._marginRight;
set
{
this._marginRight = value;
this.AddThemeConstantOverride("margin_right", this._marginRight);
this.Update();
}
}
public int MarginBottom
{
get => this._marginBottom;
set
{
this._marginBottom = value;
this.AddThemeConstantOverride("margin_bottom", this._marginBottom);
this.Update();
}
}
// ReSharper restore MemberCanBePrivate.Global
public override void _EnterTree()
{
this.Update();
}
public override void _Ready()
{
this._marginLeft = this.GetThemeConstant("margin_left");
this._marginTop = this.GetThemeConstant("margin_top");
this._marginRight = this.GetThemeConstant("margin_right");
this._marginBottom = this.GetThemeConstant("margin_bottom");
this.Update();
this.Resized += this.Update;
// Listen for window size change, for any content scale mode changes.
this.GetTree().Root.SizeChanged += this.Update;
if (IsMobileOrientationHalfSensor())
{
this._safeArea = DisplayServer.GetDisplaySafeArea();
this.StartMobileOrientationListener();
}
}
public void Update()
{
if (this._isUpdating || !this.IsInsideTree() || !this.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.
// Also account for android corner radius.
this.CallDeferred(MethodName.SetThemeMarginRect, this.GetGlobalCornerArea());
return;
}
// Defer update in case that Update() is called multiple times in same frame.
this._isUpdating = true;
Callable.From(() =>
{
this.SetThemeMarginRect(this.GetGlobalSafeArea());
this._isUpdating = false;
}).CallDeferred();
}
private void SetThemeMarginRect(Rect2 marginRect)
{
Rect2 globalRect = this.GetGlobalRect();
this.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)
{
left = Mathf.RoundToInt(left / this.Scale.X);
right = Mathf.RoundToInt(right / this.Scale.X);
top = Mathf.RoundToInt(top / this.Scale.Y);
bottom = Mathf.RoundToInt(bottom / this.Scale.Y);
this.AddThemeConstantOverride("margin_left", left + this._marginLeft);
this.AddThemeConstantOverride("margin_top", top + this._marginTop);
this.AddThemeConstantOverride("margin_right", right + this._marginRight);
this.AddThemeConstantOverride("margin_bottom", bottom + this._marginBottom);
}
private Rect2 GetGlobalSafeArea()
{
this._safeArea = DisplayServer.GetDisplaySafeArea();
Rect2 safeArea = this._safeArea;
Window window = this.GetTree().Root;
if (IsAndroid())
safeArea = this.AddAndroidCornerMargins(safeArea);
if (!this.Left)
{
float endX = safeArea.End.X;
safeArea.Position = new Vector2(0, safeArea.Position.Y);
safeArea.End = new Vector2(endX, safeArea.End.Y);
}
if (!this.Top)
{
float endY = safeArea.End.Y;
safeArea.Position = new Vector2(safeArea.Position.X, 0);
safeArea.End = new Vector2(safeArea.End.X, endY);
}
if (!this.Right || !this.Bottom)
{
Vector2 screenSize = DisplayServer.ScreenGetSize();
if (!this.Right)
safeArea.End = new Vector2(screenSize.X, safeArea.End.Y);
if (!this.Bottom)
safeArea.End = new Vector2(safeArea.End.X, screenSize.Y);
}
safeArea.Position -= window.Position;
safeArea = window.GetScreenTransform().AffineInverse() * safeArea;
return safeArea;
}
private Rect2 GetGlobalCornerArea()
{
Rect2 cornerArea = new(DisplayServer.ScreenGetPosition(), DisplayServer.ScreenGetSize());
Window window = this.GetTree().Root;
if (IsAndroid())
cornerArea = this.AddAndroidCornerMargins(cornerArea);
cornerArea.Position -= window.Position;
cornerArea = window.GetScreenTransform().AffineInverse() * cornerArea;
return cornerArea;
}
private Rect2 AddAndroidCornerMargins(Rect2 safeArea)
{
if (this.AndroidCornerMarginDp == Vector2I.Zero)
return safeArea;
Vector2I cornerMargins = new(
Mathf.RoundToInt(DpToPx(this.AndroidCornerMarginDp.X)),
Mathf.RoundToInt(DpToPx(this.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 (this._mobileOrientationListenerTimer != null)
{
this.RemoveChild(this._mobileOrientationListenerTimer);
this._mobileOrientationListenerTimer.QueueFree();
}
if (this.MobileOrientationCheckDuration <= 0)
{
this._mobileOrientationListenerTimer = null;
return;
}
this._mobileOrientationListenerTimer = new Timer();
this.AddChild(this._mobileOrientationListenerTimer);
this._mobileOrientationListenerTimer.Timeout += this.OnMobileOrientationListenerTick;
this._mobileOrientationListenerTimer.Start(Math.Max(this.MobileOrientationCheckDuration, 1.0 / 60.0));
}
private void OnMobileOrientationListenerTick()
{
if (DisplayServer.GetDisplaySafeArea() != this._safeArea)
this.Update();
}
private static bool IsMobileOrientationHalfSensor()
{
if (!DisplayServer.IsTouchscreenAvailable())
return false;
return DisplayServer.ScreenGetOrientation() is DisplayServer.ScreenOrientation.SensorLandscape
or DisplayServer.ScreenOrientation.SensorPortrait;
}
private bool IsDeviceAllowed() => this.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