話題の記事

HTML5 × CSS3 × jQueryを真面目に勉強 – #13 iOSのUITableViewをjQueryで作ってみた

2013.01.24

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

img-jquerymobile_page

2013年1月14日にjQuery Mobile 1.3.0 Betaがリリースされました。

jQuery Mobile 1.3.0 Betaの紹介記事はこちら。

弊社でもモバイルコンテンツの開発案件にて徐々に利用実績が積み重なっておりますが、初期ベータ版の頃より「なんで一向に実装されないのかなー…?」と僕がずっと手をこまねいて待ち続けている機能があります。

UI Table View風リスト

iOS端末ではおなじみのリストビューです。
リスト項目をインデックスごとにグルーピングし、リストのスクロール時はインデックスヘッダーがトップに張り付いて、次のインデックスヘッダーがやってくると画面外に押し出されて新しいインデックスがトップに張り付くというインタラクションを持っています。

  1. img-uitableview_original1
  2. img-uitableview_original2
  3. img-uitableview_original3
  4. img-uitableview_original4

モバイル Gmailでは以前よりこのインタラクションが実装されていますが、jQuery Mobileには1.3.0 Betaにおいても実装されていません。

img-gmail_mob

そんな訳で、jQuerytとCSSを駆使してそれっぽい動きをするものを作ってみました。まだ完成版という訳ではなく、幾つかの課題も残っていますが、「うへー、これがjQueryで作ったUITableViewかー」といった程度にとらえていただければと思います。

はじめに

今回実装するUI Table Viewの要件を書きだしてみます。

UI Table Viewの要件

  1. リスト項目をインデックスごとにグルーピングし、各グループにインデックスヘッダーをつける
  2. グルーピングは自動で行うのではなく、渡されたリスト項目にそのまま依存する ※データの昇順がデタラメであれば、デタラメのまま表示する
  3. リストのスクロール時は一番上に表示されているグループのインデックスヘッダーがトップに張り付き、次のグループのインデックスヘッダーがやってくると画面外に押し出される
  4. リストが上から下に逆スクロールすると、画面外に押し出されたインデックスヘッダーがトップに張り付いているバーを下に押し下げる

インデックス付きのリストということでグルーピング及びデータのソートも自動で行なってくれても良さそうですが、iOSネイティブのUITableViewが渡されたデータをそのまま表示するという仕様なので、今回はコレにならった要件としました。

ではキモとなるインデックスヘッダーの動きをどうやって実現するかです。人によって様々な実現方法があるかと思いますが、僕は次のように実装していこうかと思います。

UI Table Viewの実装方法

  1. 各グループにはインデックスヘッダー用の要素を持たせる
  2. リスト領域のトップに固定されるインデックスヘッダーはダミー要素を置くことで表現する
  3. 一番上にあるグループAのヘッダーは非表示にしておき、代わりにダミーのヘッダーで表現する
  4. 次に来るグループBのインデックスヘッダーとダミーのヘッダーが接触したらダミーを非表示にし、それまで非表示にしていた本物のヘッダーをグループAの一番下に表示させる
  5. グループBのインデックスヘッダーがグループAを全て押し出すと同時に非表示にし、ダミーのヘッダーの文字列を更新して再度表示する

箇条書きにしただけではイマイチ分かり難いので、補足説明します。図1を御覧ください。

img-uitablebiew_logic1 図1

A、B、Cと3つのグループが見えています。それぞれにインデックスヘッダーがあります。BとCのはそれぞれのグループ内に定義されている要素ですが、Aだけは自分のインデックスヘッダーではありません。ダミーのヘッダーが代わりに表示されています。

img-uitablebiew_logic2 図2

図2を見るとグループBのインデックスヘッダーがトップにあるヘッダーに接触しています。この接触のタイミングでダミーヘッダーは非表示となり、入れ替わりでグループAのインデックスヘッダーが表示されます。この際グループAのヘッダーはグループ要素内の一番下に位置し、見た目上はダミーヘッダーと入れ替わったことに気が付きません。グループAのヘッダーはそのグループごとBに画面外へ押し出されるので、まるでアニメーションしているように見えるのです。

img-uitablebiew_logic3 図3

グループAが画面外に完全に押し出されると、グループBのインデックスヘッダーがトップに来ます。このタイミングでグループBのヘッダーを非表示にし、入れ替わりにダミーヘッダーを表示させ、ダミーの方の文字列をグループBのそれに更新します。(※図3)

