🎄

クリスマスを華やかにするGUI小技集

2021/07/04に公開

この記事は Angular Advent Calendar 2019 24 日目の記事です。

こんにちは、奥野賢太郎( @okunokentaro )です。今年もクリスマスがやってきましたね!クリスマスといえば飾り付け、飾り付けといえば GUI です。今回はクリスマスと全く関係ありませんが、作るのが面倒くさい GUI について 2 つ Angular で実装してみましたのでご紹介します。

ライセンスと免責

本記事に掲載されているコードはすべて CC0 とします。本記事に掲載されているコードを動作させる、あるいは商用利用することによって生じる一切の問題について、当方は責任を負いかねます。

ページネーション

ページネーションといえば、数字のボタンが並んだページセレクタがおなじみです。例えばつぎのようなものです。

image.png

さて、これってけっこう算出が面倒くさいんですよね。ある程度の数だとで省略しつつ、数が少なければすべて表示する…、といった絶妙な計算が必要です。

サンプルコード

とりあえず作ってみました。

function make3Part(
  total: number,
  perPage: number,
  current: number,
  around: number
): number[][] {
  const all = Array(Math.ceil(total / perPage))
    .fill(true)
    .map((_, i) => i + 1);

  return [
    all.slice(0, around),
    all.slice(
      current - Math.ceil(around / 2),
      current + Math.floor(around / 2)
    ),
    all.slice(all.length - around, all.length),
  ];
}

function merge(x: number[], y: number[]): number[] {
  return Array.from(new Set(x.concat(y)));
}

function sort(arr: number[]): number[] {
  return [...arr].sort((x, y) => x - y);
}

function first(arr: number[]): number {
  return arr[0];
}

function next(arr: number[]): number {
  return arr[arr.length - 1] + 1;
}

function hasDuplicate(xy: number[], x: number[], y: number[]): boolean {
  return xy.length < x.length + y.length;
}

export function makeButtons(
  total: number,
  perPage: number,
  current: number,
  around: number
): number[][] {
  const [a, b, c] = make3Part(total, perPage, current, around);

  if (b.length === 0) {
    const ac = sort(merge(a, c));
    if (hasDuplicate(ac, a, c)) {
      return [ac];
    }
    if (next(a) === first(c)) {
      return [ac];
    }
    return [a, c];
  }

  const ab = sort(merge(a, b));
  const bc = sort(merge(b, c));

  if (hasDuplicate(ab, a, b)) {
    return next(ab) === first(c) ? [merge(ab, c)] : [ab, c];
  }
  if (hasDuplicate(bc, b, c)) {
    return next(a) === first(bc) ? [merge(a, bc)] : [a, bc];
  }
  if (next(a) === first(b)) {
    return [ab, c];
  }
  if (next(b) === first(c)) {
    return [a, bc];
  }

  return [a, b, c];
}

makeButtons()関数に総数、1 ページごとの掲載数、現在のページ番号、前後合わせた表示数(たとえばここを 5 にして 30 ページ目を選んでいたら28, 29, 30, 31, 32のように 5 つ並ぶ)を渡すと、いい感じに配列を生成してくれます。テストを書いてからひたすらリファクタリングして、おおよそ清書したつもりですが、もし考慮漏れあればご容赦ください。正常系しか考慮してないので、変な数字を渡したときの挙動は保証していません。

この手の実装をしていて思うのは、見た目に関する実装はほとんどやることがなくて、だいたい配列処理をひたすら泥臭くやる感じです。テスト駆動開発で書くのがいいですね。

タグ入力フィールド

続いてタグ入力フィールドです。自力で作るとなかなか面倒くさいやつです。なので、こういうのはさっさと OSS を漁ったほうがいいですが、私は Delete キーのハンドリングや、Command + C でのコピーなど、いろいろと自分で機能を足したかったため自作しました。

Screen Recording 2019-12-23 at 1.13.47.gif

<div
  (keyup)="onKeyupContainer($event)"
  (blur)="onBlurContainer()"
  class="Tags_Container"
  tabindex="0"
>
  <ng-container *ngFor="let v of tags">
    <span
      [class.is-selected]="selected === v"
      (click)="onClickTag(v)"
      class="Tags_Tag"
    >
      {{ v }}
    </span>
  </ng-container>

  <input
    #input
    (change)="onChange($event)"
    (keydown)="onKeydown($event)"
    (keypress)="onKeypress()"
    (keyup)="onKeyup($event)"
    (keyup.enter)="onKeyupEnter($event)"
    (blur)="onBlurInput()"
    type="text"
    class="Tags_Input"
  />
</div>

このように、配列をタグとして描画しつつ、最後尾に入力欄を作っているのが特徴です。最初、ここをcontenteditableで作ったのですが、あまりに地獄を見たのでやめました。

動作例のサンプルコードは荒削りではありますが、IME の変換確定を考慮したハンドリングや、下キー押下に伴うサジェスト表示の自動スクロールなど、色々と実装していますので、なにかの参考になれば。(趣味プロ作品で投入した実装なので、アクセシビリティ観点ではまだまだ詰め甘いのですが…)

// スクロール処理
requestAnimationFrame(() => {
  const tmp = window.document.querySelector(".is-selected");
  if (tmp === null) {
    return;
  }
  const el = tmp as HTMLElement;
  const parent = el.parentElement;
  if (parent === null) {
    throw new Error("Parent not found");
  }

  const scrollTop = parent.scrollTop;
  const scrollBottom = scrollTop + parent.offsetHeight;

  const h = el.offsetHeight;
  const b = el.offsetTop + h;

  if (scrollBottom - h < b) {
    parent.scrollTop = parent.scrollTop + h;
    return;
  }
  if (el.offsetTop < scrollTop + h) {
    parent.scrollTop = Math.max(0, parent.scrollTop - h);
    return;
  }
  // noop
});

みんなも GUI 実装を書こう

カレンダー、モーダルダイアログ、プルダウン、特殊なフォーム…、だいたいの GUI はちょっと探せば OSS が転がっています。Angular CDKにもたくさんのパーツが用意されています。

たしかに、これらを使えば何も実装せずにスピーディに欲しい表示が実現できるかもしれません。しかし、業務でちょっと特殊な要件が出てきたり、特殊な組み合わせを要求されたりするとき、既存の GUI パーツの組み合わせではどうにもならず、結局改造しなければならない場面をよく見かけます。

私は基本的に、よほど車輪の再発明がつらいものでなければ自作するよう心がけています。車輪の再発明がつらいものといえば Google Map や、グラフィカルなチャートなどを指しますが、あとのカレンダーやモーダルダイアログなどは、いくつも実装ストックを持っており、日頃から自作のストックを組み合わせるようにしています。

日頃から GUI を自作することは、配列操作や描画の効率を考える訓練になりますし、いざ業務で突飛な要件を持ち出されても、慣れているために臆することなく対応できるという利点があります。今回紹介したコードが決して正解というわけではないですが、昨今視座の高い記事や登壇を重ねていたこともあって、久々に泥臭い実例の紹介記事としました。

それではよいお年を。

Discussion