LoginSignup
3
6

More than 1 year has passed since last update.

仮想キーボード作ってみる【Vanilla JS】

Last updated at Posted at 2022-03-02

はじめに

HTML/CSS/JavaScriptで10個(くらい)何かを作ってみるチャレンジの一環として、
仮想キーボードを作っていきます。

目的

何か作ってみるチャレンジの目的は、
HTML/CSS/Vanilla JSを使ってとりあえずなにか作ること、手を動かすことです。

これでは曖昧すぎるので、もう少し深掘ります。

現状

  • HTML/CSS/JavaScriptについて、コードを見ればなんとなく理解出来る。
  • チュートリアルと一緒に進めれば何かは作れる。
  • 0から何か作ろうと思うと厳しい。
    • Python(業務効率化などで使用)の場合はある程度出来る。

なぜ厳しいのか?を考えた時に、
Pythonとの違いは実際に手を動かしているかどうかだなと思いました。

プログラミングにおいて手を動かすに勝る学習方法はないと思います。
実感としてもそうですし、そう言っているエンジニアの方も多く感じます。

また、質を高めるためにも、まずは絶対的な量が必要とよく言います。

というわけで、色々作ってみることにしました。

ルール

  • 期間は3週間くらい。
  • 数は10個くらい。
  • お題はパクリでもOK。
    • ただし、教材に従って進めるだけ、はNG。アイデアは持ってきてもいいし参考にするのは良いが、まずは自分で考える。
  • 人のコードを参考にしても良いが、ただのコピペはNG。何をしているのか理解すること。

また、今回のように動画を参考にして作る場合は以下のような流れで進めました。

  1. 1セクションごとに自分ならどうするか考える
  2. ざっくり実装したり、処理をコメントで書いたりしてみる
  3. 動画を見ながら修正

参考

以下から面白そうなお題をいくつか参考にします。


リポジトリ


仮想キーボード

概要

下の動画を参考に画面上で操作出来る仮想キーボードを実装していきます。

主な目的

  • 仮想キーボードの作り方を学ぶ
    • キーボードのように若干複雑なレイアウトをどのように実現するのか学ぶ
  • コードの書き方を参考にする

完成イメージ

demo.png

HTML実装

早速実装していきます。

とりあえずHTML。

<!DOCTYPE html>
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title> Virtual Keyboard HTML/CSS/JS</title>
    <link rel="stylesheet" href="assets/keyboard.css">
    <link rel="shortcut icon" href="assets/favicon.ico">
</head>

<body>
    <h1>Virtual Keyboard HTML/CSS/JS</h1>
    <h3>Features</h3>

    <ul>
        <li>Easy to integrate</li>
        <li>Responsive</li>
        <li>Vanilla JS( <strong>no libraries required!</strong> )</li>
    </ul>
    <textarea style="position: absolute; top: 130px; right: 30px; width: 300px;"></textarea>

</body>
</html>

textarea に対してインラインCSSを書き、スタイルを割り当てます。

本来キーボード部分はJavaScriptで作りますが、今は存在しません。

CSSを編集したいのでダミーのHTMLを作って行きます。
具体的には、キーボードのコンテナ、キーボード用の div を作ります。
そして、その中にキーボード( button type="button" )を入れていきます。

<div class="keyboard">
  <div class="keyboard__keys">
      <button type="button" class="keyboard__key">a</button>
  </div>
</div>

Google Material icon

特殊なキーの表現にはGoogle Material iconを使っていくようです。

HTMLにリンクを追加して使えるようにします。

<link href="https://fonts.googleapis.com/icon?family=Material+Icons"
      rel="stylesheet">

CSS実装

キーボードコンテナ

.keyboard {
    position: fixed;
    left: 0;
    bottom: 0;
    width: 100%;
    padding: 5px 0;
    background-color: #53a6bb;
    box-shadow: 0 0 50px rgba(0, 0, 0, 0.5);
    user-select: none;
    transition: bottom 0.4s;
    height: 100px;
}

position: fixed
スクロールしてもキーボードが表示され続けるようにする。

padding: 5px 0;
参考 : padding - CSS: カスケーディングスタイルシート | MDN
一括指定。この場合は左右5px、上下0

background-color: #004134;
背景色の指定。

box-shadow: 0 0 50px rgba(0, 0, 0, 0.5);
参考 : box-shadow - CSS: カスケーディングスタイルシート | MDN

以下のような構文で使用します。
blur-radius は値が大きくなるほどぼかしが大きくなり、影の面積が広く・色が薄くなります。

/* キーワード値 */
box-shadow: none;

/* offset-x | offset-y | color */
box-shadow: 60px -16px teal;

/* 今回はこれ */
/* offset-x | offset-y | blur-radius | color */
box-shadow: 10px 5px 5px black;

/* offset-x | offset-y | blur-radius | spread-radius | color */
box-shadow: 2px 2px 2px 1px rgba(0, 0, 0, 0.2);

/* inset | offset-x | offset-y | color */
box-shadow: inset 5em 1em gold;

/* 複数の影はカンマで区切る */
box-shadow: 3px 3px red, -1em 0 0.4em olive;

user-select: none;
参考 : user-select - CSS: カスケーディングスタイルシート | MDN
ユーザーのテキスト範囲選択を禁止します。

transition: bottom 0.4s;
キーボードが下から出てくるアニメーション。

キーボードを隠す

.keyboard--hidden という名称のクラスを使います。

.keyboard--hidden {
    bottom: -100%;
}

bottom: -100% を使用、JSによりクラスを add/remove することで見える状態⇔見えない状態を切り替える。

BEMについて

動画内でBEMという命名規則に従う、といった趣旨の話をされていました。
調べてみます。

参考

BEMとは

BEMはBlock Element Modifierの略で、CSSを設計・命名していく手法です。

  • Block: 大枠となる独立した要素
  • Element: Block中の要素
  • Modifier: BlockやElementのスタイル

命名ルール

クラスであれば以下のようになります。

class=”block__element—modifier”

Block: 大枠となる独立した要素
Element: Block中の要素
Modifier: BlockやElementのスタイル

これらの要素を以下のルールに基づいて命名しています。

  • blockとelementはアンダースコア2つで区切る
  • modifierはハイフン2つで区切る
  • block, element, modifierが複数の単語で構成される場合、単語間はハイフン1つで区切る

ファイル

1blockにつき1ファイル。

block名.cssとする。

CSSは全ての名前がグローバルなため, 命名が重複するとメンテナンス性が著しく低下するのが弱点であるが, BEMは命名規則を厳しく縛ることによってこの弱点を克服しており, element名はいくら重複しても問題ない.ただし, block名が重複してしまうとせっかく克服したものが台無しになってしまうため絶対に避けなければならない.1ファイルにつき1blockしか定義せずファイル名をblock名にする規則を守ってさえいれば, block名が重複する心配が無い.

仮想キーボードでは keyboard がblock、--hiddenがmodifierですね。

キー

キーのスタイル

キーそれぞれに対してスタイルを追加していきます。

  • キー全て
.keyboard__keys {
    text-align: center;
}

テキストが中心になるようにします。

  • それぞれのキー
.keyboard__key {
    height: 45px;
    width: 6%;
    max-width: 90px;
    margin: 3px;
    border-radius: 4px;
    border: none;
    background: rgba(255, 255, 255, 0.2);
    color: #ffffff;
}

width 6% max-width: 90px
レスポンシブになるように。
また、画面からはみ出さないように。

border-radius: 4px; border: none;
角を丸め、線を消す。

background: rgba(255, 255, 255, 0.2)
.keyboard で設定した背景色よりも明るい色になるように

keyboard-only-a.png

更に追加します。

font-size: 1.05rem
ルートサイズより5%大きく

outline: none
参考 : outline - CSS: カスケーディングスタイルシート | MDN

borderとoutlineの違いは以下です。

境界線と輪郭線はとても似ています。しかし、輪郭線は以下の点で境界線とは異なります。
輪郭線は領域を占有せず、要素のコンテンツの外側に描かれます。- 仕様によれば、輪郭線は矩形である必要はありませんが、ふつうは矩形です。
他の一括指定プロパティと同様に、省略された値は初期値に設定されます。

border outlineまで翻訳されてしまっていて若干わかりにくいですが、 outline は要素の外側に描画されるそうです。

inline-flex
キーの中にアイコンを置くことがあり、それらを中央寄せするために設定します。

参考 : display - CSS: カスケーディングスタイルシート | MDN
インライン要素のような振る舞いをしつつ、中身を flex に従ってレイアウトします。

キーにアイコン表示

Google Material iconを使ってアイコンを置いてみます。

<button type="button" class="keyboard__key">
    <i class="material-icons">backspace</i>
</button>

え?これだけ?という感じなのですが、
keyoboard-backspace.png
ちゃんと追加出来てます。すごい。

ただ、見ての通りアイコンの方が若干ずれているので修正していきます。

		・・・
    vertical-align: top;
    padding: 0;
    -webkit-tap-highlight-color: transparent;
}

vertical-align
参考 : vertical-align | MDN

vertical-align は、2 つの場面で使用することができます。
包含する行ボックスの中で、インライン要素のボックスの垂直方向の配置を決める場合。例えば、テキストの行の中で画像の垂直位置を決めるために使用することができます。
表のセルの内容の垂直方向の配置を決める場合。
vertical-align はインライン要素、インラインブロック要素、表のセル要素だけに適用されることに注意してください。つまり、ブロックレベル要素の垂直方向の配置には使用できません。

webkit-tap-highlight-color
参考 : webkit-tap-highlight-color

標準ではないプロパティです。Firefox、Safariではサポートされていません。

webkit-tap-highlight-color は CSS の標準外のプロパティで、リンクがタップされている間に表示される強調色を設定します。強調は、ユーザーがタップしたことが正常に認識されていることを示し、またどの要素がタップされているかを示します。

webkit-tap-highlight-color: red;
-webkit-tap-highlight-color: transparent; /* 強調をなくす */

最後に、 position: relative を追加。

capslockのアクティブ/非アクティブを示すライトのためらしい。
absolute だと親からの位置になってしまって、ライトが上手く配置出来ないのだと思われる。

キーがアクティブなとき

.keyboard__key:active {
    background: rgba(255, 255, 255, 0.12);
}

若干暗くする。

特殊なキー

  • 若干幅のあるキー
  • かなり幅のあるキー
  • 色が暗いキー

用のCSSを追加します。
HTMLにアイコン、クラスを追加します。

<button type="button" class="keyboard__key keyboard__key--wide">
    <i class="material-icons">backspace</i>
</button>
<button type="button" class="keyboard__key keyboard__key--extra-wide">
	  <i class="material-icons">space_bar</i>
</button>
.keyboard__key--wide {
    width: 12%;
}

.keyboard__key--extra-wide {
    width: 36%;
    max-width: 500px;
}

width を変更します。
.keyboard__key--extra-wide ははみ出さないようにmax-width を指定します。

capslockのライトを実装します。
keyboard__key--activatable というクラスを持つcapslockキーを実装し、以下のスタイルを追加します。

.keyboard__key--activatable::after {
    content: "";
    top: 10px;
    right: 10px;
    position: absolute;
    width: 8px;
    height: 8px;
    background: rgba(0, 0, 0, 0.4);
    border-radius: 50%;
}

絶対位置指定で親(キー)の右上に配置し、スタイルを与えます。

また、 keyboard__key--active を追加、ライトをアクティブにできるようにします。

.keyboard__key--active::after {
    background : #08ff00;
}

暗いキーは以下のように実装。

.keyboard__key--dark {
    background: rgba(0, 0, 0, 0.5);
}

JavaScript実装

Keyboardオブジェクト

const Keyboard = {
    elements: {
        main: null,
        keysContainer: null,
        keys: []
    },

    eventHandlers: {
        oninput: null,
        onclose: null
    },

    properties: {
        // キーボードの入力
        value: "",
        capsLock: false
    }
};

キーボードを表すために必要なKeyboardオブジェクトを定義していきます。

elements
メイン要素(キーボード)、キーボードのコンテナ、それぞれのキー

eventHandlers
イベントハンドラー

properties
キーボードの入力値、capsLockのアクティブ状態

また、このオブジェクトは以下のメソッドを持ちます。

_ から始まるメソッドはプライベートメソッドです。

init() {

},
_createKeys() {

},
_triggerEvent(handlerName) {
    console.log("Event Triggered! Event Name:" + handlerName);
},
_toggleCapsLock() {
    console.log("Caps Lock Toggled!");
},
open(initialValue, oninput, onclose) {

},
close() {

}

openメソッドにはinitialVale を渡します。
これは textarea に既に入力があった場合、その入力をスタート値にするためです。

init

全てのDOMがロードされたときをトリガーに init を実行します。

// 全てのDOMがロードされた時
window.addEventListener("DONContentLoaded", function() {
    Keyboard.init();
})
init() {
        // main要素を作成
        this.elements.main = document.createElement("div");
        this.elements.keysContainer = document.createElement("div");

        // main要素をセットアップ
        this.elements.main.classList.add("keyboard", "1keyboard--hidden");
        this.elements.keysContainer.classList.add("keyboard__keys");

        // DOMの追加
        this.elements.main.appendChild(this.elements.keysContainer);
        document.body.appendChild(this.elements.main);
    },

elements に生成したDOM要素を代入、 documentappendChild していきます。
なるほど、オブジェクトに生成した要素を持たせる感じなんですね。

