13
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Custom Tabsで呼び出せるブラウザアプリの作り方

Last updated at Posted at 2019-06-23

以下の記事の最後で
Chrome Custom Tabsに対応しているChromeでないブラウザアプリたち

ブラウザアプリを作るなら対応必須かもしれません。

なんてことを書いたわけですが、その時点ではどうやって作ればいいか知りませんでした。
知らないままなのはよくないと言うことで作り方を調べて、実際に作ってみました。

Githubで公開していますので実コードはこちらを参照ください。
https://github.com/ohmae/custom-tabs-browser

ちなみに、ここで使われているプロセス間通信のノウハウについてはDroidKaigi 2019で発表しました。
https://droidkaigi.jp/2019/timetable/70931

必須:ブラウザアプリとして動作させる

当たり前といえば当たり前ですが、ブラウザアプリでなければ呼び出すことができません。
ここでいう「ブラウザアプリ」というのはブラウジング機能をもったアプリという意味ではなく、外部のアプリやシステムからみてブラウザであると認識されるアプリという意味です。そのようなアプリは「デフォルトのブラウザアプリ」の一覧にも出てきます。

ブラウザアプリ」であると認識されるために必要なことは、任意のhttp/httpsプロトコルを受け取れることです。要するに

AndroidManifest.xml
<intent-filter>
    <action android:name="android.intent.action.VIEW"/>

    <category android:name="android.intent.category.DEFAULT"/>
    <category android:name="android.intent.category.BROWSABLE"/>

    <data android:scheme="http"/>
    <data android:scheme="https"/>
</intent-filter>

というintent-filterをもったActivityが定義されていることが要件になります。
実際にブラウジング機能を持っているかどうかはシステム側からは分かりませんので、受け取ったURLのページを表示できなくても呼び出してもらうこと自体はできます。

しかし、いくら検証目的でも、そこまで意味がないものを作ってしまうと訳が分からなくなるので、WebViewをつかって最低限のブラウジング機能は載せることにします。

必須:bindされるサービスを作る

Custom Tabsを簡易的に使う場合、CustomTabsIntentだけを使う場合もありますが、基本的にはCustomTabsClientをつかいCustomTabsIntent#bindCustomTabsServiceでbindを行います。
そのbind先のサービスを作る必要があります。

bindしない場合でもサービスを作っておかないと、パッケージ選択処理のここではじかれてしまいます。

さて、このサービスの作り方ですが、CustomTabsServiceというabstuctクラスが用意されていますのでこれを継承します。

このCustomTabsServiceを始め対応ブラウザを作るためのクラス、CustomTabsを使うときに使うandroidx.browserに含まれていますので、これを依存関係に追加しておきます。

implementation 'androidx.browser:browser:1.0.0'

見かけ上動かすだけなら中身はなくてもいけるので、ひとまずは適当な空実装にしておきます。
一部は仕様が明確でなかったりするので、対応できるところから徐々に作っていけばよいです。

注意点としてはnewSessionの戻り値はtrueにしておかないと、クライアント側のnewSessionが失敗してしまいます。他の機能はともかく、これが失敗してしまうとクライアント側でSessionのインスタンスを取得できないため、必ずtrueを返しておきましょう。

CustomTabsConnectionService.kt
class CustomTabsConnectionService : CustomTabsService() {
    override fun warmup(flags: Long): Boolean = false
    override fun newSession(sessionToken: CustomTabsSessionToken?): Boolean = true
    override fun mayLaunchUrl(sessionToken: CustomTabsSessionToken?, url: Uri?, extras: Bundle?, otherLikelyBundles: MutableList<Bundle>?): Boolean = true
    override fun extraCommand(commandName: String?, args: Bundle?): Bundle? = null
    override fun requestPostMessageChannel(sessionToken: CustomTabsSessionToken?, postMessageOrigin: Uri?): Boolean = false
    override fun postMessage(sessionToken: CustomTabsSessionToken?, message: String?, extras: Bundle?): Int = CustomTabsService.RESULT_FAILURE_DISALLOWED
    override fun validateRelationship(sessionToken: CustomTabsSessionToken?, relation: Int, origin: Uri?, extras: Bundle?): Boolean = false
    override fun updateVisuals(sessionToken: CustomTabsSessionToken?, bundle: Bundle?): Boolean = false
}

