⬅️

フルComposeアプリでPredictive Backに対応してみた備忘録

に公開1

はじめに

AndroidにはPredictive Backという機能があります。
戻るジェスチャ−操作をしている途中で、次に何がおきるか予測できるアニメーションを表示する、というものです。

具体的にどのようなアニメーションがおこなわれるのかは、下記リンク先のアニメーションGIFが参考になります。

https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture

Android 15ではopt-inでしたが、Android 16にてAndroid 16以上をターゲットとするアプリではopt-outとなりました。
可能であればアプリのtargetSdkをAndroid 16にするタイミングで、Predictive Backにも対応しておくのが望ましいでしょう。

今回、(完璧ではないですが)開発に関わっているアプリでPredictive Back対応をおこなったので、そこで得た知見やハマりどころをご紹介します。

Predictive Back対応したアプリの情報

フルComposeアプリでPredictive Backに対応する方法

画面遷移をnavigation-composeで実現しているアプリの場合、 navigation-compose 2.8.0以上 を使うだけ、です。
Android 15でPredictive Backを有効化する場合、こちらを参考にAndroidManifest.xmlに下記の記述を追加すればOKです。

AndroidManifest.xml
<application
    ...
    android:enableOnBackInvokedCallback="true"
    ... >
...
</application>

より詳しい情報は下記を参考にして下さい。

https://developer.android.com/develop/ui/compose/system/predictive-back-setup

ただし、私が開発に携わっているアプリはこれだけではPredictive Backが動作しませんでした。

原因

原因は、NavHostの後にBackHandlerを登録している事、でした。

    NavHost(
        // 省略
    )

    BackHandler() { // これが原因でPredictive Backが無効化される
        // 省略
    }

実はNavHostの中でPredictiveBackHandlerを登録しており、ここで戻る操作を処理することでPredictive Backを実現しています。

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:navigation/navigation-compose/src/commonMain/kotlin/androidx/navigation/compose/NavHost.kt;l=513?q=NavHost

