WebViewのフォーム入力でImmersiveモードを維持する

前回、Javascript の alertconfirm 関数を使って WebView から AlertDialog を呼び出した際に全画面表示を止めてしまう問題について取り組んでいた時に、入力フィールドへフォーカスした際に Immersive モードが解除されるのは仕方がないとしても、その扱いについて考えていたところ、こちらも一癖ある挙動だったので紹介します。

通常 Immersive モードではない場合、WebView にキーボードが被るという問題を解決すべく、AndroidManifest.xml の activity ノード属性として android:windowSoftInputMode="adjustResize" と書くのですが、Immersive モードの場合はこの方法がうまくいきません。そこで試してみたのが、入力フィールドがフォーカスした時に Immersive モードを解除する、という方法です。

まず、MainActivity クラスの中に次のような JavaScript インターフェースを用意し、

public class WebViewJavaScriptInterface{
  private Context context;
  public WebViewJavaScriptInterface(Context context){
    this.context = context;
  }
  @JavascriptInterface
  public void disableFullScreen() {
    getWindow().getDecorView().setSystemUiVisibility(0);
  }
}

WebView について色々と書いてある onCreate の中で JavaScript から呼び出せるように設定しました。

webView.addJavascriptInterface(new WebViewJavaScriptInterface(this), "app");

そしてテキスト入力フィールドを1つ持っている HTML ではこのように JavaScript から Immersive モードを解除するようにしました。

document.querySelector('input[type=text]').ontouchend = function(){
  app.disableFullScreen();
};

ところが、Logcat に “Uncaught Error: Java exception was raised during method invocation” というエラーを出力しうまく動かなかったので、MainActivity で Handler を呼び出し、JavaScript インターフェース内の関数を次のようにすると期待通りの動作をエミュレータで確認しました。

@JavascriptInterface
public void disableFullScreen() {
  handler.postDelayed(new Runnable() {
    @Override
    public void run() {
      getWindow().getDecorView().setSystemUiVisibility(0);
    }
  }, 0);
}

また、JavaScript インターフェース内にもう一つ次のようなものを用意して(前回不要だと思った enableImmersiveMode 関数を再度用意)、

@JavascriptInterface
public void enableFullScreen() {
  handler.postDelayed(new Runnable() {
    @Override
    public void run() {
      enableImmersiveMode();
    }
  }, 0);
}

送信ボタンを押した時に Immersive モードとなるようにしました。

document.querySelector('#submit').ontouchend = function(){
  app.enableFullScreen();
};

実際にこのような挙動で動くようになったのですが、

ソフトウェアキーボード以外の場所で画面を押すと入力フィールドからフォーカスが外れてキーボードが隠れるという OS の設計上、このように加えておくと更に安心です。

document.querySelector('input[type=text]').onblur = function(){
  app.disableFullScreen();
};

実機で検証してみると、処理能力が劣る古い機種ではフルスクリーンになる際にステータスバーが途中で止まってしまったりということが確認できたので、postDelayed で遅延を750〜1000と与えると(動作的にはギクシャクする感は否めないのですが)解決しました。そして、最初に書いていた android:windowSoftInputMode="adjustResize" を外してみると…なんと入力フィールドが隠れずにキーボードに合わせて移動するという世にも奇妙な…。

参考:

WebViewのAlertDialogでImmersiveモードを維持する

Android の上部にあるツールバーと下部にあるナビゲーションバーを隠しフルスクリーンで表示する「Immersive モード」を使ってハイブリッドアプリを開発している際に、WebView 内の JavaScript で confirm を呼び出すと、全画面表示が解除されてツールバー及びナビゲーションバーが再び表示されてしまうという問題に直面したので調べました。

元々は confirm のボタンラベル名を拡張すべく WebChromeClient 内でこのようなコードを使っていました。

@Override
public boolean onJsConfirm(WebView view, String url, String message, final JsResult result)  {
  new AlertDialog.Builder(MainActivity.this)
    .setMessage(message)
    .setPositiveButton("確認",
      new DialogInterface.OnClickListener() {
        public void onClick(DialogInterface dialog, int id) {
          result.confirm();
        }
      })
    .setNegativeButton("消去",
      new DialogInterface.OnClickListener() {
        public void onClick(DialogInterface dialog, int id) {
          result.cancel();
        }
      })
    .setCancelable(false).create().show();
  return true;
};

この問題の根本にある原因はダイアログがフォーカスを持ってしまうことにあるようなので、参考にした文献のコードを利用し(ワンライナーフェチとしては少し残念な) AlertDialog を一旦 alertToShow に入れるようにして addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) を追加しました。

@Override
public boolean onJsConfirm(WebView view, String url, String message, final JsResult result)  {
  AlertDialog alertToShow = new AlertDialog.Builder(MainActivity.this)
(中略)
  alertToShow.getWindow().addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE);
  alertToShow.show();
  return true;
};

今回の失敗談を一つ紹介すると、MainActivity に次のような全画面設定関数を用意して setPositiveButtonsetNegativeButton のそれぞれの onClick で呼び出したのですが、案の定ボタンを押した後に Immersive モードを再開するという間抜けな結果になり、上述したコードで不要になりました汗。

@SuppressLint("NewApi")
private void enableImmersiveMode() {
  if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) {
    getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_IMMERSIVE | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION);
  }
}

この例は JavaScript の confirm でしたが、もちろん alert でも同じような処理で解決できると思います。更に調べているとテキストフォーム入力時の input にフォーカスした際に下部からニュッと現れるソフトキーボードと共に Immersive モードが解除されるという問題について Stackoverflow で多く議論されていることが分かりました。こちらも原因は同様なのですが WebView と連携した場合にどうなるのか、別投稿で紹介したいと思います。

参考:

TEXTAREAの字数制限

テキストエリアの字数制限は HTML5 の仕様にある maxlength 属性を使うことが王道になると思うのだけど、現状ではブラウザの下位互換を考慮すると、属性を与えつつも JavaScript で制御しておくという方法を採用することにした。 設定ファイルから上限とする字数を読み込み、ミドルウェア(今回は PHP)で maxlength 属性を付与したタグを出力し、jQuery と Prototype の両方の JS プラグインが混在している環境、特に前者の処理が簡単過ぎたために少し面倒だった後者の手法について記録しておきます。

まず PHP で次のようなテキストエリアタグを出力しておき、

<textarea maxlength="'.MAX_LENGTH.'"></textarea>

jQuery ではこのように書きました。

var textarea = $('textarea[maxlength]');
textarea.bind(
    "keydown keyup click",
    function(){
        var maxlength = $(this).attr('maxlength');
        if($(this).val().length > maxlength){
            $(this).val($(this).val().substr(0, maxlength));
        }
    }
);

一方、Prototype は少し古めかしい2通りの記述方法があります。

function checkLength(elementId, maxlength) {
  var field = $(elementId);
  var value = field.value;
  if(value.length > maxlength) {
    field.value = value.substring(0, maxlength);
  }
}

当然この関数を呼び出すのにタグの方へ、jQuery 側でも指定している3つのイベントについて属性を記述する必要があります。

<textarea maxlength="'.MAX_LENGTH.'" onkeydown="checkLength(this.id, this.maxlength)" onkeyup="checkLength(this.id, this.maxlength)" onclick="checkLength(this.id, this.maxlength)"></textarea>

もし制限字数をフロント側で保持しているのであれば、Event.observe を使う方が望ましいと思いますが、イベントの数だけ行数が増えるだけでなく window.onload やイベントリスナーに追加してページ読み込み後に呼び出すという処理が必要になるので面倒この上ありません。また字数を超えた場合に Event.stop とすると、再開できず文字を消去することすらできないという一時期に Tumber が Tw 投稿部分でやらかしていた轍を踏むことになるので注意が必要です。(この辺りの解決方法を模索したい気もしますが将来性を考えると放置しておいてもいいかな、と。)

