C#とHTMLでスマホアプリを作る MAUI Blazor
はじめに
こちらは、デジクリ 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
ではなくHTML
とCSS
で構築できるので、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
タグのonclick
もC#
の要素に接続する@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対応アプリが組めましたね。
今回作成したアプリのコードは以下のリポジトリから入手可能です。
Discussion