WebViewで表示されるHTMLをネイティブっぽく振る舞う方法
AndroidやiOSアプリケーション内でWebViewまたはUIWebViewを使用してHTMLをレンダリングしたいことがあります
しかしハイブリッドアプリ(HTMLとネイティブの混合アプリ)の場合は何も考えずにやるのはUI/UX的によろしくないことになってしまうかもしれません
目的
何も考えずにWebViewにHTMLを表示してしまうと表示が崩れたり、画面内でコピーや選択、拡大縮小が可能だったり、リンクタップ時の画面遷移の仕方がネイティブ画面(Java/Swift/objective-c実装)の実装と異なるなどアプリケーションの操作に一貫性がなくなってしまうことが考えられます
今回はこれらを何とかする方法をまとめます
対応プラットフォーム
iOS、Androidとしますが、PCでも大丈夫です
※後述するコールバック用のプロトコル実装がPCの場合はないので、アプリからの表示かどうかの識別は必要です
iOSは8以降、Androidは2.3系以降を対象とします
課題
たかだかHTMLをアプリで表示するだけでも考えなければいけないことは沢山あったりします
- HTMLがフルスクリーン(横幅いっぱい)表示されない問題
- 拡大縮小できてしまう問題
- 部分選択できてしまう問題
- 文字列のコピーができてしまう問題
- リンクのタップ時に遷移が遅い問題
- ネイティブ画面を表示できない問題
- HTMLを表示する事によるセキュリティ的な問題
これらの多くはJSやCSSでなんとかなりますが、一部はアプリ側の対応が必要です
実現方法
順番に解決していきます
まずは1のHTMLをWebViewで表示した際に画面いっぱいに表示されない(または横スクロールバーが表示される)問題を解決します
HTMLをWebViewで表示した際に画面いっぱいに表示する方法
今であればviewportを使用するなどの方法がありますが、昔のviewportの解釈がバグバグな機種(Android2.3系とか)も対応する場合はviewportに任せる事すらできません
なので、ここではjavascriptでHTMLをぴったりなサイズに拡大/縮小する手法で解決します
HTMLは横幅を1080px==100%として作りますが、縦にフィットさせたい場合は同じように高さを決めて拡大基準を高さに変更してやれば良いです
$(function(){
$(window).bind("resize", function() {
zoom();
$('body').css('display', 'block');
}).trigger("resize");
});
function zoom(screenSize) {
if(typeof(screenSize) !== "number") {
screenSize = 1080;
}
var tPortraitWidth,tLandscapeWidth;
if(Math.abs(window.orientation) === 0) {
tPortraitWidth=$(window).width();
$("html").css("zoom" , tPortraitWidth / screenSize);
}
else {
tLandscapeWidth=$(window).width();
$("html").css("zoom" , tLandscapeWidth / screenSize);
}
}
アプリ側
Android
public static void setupWebView(WebView aWebView) {
aWebView.setInitialScale(0);
final WebSettings tSettings = aWebView.getSettings();
tSettings.setLoadWithOverviewMode(false);
tSettings.setDefaultZoom(WebSettings.ZoomDensity.MEDIUM);
tSettings.setUseWideViewPort(false);
}
やっていることは、DOMの準備が完了したら目標のサイズに合致するようにzoomしているだけです
アプリ側ではViewPortの無効と拡大率の設定をする必要があります
余白が追加されたりと期待する結果に対して余計なことをするのでLoadWithOverviewModeを無効にしておきます
拡大縮小を禁止する方法
アプリ側
Android
public static void setupWebView(WebView aWebView) {
final WebSettings tSettings = aWebView.getSettings();
tSettings.setSupportZoom(false);
tSettings.setBuiltInZoomControls(false);
}
拡大機能の無効化をします
これでも一部の端末だとジェスチャでズームできてしまうかもしれませんが、後述する"部分選択できてしまう問題"に対する対処で対策できると思います
部分選択を禁止する方法
CSS
* {
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-webkit-touch-callout: none;
}
*:not(input) {
-webkit-user-select: none;
}
input系以外のDOMに対しての選択を無効化し、また選択時の色を透明にしています
-webkit-touch-calloutはsafariつまりiOSの場合に長押しで表示されるポップアップメニューを無効化します
アプリ側
特に対処は不要です
文字列のコピーを禁止する方法
上記の"部分選択を禁止する方法"で選択できないのでコピーもできません
ただし、フォーム上のコピーはできてしまうと思われます
ただネイティブアプリでも入力は貼付けできるので禁止する必要はない...はずです
もし禁止したいのであれば、下記のようにCSSを修正します
* {
-webkit-user-select: none;
}
アプリ側
特に対処は不要です
リンクのタップ時にすぐに反応させる方法
モバイル端末ではリンクのタップから期待するクリックイベントが発火するまでに300ms程度の遅延が発生します
自前で実装してもよいですが、ここではfastclick.jsを使います
jqueryのプラグインもあるので、jquer使う場合はこっちのが楽かもしれません
どちらもやってることは、クリックイベントの発火遅いからタップイベントをフックして発火させようぜって感じですかね?(見てませんのでおそらくです)
とりあえずあとはそれぞれのライブラリに従って全体に適用させましょう
document.addEventListener('DOMContentLoaded', function() {
FastClick.attach(document.body);
}, false);
HTML側のイベントをネイティブ側に移譲する方法
これをやると結果的に"ネイティブ画面を表示できない問題"を解決できます
HTML側
<a href="example://action=click&id=1">Click Me</a>
プロトコル(example)とGETパラメータを好きに定義してください
アプリ側
public static void openBrowser(Context aContext, String aUrl) {
final Intent tIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(aUrl));
tIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
aContext.startActivity(tIntent);
}
public static bool isSafeDomain(Uri aUri){
return aUri.getHost() != null && aUri.getHost().endsWith("example.com");
}
public static bool isCallbackScheme(Uri aUri) {
return aUri.getScheme() != null && aUri.getScheme().equals("example");
}
public static void setupWebView(WebView aWebView) {
aWebView.setWebViewClient(new WebViewClient(){
@Override
public void onPageFinished(WebView aWebView, String aUrl) {
super.onPageFinished(aWebView, aUrl);
}
@Override
public boolean shouldOverrideUrlLoading(WebView aWebView, String aUrl) {
Log.d("WEBVIEW", "shouldOverrideUrlLoading: " + aUrl);
final Uri tUri = Uri.parse(aUrl);
if (tUri.isOpaque() || (!isSafeDomain(tUri) && !isCallbackScheme(tUri))) {
openBrowser(getActivity(), aUrl);
return true;
}
if(isSafeDomain(Uri.parse(aWebView.getUrl()))) {
if(isCallbackScheme(tUri)) {
final String tQueryParam = tUri.getQuery();
//Activityを開始したり好きな処理...
return true;
}
}
return super.shouldOverrideUrlLoading(aWebView, aUrl);
}
@Override
public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
String tErrorMessage = String.format("WebView ERROR at %s (code: %d) %s", failingUrl, errorCode, description);
Log.d("WEBVIEW", tErrorMessage);
}
});
}
mailtoだったり、信頼できないドメインに対してのリクエストであれば別途ブラウザで開くようにします
信頼できるドメイン上でコールバック用のScheme(プロトコル)が呼び出されたら、そのパラメータをGETクエリとして取得しネイティブ側で画面を起動したりいろいろできます
後述しますが、信頼できるドメインなのかどうかをチェックした上でコールバック処理を行わないとセキュリティ上よろしくないです
またパラメータの文字列についてはサニタイズ等、適切な処置を必ず行ってくださいネ
HTMLを表示する事によるセキュリティ的な問題
"HTML側のイベントをネイティブ側に移譲する方法"ではURLリクエストに対してアプリ側の処理を切り替える方法で実現しましたが、これは実行制限を設けておかないと下記のような問題が出てきます
- コールバックを悪用してアプリに任意の処理を行わさせることができる
- -> コールバック処理は必ず信頼できるドメイン上からのリクエストかを確認する
- コールバックの値は常に信用できないことを前提に処理する(クライアント側で改竄可能)
- -> 正しい手順であってもパラメータを改ざんできるため重要なデータは与えない
- アプリのデザインを崩す、または任意のスクリプトが実行できる
- -> 外部リンクは必ずブラウザで開き、アプリから隔離して適切に処理する