Android 端末の回転処理

端末を回転させたとき、アプリを見やすくするために要素の配置やサイズを変えたり、要素を非表示にしたいことがあるかと思います。そこで、Android端末の回転処理について調べました。

回転を抑止する

端末を回転させて横置きにすると回転に対応しているアプリでは横幅に合わせて文字や画像が拡大されたりしますが、そもそもアプリを回転させるメリットがない場合は、回転させないようにしたいところです。
Monacaの場合はconfig.xmlに以下の記述をすることでアプリの向きを固定することができます。

// 縦置き
<preference name="Orientation" value="portrait">

// 横置き
<preference name="Orientation" value="landscape">

CSSメディアクエリ

CSSのorientationメディアクエリで端末の回転を検出することができます。

メディアクエリ — MDN
メディアクエリの利用 — MDN

例)縦置きのときツールバーを表示し、横置きのときツールバーを非表示にする

HTML

<ons-toolbar id="tinytoolbar">
  ...
</ons-toolbar>

CSS

@media screen and (orientation: portrait) {  // 縦置き
  #tinytoolbar {
    display: flex;
  }
}
@media screen and (orientation: landscape) {  // 横置き
  #tinytoolbar {
    display: none;
  }
}

Note

ソフトキーボードが起動すると、その高さの分だけコンテンツが縮小します。このため縦置きの状態でもlandscapeのメディアクエリが適用されることがあります。個人的にこれはネックになりやすいのではと思います。

window.orientation

windowオブジェクトのorientationプロパティは、デバイスの本来の向きに対するビューポートの向きを表します。取得できる値は [-90, 0, 90, 180] とのことです。

Window.orientation — MDN

原文(英語)— WHATWG
日本語翻訳版 — WHATWG

Can i useに掲載されていないのがちょっと謎です。window.screenは掲載されているのですが、現在AndroidやiOSでは対応していないようです。

orientationchangeイベントとresizeイベント

端末を回転させるとorientationchangeイベントとresizeイベントが発火するようです。

Script

window.addEventListener("orientationchange", function(e) {
  console.log("orientationchange");
});

window.addEventListener("resize", function(e) {
  console.log("resize");
});

実機で確認してみる!

テスト用のapkを構築してAndroid端末で動かしてみることにしました。

調べたいこと

・orientationchangeイベントとresizeイベントの発火タイミング
・イベント発生時のwindow.orientationの値
・イベント発生時のビューポートの幅と高さ

HTML

<body>
  <ul id="results" contenteditable="true"></ul>
</body>

CSS

#results {
  margin: 0;
  padding: 4px;
}
li.orient { background-color: orange; }
li.resize {}
li.start { background-color: yellow; }

Script

var count = 0;

ons.ready(function() {
  console.log("Onsen UI is ready!");
  print(-1, check());
});

window.addEventListener('orientationchange', function(e) {
  print(0, check());
});

window.addEventListener('resize', function(e) {
  print(1, check());
});

function check() {
  var result = (count++) + ">";
  result += " i(" + window.innerWidth + "," + window.innerHeight + ")";
  result += " o(" + window.outerWidth + "," + window.outerHeight + ")";
  result += " s(" + screen.width + "," + screen.height + ")";
  result += " *(" + window.orientation + ")";
  return result;
}

function print(typ, msg) {
  var results = document.getElementById("results");
  var li = document.createElement("li");
  switch (typ) {
    case 0: li.className = "orient"; break;
    case 1: li.className = "resize"; break;
    case -1: li.className = "start"; break;
    default: break;
  }
  li.appendChild(document.createTextNode(msg));
  results.appendChild(li);
}

テストプログラムでは以下の3か所で値を取得し表示します。
黄色:起動時
:resizeイベント
:orientationchangeイベント

端末はAndroid 4.4.2を使用しています。テストに先立ち、chromeインスペクタでinnerWidth/innerHeightの値を調べました。

縦置き 360 × 567
横置き 598 × 335

