Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save okunokentaro/ebcef6c13cf51f6ddb6a8546e39b8358 to your computer and use it in GitHub Desktop.
Save okunokentaro/ebcef6c13cf51f6ddb6a8546e39b8358 to your computer and use it in GitHub Desktop.
クリスマスを華やかにするGUI小技集
2019/12/24 にQiitaに投稿した記事のアーカイブです
---
この記事は [Angular Advent Calendar 2019](https://qiita.com/advent-calendar/2019/angular) 24日目の記事です。
こんにちは、奥野賢太郎( @okunokentaro )です。今年もクリスマスがやってきましたね!クリスマスといえば飾り付け、飾り付けといえばGUIです。今回はクリスマスと全く関係ありませんが、作るのが面倒くさいGUIについて2つAngularで実装してみましたのでご紹介します。
# ライセンスと免責
本記事に掲載されているコードはすべてCC0とします。本記事に掲載されているコードを動作させる、あるいは商用利用することによって生じる一切の問題について、当方は責任を負いかねます。
# ページネーション
ページネーションといえば、数字のボタンが並んだページセレクタがおなじみです。例えばつぎのようなものです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17959/31a48a4c-ae9d-ec74-5f05-f33669d04b22.png)
- https://bulma.io/documentation/components/pagination/
さて、これってけっこう算出が面倒くさいんですよね。ある程度の数だと`…`で省略しつつ、数が少なければすべて表示する…、といった絶妙な計算が必要です。
## サンプルコード
とりあえず作ってみました。
```ts
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];
}
```
- 動作例
- https://stackblitz.com/edit/angular-a37m6u?file=src/app/pagination/make-buttons.ts
`makeButtons()`関数に総数、1ページごとの掲載数、現在のページ番号、前後合わせた表示数(たとえばここを5にして30ページ目を選んでいたら`28, 29, 30, 31, 32`のように5つ並ぶ)を渡すと、いい感じに配列を生成してくれます。テストを書いてからひたすらリファクタリングして、おおよそ清書したつもりですが、もし考慮漏れあればご容赦ください。正常系しか考慮してないので、変な数字を渡したときの挙動は保証していません。
この手の実装をしていて思うのは、見た目に関する実装はほとんどやることがなくて、だいたい配列処理をひたすら泥臭くやる感じです。テスト駆動開発で書くのがいいですね。
# タグ入力フィールド
続いてタグ入力フィールドです。自力で作るとなかなか面倒くさいやつです。なので、こういうのはさっさとOSSを漁ったほうがいいですが、私はDeleteキーのハンドリングや、Command + Cでのコピーなど、いろいろと自分で機能を足したかったため自作しました。
![Screen Recording 2019-12-23 at 1.13.47.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17959/b77abe58-feae-ef00-bc73-cdaf6be338e8.gif)
```html
<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>
```
- 動作例
- https://stackblitz.com/edit/angular-awsrxx?file=src/app/tag-input/tag-input.component.ts
このように、配列をタグとして描画しつつ、最後尾に入力欄を作っているのが特徴です。最初、ここを`contenteditable`で作ったのですが、あまりに地獄を見たのでやめました。
動作例のサンプルコードは荒削りではありますが、IMEの変換確定を考慮したハンドリングや、下キー押下に伴うサジェスト表示の自動スクロールなど、色々と実装していますので、なにかの参考になれば。(趣味プロ作品で投入した実装なので、アクセシビリティ観点ではまだまだ詰め甘いのですが…)
```ts
// スクロール処理
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](https://material.angular.io/cdk/categories)にもたくさんのパーツが用意されています。
たしかに、これらを使えば何も実装せずにスピーディに欲しい表示が実現できるかもしれません。しかし、業務でちょっと特殊な要件が出てきたり、特殊な組み合わせを要求されたりするとき、既存のGUIパーツの組み合わせではどうにもならず、結局改造しなければならない場面をよく見かけます。
私は基本的に、よほど車輪の再発明がつらいものでなければ自作するよう心がけています。車輪の再発明がつらいものといえばGoogle Mapや、グラフィカルなチャートなどを指しますが、あとのカレンダーやモーダルダイアログなどは、いくつも実装ストックを持っており、日頃から自作のストックを組み合わせるようにしています。
日頃からGUIを自作することは、配列操作や描画の効率を考える訓練になりますし、いざ業務で突飛な要件を持ち出されても、慣れているために臆することなく対応できるという利点があります。今回紹介したコードが決して正解というわけではないですが、昨今視座の高い記事や登壇を重ねていたこともあって、久々に泥臭い実例の紹介記事としました。
それではよいお年を。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment