ウェブアプリにアンドゥ機能をつけてユーザーの操作ミスを減らす

上野 学
2013年5月17日

「ウェブアプリにアンドゥ機能をつけてユーザーの操作ミスを減らす」というタイトルにしましたが、なぜ、アンドゥ(取り消し)機能があると操作ミスが減るのでしょうか。アンドゥはユーザーが操作ミスをした時にそれを取り消すための機能です。アンドゥ機能があるからといって操作ミスが減るわけではないと考える人も多いでしょう。
今回はアンドゥ機能の意義と、ウェブアプリにおける簡単な実装方法を解説します。

アンドゥ機能の意義

デスクトップアプリケーションでおなじみのアンドゥ機能は、システムをモードレスにするために重要な役割を果たしています。アンドゥ機能があることでユーザーは、今行おうとしている操作が本当に正しいのかどうかを心配せずにとりあえずやってみることができます。そして誤って消してしまった項目を復活させたり、適用した画像イフェクトが気に入らなければ、すぐに元に戻すことができるのです。

またアンドゥ機能があれば「本当にゴミ箱に入れますか?」といったモーダルな確認ダイアログも多くの場面で不要になりますから、ユーザーの操作をいちいちストップしなくて済みます。

どんなにデザインの論理性や分かりやすさを高めても、タスクや機能が豊富になればUIは複雑化します。ユーザーの操作ミスを完全に無くすことはできません。

しかし、もしあらゆる操作がアンドゥ可能であれば(実際には難しいでしょうが)、ユーザーにとって間違った操作というものは存在しなくなります。すべての操作は、ユーザーの学習や試行錯誤の一環になるのです。

そういう意味で、アンドゥ機能というものは、ユーザーの操作ミスを減らす役割を果たすと考えることができます。もちろんその前にできるだけ操作ミスが起きにくいデザインを追求することが重要ですが。

ウェブにおけるアンドゥ

現状、ウェブアプリケーションではアンドゥ機能はあまり見かけません。

テキスト入力欄の中は OS のフレームワーク側のアンドゥ機能が実装されていますが、それ以外に、ウェブアプリケーション側の機能として実装されているのは、例えば Gmail でメールをゴミ箱に入れた後にそれを取り消せるなど、局所的なものにとどまっています。

かつてのウェブは静的なページをリンクで繋ぎ合わせただけの紙芝居のようなものでしたが、ご存じのように、今やウェブは非同期通信と動的な DOM 操作を利用した本格的な GUI アプリケーションになってきました。

デスクトップアプリケーションと同等の機能性を持つようなUIコンポーネントや、データバインディング(データモデルの変更を自動的にUIに反映する)のためのライブラリも充実してきており、ページ遷移を行わずにひとつのページの中でユーザーの操作に応じて画面の状態が変化していく、いわゆるシングルページアプリケーションも増えています。

このように、ウェブページの位置づけが「ドキュメント」から「アプリケーション」に変化していくにあたっては、HTML のセマンティクスが軽視されたり、アクセシビリティが損なわれるといった問題があります。しかしそういった問題を解決する方法論の議論も含めて、ウェブが今後更にインタラクティブなメディアになっていくのは確実でしょう。

ウェブのUIがユーザーの要求に適切に反応して、よりよいインタラクションを実現するには、モードレス・ユーザーインターフェースの視点が重要になってきます。例えば操作の可逆性を高めたり、確認ダイアログのようなもので操作の流れを不必要に止めてしまわないようなデザインを検討する価値があります。

そう考えると、これからはウェブアプリケーションにもアンドゥ(取り消し)機能がもっと必要になってくるのではないかと思います。

しかしウェブのデザイナーやプログラマーは、アンドゥ機能の実装にあまり慣れていないのではないかと思います。

JavaScript でアンドゥ機能を実現するためのライブラリー等は、検索すればいろいろと出てきますが、どれもやや複雑な印象です。そこで以下に、JavaScript で簡単にアンドゥ機能を実装する例をご紹介しようと思います。

アンドゥの作り方

アンドゥ機能の実現方法はいくつかあると思いますが、基本的な考え方は次のようなものです。

  • アンドゥのための処理を記録しておくスタックを用意する。
  • 処理 A を実行する時に、それを取り消すための処理 A’ をスタックの一番上に追加する。
  • ユーザーがアンドゥしたら、スタックの一番上の処理 A’ を実行する。そしてスタックをひとつ減らす。

これでマルチレベルのアンドゥ(多段階の取り消し)ができます。

実装例です。

  • Item 1
  • Item 2
  • Item 3
  • Item 4
  • Item 5

この例では、各リスト行の「X」ボタンを押すとその項目が消えるようになっています。その削除機能をアンドゥ可能にしています。「X」ボタンを押して項目を消した後に Undo ボタンを押すと元に戻ります。Undo は多段階に実行できます。

