ビューポート設定について

私が遭遇した、ビューポート制御ライブラリ「monaca.viewport.js」を使用したときの不思議な現象について記してみたいと思います。

monaca.viewport.js

このライブラリは、モバイル端末の解像度の違いを吸収してくれる便利なライブラリです。具体的には、端末の表示領域(window.innerWidth)にぴったり合うようにアプリのコンテンツ(body要素)を拡大縮小します。また、resizeイベントを監視して端末の回転を検知し拡大縮小します。

詳細については以下のサイトをご参照ください。

現象

monaca.viewport.jsを使用するとき、body要素のwidthプロパティに値を設定すると、端末の向きを縦から横に回転させ、縦に戻したときに、window.innerWidth/innerHeightやdocument.body.clientWidth/clientHeightの値がもとに戻らなくなりました。例えば、「ライブラリでビューポート(表示領域)を360pxに設定したから、bodyのサイズも360pxに設定しとこう」と考えてbody要素のwidthプロパティに値を設定してしまうと、端末を回転させたときに画面のレイアウトが崩れることになるかもしれません。実際に私が作成したアプリでは、画面の回転の発生後に縦向きに戻ったとき、表示領域の高さが短くなり、リスト要素やボタンが途中で切れて隠れてしまいました。(表示されている部分をスワイプするとスクロールでき、リストの続きやボタンがでてきました)

検証

以下に検証用のソースコードを示します。使用する端末はAndroid 4.4.2で、apkファイルを構築し端末にインストールして確認します。テストは、端末を縦向きの状態で検証アプリを起動し、横向きにしてから縦向きに戻す、というもので、向きを変えたときに発生するresizeイベントのハンドラ内でwindow.innerWidth/innerHeightとdocument.body.clientWidth/clientHeightの値を出力して確認します。

index.html

ライブラリはデバッグ用のコードを追記して使用するため手動で読み込んでいます。コメント部分を有効/無効に切り替えてそれぞれテストを行います。

<!DOCTYPE HTML>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
    <meta http-equiv="Content-Security-Policy" content="default-src * data:; style-src * 'unsafe-inline'; script-src * 'unsafe-inline' 'unsafe-eval'">
    <script src="components/loader.js"></script>
    <script src="lib/onsenui/js/onsenui.min.js"></script>
    <script src="monaca.viewport.js"></script>
    <link rel="stylesheet" href="components/loader.css">
    <link rel="stylesheet" href="lib/onsenui/css/onsenui.css">
    <link rel="stylesheet" href="lib/onsenui/css/onsen-css-components.css">
    <link rel="stylesheet" href="css/style.css">

    <style>
        body {
            margin: 0;
            padding: 0;
            background-color: blue;
/*
            width: 360px;
*/
        }
        #results li {font-size: 10px;}
        #results li.title {color: red;}
        #results li.desc {color: white;}
    </style>

    <script>
        ons.ready(function() {
            assert("ons.ready", true);
            assert("innerWidth: " + window.innerWidth);
            assert("innerHeight: " + window.innerHeight);
            assert("clientWidth: " + window.document.body.clientWidth);
            assert("clientHeight: " + window.document.body.clientHeight);
        });

        // ビューポート設定
        monaca.viewport({
        	width : 360,
        	onAdjustment : function(scale) {
                assert("onAdjustment", true);
                assert("zoom: " + scale);
        	}
        });

        function assert(desc, title) {
            var li = document.createElement("li");
            li.className = title ? "title" : "desc";
            li.appendChild(document.createTextNode(desc));
            document.getElementById("results").appendChild(li);
        }
    </script>
</head>
<body>
    <ul id="results"></ul>
</body>
</html>

 

monaca.viewport.js

ハイライト部分に手を加えました。