keyboard--hidden の頭に1をつけているのは、開発用(開発中は見えるようにしておく)だから。
こういった小技も勉強になります。

DOMContentLoaded

参考

HTMLの解析、DOMツリーの構築が完了した段階で発生するイベントです。
img のような外部リソース、スタイルシートはまだ読み込まれていない可能性があります。

それに対してloadは、ブラウザが画像やスタイルなどを含めたすべてのリソースを読み込んだ段階で発生するイベントです。

window での load イベントはページとすべてのリソースがロードされたときにトリガされます。通常はこんなに長く待つ必要はないため、めったに使われません。

ページのライフサイクル: DOMContentLoaded, load, beforeunload, unload より

別なイベントである loadは、ページ全体が読み込まれたことを検出するためにのみ使用してください。 loadを、 DOMContentLoadedがより適切である場面に使用する間違いがよくあります。

Window: DOMContentLoaded イベント - Web API | MDN より

上のような記述から、

  • 特別な理由がない限り DOMContentLoaded を使う
  • 何かしら(画像を操作したいなど)の理由があれば load を使う

という感じでしょうか。

_createKeys

DocumentFragment を使ってHTML要素を追加していきます。

DocumentFragment は仮想的なDOMツリーを生成するようなイメージで使います。
主に大量の要素を一気に追加したい時なんかに使います。