<ul>
	<!-- 【1】-->
	<li id="item1">Item 1 <button onclick="show( { id: 'item1', showing: false } )">X</button></li>
	<li id="item2">Item 2 <button onclick="show( { id: 'item2', showing: false } )">X</button></li>
	<li id="item3">Item 3 <button onclick="show( { id: 'item3', showing: false } )">X</button></li>
	<li id="item4">Item 4 <button onclick="show( { id: 'item4', showing: false } )">X</button></li>
	<li id="item5">Item 5 <button onclick="show( { id: 'item5', showing: false } )">X</button></li>
</ul>
<button onclick="undoManager.undo()">Undo</button>

<script>

var undoManager = { //【2】
	undoStack : [], //【3】
	register : function ( fn, arg ) { //【4】
		this.undoStack.push( { 'fn' : fn, 'arg' : arg } ); //【5】
	},
	undo : function () { //【6】
		if ( this.undoStack.length > 0 ) { //【7】
			var undoItem = this.undoStack [ this.undoStack.length -1 ]; //【8】
			this.undoStack.pop(); //【9】
			undoItem.fn( undoItem.arg ); //【10】
			this.undoStack.pop(); //【11】
		};
	}
};

function show ( arg ) { //【12】
	var element = document.getElementById( arg.id ); //【13】
	element.style.visibility = arg.showing ? 'visible' : 'hidden'; //【14】
	undoManager.register( show, { id: arg.id, showing: !arg.showing } ); //【15】
};

</script>

コードの説明

  • 【1】 この例では、各「x」ボタンを押すと、show という関数を実行するようになっています。この show 関数が、今回アンドゥ可能にする処理です。show 関数に渡す引数は便宜上オブジェクト形式にしています。引数オブジェクトには、削除する(非表示にする)要素の id と、表示/非表示を指示する真偽値(削除する場合は false)を含めています。
  • 【2】 アンドゥ処理を司る undoManager オブジェクトです。もしページ内で複数のアンドゥスタックを持ちたければコンストラクタにしますが、この例では不要なので直接オブジェクトを定義しています。
  • 【3】 undoManager が持っているアンドゥスタック(配列)です。初期状態は空にします。
  • 【4】 アンドゥスタックの配列にアンドゥ項目を登録するメソッドです。アンドゥを可能にする処理(show)が実行された時にこのメソッドが呼ばれるようにします。引数として、アンドゥ時に実行する関数(fn)とその関数に渡す引数(arg)を取ります。
  • 【5】 アンドゥスタックの配列にアンドゥ項目を追加しています。アンドゥ項目はオブジェクトで、register メソッドが受け取った引数(アンドゥ時に実行する関数とその関数に渡す引数)をセットにしたものです。
  • 【6】 アンドゥを実行するためのメソッドです。この例ではユーザーが「Undo」ボタンを押した時に呼ばれます。
  • 【7】 undo メソッドではまず、アンドゥスタックに項目があるかどうかをチェックします。項目があればアンドゥを実行します。
  • 【8】 アンドゥスタックから、一番最後に追加されたアンドゥ項目(配列の末尾の要素)を参照します。
  • 【9】 アンドゥスタックをひとつ減らします。
  • 【10】 取り出したアンドゥ項目(undoItem)に入っている関数(undoItem.fn)に、引数(undoItem.arg)を渡して実行します。これでアンドゥが実行されたことになります。実際にはここは例外処理を行った方がよいでしょう。
  • 【11】 今回の例では、リスト行の削除と復帰をどちらも show という関数を使って実現しています。アンドゥを実行すると再度 register が呼ばれて新たなアンドゥ項目がスタックに追加されてしまうので、ここでもう一度アンドゥスタックをひとつ減らします。
  • 【12】 リスト行の表示/非表示の両方を行う関数です。引数には便宜上ひとつのオブジェクトを受け取るようにしています。その引数オブジェクト(arg)には、表示/非表示する対象要素の id と、表示なのか非表示なのかの真偽値(showing)が含まれている前提です。
  • 【13】 リスト行の表示/非表示を行うために、まず引数で渡された id から、対象要素を見つけます。
  • 【14】 そして対象要素の visibility スタイル属性の値を、引数で渡された showing 真偽値に応じて、true なら “visible” に、false なら “hidden” にします。(これはあくまで例としての処理方法ですから、もちろん、別な方法で表示/非表示を実現してもかまいません。)
  • 【15】 アンドゥスタック(undoManager.undoStack)にアンドゥ項目を登録します。引数には、アンドゥ時に実行する関数(show)と、その引数とするオブジェクト(対象要素の id と、表示/非表示の真偽値)を渡します。表示/非表示の真偽値は、直前の【14】で行ったことの反対の値を指定します。これで、今行っている処理を取り消すための処理がアンドゥスタックに追加されたことになります。

リドゥの作り方