お分かりいただけただろうか?

以上の動作を繰り返すことでiOSのUI Table Viewのような動きを、それっぽくではありますが再現すること出来ます。

では実際にコードを書いて動くものを作っていくとします。

UI Table View風リストを実装

モダンブラウザもしくはIE8+に対応しています。

1 | HTMLをマークアップ

まずはHTMLをどのような構成にするか決めます。

<div class="tableview-wrapper">
	<h2 id="toc-a" class="dummy-header">A</h2>
	<div class="tableview">
		<dl>
			<dt>A</dt>
			<dd>AC/DC</dd>
			<dd>Aphex Twin</dd>
			<dd>Asian Dub Foundation</dd>
		</dl>
		<dl>
			<dt>B</dt>
			<dd>The Beatles</dd>
			<dd>Bill Evans & Jim Hall</dd>
			<dd>The Blues Brothers</dd>
			<dd>Bob Dylan</dd>
			<dd>Bruse Springsteen</dd>
		</dl>
		<dl>
			<dt>C</dt>
			<dd>Carole King</dd>
			<dd>Char</dd>
			<dd>Coldplay</dd>
			<dd>Cream</dd>
			<dd>Crosby, Stills, Nash & Young</dd>
		</dl>
		 etc ...
	</div>
</div>

最終的にはこのような構造になりますが、table-wrapperクラス要素dummy-headerクラス要素はJavaScriptで動的に生成するので、明示的にマークアップするのは3行目~27行目までの要素のみとなります。

各グループはdl要素を使い、dt要素でグループ内インデックスヘッダーを定義します。

2 | リストをCSSでデザイン

グループとグループ内ヘッダー、そしてリストのコンテナ及びダミーヘッダーのスタイルを書いていきます。

.tableview {
  height: 100%;
  overflow-x: hidden;
  overflow-y: auto;
  position: absolute;
  width: 100%;
}
.tableview dl {
  margin: 0;
  min-height: 1px;
  overflow: hidden;
  padding: 24px 0 0;
  position: relative;
}
.tableview dl.animated dt {
  bottom: 0;
  top: auto;
}
.tableview dt {
  bottom: auto;
  min-height: 1px;
  top: 0;
}
.tableview dd {
  background: #fff;
  font-size: 20px;
  font-weight: bold;
  line-height: 45px;
  margin: 0;
  padding: 0 0 0 12px;
  white-space: nowrap;
}
.tableview dd + dd {
  border-top: 1px solid #ccc;
}

.tableview-wrapper {
  font-family: Helvetica, Arial, sans-serif;
  height: 300px;
  overflow: visible;
  position: relative;
  zoom: 1;
}
.tableview-wrapper .dummy-header {
  box-sizing: border-box;
  margin: 0;
  z-index: 1000;
}
.tableview-wrapper .dummy-header.hidden {
  visibility: hidden;
}

.tableview-wrapper .dummy-header,
.tableview dt {
  background: #B8C1C8;
  background: rgba(184, 193, 200, 0.85);
  border-bottom: 1px solid #989EA4;
  border-top: 1px solid #717D85;
  color: #fff;
  font-size: 18px;
  font-weight: bold;
  line-height: 21px;
  margin-top: 0;
  padding: 2px 0 0 12px;
  position: absolute;
  text-shadow: 0 1px rgba(58, 58, 58, 0.8);
  width: 100%;
}

当記事執筆時点(※2013年1月)ではbox-sizingプロパティに対してベンダープレフィックスをつけて指定する必要があります。(※FireFoxのみ必要) 当記事では可読性を考慮して省略しております。

ここまでで基本的なデザインは出来ました。いよいよここからJavaScriptの実装を行なっていきます

3 | JavaScriptの実装1 - レイアウトの実現

まずは簡単なところでtable-wrapperクラス要素dummy-headerクラス要素を動的に生成するところから始めます。

$(function() {
	var $tableviewWrapper,
		$dummyHeader,
		$tableview;

	$tableview = $('.tableview');
	$tableview.wrap('<div class="tableview-wrapper" />');
	$tableviewWrapper = $tableview.parent();
	$tableviewWrapper.prepend('<h2 class="dummy-header" />');
	$dummyHeader = $tableviewWrapper.find('.dummy-header');
	
});

まずtableviewクラスを起点とし、tableview-wrapperクラス要素を生成してコレで囲みます。次にdummy-headerクラス要素を生成してtableview-wrapperの先頭に差し込みます。これで最終的なHTMLの構成は完成です。