NavHost.kt
   var progress by remember { mutableFloatStateOf(0f) }
    var inPredictiveBack by remember { mutableStateOf(false) }
    PredictiveBackHandler(currentBackStack.size > 1) { backEvent ->
        var currentBackStackEntry: NavBackStackEntry? = null
        if (currentBackStack.size > 1) {
            progress = 0f
            currentBackStackEntry = currentBackStack.lastOrNull()
            composeNavigator.prepareForTransition(currentBackStackEntry!!)
            val previousEntry = currentBackStack[currentBackStack.size - 2]
            composeNavigator.prepareForTransition(previousEntry)
        }

しかし、このPredictiveBackHandlerの後に別のPredictiveBackHandlerやBackHandlerを登録すると、戻る操作の処理は最後に登録されたPredictiveBackHandler/BackHandlerがおこなうので、NavHostのPredictiveBackHandlerは呼ばれません。

結果として、Predictive Backが動作しなくなくなります。

対策

NavHostの後に登録しているBackHandlerは、特定条件でのみ有効化するようにしました。
本当はBackHandlerの登録自体を避けた方が良いです。
しかし、アプリの作りと仕様の関係上、戻る操作でtop level destinationを切り替える時の順番を制御したいため、このような変更をおこないました。

    NavHost(
        // 省略
    )

    BackHandler(enable = shouldBackTopLevelDestination) { // changed
        // 省略
    }

ただし、BackHandlerが有効なケースではPredictive Backが無効化されるため、今後はアプリの作りを見直してBackHandler登録自体を止めたいです。

より高度なPredictive Back対応

currentBackStack.size > 1でもPredictive BackでActivityを終了する

画面遷移にnavigation-composeを使用しており、back stackが複数存在するケースでも、戻る操作でActivityを終了したい事があると思います。

しかし、これの実現は大変困難です。

Predictive BackでActivityを終了させるには(私の調査した結果では)全てのPredictiveBackHandler/BackHandlerを登録解除 or 無効化が必要です。
(より正確には、ActivityのonBackInvokedDispatcherにcallbackが一つも登録されていない事)

しかし、残念ながらNavHostのPredictiveBackHandlerを外部から直接無効化する方法は提供されておらず、またその予定もありません。機能リクエストがありましたが、拒否されています。

なので、力技で実現します。

OnBackInvokedDispatcher自体はpublic interfaceで独自実装が作れるので、登録済みcallbackを全て登録解除する機能を追加したものを用意し、これを使えば良さそうです。

まず、OnBackInvokedDispatcher登録済みcallbackの全解除/再登録をinterfaceとして定義し、CompositionLocalも追加します。

OnBackInvokedDispatchSuspender.kt
interface OnBackInvokedDispatchSuspender {
    /**
     * Requests to suspend back dispatching.
     * Multiple calls will increase internal counter, requiring an equal number of release calls.
     * Only effective for API Level 33 and above.
     */
    fun requestBackDispatchSuspension()

    /**
     * Releases a suspension request.
     * Back dispatching resumes when all suspension requests are released.
     * Only effective for API Level 33 and above.
     */
    fun releaseBackDispatchSuspension()
}

val LocalOnBackInvokedDispatchSuspender =
    staticCompositionLocalOf<OnBackInvokedDispatchSuspender> {
        object : OnBackInvokedDispatchSuspender {
            override fun requestBackDispatchSuspension() {
                // NOP
            }
            
            override fun releaseBackDispatchSuspension() {
                // NOP
            }
        }
    }

OnBackInvokedDispatcherWrapperを追加し、OnBackInvokedDispatcherをラップしてOnBackInvokedDispatchSuspenderも実装します。

OnBackInvokedDispatcherWrapper.kt
/**
 * Wrapper for OnBackInvokedDispatcher,
 * which can suspend the back dispatching by calling setSuspended.
 *
 * Why need this?
 *
 * There are cases where you want to finish Activity with PredictiveBack by a back operation even if
 * the NavHost back stack size is more than 2.
 * This requires disabling NavHost's PredictiveBackHandler from the outside,
 * but this feature request was rejected by Google https://issuetracker.google.com/issues/308445371.
 *
 * After much consideration, the only way to externally disable the PredictiveBackHandler is
 * to create an OnBackInvokedDispatcher with suspend back dispatching function and
 * set it to LocalOnBackPressedDispatcherOwner.
 */
@RequiresApi(VERSION_CODES.TIRAMISU)
class OnBackInvokedDispatcherWrapper(
    private val onBackInvokedDispatcher: OnBackInvokedDispatcher,
) : OnBackInvokedDispatcher by onBackInvokedDispatcher, OnBackInvokedDispatchSuspender {

    private data class CallbackWithPriority(
        val priority: Int,
        val callback: OnBackInvokedCallback,
    )

    private val callbacks = mutableListOf<CallbackWithPriority>()

    private var suspendRequestCount = 0 // 一時停止要求のカウント
    private var isBackDispatchSuspended = false

    override fun registerOnBackInvokedCallback(
        priority: Int,
        callback: OnBackInvokedCallback,
    ) {
        callbacks.add(CallbackWithPriority(priority, callback))

        if (!isBackDispatchSuspended) {
            onBackInvokedDispatcher.registerOnBackInvokedCallback(priority, callback)
        }
    }

    override fun unregisterOnBackInvokedCallback(
        callback: OnBackInvokedCallback,
    ) {
        callbacks.removeIf { it.callback == callback }

        if (!isBackDispatchSuspended) {
            onBackInvokedDispatcher.unregisterOnBackInvokedCallback(callback)
        }
    }

    override fun requestBackDispatchSuspension() {
        val oldSuspendedState = isBackDispatchSuspended
        suspendRequestCount++
        isBackDispatchSuspended = suspendRequestCount > 0
        val newSuspendedState = isBackDispatchSuspended

        if (oldSuspendedState != newSuspendedState) {
            callbacks.forEach {
                onBackInvokedDispatcher.unregisterOnBackInvokedCallback(it.callback)
            }
        }
    }

    override fun releaseBackDispatchSuspension() {
        val oldSuspendedState = isBackDispatchSuspended
        
        if (suspendRequestCount > 0) {
            suspendRequestCount--
        }
        
        isBackDispatchSuspended = suspendRequestCount > 0
        val newSuspendedState = isBackDispatchSuspended

        if (oldSuspendedState != newSuspendedState) {
            callbacks.forEach { (priority, callback) ->
                onBackInvokedDispatcher.registerOnBackInvokedCallback(priority, callback)
            }
        }
    }
}

MainActivityでOnBackInvokedDispatcherWrapperをインスタンス化して、OnBackPressedDispatcherOwnerにセット・LocalOnBackInvokedDispatchSuspenderとしても配ります。

MainActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            var onBackInvokedDispatchSuspender = LocalOnBackInvokedDispatchSuspender.current

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                val onBackInvokedDispatcherWrapper = remember {
                    OnBackInvokedDispatcherWrapper(onBackInvokedDispatcher)
                }

                onBackInvokedDispatchSuspender = onBackInvokedDispatcherWrapper

                LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher?.setOnBackInvokedDispatcher(
                    onBackInvokedDispatcherWrapper
                )
            }

            CompositionLocalProvider(
                LocalOnBackInvokedDispatchSuspender provides onBackInvokedDispatchSuspender,
            ) {
                // 省略
            }
        }