bindするときはandroid.support.customtabs.action.CustomTabsServiceというActionを使うのでAndroidManifestには以下のように記述します。

AndroidManifest.xml
<service
    android:name=".CustomTabsConnectionService"
    android:exported="true"
    tools:ignore="ExportedService"
    >
    <intent-filter>
        <action android:name="android.support.customtabs.action.CustomTabsService"/>
    </intent-filter>
</service>

Action名のprefixにandroid.support.customtabsとSupport Libraryのパッケージ名が使われていますが、AndroidXを使っている場合でもここは変わりません。他アプリとのインターフェースなので変えられることはないでしょう(参考:Androidアプリ開発で取り返しがつかないことまとめ)


以上、2つの対応を行うことで、クライアントからは対応アプリであるように見えます。
最低限の対応はここまでです。


オプション:UIのカスタマイズ

CustomTabsIntentではUIに対する様々なカスタマイズを行うことができますので、それに対応させていきます。

前準備:CustomTabs用のActivityとBrowser用のActivityを分ける

Browserアプリではそれぞれを特徴付けるUIを持たせることになると思います。
CustomTabsで起動される場合は、起動元アプリの延長線上で動作しているように見せる必要もあり、それらの特徴を出さない専用のUIを持たせる必要があります。

UIをだし分ける方法はいくつかあると思いますが、ここではActivityを分けることにします。
ただし、CustomTabsとして受け取るIntentとブラウザアプリとして受け取るIntentはintent-filterは区別できないので、実際には表示されないActivityで一端受け取り、それぞれのActivityに振り分けるようにします。コールドスタート時の起動が遅くなるのであまり賢い方法ではないと思いますが、Chromiumの実装でも同様のことをやっています。

AndroidManifest.xml
<activity-alias
    android:name=".EntryPoint"
    android:label="@string/app_name"
    android:targetActivity=".IntentDispatcher"
    >
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.LAUNCHER" />
        <category android:name="android.intent.category.BROWSABLE" />
        <category android:name="android.intent.category.APP_BROWSER" />
    </intent-filter>
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />

        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />

        <data android:scheme="http" />
        <data android:scheme="https" />
    </intent-filter>
</activity-alias>

<activity
    android:name=".IntentDispatcher"
    android:configChanges="orientation|keyboard|keyboardHidden|smallestScreenSize"
    />
<activity
    android:name=".BrowserActivity"
    android:configChanges="orientation|keyboard|keyboardHidden|smallestScreenSize"
    android:launchMode="singleTask"
    />
<activity
    android:name=".CustomTabsActivity"
    android:configChanges="orientation|keyboard|keyboardHidden|smallestScreenSize"
    android:launchMode="singleTask"
    android:theme="@style/AppTheme.NoActionBar"
    />

上記のactivity-aliasIntentDispatcherの名前を隠蔽しているあたりは本質ではないのでここでは無視してください。
ポイントとしては、IntentDispatchというActivityに対してintent-filterを設定し、一度このActivityでintentを受け付けるようにします。BrowserActivityとCustomTabsActivityはそれぞれ名前の通り、ブラウザアプリとしてのActivityとCustomTabsで起動された時用のActivityです。
IntentDispatcherはUIを持たず、Intentを受け取ったらBrowserActivityもしくはCustomTabsActivityを起動し、即座に終了します。

IntentDispatcherでの分岐

CustomTabsのIntentかどうかの判断は以下のようにします。