4 | JavaScriptの実装2 - グループから必要な情報を取得

次にインデックスヘッダーの動きを実現するための実装を行なっていきます。まずはHTMLにある各グループから必要な情報を取得していきます。

※上に書いたソースに以下のコードを追記してください。

$(function() {
	// ・・・
	// ・・・前に書いたコード・・・
	
	var groups= [];
	
	$tableview.find('dl').each(function(index, element) {
		var $tempGroup = $(element),
			$tempHeader = $tempGroup.find('dt'),
			$tempGroupHeight = $tempGroup.height(),
			$tempGroupOffset = $tempGroup.position().top;
		groups.push({
			'group': $tempGroup,								// インデックスごとのグループ
			'header': $tempHeader,								// グループ内ヘッダー(インデックス部分)
			'groupHeight': $tempGroupHeght,						// グループの高さ
			'headerText': $tempHeader.text(),					// インデックス
			'headerHeight': $tempHeader.outerHeight(),			// グループ内ヘッダーの高さ
			'groupOffset': $atempGroupOffset,					// tableview-wrapperトップからグループトップまでの距離
			'groupEndY': $tempGroupHeght + $atempGroupOffset	// tableview-wrapperトップからグループボトムまでの距離
		});
	});
	
	$dummyHeader.text(groups[0].headerText);
});

はじめにリスト内の各グループ情報を格納する変数groupsを用意します。$tableviewには各グループの要素であるdlが含まれており.find('dl')each()関数を使って1つずつ中身を見ていきます。そこから必要な項目を抜き出してgroupsオブジェクトに格納します。

grouosオブジェクトに全て格納したあとで$dummyHeaderの初回表示時のテキストを更新します。

5 | JavaScriptの実装3 - インデックスヘッダーに動きをつける

各グループの必要な情報が取得できました。この情報を駆使してスクロール中の各グループの位置、インデックスヘッダーの位置を算出するロジックを書いていきます。

※上に書いたソースに以下のコードを追記してください。

$(function() {
	// ・・・
	// ・・・前に書いたコード・・・
	
	$tableview.scroll(function() {
		setPosition();
	});

	function setPosition() {
		var currentTop = $tableview.scrollTop(),
			topElement,			// 表示領域内で一番上に来ているグループ
			topElementBottom,	// topElementのbottomのY座標値
			offscreenElement,	// 表示領域外にフレームアウトしたグループ
			i = 0;
		while ((groups[i].groupOffset - currentTop) <= 0) {
			topElement = groups[i];
			topElementBottom = topElement.groupEndY - currentTop;
			if (topElementBottom = groups.length) {
				break;
			}
		}

		if (topElementBottom  -topElement.headerHeight) {
			$dummyHeader.addClass('hidden');
			$(topElement.group).addClass('animated');
		} else {
			$dummyHeader.removeClass('hidden');
			if (topElement) {
				$(topElement.group).removeClass('animated');
			}
		}

		if (topElement) {
			$dummyHeader.text(topElement.headerText);
		}
	}
});

$tableviewをスクロールする度にsetPosition()関数を呼び出しています。

まず$tableviewのスクロール量を取得します。次のwhile()ループで表示領域内において一番上に着ているグループとそのBottomの座標値を算出します。

25行目に書いた条件式は、ダミーヘッダーと次にくるグループのインデックスヘッダーが接触してから、次のヘッダーが一番上に来るまでの間を意味しています。この条件に当てはまる間はダミーヘッダーを非表示にし、topElement内のインデックスヘッダーを表示させることで、あたかも画面外に押し出されているような動きを演出することができます。

sampleimg-uitableview1

これでようやく動作するモノが出来ました。ブラウザで実際の動きを確認してみましょう。

6 | jQueryプラグイン化する

出来上がったJavaScriptコードをjQueryプラグインとして外出しします。とりあえずオプションとしてリストの高さ(※ height)を渡せるようにしました。

jquery.uiTableView.js

