diff --git a/samples/SafeAreaDemo/ViewModels/MainViewModel.cs b/samples/SafeAreaDemo/ViewModels/MainViewModel.cs index 3d826d8a9c..c52536d157 100644 --- a/samples/SafeAreaDemo/ViewModels/MainViewModel.cs +++ b/samples/SafeAreaDemo/ViewModels/MainViewModel.cs @@ -1,4 +1,6 @@ -using Avalonia; +using System; +using Avalonia; +using Avalonia.Animation.Easings; using Avalonia.Controls; using Avalonia.Controls.Platform; using MiniMvvm; @@ -12,7 +14,26 @@ namespace SafeAreaDemo.ViewModels private IInsetsManager? _insetsManager; private bool _hideSystemBars; private bool _autoSafeAreaPadding; + private IInputPane? _inputPane; + public InputPaneState InputPaneState + { + get + { + return _inputPane?.State ?? InputPaneState.Closed; + } + } + + public IEasing? InputPaneEasing { get; private set; } + public TimeSpan? InputPaneDuration { get; private set; } + + public Thickness InputPaneMarkerMargin => InputPaneState == InputPaneState.Open + ? new Thickness(0, 0, 0, Math.Max(0, CanvasSize.Height - InputPaneRect.Top)) + : default; + public Rect InputPaneRect => _inputPane?.OccludedRect ?? default; + + public Rect CanvasSize { get; set; } + public Thickness SafeAreaPadding { get @@ -90,12 +111,16 @@ namespace SafeAreaDemo.ViewModels } } - internal void Initialize(Control mainView, IInsetsManager? InsetsManager) + internal void Initialize(Control mainView, IInsetsManager? InsetsManager, IInputPane? inputPane) { if (_insetsManager != null) { _insetsManager.SafeAreaChanged -= InsetsManager_SafeAreaChanged; } + if (_inputPane != null) + { + _inputPane.StateChanged -= InputPaneOnStateChanged; + } _autoSafeAreaPadding = mainView.GetValue(TopLevel.AutoSafeAreaPaddingProperty); _insetsManager = InsetsManager; @@ -107,6 +132,20 @@ namespace SafeAreaDemo.ViewModels _displayEdgeToEdge = _insetsManager.DisplayEdgeToEdge; _hideSystemBars = !(_insetsManager.IsSystemBarVisible ?? false); } + + _inputPane = inputPane; + if (_inputPane != null) + { + _inputPane.StateChanged += InputPaneOnStateChanged; + } + RaiseKeyboardChanged(); + } + + private void InputPaneOnStateChanged(object? sender, InputPaneStateEventArgs e) + { + InputPaneDuration = e.AnimationDuration; + InputPaneEasing = e.Easing ?? new LinearEasing(); + RaiseKeyboardChanged(); } private void InsetsManager_SafeAreaChanged(object? sender, SafeAreaChangedArgs e) @@ -118,6 +157,16 @@ namespace SafeAreaDemo.ViewModels { this.RaisePropertyChanged(nameof(SafeAreaPadding)); this.RaisePropertyChanged(nameof(ViewPadding)); + this.RaisePropertyChanged(nameof(InputPaneMarkerMargin)); + } + + private void RaiseKeyboardChanged() + { + this.RaisePropertyChanged(nameof(InputPaneState)); + this.RaisePropertyChanged(nameof(InputPaneRect)); + this.RaisePropertyChanged(nameof(InputPaneEasing)); + this.RaisePropertyChanged(nameof(InputPaneDuration)); + this.RaisePropertyChanged(nameof(InputPaneMarkerMargin)); } } } diff --git a/samples/SafeAreaDemo/Views/MainView.xaml b/samples/SafeAreaDemo/Views/MainView.xaml index 966b0a02ea..85163e7dad 100644 --- a/samples/SafeAreaDemo/Views/MainView.xaml +++ b/samples/SafeAreaDemo/Views/MainView.xaml @@ -11,10 +11,11 @@ Background="#ccc" TopLevel.AutoSafeAreaPadding="{Binding AutoSafeAreaPadding, Mode=TwoWay}"> - + VerticalAlignment="Stretch" + Bounds="{Binding CanvasSize, Mode=OneWayToSource}"> + + diff --git a/samples/SafeAreaDemo/Views/MainView.xaml.cs b/samples/SafeAreaDemo/Views/MainView.xaml.cs index bacb721d27..e4cd53c1a4 100644 --- a/samples/SafeAreaDemo/Views/MainView.xaml.cs +++ b/samples/SafeAreaDemo/Views/MainView.xaml.cs @@ -18,8 +18,9 @@ namespace SafeAreaDemo.Views base.OnLoaded(e); var insetsManager = TopLevel.GetTopLevel(this)?.InsetsManager; + var inputPane = TopLevel.GetTopLevel(this)?.InputPane; var viewModel = new MainViewModel(); - viewModel.Initialize(this, insetsManager); + viewModel.Initialize(this, insetsManager, inputPane); DataContext = viewModel; } } diff --git a/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs b/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs index 6d8ae873a2..c38124e6da 100644 --- a/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs +++ b/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs @@ -2,53 +2,68 @@ using System.Collections.Generic; using Android.OS; using Android.Views; +using Android.Views.Animations; using AndroidX.Core.View; using Avalonia.Android.Platform.SkiaPlatform; +using Avalonia.Animation.Easings; using Avalonia.Controls.Platform; using Avalonia.Media; +using AndroidWindow = Android.Views.Window; namespace Avalonia.Android.Platform { - internal class AndroidInsetsManager : Java.Lang.Object, IInsetsManager, IOnApplyWindowInsetsListener, ViewTreeObserver.IOnGlobalLayoutListener + internal sealed class AndroidInsetsManager : WindowInsetsAnimationCompat.Callback, IInsetsManager, IOnApplyWindowInsetsListener, ViewTreeObserver.IOnGlobalLayoutListener, IInputPane { 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; private Color? _systemBarColor; + private InputPaneState _state; + private Rect _previousRect; + private readonly bool _usesLegacyLayouts; + private AndroidWindow Window => _activity.Window ?? throw new InvalidOperationException("Activity.Window must be set."); + public event EventHandler SafeAreaChanged; + public event EventHandler StateChanged; + + public InputPaneState State + { + get => _state; set + { + var oldState = _state; + _state = value; + + if (oldState != value && Build.VERSION.SdkInt <= BuildVersionCodes.Q) + { + var currentRect = OccludedRect; + StateChanged?.Invoke(this, new InputPaneStateEventArgs(value, _previousRect, currentRect, TimeSpan.Zero, null)); + _previousRect = currentRect; + } + } + } public bool DisplayEdgeToEdge { - get => _displayEdgeToEdge; + get => _displayEdgeToEdge; set { _displayEdgeToEdge = value; - var window = _activity.Window; - - if (OperatingSystem.IsAndroidVersionAtLeast(28) && window?.Attributes is { } attributes) + if (OperatingSystem.IsAndroidVersionAtLeast(28) && Window.Attributes is { } attributes) { attributes.LayoutInDisplayCutoutMode = value ? LayoutInDisplayCutoutMode.ShortEdges : LayoutInDisplayCutoutMode.Default; } - if (window is not null) - { - WindowCompat.SetDecorFitsSystemWindows(_activity.Window, !value); - } + WindowCompat.SetDecorFitsSystemWindows(Window, !value); - if(value) + if (value) { - if (window is not null) - { - window.AddFlags(WindowManagerFlags.TranslucentStatus); - window.AddFlags(WindowManagerFlags.TranslucentNavigation); - } + Window.AddFlags(WindowManagerFlags.TranslucentStatus); + Window.AddFlags(WindowManagerFlags.TranslucentNavigation); } else { @@ -57,20 +72,12 @@ namespace Avalonia.Android.Platform } } - public AndroidInsetsManager(AvaloniaMainActivity activity, TopLevelImpl topLevel) + internal AndroidInsetsManager(AvaloniaMainActivity activity, TopLevelImpl topLevel) : base(DispatchModeStop) { _activity = activity; _topLevel = topLevel; - _callback = new InsetsAnimationCallback(WindowInsetsAnimationCompat.Callback.DispatchModeStop); - - _callback.InsetsManager = this; - - if (_activity.Window is { } window) - { - ViewCompat.SetOnApplyWindowInsetsListener(window.DecorView, this); - ViewCompat.SetWindowInsetsAnimationCallback(window.DecorView, _callback); - } + ViewCompat.SetOnApplyWindowInsetsListener(Window.DecorView, this); if (Build.VERSION.SdkInt < BuildVersionCodes.R) { @@ -79,32 +86,48 @@ namespace Avalonia.Android.Platform } DisplayEdgeToEdge = false; + + ViewCompat.SetWindowInsetsAnimationCallback(Window.DecorView, this); } public Thickness SafeAreaPadding { get { - var insets = _activity.Window is { } window ? ViewCompat.GetRootWindowInsets(window.DecorView) : null; + var insets = ViewCompat.GetRootWindowInsets(Window.DecorView); if (insets != null) { var renderScaling = _topLevel.RenderScaling; var inset = insets.GetInsets( - (_displayEdgeToEdge ? + _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()); + WindowInsetsCompat.Type.DisplayCutout() : 0); return new Thickness(inset.Left / renderScaling, inset.Top / renderScaling, inset.Right / renderScaling, - (imeInset.Bottom > 0 && ((_usesLegacyLayouts && !_displayEdgeToEdge) || !_usesLegacyLayouts) ? - imeInset.Bottom - (_displayEdgeToEdge ? 0 : navBarInset.Bottom) : - inset.Bottom) / renderScaling); + inset.Bottom / renderScaling); + } + + return default; + } + } + + public Rect OccludedRect + { + get + { + var insets = ViewCompat.GetRootWindowInsets(Window.DecorView); + + if (insets != null) + { + var navbarInset = insets.GetInsets(WindowInsetsCompat.Type.NavigationBars()).Bottom; + + var height = Math.Max((float)((insets.GetInsets(WindowInsetsCompat.Type.Ime()).Bottom - navbarInset) / _topLevel.RenderScaling), 0); + + return new Rect(0, _topLevel.ClientSize.Height - SafeAreaPadding.Bottom - height, _topLevel.ClientSize.Width, height); } return default; @@ -113,8 +136,16 @@ namespace Avalonia.Android.Platform public WindowInsetsCompat OnApplyWindowInsets(View v, WindowInsetsCompat insets) { - NotifySafeAreaChanged(SafeAreaPadding); insets = ViewCompat.OnApplyWindowInsets(v, insets); + NotifySafeAreaChanged(SafeAreaPadding); + + if (_previousRect == default) + { + _previousRect = OccludedRect; + } + + State = insets.IsVisible(WindowInsetsCompat.Type.Ime()) ? InputPaneState.Open : InputPaneState.Closed; + return insets; } @@ -126,6 +157,12 @@ namespace Avalonia.Android.Platform public void OnGlobalLayout() { NotifySafeAreaChanged(SafeAreaPadding); + + if (_usesLegacyLayouts) + { + var insets = ViewCompat.GetRootWindowInsets(Window.DecorView); + State = insets?.IsVisible(WindowInsetsCompat.Type.Ime()) == true ? InputPaneState.Open : InputPaneState.Closed; + } } public SystemBarTheme? SystemBarTheme @@ -134,7 +171,7 @@ namespace Avalonia.Android.Platform { try { - var compat = new WindowInsetsControllerCompat(_activity.Window, _topLevel.View); + var compat = new WindowInsetsControllerCompat(Window, _topLevel.View); return compat.AppearanceLightStatusBars ? Controls.Platform.SystemBarTheme.Light : Controls.Platform.SystemBarTheme.Dark; } @@ -152,7 +189,7 @@ namespace Avalonia.Android.Platform return; } - var compat = new WindowInsetsControllerCompat(_activity.Window, _topLevel.View); + var compat = new WindowInsetsControllerCompat(Window, _topLevel.View); if (_isDefaultSystemBarLightTheme == null) { @@ -161,7 +198,7 @@ namespace Avalonia.Android.Platform if (value == null) { - value = (bool)_isDefaultSystemBarLightTheme ? Controls.Platform.SystemBarTheme.Light : Controls.Platform.SystemBarTheme.Dark; + value = _isDefaultSystemBarLightTheme.Value ? Controls.Platform.SystemBarTheme.Light : Controls.Platform.SystemBarTheme.Dark; } compat.AppearanceLightStatusBars = value == Controls.Platform.SystemBarTheme.Light; @@ -173,7 +210,7 @@ namespace Avalonia.Android.Platform { get { - if(_activity.Window == null) + if (_activity.Window == null) { return true; } @@ -190,7 +227,7 @@ namespace Avalonia.Android.Platform return; } - var compat = WindowCompat.GetInsetsController(_activity.Window, _topLevel.View); + var compat = WindowCompat.GetInsetsController(Window, _topLevel.View); if (value == null || value.Value) { @@ -210,7 +247,7 @@ namespace Avalonia.Android.Platform public Color? SystemBarColor { - get => _systemBarColor; + get => _systemBarColor; set { _systemBarColor = value; @@ -240,40 +277,48 @@ namespace Avalonia.Android.Platform SystemBarColor = _systemBarColor; } - private class InsetsAnimationCallback : WindowInsetsAnimationCompat.Callback + public override WindowInsetsAnimationCompat.BoundsCompat OnStart(WindowInsetsAnimationCompat animation, WindowInsetsAnimationCompat.BoundsCompat bounds) { - public InsetsAnimationCallback(int dispatchMode) : base(dispatchMode) + if ((animation.TypeMask & WindowInsetsCompat.Type.Ime()) != 0) { - } - - public AndroidInsetsManager InsetsManager { get; set; } + var insets = ViewCompat.GetRootWindowInsets(Window.DecorView); - public override WindowInsetsCompat OnProgress(WindowInsetsCompat insets, IList runningAnimations) - { - foreach (var anim in runningAnimations) + if (insets != null) { - 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 navbarInset = insets.GetInsets(WindowInsetsCompat.Type.NavigationBars()).Bottom; + var height = Math.Max(0, (float)((bounds.LowerBound.Bottom - navbarInset) / _topLevel.RenderScaling)); + var upperRect = new Rect(0, _topLevel.ClientSize.Height - SafeAreaPadding.Bottom - height, _topLevel.ClientSize.Width, height); + height = Math.Max(0, (float)((bounds.UpperBound.Bottom - navbarInset) / _topLevel.RenderScaling)); + var lowerRect = new Rect(0, _topLevel.ClientSize.Height - SafeAreaPadding.Bottom - height, _topLevel.ClientSize.Width, height); + var duration = TimeSpan.FromMilliseconds(animation.DurationMillis); - 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; - } + bool isOpening = State == InputPaneState.Open; + StateChanged?.Invoke(this, new InputPaneStateEventArgs(State, isOpening ? upperRect : lowerRect, isOpening ? lowerRect : upperRect, duration, new AnimationEasing(animation.Interpolator))); } - return insets; } + + return base.OnStart(animation, bounds); + } + + public override WindowInsetsCompat OnProgress(WindowInsetsCompat insets, IList runningAnimations) + { + return insets; + } + } + + internal sealed class AnimationEasing : Easing + { + private readonly IInterpolator _interpolator; + + public AnimationEasing(IInterpolator interpolator) + { + _interpolator = interpolator; + } + + public override double Ease(double progress) + { + return _interpolator.GetInterpolation((float)progress); } } } diff --git a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs index d1ef928367..5d5f01db94 100644 --- a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs +++ b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs @@ -395,7 +395,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform return _nativeControlHost; } - if (featureType == typeof(IInsetsManager)) + if (featureType == typeof(IInsetsManager) || featureType == typeof(IInputPane)) { return _insetsManager; } diff --git a/src/Avalonia.Controls/Platform/IInputPane.cs b/src/Avalonia.Controls/Platform/IInputPane.cs new file mode 100644 index 0000000000..c36788570c --- /dev/null +++ b/src/Avalonia.Controls/Platform/IInputPane.cs @@ -0,0 +1,89 @@ +using System; +using Avalonia.Animation.Easings; +using Avalonia.Metadata; + +namespace Avalonia.Controls.Platform +{ + /// + /// Listener for the platform's input pane(eg, software keyboard). Provides access to the input pane height and state. + /// + [NotClientImplementable] + public interface IInputPane + { + /// + /// The current input pane state + /// + InputPaneState State { get; } + + /// + /// The current input pane bounds. + /// + Rect OccludedRect { get; } + + /// + /// Occurs when the input pane's state has changed. + /// + event EventHandler? StateChanged; + } + + /// + /// The input pane opened state. + /// + public enum InputPaneState + { + /// + /// The input pane is either closed, or doesn't form part of the platform insets, i.e. it's floating or is an overlay. + /// + Closed, + + /// + /// The input pane is open. + /// + Open + } + + /// + /// Provides state change information about the input pane. + /// + public sealed class InputPaneStateEventArgs : EventArgs + { + /// + /// The new state of the input pane + /// + public InputPaneState NewState { get; } + + /// + /// The initial bounds of the input pane. + /// + public Rect? StartRect { get; } + + /// + /// The final bounds of the input pane. + /// + public Rect EndRect { get; } + + /// + /// The duration of the input pane's state change animation. + /// + public TimeSpan AnimationDuration { get; } + + /// + /// The easing of the input pane's state changed animation. + /// + public IEasing? Easing { get; } + + public InputPaneStateEventArgs(InputPaneState newState, Rect? startRect, Rect endRect, TimeSpan animationDuration, IEasing? easing) + { + NewState = newState; + StartRect = startRect; + EndRect = endRect; + AnimationDuration = animationDuration; + Easing = easing; + } + + public InputPaneStateEventArgs(InputPaneState newState, Rect? startRect, Rect endRect) + : this(newState, startRect, endRect, default, null) + { + } + } +} diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs index 6898879b32..5026896d41 100644 --- a/src/Avalonia.Controls/TopLevel.cs +++ b/src/Avalonia.Controls/TopLevel.cs @@ -547,6 +547,7 @@ namespace Avalonia.Controls ?? new NoopStorageProvider(); public IInsetsManager? InsetsManager => PlatformImpl?.TryGetFeature(); + public IInputPane? InputPane => PlatformImpl?.TryGetFeature(); /// /// Gets the platform's clipboard implementation diff --git a/src/Browser/Avalonia.Browser/AvaloniaView.cs b/src/Browser/Avalonia.Browser/AvaloniaView.cs index 86323afbbd..c1084ee62c 100644 --- a/src/Browser/Avalonia.Browser/AvaloniaView.cs +++ b/src/Browser/Avalonia.Browser/AvaloniaView.cs @@ -70,7 +70,7 @@ namespace Avalonia.Browser _splash = DomHelper.GetElementById("avalonia-splash"); - _topLevelImpl = new BrowserTopLevelImpl(this); + _topLevelImpl = new BrowserTopLevelImpl(this, _containerElement); _topLevel = new WebEmbeddableControlRoot(_topLevelImpl, () => { diff --git a/src/Browser/Avalonia.Browser/BrowserInputPane.cs b/src/Browser/Avalonia.Browser/BrowserInputPane.cs new file mode 100644 index 0000000000..0b96599fcc --- /dev/null +++ b/src/Browser/Avalonia.Browser/BrowserInputPane.cs @@ -0,0 +1,37 @@ +using System; +using System.Runtime.InteropServices.JavaScript; +using Avalonia.Browser.Interop; +using Avalonia.Controls.Platform; + +namespace Avalonia.Browser; + +internal class BrowserInputPane : IInputPane +{ + public BrowserInputPane(JSObject container) + { + InputHelper.SubscribeKeyboardGeometryChange(container, OnGeometryChange); + } + + public InputPaneState State { get; private set; } + public Rect OccludedRect { get; private set; } + public event EventHandler? StateChanged; + + private bool OnGeometryChange(JSObject args) + { + var oldState = (OccludedRect, State); + + OccludedRect = new Rect( + args.GetPropertyAsDouble("x"), + args.GetPropertyAsDouble("y"), + args.GetPropertyAsDouble("width"), + args.GetPropertyAsDouble("height")); + State = OccludedRect.Width != 0 ? InputPaneState.Open : InputPaneState.Closed; + + if (oldState != (OccludedRect, State)) + { + StateChanged?.Invoke(this, new InputPaneStateEventArgs(State, null, OccludedRect)); + } + + return true; + } +} diff --git a/src/Browser/Avalonia.Browser/BrowserTopLevelImpl.cs b/src/Browser/Avalonia.Browser/BrowserTopLevelImpl.cs index 0809f3a42c..cda0c02a2a 100644 --- a/src/Browser/Avalonia.Browser/BrowserTopLevelImpl.cs +++ b/src/Browser/Avalonia.Browser/BrowserTopLevelImpl.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Runtime.InteropServices.JavaScript; using System.Runtime.Versioning; using Avalonia.Browser.Skia; using Avalonia.Browser.Storage; @@ -33,8 +34,9 @@ namespace Avalonia.Browser private readonly ISystemNavigationManagerImpl _systemNavigationManager; private readonly ClipboardImpl _clipboard; private readonly IInsetsManager? _insetsManager; + private readonly IInputPane _inputPane; - public BrowserTopLevelImpl(AvaloniaView avaloniaView) + public BrowserTopLevelImpl(AvaloniaView avaloniaView, JSObject container) { Surfaces = Enumerable.Empty(); _avaloniaView = avaloniaView; @@ -47,6 +49,7 @@ namespace Avalonia.Browser _storageProvider = new BrowserStorageProvider(); _systemNavigationManager = new BrowserSystemNavigationManagerImpl(); _clipboard = new ClipboardImpl(); + _inputPane = new BrowserInputPane(container); } public ulong Timestamp => (ulong)_sw.ElapsedMilliseconds; @@ -273,6 +276,11 @@ namespace Avalonia.Browser { return _clipboard; } + + if (featureType == typeof(IInputPane)) + { + return _inputPane; + } return null; } diff --git a/src/Browser/Avalonia.Browser/Interop/InputHelper.cs b/src/Browser/Avalonia.Browser/Interop/InputHelper.cs index 76dac84bca..9fc0df28d8 100644 --- a/src/Browser/Avalonia.Browser/Interop/InputHelper.cs +++ b/src/Browser/Avalonia.Browser/Interop/InputHelper.cs @@ -51,6 +51,10 @@ internal static partial class InputHelper public static partial void SubscribeDropEvents(JSObject containerElement, [JSMarshalAs>] Func dragEvent); + [JSImport("InputHelper.subscribeKeyboardGeometryChange", AvaloniaModule.MainModuleName)] + public static partial void SubscribeKeyboardGeometryChange(JSObject containerElement, + [JSMarshalAs>] Func handler); + [JSImport("InputHelper.getCoalescedEvents", AvaloniaModule.MainModuleName)] [return: JSMarshalAs>] public static partial JSObject[] GetCoalescedEvents(JSObject pointerEvent); diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/input.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/input.ts index 27a1d998fd..99439b6034 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/input.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/input.ts @@ -245,6 +245,24 @@ export class InputHelper { return pointerEvent.getCoalescedEvents(); } + public static subscribeKeyboardGeometryChange( + element: HTMLInputElement, + handler: (args: any) => boolean) { + if ("virtualKeyboard" in navigator) { + // (navigator as any).virtualKeyboard.overlaysContent = true; + (navigator as any).virtualKeyboard.addEventListener("geometrychange", (event: any) => { + const elementRect = element.getBoundingClientRect(); + const keyboardRect = event.target.boundingRect as DOMRect; + handler({ + x: keyboardRect.x - elementRect.x, + y: keyboardRect.y - elementRect.y, + width: keyboardRect.width, + height: keyboardRect.height + }); + }); + } + } + public static clearInput(inputElement: HTMLInputElement) { inputElement.value = ""; } diff --git a/src/Windows/Avalonia.Win32/Input/WindowsInputPane.cs b/src/Windows/Avalonia.Win32/Input/WindowsInputPane.cs new file mode 100644 index 0000000000..f8035eaf00 --- /dev/null +++ b/src/Windows/Avalonia.Win32/Input/WindowsInputPane.cs @@ -0,0 +1,77 @@ +using System; +using Avalonia.Controls.Platform; +using Avalonia.MicroCom; +using Avalonia.Win32.Interop; +using Avalonia.Win32.Win32Com; + +namespace Avalonia.Win32.Input; + +internal unsafe class WindowsInputPane : IInputPane, IDisposable +{ + // GUID: D5120AA3-46BA-44C5-822D-CA8092C1FC72 + private static readonly Guid CLSID_FrameworkInputPane = new(0xD5120AA3, 0x46BA, 0x44C5, 0x82, 0x2D, 0xCA, 0x80, 0x92, 0xC1, 0xFC, 0x72); + // GUID: 5752238B-24F0-495A-82F1-2FD593056796 + private static readonly Guid SID_IFrameworkInputPane = new(0x5752238B, 0x24F0, 0x495A, 0x82, 0xF1, 0x2F, 0xD5, 0x93, 0x05, 0x67, 0x96); + + private readonly WindowImpl _windowImpl; + private readonly IFrameworkInputPane _inputPane; + private readonly uint _cookie; + + public WindowsInputPane(WindowImpl windowImpl) + { + _windowImpl = windowImpl; + _inputPane = UnmanagedMethods.CreateInstance(in CLSID_FrameworkInputPane, in SID_IFrameworkInputPane); + + using (var handler = new Handler(this)) + { + uint cookie = 0; + _inputPane.AdviseWithHWND(windowImpl.Handle.Handle, handler, &cookie); + _cookie = cookie; + } + } + public InputPaneState State { get; private set; } + + public Rect OccludedRect { get; private set; } + + public event EventHandler? StateChanged; + + private void OnStateChanged(bool showing, UnmanagedMethods.RECT? prcInputPaneScreenLocation) + { + var oldState = (OccludedRect, State); + OccludedRect = prcInputPaneScreenLocation.HasValue + ? ScreenRectToClient(prcInputPaneScreenLocation.Value) + : default; + State = showing ? InputPaneState.Open : InputPaneState.Closed; + + if (oldState != (OccludedRect, State)) + { + StateChanged?.Invoke(this, new InputPaneStateEventArgs(State, null, OccludedRect)); + } + } + + private Rect ScreenRectToClient(UnmanagedMethods.RECT screenRect) + { + var position = new PixelPoint(screenRect.left, screenRect.top); + var size = new PixelSize(screenRect.Width, screenRect.Height); + return new Rect(_windowImpl.PointToClient(position), size.ToSize(_windowImpl.DesktopScaling)); + } + + public void Dispose() + { + if (_cookie != 0) + { + _inputPane.Unadvise(_cookie); + } + + _inputPane.Dispose(); + } + + private class Handler : CallbackBase, IFrameworkInputPaneHandler + { + private readonly WindowsInputPane _pane; + + public Handler(WindowsInputPane pane) => _pane = pane; + public void Showing(UnmanagedMethods.RECT* rect, int _) => _pane.OnStateChanged(true, *rect); + public void Hiding(int fEnsureFocusedElementInView) => _pane.OnStateChanged(false, null); + } +} diff --git a/src/Windows/Avalonia.Win32/Interop/TaskBarList.cs b/src/Windows/Avalonia.Win32/Interop/TaskBarList.cs index 7eb457480b..9e3841d555 100644 --- a/src/Windows/Avalonia.Win32/Interop/TaskBarList.cs +++ b/src/Windows/Avalonia.Win32/Interop/TaskBarList.cs @@ -19,10 +19,7 @@ namespace Avalonia.Win32.Interop { if (s_taskBarList == IntPtr.Zero) { - Guid clsid = ShellIds.TaskBarList; - Guid iid = ShellIds.ITaskBarList2; - - int result = CoCreateInstance(ref clsid, IntPtr.Zero, 1, ref iid, out s_taskBarList); + int result = CoCreateInstance(in ShellIds.TaskBarList, IntPtr.Zero, 1, in ShellIds.ITaskBarList2, out s_taskBarList); if (s_taskBarList != IntPtr.Zero) { diff --git a/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs b/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs index 7ee25e42b4..f31d5b2fc3 100644 --- a/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs +++ b/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs @@ -1481,18 +1481,14 @@ namespace Avalonia.Win32.Interop [DllImport("user32.dll", EntryPoint = "SetCursor")] internal static extern IntPtr SetCursor(IntPtr hCursor); - - [DllImport("ole32.dll", PreserveSig = true)] - internal static extern int CoCreateInstance(ref Guid clsid, - IntPtr ignore1, int ignore2, ref Guid iid, [MarshalAs(UnmanagedType.IUnknown), Out] out object pUnkOuter); - + [DllImport("ole32.dll", PreserveSig = true)] - internal static extern int CoCreateInstance(ref Guid clsid, - IntPtr ignore1, int ignore2, ref Guid iid, [Out] out IntPtr pUnkOuter); + internal static extern int CoCreateInstance(in Guid clsid, + IntPtr ignore1, int ignore2, in Guid iid, [Out] out IntPtr pUnkOuter); - internal static T CreateInstance(ref Guid clsid, ref Guid iid) where T : IUnknown + internal static T CreateInstance(in Guid clsid, in Guid iid) where T : IUnknown { - var hresult = CoCreateInstance(ref clsid, IntPtr.Zero, 1, ref iid, out IntPtr pUnk); + var hresult = CoCreateInstance(in clsid, IntPtr.Zero, 1, in iid, out IntPtr pUnk); if (hresult != 0) { throw new COMException("CreateInstance", hresult); diff --git a/src/Windows/Avalonia.Win32/Win32Com/win32.idl b/src/Windows/Avalonia.Win32/Win32Com/win32.idl index 196aa983f3..34dc746133 100644 --- a/src/Windows/Avalonia.Win32/Win32Com/win32.idl +++ b/src/Windows/Avalonia.Win32/Win32Com/win32.idl @@ -312,3 +312,19 @@ interface IDropTarget : IUnknown [in] DropEffect* pdwEffect ); } + +[uuid(226C537B-1E76-4D9E-A760-33DB29922F18)] +interface IFrameworkInputPaneHandler : IUnknown +{ + HRESULT Showing(RECT* prcInputPaneScreenLocation, boolean fEnsureFocusedElementInView); + HRESULT Hiding(boolean fEnsureFocusedElementInView); +} + +[uuid(5752238B-24F0-495A-82F1-2FD593056796)] +interface IFrameworkInputPane : IUnknown +{ + int Advise(IUnknown* pWindow, IFrameworkInputPaneHandler* pHandler, uint* pdwCookie); + int AdviseWithHWND(HWND hwnd, IFrameworkInputPaneHandler* pHandler, uint* pdwCookie); + int Unadvise(uint dwCookie); + int Location(RECT* prcInputPaneScreenLocation); +} diff --git a/src/Windows/Avalonia.Win32/Win32StorageProvider.cs b/src/Windows/Avalonia.Win32/Win32StorageProvider.cs index c4fe73b985..56ba96fcf6 100644 --- a/src/Windows/Avalonia.Win32/Win32StorageProvider.cs +++ b/src/Windows/Avalonia.Win32/Win32StorageProvider.cs @@ -87,7 +87,7 @@ namespace Avalonia.Win32 { var clsid = isOpenFile ? UnmanagedMethods.ShellIds.OpenFileDialog : UnmanagedMethods.ShellIds.SaveFileDialog; var iid = UnmanagedMethods.ShellIds.IFileDialog; - var frm = UnmanagedMethods.CreateInstance(ref clsid, ref iid); + var frm = UnmanagedMethods.CreateInstance(in clsid, in iid); var options = frm.Options; options |= DefaultDialogOptions; diff --git a/src/Windows/Avalonia.Win32/WinRT/winrt.idl b/src/Windows/Avalonia.Win32/WinRT/winrt.idl index beb88352dd..03b38d0e68 100644 --- a/src/Windows/Avalonia.Win32/WinRT/winrt.idl +++ b/src/Windows/Avalonia.Win32/WinRT/winrt.idl @@ -871,3 +871,4 @@ interface IAccessibilitySettings : IInspectable [propget] HRESULT HighContrast([out] [retval] boolean* value); [propget] HRESULT HighContrastScheme([out] [retval] HSTRING* value); } + diff --git a/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs b/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs index 0a1a1e898f..30cca408b8 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs @@ -110,6 +110,7 @@ namespace Avalonia.Win32 } _framebuffer.Dispose(); + _inputPane?.Dispose(); //Window doesn't exist anymore _hwnd = IntPtr.Zero; diff --git a/src/Windows/Avalonia.Win32/WindowImpl.cs b/src/Windows/Avalonia.Win32/WindowImpl.cs index 8d56c52ed8..db2f5c6a5a 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.cs @@ -77,6 +77,7 @@ namespace Avalonia.Win32 private readonly Win32NativeControlHost _nativeControlHost; private readonly IStorageProvider _storageProvider; + private readonly WindowsInputPane? _inputPane; private WndProc _wndProcDelegate; private string? _className; private IntPtr _hwnd; @@ -164,7 +165,7 @@ namespace Avalonia.Win32 Screen = new ScreenImpl(); _storageProvider = new Win32StorageProvider(this); - + _inputPane = Win32Platform.WindowsVersion >= PlatformConstants.Windows10 ? new WindowsInputPane(this) : null; _nativeControlHost = new Win32NativeControlHost(this, !UseRedirectionBitmap); _defaultTransparencyLevel = UseRedirectionBitmap ? WindowTransparencyLevel.None : WindowTransparencyLevel.Transparent; _transparencyLevel = _defaultTransparencyLevel; @@ -342,6 +343,11 @@ namespace Avalonia.Win32 { return AvaloniaLocator.Current.GetRequiredService(); } + + if (featureType == typeof(IInputPane)) + { + return _inputPane; + } return null; } diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.cs b/src/iOS/Avalonia.iOS/AvaloniaView.cs index a2f206cdd2..a51d3f2b28 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.cs @@ -227,6 +227,11 @@ namespace Avalonia.iOS return _clipboard; } + if (featureType == typeof(IInputPane)) + { + return UIKitInputPane.Instance; + } + return null; } } diff --git a/src/iOS/Avalonia.iOS/UIKitInputPane.cs b/src/iOS/Avalonia.iOS/UIKitInputPane.cs new file mode 100644 index 0000000000..86cae0bf53 --- /dev/null +++ b/src/iOS/Avalonia.iOS/UIKitInputPane.cs @@ -0,0 +1,54 @@ +using System; +using System.Diagnostics; +using Avalonia.Animation.Easings; +using Avalonia.Controls.Platform; +using Foundation; +using UIKit; + +#nullable enable +namespace Avalonia.iOS; + +internal sealed class UIKitInputPane : IInputPane +{ + public static UIKitInputPane Instance { get; } = new(); + + public UIKitInputPane() + { + NSNotificationCenter + .DefaultCenter + .AddObserver(UIKeyboard.WillShowNotification, KeyboardUpNotification); + NSNotificationCenter + .DefaultCenter + .AddObserver(UIKeyboard.WillHideNotification, KeyboardDownNotification); + } + + public InputPaneState State { get; private set; } + public Rect OccludedRect { get; private set; } + public event EventHandler? StateChanged; + + private void KeyboardDownNotification(NSNotification obj) => RaiseEventFromNotification(false, obj); + + private void KeyboardUpNotification(NSNotification obj) => RaiseEventFromNotification(true, obj); + + private void RaiseEventFromNotification(bool isUp, NSNotification notification) + { + State = isUp ? InputPaneState.Open : InputPaneState.Closed; + + var startFrame = UIKeyboard.FrameBeginFromNotification(notification); + var endFrame = UIKeyboard.FrameEndFromNotification(notification); + var duration = UIKeyboard.AnimationDurationFromNotification(notification); + var curve = (UIViewAnimationOptions)UIKeyboard.AnimationCurveFromNotification(notification); + IEasing? easing = + curve.HasFlag(UIViewAnimationOptions.CurveLinear) ? new LinearEasing() + : curve.HasFlag(UIViewAnimationOptions.CurveEaseIn) ? new SineEaseIn() + : curve.HasFlag(UIViewAnimationOptions.CurveEaseOut) ? new SineEaseOut() + : curve.HasFlag(UIViewAnimationOptions.CurveEaseInOut) ? new SineEaseInOut() + : null; + + var startRect = new Rect(startFrame.X, startFrame.Y, startFrame.Width, startFrame.Height); + OccludedRect = new Rect(endFrame.X, endFrame.Y, endFrame.Width, endFrame.Height); + + StateChanged?.Invoke(this, new InputPaneStateEventArgs( + State, startRect, OccludedRect, TimeSpan.FromSeconds(duration), easing)); + } +}