LocalOnBackInvokedDispatchSuspenderを使いやすくするために、PredictiveBackActivityFinishHandlerを追加します。

PredictiveBackActivityFinishHandler.kt
/**
 * Finish activity with predictive back when back operation is performed.
 *
 * This allows the PredictiveBackHandler in the Compose navigation NavHost to be disabled from
 * the outside and the back operation process delegated to the activity.
 */
@Composable
@NonRestartableComposable
fun PredictiveBackActivityFinishHandler(
    enabled: Boolean = true,
) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        /*
        During a Predictive Back operation, the previous screen enters the STARTED state.

        If the DisposableEffect is triggered in this state, back dispatch is temporarily suspended,
        causing the Predictive Back operation to be canceled and the back navigation to be finalized.

        Therefore, the DisposableEffect should only be executed from the RESUMED state onward.
         */
        val lifecycle = LocalLifecycleOwner.current.lifecycle
        val currentState: Lifecycle.State by lifecycle.currentStateAsState()
        if (currentState.isAtLeast(Lifecycle.State.RESUMED)) {
            /*
            For API Level 33 and above, back dispatching can be suspended with
            the OnBackInvokedDispatchSuspender even if the predictive back itself is disabled.
             */
            val onBackInvokedDispatchSuspender = LocalOnBackInvokedDispatchSuspender.current
            if (enabled) {
                DisposableEffect(onBackInvokedDispatchSuspender) {
                    onBackInvokedDispatchSuspender.requestBackDispatchSuspension()
                    onDispose {
                        onBackInvokedDispatchSuspender.releaseBackDispatchSuspension()
                    }
                }
            }
        }
    } else {
        /*
        OnBackInvokedDispatchSuspender can only suspend back dispatching at API Level 33 or higher.
        However, there is no predictive back function below API Level 33,
        so back operations can be handled by BackHandler.
         */
        val activity = LocalActivity.current
        BackHandler(enabled) {
            activity?.finish()
        }
    }
}

これで準備はOKです。
あとは、BackHandlerと同じように戻る操作でActivityを終了したい画面でPredictiveBackActivityFinishHandler()を呼べば、Predictive BackでActivityを終了できます。

例えば、M3のModalBottomSheetなどはそれ自体がPredictive Back対応済みです。

M3で他にPredictive Back対応しているComponentは下記で紹介されています。
https://developer.android.com/develop/ui/compose/system/predictive-back-setup#support-predictive

それ以外で独自にPredictive Back対応する場合、PredictiveBackHandlerを登録してprogressを受け取り、このprogressに応じてアニメーションを実装します。
https://developer.android.com/develop/ui/compose/system/predictive-back-progress

Shared elementsに関してはnavigation-composeと組み合わせればPredictive Back対応できるようですが、まだ試せていません。
https://developer.android.com/develop/ui/compose/animation/shared-elements/navigation#predictive-back

Predictive Backのハマりどころ

Predictive Backアニメーション中、前の画面はSTARTED状態になる

A画面からB画面に遷移し、B画面で戻る操作を途中まで(戻る操作を確定していない)おこなうと
A画面(前の画面)はライフサイクル的にSTARTED状態となります。

なので、「STARTED=ユーザーが完全にこの画面に遷移したので、色々と処理実行開始」というコードになっていると、意図せぬ挙動を引き起こします。

具体例として、今回Predictive Back対応したアプリは購入画面での購入結果を前画面のSTARTEDで処理していたので、戻る操作を途中まで実施しただけで、購入キャンセルが確定してキャンセル処理が意図せず実行されるという現象が発生しました。

おわりに

navigation-composeを素直に使っているアプリではほぼ何もしなくてもPredictive Back対応できるのですが、今回のアプリは少し特殊な使い方をしていたので思っていたよりも対応に時間がかかってしまいました。

特にcurrentBackStack.size > 1のケースでPredictive BackでのActivity終了はかなり力技での実現になってしまったので、もっとスマートな方法があれば知りたいです。

アプリによっては対応が大変なPredictive Backですが、恐らくXR時代に向けてジェスチャー操作で何が起きるのかを視覚的に表現するためにおこなっているのかな、と想像しています。

今後も、余力があるときに未対応箇所のPredictive Back対応を進めていきます。

Discussion