😸

C#とHTMLでスマホアプリを作る MAUI Blazor

2022/12/19に公開

はじめに

こちらは、デジクリ Advent Calendar 2022 の14日目の記事となります。
デジクリについてはこちらのサイトをご覧ください。

自分が書いた4日目の記事はこちら

今回は比較的プログラミング初学者向け、特にUnityなどでゲームを作ったことがありスマホやPCアプリを作りたくなった方向けを想定しています。

環境構築

環境構築といっても簡単。

VisualStudio 2022を.NET マルチプラットフォームアプリのUI開発という機能付きでインストールするだけ。

既にVisualStudio2022が入っていて、MAUIの機能をインストールしていない場合は、PCに入っているVisual Studio Installerを起動して構成を変更できます。

今回Macユーザーの方向けには解説しませんが、概ね同じ事がVisual Studio for Macの最新版でできます。もちろんこちらでもMAUIの機能インストールが必要です。

事前知識

MAUIとは?

正式名称.NET Multi-Platform App UI。名前から想像できるように、.NET(C#など)を用いてマルチプラットフォーム(Windows,MacOS,iOS,Android)向けにアプリを作るためのフレームワーク。

日本の接触確認アプリCocoaやNHK紅白歌合戦の公式アプリなどで使われたXamarinの後継フレームワークでできたてホヤホヤ。

Blazorとは?

通常、ブラウザで動くWebアプリは見た目を定義するHTMLと動きを定義するJavaScriptにて作れるが、それをJavaScriptを使わずにC#を用いて開発ができるフレームワーク。

実行方式に、C#のコードをサーバー側で動かしブラウザ側と通信しながら動くBlazor Serverとブラウザ上でWebAssemblyを用いて作られたC#の実行エンジンを用いて動かすBlazor WebAssemblyがあります。

Blazor Serverに対し、Blazor WebAssemblyは通信が要らないためオフラインで動く利点があるものの、C#の実行エンジンの初回ダウンロードが重いという欠点があります。状況に合わせた技術選定が必要です。

MAUI Blazorとは?

MAUIでアプリ本体を作り、UIはBlazor Serverで構築する構成のことです。

MAUIで使用するマークアップ言語XamlではなくHTMLCSSで構築できるので、C#をしっている&HTMLちょっとわかる人ならすぐにスマホアプリの製作に取りかかれます。

前述のBlazor Serverではオフライン環境では利用出来ないと書きましたが、Blazor MAUIではアプリ内と通信するため、アプリとしてはオフライン環境でも動作します。

ToDoアプリを作る

今回は簡単なToDoアプリを作ってみます。

プロジェクトを作成する

VisualStudioの新規プロジェクトにて.NET MAUI Blazorアプリを洗濯します。

プロジェクト名はMauiBlazorTodoAppにし、.NETのバージョンは6,7で作成します。

まずは実行

実行してみます。標準でWindowsアプリとして実行されます。
Indexページ、Counterページ、FetchDataページの動作が確認できます。

次にPages/Counter.razorを見てみます。

@page "/counter"

<h1>Counter</h1>

<p role="status">Current count: @currentCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

@code {
    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount++;
    }
}

@page "/counter"でページのルーティング設定がされていて、@codeまではHTMLで記載されています。
C#の要素は@から始まるもので記載されています。ロジックコードは@codeブロックで記載され、HTML内に@currentCountと書くことで変数の値がHTMLに埋め込まれています。
buttonタグのonclickC#の要素に接続する@onclickが使われています。

実行して気づいている方もいるかもしれませんが、ボタンなどはBootstrapが標準で導入されているので今回はそのまま使いつつTodoアプリを作っていきます。

まずは簡単なコードから

Pages/Index.razorを改造していきます。まずは以下のようにinputに入れたテキストをボタンを押すことでリストに追加する簡単なものを作ってみます。

@page "/"
<ul>
    @foreach (var todo in todoList)
    {
        <li>@todo</li>
    }
</ul>
<input type="text" @bind-value="inputText" />
<button @onclick="Add">ADD</button>

@code {
    List<string> todoList = new();
    string inputText = "";
    void Add()
    {
        todoList.Add(inputText);
        inputText = "";
    }
}

TodoクラスとTodoServiceクラスを作る

今回のTodoではタイトルと期限、やったかどうかを管理します。

ソリューションエクスプローラーにてDataを右クリックして追加から新しい項目を選択します。

クラスを選択してTodoという名前で作成します。同じ手順でTodoServiceクラスも作成します。

