.net maui(dotnet maui)で保存ダイアログのプラットフォーム別の実装方法についてのアイキャッチ画像

.NET MAUI – 保存ダイアログをプラットフォームに合わせて利用する方法

.NET MAUIで保存ダイアログを表示する方法を紹介していきます。

保存ダイアログはプラットフォームごとで動作が異なるため、実装方法も異なります。当記事ではプラットフォーム別に実装方法を紹介していきます。

紹介環境

当記事は以下の環境で作成しています。

  • Visual Studio 2022
  • .Net 8

Mac版について

Mac版は以下の環境を利用しています。

  • Visual Studio Code
  • .Net 8

以下のリンクからVisual Studio Codeの開発環境を確認できます。

はじめに

当記事では各プラットフォームの保存ダイアログの表示方法を紹介します。

保存ダイアログで保存場所を選択後、ファイル保存まで行います。Windows以外の保存ダイアログが保存場所を選択後、ファイル保存まで行うためです。

プラットフォーム別に処理を行いたい場合、以下の記事を参考にしてください。以下の記事はDIを利用してプラットフォーム別に処理を分けています。

保存ダイアログのオプション

WindowsやAndroidは、保存するファイルの種類に合わせた保存ダイアログを表示する必要があります。そのため、保存するファイルを設定するために、次のクラスが用意します。

// 名前空間はプロジェクトに合わせてください。
namespace SampleSaveDialog.DI.SaveFilePickerService;

public class SaveFilePickerOptions
{
    /// <summary>
    /// Windows用
    /// ファイルの種類・拡張子を設定します。
    /// </summary>
    public Dictionary<string, IList<string>> WindowsFileTypes { get; } = new Dictionary<string, IList<string>>();

    /// <summary>
    /// Android用
    /// MIMEタイプを設定します。
    /// </summary>
    public string AndroidMimeType { get; set; }

    /// <summary>
    /// iOS・Mac用
    /// 拡張子を設定します。
    /// </summary>
    public string Extension { get; set; }
}

DIのインターフェイス

DIに利用するインターフェイスは、次のようになります。

// 名前空間はプロジェクトに合わせてください。
namespace SampleSaveDialog.DI.SaveFilePickerService;

public interface ISaveFilePicker
{
    Task ShowAsync(string fileName, byte[] bytes, SaveFilePickerOptions options);
}

保存ダイアログ(Windows版)

Windowsで保存ダイアログを表示するには次のようになります。

using Windows.Storage;
using Windows.Storage.Pickers;
using WinRT.Interop;

// 本記事の場合
using SampleSaveDialog.DI.SaveFilePickerService;

// 名前空間はプロジェクトに合わせてください。
namespace SampleSaveDialog.Platforms.Windows.DI;

public class SaveFilePicker : ISaveFilePicker
{
    public async Task ShowAsync(string fileName, byte[] bytes, SaveFilePickerOptions options)
    {
        ArgumentNullException.ThrowIfNull(options);

        // 保存ダイアログの準備
        FileSavePicker fileSavePicker = new()
        {
            // ドキュメントを指定
            SuggestedStartLocation = PickerLocationId.DocumentsLibrary,
            SuggestedFileName = !string.IsNullOrEmpty(fileName) ? fileName : "sample",
        };
        foreach (var item in options.WindowsFileTypes)
        {
            fileSavePicker.FileTypeChoices.Add(item.Key, item.Value);
        }

        // 保存ダイアログが表示されるようにハンドラを修正
        if (MauiWinUIApplication.Current.Application.Windows[0].Handler.PlatformView is MauiWinUIWindow window)
        {
            InitializeWithWindow.Initialize(fileSavePicker, window.WindowHandle);
        }

        // 保存ダイアログを表示
        StorageFile file = await fileSavePicker.PickSaveFileAsync();

        // キャンセルの場合、保存しない
        if (file == null) return;

        // 書き込み
        using FileStream fileStream = new FileStream(file.Path, FileMode.OpenOrCreate);
        fileStream.Write(bytes, 0, bytes.Length);
    }
}

ハンドラの修正部分について

以下の部分はハンドラを修正しています。

        // 保存ダイアログが表示されるようにハンドラを修正
        if (MauiWinUIApplication.Current.Application.Windows[0].Handler.PlatformView is MauiWinUIWindow window)
        {
            InitializeWithWindow.Initialize(fileSavePicker, window.WindowHandle);
        }

この部分が存在しない無い場合、次のようなエラーが発生します。このエラーは保存ダイアログを表示するハンドラ異なるためエラーになります。

ハンドラが異なるため保存ダイアログを表示させるときにエラーになる

