Ignite UI for Blazor のドックマネージャーを使うことで、ペインのドッキングやサイズ変更といった、Visual Studio IDE のような高度なレイアウト機能を Blazor アプリケーションに搭載することができます。このドックマネージャーと Blazor の DynamicComponent コンポーネントを組み合わせると、ユーザーが自分で自分の好きなように選んだコンポーネントをドックマネージャーのペインとして追加・レイアウト・削除することができる Blazor アプリケーションを構築できます。
このブログシリーズでは、実行時にユーザーが自分が選んだコンポーネントをドックマネージャー上に追加・レイアウト・削除できる Blazor アプリケーションをどのようにして実装するかを、前編・後編の 2 回にわけて解説します。本ブログ記事はその後編です。(前編はこちら)
作成するサンプルプログラムとソースコード
このブログシリーズでは、以下の 2 つのコンポーネントを用意し、
- "Increment" ボタンをクリックするごとにカウンター数値が加算される "Counter" コンポーネント
- "Message" パラメータに指定した文字列を h1 要素で表示する "TextMessage" コンポーネント
これら 2 種類のコンポーネントからユーザーが選択してドックマネージャーのペインに複数追加できる Blazor アプリケーションを作成する方法について解説します。サンプル Blazor アプリケーションの画面イメージは下図のとおりです。なお、このブログシリーズでは、このサンプルプログラムを Blazor WebAssembly で実装しましたが、もちろん、同等のアプリケーションを Blazor Server でも実装できます。
急ぎ結果を知りたい方のために、このサンプルプログラムのソースコード一式を、下記リンク先の GitHub リポジトリに公開してあります。またこの Git リポジトリに記録されているコミット履歴は、このブログシリーズで解説している手順ごとにコミットしているため、開発途中の手順・コードの差分を確認いただくこともできます。
また、実際に動作するライブデモンストレーションサイトを用意しました。下記リンク先から開いて動作を試して頂けます。ぜひアクセスしてみてください。
前編のおさらいと、後編の目標
前編では、Blazor の DynamicComponent を使い、ユーザーがページ上の操作で選んだコンポーネントを実行時に動的に配置できるところまで、サンプルプログラムの作り込みを進めました。また、どのコンポーネントを表示するかの情報をブラウザのローカルストレージに永続化し、ページを再読み込み・再訪問しても、状態が維持されるところまで実装しました。
そしてこの後編では、いよいよ Ignite UI for Blazor のドックマネージャーを導入し、Visual Studio IDE のような高度なレイアウト機能と組み合わせて、最終目標のサンプルプログラムへと仕上げていきます。
サンプルプログラムの開発を続ける
ドックマネージャー用の支援機能を導入
Ignite UI for Blazor のドックマネージャーは、このブログ記事を作成している時点ではまだプレビューとなっており、いくつかの機能の利用にあたっては、JavaScript 相互運用機能を介しての操作が必要です。今後のバージョンアップによる変更・改善が想定されますが、今日時点で今回のサンプルプログラムの開発を円滑に進められるよう、ドックマネージャーに対する JavaScript 呼び出しをラップした「ドックマネージャー支援サービスクラス」を用意しました。下記の 2 つのソースコードを、今回のサンプルプログラムのプロジェクトに追加します。本ブログ記事の冒頭でご案内した、GitHub リポジトリから該当するコードを取得することもできます。
📂wwwroot > 📄dockmanager-helper.js
// 📂wwwroot > 📄dockmanager-helper.js function getDockManagerInternal(dockManagerContainerSelector) { const dockManagerContainer = document.querySelector(dockManagerContainerSelector); const dockManager = dockManagerContainer?.querySelector("igc-dockmanager") || dockManagerContainer ?.querySelector("igc-component-renderer-container") ?.shadowRoot ?.querySelector("igc-dockmanager"); return dockManager || null; } function getDockManager(dockManagerContainerSelector) { return new Promise((resolve, reject) => { const dockManager = getDockManagerInternal(dockManagerContainerSelector); if (dockManager !== null) { resolve(dockManager); } else { let counter = 0; const timerId = setInterval(() => { counter++; const dockManager = getDockManagerInternal(dockManagerContainerSelector); if (dockManager !== null) { clearInterval(timerId); resolve(dockManager); } else if (counter > (5000 / 10)) { clearInterval(timerId); reject(); } }, 10) } }); } export async function attachContentPane(dockManagerContainerSelector, contentId, header) { const dockManager = await getDockManager(dockManagerContainerSelector); const slot = document.createElement("slot"); slot.name = contentId; slot.slot = contentId; dockManager.appendChild(slot) const newPane = { type: "contentPane", contentId: contentId, header: header }; dockManager.layout.rootPane.panes.push(newPane); dockManager.layout = { ...dockManager.layout }; } export async function restoreLayout(dockManagerContainerSelector, layout) { const dockManager = await getDockManager(dockManagerContainerSelector); dockManager.layout = JSON.parse(layout); } let eventHandlerCounter = 0; const eventHandlers = new Map(); export async function subscribeEvent(dockManagerContainerSelector, eventName, handlerId, dotNetObjRef, callbackMethodName) { const dockManager = await getDockManager(dockManagerContainerSelector); const eventHandler = { eventName: eventName, callback: (e) => { const eventArgs = (() => { switch (e.type) { case "layoutChange": return JSON.stringify(dockManager.layout); case "paneClose": return JSON.stringify(e.detail.panes.map(pane => pane.contentId)); default: return null; } })(); dotNetObjRef.invokeMethodAsync(callbackMethodName, handlerId, eventArgs); } }; dockManager.addEventListener(eventHandler.eventName, eventHandler.callback); const subscriptionId = eventHandlerCounter++; eventHandlers.set(subscriptionId, eventHandler); return subscriptionId; } export async function unsubscribeEvent(dockManagerContainerSelector, subscriptionId) { const dockManager = await getDockManager(dockManagerContainerSelector); const eventHandler = eventHandlers.get(subscriptionId) || null; if (eventHandler === null) return; eventHandlers.delete(subscriptionId); dockManager.removeEventListener(eventHandler.eventName, eventHandler.callback); }
📂Services > 📄DockManagerHelper.cs
// 📂Services > 📄DockManagerHelper.cs using System.Collections.Concurrent; using System.Text.Json; using Microsoft.JSInterop; namespace {プロジェクトのルート名前空間}.Service; public class DockManagerHelper : IAsyncDisposable { private readonly IJSRuntime _JSRuntime; private readonly DotNetObjectReference<DockManagerHelper> _This; private IJSObjectReference? _JSModule; private class AsyncDisposer : IAsyncDisposable { private readonly Func<ValueTask> _CallBack; public AsyncDisposer(Func<ValueTask> callBack) => this._CallBack = callBack; public ValueTask DisposeAsync() => this._CallBack.Invoke(); } public DockManagerHelper(IJSRuntime jSRuntime) { this._JSRuntime = jSRuntime; this._This = DotNetObjectReference.Create(this); } private async ValueTask<IJSObjectReference> GetHelperJsModuleAsync() { if (this._JSModule != null) return this._JSModule; this._JSModule = await this._JSRuntime.InvokeAsync<IJSObjectReference>("import", "./dockmanager-helper.js"); return this._JSModule; } public async ValueTask RestoreLayoutAsync(string selector, string layoutInfo) { var module = await this.GetHelperJsModuleAsync(); await module.InvokeVoidAsync("restoreLayout", selector, layoutInfo); } public async ValueTask AttachContentPaneAsync(string selector, string id, string headerText) { var module = await this.GetHelperJsModuleAsync(); await module.InvokeVoidAsync("attachContentPane", selector, id, headerText); } private readonly ConcurrentDictionary<string, Func<string, ValueTask>> _EventHandlers = new(); public async ValueTask<IAsyncDisposable> SubscribeEventAsync<TArgs>(string selector, string eventName, Func<TArgs?, ValueTask> callBack) where TArgs : class { Func<string, ValueTask> handler = typeof(TArgs) == typeof(string) ? (string args) => callBack.Invoke(args as TArgs) : (string args) => callBack.Invoke(JsonSerializer.Deserialize<TArgs>(args)); var handlerId = Guid.NewGuid().ToString(); var module = await this.GetHelperJsModuleAsync(); var eventId = await module.InvokeAsync<int>("subscribeEvent", selector, eventName, handlerId, this._This, nameof(EventHandler)); this._EventHandlers.AddOrUpdate(handlerId, handler, (_, _) => handler); return new AsyncDisposer(async () => { this._EventHandlers.Remove(handlerId, out var _); await module.InvokeVoidAsync("unsubscribeEvent", selector, eventId); }); } [JSInvokable] public async Task EventHandler(string handlerId, string args) { if (!this._EventHandlers.TryGetValue(handlerId, out var handler)) return; await handler.Invoke(args); } public ValueTask<IAsyncDisposable> SubscribeLayoutChangeEventAsync(string selector, Func<string?, ValueTask> callBack) { return this.SubscribeEventAsync(selector, "layoutChange", callBack); } public ValueTask<IAsyncDisposable> SubscribePaneCloseEventAsync(string selector, Func<string[]?, ValueTask> callBack) { return this.SubscribeEventAsync(selector, "paneClose", callBack); } public async ValueTask DisposeAsync() { this._This.Dispose(); if (this._JSModule != null) { await this._JSModule.DisposeAsync(); } } }
こうして用意したドックマネージャー支援サービスクラスを、Program.cs
にて DI 機構に登録しておきます。
// 📄Program.cs using {プロジェクトのルート名前空間}.Services; // 👈 この名前空間を開き... ... builder.Services.AddScoped<DockManagerHelper>(); // 👈 このサービス登録を追加 ...
および、このドックマネージャー支援サービスクラスを App.razor
内で使いますので、@inject
ディレクティブを追記して、DI 機構からプロパティに注入してもらうようにします。
*@ 📄App.razor @* ... *@ 👇 この @inject を追加 @* @inject IgbDockManagerDemo.Services.DockManagerHelper Helper ...
これでドックマネージャーを使う準備ができました。
IgbDockManager で動的に表示するコンポーネントを囲む
いよいよ、App.razor
内にドックマネージャーコンポーネントを配置していきますが、その際に、ドックマネージャーの初期レイアウト情報を与える必要があります。そこで下記のように、App.razor
のフィールド変数として、ドックマネージャー用のレイアウト情報オブジェクトを用意しておきます。
*@ 📄App.razor @* ... @code { ... // 👇 このフィールド変数を追加 private readonly IgbDockManagerLayout _InitialLayout = new IgbDockManagerLayout() { RootPane = new IgbSplitPane { PaneType = DockManagerPaneType.SplitPane, Orientation = SplitPaneOrientation.Horizontal, Panes = new IgbDockManagerPaneCollection() } }; ...
(※なお、本ブログ記事作成時点では、上記ソースコードに対し BL005
の警告が発生される場合があります。このソースコードにおいては問題ありませんので、該当箇所の部分だけ BL005
の警告を無視するか #pragam
等で抑止してください。)
@foreach
のループで表示コンポーネントを列挙して表示している箇所を、ドックマネージャーコンポーネント (<IgbDockManager>
) で囲みます。
このとき、先に用意したレイアウト情報の初期値 _Layout
フィールド変数を、ドックマネージャーのパラメータに渡します。
また、ドックマネージャー支援サービスクラスを介してこのドックマネージャーの DOM 要素を識別できるよう、id="dockmanager1"
として HTML の id 属性を付与しておきます。
さらに、個々の表示コンポーネントを囲む <div>
要素には、slot
属性を書き加え、これに表示コンポーネントの ID を指定しておきます。ドックマネージャーが、この slot
属性に書かれた識別子をドックマネージャー内の個々のペインと関連付けすることで、ペイン内に各コンポーネントが表示されます。
*@ 📄App.razor @* ... <!-- 👇 表示コンポーネントを foreach で列挙して表示する部分を、IgbDockManager で囲む --> <IgbDockManager id="dockmanager1" Layout="this._InitialLayout" Width="800px" Height="400px"> @foreach (var descriptor in this._Components) { var typeOfComponent = _ComponentNameToType[descriptor.ComponentName]; // 👇slot 属性を追加し、表示コンポーネントの ID を指定する <div @key="descriptor.Id" slot="@descriptor.Id"> <DynamicComponent Type="typeOfComponent" Parameters="descriptor.Parameters" /> </div> } </IgbDockManager> ...
追加したコンポーネントをドックマネージャーに "取り付け" する
ここまでで、いったん、ブラウザの開発者ツールを開いてローカルストレージをクリアし、ページを再読み込みしてから、改めて "Add Counter" ボタンをクリックしてみます。すると残念ながら、まだドックマネージャー上には何の変化も現れません。ブラウザ上の DOM 構造には、たしかに "Counter" コンポーネントの DOM 要素が出現してはいます。しかし、その追加されたコンポーネントをドックマネージャーのペイン内で表示するよう、新規ペインの追加、および、先に書きましたとおり slot
属性を介したペインとの関連付けが行なわれていないためです。
これを行なうためには、ドックマネージャー支援サービスクラスの AttachContentPaneAsync()
メソッドを呼び出して、「追加されたコンポーネントを、ドックマネージャーに "取り付ける" (新規ペインを追加し、slot 属性を介した関連付けを設定する)」処理が必要です。
具体的には、前編にて実装しました「表示するコンポーネントを追加する」メソッド、AddComponentAsync()
の中に、この "取り付け" 処理を書くのが最適でしょう。取り付け先のドックマネージャーは、先にドックマネージャーの属性で指定した id="dockmanager1"
を元に CSS セレクター形式で第1引数に指定します。追加した表示コンポーネントの slot
属性には、表示コンポーネントの Id
を指定していますから、これを新規ペインに関連付けるよう第2引数で指定します。さらにドックマネージャーのペインには "ヘッダー文字列" というものがあり、新規ペインを追加する以上そのヘッダー文字列を何とするかの指定が必要なので、今回のサンプルプログラムではコンポーネント名をそのまま指定することとしました。
*@ 📄App.razor @* ... @code { ... private async ValueTask AddComponentAsync(ComponentDescriptor descriptor) { ... // 👇 ドックマネージャーに追加した表示コンポーネントを取り付ける、この行を追加 await this.Helper.AttachContentPaneAsync( "#dockmanager1", descriptor.Id, descriptor.ComponentName); ... } ...
ここまでで実装したら、もういちどブラウザのローカルストレージをクリアしページを再読み込みし、"Add Counter" ボタンをクリックしてみると、今度は期待どおりドックマネージャーのペインとして "Counter" コンポーネントが追加され出現します! 同じように "Add TextMessage" ボタンも機能します。
ページ再読み込みに対応する
さてここまでの状態で、いくつかペインにコンポーネントを追加したりレイアウト変更したりしたあと、ページを再読み込みするとどうなるでしょうか。前編までの実装により、表示コンポーネントの状態はブラウザのローカルストレージに保存された JSON 文字列から復元されます。しかし、ドックマネージャーのペインについては、当然のことながらそのレイアウト状態の保存・復元について何も実装していませんので、復元されません。引き続き、ドックマネージャーのレイアウト状態の保存・復元を作り込んでいきます。
ドックマネージャーのレイアウト状態の保存先は、表示コンポーネントの情報と同じくブラウザのローカルストレージに保存することにします。
ドックマネージャーのレイアウト状態は、どのタイミングで、どのようにして保存すればよいでしょうか? 幸い、ドックマネージャーには 「レイアウトが変更されたとき」に発生するイベントが用意されています。この「レイアウトが変更されたとき」イベントを Blazor アプリケーション側でハンドルし、レイアウト状態をローカルストレージに保存すればよいです。ドックマネージャーのレイアウト状態は、JSON 文字列で扱えます。
ドックマネージャー支援サービスには「レイアウトが変更されたとき」イベントを購読するためのメソッド SubscribeLayoutChangeEventAsync()
を用意してあります。このメソッドの引数にイベント発生時に呼び出されるコールバック関数を渡せばよいです。このメソッドを呼び出すと、戻り値に IAsyncDisposable
オブジェクトが返ります。イベント購読が不要になったら、この IAsyncDisposable
オブジェクトを破棄 (DisposeAsync()
メソッドを呼び出す) することを忘れないでください (そうしないと、予期しない不具合やメモリリークを引き起こします)。
それでは、イベント購読時に返される IAsyncDisposable
オブジェクトを最後に破棄できるよう保存するフィールド変数を App.razor
に用意します。このあと他のイベント購読も行なうため、IAsyncDisposable
オブジェクトを複数保持しておけるよう、リストのフィールド変数を用意します。
*@ 📄App.razor @* ... @code { ... // 👇 このフィールド変数を追加 private readonly List<IAsyncDisposable> _Subscriptions = new(); ...
続けて、ドックマネージャー支援サービスが利用可能になる OnAfterRenderAsync()
のタイミングで、ドックマネージャーのレイアウト変更時イベントの購読を開始します。
*@ 📄App.razor @* ... @code { ... protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { ... // 👇 レイアウト変更時イベントを購読する以下の行を追加 // (コールバックメソッド "OnLayoutChange" はこのあと実装) this._Subscriptions.Add( await this.Helper.SubscribeLayoutChangeEventAsync( "#dockmanager1", OnLayoutChange)); } ...
ドックマネージャーのレイアウト変更時イベント発生時のコールバックメソッドは OnLayoutChange
とします。このコールバックメソッドには、その引数に、JSON 文字列でドックマネージャーのレイアウト情報が渡されます。これをローカルストレージに保存すればよいでしょう。ローカルストレージに保存する際のキーは "layout" としました。
*@ 📄App.razor @* ... @code { ... // 👇 この OnLayoutChange メソッドを追加 private async ValueTask OnLayoutChange(string? layout) { if (layout == null) return; // 引数に渡されたレイアウト情報の JSON 文字列を、ローカルストレージに保存 await this.LocalStorage.SetItemAsStringAsync("layout", layout); } ...
次に必要なのは、こうしてローカルストレージに保存されたドックマネージャーのレイアウト情報を、ページの初回表示時に復元することです。ドックマネージャーのレイアウト状態の復元のために、ドックマネージャー支援サービスに RestoreLayoutAsync()
メソッドを用意してあります。再び OnAfterRenderAsync()
の実装箇所に戻り、ローカルストレージからレイアウト情報の取得とレイアウト復元の処理を書き足します。
*@ 📄App.razor @* ... @code { ... protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { ... this.StateHasChanged(); // ローカルストレージに保存しておいたレイアウト情報から、レイアウトを復元 var layout = await this.LocalStorage.GetItemAsStringAsync("layout"); if (!string.IsNullOrEmpty(layout)) { await this.Helper.RestoreLayoutAsync("#dockmanager1", layout); } ...
あとは、購読したイベントを、App.razor
の破棄の時点であわせて破棄することを忘れないようにしましょう。App.razor
自身の破棄の処理を書くことができるよう、IAsyncDisposable
インターフェースを App.razor
に実装します。まずは @implemets
ディレクティブを App.razor
の冒頭に書き足します。
*@ 📄App.razor @* @implements IAsyncDisposable *@ 👈 この行を追加 @* ...
および、IAsyncDisposable.DisposeAsync()
メソッドを実装し、フィールド変数に保持しておいた IAsyncDisposable
オブジェクトを1つずつ列挙しながら破棄 (DisposeAsync()
メソッドを呼び出す) していきます。
*@ 📄App.razor @* ... @code { public async ValueTask DisposeAsync() { var valueTasks = this._Subscriptions .Select(subscription => subscription.DisposeAsync()) .ToArray(); foreach (var valueTask in valueTasks) { await valueTask; } } }
ここまでできたら、もういちどブラウザのローカルストレージをクリアしページを再読み込みします。その上で "Add Counter" ボタンをクリックしたりペインのレイアウトを変更してみたりしたあと、再びページを再読み込みすると、今度はドックマネージャーのレイアウト状態も含めて状態が復元されるはずです。
ペインが閉じられたときに対応する
これでペインの追加やレイアウトの変更には対応できました。しかし実は、ペインを閉じたときの処理が不完全です。ペイン右上の [X] をクリックすると、一見、期待どおりペインが消えたように見えます。しかし思い出してください。"どのコンポーネントをページ上に表示するか" は、App.razor
のフィールド変数 _Components
に基づいて表示しているのでした。しかしこの段階では、プログラムのどこにも _Components
から要素を削除する処理は存在しません。つまり、実は、閉じられたペインに取り付けられていたコンポーネントは、ペインが閉じられたことで表示する場所を失ってページ上は不可視となっているものの、ブラウザの DOM ツリーの中に存在し続けているのです (ブラウザの開発者ツールで要素を確認するとわかります)。
そこで、ドックマネージャーのペインが閉じられたら、そのペインに "取り付け" られていた表示コンポーネント (に対応するディスクリプタ) を _Components
から削除する必要があります。ドックマネージャーには「ペインが閉じられるとき」に発生するイベントが用意されており、ドックマネージャー支援サービスにはそのイベントを購読するための SubscribePaneCloseEventAsync()
メソッドが用意されています。「ペインが閉じられるとき」イベントの引数には、その閉じられるペインの ID、すなわち、このサンプルプログラムでは表示コンポーネントディスクリプタの ID が配列で引数に渡されます。そこでペインが閉じられるとき」イベントを購読し、そのコールバックメソッドにて閉じられるペインの ID に該当する表示コンポーネントのディスクリプタを _Components
フィールド変数から削除すればよいです。
まずは「ペインが閉じられるとき」イベントを購読します。イベント購読のタイミングは「レイアウト変更時」イベントの購読時と同じく OnAfterRenderAsync()
メソッド内です。コールバックメソッドは OnPaneClose
とします。
*@ 📄App.razor @* ... @code { ... protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { ... // 👇 ペインクローズ時イベントを購読する以下の行を追加 // (コールバックメソッド "OnPaneClose" はこのあと実装) this._Subscriptions.Add( await this.Helper.SubscribePaneCloseEventAsync( "#dockmanager1", OnPaneClose)); } ...
次いで「ペインが閉じられるとき」イベントのコールバックメソッド OnPaneClose
を実装します。
*@ 📄App.razor @* ... @code { ... // 👇 この OnPaneClose メソッドを追加 private async ValueTask OnLayoutChange(string[]? closedPaneIds) { // 引数に渡された閉じられたペインの ID 配列から、 // 同じ ID を持つ表示コンポーネントのディスクリプタを _Components から除去 if (closedPaneIds == null) return; var componentsToRemove = closedPaneIds .Select(id => this._Components.Find(descriptor => descriptor.Id == id)) .Where(descriptor => descriptor != null) .ToArray(); foreach (var descriptor in componentsToRemove) { this._Components.Remove(descriptor!); } // フィールド変数 _Components が変更されるのでローカルストレージに保存 await LocalStorage.SetItemAsync("components", this._Components); this.StateHasChanged(); } ...
これで本当に完成です! ペインが閉じられたら、そのペインに取り付けられていた表示コンポーネントも正しく DOM ツリーから消え去ります。
まとめ
Ignite UI for Blazor のドックマネージャーを使うことで、ペインのドッキングやサイズ変更といった、Visual Studio IDE のような高度なレイアウト機能を備えた Blazor アプリケーションを作成することができました。Blazor の DynamicComponent コンポーネントを応用することで、ユーザーが自分で自分の好きなように選んだコンポーネントをドックマネージャーのペインとして追加・レイアウト・削除することを実現できます。
Web アプリケーションのユーザーインターフェースであっても、デスクトップアプリケーションに劣らない高度な自由レイアウトを提供する Blazor アプリケーションを作成し、高い作業効率と生産性をユーザーに提供できます。
Blazor ドックマネージャーの利用にあたって、Ignite UI for Blazor のライセンスをお持ちでない場合は、30 日間のサポート付きトライアル版にてお試しいただけます。 トライアル版のダウンロードは、下記リンク先のフォームに必要事項を記入頂くことで可能です。
🚀 Ignite UI for Blazor 無料トライアル版のダウンロード
以上、本記事が皆様のアプリケーション開発のお役に立てば幸いです。
開発全般に関するご相談はお任せください!
インフラジスティックス・ジャパンでは、各プラットフォームの特別技術トレーニングの提供や、開発全般のご支援を行っています。
- 「古い技術やサポート終了のプラットフォームから脱却する必要があるが、その移行先のプラットフォームやフレームワークの検討が進まない、知見がない」
- 「新しい開発テクノロジーを採用したいが、自社内にエキスパートがいない。日本語リソースも少ないし、開発を進められるか不安」
- 「自社のメンバーで開発を進めたいが、これまで開発フェーズを外部ベンダーに頼ってきたため、ツールや技術に対する理解が乏しい」
- 「UIを刷新したい。UIデザインやUI/UXに関する検討の進め方が分からない。外部のデザイン会社に頼むと、開発が難しくなるのではないか、危惧している」
といったご相談を承っています。
お問い合わせはこちらから
お問い合わせフォームをご用意しております。ぜひお気軽にご連絡ください。