IntentDispatcher.kt
private fun isCustomTabIntent(intent: Intent): Boolean {
    if (CustomTabsIntent.shouldAlwaysUseBrowserUI(intent)) return false
    if (!intent.hasExtra(CustomTabsIntent.EXTRA_SESSION)) return false
    return intent.data != null
}

最初のif文はクライアント側でCustomTabsIntent#setAlwaysUseBrowserUIというメソッドを使いCustomTabsのUIではなく、ブラウザーのUIで起動するようにする設定があるため、最初にこのフラグの確認をしています。中身的にはBooleanExtraのフラグなのですがCustomTabsIntentに判定メソッドが用意されているためそのまま利用します。

CustomTabsIntent.Builderでintentを作ると、引数のSesssion値がCustomTabsIntent.EXTRA_SESSIONとしてIntentについてきているはずなのでこれの有無でCustomTabsかどうかを判定しています。(Sessionを指定しない場合でもnull値が入っているため、hasExtra()はtrueとなります)

dataのnull判定は開くべきURIがない場合はCustomTabsのUIで開いても仕方ないので通常のブラウザ起動に切り替えるために行っています。

BrowserActivityは通常のブラウザとして起動するので、intent.dataの値があればそれを開くし、無ければデフォルトのホームページを表示するといった形になります。そちらはCustomTabsとは関係ないのでここでは割愛。

CustomTabsActivityの方で、CustomTabsIntentの様々な指定に対応させていきます。

CustomTabsActivityのレイアウト構成

様々なUIのカスタマイズができるように以下のような構成にしています。これに対して様々な指定を行うので先に全体を示しておきます。

AppBarLayoutを持つCoordinatorLayoutをLinearLayoutで囲んでいます。
CustomTabsではSecondaryToolbarとしてボタン配置を指定するものとRemoteViewsを指定するものがあります。(前者はAPI level 24.1.0でdeprecated)この2つは挙動が異なるため、toolbar2とtoolbar3という形で別のViewとして配置しています。behaviorを適切に実装すればレイアウトのネストを回避することができると思いますが、実装自体はこちらの方が簡単だったのでこうしました。toolbar2とtoolbar3は指定が無ければ表示されないのでデフォルトのvisivilityがgoneになっています。(性質的にViewStubを使ったほうがいいかもしれない)

ブラウザ機能を実現するWebViewとしてNestedScrollingWebViewという自作クラスを配置しています。これはCoordinatorLayoutでNestedScrollを実現する簡易実装をいれたWebViewです。(WebViewではChromeのようなきれいなNestedScrollは実現できません。それっぽく見えるようにしただけの簡易実装です。どういう実装になっているか気になる方はgithubの方を参照ください)
ロード状態を表示するためAppBarLayoutの中にProgressBarも配置しています。

activity_custom_tabs.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    >

    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        >

        <com.google.android.material.appbar.AppBarLayout
            android:id="@+id/app_bar"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:theme="@style/AppTheme.AppBarOverlay"
            >

            <androidx.appcompat.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                android:background="?attr/colorPrimary"
                app:popupTheme="@style/AppTheme.PopupOverlay"
                app:subtitleTextAppearance="@style/toolbar.Subtitle"
                app:titleTextAppearance="@style/toolbar.Title"
                >

                <ImageView
                    android:id="@+id/action_button"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_gravity="end"
                    android:layout_marginEnd="8dp"
                    android:visibility="gone"
                    tools:ignore="ContentDescription"
                    />
            </androidx.appcompat.widget.Toolbar>

            <ProgressBar
                android:id="@+id/progress_bar"
                style="?android:attr/progressBarStyleHorizontal"
                android:layout_width="match_parent"
                android:layout_height="2dp"
                android:progressDrawable="@drawable/browser_progress"
                />

        </com.google.android.material.appbar.AppBarLayout>

        <LinearLayout
            android:id="@+id/toolbar3"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:layout_gravity="bottom"
            android:elevation="8dp"
            android:orientation="horizontal"
            android:visibility="gone"
            app:layout_behavior="net.mm2d.customtabsbrowser.BottomBarBehavior"
            >
        </LinearLayout>

        <net.mm2d.customtabsbrowser.NestedScrollingWebView
            android:id="@+id/web_view"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_behavior="net.mm2d.customtabsbrowser.NestedScrollingWebView$Behavior"
            />

    </androidx.coordinatorlayout.widget.CoordinatorLayout>

    <LinearLayout
        android:id="@+id/toolbar2"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        android:elevation="8dp"
        android:orientation="horizontal"
        android:visibility="gone"
        >
    </LinearLayout>
