Browse Source

AppleTV support + improvements for iOS backend (#14196)

* Add initial metal support for iOS

* Make iOS backend compilable with TVOS and MacCatalyst

* Enable nullable and fix almost all warnings in iOS project

* Implement keyboard and remote (+touch) input on iOS and tvOS

* Disable Metal for now

* Fix warning

* Add tvOS target for Avalonia.iOS

* Include tvos workloads

* Don't use WebKit on tvOS samples

* Extend StandardRuntimePlatform with more apple platforms

* Handle mouse and stylus for iOS backend as well

* And IntermediatePoints

* Fix compatibility warnings

* Respect t.MaximumPossibleForce

* Make ControlCatalog.IOS work with both iOS and tvOS

* Exclude compiled files using csproj instead of #if directives

* Make Metal rendering actually usable

* No need for buffer.WaitUntilCompleted on iOS here
Max Katz 1 year ago
parent
commit
37d30dcbc2
37 changed files with 1021 additions and 191 deletions
  1. 1 1
      azure-pipelines.yml
  2. 1 0
      samples/ControlCatalog.iOS/ControlCatalog.iOS.csproj
  3. 3 2
      samples/ControlCatalog.iOS/EmbedSample.iOS.cs
  4. 5 4
      samples/ControlCatalog.iOS/Info.plist
  5. 9 3
      src/Avalonia.Base/Compatibility/OperatingSystem.cs
  6. 6 8
      src/Avalonia.Base/Platform/StandardRuntimePlatform.cs
  7. 2 2
      src/Avalonia.Controls/ApplicationLifetimes/IActivatableApplicationLifetime.cs
  8. 7 2
      src/Skia/Avalonia.Skia/Gpu/Metal/SkiaMetalApi.cs
  9. 20 2
      src/iOS/Avalonia.iOS/Avalonia.iOS.csproj
  10. 3 3
      src/iOS/Avalonia.iOS/AvaloniaAppDelegate.cs
  11. 0 1
      src/iOS/Avalonia.iOS/AvaloniaView.Text.cs
  12. 154 36
      src/iOS/Avalonia.iOS/AvaloniaView.cs
  13. 33 6
      src/iOS/Avalonia.iOS/ClipboardImpl.cs
  14. 0 2
      src/iOS/Avalonia.iOS/DispatcherImpl.cs
  15. 2 2
      src/iOS/Avalonia.iOS/DisplayLinkTimer.cs
  16. 30 9
      src/iOS/Avalonia.iOS/Eagl/EaglDisplay.cs
  17. 7 5
      src/iOS/Avalonia.iOS/Eagl/EaglLayerSurface.cs
  18. 12 6
      src/iOS/Avalonia.iOS/Eagl/LayerFbo.cs
  19. 432 0
      src/iOS/Avalonia.iOS/InputHandler.cs
  20. 0 8
      src/iOS/Avalonia.iOS/InsetsManager.cs
  21. 34 0
      src/iOS/Avalonia.iOS/Metal/MetalDevice.cs
  22. 34 0
      src/iOS/Avalonia.iOS/Metal/MetalDrawingSession.cs
  23. 49 0
      src/iOS/Avalonia.iOS/Metal/MetalPlatformGraphics.cs
  24. 25 0
      src/iOS/Avalonia.iOS/Metal/MetalPlatformSurface.cs
  25. 37 0
      src/iOS/Avalonia.iOS/Metal/MetalRenderTarget.cs
  26. 1 3
      src/iOS/Avalonia.iOS/NativeControlHostImpl.cs
  27. 68 9
      src/iOS/Avalonia.iOS/Platform.cs
  28. 0 1
      src/iOS/Avalonia.iOS/PlatformSettings.cs
  29. 6 6
      src/iOS/Avalonia.iOS/SingleViewLifetime.cs
  30. 8 7
      src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs
  31. 4 2
      src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs
  32. 2 1
      src/iOS/Avalonia.iOS/Stubs.cs
  33. 0 1
      src/iOS/Avalonia.iOS/TextInputResponder.Properties.cs
  34. 6 3
      src/iOS/Avalonia.iOS/TextInputResponder.cs
  35. 0 52
      src/iOS/Avalonia.iOS/TouchHandler.cs
  36. 10 2
      src/iOS/Avalonia.iOS/UIKitInputPane.cs
  37. 10 2
      src/iOS/Avalonia.iOS/ViewController.cs

+ 1 - 1
azure-pipelines.yml

@@ -171,7 +171,7 @@ jobs:
     displayName: 'Install Workloads'
     inputs:
       script: |
-       dotnet workload install android ios wasm-tools wasm-experimental
+       dotnet workload install android ios tvos wasm-tools wasm-experimental
 
   - task: PowerShell@2
     displayName: 'Install Tizen Workload'

+ 1 - 0
samples/ControlCatalog.iOS/ControlCatalog.iOS.csproj

@@ -3,6 +3,7 @@
     <OutputType>Exe</OutputType>
     <ProvisioningType>manual</ProvisioningType>
     <TargetFramework>net7.0-ios</TargetFramework>
+<!--    <TargetFramework>net7.0-tvos</TargetFramework>-->
     <SupportedOSPlatformVersion>13.0</SupportedOSPlatformVersion>
   </PropertyGroup>
   <ItemGroup>

+ 3 - 2
samples/ControlCatalog.iOS/EmbedSample.iOS.cs

@@ -3,7 +3,6 @@ using Avalonia.Platform;
 using CoreGraphics;
 using Foundation;
 using UIKit;
-using WebKit;
 using Avalonia.iOS;
 using ControlCatalog.Pages;
 
@@ -13,14 +12,16 @@ public class EmbedSampleIOS : INativeDemoControl
 {
     public IPlatformHandle CreateControl(bool isSecond, IPlatformHandle parent, Func<IPlatformHandle> createDefault)
     {
+#if !TVOS
         if (isSecond)
         {
-            var webView = new WKWebView(CGRect.Empty, new WKWebViewConfiguration());
+            var webView = new WebKit.WKWebView(CGRect.Empty, new WebKit.WKWebViewConfiguration());
             webView.LoadRequest(new NSUrlRequest(new NSUrl("https://www.apple.com/")));
 
             return new UIViewControlHandle(webView);
         }
         else
+#endif
         {
             var button = new UIButton();
             var clickCount = 0;

+ 5 - 4
samples/ControlCatalog.iOS/Info.plist

@@ -18,13 +18,14 @@
 	<array>
 		<integer>1</integer>
 		<integer>2</integer>
+        <integer>3</integer>
 	</array>
+    <key>UIRequiredDeviceCapabilities</key>
+    <array>
+        <string>arm64</string>
+    </array>
 	<key>UILaunchStoryboardName</key>
 	<string>LaunchScreen</string>
-	<key>UIRequiredDeviceCapabilities</key>
-	<array>
-		<string>armv7</string>
-	</array>
 	<key>UISupportedInterfaceOrientations</key>
 	<array>
 		<string>UIInterfaceOrientationPortrait</string>

+ 9 - 3
src/Avalonia.Base/Compatibility/OperatingSystem.cs

@@ -8,18 +8,24 @@ namespace Avalonia.Compatibility
 #if NET6_0_OR_GREATER
         public static bool IsWindows() => OperatingSystem.IsWindows();
         public static bool IsMacOS() => OperatingSystem.IsMacOS();
+        public static bool IsMacCatalyst() => OperatingSystem.IsMacCatalyst();
         public static bool IsLinux() => OperatingSystem.IsLinux();
+        public static bool IsFreeBSD() => OperatingSystem.IsFreeBSD();
         public static bool IsAndroid() => OperatingSystem.IsAndroid();
         public static bool IsIOS() => OperatingSystem.IsIOS();
+        public static bool IsTvOS() => OperatingSystem.IsTvOS();
         public static bool IsBrowser() => OperatingSystem.IsBrowser();
         public static bool IsOSPlatform(string platform) => OperatingSystem.IsOSPlatform(platform);
 #else
         public static bool IsWindows() => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
         public static bool IsMacOS() => RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
         public static bool IsLinux() => RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
-        public static bool IsAndroid() => IsOSPlatform("ANDROID");
-        public static bool IsIOS() => IsOSPlatform("IOS");
-        public static bool IsBrowser() => IsOSPlatform("BROWSER");
+        public static bool IsFreeBSD() => false;
+        public static bool IsAndroid() => false;
+        public static bool IsIOS() => false;
+        public static bool IsMacCatalyst() => false;
+        public static bool IsTvOS() => false;
+        public static bool IsBrowser() => false;
         public static bool IsOSPlatform(string platform) => RuntimeInformation.IsOSPlatform(OSPlatform.Create(platform));
 #endif
     }

+ 6 - 8
src/Avalonia.Base/Platform/StandardRuntimePlatform.cs

@@ -1,20 +1,18 @@
-using System;
-using System.Threading;
 using Avalonia.Compatibility;
 using Avalonia.Metadata;
-using Avalonia.Platform.Internal;
 
 namespace Avalonia.Platform
 {
     [PrivateApi]
     public class StandardRuntimePlatform : IRuntimePlatform
     {
-        private static readonly RuntimePlatformInfo s_info = new()
+        public virtual RuntimePlatformInfo GetRuntimeInfo() => new()
         {
-            IsDesktop = OperatingSystemEx.IsWindows() || OperatingSystemEx.IsMacOS() || OperatingSystemEx.IsLinux(),
-            IsMobile = OperatingSystemEx.IsAndroid() || OperatingSystemEx.IsIOS()
+            IsDesktop = OperatingSystemEx.IsWindows()
+                        || OperatingSystemEx.IsMacOS() || OperatingSystemEx.IsMacCatalyst()
+                        || OperatingSystemEx.IsLinux() || OperatingSystemEx.IsFreeBSD(),
+            IsMobile = OperatingSystemEx.IsAndroid() || (OperatingSystemEx.IsIOS() && !OperatingSystemEx.IsMacCatalyst()),
+            IsTV = OperatingSystemEx.IsTvOS()
         };
-        
-        public virtual RuntimePlatformInfo GetRuntimeInfo() => s_info;
     }
 }

+ 2 - 2
src/Avalonia.Controls/ApplicationLifetimes/IActivatableApplicationLifetime.cs

@@ -11,13 +11,13 @@ public interface IActivatableApplicationLifetime
     /// An event that is raised when the application is Activated for various reasons
     /// as described by the <see cref="ActivationKind"/> enumeration.
     /// </summary>
-    event EventHandler<ActivatedEventArgs> Activated;
+    event EventHandler<ActivatedEventArgs>? Activated;
     
     /// <summary>
     /// An event that is raised when the application is Deactivated for various reasons
     /// as described by the <see cref="ActivationKind"/> enumeration.
     /// </summary>
-    event EventHandler<ActivatedEventArgs> Deactivated;
+    event EventHandler<ActivatedEventArgs>? Deactivated;
 
     /// <summary>
     /// Tells the application that it should attempt to leave its background state.

+ 7 - 2
src/Skia/Avalonia.Skia/Gpu/Metal/SkiaMetalApi.cs

@@ -26,7 +26,12 @@ internal unsafe class SkiaMetalApi
         // Make sure that skia is loaded
         GC.KeepAlive(new SKPaint());
 
-        var dll = NativeLibraryEx.Load("libSkiaSharp", typeof(SKPaint).Assembly);
+        // https://github.com/mono/SkiaSharp/blob/25e70a390e2128e5a54d28795365bf9fdaa7161c/binding/SkiaSharp/SkiaApi.cs#L9-L13
+        // Note, IsIOS also returns true on MacCatalyst.
+        var libSkiaSharpPath = OperatingSystemEx.IsIOS() || OperatingSystemEx.IsTvOS() ?
+            "@rpath/libSkiaSharp.framework/libSkiaSharp" :
+            "libSkiaSharp";
+        var dll = NativeLibraryEx.Load(libSkiaSharpPath, typeof(SKPaint).Assembly);
 
         IntPtr address;
 
@@ -75,7 +80,7 @@ internal unsafe class SkiaMetalApi
         var context = _gr_direct_context_make_metal_with_options(device, queue, pOptions);
         Marshal.FreeHGlobal(pOptions);
         if (context == IntPtr.Zero)
-            throw new ArgumentException();
+            throw new InvalidOperationException("Unable to create GRContext from Metal device.");
         return (GRContext)_contextCtor.Invoke(new object[] { context, true });
     }
 

+ 20 - 2
src/iOS/Avalonia.iOS/Avalonia.iOS.csproj

@@ -1,15 +1,33 @@
 <Project Sdk="Microsoft.NET.Sdk">
   <PropertyGroup>
-    <TargetFramework>net7.0-ios16.0</TargetFramework>
-    <SupportedOSPlatformVersion>13.0</SupportedOSPlatformVersion>
+    <TargetFrameworks>net7.0-ios16.0;net7.0-tvos</TargetFrameworks>
+    <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">13.0</SupportedOSPlatformVersion>
+    <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tvos'">13.0</SupportedOSPlatformVersion>
+    <!-- Not yet enabled as a target framework -->
+    <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">13.1</SupportedOSPlatformVersion>
     <MSBuildEnableWorkloadResolver>true</MSBuildEnableWorkloadResolver>
     <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+
+    <!-- Compatibility attributes are pretty much broken for iOS-like platforms. Verify by hand. -->
+    <!-- Workaround: https://github.com/dotnet/roslyn-analyzers/issues/6158 -->
+    <NoWarn>$(NoWarn);CA1416</NoWarn>
   </PropertyGroup>
   <ItemGroup>
     <ProjectReference Include="..\..\Avalonia.Base\Avalonia.Base.csproj" />
     <ProjectReference Include="..\..\Skia\Avalonia.Skia\Avalonia.Skia.csproj" />
   </ItemGroup>
 
+  <ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">
+    <Compile Remove="Eagl/**/*.*" />
+  </ItemGroup>
+
+  <ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tvos'">
+    <Compile Remove="Storage/**/*.*" />
+    <Compile Remove="ClipboardImpl.cs" />
+    <Compile Remove="UIKitInputPane.cs" />
+  </ItemGroup>
+
   <Import Project="..\..\..\build\DevAnalyzers.props" />
   <Import Project="..\..\..\build\TrimmingEnable.props" />
+  <Import Project="..\..\..\build\NullableEnable.props" />
 </Project>

+ 3 - 3
src/iOS/Avalonia.iOS/AvaloniaAppDelegate.cs

@@ -15,7 +15,7 @@ namespace Avalonia.iOS
     public class AvaloniaAppDelegate<TApp> : UIResponder, IUIApplicationDelegate, IAvaloniaAppDelegate
         where TApp : Application, new()
     {
-        private EventHandler<ActivatedEventArgs> _onActivated, _onDeactivated;
+        private EventHandler<ActivatedEventArgs>? _onActivated, _onDeactivated;
 
         public AvaloniaAppDelegate()
         {
@@ -37,7 +37,7 @@ namespace Avalonia.iOS
         protected virtual AppBuilder CustomizeAppBuilder(AppBuilder builder) => builder;
         
         [Export("window")]
-        public UIWindow Window { get; set; }
+        public UIWindow? Window { get; set; }
 
         [Export("application:didFinishLaunchingWithOptions:")]
         public bool FinishedLaunching(UIApplication application, NSDictionary launchOptions)
@@ -64,7 +64,7 @@ namespace Avalonia.iOS
 
             builder.SetupWithLifetime(lifetime);
 
-            Window.MakeKeyAndVisible();
+            Window!.MakeKeyAndVisible();
 
             return true;
         }

+ 0 - 1
src/iOS/Avalonia.iOS/AvaloniaView.Text.cs

@@ -1,4 +1,3 @@
-#nullable enable
 using Avalonia.Input.TextInput;
 using UIKit;
 

+ 154 - 36
src/iOS/Avalonia.iOS/AvaloniaView.cs

@@ -1,7 +1,6 @@
-#nullable enable
-
 using System;
 using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
 using System.Runtime.Versioning;
 using Avalonia.Controls;
 using Avalonia.Controls.Embedding;
@@ -12,19 +11,20 @@ using Avalonia.Input;
 using Avalonia.Input.Platform;
 using Avalonia.Input.Raw;
 using Avalonia.Input.TextInput;
-using Avalonia.iOS.Storage;
 using Avalonia.Platform;
 using Avalonia.Platform.Storage;
 using Avalonia.Rendering.Composition;
 using CoreAnimation;
 using Foundation;
 using ObjCRuntime;
-using OpenGLES;
 using UIKit;
 using IInsetsManager = Avalonia.Controls.Platform.IInsetsManager;
 
 namespace Avalonia.iOS
 {
+    /// <summary>
+    /// Root view container for Avalonia content, that can be embedded into iOS visual tree.
+    /// </summary>
     public partial class AvaloniaView : UIView, ITextInputMethodImpl
     {
         internal IInputRoot InputRoot
@@ -32,38 +32,74 @@ namespace Avalonia.iOS
 
         private readonly TopLevelImpl _topLevelImpl;
         private readonly EmbeddableControlRoot _topLevel;
-        private readonly TouchHandler _touches;
+        private readonly InputHandler _input;
         private TextInputMethodClient? _client;
         private IAvaloniaViewController? _controller;
         private IInputRoot? _inputRoot;
+        private Metal.MetalRenderTarget? _currentRenderTarget;
+        private (PixelSize size, double scaling) _latestLayoutProps;
 
         public AvaloniaView()
         {
             _topLevelImpl = new TopLevelImpl(this);
-            _touches = new TouchHandler(this, _topLevelImpl);
+            _input = new InputHandler(this, _topLevelImpl);
             _topLevel = new EmbeddableControlRoot(_topLevelImpl);
 
             _topLevel.Prepare();
 
             _topLevel.StartRendering();
 
-            InitEagl();
-            MultipleTouchEnabled = true;
+            InitLayerSurface();
+
+            // Remote touch handling
+            if (OperatingSystem.IsTvOS())
+            {
+                AddGestureRecognizer(new UISwipeGestureRecognizer(_input.Handle)
+                {
+                    Direction = UISwipeGestureRecognizerDirection.Up
+                });
+                AddGestureRecognizer(new UISwipeGestureRecognizer(_input.Handle)
+                {
+                    Direction = UISwipeGestureRecognizerDirection.Right
+                });
+                AddGestureRecognizer(new UISwipeGestureRecognizer(_input.Handle)
+                {
+                    Direction = UISwipeGestureRecognizerDirection.Down
+                });
+                AddGestureRecognizer(new UISwipeGestureRecognizer(_input.Handle)
+                {
+                    Direction = UISwipeGestureRecognizerDirection.Left
+                });
+            }
+            else if (OperatingSystem.IsIOS() || OperatingSystem.IsMacCatalyst())
+            {
+#if !TVOS
+                MultipleTouchEnabled = true;
+#endif
+            }
         }
 
-        [ObsoletedOSPlatform("ios12.0", "Use 'Metal' instead.")]
-        [SupportedOSPlatform("ios")]
-        [UnsupportedOSPlatform("maccatalyst")]
-        private void InitEagl()
+        [SuppressMessage("Interoperability", "CA1422:Validate platform compatibility")]
+        private void InitLayerSurface()
         {
-            var l = (CAEAGLLayer)Layer;
+            var l = Layer;
             l.ContentsScale = UIScreen.MainScreen.Scale;
             l.Opaque = true;
-            l.DrawableProperties = new NSDictionary(
-                EAGLDrawableProperty.RetainedBacking, false,
-                EAGLDrawableProperty.ColorFormat, EAGLColorFormat.RGBA8
-            );
-            _topLevelImpl.Surfaces = new[] { new EaglLayerSurface(l) };
+#if !MACCATALYST
+            if (l is CAEAGLLayer eaglLayer)
+            {
+                eaglLayer.DrawableProperties = new NSDictionary(
+                    OpenGLES.EAGLDrawableProperty.RetainedBacking, false,
+                    OpenGLES.EAGLDrawableProperty.ColorFormat, OpenGLES.EAGLColorFormat.RGBA8
+                );
+                _topLevelImpl.Surfaces = new[] { new Eagl.EaglLayerSurface(eaglLayer) };
+            }
+            else
+#endif
+            if (l is CAMetalLayer metalLayer)
+            {
+                _topLevelImpl.Surfaces = new[] { new Metal.MetalPlatformSurface(metalLayer, this) };
+            }
         }
 
         /// <inheritdoc />
@@ -73,6 +109,12 @@ namespace Avalonia.iOS
         public override bool CanResignFirstResponder => true;
 
         /// <inheritdoc />
+        [ObsoletedOSPlatform("ios17.0", "Use the 'UITraitChangeObservable' protocol instead.")]
+        [ObsoletedOSPlatform("maccatalyst17.0", "Use the 'UITraitChangeObservable' protocol instead.")]
+        [ObsoletedOSPlatform("tvos17.0", "Use the 'UITraitChangeObservable' protocol instead.")]
+        [SupportedOSPlatform("ios")]
+        [SupportedOSPlatform("tvos")]
+        [SupportedOSPlatform("maccatalyst")]
         public override void TraitCollectionDidChange(UITraitCollection? previousTraitCollection)
         {
             base.TraitCollectionDidChange(previousTraitCollection);
@@ -101,9 +143,10 @@ namespace Avalonia.iOS
         {
             private readonly AvaloniaView _view;
             private readonly INativeControlHostImpl _nativeControlHost;
-            private readonly IStorageProvider _storageProvider;
             internal readonly InsetsManager _insetsManager;
-            private readonly ClipboardImpl _clipboard;
+            private readonly IStorageProvider? _storageProvider;
+            private readonly IClipboard? _clipboard;
+            private readonly IInputPane? _inputPane;
             private IDisposable? _paddingInsets;
 
             public AvaloniaView View => _view;
@@ -112,8 +155,16 @@ namespace Avalonia.iOS
             {
                 _view = view;
                 _nativeControlHost = new NativeControlHostImpl(view);
-                _storageProvider = new IOSStorageProvider(view);
-                _insetsManager = new InsetsManager(view);
+#if TVOS
+                _storageProvider = null;
+                _clipboard = null;
+                _inputPane = null;
+#else
+                _storageProvider = new Storage.IOSStorageProvider(view);
+                _clipboard = new ClipboardImpl();
+                _inputPane = UIKitInputPane.Instance;
+#endif
+                _insetsManager = new InsetsManager();
                 _insetsManager.DisplayEdgeToEdgeChanged += (_, edgeToEdge) =>
                 {
                     // iOS doesn't add any paddings/margins to the application by itself.
@@ -128,7 +179,6 @@ namespace Avalonia.iOS
                             BindingPriority.Style); // lower priority, so it can be redefined by user
                     }
                 };
-                _clipboard = new ClipboardImpl();
             }
 
             public void Dispose()
@@ -136,7 +186,8 @@ namespace Avalonia.iOS
                 // No-op
             }
 
-            public Compositor Compositor => Platform.Compositor;
+            public Compositor Compositor => Platform.Compositor
+                ?? throw new InvalidOperationException("iOS backend wasn't initialized. Make sure UseiOS was called.");
 
             public void Invalidate(Rect rect)
             {
@@ -185,8 +236,11 @@ namespace Avalonia.iOS
 
             public void SetFrameThemeVariant(PlatformThemeVariant themeVariant)
             {
+#if !TVOS
                 // TODO adjust status bar depending on full screen mode.
-                if (OperatingSystem.IsIOSVersionAtLeast(13) && _view._controller is not null)
+                if ((OperatingSystem.IsIOSVersionAtLeast(13)
+                    || OperatingSystem.IsMacCatalyst())
+                    && _view._controller is not null)
                 {
                     _view._controller.PreferredStatusBarStyle = themeVariant switch
                     {
@@ -195,6 +249,7 @@ namespace Avalonia.iOS
                         _ => UIStatusBarStyle.Default
                     };
                 }
+#endif
             }
             
             public AcrylicPlatformCompensationLevels AcrylicCompensationLevels { get; } =
@@ -202,11 +257,6 @@ namespace Avalonia.iOS
 
             public object? TryGetFeature(Type featureType)
             {
-                if (featureType == typeof(IStorageProvider))
-                {
-                    return _storageProvider;
-                }
-
                 if (featureType == typeof(ITextInputMethodImpl))
                 {
                     return _view;
@@ -227,9 +277,14 @@ namespace Avalonia.iOS
                     return _clipboard;
                 }
 
+                if (featureType == typeof(IStorageProvider))
+                {
+                    return _storageProvider;
+                }
+
                 if (featureType == typeof(IInputPane))
                 {
-                    return UIKitInputPane.Instance;
+                    return _inputPane;
                 }
 
                 return null;
@@ -239,20 +294,77 @@ namespace Avalonia.iOS
         [Export("layerClass")]
         public static Class LayerClass()
         {
-            return new Class(typeof(CAEAGLLayer));
+#if !MACCATALYST
+            if (Platform.Graphics is Eagl.EaglPlatformGraphics)
+            {
+                return new Class(typeof(CAEAGLLayer));
+            }
+            else
+#endif
+            {
+                return new Class(typeof(CAMetalLayer));
+            }
         }
 
-        public override void TouchesBegan(NSSet touches, UIEvent? evt) => _touches.Handle(touches, evt);
+        /// <inheritdoc/>
+        public override void TouchesBegan(NSSet touches, UIEvent? evt) => _input.Handle(touches, evt);
+
+        /// <inheritdoc/>
+        public override void TouchesMoved(NSSet touches, UIEvent? evt) => _input.Handle(touches, evt);
 
-        public override void TouchesMoved(NSSet touches, UIEvent? evt) => _touches.Handle(touches, evt);
+        /// <inheritdoc/>
+        public override void TouchesEnded(NSSet touches, UIEvent? evt) => _input.Handle(touches, evt);
 
-        public override void TouchesEnded(NSSet touches, UIEvent? evt) => _touches.Handle(touches, evt);
+        /// <inheritdoc/>
+        public override void TouchesCancelled(NSSet touches, UIEvent? evt) => _input.Handle(touches, evt);
 
-        public override void TouchesCancelled(NSSet touches, UIEvent? evt) => _touches.Handle(touches, evt);
+        /// <inheritdoc/>
+        public override void PressesBegan(NSSet<UIPress> presses, UIPressesEvent evt)
+        {
+            if (!_input.Handle(presses, evt))
+            {
+                base.PressesBegan(presses, evt);
+            }
+        }
 
+        /// <inheritdoc/>
+        public override void PressesChanged(NSSet<UIPress> presses, UIPressesEvent evt)
+        {
+            if (!_input.Handle(presses, evt))
+            {
+                base.PressesBegan(presses, evt);
+            }
+        }
+
+        /// <inheritdoc/>
+        public override void PressesEnded(NSSet<UIPress> presses, UIPressesEvent evt)
+        {
+            if (!_input.Handle(presses, evt))
+            {
+                base.PressesEnded(presses, evt);
+            }
+        }
+
+        /// <inheritdoc/>
+        public override void PressesCancelled(NSSet<UIPress> presses, UIPressesEvent evt)
+        {
+            if (!_input.Handle(presses, evt))
+            {
+                base.PressesCancelled(presses, evt);
+            }
+        }
+
+        /// <inheritdoc/>
         public override void LayoutSubviews()
         {
             _topLevelImpl.Resized?.Invoke(_topLevelImpl.ClientSize, WindowResizeReason.Layout);
+            var scaling = (double)ContentScaleFactor;
+            _latestLayoutProps = (new PixelSize((int)(Bounds.Width * scaling), (int)(Bounds.Height * scaling)), scaling);
+            if (_currentRenderTarget is not null)
+            {
+                _currentRenderTarget.PendingLayout = _latestLayoutProps;
+            }
+
             base.LayoutSubviews();
         }
 
@@ -261,5 +373,11 @@ namespace Avalonia.iOS
             get => (Control?)_topLevel.Content;
             set => _topLevel.Content = value;
         }
+
+        internal void SetRenderTarget(Metal.MetalRenderTarget target)
+        {
+            _currentRenderTarget = target;
+            _currentRenderTarget.PendingLayout = _latestLayoutProps;
+        }
     }
 }

+ 33 - 6
src/iOS/Avalonia.iOS/ClipboardImpl.cs

@@ -1,19 +1,21 @@
 using System;
+using System.Collections.Generic;
 using System.Threading.Tasks;
 using Avalonia.Input;
 using Avalonia.Input.Platform;
+using Foundation;
 using UIKit;
 
 namespace Avalonia.iOS
 {
     internal class ClipboardImpl : IClipboard
     {
-        public Task<string> GetTextAsync()
+        public Task<string?> GetTextAsync()
         {
             return Task.FromResult(UIPasteboard.General.String);
         }
 
-        public Task SetTextAsync(string text)
+        public Task SetTextAsync(string? text)
         {
             UIPasteboard.General.String = text;
             return Task.CompletedTask;
@@ -21,14 +23,39 @@ namespace Avalonia.iOS
 
         public Task ClearAsync()
         {
-            UIPasteboard.General.String = "";
+            UIPasteboard.General.Items = Array.Empty<NSDictionary>();
             return Task.CompletedTask;
         }
 
-        public Task SetDataObjectAsync(IDataObject data) => Task.CompletedTask;
+        public Task SetDataObjectAsync(IDataObject data)
+        {
+            if (data.Contains(DataFormats.Text))
+            {
+                UIPasteboard.General.String = data.GetText();
+            }
+
+            return Task.CompletedTask;
+        }
+
+        public Task<string[]> GetFormatsAsync()
+        {
+            var formats = new List<string>();
+            if (UIPasteboard.General.HasStrings)
+            {
+                formats.Add(DataFormats.Text);
+            }
 
-        public Task<string[]> GetFormatsAsync() => Task.FromResult(Array.Empty<string>());
+            return Task.FromResult(formats.ToArray());
+        }
 
-        public Task<object> GetDataAsync(string format) => Task.FromResult<object>(null);
+        public Task<object?> GetDataAsync(string format)
+        {
+            if (format == DataFormats.Text)
+            {
+                return Task.FromResult<object?>(UIPasteboard.General.String);
+            }
+
+            return Task.FromResult<object?>(null);
+        }
     }
 }

+ 0 - 2
src/iOS/Avalonia.iOS/DispatcherImpl.cs

@@ -1,5 +1,3 @@
-#nullable enable
-
 using System;
 using System.Diagnostics;
 using System.Runtime.InteropServices;

+ 2 - 2
src/iOS/Avalonia.iOS/DisplayLinkTimer.cs

@@ -11,7 +11,7 @@ namespace Avalonia.iOS
 {
     class DisplayLinkTimer : IRenderTimer
     {
-        public event Action<TimeSpan> Tick;
+        public event Action<TimeSpan>? Tick;
         private Stopwatch _st = Stopwatch.StartNew();
 
         public DisplayLinkTimer()
@@ -36,4 +36,4 @@ namespace Avalonia.iOS
             Tick?.Invoke(_st.Elapsed);
         }
     }
-}
+}

+ 30 - 9
src/iOS/Avalonia.iOS/EaglDisplay.cs → src/iOS/Avalonia.iOS/Eagl/EaglDisplay.cs

@@ -1,15 +1,19 @@
 using System;
 using System.Collections.Generic;
 using System.Runtime.Versioning;
+using Avalonia.Logging;
 using Avalonia.OpenGL;
 using Avalonia.Platform;
 using Avalonia.Reactive;
 using OpenGLES;
 
-namespace Avalonia.iOS
+namespace Avalonia.iOS.Eagl
 {
     [ObsoletedOSPlatform("ios12.0", "Use 'Metal' instead.")]
+    [ObsoletedOSPlatform("tvos12.0", "Use 'Metal' instead.")]
+    [UnsupportedOSPlatform("maccatalyst")]
     [SupportedOSPlatform("ios")]
+    [SupportedOSPlatform("tvos")]
     class EaglPlatformGraphics : IPlatformGraphics
     {
         public IPlatformGraphicsContext GetSharedContext() => Context;
@@ -19,7 +23,7 @@ namespace Avalonia.iOS
         public GlContext Context { get; }
         public static GlVersion GlVersion { get; } = new(GlProfileType.OpenGLES, 3, 0);
 
-        public EaglPlatformGraphics()
+        private EaglPlatformGraphics()
         {
             
             const string path = "/System/Library/Frameworks/OpenGLES.framework/OpenGLES";
@@ -29,15 +33,30 @@ namespace Avalonia.iOS
             var iface = new GlInterface(GlVersion, proc => ObjCRuntime.Dlfcn.dlsym(libGl, proc));
             Context = new(iface, null);
         }
+        
+        public static EaglPlatformGraphics? TryCreate()
+        {
+            try
+            {
+                return new EaglPlatformGraphics();
+            }
+            catch(Exception e)
+            {
+                Logger.TryGet(LogEventLevel.Error, "OpenGL")?.Log(null, "Unable to initialize EAGL-based rendering: {0}", e);
+                return null;
+            }
+        }
     }
 
     [ObsoletedOSPlatform("ios12.0", "Use 'Metal' instead.")]
+    [ObsoletedOSPlatform("tvos12.0", "Use 'Metal' instead.")]
     [SupportedOSPlatform("ios")]
+    [SupportedOSPlatform("tvos")]
     class GlContext : IGlContext
     {
-        public EAGLContext Context { get; private set; }
+        public EAGLContext? Context { get; private set; }
         
-        public GlContext(GlInterface glInterface, EAGLSharegroup sharegroup)
+        public GlContext(GlInterface glInterface, EAGLSharegroup? sharegroup)
         {
             GlInterface = glInterface;
             Context = sharegroup == null ?
@@ -53,10 +72,10 @@ namespace Avalonia.iOS
 
         class ResetContext : IDisposable
         {
-            private EAGLContext _old;
+            private EAGLContext? _old;
             private bool _disposed;
 
-            public ResetContext(EAGLContext old)
+            public ResetContext(EAGLContext? old)
             {
                 _old = old;
             }
@@ -87,7 +106,7 @@ namespace Avalonia.iOS
         {
             if (Context == null)
                 throw new PlatformGraphicsContextLostException();
-            if(EAGLContext.CurrentContext == Context)
+            if (EAGLContext.CurrentContext == Context)
                 return Disposable.Empty;
             return MakeCurrent();
         }
@@ -95,8 +114,10 @@ namespace Avalonia.iOS
         public bool IsSharedWith(IGlContext context) => context is GlContext other
             && ReferenceEquals(other.Context?.ShareGroup, Context?.ShareGroup);
         public bool CanCreateSharedContext => true;
-        public IGlContext CreateSharedContext(IEnumerable<GlVersion> preferredVersions = null)
+        public IGlContext CreateSharedContext(IEnumerable<GlVersion>? preferredVersions = null)
         {
+            if (Context == null)
+                throw new PlatformGraphicsContextLostException();
             return new GlContext(GlInterface, Context.ShareGroup);
         }
 
@@ -119,6 +140,6 @@ namespace Avalonia.iOS
             }
         }
 
-        public object TryGetFeature(Type featureType) => null;
+        public object? TryGetFeature(Type featureType) => null;
     }
 }

+ 7 - 5
src/iOS/Avalonia.iOS/EaglLayerSurface.cs → src/iOS/Avalonia.iOS/Eagl/EaglLayerSurface.cs

@@ -1,4 +1,3 @@
-
 using System;
 using System.Runtime.Versioning;
 using System.Threading;
@@ -6,10 +5,13 @@ using Avalonia.OpenGL;
 using Avalonia.OpenGL.Surfaces;
 using CoreAnimation;
 
-namespace Avalonia.iOS
+namespace Avalonia.iOS.Eagl
 {
     [ObsoletedOSPlatform("ios12.0", "Use 'Metal' instead.")]
+    [ObsoletedOSPlatform("tvos12.0", "Use 'Metal' instead.")]
     [SupportedOSPlatform("ios")]
+    [SupportedOSPlatform("tvos")]
+    [UnsupportedOSPlatform("maccatalyst")]
     class EaglLayerSurface : IGlPlatformSurface
     {
         private readonly CAEAGLLayer _layer;
@@ -77,19 +79,19 @@ namespace Avalonia.iOS
 
         static void CheckThread()
         {
-            if (Platform.Timer.TimerThread != Thread.CurrentThread)
+            if (Platform.Timer!.TimerThread != Thread.CurrentThread)
                 throw new InvalidOperationException("Invalid thread, go away");
         }
         
         public IGlPlatformSurfaceRenderTarget CreateGlRenderTarget(IGlContext context)
         {
             CheckThread();
-            var ctx = Platform.GlFeature.Context;
+            var ctx = ((EaglPlatformGraphics)Platform.Graphics!).Context;
             if (ctx != context)
                 throw new InvalidOperationException("Platform surface is only usable with tha main context");
             using (ctx.MakeCurrent())
             {
-                var fbo = new SizeSynchronizedLayerFbo(ctx.Context, ctx.GlInterface, _layer);
+                var fbo = new SizeSynchronizedLayerFbo(ctx.Context!, ctx.GlInterface, _layer);
                 if (!fbo.Sync())
                     throw new InvalidOperationException("Unable to create render target");
                 return new RenderTarget(ctx, fbo);

+ 12 - 6
src/iOS/Avalonia.iOS/LayerFbo.cs → src/iOS/Avalonia.iOS/Eagl/LayerFbo.cs

@@ -4,10 +4,13 @@ using Avalonia.OpenGL;
 using CoreAnimation;
 using OpenGLES;
 
-namespace Avalonia.iOS
+namespace Avalonia.iOS.Eagl
 {
     [ObsoletedOSPlatform("ios12.0", "Use 'Metal' instead.")]
+    [ObsoletedOSPlatform("tvos12.0", "Use 'Metal' instead.")]
     [SupportedOSPlatform("ios")]
+    [SupportedOSPlatform("tvos")]
+    [UnsupportedOSPlatform("maccatalyst")]
     internal class LayerFbo
     {
         private readonly EAGLContext _context;
@@ -28,7 +31,7 @@ namespace Avalonia.iOS
             _depthBuffer = depthBuffer;
         }
 
-        public static LayerFbo TryCreate(EAGLContext context, GlInterface gl, CAEAGLLayer layer)
+        public static LayerFbo? TryCreate(EAGLContext context, GlInterface gl, CAEAGLLayer layer)
         {
             if (context != EAGLContext.CurrentContext)
                 return null;
@@ -77,7 +80,7 @@ namespace Avalonia.iOS
         public void Present()
         {
             Bind();
-            var success = _context.PresentRenderBuffer(GlConsts.GL_RENDERBUFFER);
+            _context.PresentRenderBuffer(GlConsts.GL_RENDERBUFFER);
         }
 
         public void Dispose()
@@ -94,13 +97,16 @@ namespace Avalonia.iOS
     }
 
     [ObsoletedOSPlatform("ios12.0", "Use 'Metal' instead.")]
+    [ObsoletedOSPlatform("tvos12.0", "Use 'Metal' instead.")]
     [SupportedOSPlatform("ios")]
+    [SupportedOSPlatform("tvos")]
+    [UnsupportedOSPlatform("maccatalyst")]
     class SizeSynchronizedLayerFbo : IDisposable
     {
         private readonly EAGLContext _context;
         private readonly GlInterface _gl;
         private readonly CAEAGLLayer _layer;
-        private LayerFbo _fbo;
+        private LayerFbo? _fbo;
         private double _oldLayerWidth, _oldLayerHeight, _oldLayerScale;
         
         public SizeSynchronizedLayerFbo(EAGLContext context, GlInterface gl, CAEAGLLayer layer)
@@ -138,10 +144,10 @@ namespace Avalonia.iOS
         {
             if(!Sync())
                 throw new InvalidOperationException("Unable to create a render target");
-            _fbo.Bind();
+            _fbo!.Bind();
         }
 
-        public void Present() => _fbo.Present();
+        public void Present() => _fbo!.Present();
 
         public int Width => _fbo?.Width ?? 0;
         public int Height => _fbo?.Height ?? 0;

+ 432 - 0
src/iOS/Avalonia.iOS/InputHandler.cs

@@ -0,0 +1,432 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Collections.Pooled;
+using Avalonia.Input;
+using Avalonia.Input.Raw;
+using Avalonia.Platform;
+using Foundation;
+using UIKit;
+
+namespace Avalonia.iOS;
+
+internal sealed class InputHandler
+{
+    private static readonly PooledList<RawPointerPoint> s_intermediatePointsPooledList = new(ClearMode.Never);
+
+    private readonly AvaloniaView _view;
+    private readonly ITopLevelImpl _tl;
+    private readonly TouchDevice _touchDevice = new();
+    private readonly MouseDevice _mouseDevice = new();
+    private readonly PenDevice _penDevice = new();
+    private static long _nextTouchPointId = 1;
+    private readonly Dictionary<UITouch, long> _knownTouches = new();
+
+    public InputHandler(AvaloniaView view, ITopLevelImpl tl)
+    {
+        _view = view;
+        _tl = tl;
+    }
+
+    private static ulong Ts(UIEvent? evt) => evt is null ? 0 : (ulong)(evt.Timestamp * 1000);
+    private IInputRoot Root => _view.InputRoot;
+
+    public void Handle(NSSet touches, UIEvent? evt)
+    {
+        foreach (UITouch t in touches)
+        {
+            if (t.Type == UITouchType.Indirect)
+            {
+                // Ignore Indirect input, like remote controller trackpad.
+                // For Avalonia we handle it independently with gestures.
+                continue;
+            }
+
+            if (!_knownTouches.TryGetValue(t, out var id))
+                _knownTouches[t] = id = _nextTouchPointId++;
+
+            IInputDevice device = t.Type switch
+            {
+                UITouchType.Stylus => _penDevice,
+#pragma warning disable CA1416
+                UITouchType.IndirectPointer => _mouseDevice,
+#pragma warning restore CA1416
+                _ => _touchDevice
+            };
+
+            var modifiers = RawInputModifiers.None;
+            if (OperatingSystem.IsIOSVersionAtLeast(13, 4)
+                 || OperatingSystem.IsTvOSVersionAtLeast(13, 4))
+            {
+                modifiers = ConvertModifierKeys(evt?.ModifierFlags);
+            }
+            
+            var ev = new RawTouchEventArgs(device, Ts(evt), Root,
+                (device, t.Phase) switch
+                {
+                    (TouchDevice, UITouchPhase.Began) => RawPointerEventType.TouchBegin,
+                    (TouchDevice, UITouchPhase.Ended) => RawPointerEventType.TouchEnd,
+                    (TouchDevice, UITouchPhase.Cancelled) => RawPointerEventType.TouchCancel,
+                    (TouchDevice, _) => RawPointerEventType.TouchUpdate,
+                    
+                    (_, UITouchPhase.Began) => IsRightClick() ? RawPointerEventType.RightButtonDown : RawPointerEventType.LeftButtonDown,
+                    (_, UITouchPhase.Ended or UITouchPhase.Cancelled) => IsRightClick() ? RawPointerEventType.RightButtonUp : RawPointerEventType.RightButtonDown,
+                    (_, _) => RawPointerEventType.Move,
+                }, ToPointerPoint(t), modifiers, id)
+            {
+                IntermediatePoints = evt is {} thisEvent ? new Lazy<IReadOnlyList<RawPointerPoint>?>(() =>
+                {
+                    var coalesced = thisEvent.GetCoalescedTouches(t) ?? Array.Empty<UITouch>();
+                    s_intermediatePointsPooledList.Clear();
+                    s_intermediatePointsPooledList.Capacity = coalesced.Length - 1;
+
+                    // Skip the last one, as it is already processed point.
+                    for (var i = 0; i < coalesced.Length - 1; i++)
+                    {
+                        s_intermediatePointsPooledList.Add(ToPointerPoint(coalesced[i]));
+                    }
+
+                    return s_intermediatePointsPooledList;
+                }) : null
+            };
+
+            _tl.Input?.Invoke(ev);
+
+            if (t.Phase is UITouchPhase.Cancelled or UITouchPhase.Ended)
+                _knownTouches.Remove(t);
+
+            RawPointerPoint ToPointerPoint(UITouch touch) => new()
+            {
+                Position = touch.LocationInView(_view).ToAvalonia(),
+                // in iOS "1.0 represents the force of an average touch", when Avalonia expects 0.5 for "average".
+                // If MaximumPossibleForce is 0, we ignore it completely.
+                Pressure = t.MaximumPossibleForce == 0 ? 0.5f : (float)t.Force / 2
+            };
+
+            bool IsRightClick()
+#if !TVOS
+                => OperatingSystem.IsIOSVersionAtLeast(13, 4) && (evt?.ButtonMask.HasFlag(UIEventButtonMask.Secondary) ?? false);
+#else
+                => false;
+#endif
+        }
+    }
+
+    public bool Handle(NSSet<UIPress> presses, UIPressesEvent? evt)
+    {
+        var handled = false;
+        foreach (UIPress p in presses)
+        {
+            PhysicalKey physicalKey;
+            RawInputModifiers modifier = default;
+            string? characters = null;
+            KeyDeviceType keyDeviceType;
+
+            if ((OperatingSystem.IsIOSVersionAtLeast(13, 4)
+                || OperatingSystem.IsTvOSVersionAtLeast(13, 4))
+                && p.Key is { } uiKey
+                && s_keys.TryGetValue(uiKey.KeyCode, out physicalKey))
+            {
+                modifier = ConvertModifierKeys(uiKey.ModifierFlags);
+
+                keyDeviceType = KeyDeviceType.Keyboard; // very likely
+
+                if (!uiKey.Characters.StartsWith("UIKey"))
+                    characters = uiKey.Characters;
+            }
+            else
+            {
+                physicalKey = p.Type switch
+                {
+                    UIPressType.UpArrow => PhysicalKey.ArrowUp,
+                    UIPressType.DownArrow => PhysicalKey.ArrowDown,
+                    UIPressType.LeftArrow => PhysicalKey.ArrowLeft,
+                    UIPressType.RightArrow => PhysicalKey.ArrowRight,
+                    UIPressType.Select => PhysicalKey.Space,
+                    UIPressType.Menu => PhysicalKey.ContextMenu,
+                    UIPressType.PlayPause => PhysicalKey.MediaPlayPause,
+#pragma warning disable CA1416
+                    UIPressType.PageUp => PhysicalKey.PageUp,
+                    UIPressType.PageDown => PhysicalKey.PageDown,
+#pragma warning restore CA1416
+                    _ => PhysicalKey.None
+                };
+                keyDeviceType = KeyDeviceType.Remote; // very likely
+            }
+
+            var key = physicalKey.ToQwertyKey();
+            if (key == Key.None)
+                continue;
+
+            var ev = new RawKeyEventArgs(KeyboardDevice.Instance!, Ts(evt), Root,
+                p.Phase switch
+                {
+                    UIPressPhase.Began => RawKeyEventType.KeyDown,
+                    UIPressPhase.Changed => RawKeyEventType.KeyDown,
+                    UIPressPhase.Stationary => RawKeyEventType.KeyDown,
+                    UIPressPhase.Ended => RawKeyEventType.KeyUp,
+                    _ => RawKeyEventType.KeyUp
+                }, key, modifier, physicalKey, keyDeviceType, characters);
+
+            _tl.Input?.Invoke(ev);
+            handled |= ev.Handled;
+
+            if (!ev.Handled && p.Phase == UIPressPhase.Began && !string.IsNullOrEmpty(characters))
+            {
+                var rawTextEvent = new RawTextInputEventArgs(
+                    KeyboardDevice.Instance!,
+                    Ts(evt),
+                    _view.InputRoot,
+                    characters
+                );
+                _tl.Input?.Invoke(rawTextEvent);
+                handled |= rawTextEvent.Handled;
+            }
+        }
+
+        return handled;
+    }
+
+    public void Handle(UISwipeGestureRecognizer recognizer)
+    {
+        var handled = false;
+        var direction = recognizer.Direction;
+        var timestamp = 0UL; // todo
+
+        if (OperatingSystem.IsTvOS())
+        {
+            if (direction.HasFlag(UISwipeGestureRecognizerDirection.Up))
+                handled = handled || HandleNavigationKey(Key.Up);
+            if (direction.HasFlag(UISwipeGestureRecognizerDirection.Right))
+                handled = handled || HandleNavigationKey(Key.Right);
+            if (direction.HasFlag(UISwipeGestureRecognizerDirection.Down))
+                handled = handled || HandleNavigationKey(Key.Down);
+            if (direction.HasFlag(UISwipeGestureRecognizerDirection.Left))
+                handled = handled || HandleNavigationKey(Key.Left);
+        }
+
+        if (!handled)
+        {
+            // TODO raise RawPointerGestureEventArgs
+        }
+
+        bool HandleNavigationKey(Key key)
+        {
+            // Don't pass PhysicalKey, as physically it's just a touch gesture.
+            var ev = new RawKeyEventArgs(KeyboardDevice.Instance!, timestamp, Root,
+                RawKeyEventType.KeyDown, key, RawInputModifiers.None, PhysicalKey.None, KeyDeviceType.Remote, null);
+            _tl.Input?.Invoke(ev);
+            var handled = ev.Handled;
+
+            ev.Handled = false;
+            ev.Type = RawKeyEventType.KeyUp;
+            _tl.Input?.Invoke(ev);
+            handled |= ev.Handled;
+
+            return handled;
+        }
+    }
+
+    private static RawInputModifiers ConvertModifierKeys(UIKeyModifierFlags? uiModifier)
+    {
+        RawInputModifiers modifier = default;
+        if (uiModifier is { } flags)
+        {
+            if (flags.HasFlag(UIKeyModifierFlags.Shift))
+                modifier |= RawInputModifiers.Shift;
+            if (flags.HasFlag(UIKeyModifierFlags.Alternate))
+                modifier |= RawInputModifiers.Alt;
+            if (flags.HasFlag(UIKeyModifierFlags.Control))
+                modifier |= RawInputModifiers.Control;
+            if (flags.HasFlag(UIKeyModifierFlags.Command))
+                modifier |= RawInputModifiers.Meta;
+        }
+
+        return modifier;
+    }
+
+#pragma warning disable CA1416
+    private static Dictionary<UIKeyboardHidUsage, PhysicalKey> s_keys = new()
+    {
+        //[UIKeyboardHidUsage.KeyboardErrorRollOver] = PhysicalKey.None,
+        //[UIKeyboardHidUsage.KeyboardPostFail] = PhysicalKey.None,
+        //[UIKeyboardHidUsage.KeyboardErrorUndefined] = PhysicalKey.None,
+        [UIKeyboardHidUsage.KeyboardA] = PhysicalKey.A,
+        [UIKeyboardHidUsage.KeyboardB] = PhysicalKey.B,
+        [UIKeyboardHidUsage.KeyboardC] = PhysicalKey.C,
+        [UIKeyboardHidUsage.KeyboardD] = PhysicalKey.D,
+        [UIKeyboardHidUsage.KeyboardE] = PhysicalKey.E,
+        [UIKeyboardHidUsage.KeyboardF] = PhysicalKey.F,
+        [UIKeyboardHidUsage.KeyboardG] = PhysicalKey.G,
+        [UIKeyboardHidUsage.KeyboardH] = PhysicalKey.H,
+        [UIKeyboardHidUsage.KeyboardI] = PhysicalKey.I,
+        [UIKeyboardHidUsage.KeyboardJ] = PhysicalKey.J,
+        [UIKeyboardHidUsage.KeyboardK] = PhysicalKey.K,
+        [UIKeyboardHidUsage.KeyboardL] = PhysicalKey.L,
+        [UIKeyboardHidUsage.KeyboardM] = PhysicalKey.M,
+        [UIKeyboardHidUsage.KeyboardN] = PhysicalKey.N,
+        [UIKeyboardHidUsage.KeyboardO] = PhysicalKey.O,
+        [UIKeyboardHidUsage.KeyboardP] = PhysicalKey.P,
+        [UIKeyboardHidUsage.KeyboardQ] = PhysicalKey.Q,
+        [UIKeyboardHidUsage.KeyboardR] = PhysicalKey.R,
+        [UIKeyboardHidUsage.KeyboardS] = PhysicalKey.S,
+        [UIKeyboardHidUsage.KeyboardT] = PhysicalKey.T,
+        [UIKeyboardHidUsage.KeyboardU] = PhysicalKey.U,
+        [UIKeyboardHidUsage.KeyboardV] = PhysicalKey.V,
+        [UIKeyboardHidUsage.KeyboardW] = PhysicalKey.W,
+        [UIKeyboardHidUsage.KeyboardX] = PhysicalKey.X,
+        [UIKeyboardHidUsage.KeyboardY] = PhysicalKey.Y,
+        [UIKeyboardHidUsage.KeyboardZ] = PhysicalKey.Z,
+        [UIKeyboardHidUsage.Keyboard1] = PhysicalKey.Digit1,
+        [UIKeyboardHidUsage.Keyboard2] = PhysicalKey.Digit2,
+        [UIKeyboardHidUsage.Keyboard3] = PhysicalKey.Digit3,
+        [UIKeyboardHidUsage.Keyboard4] = PhysicalKey.Digit4,
+        [UIKeyboardHidUsage.Keyboard5] = PhysicalKey.Digit5,
+        [UIKeyboardHidUsage.Keyboard6] = PhysicalKey.Digit6,
+        [UIKeyboardHidUsage.Keyboard7] = PhysicalKey.Digit7,
+        [UIKeyboardHidUsage.Keyboard8] = PhysicalKey.Digit8,
+        [UIKeyboardHidUsage.Keyboard9] = PhysicalKey.Digit9,
+        [UIKeyboardHidUsage.Keyboard0] = PhysicalKey.Digit0,
+        [UIKeyboardHidUsage.KeyboardReturnOrEnter] = PhysicalKey.Enter,
+        [UIKeyboardHidUsage.KeyboardEscape] = PhysicalKey.Escape,
+        [UIKeyboardHidUsage.KeyboardDeleteOrBackspace] = PhysicalKey.Delete,
+        [UIKeyboardHidUsage.KeyboardTab] = PhysicalKey.Tab,
+        [UIKeyboardHidUsage.KeyboardSpacebar] = PhysicalKey.Space,
+        [UIKeyboardHidUsage.KeyboardHyphen] = PhysicalKey.NumPadSubtract,
+        [UIKeyboardHidUsage.KeyboardEqualSign] = PhysicalKey.NumPadEqual,
+        [UIKeyboardHidUsage.KeyboardOpenBracket] = PhysicalKey.BracketLeft,
+        [UIKeyboardHidUsage.KeyboardCloseBracket] = PhysicalKey.BracketRight,
+        [UIKeyboardHidUsage.KeyboardBackslash] = PhysicalKey.Backslash,
+        // [UIKeyboardHidUsage.KeyboardNonUSPound] = 50,
+        [UIKeyboardHidUsage.KeyboardSemicolon] = PhysicalKey.Semicolon,
+        [UIKeyboardHidUsage.KeyboardQuote] = PhysicalKey.Quote,
+        // [UIKeyboardHidUsage.KeyboardGraveAccentAndTilde] = 53,
+        [UIKeyboardHidUsage.KeyboardComma] = PhysicalKey.Comma,
+        [UIKeyboardHidUsage.KeyboardPeriod] = PhysicalKey.Period,
+        [UIKeyboardHidUsage.KeyboardSlash] = PhysicalKey.Slash,
+        [UIKeyboardHidUsage.KeyboardCapsLock] = PhysicalKey.CapsLock,
+        [UIKeyboardHidUsage.KeyboardF1] = PhysicalKey.F1,
+        [UIKeyboardHidUsage.KeyboardF2] = PhysicalKey.F2,
+        [UIKeyboardHidUsage.KeyboardF3] = PhysicalKey.F3,
+        [UIKeyboardHidUsage.KeyboardF4] = PhysicalKey.F4,
+        [UIKeyboardHidUsage.KeyboardF5] = PhysicalKey.F5,
+        [UIKeyboardHidUsage.KeyboardF6] = PhysicalKey.F6,
+        [UIKeyboardHidUsage.KeyboardF7] = PhysicalKey.F7,
+        [UIKeyboardHidUsage.KeyboardF8] = PhysicalKey.F8,
+        [UIKeyboardHidUsage.KeyboardF9] = PhysicalKey.F9,
+        [UIKeyboardHidUsage.KeyboardF10] = PhysicalKey.F10,
+        [UIKeyboardHidUsage.KeyboardF11] = PhysicalKey.F11,
+        [UIKeyboardHidUsage.KeyboardF12] = PhysicalKey.F12,
+        [UIKeyboardHidUsage.KeyboardPrintScreen] = PhysicalKey.PrintScreen,
+        [UIKeyboardHidUsage.KeyboardScrollLock] = PhysicalKey.ScrollLock,
+        [UIKeyboardHidUsage.KeyboardPause] = PhysicalKey.Pause,
+        [UIKeyboardHidUsage.KeyboardInsert] = PhysicalKey.Insert,
+        [UIKeyboardHidUsage.KeyboardHome] = PhysicalKey.Home,
+        [UIKeyboardHidUsage.KeyboardPageUp] = PhysicalKey.PageUp,
+        [UIKeyboardHidUsage.KeyboardDeleteForward] = PhysicalKey.Delete,
+        [UIKeyboardHidUsage.KeyboardEnd] = PhysicalKey.End,
+        [UIKeyboardHidUsage.KeyboardPageDown] = PhysicalKey.PageDown,
+        [UIKeyboardHidUsage.KeyboardRightArrow] = PhysicalKey.ArrowRight,
+        [UIKeyboardHidUsage.KeyboardLeftArrow] = PhysicalKey.ArrowLeft,
+        [UIKeyboardHidUsage.KeyboardDownArrow] = PhysicalKey.ArrowDown,
+        [UIKeyboardHidUsage.KeyboardUpArrow] = PhysicalKey.ArrowUp,
+        [UIKeyboardHidUsage.KeypadNumLock] = PhysicalKey.NumLock,
+        [UIKeyboardHidUsage.KeypadSlash] = PhysicalKey.Slash,
+        [UIKeyboardHidUsage.KeypadAsterisk] = PhysicalKey.NumPadMultiply,
+        [UIKeyboardHidUsage.KeypadHyphen] = PhysicalKey.NumPadSubtract,
+        [UIKeyboardHidUsage.KeypadPlus] = PhysicalKey.NumPadAdd,
+        [UIKeyboardHidUsage.KeypadEnter] = PhysicalKey.Enter,
+        [UIKeyboardHidUsage.Keypad1] = PhysicalKey.NumPad1,
+        [UIKeyboardHidUsage.Keypad2] = PhysicalKey.NumPad2,
+        [UIKeyboardHidUsage.Keypad3] = PhysicalKey.NumPad3,
+        [UIKeyboardHidUsage.Keypad4] = PhysicalKey.NumPad4,
+        [UIKeyboardHidUsage.Keypad5] = PhysicalKey.NumPad5,
+        [UIKeyboardHidUsage.Keypad6] = PhysicalKey.NumPad6,
+        [UIKeyboardHidUsage.Keypad7] = PhysicalKey.NumPad7,
+        [UIKeyboardHidUsage.Keypad8] = PhysicalKey.NumPad8,
+        [UIKeyboardHidUsage.Keypad9] = PhysicalKey.NumPad9,
+        [UIKeyboardHidUsage.Keypad0] = PhysicalKey.NumPad0,
+        [UIKeyboardHidUsage.KeypadPeriod] = PhysicalKey.Period,
+        [UIKeyboardHidUsage.KeyboardNonUSBackslash] = PhysicalKey.IntlBackslash,
+        //[UIKeyboardHidUsage.KeyboardApplication] = 101,
+        //[UIKeyboardHidUsage.KeyboardPower] = 102,
+        //[UIKeyboardHidUsage.KeypadEqualSign] = 103,
+        [UIKeyboardHidUsage.KeyboardF13] = PhysicalKey.F13,
+        [UIKeyboardHidUsage.KeyboardF14] = PhysicalKey.F14,
+        [UIKeyboardHidUsage.KeyboardF15] = PhysicalKey.F15,
+        [UIKeyboardHidUsage.KeyboardF16] = PhysicalKey.F16,
+        [UIKeyboardHidUsage.KeyboardF17] = PhysicalKey.F17,
+        [UIKeyboardHidUsage.KeyboardF18] = PhysicalKey.F18,
+        [UIKeyboardHidUsage.KeyboardF19] = PhysicalKey.F19,
+        [UIKeyboardHidUsage.KeyboardF20] = PhysicalKey.F20,
+        [UIKeyboardHidUsage.KeyboardF21] = PhysicalKey.F21,
+        [UIKeyboardHidUsage.KeyboardF22] = PhysicalKey.F22,
+        [UIKeyboardHidUsage.KeyboardF23] = PhysicalKey.F23,
+        [UIKeyboardHidUsage.KeyboardF24] = PhysicalKey.F24,
+        //[UIKeyboardHidUsage.KeyboardExecute] = 116,
+        //[UIKeyboardHidUsage.KeyboardHelp] = 117,
+        //[UIKeyboardHidUsage.KeyboardMenu] = 118,
+        [UIKeyboardHidUsage.KeyboardSelect] = PhysicalKey.Space,
+        //[UIKeyboardHidUsage.KeyboardStop] = 120,
+        //[UIKeyboardHidUsage.KeyboardAgain] = 121,
+        //[UIKeyboardHidUsage.KeyboardUndo] = 122,
+        //[UIKeyboardHidUsage.KeyboardCut] = 123,
+        //[UIKeyboardHidUsage.KeyboardCopy] = 124,
+        //[UIKeyboardHidUsage.KeyboardPaste] = 125,
+        //[UIKeyboardHidUsage.KeyboardFind] = 126,
+        [UIKeyboardHidUsage.KeyboardMute] = PhysicalKey.AudioVolumeMute,
+        [UIKeyboardHidUsage.KeyboardVolumeUp] = PhysicalKey.AudioVolumeUp,
+        [UIKeyboardHidUsage.KeyboardVolumeDown] = PhysicalKey.AudioVolumeDown,
+        //[UIKeyboardHidUsage.KeyboardLockingCapsLock] = PhysicalKey.CapsLock,
+        //[UIKeyboardHidUsage.KeyboardLockingNumLock] = PhysicalKey.Space,
+        //[UIKeyboardHidUsage.KeyboardLockingScrollLock] = 132,
+        [UIKeyboardHidUsage.KeypadComma] = PhysicalKey.NumPadComma,
+        //[UIKeyboardHidUsage.KeypadEqualSignAS400] = 134,
+        //[UIKeyboardHidUsage.KeyboardInternational1] = 135,
+        //[UIKeyboardHidUsage.KeyboardInternational2] = 136,
+        //[UIKeyboardHidUsage.KeyboardInternational3] = 137,
+        //[UIKeyboardHidUsage.KeyboardInternational4] = 138,
+        //[UIKeyboardHidUsage.KeyboardInternational5] = 139,
+        //[UIKeyboardHidUsage.KeyboardInternational6] = 140,
+        //[UIKeyboardHidUsage.KeyboardInternational7] = 141,
+        //[UIKeyboardHidUsage.KeyboardInternational8] = 142,
+        //[UIKeyboardHidUsage.KeyboardInternational9] = 143,
+        //[UIKeyboardHidUsage.KeyboardHangul] = 144,
+        //[UIKeyboardHidUsage.KeyboardKanaSwitch] = 144,
+        //[UIKeyboardHidUsage.KeyboardLang1] = 144,
+        //[UIKeyboardHidUsage.KeyboardAlphanumericSwitch] = 145,
+        //[UIKeyboardHidUsage.KeyboardHanja] = 145,
+        //[UIKeyboardHidUsage.KeyboardLang2] = 145,
+        //[UIKeyboardHidUsage.KeyboardKatakana] = 146,
+        //[UIKeyboardHidUsage.KeyboardLang3] = 146,
+        //[UIKeyboardHidUsage.KeyboardHiragana] = 147,
+        //[UIKeyboardHidUsage.KeyboardLang4] = 147,
+        //[UIKeyboardHidUsage.KeyboardLang5] = 148,
+        //[UIKeyboardHidUsage.KeyboardZenkakuHankakuKanji] = 148,
+        //[UIKeyboardHidUsage.KeyboardLang6] = 149,
+        //[UIKeyboardHidUsage.KeyboardLang7] = 150,
+        //[UIKeyboardHidUsage.KeyboardLang8] = 151,
+        //[UIKeyboardHidUsage.KeyboardLang9] = 152,
+        //[UIKeyboardHidUsage.KeyboardAlternateErase] = 153,
+        //[UIKeyboardHidUsage.KeyboardSysReqOrAttention] = 154,
+        //[UIKeyboardHidUsage.KeyboardCancel] = PhysicalKey.Cancel,
+        //[UIKeyboardHidUsage.KeyboardClear] = PhysicalKey.NumPadClear,
+        //[UIKeyboardHidUsage.KeyboardPrior] = PhysicalKey.Prior,
+        //[UIKeyboardHidUsage.KeyboardReturn] = PhysicalKey.Return,
+        //[UIKeyboardHidUsage.KeyboardSeparator] = PhysicalKey.Separator,
+        //[UIKeyboardHidUsage.KeyboardOut] = 160,
+        //[UIKeyboardHidUsage.KeyboardOper] = 161,
+        //[UIKeyboardHidUsage.KeyboardClearOrAgain] = 162,
+        //[UIKeyboardHidUsage.KeyboardCrSelOrProps] = 163,
+        //[UIKeyboardHidUsage.KeyboardExSel] = 164,
+        [UIKeyboardHidUsage.KeyboardLeftControl] = PhysicalKey.ControlLeft,
+        [UIKeyboardHidUsage.KeyboardLeftShift] = PhysicalKey.ShiftLeft,
+        [UIKeyboardHidUsage.KeyboardLeftAlt] = PhysicalKey.AltLeft,
+        [UIKeyboardHidUsage.KeyboardLeftGui] = PhysicalKey.MetaLeft,
+        [UIKeyboardHidUsage.KeyboardRightControl] = PhysicalKey.ControlRight,
+        [UIKeyboardHidUsage.KeyboardRightShift] = PhysicalKey.ShiftRight,
+        [UIKeyboardHidUsage.KeyboardRightAlt] = PhysicalKey.AltRight,
+        [UIKeyboardHidUsage.KeyboardRightGui] = PhysicalKey.MetaRight,
+        //[UIKeyboardHidUsage.KeyboardReserved] = 65535,
+    };
+#pragma warning restore CA1416
+}

+ 0 - 8
src/iOS/Avalonia.iOS/InsetsManager.cs

@@ -1,22 +1,14 @@
 using System;
 using Avalonia.Controls.Platform;
 using Avalonia.Media;
-using UIKit;
 
 namespace Avalonia.iOS;
-#nullable enable
 
 internal class InsetsManager : IInsetsManager
 {
-    private readonly AvaloniaView _view;
     private IAvaloniaViewController? _controller;
     private bool _displayEdgeToEdge = true;
 
-    public InsetsManager(AvaloniaView view)
-    {
-        _view = view;
-    }
-
     internal void InitWithController(IAvaloniaViewController controller)
     {
         _controller = controller;

+ 34 - 0
src/iOS/Avalonia.iOS/Metal/MetalDevice.cs

@@ -0,0 +1,34 @@
+using System;
+using System.Runtime.Versioning;
+using Avalonia.Metal;
+using Avalonia.Utilities;
+using Metal;
+
+namespace Avalonia.iOS.Metal;
+
+internal class MetalDevice : IMetalDevice
+{
+    private readonly DisposableLock _syncRoot = new();
+
+    public MetalDevice(IMTLDevice device)
+    {
+        Device = device;
+        Queue = device.CreateCommandQueue()
+            ?? throw new InvalidOperationException("IMTLCommandQueue is not available");
+    }
+
+    public IMTLDevice Device { get; }
+    public IMTLCommandQueue Queue { get; }
+    IntPtr IMetalDevice.Device => Device.Handle;
+    IntPtr IMetalDevice.CommandQueue => Queue.Handle;
+
+    public bool IsLost => false;
+
+    public IDisposable EnsureCurrent() => _syncRoot.Lock();
+    public object? TryGetFeature(Type featureType) => null;
+
+    public void Dispose()
+    {
+        Queue.Dispose();
+    }
+}

+ 34 - 0
src/iOS/Avalonia.iOS/Metal/MetalDrawingSession.cs

@@ -0,0 +1,34 @@
+using System;
+using Avalonia.Metal;
+using CoreAnimation;
+
+namespace Avalonia.iOS.Metal;
+
+internal class MetalDrawingSession : IMetalPlatformSurfaceRenderingSession
+{
+    private readonly MetalDevice _device;
+    private readonly ICAMetalDrawable _drawable;
+
+    public MetalDrawingSession(MetalDevice device, ICAMetalDrawable drawable, PixelSize size, double scaling)
+    {
+        _device = device;
+        _drawable = drawable;
+        Size = size;
+        Scaling = scaling;
+        Texture = _drawable.Texture.Handle;
+    }
+
+    public void Dispose()
+    {
+        var buffer = _device.Queue.CommandBuffer();
+        buffer!.PresentDrawable(_drawable);
+        buffer.Commit();
+    }
+
+    public IntPtr Texture { get; }
+    public PixelSize Size { get; }
+
+    public double Scaling { get; }
+
+    public bool IsYFlipped => false;
+}

+ 49 - 0
src/iOS/Avalonia.iOS/Metal/MetalPlatformGraphics.cs

@@ -0,0 +1,49 @@
+using System;
+using System.Runtime.Versioning;
+using Avalonia.Platform;
+using Metal;
+using SkiaSharp;
+
+namespace Avalonia.iOS.Metal;
+
+[SupportedOSPlatform("ios")]
+[SupportedOSPlatform("macos")]
+[SupportedOSPlatform("maccatalyst")]
+[SupportedOSPlatform("tvos")]
+internal class MetalPlatformGraphics : IPlatformGraphics
+{
+    private readonly IMTLDevice _defaultDevice;
+
+    private MetalPlatformGraphics(IMTLDevice defaultDevice)
+    {
+        _defaultDevice = defaultDevice;
+    }
+    
+    public bool UsesSharedContext => false;
+    public IPlatformGraphicsContext CreateContext() => new MetalDevice(_defaultDevice);
+
+    public IPlatformGraphicsContext GetSharedContext() => throw new NotSupportedException();
+
+    public static MetalPlatformGraphics? TryCreate()
+    {
+        var device = MTLDevice.SystemDefault;
+        if (device is null)
+        {
+            // Can be null on unsupported OS versions.
+            return null;
+        }
+
+#if !TVOS
+        using var queue = device.CreateCommandQueue();
+        using var context = GRContext.CreateMetal(new GRMtlBackendContext { Device = device, Queue = queue });
+        if (context is null)
+        {
+            // Can be null on macCatalyst because of older Skia bug.
+            // Fixed in SkiaSharp 3.0
+            return null;
+        }
+#endif
+
+        return new MetalPlatformGraphics(device);
+    }
+}

+ 25 - 0
src/iOS/Avalonia.iOS/Metal/MetalPlatformSurface.cs

@@ -0,0 +1,25 @@
+using Avalonia.Metal;
+using CoreAnimation;
+
+namespace Avalonia.iOS.Metal;
+
+internal class MetalPlatformSurface : IMetalPlatformSurface
+{
+    private readonly CAMetalLayer _layer;
+    private readonly AvaloniaView _avaloniaView;
+
+    public MetalPlatformSurface(CAMetalLayer layer, AvaloniaView avaloniaView)
+    {
+        _layer = layer;
+        _avaloniaView = avaloniaView;
+    }
+    public IMetalPlatformSurfaceRenderTarget CreateMetalRenderTarget(IMetalDevice device)
+    {
+        var dev = (MetalDevice)device;
+        _layer.Device = dev.Device;
+
+        var target = new MetalRenderTarget(_layer, dev);
+        _avaloniaView.SetRenderTarget(target);
+        return target;
+    }
+}

+ 37 - 0
src/iOS/Avalonia.iOS/Metal/MetalRenderTarget.cs

@@ -0,0 +1,37 @@
+using Avalonia.Metal;
+using Avalonia.Platform;
+using CoreAnimation;
+using CoreGraphics;
+
+namespace Avalonia.iOS.Metal;
+
+internal class MetalRenderTarget : IMetalPlatformSurfaceRenderTarget
+{
+    private readonly CAMetalLayer _layer;
+    private readonly MetalDevice _device;
+    private (PixelSize size, double scaling) _lastLayout;
+
+    public MetalRenderTarget(CAMetalLayer layer, MetalDevice device)
+    {
+        _layer = layer;
+        _device = device;
+    }
+
+    public (PixelSize size, double scaling) PendingLayout { get; set; } = (new PixelSize(1, 1), 1);
+    public void Dispose()
+    {
+    }
+
+    public IMetalPlatformSurfaceRenderingSession BeginRendering()
+    {
+        var (size, scaling) = PendingLayout;
+        if (_lastLayout != (size, scaling))
+        {
+            _lastLayout = (size, scaling);
+            _layer.DrawableSize = new CGSize(size.Width, size.Height);
+        }
+
+        var drawable = _layer.NextDrawable() ?? throw new PlatformGraphicsContextLostException();
+        return new MetalDrawingSession(_device, drawable, size, scaling);
+    }
+}

+ 1 - 3
src/iOS/Avalonia.iOS/NativeControlHostImpl.cs

@@ -1,6 +1,4 @@
-#nullable enable
-
-using System;
+using System;
 using System.Diagnostics.CodeAnalysis;
 using Avalonia.Controls.Platform;
 using Avalonia.Platform;

+ 68 - 9
src/iOS/Avalonia.iOS/Platform.cs

@@ -1,9 +1,8 @@
 using System;
-
-using Avalonia.Controls;
+using System.Collections.Generic;
+using System.Linq;
 using Avalonia.Input;
 using Avalonia.Input.Platform;
-using Avalonia.OpenGL;
 using Avalonia.Platform;
 using Avalonia.Rendering;
 using Avalonia.Rendering.Composition;
@@ -11,6 +10,33 @@ using Avalonia.Threading;
 
 namespace Avalonia
 {
+    public enum iOSRenderingMode
+    {
+        /// <summary>
+        /// Enables EaGL rendering for iOS and tvOS. Not supported on macCatalyst.
+        /// </summary>
+        OpenGl = 1,
+        
+        /// <summary>
+        /// Enables Metal rendering for all apple targets. Not stable and currently only works on iOS.
+        /// </summary>
+        Metal
+    }
+
+    public class iOSPlatformOptions
+    {
+        /// <summary>
+        /// Gets or sets Avalonia rendering modes with fallbacks.
+        /// The first element in the array has the highest priority.
+        /// The default value is: <see cref="iOSRenderingMode.OpenGl"/>. 
+        /// </summary>
+        /// <exception cref="System.InvalidOperationException">Thrown if no values were matched.</exception>
+        public IReadOnlyList<iOSRenderingMode> RenderingMode { get; set; } = new[]
+        {
+            iOSRenderingMode.OpenGl, iOSRenderingMode.Metal
+        };
+    }
+
     public static class IOSApplicationExtensions
     {
         public static AppBuilder UseiOS(this AppBuilder builder)
@@ -27,18 +53,21 @@ namespace Avalonia.iOS
 {
     static class Platform
     {
-        public static EaglPlatformGraphics GlFeature;
-        public static DisplayLinkTimer Timer;
-        internal static Compositor Compositor { get; private set; }
+        public static iOSPlatformOptions? Options;
+        public static IPlatformGraphics? Graphics;
+        public static DisplayLinkTimer? Timer;
+        internal static Compositor? Compositor { get; private set; }
 
         public static void Register()
         {
-            GlFeature ??= new EaglPlatformGraphics();
+            Options = AvaloniaLocator.Current.GetService<iOSPlatformOptions>() ?? new iOSPlatformOptions();
+
+            Graphics = InitializeGraphics(Options);
             Timer ??= new DisplayLinkTimer();
             var keyboard = new KeyboardDevice();
 
             AvaloniaLocator.CurrentMutable
-                .Bind<IPlatformGraphics>().ToConstant((IPlatformGraphics) GlFeature)
+                .Bind<IPlatformGraphics>().ToConstant(Graphics)
                 .Bind<ICursorFactory>().ToConstant(new CursorFactoryStub())
                 .Bind<IWindowingPlatform>().ToConstant(new WindowingPlatformStub())
                 .Bind<IPlatformSettings>().ToSingleton<PlatformSettings>()
@@ -48,7 +77,37 @@ namespace Avalonia.iOS
                 .Bind<IDispatcherImpl>().ToConstant(DispatcherImpl.Instance)
                 .Bind<IKeyboardDevice>().ToConstant(keyboard);
 
-                Compositor = new Compositor(AvaloniaLocator.Current.GetService<IPlatformGraphics>());
+            Compositor = new Compositor(AvaloniaLocator.Current.GetService<IPlatformGraphics>());
+        }
+
+        private static IPlatformGraphics InitializeGraphics(iOSPlatformOptions opts)
+        {
+            if (opts.RenderingMode is null || !opts.RenderingMode.Any())
+            {
+                throw new InvalidOperationException($"{nameof(iOSPlatformOptions)}.{nameof(iOSPlatformOptions.RenderingMode)} must not be empty or null");
+            }
+
+            foreach (var renderingMode in opts.RenderingMode)
+            {
+#if !MACCATALYST
+                if (renderingMode == iOSRenderingMode.OpenGl
+                    && !OperatingSystem.IsMacCatalyst()
+#pragma warning disable CA1422
+                    && Eagl.EaglPlatformGraphics.TryCreate() is { } eaglGraphics)
+#pragma warning restore CA1422
+                {
+                    return eaglGraphics;
+                }
+#endif
+
+                if (renderingMode == iOSRenderingMode.Metal
+                    && Metal.MetalPlatformGraphics.TryCreate() is { } metalGraphics)
+                {
+                    return metalGraphics;
+                }
+            }
+
+            throw new InvalidOperationException($"{nameof(iOSPlatformOptions)}.{nameof(iOSPlatformOptions.RenderingMode)} has a value of \"{string.Join(", ", opts.RenderingMode)}\", but no options were applied.");
         }
     }
 }

+ 0 - 1
src/iOS/Avalonia.iOS/PlatformSettings.cs

@@ -1,4 +1,3 @@
-#nullable enable
 using System;
 using Avalonia.Media;
 using Avalonia.Platform;

+ 6 - 6
src/iOS/Avalonia.iOS/SingleViewLifetime.cs

@@ -12,16 +12,16 @@ internal class SingleViewLifetime : ISingleViewApplicationLifetime, IActivatable
         avaloniaAppDelegate.Deactivated += (_, args) => Deactivated?.Invoke(this, args);
     }
             
-    public AvaloniaView View;
+    public AvaloniaView? View;
 
-    public Control MainView
+    public Control? MainView
     {
-        get => View.Content;
-        set => View.Content = value;
+        get => View!.Content;
+        set => View!.Content = value;
     }
 
-    public event EventHandler<ActivatedEventArgs> Activated;
-    public event EventHandler<ActivatedEventArgs> Deactivated;
+    public event EventHandler<ActivatedEventArgs>? Activated;
+    public event EventHandler<ActivatedEventArgs>? Deactivated;
     public bool TryLeaveBackground() => false;
     public bool TryEnterBackground() => false;
 }

+ 8 - 7
src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs

@@ -9,8 +9,6 @@ using Foundation;
 
 using UIKit;
 
-#nullable enable
-
 namespace Avalonia.iOS.Storage;
 
 internal abstract class IOSStorageItem : IStorageBookmarkItem
@@ -56,7 +54,10 @@ internal abstract class IOSStorageItem : IStorageBookmarkItem
 
         var properties = attributes is null ?
             new StorageItemProperties() :
-            new StorageItemProperties(attributes.Size, (DateTime)attributes.CreationDate, (DateTime)attributes.ModificationDate);
+            new StorageItemProperties(
+                attributes.Size,
+                attributes.CreationDate is { } creationDate ? (DateTime)creationDate : null,
+                attributes.ModificationDate is { } modificationDate ? (DateTime)modificationDate : null);
 
         return Task.FromResult(properties);
     }
@@ -82,7 +83,7 @@ internal abstract class IOSStorageItem : IStorageBookmarkItem
         }
     }
 
-    public async Task<IStorageItem?> MoveAsync(IStorageFolder destination)
+    public Task<IStorageItem?> MoveAsync(IStorageFolder destination)
     {
         if (destination is not IOSStorageFolder folder)
         {
@@ -99,9 +100,9 @@ internal abstract class IOSStorageItem : IStorageBookmarkItem
 
             if (NSFileManager.DefaultManager.Move(Url, newPath, out var error))
             {
-                return isDir
+                return Task.FromResult<IStorageItem?>(isDir
                     ? new IOSStorageFolder(newPath)
-                    : new IOSStorageFile(newPath);
+                    : new IOSStorageFile(newPath));
             }
 
             if (error is not null)
@@ -109,7 +110,7 @@ internal abstract class IOSStorageItem : IStorageBookmarkItem
                 throw new NSErrorException(error);
             }
 
-            return null;
+            return Task.FromResult<IStorageItem?>(null);
         }
         finally
         {

+ 4 - 2
src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs

@@ -12,8 +12,6 @@ using UniformTypeIdentifiers;
 using UTTypeLegacy = MobileCoreServices.UTType;
 using UTType = UniformTypeIdentifiers.UTType;
 
-#nullable enable
-
 namespace Avalonia.iOS.Storage;
 
 internal class IOSStorageProvider : IStorageProvider
@@ -68,8 +66,10 @@ internal class IOSStorageProvider : IStorageProvider
             var allowedUtils = options.FileTypeFilter?.SelectMany(f => f.AppleUniformTypeIdentifiers ?? Array.Empty<string>())
                 .ToArray() ?? new[]
             {
+#pragma warning disable CA1422
                 UTTypeLegacy.Content,
                 UTTypeLegacy.Item,
+#pragma warning restore CA1422
                 "public.data"
             };
             documentPicker = new UIDocumentPickerViewController(allowedUtils, UIDocumentPickerMode.Open);
@@ -148,7 +148,9 @@ internal class IOSStorageProvider : IStorageProvider
     {
         using var documentPicker = OperatingSystem.IsIOSVersionAtLeast(14) ?
             new UIDocumentPickerViewController(new[] { UTTypes.Folder }, false) :
+#pragma warning disable CA1422
             new UIDocumentPickerViewController(new string[] { UTTypeLegacy.Folder }, UIDocumentPickerMode.Open);
+#pragma warning restore CA1422
 
         if (OperatingSystem.IsIOSVersionAtLeast(13))
         {

+ 2 - 1
src/iOS/Avalonia.iOS/Stubs.cs

@@ -12,6 +12,7 @@ namespace Avalonia.iOS
 
         private class CursorImplStub : ICursorImpl
         {
+            public CursorImplStub(){}
             public void Dispose() { }
         }
     }
@@ -22,7 +23,7 @@ namespace Avalonia.iOS
 
         public IWindowImpl CreateEmbeddableWindow() => throw new NotSupportedException();
 
-        public ITrayIconImpl CreateTrayIcon() => null;
+        public ITrayIconImpl? CreateTrayIcon() => null;
     }
     
     internal class PlatformIconLoaderStub : IPlatformIconLoader

+ 0 - 1
src/iOS/Avalonia.iOS/TextInputResponder.Properties.cs

@@ -1,4 +1,3 @@
-#nullable enable
 using Avalonia.Input.TextInput;
 using Foundation;
 using UIKit;

+ 6 - 3
src/iOS/Avalonia.iOS/TextInputResponder.cs

@@ -14,8 +14,6 @@ using UIKit;
 
 namespace Avalonia.iOS;
 
-#nullable enable
-
 partial class AvaloniaView
 {
 
@@ -108,7 +106,12 @@ partial class AvaloniaView
         {
             get
             {
-                var mode = UITextInputMode.CurrentInputMode;
+                UITextInputMode? mode = null;
+#if !TVOS
+#pragma warning disable CA1422
+                mode = UITextInputMode.CurrentInputMode;
+#pragma warning restore CA1422
+#endif
                 // Can be empty see https://developer.apple.com/documentation/uikit/uitextinputmode/1614522-activeinputmodes
                 if (mode is null && UITextInputMode.ActiveInputModes.Length > 0)
                 {

+ 0 - 52
src/iOS/Avalonia.iOS/TouchHandler.cs

@@ -1,52 +0,0 @@
-using System.Collections.Generic;
-using Avalonia.Input;
-using Avalonia.Input.Raw;
-using Avalonia.Platform;
-using Foundation;
-using UIKit;
-
-namespace Avalonia.iOS
-{
-    class TouchHandler
-    {
-        private readonly AvaloniaView _view;
-        private readonly ITopLevelImpl _tl;
-        public TouchDevice _device = new TouchDevice();
-
-        public TouchHandler(AvaloniaView view, ITopLevelImpl tl)
-        {
-            _view = view;
-            _tl = tl;
-        }
-
-        static ulong Ts(UIEvent evt) => (ulong) (evt.Timestamp * 1000);
-        private IInputRoot Root => _view.InputRoot;
-        private static long _nextTouchPointId = 1;
-        private Dictionary<UITouch, long> _knownTouches = new Dictionary<UITouch, long>();
-
-        public void Handle(NSSet touches, UIEvent evt)
-        {
-            foreach (UITouch t in touches)
-            {
-                var pt = t.LocationInView(_view).ToAvalonia();
-                if (!_knownTouches.TryGetValue(t, out var id))
-                    _knownTouches[t] = id = _nextTouchPointId++;
-
-                var ev = new RawTouchEventArgs(_device, Ts(evt), Root,
-                    t.Phase switch
-                    {
-                        UITouchPhase.Began => RawPointerEventType.TouchBegin,
-                        UITouchPhase.Ended => RawPointerEventType.TouchEnd,
-                        UITouchPhase.Cancelled => RawPointerEventType.TouchCancel,
-                        _ => RawPointerEventType.TouchUpdate
-                    }, pt, RawInputModifiers.None, id);
-
-                _tl.Input?.Invoke(ev);
-                
-                if (t.Phase == UITouchPhase.Cancelled || t.Phase == UITouchPhase.Ended)
-                    _knownTouches.Remove(t);
-            }
-        }
-        
-    }
-}

+ 10 - 2
src/iOS/Avalonia.iOS/UIKitInputPane.cs

@@ -1,13 +1,16 @@
 using System;
 using System.Diagnostics;
+using System.Runtime.Versioning;
 using Avalonia.Animation.Easings;
 using Avalonia.Controls.Platform;
 using Foundation;
 using UIKit;
 
-#nullable enable
 namespace Avalonia.iOS;
 
+[UnsupportedOSPlatform("tvos")]
+[SupportedOSPlatform("maccatalyst")]
+[SupportedOSPlatform("ios")]
 internal sealed class UIKitInputPane : IInputPane
 {
     public static UIKitInputPane Instance { get; } = new();
@@ -33,7 +36,11 @@ internal sealed class UIKitInputPane : IInputPane
     private void RaiseEventFromNotification(bool isUp, NSNotification notification)
     {
         State = isUp ? InputPaneState.Open : InputPaneState.Closed;
-
+#if MACCATALYST
+        OccludedRect = default;
+        StateChanged?.Invoke(this, new InputPaneStateEventArgs(
+            State, null, OccludedRect));
+#else
         var startFrame = UIKeyboard.FrameBeginFromNotification(notification);
         var endFrame = UIKeyboard.FrameEndFromNotification(notification);
         var duration = UIKeyboard.AnimationDurationFromNotification(notification);
@@ -50,5 +57,6 @@ internal sealed class UIKitInputPane : IInputPane
 
         StateChanged?.Invoke(this, new InputPaneStateEventArgs(
             State, startRect, OccludedRect, TimeSpan.FromSeconds(duration), easing));
+#endif
     }
 }

+ 10 - 2
src/iOS/Avalonia.iOS/ViewController.cs

@@ -7,16 +7,20 @@ namespace Avalonia.iOS;
 [Unstable]
 public interface IAvaloniaViewController
 {
+#if !TVOS
     UIStatusBarStyle PreferredStatusBarStyle { get; set; }
+#endif
     bool PrefersStatusBarHidden { get; set; }
     Thickness SafeAreaPadding { get; }
-    event EventHandler SafeAreaPaddingChanged;
+    event EventHandler? SafeAreaPaddingChanged;
 }
 
 /// <inheritdoc cref="IAvaloniaViewController" />
 public class DefaultAvaloniaViewController : UIViewController, IAvaloniaViewController
 {
+#if !TVOS
     private UIStatusBarStyle? _preferredStatusBarStyle;
+#endif
     private bool? _prefersStatusBarHidden;
     
     /// <inheritdoc/>
@@ -33,6 +37,7 @@ public class DefaultAvaloniaViewController : UIViewController, IAvaloniaViewCont
         }
     }
 
+#if !TVOS
     /// <inheritdoc/>
     public override bool PrefersStatusBarHidden()
     {
@@ -55,6 +60,7 @@ public class DefaultAvaloniaViewController : UIViewController, IAvaloniaViewCont
             SetNeedsStatusBarAppearanceUpdate();
         }
     }
+#endif
 
     bool IAvaloniaViewController.PrefersStatusBarHidden
     {
@@ -62,7 +68,9 @@ public class DefaultAvaloniaViewController : UIViewController, IAvaloniaViewCont
         set
         {
             _prefersStatusBarHidden = value;
+#if !TVOS
             SetNeedsStatusBarAppearanceUpdate();
+#endif
         }
     }
 
@@ -70,5 +78,5 @@ public class DefaultAvaloniaViewController : UIViewController, IAvaloniaViewCont
     public Thickness SafeAreaPadding { get; private set; }
 
     /// <inheritdoc/>
-    public event EventHandler SafeAreaPaddingChanged;
+    public event EventHandler? SafeAreaPaddingChanged;
 }