;(function($) {
	$.fn.uiTableView = function(options) {
		var elements = $(this);
		var opts = $.extend({}, $.fn.uiTableView.defaults, options);

		elements.each(function() {
			initUITableView(this, opts);
		});

		return this;
	}

	$.fn.uiTableView.defaults = {
		height: 300
	};

	// 初期化
	function initUITableView(element, options) {
		var $tableviewWrapper,
			$dummyHeader,
			$tableview,
			groups = [];

		$tableview = $(element);
		$tableview.wrap('<div class="tableview-wrapper" />');
		$tableviewWrapper = $tableview.parent().height(options.height);
		$tableviewWrapper.prepend('<h2 class="dummy-header" />');
		$dummyHeader = $tableviewWrapper.find('.dummy-header');

		$tableview.find('dl').each(function(index, element) {
			var $tempGroup = $(element),
				$tempHeader = $tempGroup.find('dt'),
				$tempGroupHeight = $tempGroup.height(),
				$tempGroupOffset = $tempGroup.position().top;
			groups.push({
				'group': $tempGroup,								// インデックスごとのグループ
				'header': $tempHeader,								// グループ内ヘッダー(インデックス部分)
				'groupHeight': $tempGroupHeight,						// グループの高さ
				'headerText': $tempHeader.text(),					// インデックス
				'headerHeight': $tempHeader.outerHeight(),			// グループ内ヘッダーの高さ
				'groupOffset': $tempGroupOffset,					// tableview-wrapperトップからグループトップまでの距離
				'groupEndY': $tempGroupHeight + $tempGroupOffset	// tableview-wrapperトップからグループボトムまでの距離
			});
		});

		$dummyHeader.text(groups[0].headerText);

		$tableview.scroll(function() {
			setDummyHeader();
		});

		var setDummyHeader = function() {
			var currentTop = $tableview.scrollTop(),
				topElement,			// 表示領域内で一番上に来ているグループ
				topElementBottom,	// topElementのbottomのY座標値
				offscreenElement,	// 表示領域外にフレームアウトしたグループ
				i = 0;
			while ((groups[i].groupOffset - currentTop) <= 0) {
				topElement = groups[i];
				topElementBottom = topElement.groupEndY - currentTop;
				if (topElementBottom = groups.length) {
					break;
				}
			}

			if (topElementBottom  -topElement.headerHeight) {
				$dummyHeader.addClass('hidden');
				$(topElement.group).addClass('animated');
			} else {
				$dummyHeader.removeClass('hidden');
				if (topElement) {
					$(topElement.group).removeClass('animated');
				}
			}

			if (topElement) {
				$dummyHeader.text(topElement.headerText);
			}
		};
	}
})(jQuery);

これでプラグイン化できました。jQueryプラグインについては以下の記事にて詳しく解説しています。

次にプラグインの呼び出し部分です。以下のようにオプション指定して呼び出します。

uitableview.html(※JS部分のみ)

$(function() {
	$('.tableview').uiTableView({
		height: '600px'
	});
});

iQuery Mobileと組み合わせてみた

せっかくなのでjQuery Mobile 1.3.0 Betaと組み合わせて、それっぽい見た目を作ってみました。

sampleimg-uitableview_jqm

iOS風のビジュアルデザインは、コチラよりテーマファイルを拝借しています。

iPhone5(iOS 6.0.2) / Safariにて動作確認をしておりますが、端末のタテヨコの向きを変えた際にレイアウトが崩れる不具合があります。ページロード完了後にSafariのアドレスバーが引っ込んでしまうのが原因ですが、お恥ずかしながら打開策が見つけられていません。

uitableview-jqm.html(※JS部分のみ)

$(function() {
	$(window).on('load', function(e) {
		$('.tableview').uiTableView();

		$(window).on('resize orientationchange', function(e) {
			resizeTableView(0);
		});

		var margin = 0;
		if (navigator.userAgent.indexOf('iPhone') && $.browser.safari) {
			margin = 60;
		}
		resizeTableView(margin);
	});

	function resizeTableView(margin) {
		var offsetTop = $('.tableview').offset().top,
			footerHeight = $('.ui-footer').outerHeight(),
			$tableViewWrapper = $('.tableview').parent();

		var h = $(window).height() - offsetTop - footerHeight + margin;
		$tableViewWrapper.height(h);
	}
});

デモ

View demo(with jQuery Mobile)を開く(このサンプルはChromeブラウザでの閲覧をおすすめします。)

おわりに

数年前にiPhoneを初めて手にした時、このさりげないインタラクションがとても魅力的に思えて「さすがApple!おれたちにできない事を平然とやってのけるッ!そこにシビれる!あこがれるゥ!」と地味に感動したものでした。(※同時にはじめて購入したApple製品でもありました。)それから随分と時間が経ちましたが、ひとまずそれっぽいものを作ることが出来、いろいろと収穫のあるサンプルコードになったかと思います。

参考リンク