ソフトキーボードを表示した状態では以下のようになりました。縦置きでも(幅 > 高さ)になっています。

縦置き 360 × 341
横置き 598 × 151

縦置きでアプリを起動した場合

(1) 端末を縦置きにしてアプリを起動
0 > orientation == 0

(2) 横置きに回転させる
1 > resizeイベント発火 orientation == 0
2 > resizeイベント発火 orientation == 0
3 > orientationchangeイベント発火 orientation == 90

(3) 縦置きに戻す
4 > resizeイベント発火 orientation == 90
5 > resizeイベント発火 orientation == 90
6 > orientationchangeイベント発火 orientation == 0

まとめ

・resizeイベントは回転時に複数回発火していた
・window.orientationはorientationchangeイベントリスナで使えそう(3, 6)
 resizeイベントリスナでは変化しなかった(1, 2, 4, 5)
・innerWidth/innerHeightはどちらのイベントリスナでも使えそう
・screen.width/screen.heightはどちらのイベントリスナでも変化しなかった
・outerWidth/outerHeightはresizeイベントリスナでは変化しなかった

横置きでアプリを起動した場合

(1) 端末を置きにしてアプリを起動
0 > orientation == 0

(2) 縦置きに回転させる
1 > resizeイベント発火 orientation == 0
2 > resizeイベント発火 orientation == 0
3 > orientationchangeイベント発火 orientation == 0

(3) 横置きに戻す
4 > resizeイベント発火 orientation == 0
5 > resizeイベント発火 orientation == 0
6 > orientationchangeイベント発火 orientation == 90

(4) もう一度縦置きにしてみる
7 > resizeイベント発火 orientation == 90
8 > resizeイベント発火 orientation == 90
9 > orientationchangeイベント発火 orientation == 0

イベントの発火や各種サイズの値については縦置きから回転させた場合と同じでした。ただ、予期していなかった点が一つ。
横置きでアプリを起動したときのwindow.orientationの値は90ではなく0ということでした。

これは、回転が発生して初めてwindow.orientationの値が設定される、という感じでしょうか?
横置きにしてアプリのアイコンをタップしてからアプリ画面が表示されるまでをよく観察してみると、まずアプリ画面が表示される前にAndroid端末のフレーム表示(上部にあるバッテリーや時間の表示、下部にあるホームボタン、戻るボタンなど)が回転しています。その後アプリ画面が表示されますが、この間に回転イベントは発火していません。

はっきりとはいえませんが、とりあえず回転イベントが発生する前にwindow.orientationを参照しないほうがよさそうです。

キーボードを表示した状態で回転

縦置きでアプリを起動後、キーボードを表示した状態で回転させてみました。

(1) 端末を縦置きにしてアプリを起動
0 > orientation == 0

(2) キーボードを表示させる(一行目をタップするとcontenteditable属性によりキーボードが起動する)
1 > resizeイベント発火
2 > resizeイベント発火

(3) 横置きに回転させる
3 > resizeイベント発火
4 > resizeイベント発火
5 > orientationchangeイベント発火 orientation == 90

(3) 縦置きに戻す
6 > resizeイベント発火
7 > resizeイベント発火
8 > orientationchangeイベント発火 orientation == 0
9 > resizeイベント発火
10 > resizeイベント発火
11 > resizeイベント発火
12 > resizeイベント発火

まとめ

・resizeイベントはキーボードの描画のためかorientationchangeイベントの発火後も数回発火した
・orientationchangeイベントはresizeイベントとは異なり一回転につき一回だけ発火した
・innerWidth/innerHeightは描画が完全に完了してからでないと最終的な値は取得できなさそう

Note

数回テストしましたが、resizeイベントの発火回数やresizeイベントとorientationchangeイベントの発火順序がこの結果と異なることがたまにありました。おそらく順番に発火していくのではなくそれぞれが独立した発火タイミングを持っていて、再描画などの条件によりずれるのだと思います。

縦置き・横置きを検出してみる!