</LinearLayout>

タイトル表示

CustomTabsIntent.Builder#setShowTitle(boolean) でtrueを指定するとツールバー部分にサイトのタイトルが表示されるようになります。

Intentからの読み出しは以下のようにします。

CustomTabsIntentReader.kt
val shouldShowTitle: Boolean = intent.getIntExtraSafely(EXTRA_TITLE_VISIBILITY_STATE, NO_TITLE) == SHOW_PAGE_TITLE

タイトルはWebChromeClientのonReceivedTitleで取得できますので、ここでToolbarに設定するようにします。

CustomTabsActivity.kt
web_view.webChromeClient = object : WebChromeClient() {
    override fun onReceivedTitle(view: WebView?, title: String?) {
        if (reader.shouldShowTitle) supportActionBar?.title = title
    }
}

ツールバーの色指定

CustomTabsIntent.Builder#setToolbarColor(int) で、ツールバーの色を指定できます。
これは、指定した色がint値としてExtraに入っているだけなので以下のように読み出せます。

CustomTabsIntentReader.kt
val toolbarColor: Int = intent.getIntExtraSafely(EXTRA_TOOLBAR_COLOR, Color.WHITE)

色を反映させるだけならそのままtoolbarに指定してしまえばいいのですが、任意の色が指定できてしまうのでテキストなどのフォアグラウンドカラーの視認性を確保するための工夫が必要です。(黒背景に黒文字になると全く見えませんので)

フォアグラウンドの色も任意にすると非常に難しくなってしまいますので、白系と黒系の2色用意して、どちらのほうが良いかを判定し使用するようにします。

ChromiumのソースコードをみるとsRGBの色空間で白とのコントラスト比を計算しそれが3以上かどうかで判定しているようです。(W3Cのアクセシビリティ基準で白がフォアグラウンドカラーとして適切かを判定し、コントラストが足りなければ黒系にする)
以下の処理はChromiumの処理を参考にKotlin化したものです。

ColorUtils.kt
internal fun Int.shouldUseWhiteForeground(): Boolean =
    calculateContrast(this, Color.WHITE) >= 3

// https://www.w3.org/TR/WCAG20/#contrast-ratiodef
private fun calculateContrast(color1: Int, color2: Int): Float {
    val l1 = color1.calculateRelativeLuminance()
    val l2 = color2.calculateRelativeLuminance()
    return if (l1 > l2) {
        (l1 + 0.05f) / (l2 + 0.05f)
    } else {
        (l2 + 0.05f) / (l1 + 0.05f)
    }
}

// https://www.w3.org/TR/WCAG20/#relativeluminancedef
private fun Int.calculateRelativeLuminance(): Float {
    val r = red().normalize()
    val g = green().normalize()
    val b = blue().normalize()
    return r * 0.2126f + g * 0.7152f + b * 0.0722f
}

private fun Int.red(): Float = Color.red(this) / 255f
private fun Int.green(): Float = Color.green(this) / 255f
private fun Int.blue(): Float = Color.blue(this) / 255f
private fun Float.normalize(): Float =
    if (this < 0.03928f) this / 12.92f else ((this + 0.055f) / 1.055f).pow(2.4f)

というわけでこんな感じで色指定を行います。

