Skip to content

Commit

Permalink
Merge pull request #6445 from frenzibyte/ios-document-browser
Browse files Browse the repository at this point in the history
Implement system-provided file selection display on iOS
  • Loading branch information
smoogipoo authored Jan 1, 2025
2 parents 7423a30 + d8621f4 commit 98dc4f5
Show file tree
Hide file tree
Showing 11 changed files with 285 additions and 62 deletions.
Original file line number Diff line number Diff line change
@@ -1,18 +1,46 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System.IO;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.IO.Stores;
using osu.Framework.Platform;

namespace osu.Framework.Tests.Visual.UserInterface
{
public partial class TestSceneFileSelector : FrameworkTestScene
{
private BasicFileSelector selector = null!;

[Resolved]
private GameHost host { get; set; } = null!;

[BackgroundDependencyLoader]
private void load()
{
Add(new BasicFileSelector { RelativeSizeAxes = Axes.Both });
Add(selector = new BasicFileSelector(null, new[] { ".png", ".jpg", ".jpeg" }) { RelativeSizeAxes = Axes.Both });
}

protected override void LoadComplete()
{
base.LoadComplete();

selector.CurrentFile.BindValueChanged(f =>
{
using var resources = new StorageBackedResourceStore(host.GetStorage(f.NewValue.Directory!.FullName));
using var store = new TextureStore(host.Renderer, host.CreateTextureLoaderStore(resources));

Add(new Sprite
{
FillMode = FillMode.Fit,
RelativeSizeAxes = Axes.Both,
Texture = store.Get(Path.GetFileName(f.NewValue.FullName)),
});
});
}
}
}
24 changes: 24 additions & 0 deletions osu.Framework.iOS/IIOSWindow.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using osu.Framework.Platform;
using UIKit;

namespace osu.Framework.iOS
{
/// <summary>
/// Interface representation of the game window on the iOS platform.
/// </summary>
public interface IIOSWindow : IWindow
{
/// <summary>
/// The underlying <see cref="UIWindow"/> associated to this window.
/// </summary>
UIWindow UIWindow { get; }

/// <summary>
/// The <see cref="UIViewController"/> presenting this window's content.
/// </summary>
UIViewController ViewController { get; }
}
}
28 changes: 14 additions & 14 deletions osu.Framework.iOS/IOSFilePresenter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@

namespace osu.Framework.iOS
{
internal class IOSFilePresenter : UIDocumentInteractionControllerDelegate
public class IOSFilePresenter : UIDocumentInteractionControllerDelegate
{
private readonly IOSWindow window;
private readonly UIDocumentInteractionController viewController = new UIDocumentInteractionController();
private readonly IIOSWindow window;
private readonly UIDocumentInteractionController documentInteraction = new UIDocumentInteractionController();

internal IOSFilePresenter(IOSWindow window)
public IOSFilePresenter(IIOSWindow window)
{
this.window = window;
}
Expand All @@ -23,39 +23,39 @@ public bool OpenFile(string filename)
{
setupViewController(filename);

if (viewController.PresentPreview(true))
if (documentInteraction.PresentPreview(true))
return true;

var gameView = window.UIWindow.RootViewController!.View!;
return viewController.PresentOpenInMenu(gameView.Bounds, gameView, true);
var gameView = window.ViewController.View!;
return documentInteraction.PresentOpenInMenu(gameView.Bounds, gameView, true);
}

public bool PresentFile(string filename)
{
setupViewController(filename);

var gameView = window.UIWindow.RootViewController!.View!;
return viewController.PresentOptionsMenu(gameView.Bounds, gameView, true);
var gameView = window.ViewController.View!;
return documentInteraction.PresentOptionsMenu(gameView.Bounds, gameView, true);
}

private void setupViewController(string filename)
{
var url = NSUrl.FromFilename(filename);

viewController.Url = url;
viewController.Delegate = this;
documentInteraction.Url = url;
documentInteraction.Delegate = this;

if (OperatingSystem.IsIOSVersionAtLeast(14))
viewController.Uti = UTType.CreateFromExtension(Path.GetExtension(filename))?.Identifier ?? UTTypes.Data.Identifier;
documentInteraction.Uti = UTType.CreateFromExtension(Path.GetExtension(filename))?.Identifier ?? UTTypes.Data.Identifier;
}

public override UIViewController ViewControllerForPreview(UIDocumentInteractionController controller) => window.UIWindow.RootViewController!;
public override UIViewController ViewControllerForPreview(UIDocumentInteractionController controller) => window.ViewController;

public override void WillBeginSendingToApplication(UIDocumentInteractionController controller, string? application)
{
// this path is triggered when a user opens the presented document in another application,
// the menu does not dismiss afterward and locks the game indefinitely. dismiss it manually.
viewController.DismissMenu(true);
documentInteraction.DismissMenu(true);
}
}
}
71 changes: 71 additions & 0 deletions osu.Framework.iOS/IOSFileSelector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.IO;
using System.Runtime.Versioning;
using Foundation;
using osu.Framework.Platform;
using UIKit;
using UniformTypeIdentifiers;