気をつける点

・回転が発生する前のwindow.orientationは参照しない
・回転発生後はorientationchangeイベントリスナでwindow.orientationを参照できる
・window.orientationの0という値は「自然な方位を表現する(0 represents the natural orientation.)」と定義されており、縦置きという意味ではない
・キーボードが表示されている状態では、幅と高さの関係から縦置き・横置きを判定できない

これらを踏まえて以下のように実装しました。

var orn = {
  portrait: true,     // true: portrait, false: landscape
  orientgp: -1,        // 0: 0 or 180, 1: 90 or -90
  oflg: false,
  initOrn: function() {  // キーボードが表示されていない状態で使用する
    this.portrait = window.innerWidth < window.innerHeight;
  },
  initGroup: function() {  // orientationchangeイベントで呼び出す
    if (this.oflg === false) {  // 初回の回転時
      this.oflg = true;
      this.orientgp = !this.getOrnGroup();  // アプリ起動時のorientation値を設定
    }
  },
  getOrnGroup: function() {
    return (window.orientation === 0 || window.orientation === 180) ? 0 : 1;
  },
// 回転が発生していない場合、orientation値は不定(-1)とみなし、この状態の縦横比に従う
  isPortrait: function() {
    var xor = (this.orientgp === -1) ? false : this.orientgp ^ this.getOrnGroup();
    if ((!xor && this.portrait) || (xor && !this.portrait)) return true;
    else return false;
  }
};

document.addEventListener('init', function(event) {  // DOM初期化完了イベント
  orn.initOrn();
});

window.addEventListener('orientationchange', function(e) {
  orn.initGroup();
});

アプリの起動時にinitOrn()により縦置きか横置きかを判定しportraitに保存しておきます。
初回の回転イベントでorientation値を取得し、その値の否定値をとることで、回転イベント発生前のorientation値とし保存します。このorientation値と先ほどのportrait値をペアとして、以降、縦横判定を行いたいときにisPortrait()により現在のorientation値と比較することで縦横を判定します。

使い方は以下のようになります。

// 回転発生時
window.addEventListener('orientationchange', function(e) {
  orn.initGroup();
  if (orn.isPortrait()) {
    console.log("portrait");  // 縦置き
  } else {
    console.log("landscape");  // 横置き
  }
});
// キーボード表示時(cordovaプラグイン)
window.addEventListener('native.keyboardshow', function(e) {
  if (orn.isPortrait()) {
    console.log("portrait");  // 縦置き
  } else {
    console.log("landscape");  // 横置き
  }
});

キーボードさえ表示されなければ、orientationchangeイベントリスナでinnerWidthとinnerHeightを比較するだけで縦横判定できそうなんですけどね。
そういえば最初、キーボードが表示しているかどうかが分かればorientationchangeイベントリスナで縦横判定できるのではと思い、上記キーボード表示イベントリスナでフラグを立てるようにしたら、うまくいきませんでした。このリスナ、キーボードが表示されていなくても端末を回転させたときにイベントを受けていました。showもないなーと調べてみると、このkeyboardshowの後すぐにkeyboardhideも発生していました。それでキーボードは表示されずに何事もないように見えていたというわけです。いやーちょっと怖いですね。
最後蛇足になりましたが、以上です。

スタイルシートの書き換え

CSSの設定方法

おもなCSS(Cascading Style Sheets)の設定方法をまとめてみます。

  1. HTML要素のstyle属性を設定する(インラインスタイル)
  2. スタイルシートを使う
    • CSSファイル(外部スタイルシート)
    • <style>タグ(ページ内スタイルシート)
  3. 動的に設定・変更する
    • DOMメソッド
    • style.cssTextプロパティ
    • styleプロパティ

1. HTML要素のstyle属性を設定する(インラインスタイル)

HTMLのstyle属性で設定したスタイルは、スタイルシートで指定されたスタイルを上書きします。

HTML

<div style="color: red;"></div>