CustomTabsActivity.kt
private fun customUi() {
    val shouldUseWhiteForeground = reader.toolbarColor.shouldUseWhiteForeground()
    toolbar.setBackgroundColor(reader.toolbarColor)
    app_bar.setBackgroundColor(reader.toolbarColor)
    progress_bar.progressDrawable = ContextCompat.getDrawable(this,
            if (shouldUseWhiteForeground) R.drawable.browser_progress_dark
            else R.drawable.browser_progress)
    if (darkToolbar) {
        setForegroundColor(R.color.text_main_dark, R.color.text_sub_dark)
    } else {
        setForegroundColor(R.color.text_main, R.color.text_sub)
    }
}

private fun setForegroundColor(mainColorId: Int, subColorId: Int) {
    val mainColor = ContextCompat.getColor(this, mainColorId)
    val subColor = ContextCompat.getColor(this, subColorId)
    toolbar.setTitleTextColor(mainColor)
    toolbar.setSubtitleTextColor(subColor)
    toolbar.overflowIcon?.setTint(mainColor)
    toolbar.navigationIcon?.setTint(mainColor)
    tintedColor = mainColor
}

toolbarとapp_barの両方に背景色を設定します。
app_barの中にはprogressbarもあるので、これも視認性を確保するためにprogressDrawableを指定します。

ステータスバーの色をtoolbarに合わせる

また、Android6以上ならStatusBarの色指定ができるのでこちらも指定します。
StatusBarも背景を指定しただけではフォアグラウンドの色が変わらないため、
明るい色を設定する場合はsystemUiVisivilityとして、View.SYSTEM_UI_FLAG_LIGHT_STATUS_BARのフラグを追加します。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
    window.statusBarColor = reader.toolbarColor
    if (!shouldUseWhiteForeground) {
        window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
    }
}

オプションメニュー

シェアボタン

CustomTabsIntent.Builder#addDefaultShareMenuItem() でオプションメニューにシェアボタンを追加することができます。

これはただのBooleanが格納されているだけなので、以下のように読み出せます。

val shouldShowShareMenuItem: Boolean = intent.getBooleanExtra(EXTRA_DEFAULT_SHARE_MENU_ITEM, false)

あとは、ActivityのonCreateOptionsMenu()にて以下のようにフラグが立っていないときに削除するか、フラグの有無でinflateするメニューを変えるなどすればよいでしょう。

menuInflater.inflate(R.menu.menu_main, menu)
if (!shouldShowShareMenuItem) {
    menu.removeItem(R.id.action_share)
}

タップされたときの動きは特にクライアント側から指定するものではないので、ShareCompatを使って共有メニューを表示させればよいです。

override fun onOptionsItemSelected(item: MenuItem): Boolean {
    when (item.itemId) {
        R.id.action_share -> onSelectShare()
        else -> return super.onOptionsItemSelected(item)
    }
    return true
}

private fun onSelectShare() {
    val url = webView.url ?: return
    if (!URLUtil.isNetworkUrl(url)) {
        return
    }
    ShareCompat.IntentBuilder.from(this)
        .setType("text/plain")
        .setText(url)
        .setSubject(webView.title)
        .setChooserTitle(R.string.action_share)
        .startChooser()
}

任意メニューの追加

CustomTabsIntent.Builder#addMenuItem(String, PendingIntent) で、オプションメニューを追加することができます。タップしたアクションをPendingIntentで指定します。

ここで指定したパラメータはStringとPendingIntentの入ったBundleのArrayListとしてIntentに格納されています。
以下のようにデータクラスをつくってBundleから読み出せるようにして

class MenuParams(
    val title: String,
    val pendingIntent: PendingIntent?
)

private fun makeMenuParams(bundle: Bundle): MenuParams? {
    val title = bundle.getString(KEY_MENU_ITEM_TITLE)
    if (title.isNullOrEmpty()) return null
    return MenuParams(title, bundle.getParcelableSafely(KEY_PENDING_INTENT))
}