参考:

CSS Spriteで読み込み中アニメ

OS X 以来近年の Apple のサービスによく登場している「読み込み中」の表現は、10年前ならアニメーションGIFで対応したと思うのですが最近では CSS Sprite の技術を利用することが可能になりました。

(function($) {
	var initData = (function() {
		const imgW = 35;
		const imgNum = 12;
		var intid_loader = 0;
		var intval_loader = 50;
		var posY = 0;
		var imgId = 0;
		intid_loader = setInterval(function(){
			imgId = imgId >= imgNum ? 0 : imgId;
			var pos = '0px ' + (-imgW * imgId) + 'px';
			$('#loading').css('background-position', pos);
			imgId++;
			//console.log(pos);
		}, intval_loader);
	}).call(this);
})(jQuery);

この例では、35ピクセルの正方形を12駒分、縦方向であるY座標を上方へ100ミリ秒ずつ動かしています。このブログの先頭ページに設置したのでご参考まで。

ところがこのタイマーによって繰り返す手法も代替の時期にきていて、実際に繰り返し関数をアニメーションのフレームに使うことに比べると正しい姿だと思うのですが、既に jQuery と requestAnimationFrame の採用問題などあり、更に向こう10年間のブラウザの対応など注意して追いかけていく必要があります。


今回のネタ元でした。

P.S. 画像処理でもいいよね… loading – Google 検索

参考:

phpBBのスパム投稿対策

管理中の phpBB 3 を使ったフォーラムに日本語以外のスパム投稿が多くなってきたたので、「phpbb-禁止ワード設定と英語のみ禁止でspam投稿対策 – 検索プログラマのメモ帳」を参考にしつつ次のように正規表現のパターンを少し改変して対応した。

//英語のみ禁止チェック
//$chkresult = ((mb_detect_encoding($chkmsg, "auto") == 'ASCII') && (strlen($chkmsg) != 0));
$chkresult = ((!preg_match('/[一-龠]+|[ぁ-んー]+/u', $chkmsg)) && (strlen($chkmsg) != 0));

というのは、近年英語だけではなくてロシア語や中国語のスパム投稿も増えているので、そのすり抜けを防ぐ意味がある。それでも平仮名混じりのものや機械翻訳を使った意味不明な投稿は受け付けてしまうので、そこは上述したリンク先にあるように禁止語を手作業で追加するしかない。前者の場合は比率を比較するようなことも考えられるけれど、応答速度を考えるとあまり実用的ではないかもしれない。

アップデートや MOD のインストールといった正攻法で対応することが本来は望ましいのだけど、v2時代から使っているレンタルサーバーに絡んで文字コードが EUC だったり、MOD の日本語対応が甘かったり、その辺りは WordPress とちょっと違うところなので言語を理解していれば直接記述した方が手っ取り早い。理解していなくても数行差し込むだけなので特に難しい事はないと思う。

連勝記録を集計する

引き分けの無い戦歴から連勝記録を集計するという要件があったので PHP を使って処理をした。SQLで出せればもっと良いのだろうけど、同一ページ内で他の集計に使う連想配列を持っていたのでそのデータを利用することにした。また特定期間分のHTMLテーブル表を書き出すというプログラムの性格上においても反応速度はそれほど気にしないことも(言い訳の一つに)あるかもしれない。 // 時間があったらの課題、ということで 😉

次の関数は、

$results = array('L', 'W', 'W', 'L', 'L', 'L', 'L', 'L', 'W', 'W', 'W', 'L', 'W', 'W', 'L', 'L', 'L', 'W', 'W', 'W', 'W', 'W', 'W');

のような勝敗を連続して記録した引数を渡すと最大の連勝回数と連敗回数を返します。もちろん連敗回数の方は任意ですので関係する部分は省いてお使いください。