2. スタイルシートを使う

セレクタ(要素や要素の組み合わせ)でスタイルを指定します。要素はIDやタグ名、クラスを使って表すことができます。
また、<style>タグは<script>タグと同様にHTMLとしては扱われません。

CSSファイル(外部スタイルシート)

HTML

<!DOCTYPE html>
<html>
<head>
<link href="css/style.css" rel="stylesheet" type="text/css">
</head>
<body>
  <div id="sample"></div>
...

CSS

#sample {
  color: red;
}

<style>タグ(ページ内スタイルシート)

HTML

<!DOCTYPE html>
<html>
<head>
<style>
  #sample {
    color: red;
  }	
</style>
</head>
<body>
  <div id="sample"></div>
...

3. 動的に設定・変更する

JavaScriptによりスタイルを動的に設定・変更することができます。

DOMメソッド

ElementオブジェクトのsetAttribute()メソッドを使ってスタイルを設定することができます。第一引数の属性名に”style”、第二引数にスタイル文字列を指定します。

Script

var elem = document.getElementById("sample");
elem.setAttribute("style", "color: red;");

style.cssTextプロパティ

上記DOMメソッドと同様のスタイル文字列を扱います。

Script

var elem = document.getElementById("sample");
elem.style.cssText = "color: red;";

styleプロパティ

styleプロパティは文字列ではなくCSSStyleDeclarationオブジェクトになります。このオブジェクトのCSSプロパティにスタイルの値として文字列を設定します。文字列には単位が必要になります。また、セミコロンは含めません。一般にプロパティアクセスのほうがDOMメソッドより高速といわれています。

Script

var elem = document.getElementById("sample");
elem.style.color = "red";

スタイルシートの書き換え

通常は要素のクラス属性やstyleプロパティを書き換えることで容易にスタイルを設定することができるので、スタイルシート自体を書き換えることはないと思います。スタイルシートの書き換えは、スクリプトで要素ひとつひとつにアクセスしながらスタイルを設定するという方式とは異なり、ページ全体のスタイルを変更することができます。

スタイルシートオブジェクトの構造

document.styleSheetsプロパティには、配列のようなオブジェクトであるCSSStyleSheetオブジェクトが格納されています。cssRules配列には、スタイルシートのルールが格納されています。disabledプロパティは、false/trueを設定することでスタイルシートを有効化/無効化することができます。

document
    .styleSheets[0]    // 一枚目のスタイルシート
        .cssRules          : null
        .disabled          : false
        .(略)
    .styleSheets[1]    // 二枚目のスタイルシート
        .cssRules[0]       // 一個目のルール
            .cssText           : "#sample { color: red; }"
            .selectorText      : "#sample"
            .style
                .color             : "red"
                .fontSize          : "20px"
                .(略)
        .cssRules[1]       // 二個目のルール
            .(略)
        .cssRules[2]       // 三個目のルール
            .(略)
        .disabled          : false
        .(略)

なお、<link>タグで指定したcssファイルのルール(cssRules)はnullになるようです。また、cssRulesのcssTextは読み取り専用で直接書き換えることはできないようです。

ルールの追加/削除

ルールの追加や削除はスタイルシートのメソッドであるinsertRule()やdeleteRule()で行うことができます。このときの引数はスタイル文字列とルールのインデックスになります。

Script

document.styleSheets[1].insertRule("#sample { color: red; }", 0);
document.styleSheets[1].deleteRule(0);

また、cssRules[].styleプロパティにはCSSプロパティ一式が格納されており、これを直接読み書きすることができます。

Script

document.styleSheets[1].cssRules[0].style.color = "red";

以下に、必要に迫られて作ったものの後になって必要がないことに気づき愕然としたスタイルシート書き換えのサンプルを掲載します。

See the Pen stylesheet overwrite test by senmyou (@senmyou) on CodePen.