以下のようにメニューリストを取り出します。

val menuParamsList: List<MenuParams> = intent.getParcelableArrayListExtra<Bundle>(EXTRA_MENU_ITEMS)
                ?.mapNotNull { makeMenuParams(it) } ?: emptyList()

onCreateOptionsMenuでこのメニューを追加し、onOptionsItemSelectedでメニューの選択をハンドリングします。
PendingIntentを受け取った側としては、中身がどのようなものかを気にする必要は無く、PendingIntentはsendメソッドを使えば、作成時に指定されたIntentを飛ばすことができます。ただし、送信できないなどについてはPendingIntentを作った側の責任ではありますが、Exceptionが発生する可能性がありますので、巻き込まれてアプリがクラッシュしないように、きちんとcatchだけはしておきましょう。

override fun onCreateOptionsMenu(menu: Menu): Boolean {
    menuInflater.inflate(R.menu.menu_main, menu)
    reader.menuParamsList.forEachIndexed { index, menuParams ->
        menu.add(
            R.id.overflow,
            CUSTOM_MENU_ID_START + index,
            CUSTOM_MENU_ORDER_START + index,
            menuParams.title
        ).isVisible = false
    }
    return true
}

override fun onOptionsItemSelected(item: MenuItem): Boolean {
    val customMenuRange =
        CUSTOM_MENU_ID_START until CUSTOM_MENU_ID_START + reader.menuParamsList.size
    when (item.itemId) {
        in customMenuRange -> onSelectCustomMenu(item.itemId - CUSTOM_MENU_ID_START)
        else -> return super.onOptionsItemSelected(item)
    }
    return true
}

private fun onSelectCustomMenu(index: Int) {
    try {
        reader.menuParamsList[index].pendingIntent?.send(this, 0, null)
    } catch (ignored: Throwable) {
    }
}

ボトムツールバーの追加

CustomTabsIntent.Builder#setSecondaryToolbarViews(RemoteViews, int[], PendingIntent) でボトムツールバーのViewを指定することができます。
ViewはRemoteViewsで指定しますので結構レイアウトの自由度があります。第二引数はRemoteViewsの中のViewIDの配列を格納し、そのViewのタップイベント用のPendingIntentを第三引数で渡します。PendingIntentにはViewのIDと、そのときブラウザで表示しているURIが格納されてなげられます。
PendingIntentにRemoteViewsのsetOnClickPendingIntentを使えばいいはずのところですが、URIを付与するためか、こうなっています。

これらは単にExtraとして格納されていますので以下のように読み出せます。

val remoteViews: RemoteViews? = intent.getParcelableExtra(EXTRA_REMOTEVIEWS)
val remoteViewsClickableIDs: IntArray? = intent.getIntArrayExtra(EXTRA_REMOTEVIEWS_VIEW_IDS)
val remoteViewsPendingIntent: PendingIntent? = intent.getParcelableExtra(EXTRA_REMOTEVIEWS_PENDINGINTENT)

RemoteViewsはapplyメソッドを使うことでViewをInflateできるますので、あとはただのViewと同じ扱いができます。
なので指定されたIDのViewをfindViewByIdを使って、ClickListenerを登録します。
PendingIntentに追加のパラメータを付与して投げる場合は、それが設定されたIntentを作成した後、PendingIntent#sendの第三引数に渡せばよいです。

private fun tryShowRemoteViews(): Boolean {
    val remoteViews = reader.remoteViews ?: return false
    val inflatedViews = try {
        remoteViews.apply(applicationContext, toolbar3)
    } catch (e: ActionException) {
        return false
    }
    toolbar3.visibility = View.VISIBLE
    toolbar3.addView(inflatedViews)
    val pendingIntent = reader.remoteViewsPendingIntent ?: return true
    reader.remoteViewsClickableIDs?.filter { it >= 0 }?.forEach {
        inflatedViews.findViewById<View>(it)?.setOnClickListener { v ->
            sendPendingIntentOnClick(pendingIntent, v.id)
        }
    }
    return true
}