/*
 *  monaca.viewport.js
 *
 *  Copyright (c) 2012 Asial Corporation<info@asial.co.jp>
 *
 *  Permission is hereby granted, free of charge, to any person obtaining a copy
 *  of this software and associated documentation files (the "Software"), to deal in
 *  the Software without restriction, including without limitation the rights to
 *  use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
 *  of the Software, and to permit persons to whom the Software is furnished to do
 *  so, subject to the following conditions:
 *
 *  The above copyright notice and this permission notice shall be included in all
 *  copies or substantial portions of the Software.
 *
 *  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 *  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
 *  FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
 *  COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
 *  IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 *  WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

(function() {
    window.monaca = window.monaca || {};

    var IS_DEV = false;
    var d = IS_DEV ? alert : function(line) { console.debug(line); };

    /**
     * Check User-Agent
     */
    var isAndroid = !!(navigator.userAgent.match(/Android/i));
    var isIOS     = !!(navigator.userAgent.match(/iPhone|iPad|iPod/i));

    var defaultParams = {
        width : 640,
        onAdjustment : function(scale) { }
    };

    var merge = function(base, right) {
        var result = {};
        for (var key in base) {
            result[key] = base[key];
            if (key in right) {
                result[key] = right[key];
            }
        }
        return result;
    };

    var zoom = function(ratio) {
        if (document.body) {
            if ("OTransform" in document.body.style) {
                document.body.style.OTransform = "scale(" + ratio + ")";
                document.body.style.OTransformOrigin = "top left";
                document.body.style.width = Math.round(window.innerWidth / ratio) + "px";
            } else if ("MozTransform" in document.body.style) {
                document.body.style.MozTransform = "scale(" + ratio + ")";
                document.body.style.MozTransformOrigin = "top left";
                document.body.style.width = Math.round(window.innerWidth / ratio) + "px";
            } else {
                document.body.style.zoom = ratio;
            }
        }
    };

    if (isIOS) {
        monaca.viewport = function(params) {
            d("iOS is detected");
            params = merge(defaultParams, params);
            document.write('<meta name="viewport" content="width=' + params.width + ',user-scalable=no" />');
            monaca.viewport.adjust = function() {};
        };
    } else if (isAndroid) {
        monaca.viewport = function(params) {
            d("Android is detected");
            params = merge(defaultParams, params);

            document.write('<meta name="viewport" content="width=device-width,target-densitydpi=device-dpi" />');

            monaca.viewport.adjust = function() {
                var scale = window.innerWidth / params.width;
                monaca.viewport.scale = scale;
                zoom(scale);
                params.onAdjustment(scale);
            };

            var orientationChanged = (function() {
                var wasPortrait = window.innerWidth < window.innerHeight;
                return function() {
                    var isPortrait = window.innerWidth < window.innerHeight;
                    var result = isPortrait != wasPortrait;
                    wasPortrait = isPortrait;
                    return result;
                };
            })();

            var aspectRatioChanged = (function() {
                var oldAspect = window.innerWidth / window.innerHeight;
                return function() {
                    var aspect = window.innerWidth / window.innerHeight;
                    var changed = Math.abs(aspect - oldAspect) > 0.0001;
                    oldAspect = aspect;

                    d("aspect ratio changed");
                    return changed;
                };
            })();       // ()付け足し

            if (params.width !== 'device-width') {
                window.addEventListener("resize", function() {
                    assert("resize", true);
                    assert("innerWidth: " + window.innerWidth);
                    assert("innerHeight: " + window.innerHeight);
                    assert("clientWidth: " + window.document.body.clientWidth);
                    assert("clientHeight: " + window.document.body.clientHeight);

                    var left = orientationChanged();
                    var right = aspectRatioChanged();

                    if (left || right) {
                        monaca.viewport.adjust();
                    }
                }, false);
                document.addEventListener('DOMContentLoaded', function() {
                    assert("DOMContentLoaded", true);
                    assert("innerWidth: " + window.innerWidth);
                    assert("innerHeight: " + window.innerHeight);
                    assert("clientWidth: " + window.document.body.clientWidth);
                    assert("clientHeight: " + window.document.body.clientHeight);

                    monaca.viewport.adjust();
                });
            }
        };
    } else {
        monaca.viewport = function(params) {
            params = merge(defaultParams, params);
            d("PC browser is detected");

            monaca.viewport.adjust = function() {
                var width = window.innerWidth || document.body.clientWidth || document.documentElement.clientWidth;
                var scale = width / params.width;
                zoom(width / params.width);
                params.onAdjustment(scale);
            };

            if (params.width !== 'device-width') {
                window.addEventListener("resize", function() {
                    monaca.viewport.adjust();
                }, false);
                document.addEventListener("DOMContentLoaded", function() {
                    monaca.viewport.adjust();
                });
            }
        };
    }

    monaca.viewport.isAndroid = isAndroid;
    monaca.viewport.isIOS     = isIOS;
    monaca.viewport.adjust    = function() { };
})();

body要素にwidthプロパティを指定しない場合

1.アプリ起動時

私の端末は 360 x 567 です。

vp_no_width_1
 

2.横向き

端末を横にすると、resizeハンドラ内で 598 x 335 と表示されました。ライブラリでは、598 / 360 = 1.6611… ということで、body要素のzoomプロパティにこの値を設定し、コンテンツが拡大されます。実際、縦向きのときと文字の大きさを比べてみると拡大されているのが分かります。(ライブラリを使わない場合は縦向きのときと同じ大きさになります)