私がスタイルシートの書き換えが必要だと思ったのは、contenteditable属性を使ったときリターンキーの入力で自動的に生成されるdiv要素に動的にスタイルを設定したかったからです。div要素を生成するコード(createElement()など)は隠蔽されているため、div要素にアクセスできそうにありませんでした。そこで、セレクタ “#parent > div” のスタイルを変更できればdiv要素にアクセスしなくてもスタイルが変更できるのではと調べていたところ、スタイルシートの書き換えにたどり着きました。
ところが、スタイルシートの操作関数を作成し動作確認しているとき、このdiv要素は兄弟要素のスタイルを引き継いで生成されるということに気づきました。つまり、スタイルシートを書き換えなくても兄弟要素のstyleプロパティを変更するだけでよかったのです。。。
いつか使う日が来るといいなと思ってます。

contenteditable 属性

HTML5 の Content Editable 属性を使用したノートを作成してみました。

See the Pen note (contenteditable) by senmyou (@senmyou) on CodePen.

現在 Android 用の軽量ノート、というかメモの切れ端の代わりになるようなアプリを作っているところです。
最初 textarea 要素で作り始めたのですが罫線がうまく引けませんでした。テキストエリアの背景の高さ(background-size)と行の高さ(line-height)を合わせ linear-gradient で背景に罫線を引くといい感じになるのですが、縦スクロールが発生したとき罫線は固定されたままでスクロールについてきません。試行錯誤を繰り返しているうち、背景(background-size)の高さがテキストエリアより小さいのでスクロールが発生しないのではと思い至りこの方法をあきらめました。
ほかに方法がないか探していたところ contenteditable 属性というものを発見。聞いたこともなかったのですが使ってみました。そのさいに気づいたことをまとめてみます。

contenteditable 属性の挙動について

HTML 要素の contenteditable 属性に true を設定すると、それだけで編集可能状態になります。入力した文字はその要素のコンテンツとなり、要素の幅に達すると overflow プロパティに従い自動的にスクロールバーを表示したり折り返したりします。改行すると新しい子要素が作成され、行の先頭でバックスペースを入力するとその行にあたる子要素が削除されカーソルが上の行へ移動します。
実際の html とその表示は以下のようになります。CSS で装飾していますが html は一行のみです。

  <div id="notearea" contenteditable="true"></div>

See the Pen RgPEry by senmyou (@senmyou) on CodePen.

white-space プロパティの設定

HTML5.jp で以下の解説を見つけました。

ウェブ制作者は、編集ホスト、および、これら編集メカニズムを通して生成されたマークアップ上で、’white-space’ プロパティを値 ‘pre-wrap’ にセットすることが推奨されます。デフォルトの HTML ホワイトスペースのハンドリングは、WYSIWYG 編集に良く適しているわけではありません。’white-space’ がデフォルト値のままだと、行の折り返しが正しく機能しないでしょう。

行の折り返しに連続した半角スペースがあたる場合について警告しています。また、デフォルト(normal)のままだと半角スペースを何個入力しても一個分のスペースになってしまいますが、これはエディタとしてはありがたくないですね。そこで white-space を設定しました。

white-space: pre-wrap;

ちなみに pre-wrap と pre の挙動の違いはわかりませんでした。

罫線を引く

ノートを作るにあたり、最初から罫線を引きたいと思っていました。そこで、あらかじめ子要素として div 要素を10個配置し、CSS で各要素に線を引きました。textarea 要素に線を引くと前述のとおり縦スクロールについてこないのですが、div 要素に線を引くことで div 要素のコンテンツと罫線が一緒にスクロールするようになります。

#notearea > div {
  margin: 0;
  padding: 0 4px;
  min-height: 2rem;
  background-image:
    linear-gradient(to bottom, transparent, transparent calc(2rem - 1px), rgba(0,150,136,0.4) calc(2rem - 1px));
  background-size: auto 2rem;
}

pre-wrap の影響

Monaca デバッガを使用し android 上で確認しながら作っていたのですが、試しに APK を構築して動かしてみることにしました。すると以下のように、一見すると line-height が3倍になったかのような表示になってしまいました。