private fun sendPendingIntentOnClick(pendingIntent: PendingIntent, id: Int) {
    val addedIntent = Intent().also {
        it.putExtra(CustomTabsIntent.EXTRA_REMOTEVIEWS_CLICKED_ID, id)
        it.data = Uri.parse(webView.url)
    }
    try {
        pendingIntent.send(this, 0, addedIntent)
    } catch (ignored: Throwable) {
    }
}

画面遷移アニメーション

CustomTabsIntent.Builder#setStartAnimations(Context, int, int)
CustomTabsIntent.Builder#setExitAnimations(Context, int, int)
でCustomTabsの起動と終了アニメーションを指定することができます。

このアニメーションはActivityOptionsCompat.makeCustomAnimationを使ってBundleを作成しています。

bundle = ActivityOptionsCompat.makeCustomAnimation(context, enterResId, exitResId).toBundle();

開始アニメーションについては、起動元のアプリで指定します。
launchUrlの処理の中身は以下のようになっていて、ここで開始アニメーションを指定します。

public void launchUrl(Context context, Uri url) {
    intent.setData(url);
    ContextCompat.startActivity(context, intent, startAnimationBundle);
}

終了時アニメーションは前述のBundleをExtraとしてIntentにつけて送られます。
受け取った側は以下のように送り元のpackagenameとenter/exitのアニメーションリソースIDを取り出します。

val animationBundle = intent.getBundleExtra(EXTRA_EXIT_ANIMATION_BUNDLE)
val clientPackageName = animationBundle.getString(BUNDLE_PACKAGE_NAME, null)
val hasExitAnimation = clientPackageName != null
val enterAnimationRes = animationBundle.getInt(BUNDLE_ENTER_ANIMATION_RESOURCE)
val exitAnimationRes = animationBundle.getInt(BUNDLE_EXIT_ANIMATION_RESOURCE)

※BUNDLE_ENTER_ANIMATION_RESOURCE/BUNDLE_EXIT_ANIMATION_RESOURCEの中身はOSバージョンで差異があるので注意、詳細が気になる方はGithubのコードを参照。

取り出されるのは送信元アプリのアニメーションリソースIDであるという点に注意、このIDをそのまま読み出そうとしても読み出せません。
終了時のアニメーションはfinishの後にoverridePendingTransitionでアニメーションリソースIDを渡すことで実現しますが、ここで指定するIDは呼び出し元アプリのものなので、overridePendingTransitionを呼び出している間だけ、ActivityのgetPackageName()の戻り値を、呼び出し元アプリのものに書き換えておきます。

override fun getPackageName(): String {
    if (overridePackageName) return reader.clientPackageName
        ?: super.getPackageName()
    return super.getPackageName()
}

override fun finish() {
    super.finish()
    if (reader.hasExitAnimation) {
        overridePackageName = true
        overridePendingTransition(reader.enterAnimationRes, reader.exitAnimationRes)
        overridePackageName = false
    }
}

overridePendingTransitionの中でリソースを読み出すときだけ、ActivityのアプリIDを偽装するという裏技をつかって、呼び出し元アプリのアニメーションリソースを使って、自身の終了時アニメーションを指定することができるようになります。

個人的に調べててこの裏技テクニックがおもしろかったです。

まとめ

全然紹介し切れていないところではありますが、様々な機能の集合体なので、こんな感じで一つずつ機能を実装していくと、CustomTabs対応ブラウザができあがります。仕様が明確になっていないところとかも結構ありますが、Chromiumのソースコードを読めばだいたいわかります。仕様が明確になってないということは使うユーザーもいないでしょうし未実装でも構わないかもしれません。
DroidKaigiで発表したように、プロセス間通信というか、Intentの扱いだけでもかなりディープな使い方がされていて知見の塊です。興味がわいた方は是非調べてみてください。

13
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?