はじめに
弁護士ドットコム デザイナーの林(@taka_piya)です。 弁護士ドットコム 案件管理システムでは、アプリケーションとUIの状態管理にXStateを用いたステートマシンでの管理を導入しています。
この記事では、UIデザインの考え方にステートマシンを導入し、実装まで一気通貫で行う方法と、そのメリットについて説明します。
UIは2つの要素からなっている
Android デベロッパーのドキュメントにある一文を紹介します。
ユーザーが目にするものが UI であるとすれば、ユーザーが目にするべきであるとアプリがみなすものが UI 状態です。同じコインの両面のように、UI は UI 状態を視覚的に表したものです。UI 状態が変更されると、すぐに UI に反映されます。
UIは表示要素と状態からなるというこの定義は、Androidアプリに限らず、デジタルプロダクトのUI全般で同様に言えるでしょう。
今回は、UIからUI状態を分離しステートマシンで管理するという試みです。
ステートマシンを理解する
定義
ステートマシンとは、状態の集合と、 初期状態 、入力、入力によって引き起こされる別の状態への遷移をモデル化したものです。*1
中でも、次に遷移すべき状態が常に一意に定まるものは決定性有限ステートマシンと呼び、今回は決定性有限ステートマシンであることを前提にします。
定義だけ聞くと難しそうですが、次のこの状態遷移図を見ると理解が進むでしょう。
状態遷移図
ステートマシンは挙動のモデルなので、様々なかたちで表現できます。*2
状態遷移図とはステートマシンの挙動を図示したものです。
駅でよく見る鍵式コインロッカーを例に考えてみます。 決められた枚数コインを入れると施錠できるようになり、ひねれば施錠し、戻せば解錠される、このサービスを1度は使った経験があると思います。
このコインロッカーの挙動をステートマシンとして捉え、状態遷移図を表すと以下のようになります。
- 未入金、入金済、ロックの3つの状態があります。
- 初期状態は「未入金」状態からスタートし、入金という入力(イベント)によって「入金済」状態に遷移します。 「未入金」状態では鍵をかけようとしてもかかりません(遷移しない)。
- 同様に「入金済」状態で入金を繰り返しても、状態としては変わりません。
- 施錠すると「ロック」状態になり、解錠すると「未入金」状態に戻ります。
どうでしょう。この図から、コインロッカーの挙動が浮かんできますよね。
さらに、個々の状態が1画面に対応すると考えると、画面遷移図にも近いように見えませんか。 画面遷移図との違いは、画面の役割でデザインをしているのではなく、状態に基づくデザインをしていると捉え直すことで、最小のUIパターン数で網羅できる点です。
ステートマシンを使ったUIデザイン〜実装プロセス
それでは実際の作成手順を追っていきましょう。
ステートマシンを定義する
ステートマシンの定義を最初に行うことが必要です。 ここでは、業務系システムでよくあるレコードを追加するモーダルUIを例に考えていきます。
状態と入力を書き出すと以下のようになります。このときUI上の入力だけではなくAPI通信など他の入力によって起きる状態も含めて書き出すことで、想定すべき状態が洗い出せます。 この定義のプロセスを踏むことが一番の鍵です。
今回は5状態を想定しました。
- idle - モーダルが非表示
- open - モーダルが表示
- pending - 通信中
- success - 送信完了
- failed - 通信失敗
Figmaで表現する
次に書き出した個々の状態に対するUIを当て込んでいきます。
状態と入力を先に書き出しておいたことによって、その入力を引き起こす要素を意識しながら検討できます。
実際にデザインする際は、Figmaでは1つの状態に対し1つのFrameもしくはVariablesを対応させるとわかりやすいです。
JavaScriptで表現する
同様にコードでステートマシンを実装します。
今回はTypeScriptとXStateを利用しますが、VanillaなJavaScriptでも実装できます。
まずはステートマシンの入力を定義します。
type Events = | { type: "OPEN" } | { type: "CLOSE" } | { type: "SEND" } | { type: "RESOLVE" } | { type: "REJECT" } | { type: "DONE" } | { type: "RETRY" };
そして状態と初期状態を定義します。
import { createMachine } from 'xstate'; const modalMachine = createMachine({ id: 'modal', schema: { events: {} as Events, // イベント定義を参照する }, initial: 'idle', // 初期状態 states: { // 状態の集合 idle:{}, open:{}, pending:{}, success:{}, failed:{} } })
最後に入力に対する状態の遷移を定義します。
const machine = createMachine( { id: "FormModal", initial: "idle", schema: { events: {} as Events, }, states: { idle: { on: { OPEN: "open", //入力に対する遷移先を設定する }, }, open: { on: { CLOSE: "idle", SEND: "pending", }, }, pending: { on: { RESOLVE: "idle", REJECT: "failed", }, }, success: { on: { DONE: "idle", }, }, failed: { on: { RETRY: "pending", }, }, }, } );
ステートマシンをアプリケーションに適用する
Vue.jsを利用した場合のステートマシンを実際に利用するサンプルを用意してみました。
コンポーネントの出しわけを、複数のフラグの組合せではなく、v-if = state.value === "XXXX"
のように1つの状態を参照することで実現しているため、フラグを組合わせた条件よりわかりやすいでしょう。
<template> <button @click="send({ type: 'OPEN' })">ADD ITEM</button> <div v-if="state.value !== 'idle'" class="AddItemModal"> <div class="AddItemModal__inner"> <label>NAME:<input :disabled="state.value === 'pending'" /></label> <div> <button @click="send({ type: 'CLOSE' })">CANCEL</button> <button @click="send({ type: 'SEND' })" :disabled="state.value === 'pending'" > {{ state.value === "pending" ? "loading" : "SEND" }} </button> </div> </div> </div> <!-- トースト --> <div v-if="state.value === 'success'" class="Toast Toast__success"> 成功! </div> <div v-if="state.value === 'failed'" class="Toast Toast__failed">失敗!</div> </template> <script lang="ts"> import { defineComponent } from "vue"; import { useMachine } from "@xstate/vue"; import { machine } from "./path_to_machine/machine"; export default defineComponent({ setup() { const { state, send } = useMachine(machine); return { state, // ステートマシン send, //ステートマシンに入力を与える関数 }; }, }); </script>
実際のデモはこちらをご覧ください。
デモのイメージ(詳細)
UIデザインにステートマシンを導入するメリット
ステートマシンを導入するメリットをあらためて3つの視点からまとめます。
振る舞いに集中できる
例えば「フォーム変更があった場合は、閉じる入力情報を破棄していいか確認したい」と仕様追加があったときにどうしたら良いでしょう。 UIの表示をどうする? ではなく「1つ状態を追加する必要がある」「追加したその状態はどこから遷移してくるか」を検討するでしょう。
UIと状態を切り離したことで見た目やコードに左右されず、達成すべきUIの振る舞いに着目できる思考の変化が起きます。
チームの共通認識として使える
先立ってステートマシンを定義さえしておけば、その定義をFigmaでも、コードでも、物理でもシミュレートできるのは見てきたとおりです。
定義したステートマシンこそがUIの挙動、ひいてはアプリケーションの挙動を表すものとなり、共通認識として利用できます。 状態遷移図を活用すれば、デザイナー以外ともコミュニケーションが取りやすくなるでしょう。
変更に強い
ステートマシンは計算が可能です。
計算可能ということは、定義した遷移が実現できているか、コードベースでテストしたり、コードから状態遷移図を作ることもできます。
到達してはいけない状態変更や、到達不可能な状態が存在しないかをチェックしやすいため、抜け漏れを減らすことができます。 またステートマシンに1つ状態を追加しても、アプリケーション側で入力を変えない限りは挙動が変わりません。実装されたUIも壊れにくいといえます。
おわりに
ステートマシンを使ってUI状態を分離すること、そのメリット、実現の仕方を見てきました。 重要なのは、UIをデザインするときに状態を分離し、状態もシステムとして扱うことで、考えやすくなるという点です。
思考の下敷きとして「こういう考え方もあったな」と頭の片隅においていただけると、堅牢なUIデザインに一歩近づくのではないでしょうか。
今回ご紹介した話は、トップダウンなアプローチです。そのためプロダクトの初期状態が想定できない場合などステートマシンがなかなか適用しにくいこともあります。
ぜひ、目的に合わせて試してみてください!
関連ドキュメント
ALPS-ASD
アプリケーションの挙動や語彙を表現するフォーマット(ALPS)を使ってアプリケーションの遷移図を表現するためのドキュメント生成ツール(ASD)です。 やりたいことは近いと思います。
先日のPHP Conferenceでも触れられていますので、ぜひこちらもご覧ください。 creators.bengo4.com
*1:厳密には受理状態も含まれますが、今回は登場しません。