UI 層(3層に分けた理由)
UI を3層に分ける理由
Section titled “UI を3層に分ける理由”FloatSoda は、UI の部品(ボタンなど)を、コア、ヘッドレス、デザインシステムの3層に分けます。置き場所のプロジェクトは、コア(FloatSoda)、ヘッドレスの振る舞い(FloatSoda.UI)、デザインシステム(FloatSoda.UI.Cream と FloatSoda.UI.FizzyPop)の4つです。分ける理由は、見た目と振る舞いが一体になることを避けるためです。
一体になると、別の見た目の部品を作りたいときに、振る舞いのコードを複製することになります。プロジェクトの設計方針は、Flutter で起きた「Material ロックイン」を例にしています。振る舞いの層が独立せず、見た目と振る舞いが material パッケージに一体化したため、別の見た目の cupertino が振る舞いを複製することになった、という問題です。
3層の役割と、置き場所の基準
Section titled “3層の役割と、置き場所の基準”新しい部品を足すときは、置く層を、次の表で仮に決めます。
| 層 | プロジェクト | 置くもの | 置かないもの |
|---|---|---|---|
| コア | FloatSoda |
Skia や RenderObject の型に触れるウィジェット。見た目の方針を持たない合成ウィジェット(Center、Container など) |
インタラクションの状態機械、具体的な見た目 |
| ヘッドレス | FloatSoda.UI |
インタラクションの状態機械(ButtonBase、InteractionState)。見た目を受け取る型付きの入口 |
色、余白、角丸 |
| デザインシステム | FloatSoda.UI.Cream、FloatSoda.UI.FizzyPop |
ヘッドレスの状態から見た目への対応。スタイルの record(ButtonStyle)、テーマ |
振る舞いの状態機械 |
プロジェクトの参照は、次の向きです。
FloatSoda ◀── FloatSoda.UI ◀── FloatSoda.UI.Cream ▲ └────────── FloatSoda.UI.FizzyPop2つのデザインシステムは、お互いを参照しません。デザインシステムは、FloatSoda.UI を通して、コアのウィジェットも使えます。
2つの規約と、判定基準
Section titled “2つの規約と、判定基準”置き場所は、次の2つの規約と、1つの判定基準で確定します。
- 振る舞いと状態機械は
FloatSoda.UIに置き、デザインシステムのStateには、ヘッドレスの状態から見た目への対応だけを書くこと FloatSoda.UIのウィジェットは、デザインシステムのInheritedWidgetがなくても動くこと
規約1は、Flutter の material に振る舞いが入った轍を踏まないためです。規約2のため、ヘッドレス層はデザインシステムのテーマに依存させません。デザインシステム側のテーマの Of() は、祖先にテーマが見つからないときに null を返し、部品は既定のスタイルに戻ります。FloatSoda.UI.Cream の Button は、スタイルが未指定なら祖先の CreamTheme を、それもなければ ButtonStyle.Default を使います。
判定基準は、「2つ目のデザインシステムが、1つ目のコードをコピーせずに、同じ部品を作れるか」です。Cream と FizzyPop を最初から並べて作るのは、この基準を常に確かめるためです。ヘッドレス層の API に片方のデザインシステムの都合が漏れると、もう片方が作れなくなるので、漏れに気づけます。
基準を満たさない場合は、必要な状態や拡張点がヘッドレス層に足りないか、片方のデザインシステムの都合がヘッドレス層の API に漏れていないかを確かめます。置き場所と API の形を見直します。
見た目を型付きのビルダーで渡す
Section titled “見た目を型付きのビルダーで渡す”ヘッドレス層は、見た目を Builder で受け取ります。ButtonBase は、required Func<IBuildContext, InteractionState, Widget> Builder を持ちます。InteractionState は、IsPressed、IsHovered、IsFocused、IsDisabled を持つ readonly record struct で、インタラクションの状態を型で公開します。デザインシステムは、Builder の中で、この状態を見た目に対応づけます。
次は、FloatSoda.UI.Cream の Button の処理の概略です。Builder が Widget を返す部分を省略しているため、このままではコンパイルできません。
new ButtonBase{ OnPressed = OnPressed, IsDisabled = IsDisabled, Builder = (ctx, state) => { var style = Style ?? CreamTheme.Of(ctx)?.ButtonStyle ?? ButtonStyle.Default; var background = state switch { { IsDisabled: true } => style.DisabledBackgroundColor, { IsPressed: true } => style.PressedBackgroundColor, _ => style.BackgroundColor }; // background で ColoredBox を作り、Child を包んで返す }};押下やホバーの状態を入力イベントから更新する処理は、ヘッドレス層に置きます。Builder は、渡された state に応じて色を選びます。自分の部品の Builder の形を設計するときは、デザインシステムが必要とする状態を、InteractionState に足すかどうかを先に決めます。
この方式は、Avalonia のルックレスコントロール(状態を疑似クラス、見た目の注入を PART_ のテンプレートパーツで行う契約)を、型付きにした形です。InteractionState のコメントが、この対応を述べています。文字列の疑似クラスの代わりに型付きの InteractionState を、名前で探す PART_ の代わりに型付きの Builder を使います。
実装の進み具合の追い方
Section titled “実装の進み具合の追い方”このページは、実装の進み具合を書きません。進み具合は、次の Issue で追います。
| Issue | 追うもの |
|---|---|
| #102 | ButtonBase の押下・ホバー状態(GestureDetector との接続) |
| #78 | Cream の Button と、テーマ |
| #100 | FizzyPop の Button と、テーマ |
| #38 | 背景ブラー |
| #182 | ダッシュボード以外のオーバーレイへのポインター入力 |
3つのプロジェクトを NuGet で配布しているかは、各プロジェクトの .csproj の IsPackable で確かめます。