保存ダイアログの見た目

保存ダイアログの見た目は以下のようになります。

保存ダイアログの見た目(Windows)

以下の個所で「ドキュメント」が指定されています。そのため、保存ダイアログの表示時は「ドキュメント」が選択されています。

        // 保存ダイアログの準備
        FileSavePicker fileSavePicker = new()
        {
            // ドキュメントを指定
            SuggestedStartLocation = PickerLocationId.DocumentsLibrary,
            SuggestedFileName = !string.IsNullOrEmpty(fileName) ? fileName : "sample",
        };

保存ダイアログ(Android版)

Androidで保存ダイアログを表示させるためには、MainActivityクラスで準備が必要になります。また、ファイルの保存を行う際に外部ストレージの権限が必要になります。

MainActivityクラスの準備

Androidで保存ダイアログを表示させるためにMainActivityクラスを以下のようにする必要があります。

保存ダイアログの結果は、コールバックで取得する必要があります。コールバックで取得する方法としてstartActivityForResult() API と onActivityResult() APIがありますが、Activity Result API がAndroid デベロッパーで進められています。

そのため、当記事ではActivity Result API を利用しています。

using Android.App;
using Android.Content.PM;
using Android.OS;
using AndroidX.Activity.Result;
using AndroidX.Activity.Result.Contract;
using Uri = Android.Net.Uri;

/* <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> */
[assembly: UsesPermission(Android.Manifest.Permission.WriteExternalStorage)]

// 名前空間はプロジェクトに合わせてください。
namespace SampleSaveDialog;

[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
    public static MainActivity Instance;

    /// <summary>
    /// 保存ダイアログ用ランチャー
    /// </summary>
    public static ActivityResultLauncher SaveFilePickerLauncher;

    /// <summary>
    ///保存ダイアログの結果取得タスク
    /// </summary>
    public TaskCompletionSource<Uri> CompletionSource { get; set; }

    protected override void OnCreate(Bundle savedInstanceState)
    {
        Instance = this;

        SaveFilePickerLauncher = RegisterForActivityResult(
            new ActivityResultContracts.StartActivityForResult(),
            new SaveFilePickerCallback(result =>
            {
                // 保存ダイアログで選択されたURIを結果として返却
                Instance.CompletionSource.SetResult(result);
            }));

        base.OnCreate(savedInstanceState);
    }

    /// <summary>
    /// 保存ダイアログのコールバック用クラス
    /// </summary>
    public class SaveFilePickerCallback : Java.Lang.Object, IActivityResultCallback
    {
        /// <summary>
        /// 保存ダイアログのコールバック用アクション
        /// </summary>
        private readonly Action<Uri> _callback;

        public SaveFilePickerCallback(Action<Uri> callback) => _callback = callback;

        public void OnActivityResult(Java.Lang.Object p0)
        {
            ActivityResult activityResult = (ActivityResult)p0;
            // 保存ダイアログで選択されたURIをコールバックで取得します。
            _callback(activityResult.Data.Data);
        }
    }
}

外部ストレージへのアクセス権限を追加

以下のように記載することで外部ストレージへのアクセス権限を追加することができます。

/* <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> */
[assembly: UsesPermission(Android.Manifest.Permission.WriteExternalStorage)]

// 名前空間はプロジェクトに合わせてください。
namespace SampleSaveDialog;

保存ダイアログの表示

Androidで保存ダイアログを表示するには次のようになります。

using Android.Content;
using Uri = Android.Net.Uri;

// 本記事の場合
using SampleSaveDialog.DI.SaveFilePickerService;

// 名前空間はプロジェクトに合わせてください。
namespace SampleSaveDialog.Platforms.Android.DI;

public class SaveFilePicker : ISaveFilePicker
{
    public async Task ShowAsync(string fileName, byte[] bytes, SaveFilePickerOptions options)
    {
        ArgumentNullException.ThrowIfNull(options);

        fileName = !string.IsNullOrEmpty(fileName) ? fileName : "sample";

        // 保存ダイアログの準備
        Intent intent = new(Intent.ActionCreateDocument);
        intent.AddCategory(Intent.CategoryOpenable);
        intent.SetType(options.AndroidMimeType);
        intent.PutExtra(Intent.ExtraTitle, fileName);

        // 保存ダイアログの表示
        MainActivity.SaveFilePickerLauncher.Launch(intent);
        MainActivity.Instance.CompletionSource = new TaskCompletionSource<Uri>();
        Uri uri = await MainActivity.Instance.CompletionSource.Task;

        if (uri is null) return;

        // ファイル保存
        using var outStream = Platform.CurrentActivity.ContentResolver.OpenOutputStream(uri);
        outStream?.Write(bytes, 0, bytes.Length);
    }
}