See the Pen NgqerV by senmyou (@senmyou) on CodePen.

この現象は PC 上のシミュレータ(プレビュー機能)でも発生していました。よくみると空白文字がいくつか挿入されています。また、罫線が引かれておらずインスペクタで DOM をみても一つの div 要素になっています。つまり通常の改行文字の入力や折り返しによるものではなさそうです。このときの html のコードは以下のようになっていました。

<div id="notearea" contenteditable="true">
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
</div>

ここで white-space プロパティの pre-wrap をデフォルト(normal)にすると空白文字が詰められるせいか問題がなくなります。ではなぜ上記コードで空白文字が挿入されるのか。これは私見ですが、contenteditable を指定した要素のなかでは html の記述がそのままコンテンツの対象になっていると思われます。つまり、上記 html では「div 開始タグの前にあるタブ文字」と「div 終了タグの後ろにある改行」が空白文字に変換され、それぞれ div 要素の外に挿入される感じです。
html の記述から以下のようにタブ文字と改行を削除してみるとうまくいきました。

<div id="notearea" contenteditable="true"><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div></div>

ふと「inline 要素を横に並べると要素間に不要な隙間が生じるけれど html の記述から改行を削除して詰めると隙間がなくなる」という現象を思い出しました。また、なぜ Monaca デバッガでは大丈夫だったのかは謎です。

backspace 入力

行の先頭でバックスペースを入力すると、その行にあたる div 要素が削除され、カーソルが上の行へ移動します。このとき、上の行でコンテンツが空(””)の div 要素はまとめて削除され、文字のある行までカーソルが一気に移動しました。この現象は各 div 要素のコンテンツに <br> を入力しておくことで防ぐことができました。これは子要素を持たない contenteditable 属性の要素で改行を行うと div 要素のコンテンツに <br> が入力されるというデフォルト動作になります。<br> なんて必要なさそうに思えますが、実は必要だったわけです。
以下の html が最終形態となります。うーん、見づらいですねw

<div id="notearea" contenteditable="true"><div><br></div><div><br></div><div><br></div><div><br></div><div><br></div><div><br></div><div><br></div><div><br></div><div><br></div><div><br></div></div>

Android のキーボード出現時のスクロール

実機上で contenteditable 属性を指定した要素をタップすると編集可能状態になりキーボードが表示されます。このとき、要素のスクロールが行われないためカーソルがキーボードに隠れてしまうことがあります。これは contenteditable の影響か onsen ui の影響か、はたまた android の影響か私の作り方が原因か定かではありませんが、以下の対応でカーソルを見える位置にスクロールできました。
なお、以下のコードでは cordova のプラグイン ionic-plugin-keyboard を使用してキーボード表示イベントの監視を行っています。ちなみにこのリスナ内での window.innerHeight は画面の高さからキーボードの高さを引いた値になります。

var clickedDiv = {
  elem: null,
  y: 0
};
document.getElementById("notearea").addEventListener('click', function(e) {
  clickedDiv.elem = e.target;
  clickedDiv.y = e.y;
});
window.addEventListener('native.keyboardshow', function(e) {
  if (clickedDiv.y + clickedDiv.elem.clientHeight > window.innerHeight) {
    clickedDiv.elem.scrollIntoView(false);
  }
});

読み出しと書き込み

最後に上記 html(div の子要素10個配置)で使用している読み書き関数を記載して本記事を終了します。

function read() {
  var divs = document.getElementById("notearea").getElementsByTagName("div");
  var num = divs.length;
  var i, note = [];

  for (i=0; i<num; i++ ) {
    note.push(divs[i].textContent);
  }
  return note;
}
function write(note) {
  var num = note.length;
  var i, text = "";

  for (i=0; i<num; i++) {
    text = note[i] ? note[i] : "<br>";
    text += ("<div>" + text + "</div>");
  }
  document.getElementById("notearea").innerHTML = text;
}