最終行のresizeハンドラ内でclientWidth/clientHeightが 360 x 202 になっています。これは正直わかりませんが、zoomプロパティに1.6611…を設定した後に発生したresizeイベントのハンドラ内ですでにclientWidth/clientHeightの値がどちらも 0.6020倍(= 360 / 598)縮小された(幅に合わせて縮小された?)状態になっている、という見方もできそうです。

vp_no_width_2
 

3.縦に戻す

最後に端末を縦に戻すと、resizeハンドラ内ですでにinnerのサイズが 360 x 567 に戻っています。clientのサイズは 217 x 341 になりました。次にzoomプロパティの値を1.66111…から 1 に書き換えると、resizeハンドラ内でclientのサイズが 360 x 567 に戻ったことが確認できます。

vp_no_width_3
 

body要素にwidthプロパティを指定した場合

上記index.htmlの20-22行目のコメントをとった状態(width: 360pxを設定した状態)で実行してみます。

1.アプリ起動時

widthプロパティ指定なしと同じ状態です。

vp_360px_1
 

2.横向き

ここでもwidthプロパティ指定なしと同じ状態です。

vp_360px_2
 

3.縦に戻す

ここが問題の箇所になります。端末の向きを縦にしたことで発生するresizeイベントのハンドラ内で、innerのサイズが 598 x 942 と、もとに戻らないのです。このため、zoomの値は 360 / 360 = 1 ではなく、598 / 360 = 1.6611…となります。最終的に、innerのサイズとclientのサイズがもとのサイズに戻らないということになります。(ちなみに私のアプリでは、clientHeightの値である341pxで表示が切れてしまいました)

vp_360px_3
 

まとめ

monaca.viewport.jsを使用する場合、body要素のwidthプロパティに値を設定すると、端末を回転させたとき、innerのサイズやclientのサイズが想定外の値になるので、widthの指定はやめたほうがいいかと思います(ちなみに「width: 100%;」の指定なら問題ないみたいです)。

「端末を回転させなければ問題ない」とは思いますが、この場合も注意したほうがいいケースがあります。それはインタースティシャル広告を使用している場合です。横向きのインタースティシャル広告が表示されるとき、強制的に回転した状態になります。そもそもこの現象は、端末の「画面の自動回転」設定を無効にしている状態で、横向きのインタースティシャル広告が表示されたときに発覚しました。そのときはかなりの衝撃を受けました。「早急に対応しなくては」ということで、bodyのwidthを削除することなく(気づかず)、ライブラリをいじりました。ついでなので載せておきます。一応問題なく動いています。

monaca.viewport.adjust = function() {
    var params_width = params.width * parseFloat(document.body.style.zoom);
    var scale = window.innerWidth / params_width;
    monaca.viewport.scale = scale;
    zoom(scale);
    params.onAdjustment(scale);
};
document.addEventListener('DOMContentLoaded', function() {
    document.body.style.zoom = 1;
    monaca.viewport.adjust();
});

以上、ご参考まで。

Monacaメモ(環境編)

これまでのアプリ開発でハマったことをまとめてみようと思います。私の理解不足や勘違いがあるかもしれませんので、その点はご容赦を。

MonacaクラウドIDE/LocalkitとMonacaデバッガ/実機のイベント発生タイミングの相違について(onsen ui v1)

最初に悩んだのがこれです。ons-navigatorのpage属性によりons-templateを読み込む場合に、ons.ready()でons-template内の要素を取得すると、Monacaデバッガや実機ではオブジェクトが返るのですが、クラウドIDEやlocalkitではnullが返ります。

html

<body>
    <ons-navigator page="test.html" var="test.navi"></ons-navigator>

    <ons-template id="test.html">
        <ons-page>
            <p id="text01">hello</p>
        </ons-page>
    </ons-template>
</body>

javascript

ons.bootstrap();
ons.ready(function() {
    var elt = document.getElementById("text01");
    console.log("ons.ready: " + elt);  // クラウドIDEやlocalkitではnull
});

私には、実機ではちゃんとpage属性によるons-templateの読み込み完了後にons.ready()が呼ばれるけど、クラウドIDEやlocalkitの環境では読み込み完了を待たずにons.ready()が呼ばれているように見えます。対策として私は最初、ons.ready()で要素が取得できない場合(クラウドIDEやlocalkitの場合)はフラグを立て、pageinitイベントで初期化をしていました。これでどちらの環境でも動くようになりましたが、しばらく経ったある日ソースを見てみると、へんてこなフラグはあるは初期化関数の呼び出しが2箇所あるはで、ちょっとかっこ悪いことに気づきました。どうにかならないかと考えた挙句、最終的に下した結論はons-navigatorのpage属性の削除です。

html