保存ダイアログの見た目

保存ダイアログの見た目は以下のようになります。

保存ダイアログの表示時は「ダウンロード」が選択されています。

保存ダイアログの見た目(Android)

保存ダイアログ(iOS版)

iOSで保存ダイアログを表示するには次のようになります。

using Foundation;
using UIKit;

// 本記事の場合
using SampleSaveDialog.DI.SaveFilePickerService;

// 名前空間はプロジェクトに合わせてください。
namespace SampleSaveDialog.Platforms.iOS.DI;

public class SaveFilePicker : ISaveFilePicker
{
    public async Task ShowAsync(string fileName, byte[] bytes, SaveFilePickerOptions options)
    {
        ArgumentNullException.ThrowIfNull(options);

        fileName = !string.IsNullOrEmpty(fileName) ? fileName : "sample";
        fileName = string.IsNullOrEmpty(options.Extension) ? fileName : $"{fileName}{options.Extension}";

        // 保存するファイルをキャッシュに保存
        string path = Path.Combine(FileSystem.CacheDirectory, fileName);
        using (MemoryStream memoryStream = new(bytes))
        {
            using (FileStream file = File.Create(path))
            {
                await memoryStream.CopyToAsync(file);
            }
        }

        // キャッシュに保存されたファイル場所
        NSUrl url = new(path, false);

        // 保存ダイアログ準備
        UIDocumentPickerViewController pickerViewController = new(new[] { url }, true);

        // 保存ダイアログ表示
        UIViewController viewController = Platform.GetCurrentUIViewController();
        await viewController?.PresentViewControllerAsync(pickerViewController, true);
    }
}

保存ダイアログの見た目

保存ダイアログの見た目は以下のようになります。

保存ダイアログの表示時は「このiPhone内」が選択されています。

保存ダイアログの見た目(iOS)

保存ダイアログ(Mac版)

Macで保存ダイアログを表示するには次のようになります。.NET MAUIでは、NSSavePanelクラスが存在しないため、iOSと同じ方法になります。

using Foundation;
using UIKit;

// 本記事の場合
using SampleSaveDialog.DI.SaveFilePickerService;

// 名前空間はプロジェクトに合わせてください。
namespace SampleSaveDialog.Platforms.MacCatalyst.DI;

public class SaveFilePicker : ISaveFilePicker
{
    public async Task ShowAsync(string fileName, byte[] bytes, SaveFilePickerOptions options)
    {
        ArgumentNullException.ThrowIfNull(options);

        fileName = !string.IsNullOrEmpty(fileName) ? fileName : "sample";
        fileName = string.IsNullOrEmpty(options.Extension) ? fileName : $"{fileName}{options.Extension}";

        // 保存するファイルをキャッシュに保存
        string path = Path.Combine(FileSystem.CacheDirectory, fileName);
        using (MemoryStream memoryStream = new(bytes))
        {
            using (FileStream file = File.Create(path))
            {
                await memoryStream.CopyToAsync(file);
            }
        }

        // キャッシュに保存されたファイル場所
        NSUrl url = new(path, false);

        // 保存ダイアログ準備
        UIDocumentPickerViewController pickerViewController = new(new[] { url }, true);

        // 保存ダイアログ表示
        UIViewController viewController = Platform.GetCurrentUIViewController();
        await viewController?.PresentViewControllerAsync(pickerViewController, true);
    }
}

Macで保存ダイアログを表示させる場合、「Entitlements.plist」に以下の設定を追加します。

  • com.apple.security.files.user-selected.read-write:true

「Entitlements.plist」に「com.apple.security.files.user-selected.read-write:true」を追加した内容は以下のようになります。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
		<!-- ===== 略 ===== -->
	<key>com.apple.security.files.user-selected.read-write</key>
	<true />
</dict>
</plist>

保存ダイアログの見た目

保存ダイアログの見た目は以下のようになります。

他のプラットフォームでは保存されているファイルが確認できましたが、Macでは保存場所しか確認できません。保存ダイアログの表示時は「Document」が選択されています。

保存ダイアログの見た目(Mac)

アプリに保存ダイアログを実装してみる

ここまでの内容を利用してテキストを保存するアプリを作成してみます。プロジェクトの種類を「.Net MAUI」、プロジェクト名を「SampleSaveDialog」で作成していきます。

手順① 今回紹介したクラスを配置します

今回紹介したクラスを配置していきます。実際に配置した例が以下のようになります。

