【Akashic Engine】リストビューで学ぶプログラミングの思考方法(中級者向け)
まえがき
typescript/Akashic Engine で一般的なリストビューすることを課題に、プログラミングの理解を進めることが目的です
Akashic Engine とプログラミングについて最低限の知識が必要です
最初はあまり考えすぎず、うんうんと思いながら読み、2周目で深く読む事をオススメします
作るリストビュー
考えていくリストビューは、上から下に項目が並ぶ表のようなものです
スクロールや選択などの機能はなく、文字が順序付きで並ぶ次のようなリストについて考えます
まずは文字を表示する簡単なリストビューについて考えます
次に複数の値を1つの項目として表示するリストビューについて考えます
最後に項目の並び替えを実装する方法について考えて終了となります
簡単なリストビュー
リストビューを Akashic Engine で作る場合、最低限の実装として以下のものを考えます
(最低限の実装として文字列を表示するリストビューです)
class ListView {
// この view はリストビューの見た目を持つエンティティです
public view: g.E;
public constructor(...) { ... }
public addContent(text: string): void {
new g.Label({
parent: this.view,
text,
y: ..., // this.view.length の値に応じて計算する必要があるでしょう
... // その他省略します
});
}
}
上記の実装では、項目を追加する addContent のみを考えました
これでリストビューに要素を追加することが出来ます
次に、追加した項目を削除する場合について考える必要があります
次の例を見てみましょう
class ListView {
... // view, constructor, addContent については省略します
public removeContent(text: string): void {
const index = this.view.children!.findIndex(x => x.text === text);
if (index === -1) return;
this.view.remove(this.view.children![index]);
// ここでは削除した場合の見た目(3個の項目の真ん中を削除すると穴が開く)ことについては考えない
// この問題は並び替え機能を考える時に一緒に解決します
}
}
これで項目の削除ができますが、問題があります
- 同じ名前の項目がある場合はどうする?
- リストビューの仕様として「同値な項目は許可しない」としますか?
この問題を解決するために addContent, removeContent を次のように変更してみましょう
class ListView {
... // view, constructor については省略します
private _nextKey = 0;
public addContent(text: string): number {
const content = new g.Label({...});
const key = _nextKey++;
content.tag = key;
return key;
}
public removeContent(key: number) {
const index = this.children!.findIndex(x => x.tag === key);
if (index === -1) return;
this.view.remove(this.children![index]);
}
}
addContent ではラベルを生成する時に、同時にKeyを生成しまして、それを tag に追加しました
この Key は絶対に重複しないため、ラベルは必ず一意な値を tag に持ちます
removeContent では一意な key を使うことで、同値な項目の問題を解決出来ました
[ワンポイント!]
ここまでに作成したリストビューを説明すると、
ListView は string を元に g.Label を生成する
と説明出来ます
複雑なデータを扱うリストビュー
では、より複雑なデータを表示したい場合について考えましょう
例えば以下のような Player を1つの項目として表示したい場合です
interface Player {
playerId: string;
name: string;
power: number;
}
Player の情報を表示するには g.Label が1つでは足りません。そのため、
「それぞれの値に対応した g.Label を作成し、それらを子として持つ g.E 」
を作成する必要があります
(親の g.E 自体は何のデータも表示しないため無くても良いですが、複数のエンティティを纏めて移動・非表示するなど、管理を楽にするために使います)
では、これをどのように管理すれば良いでしょうか?
「g.E を継承したリストビューの項目を表示するためのクラス」を作りますか?
それも良いアイデアですが、ここで先程の [ワンポイント] の説明を振り返りましょう
ListView は string を元に g.E を生成する
この説明を新しい要件に置き換えると
ListView は Player を元に 「複数のエンティティと、それらを持つ g.E」 を生成する
となります
この「複数のエンティティと、それらを持つ g.E」について考える必要があります
先程のアイデアは「g.E を継承したListViewの項目を表示するためのクラス」を作ることで解決しようとしました
しかし g.E を継承したクラスを作ることなくこれを達成することが出来ます
インターフェースの定義
クラスの代わりにインターフェースを定義することでこの目的を達成してみましょう
interface ListViewContent {
playerId: string;
parent: g.E;
children: {
name: g.Label;
power: g.Label;
};
}
新しく ListViewContent を定義しました
parent は子に複数のエンティティをもつ、リストビューで表示する項目のエンティティで、
children に各項目のエンティティを格納しています
そして大事なのは playerId です
この値が存在することで、表示している項目がどのプレイヤーの情報かを知ることが出来ます
(ここまで一息で書いたので休憩します……………適度に休みつつ読み進めて下さい)
実装
では、ここまで考えたインターフェース・クラスを実装してみましょう
(リストビューの説明には特に関係のないコメントは先頭に >> を付けています. 読み飛ばして下さい)
interface Player {
playerId: string;
name: string;
power: number;
}
interface ListViewContent {
playerId: string;
parent: g.E;
children: {
name: g.Label;
power: g.Label;
};
}
class ListView {
// >> Map はキーと値のペアを管理するオブジェクトです. 追加順で順序を持っています
// >> キーから値を取り出したり、削除することが出来ます
// >> この場合は number をキーに ListViewContent を取り出せます
// >> MDN: https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Map
private contents = new Map<string, ListViewContent>();
public view: g.E;
public constructor(...) { ... }
// 今までの addContent の代わりに upsertContent を定義します
// >> upsert: update + add の造語
public upsertContent(player: Player): void {
const content = this.contents.get(player.playerId);
if (content == null) {
const content: ListViewContent = {
playerId: player.playerId,
parent: new g.E({...}),
children: {
name: new g.Label({...}),
power: new g.Label({...}),
},
};
this.contents.set(player.playerId, content);
} else {
// >> これは分割代入と呼ばれる、jsの構文です https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment
const { name, power } = content.children;
name.text = player.name;
name.invalidate();
power.text = player.power;
power.invalidate();
}
}
public removeContent(playerId: string): void {
const content = this.contents.get(playerId);
if (content == null) return;
// >> g.E の destroy は自身を親から取り除き、自身を破棄します. 子要素も破棄します
content.parent.destroy();
this.contents.delete(playerId);
}
}
2つのインターフェース。Player, ListViewContent については従来と同じなので特に説明はありません
ListView について、各プロパティの説明です
- contents 項目を持っています. private なので外からは見えません
- view リストビューを表示するためのエンティティです
- upsertContent 項目を追加します
- removeContent 項目を削除します
contents から取り除くだけでなく、その項目のエンティティを破棄する必要があります
実際にコードで使ってみるとより理解が進むかもしれません
const player = {
playerId: ...,
name: ...,
power: ...,
};
const listView = new ListView(...); // リストビューの生成
listView.upsertContent(player); // 項目を追加します
listView.removeContent(player.playerId); // 項目を削除します
ここまでで項目を追加/削除することが可能なリストビューが完成しました
お疲れ様です
休憩を挟んだら、このリストビューに並び替え機能を実装する方法について考えてみましょう
並び替え機能の追加
並び替えをするということはリストビューの見た目を変更するということです
(実際は ListView.content の持つ各要素の parent エンティティの座標を変更するということです)
並び替えについて考えるうちに、次のような疑問が浮かんだかもしれません
a. 追加順(upsertContent された順)の並び替えも必要か?
b. upsertContent, removeContent を実行した場合、同時に見た目の変更/並び替えをすることが望ましいか?
C. EX) Array.sort の動作の不安定さについて考慮すべきか
a の問題は、追加順の並び替えも必要である。ことにしましょう (実際の要件次第で変わるでしょう)
その場合は「ListView.contents の順序を変更せずに見た目のみ変更する」「ListViewContent に追加順を保持するプロパティを追加する」
のどちらかの方法で解決出来そうです
今回は「ListView.contents の順序を変更せずに見た目のみ変更する」方法を選びます
b の問題は upsertContent, removeContent で見た目の変更を同時に行う。ことにしましょう
この問題はリストビュー本来の説明とし離れてしまうため、下の [ワンポイント] で詳しく説明します
C の問題は Array.sort 関数に渡した関数が0を返すを渡した場合のブラウザ間の挙動の違いについてです
詳しくは下のニコ生ゲーム公式に任せるとします
それでは上記の問題 a,b を踏まえて並び替えを実装してみましょう
... // インターフェースについては省略します
// 2つ playerId を受け取って a < b ならマイナス. a > b ならプラス. a == b なら0を返す関数です
// js の配列の sort 関数と同じです https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/sort
type SortFunc = (a: string, b: string) => number;
class ListView {
private contents = new Map<string, ListViewContent>();
public view: g.E;
private sortFunc: SortFunc | undefined;
public constructor(...) { ... }
public upsertContent(player: Player): void {
...
this.updateContent();
}
public removeContent(playerId: string): void {
...
this.updateContent();
}
public sortContent(fn: SortFunc | undefined) {
this.sortFunc = fn;
this.updateContent();
}
private updateContent(): void {
const keys = this.getSortedKeys();
for (let i=0; i<keys.length; i++) {
// contents.get は undefined でないことが保証されているので、最後に「!」を付けています
const content = this.contents.get(keys[i])!;
content.parent.y = ...;
content.modified();
}
}
private getSortedKeys(): string[] {
// >> Map.keys() は配列でなくイテレーターを返すため Array.from() で配列にします
const keys = Array.from(this.contents.keys());
if (this.sortFunc != null) {
// >> Array.sort は「破壊的」な関数です
// >> 破壊的: 元のオブジェクトを直接操作する
// >> 非破壊的: 元のオブジェクトのコピーを作成して、それを操作する. 元のオブジェクトは変更されません
keys.sort(this.sortFunc);
}
return keys;
}
}
新しいプロパティが登場しました。まずは簡単な説明です
- sortFunc : リストビューのソート関数を保持するプロパティです
- sortContent : リストビューのソート関数をセットする関数です. 同時に更新も行います
- updateContent : リストビューの見た目を更新する関数です
- getSortedKeys : リストビューの項目の表示順に並べたキー配列を返す関数です
getSortedKeys 関数は contents のキーの配列を返しますが、
sortFunc が undefined の場合は contents の順序通りに並びます。つまり、項目の追加順です
sortFunc が undefined でない場合は、その関数を使って並びます
また contents を変更しないため contents は追加順であることが保証されます
updateContent はまず getSortedKeys 関数を使い、表示順に並んだ項目のキー配列を生成します
次に各項目の座標を変えることで見た目の順序を変えます
sortContent の使い方は次の通りです
const listView: ListView = ...;
const players: Player[] = ...;
// players の全ての項目を追加します
listView.sortContent(sortFunc);
function sortFunc(a: string, b: string): number {
// a,b 共に undefined はあり得ないとする
const aPlayer = getPlayer(a)!;
const bPlayer = getPlayer(b)!;
// 並び替えるのルールについては今回は2つのパワーの低い方を優先します
const value = aPlayer.power - bPlayer.power;
if (value === 0) return 1; // 0 は返さないようにする. 並び替えにおける問題 C の回避
return value;
}
function getPlayer(id: playerId): Player | undefined {
const index = players.indexOf(id);
if (index == null) return undefined;
return players[index];
}
これでリストビューにソート機能を追加することが出来たことが確認出来ると思います
おわり
ここまででリストビューの解説は終わりです!お疲れ様です!
リストビューの実装について理解できたでしょうか?
分からない場合は何度も読み返したり、ChatGPTなどのAIや、頼れる人に相談してみましょう
もし理解できていれば、プログラミングで何かを実装する場合の考え方について経験が積めたと思います
最後まで読んでいただき、ありがとうございます
...もしやる気があれば、リストビューを更に発展させても良いかも知れません
その場合は次の機能についてどうでしょうか?
- 表示可能な範囲を超えてしまった?それならスクロールやページング(ページ切り替え)が必要でしょう
スクロールする場合は ListView.view に g.Pane を使うと良いでしょう。g.Pane の範囲内のみの g.Pane の子要素が表示されます
g.Pane: https://akashic-games.github.io/akashic-engine/v3/classes/Pane.html - 項目の表示/非表示を切り替えます。実際の項目は削除しません
- 項目をクリック出来るようにします。クリックされた場合、IDを返す関数を登録出来るようにします
- 項目の並び替えが欲しいですか?(sort関数や追加順とは別に、項目を1つ上/下に移動する)
その場合は並び替えにおける問題b で考えた、もう1つのアイデアが有効かも知れません
Discussion