Todo.csを以下のようにします。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace MauiBlazorTodoApp.Data
{
    public class Todo
    {
        public Guid Id { get; set; }
        public string Title { get; set; }
        public bool IsEnded { get; set; }
        public DateTime? Limit { get; set; }
        public Todo(string title, DateTime limit)
        {
            Id = Guid.NewGuid();
            Title = title;
            IsEnded = false;
            Limit = limit;
        }
        public Todo(string title)
        {
            Id = Guid.NewGuid();
            Title = title;
            IsEnded = false;
        }
        public Todo()
        {

        }
    }
}

TodoService.csを以下のようにします。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

namespace MauiBlazorTodoApp.Data
{
    public class TodoService
    {
        private static TodoService _instance;
        public static TodoService Instance 
        { 
            get
            {
                if(_instance == null) 
                    _instance = new TodoService();
                return _instance;
            } 
        }

        public List<Todo> TodoList = new();
        public TodoService()
        {
            string filePath = Path.Combine(
                FileSystem.Current.AppDataDirectory, 
                "todolist.json");
            if (File.Exists(filePath))
            {
                string json = File.ReadAllText(filePath);
                TodoList = JsonSerializer.Deserialize<List<Todo>>(json);
            }
        }

        public async Task Commit()
        {
            string filePath = Path.Combine(
                FileSystem.Current.AppDataDirectory,
                "todolist.json");
            string json = JsonSerializer.Serialize(TodoList);
            await File.WriteAllTextAsync(filePath, json);
        }
    }
}

TodoListのコンポーネントを作る

Sharedフォルダを右クリックして追加からRazorコンポーネントを選択し、TodoItemという名前のコンポーネントを作成します。

<label class="list-group-item">
    <input class="form-check-input me-1" type="checkbox" value="@todo.IsEnded" @oninput="OnCheck">
    @todo.Title
    @if(todo.Limit != null)
    {
        <p style="color:gray;">@todo.Limit.ToString()</p>
    }
</label>

@code {
    [Parameter]
    public Data.Todo todo { get; set; }
    [Parameter]
    public RenderFragment? ChildContent{ get; set; } 
    [Parameter]
    public EventCallback OnCheck { get; set; }
}

TodoListのページを作る

Index.razorを以下のように書き換えます。

@page "/"
@using Data;
@foreach (Todo todo in todoService.TodoList.Where(t=>!t.IsEnded))
{
    <TodoItem @key="todo.Id" todo="@todo" OnCheck="@(() => {
todo.IsEnded = true;
todoService.Commit();
StateHasChanged();
})" />
}
<input type="text" @bind-value="inputText" />
<button @onclick="Add">ADD</button>

@code {
    TodoService todoService = TodoService.Instance;
    string inputText = "";
    void Add()
    {
        todoService.TodoList.Add(new(inputText));
        todoService.Commit();
        inputText = "";
    }
}

ここまでのコードを用いて実行すると画像のようなアプリとなっています。

ここから、入力部を作っていきます。

入力用のモーダルを作る

TodoItemを作るときと同じように、Shaerdフォルダ内にTodoCreateModal.razorを作成します。

@using MauiBlazorTodoApp.Data
@if (IsOpen)
{
    <div class="modalArea">
        <div class="modalBg"></div>
        <div class="modalWrapper">
            <div class="mb-2">
                <label for="todoTitle" class="form-label">タイトル</label>
                <input type="text" class="form-control" id="todoTitle" @bind-value="inputText">
            </div>
            <div class="mb-2">
                <label for="limit" class="form-label">期限</label>
                <input type="datetime-local" id="limit" step="300" class="form-control" @bind-value="limitDate">
            </div>
            <button @onclick="OnClose" class="btn btn-secondary">キャンセル</button>
            <button @onclick="Add" class="btn btn-success">追加</button>
        </div>
    </div>
}
@code {
    [Parameter]
    public bool IsOpen { get; set; } = true;
    [Parameter]
    public EventCallback OnClose { get; set; }
    [Parameter]
    public EventCallback OnAdd { get; set; }

    public TodoService TodoService { get; set; } = TodoService.Instance;
    string inputText = "";
    DateTime? limitDate = null;
    void Add()
    {
        if(limitDate is not null)
            TodoService.TodoList.Add(new(inputText, (DateTime)limitDate));
        else
            TodoService.TodoList.Add(new(inputText));
        TodoService.Commit();
        inputText = "";
        OnAdd.InvokeAsync();
    }
}

これのスタイルシートをRazorコンポーネントを追加するのと同じ手順でTodoCreateModal.razor.cssという名前で作成します。

このように作成すると、対象のRazorコンポーネントのみに適用するスタイルシートになります。

内容は以下のようにします。

.modalArea {
    position: fixed;
    z-index: 100;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
}

