ボイラーのチラシ裏

テクノロジーやフィギュア、ゲームに関すること

【MAUI】アプリからPHPickerViewControllerを使ってみるサンプル

こんばんは。

今週はサンプル動画を作るために、試作中の画像ビューワーアプリをせこせこ開発しておりまして。

そのPCーiPad間のデータ転送の仕組みを再設計していたもので。。。

若干時間はかかりましたが、やっとブログ記事にまとまりそうなので書きます。

今週のお題

試作中の画像ビューワーは当然マルチプラットフォーム対応しようと思っていて、アプリに画像を取り込むために例えばユーザーがインポートフォルダを指定してあげてその中の画像ファイルを全部取り込むみたいなことが考えられます。インポートフォルダは複数指定できることが要件です。そこでWindowsならMicrosoft.Win32.OpenFolderDialogを無理やり使ったりできますね。(言葉足らずだったため、2024/1/29 1:14に追記)

var dialog = new OpenFolderDialog()
{
    InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.Desktop),
    Multiselect = true,
    Title = "インポートするフォルダの選択"
};
if (dialog.ShowDialog() == true)
{
    await Library.Value.ImportAsync(dialog.FolderNames);
}

みたいな?

iOSやiPadOSの場合はどうでしょう。。。?

iOSやiPadOSというと、システムが画像ピッカーのようなものを用意してくれているという印象がありました。

なのでそれに乗っかります。C#コードから。

特別にNuGetライブラリをインストールする必要はなさそうです。(間違ってたらごめんなさい)

どのようなコードをかけばいいかは以下に示します。

2つクラスが必要で、一つはPHPicerViewControllerを使用するクラス。

using PhotosUI;
using UIKit;

namespace boilersControl.Maui.MultiImagePicker.Platforms.iOS
{
    internal class MultiImagePicker : UIViewController
    {
        // PVCDelegateのインスタンスをクラスレベルで保持
        private PVCDelegate pickerDelegate;

        public async Task<IEnumerable<string>> PresentPhotoPickerAsync()
        {
            var tcs = new TaskCompletionSource<IEnumerable<string>>();

            await MainThread.InvokeOnMainThreadAsync(async () =>
            {
                var configuration = new PHPickerConfiguration();
                configuration.SelectionLimit = 0;
                configuration.Filter = PHPickerFilter.ImagesFilter;

                var picker = new PHPickerViewController(configuration);

                var currentUIViewController = WindowStateManager.Default.GetCurrentUIViewController();
                
                pickerDelegate = new PVCDelegate(tcs, currentUIViewController);
                picker.Delegate = pickerDelegate;
                await currentUIViewController.PresentViewControllerAsync(picker, true);
            });

            return await tcs.Task;
        }
    }
}

もう一つはPhotosUI.PHPickerViewControllerDelegateを継承するカスタムなデリゲートクラスです。

using Foundation;
using ObjCRuntime;
using PhotosUI;
using UIKit;
using UniformTypeIdentifiers;

namespace boilersControl.Maui.MultiImagePicker.Platforms.iOS
{
    internal class PVCDelegate : PhotosUI.PHPickerViewControllerDelegate
    {
        private readonly TaskCompletionSource<IEnumerable<string>> tcs;
        private readonly UIViewController _currentViewController;

        public bool IsCompleted { get; private set; } = false;

        public PVCDelegate(TaskCompletionSource<IEnumerable<string>> tcs,
            UIViewController currentViewController) : base()
        {
            this.tcs = tcs;
            _currentViewController = currentViewController;
        }

        public NativeHandle Handle { get; }
        public void Dispose()
        {
        }

        public override void DidFinishPicking(PHPickerViewController picker, PHPickerResult[] results)
        {
            var _imagePaths = new List<string>();

            MainThread.BeginInvokeOnMainThread(async () =>
            {
                await _currentViewController.DismissViewControllerAsync(true);
            });

            var tasks = results.Select(async result =>
            {
                var uiimage = await result.ItemProvider.LoadObjectAsync<UIImage>();
                var pngData = uiimage.AsPNG();
                var tempDirectoryURL = NSFileManager.DefaultManager.GetTemporaryDirectory();
                var filename = Guid.NewGuid().ToString() + ".png";
                var fileURL = tempDirectoryURL.AppendPathComponent(filename, UTType.CreateFromExtension("png"));

                WriteNSDataToFile(pngData, fileURL.Path);
                _imagePaths.Add(fileURL.Path);
            });

            Task.WhenAll(tasks).ContinueWith(t =>
            {
                IsCompleted = true;
                tcs.SetResult(_imagePaths);
            });
        }

