diff --git a/samples/ControlCatalog.Android/Resources/values/styles.xml b/samples/ControlCatalog.Android/Resources/values/styles.xml index 49e079a719..3e1270256d 100644 --- a/samples/ControlCatalog.Android/Resources/values/styles.xml +++ b/samples/ControlCatalog.Android/Resources/values/styles.xml @@ -4,7 +4,7 @@ - diff --git a/samples/ControlCatalog.Browser/app.css b/samples/ControlCatalog.Browser/app.css index 27680f6e1a..0e6ab12461 100644 --- a/samples/ControlCatalog.Browser/app.css +++ b/samples/ControlCatalog.Browser/app.css @@ -1,4 +1,11 @@ -#out { +:root { + --sat: env(safe-area-inset-top); + --sar: env(safe-area-inset-right); + --sab: env(safe-area-inset-bottom); + --sal: env(safe-area-inset-left); +} + +#out { height: 100vh; width: 100vw } diff --git a/samples/ControlCatalog.iOS/ControlCatalog.iOS.csproj b/samples/ControlCatalog.iOS/ControlCatalog.iOS.csproj index 74d5b2fd8c..b4dac5399c 100644 --- a/samples/ControlCatalog.iOS/ControlCatalog.iOS.csproj +++ b/samples/ControlCatalog.iOS/ControlCatalog.iOS.csproj @@ -3,7 +3,7 @@ Exe manual net6.0-ios - 10.0 + 13.0 True iossimulator-x64 @@ -16,4 +16,4 @@ - \ No newline at end of file + diff --git a/samples/ControlCatalog.iOS/Info.plist b/samples/ControlCatalog.iOS/Info.plist index 6ffe3ba662..1dd4416c28 100644 --- a/samples/ControlCatalog.iOS/Info.plist +++ b/samples/ControlCatalog.iOS/Info.plist @@ -13,7 +13,7 @@ LSRequiresIPhoneOS MinimumOSVersion - 10.0 + 13.0 UIDeviceFamily 1 @@ -39,9 +39,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - UIStatusBarHidden - - UIViewControllerBasedStatusBarAppearance - diff --git a/samples/ControlCatalog/App.xaml.cs b/samples/ControlCatalog/App.xaml.cs index d71d51f068..246fe4385f 100644 --- a/samples/ControlCatalog/App.xaml.cs +++ b/samples/ControlCatalog/App.xaml.cs @@ -44,11 +44,11 @@ namespace ControlCatalog { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime) { - desktopLifetime.MainWindow = new MainWindow(); + desktopLifetime.MainWindow = new MainWindow { DataContext = new MainWindowViewModel() }; } else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewLifetime) { - singleViewLifetime.MainView = new MainView(); + singleViewLifetime.MainView = new MainView { DataContext = new MainWindowViewModel() }; } base.OnFrameworkInitializationCompleted(); diff --git a/samples/ControlCatalog/MainView.xaml.cs b/samples/ControlCatalog/MainView.xaml.cs index 6f31d22677..9c439c874f 100644 --- a/samples/ControlCatalog/MainView.xaml.cs +++ b/samples/ControlCatalog/MainView.xaml.cs @@ -1,5 +1,6 @@ using System; using System.Collections; +using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; @@ -12,6 +13,7 @@ using Avalonia.VisualTree; using Avalonia.Styling; using ControlCatalog.Models; using ControlCatalog.Pages; +using ControlCatalog.ViewModels; namespace ControlCatalog { @@ -99,13 +101,47 @@ namespace ControlCatalog }; } + internal MainWindowViewModel ViewModel => (MainWindowViewModel)DataContext!; + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); + var decorations = this.Get("Decorations"); if (VisualRoot is Window window) decorations.SelectedIndex = (int)window.SystemDecorations; - + + var insets = TopLevel.GetTopLevel(this)!.InsetsManager; + if (insets != null) + { + // In real life application these events should be unsubscribed to avoid memory leaks. + ViewModel.SafeAreaPadding = insets.SafeAreaPadding; + insets.SafeAreaChanged += (sender, args) => + { + ViewModel.SafeAreaPadding = insets.SafeAreaPadding; + }; + + ViewModel.DisplayEdgeToEdge = insets.DisplayEdgeToEdge; + ViewModel.IsSystemBarVisible = insets.IsSystemBarVisible ?? true; + + ViewModel.PropertyChanged += async (sender, args) => + { + if (args.PropertyName == nameof(ViewModel.DisplayEdgeToEdge)) + { + insets.DisplayEdgeToEdge = ViewModel.DisplayEdgeToEdge; + } + else if (args.PropertyName == nameof(ViewModel.IsSystemBarVisible)) + { + insets.IsSystemBarVisible = ViewModel.IsSystemBarVisible; + } + + // Give the OS some time to apply new values and refresh the view model. + await Task.Delay(100); + ViewModel.DisplayEdgeToEdge = insets.DisplayEdgeToEdge; + ViewModel.IsSystemBarVisible = insets.IsSystemBarVisible ?? true; + }; + } + _platformSettings.ColorValuesChanged += PlatformSettingsOnColorValuesChanged; PlatformSettingsOnColorValuesChanged(_platformSettings, _platformSettings.GetColorValues()); } diff --git a/samples/ControlCatalog/MainWindow.xaml.cs b/samples/ControlCatalog/MainWindow.xaml.cs index c589f41442..10ff94d25c 100644 --- a/samples/ControlCatalog/MainWindow.xaml.cs +++ b/samples/ControlCatalog/MainWindow.xaml.cs @@ -17,7 +17,6 @@ namespace ControlCatalog { this.InitializeComponent(); - DataContext = new MainWindowViewModel(); _recentMenu = ((NativeMenu.GetMenu(this)?.Items[0] as NativeMenuItem)?.Menu?.Items[2] as NativeMenuItem)?.Menu; } diff --git a/samples/ControlCatalog/Pages/WindowCustomizationsPage.xaml b/samples/ControlCatalog/Pages/WindowCustomizationsPage.xaml index d690058b27..bcc1a71243 100644 --- a/samples/ControlCatalog/Pages/WindowCustomizationsPage.xaml +++ b/samples/ControlCatalog/Pages/WindowCustomizationsPage.xaml @@ -5,11 +5,21 @@ xmlns:viewModels="using:ControlCatalog.ViewModels" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="ControlCatalog.Pages.WindowCustomizationsPage" - x:DataType="viewModels:MainWindowViewModel"> - - - - - + x:DataType="viewModels:MainWindowViewModel" + x:CompileBindings="True"> + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/ViewModels/MainWindowViewModel.cs b/samples/ControlCatalog/ViewModels/MainWindowViewModel.cs index 3628a9b8a7..8c6f0a2bd6 100644 --- a/samples/ControlCatalog/ViewModels/MainWindowViewModel.cs +++ b/samples/ControlCatalog/ViewModels/MainWindowViewModel.cs @@ -6,6 +6,7 @@ using Avalonia.Platform; using Avalonia.Reactive; using System; using System.ComponentModel.DataAnnotations; +using Avalonia; using MiniMvvm; namespace ControlCatalog.ViewModels @@ -20,6 +21,9 @@ namespace ControlCatalog.ViewModels private bool _systemTitleBarEnabled; private bool _preferSystemChromeEnabled; private double _titleBarHeight; + private bool _isSystemBarVisible; + private bool _displayEdgeToEdge; + private Thickness _safeAreaPadding; public MainWindowViewModel() { @@ -78,25 +82,25 @@ namespace ControlCatalog.ViewModels { get { return _chromeHints; } set { this.RaiseAndSetIfChanged(ref _chromeHints, value); } - } + } public bool ExtendClientAreaEnabled { get { return _extendClientAreaEnabled; } set { this.RaiseAndSetIfChanged(ref _extendClientAreaEnabled, value); } - } + } public bool SystemTitleBarEnabled { get { return _systemTitleBarEnabled; } set { this.RaiseAndSetIfChanged(ref _systemTitleBarEnabled, value); } - } + } public bool PreferSystemChromeEnabled { get { return _preferSystemChromeEnabled; } set { this.RaiseAndSetIfChanged(ref _preferSystemChromeEnabled, value); } - } + } public double TitleBarHeight { @@ -122,6 +126,24 @@ namespace ControlCatalog.ViewModels set { this.RaiseAndSetIfChanged(ref _isMenuItemChecked, value); } } + public bool IsSystemBarVisible + { + get { return _isSystemBarVisible; } + set { this.RaiseAndSetIfChanged(ref _isSystemBarVisible, value); } + } + + public bool DisplayEdgeToEdge + { + get { return _displayEdgeToEdge; } + set { this.RaiseAndSetIfChanged(ref _displayEdgeToEdge, value); } + } + + public Thickness SafeAreaPadding + { + get { return _safeAreaPadding; } + set { this.RaiseAndSetIfChanged(ref _safeAreaPadding, value); } + } + public MiniCommand AboutCommand { get; } public MiniCommand ExitCommand { get; } diff --git a/samples/IntegrationTestApp/ShowWindowTest.axaml b/samples/IntegrationTestApp/ShowWindowTest.axaml index 9e49298829..ed23797ad7 100644 --- a/samples/IntegrationTestApp/ShowWindowTest.axaml +++ b/samples/IntegrationTestApp/ShowWindowTest.axaml @@ -8,27 +8,27 @@ - - - + - + - + - + - + Normal Minimized Maximized @@ -36,7 +36,7 @@ - + diff --git a/samples/IntegrationTestApp/ShowWindowTest.axaml.cs b/samples/IntegrationTestApp/ShowWindowTest.axaml.cs index ffcd6fc32c..f0be34fdaa 100644 --- a/samples/IntegrationTestApp/ShowWindowTest.axaml.cs +++ b/samples/IntegrationTestApp/ShowWindowTest.axaml.cs @@ -35,11 +35,11 @@ namespace IntegrationTestApp { InitializeComponent(); DataContext = this; - PositionChanged += (s, e) => this.GetControl("Position").Text = $"{Position}"; + PositionChanged += (s, e) => this.GetControl("CurrentPosition").Text = $"{Position}"; if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { - _orderTextBox = this.GetControl("Order"); + _orderTextBox = this.GetControl("CurrentOrder"); _timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) }; _timer.Tick += TimerOnTick; _timer.Start(); @@ -55,13 +55,13 @@ namespace IntegrationTestApp { base.OnOpened(e); var scaling = PlatformImpl!.DesktopScaling; - this.GetControl("Position").Text = $"{Position}"; - this.GetControl("ScreenRect").Text = $"{Screens.ScreenFromVisual(this)?.WorkingArea}"; - this.GetControl("Scaling").Text = $"{scaling}"; + this.GetControl("CurrentPosition").Text = $"{Position}"; + this.GetControl("CurrentScreenRect").Text = $"{Screens.ScreenFromVisual(this)?.WorkingArea}"; + this.GetControl("CurrentScaling").Text = $"{scaling}"; if (Owner is not null) { - var ownerRect = this.GetControl("OwnerRect"); + var ownerRect = this.GetControl("CurrentOwnerRect"); var owner = (Window)Owner; ownerRect.Text = $"{owner.Position}, {PixelSize.FromSize(owner.FrameSize!.Value, scaling)}"; } diff --git a/src/Android/Avalonia.Android/AvaloniaMainActivity.cs b/src/Android/Avalonia.Android/AvaloniaMainActivity.cs index 247008c503..eb4b6bf6a0 100644 --- a/src/Android/Avalonia.Android/AvaloniaMainActivity.cs +++ b/src/Android/Avalonia.Android/AvaloniaMainActivity.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using Android.App; using Android.Content; using Android.Content.PM; @@ -32,6 +33,9 @@ namespace Avalonia.Android lifetime.View = View; } + Window?.ClearFlags(WindowManagerFlags.TranslucentStatus); + Window?.AddFlags(WindowManagerFlags.DrawsSystemBarBackgrounds); + base.OnCreate(savedInstanceState); SetContentView(View); @@ -55,6 +59,17 @@ namespace Avalonia.Android } } + protected override void OnResume() + { + base.OnResume(); + + // Android only respects LayoutInDisplayCutoutMode value if it has been set once before window becomes visible. + if (Build.VERSION.SdkInt >= BuildVersionCodes.P) + { + Window.Attributes.LayoutInDisplayCutoutMode = LayoutInDisplayCutoutMode.ShortEdges; + } + } + public event EventHandler BackRequested; public override void OnBackPressed() diff --git a/src/Android/Avalonia.Android/AvaloniaView.cs b/src/Android/Avalonia.Android/AvaloniaView.cs index 2a345a857c..27481a598e 100644 --- a/src/Android/Avalonia.Android/AvaloniaView.cs +++ b/src/Android/Avalonia.Android/AvaloniaView.cs @@ -8,6 +8,7 @@ using Avalonia.Android.Platform; using Avalonia.Android.Platform.SkiaPlatform; using Avalonia.Controls; using Avalonia.Controls.Embedding; +using Avalonia.Controls.Platform; using Avalonia.Platform; using Avalonia.Rendering; @@ -67,6 +68,11 @@ namespace Avalonia.Android } _root.Renderer.Start(); + + if (_view.TryGetFeature(out var insetsManager) == true) + { + (insetsManager as AndroidInsetsManager)?.ApplyStatusBarState(); + } } else { diff --git a/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs b/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs new file mode 100644 index 0000000000..35d1b06e6a --- /dev/null +++ b/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using Android.OS; +using Android.Views; +using AndroidX.AppCompat.App; +using AndroidX.Core.View; +using Avalonia.Android.Platform.SkiaPlatform; +using Avalonia.Controls.Platform; +using static Avalonia.Controls.Platform.IInsetsManager; + +namespace Avalonia.Android.Platform +{ + internal class AndroidInsetsManager : Java.Lang.Object, IInsetsManager, IOnApplyWindowInsetsListener, ViewTreeObserver.IOnGlobalLayoutListener + { + private readonly AvaloniaMainActivity _activity; + private readonly TopLevelImpl _topLevel; + private readonly InsetsAnimationCallback _callback; + private bool _displayEdgeToEdge; + private bool _usesLegacyLayouts; + private bool? _systemUiVisibility; + private SystemBarTheme? _statusBarTheme; + private bool? _isDefaultSystemBarLightTheme; + + public event EventHandler SafeAreaChanged; + + public bool DisplayEdgeToEdge + { + get => _displayEdgeToEdge; + set + { + _displayEdgeToEdge = value; + + if(Build.VERSION.SdkInt >= BuildVersionCodes.P) + { + _activity.Window.Attributes.LayoutInDisplayCutoutMode = value ? LayoutInDisplayCutoutMode.ShortEdges : LayoutInDisplayCutoutMode.Default; + } + + WindowCompat.SetDecorFitsSystemWindows(_activity.Window, !value); + } + } + + public AndroidInsetsManager(AvaloniaMainActivity activity, TopLevelImpl topLevel) + { + _activity = activity; + _topLevel = topLevel; + _callback = new InsetsAnimationCallback(WindowInsetsAnimationCompat.Callback.DispatchModeStop); + + _callback.InsetsManager = this; + + ViewCompat.SetOnApplyWindowInsetsListener(_activity.Window.DecorView, this); + + ViewCompat.SetWindowInsetsAnimationCallback(_activity.Window.DecorView, _callback); + + if(Build.VERSION.SdkInt < BuildVersionCodes.R) + { + _usesLegacyLayouts = true; + _activity.Window.DecorView.ViewTreeObserver.AddOnGlobalLayoutListener(this); + } + + DisplayEdgeToEdge = false; + } + + public Thickness SafeAreaPadding + { + get + { + var insets = ViewCompat.GetRootWindowInsets(_activity.Window.DecorView); + + if (insets != null) + { + var renderScaling = _topLevel.RenderScaling; + + var inset = insets.GetInsets( + (DisplayEdgeToEdge ? + WindowInsetsCompat.Type.StatusBars() | WindowInsetsCompat.Type.NavigationBars() | + WindowInsetsCompat.Type.DisplayCutout() : + 0) | WindowInsetsCompat.Type.Ime()); + var navBarInset = insets.GetInsets(WindowInsetsCompat.Type.NavigationBars()); + var imeInset = insets.GetInsets(WindowInsetsCompat.Type.Ime()); + + return new Thickness(inset.Left / renderScaling, + inset.Top / renderScaling, + inset.Right / renderScaling, + (imeInset.Bottom > 0 && ((_usesLegacyLayouts && !DisplayEdgeToEdge) || !_usesLegacyLayouts) ? + imeInset.Bottom - navBarInset.Bottom : + inset.Bottom) / renderScaling); + } + + return default; + } + } + + public WindowInsetsCompat OnApplyWindowInsets(View v, WindowInsetsCompat insets) + { + NotifySafeAreaChanged(SafeAreaPadding); + return insets; + } + + private void NotifySafeAreaChanged(Thickness safeAreaPadding) + { + SafeAreaChanged?.Invoke(this, new SafeAreaChangedArgs(safeAreaPadding)); + } + + public void OnGlobalLayout() + { + NotifySafeAreaChanged(SafeAreaPadding); + } + + public SystemBarTheme? SystemBarTheme + { + get + { + try + { + var compat = new WindowInsetsControllerCompat(_activity.Window, _topLevel.View); + + return compat.AppearanceLightStatusBars ? Controls.Platform.SystemBarTheme.Light : Controls.Platform.SystemBarTheme.Dark; + } + catch (Exception _) + { + return Controls.Platform.SystemBarTheme.Light; + } + } + set + { + _statusBarTheme = value; + + var isDefault = _statusBarTheme == null; + + if (!_topLevel.View.IsShown) + { + return; + } + + var compat = new WindowInsetsControllerCompat(_activity.Window, _topLevel.View); + + if (_isDefaultSystemBarLightTheme == null) + { + _isDefaultSystemBarLightTheme = compat.AppearanceLightStatusBars; + } + + if (value == null && _isDefaultSystemBarLightTheme != null) + { + value = (bool)_isDefaultSystemBarLightTheme ? Controls.Platform.SystemBarTheme.Light : Controls.Platform.SystemBarTheme.Dark; + } + + compat.AppearanceLightStatusBars = value == Controls.Platform.SystemBarTheme.Light; + compat.AppearanceLightNavigationBars = value == Controls.Platform.SystemBarTheme.Light; + + AppCompatDelegate.DefaultNightMode = isDefault ? AppCompatDelegate.ModeNightFollowSystem : compat.AppearanceLightStatusBars ? AppCompatDelegate.ModeNightNo : AppCompatDelegate.ModeNightYes; + } + } + + public bool? IsSystemBarVisible + { + get + { + if(_activity.Window == null) + { + return true; + } + var compat = ViewCompat.GetRootWindowInsets(_activity.Window.DecorView); + + return compat?.IsVisible(WindowInsetsCompat.Type.StatusBars() | WindowInsetsCompat.Type.NavigationBars()); + } + set + { + _systemUiVisibility = value; + + if (!_topLevel.View.IsShown) + { + return; + } + + var compat = WindowCompat.GetInsetsController(_activity.Window, _topLevel.View); + + if (value == null || value.Value) + { + compat?.Show(WindowInsetsCompat.Type.StatusBars() | WindowInsetsCompat.Type.NavigationBars()); + } + else + { + compat?.Hide(WindowInsetsCompat.Type.StatusBars() | WindowInsetsCompat.Type.NavigationBars()); + + if (compat != null) + { + compat.SystemBarsBehavior = WindowInsetsControllerCompat.BehaviorShowTransientBarsBySwipe; + } + } + } + } + + internal void ApplyStatusBarState() + { + IsSystemBarVisible = _systemUiVisibility; + SystemBarTheme = _statusBarTheme; + } + + private class InsetsAnimationCallback : WindowInsetsAnimationCompat.Callback + { + public InsetsAnimationCallback(int dispatchMode) : base(dispatchMode) + { + } + + public AndroidInsetsManager InsetsManager { get; set; } + + public override WindowInsetsCompat OnProgress(WindowInsetsCompat insets, IList runningAnimations) + { + foreach (var anim in runningAnimations) + { + if ((anim.TypeMask & WindowInsetsCompat.Type.Ime()) != 0) + { + var renderScaling = InsetsManager._topLevel.RenderScaling; + + var inset = insets.GetInsets((InsetsManager.DisplayEdgeToEdge ? WindowInsetsCompat.Type.StatusBars() | WindowInsetsCompat.Type.NavigationBars() | WindowInsetsCompat.Type.DisplayCutout() : 0) | WindowInsetsCompat.Type.Ime()); + var navBarInset = insets.GetInsets(WindowInsetsCompat.Type.NavigationBars()); + var imeInset = insets.GetInsets(WindowInsetsCompat.Type.Ime()); + + + var bottomPadding = (imeInset.Bottom > 0 && !InsetsManager.DisplayEdgeToEdge ? imeInset.Bottom - navBarInset.Bottom : inset.Bottom); + bottomPadding = (int)(bottomPadding * anim.InterpolatedFraction); + + var padding = new Thickness(inset.Left / renderScaling, + inset.Top / renderScaling, + inset.Right / renderScaling, + bottomPadding / renderScaling); + InsetsManager?.NotifySafeAreaChanged(padding); + break; + } + } + return insets; + } + } + } +} diff --git a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs index e511ed9a8b..b8d80a50ff 100644 --- a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs +++ b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs @@ -3,9 +3,7 @@ using System.Collections.Generic; using Android.App; using Android.Content; using Android.Graphics; -using Android.OS; using Android.Runtime; -using Android.Text; using Android.Views; using Android.Views.InputMethods; using Avalonia.Android.Platform.Specific; @@ -24,11 +22,13 @@ using Avalonia.Platform.Storage; using Avalonia.Rendering; using Avalonia.Rendering.Composition; using Java.Lang; +using Java.Util; using Math = System.Math; using AndroidRect = Android.Graphics.Rect; using Window = Android.Views.Window; using Android.Graphics.Drawables; -using Java.Util; +using Android.OS; +using Android.Text; namespace Avalonia.Android.Platform.SkiaPlatform { @@ -43,6 +43,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform private readonly INativeControlHostImpl _nativeControlHost; private readonly IStorageProvider _storageProvider; private readonly ISystemNavigationManagerImpl _systemNavigationManager; + private readonly AndroidInsetsManager _insetsManager; private ViewImpl _view; public TopLevelImpl(AvaloniaView avaloniaView, bool placeOnTop = false) @@ -59,6 +60,11 @@ namespace Avalonia.Android.Platform.SkiaPlatform MaxClientSize = new PixelSize(_view.Resources.DisplayMetrics.WidthPixels, _view.Resources.DisplayMetrics.HeightPixels).ToSize(RenderScaling); + if (avaloniaView.Context is AvaloniaMainActivity mainActivity) + { + _insetsManager = new AndroidInsetsManager(mainActivity, this); + } + _nativeControlHost = new AndroidNativeControlHostImpl(avaloniaView); _storageProvider = new AndroidStorageProvider((Activity)avaloniaView.Context); @@ -70,21 +76,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform public IInputRoot InputRoot { get; private set; } - public virtual Size ClientSize - { - get - { - AndroidRect rect = new AndroidRect(); - AndroidRect intersection = new AndroidRect(); - - _view.GetWindowVisibleDisplayFrame(intersection); - _view.GetGlobalVisibleRect(rect); - - intersection.Intersect(rect); - - return new PixelSize(intersection.Right - intersection.Left, intersection.Bottom - intersection.Top).ToSize(RenderScaling); - } - } + public virtual Size ClientSize => _view.Size.ToSize(RenderScaling); public Size? FrameSize => null; @@ -285,7 +277,15 @@ namespace Avalonia.Android.Platform.SkiaPlatform public void SetFrameThemeVariant(PlatformThemeVariant themeVariant) { - // TODO adjust status bar depending on full screen mode. + if(_insetsManager != null) + { + _insetsManager.SystemBarTheme = themeVariant switch + { + PlatformThemeVariant.Light => SystemBarTheme.Light, + PlatformThemeVariant.Dark => SystemBarTheme.Dark, + _ => null, + }; + } } public AcrylicPlatformCompensationLevels AcrylicCompensationLevels => new AcrylicPlatformCompensationLevels(1, 1, 1); @@ -403,6 +403,11 @@ namespace Avalonia.Android.Platform.SkiaPlatform return _nativeControlHost; } + if (featureType == typeof(IInsetsManager)) + { + return _insetsManager; + } + return null; } } diff --git a/src/Avalonia.Controls/Platform/IInsetsManager.cs b/src/Avalonia.Controls/Platform/IInsetsManager.cs new file mode 100644 index 0000000000..6288142805 --- /dev/null +++ b/src/Avalonia.Controls/Platform/IInsetsManager.cs @@ -0,0 +1,55 @@ +using System; +using Avalonia.Metadata; + +#nullable enable +namespace Avalonia.Controls.Platform +{ + [Unstable] + [NotClientImplementable] + public interface IInsetsManager + { + /// + /// Gets or sets whether the system bars are visible. + /// + bool? IsSystemBarVisible { get; set; } + + /// + /// Gets or sets whether the window draws edge to edge. behind any visibile system bars. + /// + bool DisplayEdgeToEdge { get; set; } + + /// + /// Gets the current safe area padding. + /// + Thickness SafeAreaPadding { get; } + + /// + /// Occurs when safe area for the current window changes. + /// + event EventHandler? SafeAreaChanged; + } + + public class SafeAreaChangedArgs : EventArgs + { + public SafeAreaChangedArgs(Thickness safeArePadding) + { + SafeAreaPadding = safeArePadding; + } + + /// + public Thickness SafeAreaPadding { get; } + } + + public enum SystemBarTheme + { + /// + /// Light system bar theme, with light background and a dark foreground + /// + Light, + + /// + /// Bark system bar theme, with dark background and a light foreground + /// + Dark + } +} diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs index fdcb8cc537..5c2a8c8a13 100644 --- a/src/Avalonia.Controls/TopLevel.cs +++ b/src/Avalonia.Controls/TopLevel.cs @@ -15,6 +15,7 @@ using Avalonia.LogicalTree; using Avalonia.Media; using Avalonia.Platform; using Avalonia.Platform.Storage; +using Avalonia.Reactive; using Avalonia.Rendering; using Avalonia.Styling; using Avalonia.Utilities; @@ -391,7 +392,9 @@ namespace Avalonia.Controls ??= AvaloniaLocator.Current.GetService()?.CreateProvider(this) ?? PlatformImpl?.TryGetFeature() ?? throw new InvalidOperationException("StorageProvider platform implementation is not available."); - + + public IInsetsManager? InsetsManager => PlatformImpl?.TryGetFeature(); + /// Point IRenderRoot.PointToClient(PixelPoint p) { diff --git a/src/Browser/Avalonia.Browser/BrowserInsetsManager.cs b/src/Browser/Avalonia.Browser/BrowserInsetsManager.cs new file mode 100644 index 0000000000..30f80ba27c --- /dev/null +++ b/src/Browser/Avalonia.Browser/BrowserInsetsManager.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Avalonia.Browser.Interop; +using Avalonia.Controls.Platform; +using static Avalonia.Controls.Platform.IInsetsManager; + +namespace Avalonia.Browser +{ + internal class BrowserInsetsManager : IInsetsManager + { + public bool? IsSystemBarVisible + { + get + { + return DomHelper.IsFullscreen(); + } + set + { + DomHelper.SetFullscreen(!value ?? false); + } + } + + public bool DisplayEdgeToEdge { get; set; } + + public event EventHandler? SafeAreaChanged; + + public Thickness SafeAreaPadding + { + get + { + var padding = DomHelper.GetSafeAreaPadding(); + + return new Thickness(padding[0], padding[1], padding[2], padding[3]); + } + } + + public void NotifySafeAreaPaddingChanged() + { + SafeAreaChanged?.Invoke(this, new SafeAreaChangedArgs(SafeAreaPadding)); + } + } +} diff --git a/src/Browser/Avalonia.Browser/BrowserTopLevelImpl.cs b/src/Browser/Avalonia.Browser/BrowserTopLevelImpl.cs index 1bf4636f61..7c5418dbeb 100644 --- a/src/Browser/Avalonia.Browser/BrowserTopLevelImpl.cs +++ b/src/Browser/Avalonia.Browser/BrowserTopLevelImpl.cs @@ -31,6 +31,7 @@ namespace Avalonia.Browser private readonly INativeControlHostImpl _nativeControlHost; private readonly IStorageProvider _storageProvider; private readonly ISystemNavigationManagerImpl _systemNavigationManager; + private readonly IInsetsManager? _insetsManager; public BrowserTopLevelImpl(AvaloniaView avaloniaView) { @@ -40,9 +41,12 @@ namespace Avalonia.Browser AcrylicCompensationLevels = new AcrylicPlatformCompensationLevels(1, 1, 1); _touchDevice = new TouchDevice(); _penDevice = new PenDevice(); + + _insetsManager = new BrowserInsetsManager(); _nativeControlHost = _avaloniaView.GetNativeControlHostImpl(); _storageProvider = new BrowserStorageProvider(); _systemNavigationManager = new BrowserSystemNavigationManagerImpl(); + } public ulong Timestamp => (ulong)_sw.ElapsedMilliseconds; @@ -69,6 +73,8 @@ namespace Avalonia.Browser } Resized?.Invoke(newSize, PlatformResizeReason.User); + + (_insetsManager as BrowserInsetsManager)?.NotifySafeAreaPaddingChanged(); } } @@ -271,6 +277,11 @@ namespace Avalonia.Browser return _nativeControlHost; } + if (featureType == typeof(IInsetsManager)) + { + return _insetsManager; + } + return null; } } diff --git a/src/Browser/Avalonia.Browser/Interop/DomHelper.cs b/src/Browser/Avalonia.Browser/Interop/DomHelper.cs index d1133b8916..c1811bb117 100644 --- a/src/Browser/Avalonia.Browser/Interop/DomHelper.cs +++ b/src/Browser/Avalonia.Browser/Interop/DomHelper.cs @@ -11,6 +11,15 @@ internal static partial class DomHelper [JSImport("AvaloniaDOM.createAvaloniaHost", AvaloniaModule.MainModuleName)] public static partial JSObject CreateAvaloniaHost(JSObject element); + [JSImport("AvaloniaDOM.isFullscreen", AvaloniaModule.MainModuleName)] + public static partial bool IsFullscreen(); + + [JSImport("AvaloniaDOM.setFullscreen", AvaloniaModule.MainModuleName)] + public static partial JSObject SetFullscreen(bool isFullscreen); + + [JSImport("AvaloniaDOM.getSafeAreaPadding", AvaloniaModule.MainModuleName)] + public static partial byte[] GetSafeAreaPadding(); + [JSImport("AvaloniaDOM.addClass", AvaloniaModule.MainModuleName)] public static partial void AddCssClass(JSObject element, string className); diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/dom.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/dom.ts index b99f8e7907..d9790f69e9 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/dom.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/dom.ts @@ -84,4 +84,26 @@ export class AvaloniaDOM { inputElement }; } + + public static isFullscreen(): boolean { + return document.fullscreenElement != null; + } + + public static async setFullscreen(isFullscreen: boolean) { + if (isFullscreen) { + const doc = document.documentElement; + await doc.requestFullscreen(); + } else { + await document.exitFullscreen(); + } + } + + public static getSafeAreaPadding(): number[] { + const top = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--sat")); + const bottom = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--sab")); + const left = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--sal")); + const right = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--sar")); + + return [left, top, bottom, right]; + } } diff --git a/src/iOS/Avalonia.iOS/AvaloniaAppDelegate.cs b/src/iOS/Avalonia.iOS/AvaloniaAppDelegate.cs index b605d82541..18ccbad692 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaAppDelegate.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaAppDelegate.cs @@ -40,10 +40,12 @@ namespace Avalonia.iOS var view = new AvaloniaView(); lifetime.View = view; - Window.RootViewController = new UIViewController + var controller = new DefaultAvaloniaViewController { View = view }; + Window.RootViewController = controller; + view.InitWithController(controller); }); builder.SetupWithLifetime(lifetime); diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.cs b/src/iOS/Avalonia.iOS/AvaloniaView.cs index 2d6b93f818..09721ad181 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.cs @@ -16,6 +16,7 @@ using Foundation; using ObjCRuntime; using OpenGLES; using UIKit; +using IInsetsManager = Avalonia.Controls.Platform.IInsetsManager; namespace Avalonia.iOS { @@ -26,6 +27,7 @@ namespace Avalonia.iOS private EmbeddableControlRoot _topLevel; private TouchHandler _touches; private ITextInputMethodClient _client; + private IAvaloniaViewController _controller; public AvaloniaView() { @@ -48,10 +50,13 @@ namespace Avalonia.iOS MultipleTouchEnabled = true; } + /// public override bool CanBecomeFirstResponder => true; + /// public override bool CanResignFirstResponder => true; + /// public override void TraitCollectionDidChange(UITraitCollection previousTraitCollection) { base.TraitCollectionDidChange(previousTraitCollection); @@ -60,6 +65,7 @@ namespace Avalonia.iOS settings?.TraitCollectionDidChange(); } + /// public override void TintColorDidChange() { base.TintColorDidChange(); @@ -68,18 +74,31 @@ namespace Avalonia.iOS settings?.TraitCollectionDidChange(); } + public void InitWithController(TController controller) + where TController : UIViewController, IAvaloniaViewController + { + _controller = controller; + _topLevelImpl._insetsManager.InitWithController(controller); + } + internal class TopLevelImpl : ITopLevelImpl { private readonly AvaloniaView _view; private readonly INativeControlHostImpl _nativeControlHost; private readonly IStorageProvider _storageProvider; + internal readonly InsetsManager _insetsManager; public AvaloniaView View => _view; public TopLevelImpl(AvaloniaView view) { _view = view; - _nativeControlHost = new NativeControlHostImpl(_view); + _nativeControlHost = new NativeControlHostImpl(view); _storageProvider = new IOSStorageProvider(view); + _insetsManager = new InsetsManager(view); + _insetsManager.DisplayEdgeToEdgeChanged += (sender, b) => + { + view._topLevel.Padding = b ? default : _insetsManager.SafeAreaPadding; + }; } public void Dispose() @@ -141,17 +160,14 @@ namespace Avalonia.iOS public void SetFrameThemeVariant(PlatformThemeVariant themeVariant) { // TODO adjust status bar depending on full screen mode. - if (OperatingSystem.IsIOSVersionAtLeast(13)) + if (OperatingSystem.IsIOSVersionAtLeast(13) && _view._controller is not null) { - var uiStatusBarStyle = themeVariant switch + _view._controller.PreferredStatusBarStyle = themeVariant switch { PlatformThemeVariant.Light => UIStatusBarStyle.DarkContent, PlatformThemeVariant.Dark => UIStatusBarStyle.LightContent, - _ => throw new ArgumentOutOfRangeException(nameof(themeVariant), themeVariant, null) + _ => UIStatusBarStyle.Default }; - - // Consider using UIViewController.PreferredStatusBarStyle in the future. - UIApplication.SharedApplication.SetStatusBarStyle(uiStatusBarStyle, true); } } @@ -175,6 +191,11 @@ namespace Avalonia.iOS return _nativeControlHost; } + if (featureType == typeof(IInsetsManager)) + { + return _insetsManager; + } + return null; } } diff --git a/src/iOS/Avalonia.iOS/InsetsManager.cs b/src/iOS/Avalonia.iOS/InsetsManager.cs new file mode 100644 index 0000000000..62e560ddf9 --- /dev/null +++ b/src/iOS/Avalonia.iOS/InsetsManager.cs @@ -0,0 +1,83 @@ +using System; +using Avalonia.Controls.Platform; +using UIKit; + +namespace Avalonia.iOS; +#nullable enable + +internal class InsetsManager : IInsetsManager +{ + private readonly AvaloniaView _view; + private IAvaloniaViewController? _controller; + private bool _displayEdgeToEdge; + + public InsetsManager(AvaloniaView view) + { + _view = view; + } + + internal void InitWithController(IAvaloniaViewController controller) + { + _controller = controller; + if (_controller is not null) + { + _controller.SafeAreaPaddingChanged += (_, _) => + { + SafeAreaChanged?.Invoke(this, new SafeAreaChangedArgs(SafeAreaPadding)); + DisplayEdgeToEdgeChanged?.Invoke(this, _displayEdgeToEdge); + }; + } + } + + public SystemBarTheme? SystemBarTheme + { + get => _controller?.PreferredStatusBarStyle switch + { + UIStatusBarStyle.LightContent => Controls.Platform.SystemBarTheme.Dark, + UIStatusBarStyle.DarkContent => Controls.Platform.SystemBarTheme.Light, + _ => null + }; + set + { + if (_controller != null) + { + _controller.PreferredStatusBarStyle = value switch + { + Controls.Platform.SystemBarTheme.Light => UIStatusBarStyle.DarkContent, + Controls.Platform.SystemBarTheme.Dark => UIStatusBarStyle.LightContent, + null => UIStatusBarStyle.Default, + _ => throw new ArgumentOutOfRangeException(nameof(value), value, null) + }; + } + } + } + + public bool? IsSystemBarVisible + { + get => _controller?.PrefersStatusBarHidden == false; + set + { + if (_controller is not null) + { + _controller.PrefersStatusBarHidden = value == false; + } + } + } + public event EventHandler? SafeAreaChanged; + public event EventHandler? DisplayEdgeToEdgeChanged; + + public bool DisplayEdgeToEdge + { + get => _displayEdgeToEdge; + set + { + if (_displayEdgeToEdge != value) + { + _displayEdgeToEdge = value; + DisplayEdgeToEdgeChanged?.Invoke(this, value); + } + } + } + + public Thickness SafeAreaPadding => _controller?.SafeAreaPadding ?? default; +} diff --git a/src/iOS/Avalonia.iOS/ViewController.cs b/src/iOS/Avalonia.iOS/ViewController.cs new file mode 100644 index 0000000000..42a0949a9c --- /dev/null +++ b/src/iOS/Avalonia.iOS/ViewController.cs @@ -0,0 +1,74 @@ +using System; +using Avalonia.Metadata; +using UIKit; + +namespace Avalonia.iOS; + +[Unstable] +public interface IAvaloniaViewController +{ + UIStatusBarStyle PreferredStatusBarStyle { get; set; } + bool PrefersStatusBarHidden { get; set; } + Thickness SafeAreaPadding { get; } + event EventHandler SafeAreaPaddingChanged; +} + +/// +public class DefaultAvaloniaViewController : UIViewController, IAvaloniaViewController +{ + private UIStatusBarStyle? _preferredStatusBarStyle; + private bool? _prefersStatusBarHidden; + + /// + public override void ViewDidLayoutSubviews() + { + base.ViewDidLayoutSubviews(); + var size = View?.Frame.Size ?? default; + var frame = View?.SafeAreaLayoutGuide.LayoutFrame ?? default; + var safeArea = new Thickness(frame.Left, frame.Top, size.Width - frame.Right, size.Height - frame.Bottom); + if (SafeAreaPadding != safeArea) + { + SafeAreaPadding = safeArea; + SafeAreaPaddingChanged?.Invoke(this, EventArgs.Empty); + } + } + + /// + public override bool PrefersStatusBarHidden() + { + return _prefersStatusBarHidden ??= base.PrefersStatusBarHidden(); + } + + /// + public override UIStatusBarStyle PreferredStatusBarStyle() + { + // don't set _preferredStatusBarStyle value if it's null, so we can keep "default" there instead of actual app style. + return _preferredStatusBarStyle ?? base.PreferredStatusBarStyle(); + } + + UIStatusBarStyle IAvaloniaViewController.PreferredStatusBarStyle + { + get => _preferredStatusBarStyle ?? UIStatusBarStyle.Default; + set + { + _preferredStatusBarStyle = value; + SetNeedsStatusBarAppearanceUpdate(); + } + } + + bool IAvaloniaViewController.PrefersStatusBarHidden + { + get => _prefersStatusBarHidden ?? false; // false is default on ios/ipados + set + { + _prefersStatusBarHidden = value; + SetNeedsStatusBarAppearanceUpdate(); + } + } + + /// + public Thickness SafeAreaPadding { get; private set; } + + /// + public event EventHandler SafeAreaPaddingChanged; +} diff --git a/tests/Avalonia.IntegrationTests.Appium/WindowTests.cs b/tests/Avalonia.IntegrationTests.Appium/WindowTests.cs index fb3283fbe7..6b70edbdf4 100644 --- a/tests/Avalonia.IntegrationTests.Appium/WindowTests.cs +++ b/tests/Avalonia.IntegrationTests.Appium/WindowTests.cs @@ -93,7 +93,7 @@ namespace Avalonia.IntegrationTests.Appium { try { - _session.FindElementByAccessibilityId("WindowState").SendClick(); + _session.FindElementByAccessibilityId("CurrentWindowState").SendClick(); _session.FindElementByAccessibilityId("WindowStateNormal").SendClick(); // Wait for animations to run. @@ -113,7 +113,7 @@ namespace Avalonia.IntegrationTests.Appium { using (OpenWindow(new Size(400, 400), ShowWindowMode.NonOwned, WindowStartupLocation.Manual)) { - var windowState = _session.FindElementByAccessibilityId("WindowState"); + var windowState = _session.FindElementByAccessibilityId("CurrentWindowState"); Assert.Equal("Normal", windowState.GetComboBoxValue()); @@ -170,7 +170,7 @@ namespace Avalonia.IntegrationTests.Appium public void ShowMode(ShowWindowMode mode) { using var window = OpenWindow(null, mode, WindowStartupLocation.Manual); - var windowState = _session.FindElementByAccessibilityId("WindowState"); + var windowState = _session.FindElementByAccessibilityId("CurrentWindowState"); var original = GetWindowInfo(); Assert.Equal("Normal", windowState.GetComboBoxValue()); @@ -373,7 +373,7 @@ namespace Avalonia.IntegrationTests.Appium { PixelRect? ReadOwnerRect() { - var text = _session.FindElementByAccessibilityId("OwnerRect").Text; + var text = _session.FindElementByAccessibilityId("CurrentOwnerRect").Text; return !string.IsNullOrWhiteSpace(text) ? PixelRect.Parse(text) : null; } @@ -384,13 +384,13 @@ namespace Avalonia.IntegrationTests.Appium try { return new( - Size.Parse(_session.FindElementByAccessibilityId("ClientSize").Text), - Size.Parse(_session.FindElementByAccessibilityId("FrameSize").Text), - PixelPoint.Parse(_session.FindElementByAccessibilityId("Position").Text), + Size.Parse(_session.FindElementByAccessibilityId("CurrentClientSize").Text), + Size.Parse(_session.FindElementByAccessibilityId("CurrentFrameSize").Text), + PixelPoint.Parse(_session.FindElementByAccessibilityId("CurrentPosition").Text), ReadOwnerRect(), - PixelRect.Parse(_session.FindElementByAccessibilityId("ScreenRect").Text), - double.Parse(_session.FindElementByAccessibilityId("Scaling").Text), - Enum.Parse(_session.FindElementByAccessibilityId("WindowState").Text)); + PixelRect.Parse(_session.FindElementByAccessibilityId("CurrentScreenRect").Text), + double.Parse(_session.FindElementByAccessibilityId("CurrentScaling").Text), + Enum.Parse(_session.FindElementByAccessibilityId("CurrentWindowState").Text)); } catch (OpenQA.Selenium.NoSuchElementException) when (retry++ < 3) { diff --git a/tests/Avalonia.IntegrationTests.Appium/WindowTests_MacOS.cs b/tests/Avalonia.IntegrationTests.Appium/WindowTests_MacOS.cs index 90fdc2511f..039d30bbc1 100644 --- a/tests/Avalonia.IntegrationTests.Appium/WindowTests_MacOS.cs +++ b/tests/Avalonia.IntegrationTests.Appium/WindowTests_MacOS.cs @@ -393,7 +393,7 @@ namespace Avalonia.IntegrationTests.Appium private int GetWindowOrder(string identifier) { var window = GetWindow(identifier); - var order = window.FindElementByXPath("//*[@identifier='Order']"); + var order = window.FindElementByXPath("//*[@identifier='CurrentOrder']"); return int.Parse(order.Text); }