<body>
    <ons-navigator var="test.navi">
        <ons-page>
            <p id="text01">hello</p>
        </ons-page>
    </ons-navigator>
</body>

ons-templateが別ファイルにある場合も含め、index.htmlのons-navigatorの中にpage要素の内容を丸ごとコピペ。これでアプリ起動時のpage要素やファイルの読み込みもなくなり一件落着。ちなみに今日onsen ui v2で試したら、なんとlocalkitでもons.ready()で要素の取得ができました。なので、v2ではこのようなことはしなくてよさそうです…

ビルド設定画面での注意点(localkit)

これはlocalkitのお話です。

apk構築のため、メイン画面のツールバーから「リモートビルド」または「ビルド設定」をクリックします。すると、以下のような画面が表示されます。

build

この状態にあるとき、エディタでhtml、css、jsなどのファイルを編集・保存した後ビルドを実行しても、構築したapkファイルに編集内容は反映されません。また、右上の「×」ボタンで画面を閉じると、ファイルの状態がビルド設定画面の表示前に戻ります。これ、ビルド実行中の待ち時間にソースを修正したくなってコーディングをはじめてしまうと、気づいたときに「やばっ」てなります。もし気づかずに、ファイルを修正、保存したことに安心して「×」ボタンを押してしまうと、悲しいことに最初からやり直しです。こうゆうときはファイルをいったん別の場所に保存したり修正部分を新規ファイルに張り付けるなどの応急処置が必要です。また、この状態にあるとき、プロジェクトフォルダ内にファイルをコピーしても、元の画面に戻ると消えてなくなります。windowsの「ごみ箱」の中にも入らないので要注意です。

Cordova 6.2.0 アップグレード時の注意点(localkit)

Localkitのプロジェクトの設定で、Cordovaバージョンを 5.2.0 から 6.2.0 にアップグレードした後、Monacaデバッガーでエラーがでるようになりました。プロジェクトを選択すると同期中に「問題が発生したため、[Monaca Debugger]を終了します。」と表示され、Monacaデバッガーが強制終了します。にっちもさっちもいかなくなり、Monacaサポートにメールで問い合わせたところ迅速に対応して頂きました。

過去の事例より、Config.xmlに設定されている「FadeSplashScreenDuration」の値が、Cordova6.2以前の設定になっている場合に、同様に事象が発生する場合がございます。対象プロジェクトのConfig.xmlに設定されている「FadeSplashScreenDuration」の値が、「.25」になっている場合は、下記のように修正してお試しいただけますでしょうか。Config.xmlにつきましては、対象プロジェクトのルートフォルダーにございます。
■例:<preference name=”FadeSplashScreenDuration” value=“250”/>

ということで、このとおりFadeSplashScreenDurationの値を修正したら無事Monacaデバッガが動くようになりました。…もっと早めに聞けばよかったです。

以前に作成したプロジェクトをビルドしたらエラーになるとき

これには焦りました。アップグレードの前に以前のプロジェクトをただ再ビルドしてみただけなのに。エラーログの内容もよくわからないし、プロジェクトまるごとdiffをとって相違点をいじってもだめ。このような場合、個人的に最も有効だと思われる解決方法は、「さっさとプロジェクトを新規作成しファイルを入れ替える」です。ビルド設定やプラグイン、アイコン、スプラッシュ画像などすべて再設定することになりますが、それでもこの方法が「最終手段でありつつ最速な解決方法」だと思います。

Smoking Note V1.2.0 アップデート

本日、節煙サポートアプリ「Smoking Note」のアップデートを行いました。10/6から始めたので11日間。けっこうかかってしまいました。

アップデートの内容は、

・思いとどまりボタンにエフェクトを追加
・「本日の記録の初期化」と「全記録の初期化」を削除
・「前回の記録表示」を追加
・「月」のグラフから合計本数を削除。平均本数を棒グラフに変更
・「曜日」のグラフで喫煙率が最大となる曜日を強調表示

です。

思いとどまりボタンのエフェクトには「clickSpark.js」を使ってみました。これ、けっこう楽しくて、GIMPで画像作ったりパラメータをいろいろいじってたら時間がどんどん過ぎていった感じです。

グラフは「flotr2.js」を使ってます。棒グラフのデータを作るとき、おばかなことしてしまい軽くハマりました。これは後でネタにしようかな。

他に、アプリの機能以外のことで、アップデートのお知らせダイアログとレビューのお願いダイアログを実装しました。アップデートのお知らせはすでに使用しているユーザにだけ表示し、レビューのお願いはアップデート後7日経過時に表示されるようにしました。

とりあえず、次は瞑想サポートアプリをアップデートしたいと思っています。