アンドゥがあるなら、リドゥも作りたいところです。これはアンドゥの応用で作ることができます。

実装例です。

  • Item 1
  • Item 2
  • Item 3
  • Item 4
  • Item 5

リスト行の「X」ボタンを押して削除し、それを Undo で取り消した後で、Redo ボタンを押すと、再び削除されます。Redo も多段階で実行できます。

<ul>
	<!-- 【1】-->
	<li id="item1">Item 1 <button onclick="show( { id: 'item1', showing: false }, true )">X</button></li>
	<li id="item2">Item 2 <button onclick="show( { id: 'item2', showing: false }, true )">X</button></li>
	<li id="item3">Item 3 <button onclick="show( { id: 'item3', showing: false }, true )">X</button></li>
	<li id="item4">Item 4 <button onclick="show( { id: 'item4', showing: false }, true )">X</button></li>
	<li id="item5">Item 5 <button onclick="show( { id: 'item5', showing: false }, true )">X</button></li>
</ul>
<button onclick="undoManager.undo()">Undo</button>
<button onclick="undoManager.redo()">Redo</button>

<script>

var undoManager = {
	undoStack : [],
	redoStack : [], //【2】
	register : function ( fn, arg ) {
		this.undoStack.push( { 'fn' : fn, 'arg' : arg } );
	},
	undo : function () {
		if ( this.undoStack.length > 0 ) {
			var undoItem = this.undoStack[ this.undoStack.length -1 ];
			this.undoStack.pop();
			undoItem.fn( undoItem.arg );
			this.redoStack.push( this.undoStack[ this.undoStack.length - 1 ] ); //【3】
			this.undoStack.pop();
		};
	},
	redo : function () { //【4】
		if ( this.redoStack.length > 0 ) {
			var redoItem = this.redoStack[ this.redoStack.length - 1 ];
			redoItem.fn( redoItem.arg )
			this.redoStack.pop();
		};
	}
};

function show ( arg, clearingRedoStack ) {
	var element = document.getElementById( arg.id );
	element.style.visibility = arg.showing ? 'visible' : 'hidden';
	if ( clearingRedoStack ) undoManager.redoStack = []; //【5】
	undoManager.register( show, { id: arg.id, showing: !arg.showing } );
};

</script>

コードの説明

  • 【1】 リドゥを実現するために、「X」ボタンを押した時の処理を少し変更し、show 関数に渡す引数をひとつ増やしています。true という値が追加されています。これについては【5】で説明します。
  • 【2】 undoManager には、リドゥスタックを用意します。
  • 【3】 アンドゥ実行時に、アンドゥ処理(undoItem.fn)で追加されたアンドゥ項目をリドゥスタックに追加します。つまりアンドゥを取り消すための処理をリドゥスタックに入れたことになります。
  • 【4】 リドゥを実行するためのメソッドです。この例ではユーザーが「Redo」ボタンを押した時に呼ばれます。処理内容は、リドゥスタックに項目があればそれを実行し、リドゥスタックをひとつ減らすだけです。
  • 【5】 show 関数をアンドゥとリドゥの両方が可能なものにするために少しだけ変更しています。show 関数は、ユーザーが「X」ボタンを押した時、Undo ボタンを押した時、Redo ボタンを押した時、のすべてで実行されますが、リドゥというのはアンドゥの取り消しですから、「X」ボタンが押された直後にはリドゥできる必要がありません。そこで、「X」ボタンが押されたことを識別するためのフラグ(この例では clearingRedoStack)を設けておき、このフラグが立っていたらリドゥスタックを空にします。

これで多段階のアンドゥ&リドゥが実装できました。この undoManager の仕組みを使えば、基本的にどのような操作もアンドゥ/リドゥ可能になります。


今回の実装はあくまでアンドゥ&リドゥの実現方法を端的に示す目的でかなり簡略化したものですから、本番ではもう少し条件チェックや例外処理が必要になるでしょう。

注意点としては、不必要にアンドゥ&リドゥ機能を追加してもUIが複雑になるだけであるということです。一般的には、次のように考えます。

アンドゥができるとよい場合

  • 項目の削除、文字列の削除
  • 複数項目の一括変更
  • その他、可逆性はあるものの手動で元に戻すにはかなり面倒な操作

アンドゥが不要な場合

  • フォーカスの移動
  • 選択式コントロールの値変更
  • ウィンドウやペインのサイズ変更

また今回のリドゥの実装例では、アンドゥ実行時に同期的にリドゥ項目を登録するようにしていますので、ユーザーのアクションでサーバーのDBを更新してその結果をUIに反映するような、一連の非同期処理には対応できません。

とはいえ、アンドゥ/リドゥの実現イメージをある程度もっておけば、ウェブアプリケーションのUIを考える上でのバリエーションが増えるでしょう。またユーザーの心理的/身体的負荷が少なく、利便性の高いシステムを作るための手がかりになるのではないでしょうか。