_createKeys() {
    const fragment = document.createDocumentFragment();
    const keyLayout = [
        "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "backspace",
        "q", "w", "e", "r", "t", "y", "u", "i", "o", "p",
        "caps", "a", "s", "d", "f", "g", "h", "j", "k", "l", "enter",
        "done", "z", "x", "c", "v", "b", "n", "m", ",", ".", "?",
        "space"
    ];

    const createIconHTML = (icon_name) => {
        return `<i class="material-icons>${icon_name}</i>`
    }

    keyLayout.forEach(key => {
        const keyElement = document.createElement("button");
        const insertLineBreak = ["backspace", "p", "enter", "?"].indexOf(key) !==-1;

        // 属性/クラスを追加
        keyElement.setAttribute("type", "button");
        // 全てのキーに必要なクラス
        keyElement.classList.add("keyboard__key");

列区切り

const insertLineBreak = ["backspace", "p", "enter", "?"].indexOf(key) !==-1;

...

if(insertLineBreak) {
	fragment.appendChild(document.createElement("br"));
            }

キーボードの列区切りはindexOf を使って判定、 <br> タグで改行を入れることで表現します。

String.prototype.indexOf() - JavaScript | MDN

indexOf は見つからなければ -1 を返すので、見つかった場合は innerLineBreaktrue になります。

特殊なキー

switch-case を使います。

switch(key) {
    case "backspace":
        keyElement.classList.add("keyboard__key--wide");
        keyElement.innerHTML = createIconHTML("backspace");

        keyElement.addEventListener("click", () => {
            this.properties.value = this.properties.value.substring(0, this.properties.length - 1);
            this._triggerEvent("oninput");
        })
        break;

    case "caps":
        keyElement.classList.add("keyboard__key--wide", "keyboard__key--activatable");
        keyElement.innerHTML = createIconHTML("keyboard_capslock");

        keyElement.addEventListener("click", () => {
            this._toggleCapsLock();
            keyElement.classList.toggle("keyboard__key--active", this.properties.capsLock);
        })
        break;

    case "enter":
        keyElement.classList.add("keyboard__key--wide");
        keyElement.innerHTML = createIconHTML("keyboard_return");

        keyElement.addEventListener("click", () => {
            this.properties.value += "\n";
            this._triggerEvent("oninput");
        })
        break;

    case "space":
        keyElement.classList.add("keyboard__key--extra--wide");
        keyElement.innerHTML = createIconHTML("space_bar");

        keyElement.addEventListener("click", () => {
            this.properties.value += " ";
            this._triggerEvent("oninput");
        })
        break;

    case "done":
        keyElement.classList.add("keyboard__key--wide", "keyboard--dark");
        keyElement.innerHTML = createIconHTML("check_circle");

        keyElement.addEventListener("click", () => {
            this.close();
            this._triggerEvent("onclose");
        })
        break;

やっていることは単純で、

  • 必要なクラスとアイコンを追加
    • アイコンの生成は専用の関数を作っておいて、それを利用します。
  • クリックされた時の処理を登録

しているだけです。

backspace

substring を使って1文字削除しています。

caps

toggle を使ってアクティブにします。

DOMTokenList.toggleは渡された token をリストから削除、 false を返します。
token が存在しなかった場合は、追加して true を返します。

done

入力終了を表すキーです。close を呼びます。

通常のキー

	default:
	keyElement.textContent = key.toLowerCase();

	keyElement.addEventListener("click", () => {
		this.properties.value += this.properties.capsLock ? key.toUpperCase() : key.toLowerCase();
		this._triggerEvent("oninput");
	})
	break;

三項演算子を使ってcapsLock がアクティブなら大文字に、そうでなければ小文字にします。

これでキーボードが完成・・・

innerHTML-miss.png

あれ。

innerHTML だけ空で、 createIcon などの関数は正常に動いているように見えたのでそこそこ解決に時間がかかってしまいましたが、

const createIconHTML = (icon_name) => {
    return `<i class="material-icons>${icon_name}</i>`;
}

material-icons が足りていないだけでした。
補完頼りはよくないですね・・・

その他クラス名を間違えていてスタイルが適用されていない箇所などあったので修正しました。

結果が以下です。

innerHTML-ok.png

OKですね。

capslockがONになったときの挙動

this.elements.keys = this.elements.keysContainer.querySelectorAll(".keyboard__key");

capslock による制御で使うために、 Keyboard.keys に全てのキー要素を追加します。

通常のキーはアイコンを持たないので、子要素を持ちません。
これを利用して表示を更新します。

_toggleCapsLock() {
	  console.log("Caps Lock Toggled!");
	  this.properties.capsLock = !this.properties.capsLock;

	  for (const key of this.elements.keys) {
	      if(key.childElementCount === 0) {
	          key.textContent = this.properties.capsLock ? key.textContent.toUpperCase() : key.textContent.toLowerCase();
	      }
	  }
	}

三項演算子は今回のようにある入力を元にして値がスイッチするような場合に便利そうですね。

イベントの制御

_triggerEvent(handlerName) {
        console.log("Event Triggered! Event Name:" + handlerName);

        if(typeof this.eventHandlers[handlerName] == "function") {
            this.eventHandlers[handlerName](this.properties.value);
        }
    },

_triggerEvent に渡された引数が eventHandlers に存在する関数であれば、 現在のvalue を渡しつつ呼び出します。

_triggerEvent 関数は、入力キーのclick イベントに登録されています。

case "backspace":
    keyElement.classList.add("keyboard__key--wide");
    keyElement.innerHTML = createIconHTML("backspace");

    keyElement.addEventListener("click", () => {
        this.properties.value = this.properties.value.substring(0, this.properties.length - 1);
        this._triggerEvent("oninput");
    })
    break;
open(initialValue, oninput, onclose) {
    this.properties.value = initialValue || "";
    this.eventHandlers.oninput = oninput;
    this.eventHandlers.onclose = onclose;
    this.elements.main.classList.remove("keyboard--hidden");
},

close() {
    this.value = "";
    this.eventHandlers.oninput = oninput;
    this.eventHandlers.onclose = onclose;
    this.elements.main.classList.add("keyboard--hidden")
}

open close にはキーボードがアクティブ/非アクティブになったときの処理が記述されています。

具体的には、

  • value のリセット
  • eventHandlers のリセット
  • "keyboard--hidden" クラスの制御

です。

document.querySelectorAll(".use-keyboard-input").forEach(element => {
    element.addEventListener("focus", () => {
        this.open(element.value, currentValue => {
            element.value = currentValue;
        });
    });
})

最後に textareafocus イベントに open を登録します。

focus された時、open(initialValue, oninput, onclose) に対して

  • element.value (initialValue)
  • 引数として渡された値を element.value に代入する関数

をそれぞれ渡しています。

無事完成です!

demo

まとめ

キーボードを Keyboard オブジェクトとして表現していく流れが綺麗すぎて感動しました。

また、
VSCodeでHTMLを書く時、.keyboard__keys と打つと自動的に div class="keyboard__keys" が生成される
など、テクニック的な面でも参考になる箇所がいくつかありました。
ひょっとして常識なんでしょうか。初めて知りました。

解説がかなり丁寧で、英語も聞き取りやすいです。
非常に良い動画でした。

学んだこと

  • 仮想キーボードの考え方を理解した
    • アクティブ/非アクティブ状態を持つことで表示を制御
    • オブジェクトとしてキーの状態や入力を保持することでキーボードを表現する
  • 実装面
    • 仮想キーボードのUI実装
    • BEMについて
    • Google Material Iconを使うと簡単にアイコンを扱える
    • load DOMContentLoaded の違い
    • オブジェクトの扱い
    • JavaScriptを使ってHTMLを描画する方法
3
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
6