function streaks($results) {
 $length = count($results);
 $win = 0; $loose = 0; $maxWin = 0; $maxLoose = 0;
 
 for ($i = 0; $i > $length; $i++) {
  if ($i == 0 || $results[$i] == $results[($i - 1)]) { // 最初か同じ結果の場合
   switch ($results[$i]) {
    case 'W': $win++; break;
    case 'L': $loose++; break;
   }
   if($i == ($length - 1)) { // 最後の場合も集計する
    switch ($results[$i]) {
     case 'W': $maxWin = ($win > $maxWin) ? $win : $maxWin; break;
     case 'L': $maxLoose = ($loose > $maxLoose) ? $loose : $maxLoose; break;
    }
   }
  }
  if($results[$i] != $results[($i - 1)]){ // 違う結果の場合は集計する
   switch ($results[$i]) {
    case 'W': $maxLoose = ($loose > $maxLoose) ? $loose : $maxLoose; break;
    case 'L': $maxWin = ($win > $maxWin) ? $win : $maxWin; break;
   }
   $win = 1; $loose = 1;
  }
 }
 return array('win' => $maxWin, 'loose' => $maxLoose);
}

「もっと短くできるよ!」とか、引き分けを使った拡張、 SQL の方法などあれば(SNS等で)教えてください。
P.S. この手の内容をggrksると、ウマーな人の悩み相談が多くヒットしますね…。

最新のYoutube動画を表示する

Youtube にアップロードしている最新の動画を別ドメインにあるウェブページに埋め込み表示するという要件があって、YouTube の APIと PHP5 を使いこのような処理にした。厳密には iframe タグの API はどうでもよくて、動画 ID だけ取るのでもちろんフィードは1つしか取らない。:D

$rss = simplexml_load_file('http://gdata.youtube.com/feeds/api/users/'.your_youtube_id.'/uploads?start-index=1&max-results=1');
echo '<iframe width="420" height="315" src="http://www.youtube.com/embed/'.substr(strrchr($rss->entry->id, '/'), 1).'" frameborder="0" allowfullscreen></iframe>';

フィードされるXMLの仕様について「デベロッパー ガイド: Data API プロトコル –」に目を通しておくと理解が良いかもしれませんね。

Twitterを使ったサイトのニュース更新

Twitter の API にある JSONP を使ってニュースを表示していたのだけど、全てのリンクを t.co にするという仕様変更によってインラインによる画像表示に問題が出てしまった。解決法の一つ目は、ExpandURLのような外部サービスを使うことだったのだけど、一日に使えるデータの上限が危うい感じだったのと、複数回叩くとブラウザのCPU利用率が上昇して固まってしまうという致命的な欠点があり頓挫したので自作することにした。

var url = 'http://twitter.com/statuses/user_timeline/'
	+ twitter_id
	+ '.json?callback=twitterCallback&count='
	+ twitter_count;

2つの変数をセットし JSONP を取得後にtwitterCallbackで、

html += (obj[i].text).replace(/http:\/\/twitpic\.com\/(\w+)/ig, '<span style="display:block;"><img src="http://twitpic.com/show/thumb/$1" /></span>');

とIDを置換していたのだけど、getUnShortという関数で t.co リンクが含まれていた場合に展開して送り直すという処理を行った。

var urls = (obj[i].text).match(/http:\/\/t\.co\/(\w+)/ig);
if(urls) {
  html += (obj[i].text).replace(/http:\/\/t\.co\/(\w+)/ig, '<span style="display:block;" id="$1"></span>');
  getUnShort(urls);
} else {
 html += (obj[i].text).replace(/http:\/\/twitpic\.com\/(\w+)/ig, '<span style="display:block;"><img src="http://twitpic.com/show/thumb/$1" /></span>');
}

Ajax 部分は prototype.js を使ってこのように書いた。

function getUnShort(urls) {
 var pars = 'url=' + encodeURIComponent(urls.join(','));
 new Ajax.Request( 'unshort.php', { method: 'post', parameters: pars, onComplete: sendUnShortResult });
}
 