        public void WriteNSDataToFile(NSData data, string filePath)
        {
            // NSDataからバイト配列への変換は、大きなファイルでメモリ消費が懸念されるため、
            // ストリームを使用してファイルに書き込む
            using (var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write))
            {
                data.AsStream().CopyTo(fileStream);
            }
        }
    }
}

処理の全体的な流れ

全体的な流れは

(1) UIViewControllerクラスを継承するMultiImagePickerクラスを作成します。

(2) MultiImagePicker.PresentPhotoPickerAsyncメソッド内の処理に入り、まずTaskCompletionSource<IEnumerable>インスタンスを作っておきます。 これは後ほど説明するカスタムデリゲートクラスのDidFinishPickingメソッド内の非同期処理が完了したタイミングでPresentPhotoPickerAsyncをreturnしたいためです。

(3)メインスレッドで以下を実行します。

(3-a) PHPickerConfigurationを作成して、SelectionLimitやFilterプロパティを設定します。

SelectionLimitは取得したい写真やビデオの数の上限数です。0を指定すると無制限になります。 Filterは取得したいファイルの種類を指定します。 なんかいろいろあります。

PHPickerFilterのプロパティ一覧

(3-b)PHPickerViewControllerクラスをnewします。この時コンストラクタの引数として(3-a)で作ったPHPickerConfigurationインスタンスを渡します。

(3-c)WindowStateManager.Default.GetCurrentUIViewControllerメソッドを呼び出し、カレントUIViewControllerを取得します。

(3-d)PHPickerViewController.Delegateプロパティにカスタムデリゲートクラスのインスタンスを渡します。コンストラクタでTaskCompletionSource<IEnumerable>インスタンスと、カレントUIViewControllerインスタンスを渡します。

(3-e)非同期でPresentViewControllerAsyncメソッドをコールします。

(4)TaskCompletionSource<IEnumerable>インスタンスのTaskプロパティをawaitしつつreturnします。

特筆すべき点

        // PVCDelegateのインスタンスをクラスレベルで保持
        private PVCDelegate pickerDelegate;

上記のような記述があると思います。なぜカスタムデリゲートクラスのインスタンスをクラスレベルで保持しなければいけないか。

PHPickerViewControllerインスタンスはMainThread.InvokeOnMainThreadAsyncメソッドに渡されるラムダ式のスコープに囚われています。

このスコープを出ると、ガベージコレクションに回収されてしまうおそれがあります。それはPHPickerViewControllerのプロパティに入っているインスタンスも同様です。

対処しないと、そのスコープを出た後にDidFinishPickingメソッドをコールするタイミングでObjectDisposedExceptionが出てしまいます。

なのでカスタムデリゲートクラスのインスタンスは、MultiImagePickerクラスのフィールドとして持たせることで、ガベージコレクションに回収されるのを防ぎます。

カスタムデリゲートクラス

PhotosUI.PHPickerViewControllerDelegateを継承すること!

ここ重要

まずPHPickerViewcontroller.Delegateプロパティに代入する、カスタムデリゲートクラスはPhotosUI.PHPickerViewControllerDelegateを継承してください。

作ったカスタムクラスにIPHPickerViewControllerDelegateインターフェースを実装しただけでは、画像ピッカーの追加、キャンセルボタンが機能しません。

DidFinishPickingメソッド

DidFinishPickingメソッドは画像を取得した後どのような処理をするかを書きます。パラメーターのPHPickerResult[]からは、UIImageオブジェクトを取得できますがこれはあまり外に出すものではなさそう?です。つまり、MAUIアプリのコードでUIImageオブジェクトを操作することははばかられるという意味で、できればこのDidFinishPickingメソッド内でUIImageデータを一時ファイルとして保存して、そのパスを返すような仕組みにしたほうが健全かなぁ?と思います。サンプルコードではAsPNGメソッドでNSDataオブジェクトを取得してそれを一時ファイルに書き込んでいます。そして一時ファイルの複数のパスをTaskCompletionSource<IEnumerable>にセットしてあげるという感じ。

サンプルの動画

youtu.be

次回予告

MAUIでiOSアプリをデバッグする時にFileNotFoundExceptionが発生する問題に戸惑ったので、その解決策について書きたいと思います。