当記事で紹介したクラスの配置例

手順② ViewModelを作成します

ViewModelは以下のように配置します。クラス名は「MainViewModel」にしています。

ViewModelの配置場所

MainViewModelクラスの内容は以下のようになります。「SaveCommand」で保存ダイアログのDIを利用しています。

using SampleSaveDialog.DI.SaveFilePickerService;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Text;

namespace SampleSaveDialog.ViewModels;

public class MainViewModel : INotifyPropertyChanged
{
    private ISaveFilePicker _saveFilePicker;

    public MainViewModel(ISaveFilePicker saveFilePicker)
    {
        _saveFilePicker = saveFilePicker;
        SaveCommand = new Command(async () =>
        {
            // テキストファイルの場合 保存ダイアログのオプションを設定します。
            SaveFilePickerOptions options =
                new SaveFilePickerOptions
                {
                    AndroidMimeType = "text/plain",
                    Extension = ".txt"
                };
            options.WindowsFileTypes.Add("テキストファイル", new List<string> { ".txt" });

            byte[] bytes = Encoding.UTF8.GetBytes(Text);

            // 保存ダイアログを表示し、ファイルを保存します。
            await _saveFilePicker.ShowAsync("sample", bytes, options);
        });
    }

    public event PropertyChangedEventHandler PropertyChanged;

    /// <summary>
    /// 保存コマンド
    /// </summary>
    public Command SaveCommand { get; set; }

    private string _text = "";

    /// <summary>
    /// 入力内容
    /// </summary>
    public string Text
    {
        get => _text;
        set
        {
            SetProperty(ref _text, value);
        }
    }

    private void SetProperty<T>(ref T args, T value, [CallerMemberName] string name = "")
    {
        args = value;
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    }
}

手順➂ 画面を作成します

「MainPage.xaml」の内容は以下のようにします。

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="SampleSaveDialog.MainPage">

    <ScrollView>
        <VerticalStackLayout>
            <!-- 保存ボタン -->
            <Button
                BackgroundColor="#ff5555"
                Command="{Binding SaveCommand}"
                Text="保存"
                TextColor="White"></Button>
            <!-- テキスト入力場所 -->
            <Editor
                AutoSize="TextChanges"
                BackgroundColor="White"
                MinimumHeightRequest="200"
                Text="{Binding Text}"
                TextColor="Black"></Editor>
        </VerticalStackLayout>
    </ScrollView>
</ContentPage>

手順➃ DIを登録する

DIを以下のように登録していきます。DIの登録する際にプラットフォームごとに利用できるようにします。

using Microsoft.Extensions.Logging;
using SampleSaveDialog.DI.SaveFilePickerService;
using SampleSaveDialog.ViewModels;

#if WINDOWS

using SampleSaveDialog.Platforms.Windows.DI;

#elif ANDROID

using SampleSaveDialog.Platforms.Android.DI;

#elif IOS

using SampleSaveDialog.Platforms.iOS.DI;

#elif MACCATALYST

using SampleSaveDialog.Platforms.MacCatalyst.DI;

#endif

namespace SampleSaveDialog
{
    public static class MauiProgram
    {
        public static MauiApp CreateMauiApp()
        {
            var builder = MauiApp.CreateBuilder();
            builder
                .UseMauiApp<App>()
                .ConfigureFonts(fonts =>
                {
                    fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                    fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
                });

#if DEBUG
            builder.Logging.AddDebug();
#endif

            // サービスの登録
            builder.Services.AddSingleton<ISaveFilePicker, SaveFilePicker>();

            // ViewModelの登録
            builder.Services.AddSingleton<MainViewModel>();

            // 画面の登録
            builder.Services.AddSingleton<MainPage>();

            return builder.Build();
        }
    }
}

手順⑤ DIを画面で利用する

画面でDIを利用するためにDIを利用しているViewModelを呼び出します。今回は、以下のようにMainViewModelクラスを利用します。

using SampleSaveDialog.ViewModels;

namespace SampleSaveDialog
{
    public partial class MainPage : ContentPage
    {
        // コンストラクタの引数を追加
        public MainPage(MainViewModel viewModel)
        {
            InitializeComponent();

            // 追加
            this.BindingContext = viewModel;
        }
    }
}

実装はこれで完了になります。

実行してみる(Windows)

今回作成したアプリは以下のようになります。プラットフォームはWindowsになります。

今回作成したアプリの実行画面(Windows)

おわりに

各プラットフォームの保存ダイアログを実装してみました。ファイル形式ごとにSaveFilePickerOptionsクラスを用意しておくと利用しやすくなります。