function sendUnShortResult(originalRequest) {
//console.log(originalRequest.responseText);
 if(originalRequest.responseText && originalRequest.responseText != 1) {
  var scheme = (originalRequest.responseText).split(',');
  var img = scheme[0].replace(/http:\/\/twitpic\.com\/(\w+)/ig, '<img src="http://twitpic.com/show/thumb/$1" />');
  var node_id = scheme[1];
  var target_node = document.getElementById(scheme[1]);
  target_node.innerHTML = img;
 }
 return;
}

最後にサーバー側はPHPで短縮URLを展開するコードを参考に、展開後のURLと画像の受け皿となるノードIDをカンマ区切りで渡すことにした。

$urls = (isset($_POST['url']) && !empty($_POST['url'])) ? explode(',', trim($_POST['url'])) : exit('1');
for($i=0;$i<count($urls);$i++) {
 $node_id = substr(strrchr($urls[$i], '/'), 1);
 $h = get_headers($urls[$i],true);
 if(isset($h['Location'])){
  $long_url = $h['Location'];
  if(is_array($long_url)){
   $long_url = end($long_url);
  }
  echo substr_count($long_url, 'twitpic') ? ($long_url.','.$node_id) : null;
 }
}

条件式ではオブジェクトの型を調べておくことをオススメしますが、このブログでは割愛! 😉

近年この更新部分は、実際には社内ソーシャルメディア部の Twitter や Facebook といった広報やサポートの担当者が行うべき仕事じゃないかなと思う次第。

ニュース更新のためだけに制作会社に仕事が投げられる場合が多々あるのも事実だし、しかも作業量の割に美味しいこともあれば、サービス作業になっている場合もあるこの作業、経営者的には「出来る事は自分たちでやれ」なんだろうと思います。

WPのプロフィールを利用する

WordPressのアップグレードで上部にツールバーが表示されてプロフィールが簡単に編集出来るようになったので、”テンプレートタグ/the author meta – WordPress Codex 日本語版” を参考にしながら、テンプレート内にコードを書き込む事で従来のウィジェット型から移行した。

実際は sidebar.php へ次の様に、ウィジェットがあった場所へ画像のタグはそのまま書いた。

<div class="footer-sidebar">
<div id="sidebar1" class="sidecol">
<ul>
<!-- ここから -->
	<li><img src="http://hkitago.com/blog/wp-content/uploads/n1089424591_30041963_5262.jpg" width="90" height="120" alt="Author" style="float:left;margin:0 3px;padding:1px;border:1px solid #E9E9E9;" /><?php the_author_meta('description', ($wp_query->post->post_author ? $wp_query->post->post_author : 1)); ?></li>
<!-- ここまで -->
</ul>
</div>
</div>

序でにどんな環境で作業をしているか書き加えた。ツールバーに関しては、管理者向けのホーム画面とも呼べるダッシュボードの概念が広まってきたなあ、という気がする。

更新(4月12日):次の説明を見落としていたので 404.php にて表示されない問題に対応しました。

ループの外で使われた場合、ユーザーIDを指定する必要があります。

引用元: テンプレートタグ/the author meta – WordPress Codex 日本語版.

複数で管理している場合はユーザIDを引数に直接書けないので条件分岐をした方がいいと思う。

プレーンテキストでコピーする

WordPress でも Tumblr でも、最近のリッチテキスト(ビジュアルとも言う)に対応している入力フォームがあると、選択している部分の文字スタイルを破棄して複製したい場合がある。そこで「プレーンテキストでコピー」するサービスを Automator で作る事にした。

コード:

try
  set the clipboard to «class ktxt» of ((the clipboard as text) as record)
on error
  set theRTF to the clipboard as «class RTF »
end try

ダウンロード:プレーンテキストでコピー.workflow

使い方:
~/ユーザ名/ライブラリ/Services/にダウンロードしたファイルを置いてください。

文字を選択しつつ副クリック(右クリックや二本指ジェスチャ)で呼び出してください。ペーストすると文字のスタイルが無くなっているのが分かります。 😀