.modalBg {
    width: 100%;
    height: 100%;
    background-color: rgba(30,30,30,0.9);
}

.modalWrapper {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%,-50%);
    width: 70%;
    max-width: 500px;
    padding: 10px 30px;
    background-color: #fff;
}

他ページの要らない部分を削る

NavMenu.razorを以下のように修正します。

@using MauiBlazorTodoApp.Data
<div class="navbar navbar-dark bg-dark">
	<a class="navbar-brand m-2" href="">MauiBlazorTodoApp</a>
	<button title="Navigation menu" @onclick="ToggleModal" class="btn btn-primary m-2">
		+
	</button>
	<TodoCreateModal IsOpen="@openModal" OnClose="@ToggleModal" OnAdd="@OnAddTodo"/>
</div>
@code {
	private bool openModal = false;
	private void OnAddTodo()
	{
		openModal = false;
	}

	private void ToggleModal ()
	{
		openModal = !openModal;
	}
}

MainLayout.razorを以下のように修正します。

@inherits LayoutComponentBase

<div>
	<NavMenu />
	<main>
		<article class="content px-4">
			@Body
		</article>
	</main>
</div>

不具合確認と修正

この状態で一度実行してみます。

右上の+ボタンでモーダルが現れ、Todoのタイトルや期限を記入できることが確認できます。

ただ、追加しても一覧に反映されておらず、既存のTodoを削除したり、アプリを再起動しないと表示されません。

これはListの内容が変更されていることをBlazor側が検知できていなく、再レンダリングが行われていないためです。

今回はListからObservableCollectionという変更時にイベントを実行できるリストに使用を変更します。

TodoService.csの以下を修正します。

...
        public static TodoService Instance 
        { 
            get
            {
                if(_instance == null) 
                    _instance = new TodoService();
                return _instance;
            }
        }

+       public ObservableCollection<Todo> TodoList = new();
-       public List<Todo> TodoList = new();
        public TodoService()
        {
            string filePath = Path.Combine(
                FileSystem.Current.AppDataDirectory, 
                "todolist.json");
...

Index.razorの以下の部分を修正します。

@code {
    TodoService todoService = TodoService.Instance;
+   protected override void OnInitialized()
+   {
+       todoService.TodoList.CollectionChanged += (sender, e) =>
+       {
+           StateHasChanged();
+       };
+   }
}

Todoアプリの完成

以上で基本的なTodoアプリが完成しました。

今までWindowsで実行していましたが、実行ボタン部分よりAndroid Emulatorsを選択することでAndroidでも実行が可能です。ネットワーク上にMacがあり指示に従って接続をすればiOSデバイスでも実行が可能です。

おまけ(ネイティブの機能を使う)

このままだとあまりネイティブアプリのうま味がないので、締め切りが今日のものがあったときにトースト通知をする処理を追加してみます。

ソリューションのNugetパッケージの管理からCommunityToolkit.Mauiというパッケージを導入します。

NuGetを使った事が無い方は以下の画像を参考にインストールしてください。

.NET 7を使っている方は最新バージョンでも大丈夫ですが、.NET 6の場合はCommunityToolkit.Mauiのバージョンを2.0.0にします。(詳しくは依存関係を確認してください)

Todo読み込み時にトーストを表示する処理を追加します。

...
using System.Threading.Tasks;
+using CommunityToolkit.Maui.Alerts;

namespace MauiBlazorTodoApp.Data
...
        public TodoService()
        {
            string filePath = Path.Combine(
                FileSystem.Current.AppDataDirectory,
                "todolist.json");
            if (File.Exists(filePath))
            {
                string json = File.ReadAllText(filePath);
                TodoList = new(JsonSerializer.Deserialize<List<Todo>>(json));
+               DateTime lastTime = new DateTime(DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day, 23, 59, 59);
+               TodoList.Where(t => 
+                   t.Limit is null ? 
+                   false : 
+                   ((DateTime)t.Limit).CompareTo(lastTime) == -1 && 
+                   ((DateTime)t.Limit).CompareTo(lastTime.AddDays(-1)) == 1).ToList().ForEach(t =>
+                   {
+                       Toast.Make($"本日期限:{t.Title}", CommunityToolkit.Maui.Core.ToastDuration.Long,14).Show();
+                   });
            }
        }
...

今日が期限のTodoを作りアプリを開き直すと表示されるのが確認できます。

Androidでも問題無く動作していることが確認できます。

最後に

C#とHTMLで比較的簡単にWindows,Android,iOS対応アプリが組めましたね。

今回作成したアプリのコードは以下のリポジトリから入手可能です。

MauiBlazorTodoApp (GitHub)

Discussion