namespace osu.Framework.iOS
{
[SupportedOSPlatform("ios14.0")]
public class IOSFileSelector : UIDocumentPickerDelegate, ISystemFileSelector
{
public event Action<FileInfo>? Selected;

private readonly IIOSWindow window;

private readonly UIDocumentPickerViewController documentPicker;

public IOSFileSelector(IIOSWindow window, string[] allowedExtensions)
{
this.window = window;

UTType[] utTypes;

if (allowedExtensions.Length == 0)
utTypes = new[] { UTTypes.Data };
else
{
utTypes = new UTType[allowedExtensions.Length];

for (int i = 0; i < allowedExtensions.Length; i++)
{
string extension = allowedExtensions[i];

var type = UTType.CreateFromExtension(extension.Replace(".", string.Empty));
if (type == null)
throw new InvalidOperationException($"System failed to recognise extension \"{extension}\" while preparing the file selector.\n");

utTypes[i] = type;
}
}

// files must be provided as copies, as they may be originally located in places that cannot be accessed freely (aka. iCloud Drive).
// we can acquire access to those files via startAccessingSecurityScopedResource but we must know when the game has finished using them.
// todo: refactor FileSelector/DirectorySelector to be aware when the game finished using a file/directory.
documentPicker = new UIDocumentPickerViewController(utTypes, true);
documentPicker.Delegate = this;
}

public void Present()
{
UIApplication.SharedApplication.InvokeOnMainThread(() =>
{
window.ViewController.PresentViewController(documentPicker, true, null);
});
}

public override void DidPickDocument(UIDocumentPickerViewController controller, NSUrl url)
=> Selected?.Invoke(new FileInfo(url.Path!));

protected override void Dispose(bool disposing)
{
documentPicker.Dispose();
base.Dispose(disposing);
}
}
}
19 changes: 19 additions & 0 deletions osu.Framework.iOS/IOSGameHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ namespace osu.Framework.iOS
{
public class IOSGameHost : SDLGameHost
{
public new IIOSWindow Window => (IIOSWindow)base.Window;

private IOSFilePresenter presenter = null!;

public IOSGameHost()
Expand Down Expand Up @@ -83,6 +85,23 @@ public override IResourceStore<TextureUpload> CreateTextureLoaderStore(IResource
public override VideoDecoder CreateVideoDecoder(Stream stream)
=> new IOSVideoDecoder(Renderer, stream);

public override ISystemFileSelector? CreateSystemFileSelector(string[] allowedExtensions)
{
IOSFileSelector? selector = null;

UIApplication.SharedApplication.InvokeOnMainThread(() =>
{
// creating UIDocumentPickerViewController programmatically is only supported on iOS 14.0+.
// on lower versions, return null and fall back to our normal file selector display.
if (!OperatingSystem.IsIOSVersionAtLeast(14))
return;

selector = new IOSFileSelector(Window, allowedExtensions);
});

return selector;
}

public override IEnumerable<KeyBinding> PlatformKeyBindings => MacOSGameHost.KeyBindings;
}
}
24 changes: 11 additions & 13 deletions osu.Framework.iOS/IOSWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Diagnostics;
using System.Drawing;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
Expand All @@ -16,21 +15,19 @@

namespace osu.Framework.iOS
{
internal class IOSWindow : SDL3MobileWindow
internal class IOSWindow : SDL3MobileWindow, IIOSWindow
{
private UIWindow? uiWindow;
public UIWindow UIWindow { get; private set; } = null!;

public UIWindow UIWindow => uiWindow!;
public UIViewController ViewController => UIWindow.RootViewController!;

public override Size Size
{
get => base.Size;
protected set
{
base.Size = value;

if (uiWindow != null)
updateSafeArea();
updateSafeArea();
}
}

Expand All @@ -45,7 +42,7 @@ public override void Create()

base.Create();

uiWindow = Runtime.GetNSObject<UIWindow>(WindowHandle);
UIWindow = Runtime.GetNSObject<UIWindow>(WindowHandle)!;
updateSafeArea();

var appDelegate = (GameApplicationDelegate)UIApplication.SharedApplication.Delegate;
Expand Down Expand Up @@ -76,14 +73,15 @@ private static void runFrame(IntPtr userdata)

private void updateSafeArea()
{
Debug.Assert(uiWindow != null);
if (!Exists)
return;

SafeAreaPadding.Value = new MarginPadding
{
Top = (float)uiWindow.SafeAreaInsets.Top * Scale,
Left = (float)uiWindow.SafeAreaInsets.Left * Scale,
Bottom = (float)uiWindow.SafeAreaInsets.Bottom * Scale,
Right = (float)uiWindow.SafeAreaInsets.Right * Scale,
Top = (float)UIWindow.SafeAreaInsets.Top * Scale,
Left = (float)UIWindow.SafeAreaInsets.Left * Scale,
Bottom = (float)UIWindow.SafeAreaInsets.Bottom * Scale,
Right = (float)UIWindow.SafeAreaInsets.Right * Scale,
};
}
}
Expand Down
5 changes: 5 additions & 0 deletions osu.Framework/Graphics/UserInterface/BasicFileSelector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ namespace osu.Framework.Graphics.UserInterface
{
public partial class BasicFileSelector : FileSelector
{
public BasicFileSelector(string initialPath = null, string[] validFileExtensions = null)
: base(initialPath, validFileExtensions)
{
}

protected override DirectorySelectorBreadcrumbDisplay CreateBreadcrumb() => new BasicDirectorySelectorBreadcrumbDisplay();

protected override Drawable CreateHiddenToggleButton() => new BasicButton
Expand Down
Loading

0 comments on commit 98dc4f5

Please sign in to comment.