1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

abstract

Picture-in-Pictureという、コンテンツに触れながら他アプリケーション、機能を操作できることで有名な機能があります。
今回はAndroidネイティブアプリで、この機能に関して遊んでみました。
単純なコードで実現できる分、カスタマイズ性というべきか、フレームワーク外のことに対応しにくい印象を感じました。

はじめに

先日、Twitter(現X)にて以下の記事が流れてきたので読んでみました。

Picture-in-Picture(以降「PiP」)という動画画面を小さなウィンドウにし、画面最前部に置くことで他のアプリケーションや機能を触りながらコンテンツに触れられる機能があります。
この記事では、そんなPiP機能に関して"Document"に関わるWeb APIの紹介がされています。

私はPiP機能を動画の再生、マップの表示のようなボタン操作しかできない受動的なものだと思っていたため、驚きとともに興味深いと感じました。
Androidのネイティブアプリでも、GoogleのYoutubeやGoogle MapsなどにはPiP機能が使われています。
そこで、この記事ではPiPで文書を表示する小さいアプリを作りながら、AndroidのAPIではどのようなことができるのかを試して遊んでみようと思います。

環境

Android API Level: 31
minSDK: 26 (Oreo)
Kotlin: 2.0
ComposeBom: 2024.12.01

PiPの実装

ひとまずPiPを実現していくところまでは、よくある動画プレーヤーのアプリと同じため、以下の公式ドキュメントに添いながら実装していきます。
https://developer.android.com/develop/ui/compose/system/picture-in-picture?hl=en

Manifestの設定

AndroidManifest.xml
<activity
    ...
    android:supportsPictureInPicture="true"
    ...

対象となるActivityにsupportsPictureInPictureを設定します。

公式ドキュメント内ではconfigChangesも設定していますが、これはPiP中のアクティビティの再生成を制限し、意図しない破壊を防ぐための設定らしいです。
今回のミニアプリではそれを想定しないため設定しませんが、設定は目的に沿って行ってください。

拡張関数の定義

次に必要な拡張関数を定義します。

internal fun Context.findActivity(): ComponentActivity {
    var context = this
    while (context is ContextWrapper) {
        if (context is ComponentActivity) return context
        context = context.baseContext
    }
    throw IllegalStateException("Picture in picture should be called in the context of an Activity")
}

(ちなみに当初別の記事を参考に検証していたため、急に出てきたfindActivityでなんぞこれは、となってました。前提として書くならAPI生やしてくれればいいのに...)

API Levelによる処理の差異

Androidアプリケーションでは、通常バックグラウンド移行時にPiP機能を起動することが多いです。
しかし、多くのアプリがそうでしょうが、MinSDK Versionが31、つまりはAndroid12を下回る場合はAPI Levelによって処理の出し分けをしなくてはなりません。

API Level 31未満の場合、DisposableEffect内でaddOnUserLeaveHintListenerを使用し、バックグラウンドに移行した際の挙動を設定する必要があるため、以下のコードになります。

if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
    DisposableEffect(context) {
        val onUserLeaveBehavior: () -> Unit = {
            context
                .findActivity()
                .enterPictureInPictureMode(PictureInPictureParams.Builder().build())
        }
        context.findActivity().addOnUserLeaveHintListener(
            onUserLeaveBehavior
        )
        onDispose {
            context.findActivity().removeOnUserLeaveHintListener(
                onUserLeaveBehavior
            )
        }
    }
}

API Level 31以上の際はModifiersetAutoEnterEnabledを呼び出す必要があるため、以下のコードになります。

Modifier.onGloballyPositioned { layoutCoordinates ->
    val builder = PictureInPictureParams.Builder()

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        builder.setAutoEnterEnabled(true)
    }
    context.findActivity().setPictureInPictureParams(builder.build())
}

両方のコードの出しわけをよしなにやってみましょう。
fat感はありますが、とりあえずはこれでいいと思います。

