2010-10-30

iPhone アプリらしく #2 - 実装 (Blogger Glass)

Blogger Glass: List view

前回(「iPhone アプリらしく #1」)、画面のデザイン(おもに意匠)を示してから、途中寄り道をしていたりもしたが、ようやく iPhone アプリらしい画面が動くようになった(一部、未実装の機能あり)。

Blogger Glass は(今のところ) Ajax はもちろん、JavaScript すら使っていない、純粋に画面遷移だけによるとても古臭いウェブアプリだが、それでも画面を整えてやることでグッと iPhone アプリらしく見えてくる。

実装方針

デバイスによって画面テンプレートとスタイルの切り替える

iPhone アプリらしい画面を Mac 用のものと共通にしようとするのは骨が折れるばかりだとわかった。同じ iOS デバイスの iPad にしたところで、画面の大きさがここまで違うと同じ画面を使い回すのはユーザ体験を低下させかねない。iPad のアプリの多くが「HD」と銘打って(iPhone と兼用ではなく) iPad 専用となっているのもうなずける。思い切って、画面を作るためのテンプレートとスタイルシートは iPhone 専用のものを作ることにした。

Blogger Glass を作り始めたときから、画面の構造(テンプレート)は画面ごとに用意していた。画面が増えてきたときに共通部分をくくり出すことにしたが、取り出せたのは、head 要素の他は画面の上部と下部に表示させることにした一部の情報用の領域のみ(図の App Header と App Footer)。

iPhone 用の画面を作っているときに気付いたが、この共通情報は、ブログなどのウェブサイトの構造を引きずったものだ。ウェブアプリには向かない、むしろ不要と言って良いものだった。そんなわけで、iPhone 用の画面構造(テンプレート)では、App (Header|Footer) をばっさり削った。以下に示す通り、iPhone 用にも base.html は存在するものの、共通化されているのはほぼ head のみになっている。

(src/templates の構造)
templates/
+-- base.html
+-- iphone/
|   +-- base.html
|   +-- list.html
|   +-- menu.html
|   +-- post.html
|   +-- search.html
|   +-- settings.html
+-- list.html
+-- menu.html
+-- post.html
+-- search.html
+-- settings.html

画面の構造が変われば、スタイルシートも変わる。idclass を駆使し、複雑なセレクタ指定を使えば、すべての画面に対して 1 つのスタイルシートファイルで対応することもできる。が、それは巨大なファイルになるし、複雑さを軽減させようとテンプレートを分けたのにスタイルシートで複雑さを増やしていたのでは片手落ちと言うものだろう。だから、スタイルシートも画面ごとに分けることにした。

ただし、共通部分の多い画面(一覧画面と検索結果画面等)ではスタイルシートファイルを @import することで、同じスタイルの定義が複数の場所に散らばることの避けた。もっとも、こういう方針を厳守しているかと問われると、実は心もとなかったりする。CSS って書き方(文法)に自由度がありすぎて、統一した様式で書くのって難しいんだよ。

(src/stylesheets の構造)
stylesheets/
+-- default.css
+-- ipad.css
+-- iphone/
|   +-- common.css
|   +-- list.css
|   +-- menu.css (iphone/list.css を import; 一部独自スタイルで上書き)
|   +-- post.css
|   +-- search.css (iphone/list.css を import)
|   +-- settings.css
+-- mac.css (default.css を import)

実装

デバイスに応じて画面の構造(テンプレート)とスタイルを切り替える仕組みが、新たに追加した device モジュールだ。

device モジュール

このモジュールでは、クライアントに想定してる各種デバイスを表すクラスといくつかのユーティリティ関数を定義している。

リクエストハンドラからは、ユーティリティの 1 つ、ファクトリ関数を呼び出してリクエストデバイスを表現するオブジェクトを取得する。ファクトリ関数を以下に示す。

(src/device.py より)
def get_device(user_agent, view):
    if user_agent.find('iPad') > -1:
        dev = iPad(view)
    elif user_agent.find('iPhone') > -1 or user_agent.find('iPod') > -1:
        dev = iPhone(view)
    else:
        dev = Mac(view)
    return dev

これを呼び出すリクエストハンドラのコードは以下のようになる。

(src/main.py より)
        user_agent = self.request.headers['User-Agent']
        view = self.viewinfo.type
        self.viewinfo.device = device.get_device(user_agent, view)

ViewInfo オブジェクトに格納された device オブジェクトは、画面を定義しているテンプレートの切り替えと、画面テンプレートの中でスタイルを定義している CSS ファイルの参照に使用される。

(src/util.py より)
def render_template(handler):
    view = handler.viewinfo.type
    path = handler.viewinfo.device.template
    return template.render(path, {
            'app': handler.appinfo,
            'view': handler.viewinfo,
            })
(src/templates/iphone/base.html より)
<!DOCTYPE HTML>
<html lang='{{ app.lang }}'>
<head>
  [...snip...]
  {% for css in view.device.stylesheets %}
  <link type='text/css' rel='stylesheet' href="{{ css }}">
  {% endfor %}
</head>

各種デバイスを表すクラスを示す。

(src/device.py より)
class Device(object):
    def __init__(self, device, view):
        self.is_ios_device = False
        self.stylesheets = None
        self.template = None
        self.template = get_template(device, view)
        self.stylesheets = get_stylesheets(device, view)

class iPhone(Device):
    def __init__(self, view):
        Device.__init__(self, 'iphone', view)
        self.is_ios_device = True

is_ios_device 属性は、専用テンプレートを使っている iPhone では不要だ。他のデバイス(Mac) とテンプレートを共有している iPad でまだ使っているため残してある。実際に、テンプレートとスタイルシートの切り替えを実現しているのは、基底クラス(device.Device) の初期化中に呼び出されている以下の 2 つの関数だ。

(src/device.py より)
def get_template(device, view):
    if device == 'iphone':
        name = ("templates/%s/%s.html" % (device, view))
    else:
        name = ("templates/%s.html" % view)
    return os.path.join(os.path.dirname(__file__), name)

def get_stylesheets(device, view):
    style_dir = 'stylesheets'
    if device == 'iphone':
        common_style = ("/%s/%s/common.css" % (style_dir, device))
        view_style = ("/%s/%s/%s.css" % (style_dir, device, view))
        return [common_style, view_style]
    else:
        return [("/%s/%s.css" % (style_dir, device))]

これを見ればわかるように、切り替えといっても大袈裟なものではなく、デバイス名と画面のタイプをそのままディレクトリの構造とファイル名にしているだけ。

この部分を実装し始めた当初は、テンプレートもスタイルシートもパス名を自由に設定できるような作りにしていた。一言でいえば、デバイス名と画面タイプをキーとする二重のディクショナリだ。で、その二重ディクショナリにセットする値(つまりファイル名)を書いていて、何かひどく間違ったことをやっている気になった。

確かに、ひとつひとつファイル名を設定できた方が自由度が上がる。極端な場合を想像すれば、アプリ外部の URL を(テンプレートはともかくスタイルシートなら)使うことだってできる。しかし、それは過剰な(そして何より不要な)自由度ではないだろうか? そう思えてきた。

このとき頭の中にひとつの言葉が浮かんできた。それは "Convention over Configuration"、そう Rails の基本理念の 1 つだ(→ 「誰が Redmine を起動したのか?」の「Convention over Configuration (設定よりも規約)」を参照)。

デバイス名と画面タイプ(の名前)でファイルが特定できるなら、それをそのままファイルシステムにマップすれば良い。iPhone 用の一覧(list)画面なら iphone/list.html と決めれば良い。誰でもすぐに思い付く自然な規約(convention)だろう。むしろ直感的と言って良いほど。

(プログラマにとって)大事なことを思い出せたおかげで、コードはグッとシンプルなものになった。中途半端に(デバイス名による)条件分岐が残っているのは、今回採用したテンプレートとスタイルシートを画面ごとに分割する方式に則っているのが iPhone の場合だけだから。ま、そのうち、直すとしよう(iPad 用の画面を作るときかな)

jQTouch

今回の更新から、「iPhoneアプリケーション開発ガイド」で紹介されている jQTouch というライブラリを使っている。といっても、使っているのは jQTouch の配布にふくまれているボタン等の背景画像のみだ(iPhone 用の画面で使用)。

やはり外部で配布されているものなので、画像を src/images ディレクトリにコピーするような乱暴なことはやらず、配布物の構造そのままに src/lib に置くことにした (GData ライブラリと同様に bgithub のリポジトリには入れていない)。このため、iPhone 用のスタイルシートからは /lib/jqtouch/themes/jqt/img/button.png のような URL でアクセスしている。

スクリーンショット

この iPhone 対応版はすでに appspot に配備ずみ。ボタンの機能等で未実装部分が一部、残っているが、主要機能は iPhone/iPod touch で動かすことができる。以下は、実際に GAE アプリとして動かした様子を iPod touch (Retina ディスプレイ搭載) で撮ったスクリーンショットだ。

iPod touch によるスクリーンショット
一覧画面
画面上部左にはメニュー画面を開くためのボタンを配置。 画面下部は一覧のページ切り替えのためのボタンを配置。 また、ページ切り替えのボタンはページに応じて有効、無効が変化する。 無効の場合は、ボタンの文字がグレーで表示される。
メニュー画面
画面上部左にはメニューを開く前の画面に戻るたけのボタンを配置(機能は未実装)。 メニューの項目はログインの状態によって変化する。
設定画面
画面上部左にはメニューを開く前の画面に戻るたけのボタンを配置(機能は未実装)。 「Save Settings」等、HTML フォームのボタンについてはスタイル(文字の大きさ等)を検討中。
記事画面
画面上部左にはメニューを開く前の画面に戻るたけのボタンを配置(機能は未実装)。 画面下部左にはラベル一覧画面を開くボタンを配置(機能は未実装)。
検索結果画面
画面上部左にはメニューを開く前の画面に戻るたけのボタンを配置(機能は未実装)。 その他は一覧画面と同様(下部のページ切り替えボタン等)。

今後の展開

もちろん、未実装の部分(機能しないボタンとか)を実装しなければならない。「Back」ボタンなどは簡単に見えて、実装するには戻り先となる前の画面の情報を覚えておかなければならないため、それほど単純なものではない。GAE のデータストアサービスを使うか、あるいは(まだ使い方を調べていないけど) memcache サービスの方が良いのか。「Labels」(ラベル一覧画面を開く)も、ラベルの一覧をどこに保持しておくかで同様の課題がある。ま、見かけほど簡単じゃないと思ったから後回しにしているわけだけどね。

実用的になってくると毎日使うことになり、使えば使うほど「あら」も「ぼろ」も見えてくる。見えれば、「あら」は埋めたくなるし、「ぼろ」は繕いたくなる。やることはまだまだあるってことだ。

iPhone に専用画面で対応したんだから iPad にも同じようにしたい。記事の中身を読むという点では iPad の方が読みやすいんだから。

あと、iPhone 用としてはオフラインで記事を読めるようにしたい。ネットにつながらなかったり、つながっても遅かったりすると、やはりオフライン機能が欲しくなる。HTML5 のオフラインアプリケーションキャッシュ(「iPhoneアプリケーション開発ガイド」6 章で解説されている)を使えば可能かもしれない。しかし、そこまで考えるなら、もういっそのこと Objective-C で書き直すべきかもしれない。GData クライアントは Objective-C 版 (gdata-objectivec-client) もあるしな。

参考文献

関連リンク

関連記事

2010-10-28

リクエストの表現 (あるいは URI) の設計 - Blogger Glass

Blogger Glass では、リクエスト(特定の機能を実行するための URL)を表現するために「クエリ変数」を用いている。具体的には、個別の記事を見るためのリクエストは以下のようになる。

http://bloggerglass.appspot.com/post/?id=1234567890

Blogger Glass にとって(Blogger にとっても)、記事は最小のリソースだが、記事 ID はそれを特定するために必要十分な情報だ。言い換えると、/post/ リクエストに対する付加情報(引数と言っても良い)には記事の ID 以外にはない。このことを踏まえると、/post/?id=1234567890 という表現の中の ?id= の部分は余計なものだとわかる。これは以下のように書けばすっきりした表現になる。

http://bloggerglass.appspot.com/post/1234567890

同じことは他のリクエストに対してもあてはまるだろうか?

2 つの形式

(「RESTful Webサービス」p.126)
パス変数は階層をトラバースしているように見えるし、クエリ変数はアルゴリズムに引数を渡しているように見える。「検索」にはアルゴリズムのような響きがある。

以下では、「クエリ変数」を使ったリクエストの表現を「アルゴリズム形式」、パスにマップした表現を「パス形式」と呼ぶことにする。

プログラミング的に見れば、必要な情報をウェブアプリに渡すという意味で 2 つの形式は等価だと言える。ただし、アルゴリズム形式は、伝統的に CGI で使われてきたこともあり、パス形式より簡単に扱える(後述)。

ヒトが読むことを想定するなら、アルゴリズム形式よりもパス形式の方が見やすい。先の個別記事を見るためのリクエストの例のように、パスの階層の意味が明確ならなおさらだ。

とはいえ、ウェブサービスを作っているならともかく、ウェブアプリではトップの URL を別にして、リクエストを表現する URL はユーザの目に触れることを意図しない。であれば、どちらを使ったところで「ユーザ体験」に差が出るとは思えない。

試してみる

なにはともあれ、試してみよう。

Blogger Glass 全リクエスト形式 (省略形をのぞく)
リクエスト 機能
/?page=<number> number で指定したページの一覧を表示する。
/post/?id=<id> id で指定した記事の内容を表示する。
/search/?label=<string> ラベル string で記事を絞り込んむ。結果が複数ページにわたる場合は、最初のページ(ページ番号 0)を開く。
/search/?label=<string>&page=<number> ラベル string で記事を絞り込んだ結果のうち、number で指定したページを開く。
/settings/ 設定変更のための画面を開く。

これらアルゴリズム形式のリクエストをパス形式で表現すると以下のようになる(付加情報のない /settings/ を除く)。

アルゴリズム形式とパス形式の対応
アルゴリズム形式 パス形式
/?page=<number> /<number>
/post/?id=<id> /post/<id>
/search/?label=<string> /search/<label name>
/search/?label=<string>&page=<number> /search/<label name>/<number>

以下では、個別記事を見るためのリクエストをパス形式にするための実装上の変更点を見ていく。

追記@2010-10-31

以下の記述はほぼ無用のものだった。GAE の webapp フレームワークでは、リクエスト URL とリクエストハンドラを結びつける URL マッピングという機能を提供している。これにより、上述のパス形式で表現されたリクエスト URL の中から、その一部をパラメータとして切り出すことが簡単にできる。正規表現のグルーピングを使って切り出す部分を指定すると、get メソッド等の引数となってわたってくる。

具体的にはこうなる。

(src/postview.py より)
class PostViewHandler(webapp.RequestHandler):
    [...snip...]
    def get(self, post_id):
        [...snip...]

def main():
    application = webapp.WSGIApplication([('/post/(\d+)', PostViewHandler),
                                          ],
                                         debug=True)

Bloggger Glass のコードには、GAE の便利な機能を知らないために書いてしまっている無駄なコードが他にもまだあるんだろうな。

app.yaml への記述
(src/app.yaml より)
- url: /post/|/post/.*/?
  script: postview.py

省略形の /post//post/1234567890/ のように末尾の余分なスラッシュに対応するために、URL パターンは少し複雑になっている。

リクエストハンドラの変更

変更点は主に 2 ヶ所。1 つは URL から付加情報を取り出す部分、もう 1 つは URL に対するハンドラの対応づけだ。

付加情報の取り出しは util モジュールに関数として置く。

(src/util.py より)
def get_params(uri, request_type):
    """This function assumes that a request uri must be written in
    one of the following forms:
    - 'http://<host>/<param_1>/<param_2>/.../<param_n>/'
    - 'http://<host>/<request_type>/<param_1>/<param_2>/.../<param_n>/'
    """
    uri_parts = uri.split('/')[3:]
    if len(uri_parts) < 1 or uri_parts[0] == '':
        params = None
    elif uri_parts[0] == request_type:
        params = uri_parts[1:]
    else:
        params = uri_parts
    return params

呼び出す側を以下に示す。

(src/postview.py より)
        post_id = util.get_params(self.request.uri, 'post')[0]

ハンドラの対応づけ部分はこうなる。

(src/postview.py より)
def main():
    application = webapp.WSGIApplication([('/post/', PostViewHandler),
                                          ('/post/.*/?', PostViewHandler),
                                          ],
                                         debug=True)

app.yaml 同様の指定になっている。2 つに分けているのは見易さのためだ。

参考文献

RESTful Webサービス
Leonard Richardson, Sam Ruby
オライリー・ジャパン ( 2007-12-21 )
ISBN: 9784873113531
おすすめ度:アマゾンおすすめ度

関連リンク

関連記事

2010-10-27

絞り込み検索とパンくずリスト

Blogger Glass を「iPhone アプリらしく」仕立てるためのアレコレを考えていて、ラベルによる絞り込み検索というモノを思い付いた(「iOS デバイスに対応する (基礎編) - Blogger Glass」の「もっと iPhone らしく」を参照)。

ユーザ体験としてはこんな感じになる。

  1. 「一覧画面」から「ラベル検索画面」を開く。
  2. 元の「一覧画面」に出ていた記事それぞれに付いていたラベルがまとめて表示されている。
  3. ラベルの中から 1 つ選ぶと、検索実行。
  4. 「検索結果画面」になる。 (*)
  5. 絞り込みをする場合、再度「ラベル検索画面」を開く。
  6. さきほどの「検索結果画面」の記事それぞれに付いていたラベルがまとめて表示されている。
  7. ラベルの中から 1 つ選ぶと、検索実行。このときは、先に選んだラベルとの複合検索(AND)になる。
  8. (*) に戻る。

最初に「一覧表示」から開いた「ラベル検索画面」では、すべての記事に付いているラベルを表示する方が良いかも。

パンくずリスト

日本語では「パンくずリスト」、英語では「breadcrumbs」あるいは「breadcrum trail」と呼ばれるモノがある。現在表示中のウェブページの位置を、たどってきたパスの階層構造等で表現するナビゲーションアイテムだ。Mac OS X の Finder で、表示中のフォルダの階層構造を示すために使われている「パスバー」もこの一種だ(Finder に表示されていなければ、メニューから「表示」>「パスバーを表示」を選ぶ)。

Finder のパンくずリスト

上述の「ラベル検索」では、検索に使われたラベルをユーザにフィードバックしなければならない。そのためにも「パンくずリスト」が適しているはず。これならユーザが選んだ順序をふくめて表現できる。検索結果はラベルを選んだ順序によらないが、ユーザにとっては自分の操作を正確にフィードバックしてくれる方が良い(少なくともわたしはそうだ)し、なにより「パンくずリスト」なら、逆にたどって以前の画面(検索結果)に戻っていける(という機能をわかりやすく提示できる)。

文字入力させたら負けだと思う

iPhone のように使用状況を選ばないデバイス(歩きながらでも、満員電車で吊り革につかまっているときでも使えるし、使いたい)では、文字入力のような複雑な操作はなるべく避けたい。項目をタップし、画面をドラッグそれにスワイプする。片手で操作できるなら歩きながらでも使える。

検索では、最初にラベルを複数選んでおいてから検索実行、という方式も考えられる。だが iPhone で実現するなら、操作が自ずと小さなステップに分割され、さらに個々のステップでフィードバックも得られる「順次絞り込み」タイプの方が適している。また、現在の状況と操作の履歴を同時に表現できる「パンくずリスト」はナビゲーションの仕組みとして最適だろう。

ま、実際に使ってみない間は、ただの一般論でしかないがな。

関連リンク

関連記事

2010-10-26

iPhone アプリらしく #1 - 画面デザイン (Blogger Glass)

ウェブアプリを iPhone アプリらしく仕上げるためには、画面のデザイン(とくに意匠)も iPhone 用のものが必要になる。単にスタイルシートの切り替えだけでは限界がある。さらに言えば、スタイルシートも共通部分と画面に特有のものとに分けるべきだ。

以下は、Blogger Glass の主要画面のデザインを iPhone 用に作り直したものだ。

Blogger Glass: iPhone 用画面デザイン
画面 外観 説明
一覧画面 記事の一覧をリストとして表示する。各項目をタップすることで個別記事の内容画面に移る。左上のボタンでメニューを表示する。
内容画面 ブログの個別記事の内容を表示する。左上のボタンでリスト表示に戻る。左↓のボタンで、記事に付けられたラベル一覧を表示する(→ ラベル検索画面)。
メニュー アプリメニューを表示する。「Back」でメニューを開く前の画面に戻る。
ラベル検索画面 記事に付けられたラベルをリストで表示する。各項目をタップすることで、ラベル検索を行う。下部にはラベルによる絞り込みの状況を表示する。メニューと同様、「Back」で 1 つ前の画面に戻る。

画面のデザインが変われば機能にも手直しが入る。

たとえば、iPhone の画面の狭さから、これまではアプリ画面上部に共通で表示させていたメニューは、別画面として独立させることにした。また、内容画面では一覧表示に戻るためのボタン(リンク)を用意する。メニューやラベル検索画面にも「Back」ボタンを用意して元の画面に戻れるようにする。

この「戻る」を実現するためには、元の画面(をリクエストする URL)をプログラム的に記憶しておく仕組みが必要になる。

変更するのはスタイルシートとテンプレートだけで済みそうにないな。

関連記事

2010-10-25

iOS デバイスに対応する (基礎編) - Blogger Glass

Blogger の提供するスタイルにあれこれ変更を加え始めたのは何より iPhone (と iPad) で見やすくしたかったからだ。それが高じてブログの記事を表示する GAE アプリを作るまでに至った。ある程度の実用性を備えた今こそ、iOS デバイスの対応を始めるときだ。

iOS デバイスへの対応と言っても、これまでのように単にスタイルを最適化する程度で終わりたくない。ブログコンテンツを表示するためのアプリと呼べるものに仕上げたいのだ。最終的には Blogger Glass を iOS デバイス用のウェブアプリと呼ぶにふさわしいモノにするつもり。何をどこまで作り込めばそう呼べるかはまだわからない。そもそも BG 自体まだ機能的に不足しているし、実装ずみの機能も洗練されているとは言えないしな。課題は(見えないものもふくめて)多いが、まずは一歩を踏み出そう。そうすれば次に進むべき方向も見えてくるから。

今回は基礎編。まずは以前の成果(スタイル定義)を流用して、現在、Blogger (の LOG+REPO) を iOS デバイスで開いたときと同等の外観を実現する。それには主に以下の 2 つの作業が必要だ。

  • Viewport の設定
  • デバイスごとの CSS ファイルの用意

それでは順番に見ていこう。

viewport の設定

(「iPhoneアプリケーション開発ガイド―HTML+CSS+JavaScript による開発手法」p.17)
特に指定がない場合、iPhone 版の Safari ではページの横幅が 980 ピクセルであるとみなされます(図2-3)。多くの場合はこの設定でも問題はありませんが、iPhone の小さな画面に特化したコンテンツを作成するためには横幅を明示する必要があります。

さて、この横幅の指定は meta タグによって「viewport」を設定することで行う。viewport の設定(の meta タグ)は(iOS デバイスの Safari)以外のブラウザでは無視されるということだ(→「iPhoneアプリケーション開発ガイド」p.18)が、そこはウェブアプリなのだからリクエストデバイスに応じて追加するようにしたい。

それが以下の部分だ。app オブジェクトの is_ios_device 属性が True のときだけ viewport 設定のmeta タグを追加している。

(src/base.html より)
<title>{{ app.title }}</title>
{% if app.is_ios_device %}
  <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1, maximum-scale=1">
{% endif %}
<link type='text/css' rel='stylesheet' href="{{ app.stylesheet }}">

app オブジェクトは、各リクエストハンドラからわたってくる info.AppInfo クラスのインスタンスだ。このオブジェクトに情報を詰めているのが、util.fill_appinfo 関数で、リクエストデバイスの識別を行う util.get_device 関数を利用してデバイスごとの情報を生成している。それにはデバイス専用の CSS ファイルもふくまれている。

(src/util.py より)
def fill_appinfo(appinfo, request_url, user_agent):
    [...snip...]
    device = get_device(user_agent)
    if device == 'iphone':
        appinfo.is_ios_device = True
        appinfo.stylesheet = '/stylesheets/iphone.css'
    elif device == 'ipad':
        appinfo.is_ios_device = True
        appinfo.stylesheet = '/stylesheets/ipad.css'
    else:
        appinfo.is_ios_device = False
        appinfo.stylesheet = settings.get('stylesheet')

デバイスの識別はユーザエージェント文字列中のデバイス名によるもので、3 つのタイプを返す(iphone、ipad、そして mac)。iOS デバイス以外は mac 扱いにしている。

(src/util.py より)
def get_device(user_agent):
    if user_agent.find('iPad') > -1:
        device = 'ipad'
    elif user_agent.find('iPhone') > -1 or user_agent.find('iPod') > -1:
        device = 'iphone'
    else:
        device = 'mac'
    return device

デバイスごとの CSS ファイルの用意

デフォルトスタイルの時と同様に、Style Repository で LOG+REPO 用に提供している各デバイス用のスタイルをほぼそのまま流用した。

iPhone 用では実験的に少し色を変更してあるが、これはすぐに変更することになる(後述)。

Blogger Glass が生成する HTML は HTML5 に準拠することを目指している。headersection、そして footer のような区画分けのための要素を使っているのも、そのためだ。今回、iPad 用のスタイルを用意していて、現在の iPad 上の Safari (OS: 3.2.2 (7B500)) ではこれら区画分けの要素に単独で指定したスタイルが効かないことがわかった。具体的には以下のような定義が無効になる。

(区画分け要素のスタイル)
header {
    background-color: #eeffcc;
}

同じ定義が iPhone および iPod touch (どちらも OS 4.1 (8B117)) では有効になる。iPad の OS がアップデートされれば解決されるだろう。

ま、わたし自身が十分に HTML5 のスペックを読み込んでいないため、BG の HTML5 準拠も手探りだからな。BG が生成する HTML の構造もまだまだ変わる。スタイルもそれに合わせて変えなくてはならない。今のスタイルも暫定版でしかない。

もっと iPhone らしく

さて、以上が iOS デバイス対応の基礎編だ。ここから先は、デバイス対応というよりも、むしろ BG の iOS デバイス専用ウェブアプリ化になる。その手始めとして、今、実験中のスタイル定義によるスクショを示す。iPod touch による表示だ。

これは「iPhoneアプリケーション開発ガイド」の p.19 〜 23 の書かれている手法を適用したものだ。少しリストと見出しのスタイルをいじるだけで、ずいぶん iPhone アプリらしくなる。

ゴールを、iPhone でも使えるモノから iPhone で専ら使うモノに変えると、機能のデザインも(とくにユーザ体験に関して)いろいろと変わってくる。たとえば、ラベル検索による絞り込みも、Mac (上の Safari)で使うことが前提なら、詳細検索画面を作って複数のラベルを指定させて、なんていう拡張を考える。けれど iPhone に特化するなら、まずラベルを 1 つ選択して検索し、その結果表示画面でさらにもう 1 つラベルを選択して(2 つのラベルによる複合検索で)絞り込む、というような使い方を考える。

iPhone では一度に表示できる情報が限られているし、モバイル機器であるためユーザとの対話は短いほど望ましい(ユーザは椅子に座ってじっくりと iPhone の画面を眺めているとは限らない)。そういった機器の特性はユーザ体験のデザインに大きく影響する。ウェブアプリとして実現されていたとしても、iPhone 用を標榜するなら、iPhone アプリらしくあらねばならない。単にスタイルを変えるだけで済む問題ではないってことだ。

参考文献

関連リンク

関連記事

2010-10-24

ページ切り替え機構の切り替え - Blogger Glass

今回はちょっと実験。2 種類のページ切り替えの仕組みを用意し、どちらが使いやすいかを確かめる実験だ。

「ページ切り替え」と言うのは、Blogger Glass の一覧表示系の画面の下部に出てくるもののこと。リストする項目が多いときには複数のページに分割する。そのページを切り替えるための仕組みだ。

現状では、Google の検索結果に出てくるモノ(Goooooooooogle! のようになるアレだ)を真似ている。これをもっとシンプルな「Next」「Prev」ボタンのものと比較してみようと思ったのだ。

2 つの方式の比較

実験というからには、2 つを比較できなければ意味がない。旧来の方式と新しい方式を両方使えるようにしておいて、実際に使ってみて試すわけだ。

幸い、一覧表示系の画面(機能)は「一覧画面」と「検索結果画面」の 2 種類がある。このうち、「一覧画面」のみを新方式にして、「検索結果画面」の方は従来のままにしておく。そうすれば、どちらの方式も試すことができる。

まあ、配備ずみの正式版(appspot で稼動しているもの)と開発中のローカル版(SDK で動かしているもの)で比較するという手もあるんだがね。

リファクタリング

「ページ切り替え機構」を簡単に取り替えられるようにするため、src/info.py、さらに src/main.pysrc/searchview.py にも手を入れる。

具体的には、「ページ切り替え機構」そのものを ViewInfo のインスタンスで抱え、表示対象のページ番号や全ページ数は「ページ切り替え機構」の属性に押し込める。リクエストハンドラは「ページ切り替え機構」を選び、ViewInfo のインスタンスにセットすることになる。

「ページ切り替え機構」は、util.Pager クラスを基底として派生させる。旧方式は util.GlassPager と名付けた。

(src/util.py より)
class Pager(object):
    PAGESIZE = 25
    def __init__(self, page_num, total_posts):
        self.current = page_num
        self.total_pages = 0
        if total_posts > 0:
            self.total_pages = (total_posts - 1) // Pager.PAGESIZE + 1
        else:
            self.total_pages = 1

class GlassPager(Pager):
    class Page(object):
        def __init__(self, number, current=False):
            self.number = number
            self.current = current

    def __init__(self, page_num, total_posts):
        Pager.__init__(self, page_num, total_posts)
        self.pages = []
        for p in range(self.total_pages):
            self.pages.append(GlassPager.Page(p, p == page_num))

src/main.pyMainHandler で「ページ切り替え機構」をセットする付近のコードを示す。

(src/main.py)
            total_posts = int(feed.total_results.text)
            self.viewinfo.pager = util.PrevNextPager(page, total_posts)
            self.viewinfo.start_index = start_index

結果として、ページ切り替えに関する情報の生成と詰め直しを追い出したことになり、少しすっきりした。

シンプルなページ切り替え

ごく単純な「Prev」「Next」の 2 つのボタン(リンク)のある方式で、最初と最後へのショートカットを持っている。つまり、こんな感じになる。

[Top] [Prev] <current page #> [Next] [Last]

また、現在のページが最初のときは「Top」と「Prev」は表示せず、最後のときは同様に「Next」と「Last」を表示しない。

これを実現するための util.Pager の派生クラスは以下のようになる。

(src/util.py より)
class PrevNextPager(Pager):
    def __init__(self, page_num, total_posts):
        Pager.__init__(self, page_num, total_posts)

        if self.total_pages > 0:
            self.last = self.total_pages - 1

        if self.current != 0:
            self.is_top = False
            self.prev = self.current - 1
        else:
            self.is_top = True
            self.prev = 0

        if self.current != self.last:
            self.is_last = False
            self.next = self.current + 1
        else:
            self.is_last = True
            self.next = self.last

で、これを表示するテンプレートはこう。

(src/listview.html より)
{% block view_footer %}
<nav>
  <div class="pager">
    {% if not view.pager.is_top %}
    <span class="l-button"><a href="/?page=0">Top</a></span>
    <span class="l-button"><a href="/?page={{ view.pager.prev }}">Prev</a></span>
    {% endif %}
    <span id="pager-current">{{ view.pager.current }}</span>
    {% if not view.pager.is_last %}
    <span class="r-button"><a href="/?page={{ view.pager.next }}">Next</a></span>
    <span class="r-button"><a href="/?page={{ view.pager.last }}">Last</a></span>
    {% endif %}
  </div>
</nav>
{% endblock %}

info.Menu クラスと同様に、util.Pager クラス(とそのサブクラス)は info モジュールに移すべきかな。

「Goooooooooogle!」方式と「前後」方式

実際に使ってみたところ、「Goooooooooogle!」方式にはとくにメリットがないと感じた。この方式では途中のページにダイレクトに飛べるわけだが、そこに何がリストされているかがわかっていない限りほとんど意味がない。リストを順にたどって見るようなときには「前後」方式の方が切り替えやすい(マウスを動かさなくても良いから)。まとめて何ページも先を開くなら「Goooooooooogle!」方式の方が便利だが、実用上そういう場面が思い浮かばない。

特定の時期に書かれた記事を探すなら大きく前後に移動できた方が便利だが、そういう便利さを実現したいなら月や週で検索できる画面(ビュー)を用意した方が良い。

iPhone のような画面の狭いデバイスの場合も「前後」方式が適している。特定のページを直接開くというユーザ体験を実現するなら、それ専用の画面(ビュー)を作るべきだ。

「一覧」や「検索」といった機能を実行した結果が複数のページに分かれる場合、シンプルな前後方式の方が汎用性が高い(画面のサイズを選ばないから)。そもそも(大量の)複数ページに分かれているという点でユーザ体験としては減点だろう。ユーザが意識して「全部を順番に見たい」というのでない限り、適切に絞り込む手段を提供すべき。そして、適切に絞り込めるなら(ページ数にしてせいぜい 2 〜 3)「前後」方式が(ユーザに余分なことを考えさせないという意味で)直感的だ。

関連リンク

関連記事