val pipModifier = 
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
        // API Level 31未満
        DisposableEffect(context) {
            val onUserLeaveBehavior: () -> Unit = {
                context
                    .findActivity()
                    .enterPictureInPictureMode(PictureInPictureParams.Builder().build())
            }
            context.findActivity().addOnUserLeaveHintListener(
                onUserLeaveBehavior
            )
            onDispose {
                context.findActivity().removeOnUserLeaveHintListener(
                     onUserLeaveBehavior
                )
            }
        }
        Modifier
    } else {
        // API Level 31以上
        Modifier.onGloballyPositioned { layoutCoordinates ->
            val builder = PictureInPictureParams.Builder()
            builder.setAutoEnterEnabled(true)
            context.findActivity().setPictureInPictureParams(builder.build())
        }
    }

実行

ここまできたら実行しましょう。

よく見るPiPですね。
GreetingのTextも表示されていますし、もう既に目的は達成したと言っても過言ではありません。
AndroidのAPIだと、Documentと銘打たなくてもそのままPiPとして機能します。
逆に動画の表示が大変かもしれないですね。

その他機能の紹介

ボタンで能動的にPiPに移行する

Button Composableなどでユーザーが能動的にPiPへ移行できるようにしたい場合があります。
そんな時はAPI Level 31未満で出てきたenterPictureInPictureModeを、onClickなどのイベント内で使用すると良いでしょう。

context.findActivity().enterPictureInPictureMode(
    PictureInPictureParams.Builder().build()
)

動画では見にくいですが、ボタンをタップしてPiPに移行できていることがわかります。
また、PiPへ移行した際に文言が変わっていることがわかります。
こちらは次のセクションで説明する、PiP状態の取得によって実現可能です。

PiP状態の取得

ドキュメント内に現在PiP状態であるかを取得するremember関数がありました。

@Composable
fun rememberIsInPipMode(): Boolean {
    val activity = LocalContext.current.findActivity()
    var pipMode by remember { mutableStateOf(activity.isInPictureInPictureMode) }
    DisposableEffect(activity) {
        val observer = Consumer<PictureInPictureModeChangedInfo> { info ->
            pipMode = info.isInPictureInPictureMode
        }
        activity.addOnPictureInPictureModeChangedListener(
            observer
        )
        onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) }
    }
    return pipMode
}

通常のremember関数と同様に使用し、アプリが現在PiPかどうかの判別を行えます。

val isInPipMode = rememberIsInPipMode()
Text(text = if (isInPipMode) "Exit PiP mode!" else "Enter PiP mode!")

残念な点

ソースコード上でPiPのアスペクト比を変更するAPIはありましたが、残念ながらPiPの大きさを指定することはできなさそうです。
私はこの記事を書く際に初めて知ったのですが、PiP状態のActivityはピンチイン、ピンチアウトによってサイズを変更することが可能だそうです。

今回Columnに要素を二つ用意しただけなのが良くなかったのかもしれませんが、横長になった後はピンチイン/アウトで縦長に戻すことができず、一旦アプリをフォアグラウンドに戻さなくてはならないなど、それなりに不便がありそうです。

また、ボタンが「Exit PiP mode!」という表記になっていますが、動画を見てもらえるとわかるようにコンテンツ内部にユーザーが触れません。
PiP状態のウィンドウを一回タップすると、アクション用のボタンが表示されてユーザーに次の行動を促すようです。
ここに関してはカスタマイズすることは可能でしたが、別途で対応しなくてはいけなくなるためにウィンドウ内部のボタンを触らせて欲しい、と思いながら検証していました。

終わりに

PiPはアプリケーションのコンテンツをディスプレイ内に表示しながら、ユーザーに他の行動を許可することが可能な機能です。
ユーザーの可処分時間をさらに貰える可能性が高くなる機能とも言えます。
現状、PiP機能を有効に扱うことができるコンテンツは少ないですが、今後機能を使う人が増えてカスタマイズ性が高くなってくると嬉しいなと思います。

本記事はAndroid関連技術 Advent Calendar 2024 9日目の記事です。9日目の記事です!

参考記事

ソースコード

一応今回の検証に使用したソースコードを載せておきます。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?