diff --git a/CHANGELOG.md b/CHANGELOG.md index c34b822f..5483f247 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### Unreleased + +* [Android] Support Android Predictive Back gesture. `RibActivity` now registers an `OnBackPressedCallback` instead of overriding the deprecated `onBackPressed()`. No changes required to existing interactors or routers. + ### Version 0.1.0 * Initial release diff --git a/conventions/src/main/kotlin/ribs.android.application.gradle.kts b/conventions/src/main/kotlin/ribs.android.application.gradle.kts index 61c60699..130ddd47 100644 --- a/conventions/src/main/kotlin/ribs.android.application.gradle.kts +++ b/conventions/src/main/kotlin/ribs.android.application.gradle.kts @@ -39,7 +39,7 @@ android { compileSdk = 36 defaultConfig { - minSdk = 21 + minSdk = 23 targetSdk = 35 versionCode = 1 versionName = "1.0" diff --git a/conventions/src/main/kotlin/ribs.android.library.gradle.kts b/conventions/src/main/kotlin/ribs.android.library.gradle.kts index 23630458..46d1aeb7 100644 --- a/conventions/src/main/kotlin/ribs.android.library.gradle.kts +++ b/conventions/src/main/kotlin/ribs.android.library.gradle.kts @@ -43,7 +43,7 @@ android { compileSdk = 36 defaultConfig { - minSdk = 21 + minSdk = 23 } compileOptions { diff --git a/demos/intellij/src/main/AndroidManifest.xml b/demos/intellij/src/main/AndroidManifest.xml index d7437440..a1bb55b9 100644 --- a/demos/intellij/src/main/AndroidManifest.xml +++ b/demos/intellij/src/main/AndroidManifest.xml @@ -13,7 +13,8 @@ android:allowBackup="false" android:icon="@drawable/ub__ic_launcher" android:label="@string/app_name" - android:screenOrientation="portrait"> + android:screenOrientation="portrait" + android:enableOnBackInvokedCallback="true"> diff --git a/demos/stack-nav/src/main/AndroidManifest.xml b/demos/stack-nav/src/main/AndroidManifest.xml index ecc1c4ad..c74923b4 100644 --- a/demos/stack-nav/src/main/AndroidManifest.xml +++ b/demos/stack-nav/src/main/AndroidManifest.xml @@ -5,7 +5,8 @@ android:allowBackup="false" android:icon="@drawable/ub__ic_launcher" android:label="@string/app_name" - android:theme="@style/AppTheme"> + android:theme="@style/AppTheme" + android:enableOnBackInvokedCallback="true"> diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0aa42a15..f791af92 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] android-api = "4.1.1.4" android-studio = "2023.3.1.20" +androidx-activity = "1.13.0" androidx-annotation = "1.9.1" androidx-appcompat = "1.6.1" androidx-lifecycle = "2.6.2" @@ -37,6 +38,7 @@ savedstate = "1.2.1" [libraries] android-api = { group = "com.google.android", name = "android", version.ref = "android-api" } +androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "androidx-activity" } androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "androidx-annotation" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } autocommon = { group = "com.google.auto", name = "auto-common", version.ref = "autocommon" } diff --git a/libraries/rib-android/build.gradle.kts b/libraries/rib-android/build.gradle.kts index c92a4913..40def2bf 100644 --- a/libraries/rib-android/build.gradle.kts +++ b/libraries/rib-android/build.gradle.kts @@ -29,6 +29,7 @@ kotlin.compilerOptions { dependencies { api(project(":libraries:rib-android-core")) api(project(":libraries:rib-base")) + api(libs.androidx.activity) api(libs.rxkotlin) api(libs.rxrelay2) api(libs.rxjava2) diff --git a/libraries/rib-android/src/main/kotlin/com/uber/rib/core/PredictiveBackHandler.kt b/libraries/rib-android/src/main/kotlin/com/uber/rib/core/PredictiveBackHandler.kt new file mode 100644 index 00000000..266842d7 --- /dev/null +++ b/libraries/rib-android/src/main/kotlin/com/uber/rib/core/PredictiveBackHandler.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2017. Uber Technologies + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.uber.rib.core + +import androidx.activity.BackEventCompat + +/** + * Optional interface for [ViewRouter]s that want to drive a custom predictive back animation. + * + * [RibActivity] checks whether the root router implements this interface and, when it does, + * forwards the three gesture-progress callbacks so the router can animate the transition as the + * user swipes. [handleBackPress] is still the commit point and remains on [Router]. + * + * All methods have empty defaults so implementors only override what they need. + * + * These callbacks are only invoked by the system on devices that support predictive back (Android + * 14+ with the gesture enabled). On older devices or when the gesture is not active they are never + * called, so implementing this interface has no effect on legacy back behaviour. + */ +public interface PredictiveBackHandler { + + /** Called when the predictive back gesture is first detected. */ + public fun onBackStarted(backEvent: BackEventCompat) {} + + /** Called continuously as the user's finger moves during the back swipe. */ + public fun onBackProgressed(backEvent: BackEventCompat) {} + + /** Called when the user cancels the gesture (lifts finger without committing). */ + public fun onBackCancelled() {} +} diff --git a/libraries/rib-android/src/main/kotlin/com/uber/rib/core/RibActivity.kt b/libraries/rib-android/src/main/kotlin/com/uber/rib/core/RibActivity.kt index a0b76d6a..28e6f2bd 100644 --- a/libraries/rib-android/src/main/kotlin/com/uber/rib/core/RibActivity.kt +++ b/libraries/rib-android/src/main/kotlin/com/uber/rib/core/RibActivity.kt @@ -19,6 +19,7 @@ import android.content.Intent import android.content.res.Configuration import android.os.Build import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback import androidx.annotation.CallSuper import com.uber.autodispose.lifecycle.CorrespondingEventsFunction import com.uber.autodispose.lifecycle.LifecycleEndedException @@ -50,6 +51,39 @@ public abstract class RibActivity : RxActivityEvents { private var router: ViewRouter<*, *>? = null + private val ribBackPressCallback = + object : OnBackPressedCallback(true) { + override fun handleOnBackStarted(backEvent: androidx.activity.BackEventCompat) { + (router as? PredictiveBackHandler)?.onBackStarted(backEvent) + } + + override fun handleOnBackProgressed(backEvent: androidx.activity.BackEventCompat) { + (router as? PredictiveBackHandler)?.onBackProgressed(backEvent) + } + + override fun handleOnBackCancelled() { + (router as? PredictiveBackHandler)?.onBackCancelled() + } + + override fun handleOnBackPressed() { + if (router?.handleBackPress() != true) { + onUnhandledBackPressed() + // https://issuetracker.google.com/issues/139738913 + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + isTaskRoot && + supportFragmentManager.backStackEntryCount == 0 + ) { + finishAfterTransition() + } else { + isEnabled = false + onBackPressedDispatcher.onBackPressed() + isEnabled = true + } + } + } + } + private val _lifecycleFlow = MutableSharedFlow(1, 0, BufferOverflow.DROP_OLDEST) @@ -88,6 +122,7 @@ public abstract class RibActivity : @CallSuper override fun onCreate(savedInstanceState: android.os.Bundle?) { super.onCreate(savedInstanceState) + onBackPressedDispatcher.addCallback(this, ribBackPressCallback) val rootViewGroup = findViewById(android.R.id.content) _lifecycleFlow.tryEmit(createOnCreateEvent(savedInstanceState)) val wrappedBundle: Bundle? = @@ -184,23 +219,6 @@ public abstract class RibActivity : ) } - override fun onBackPressed() { - if (router?.handleBackPress() != true) { - onUnhandledBackPressed() - - // https://issuetracker.google.com/issues/139738913 - if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && - isTaskRoot && - supportFragmentManager.backStackEntryCount == 0 - ) { - super.finishAfterTransition() - } else { - super.onBackPressed() - } - } - } - override fun onUserLeaveHint() { _lifecycleFlow.tryEmit(create(ActivityLifecycleEvent.Type.USER_LEAVING)) super.onUserLeaveHint() diff --git a/libraries/rib-android/src/test/kotlin/com/uber/rib/core/RibActivityTest.kt b/libraries/rib-android/src/test/kotlin/com/uber/rib/core/RibActivityTest.kt index a0941ca5..79869089 100644 --- a/libraries/rib-android/src/test/kotlin/com/uber/rib/core/RibActivityTest.kt +++ b/libraries/rib-android/src/test/kotlin/com/uber/rib/core/RibActivityTest.kt @@ -245,13 +245,15 @@ class RibActivityTest { } @Test - fun onBackPressed_whenRouterIsNull_shouldFallThroughToSuper() { - val activity = Robolectric.buildActivity(RootlessActivity::class.java).create(null).get() + fun backPress_whenRouterIsNull_shouldFallThroughToSuper() { + val activity = Robolectric.buildActivity(RootlessActivity::class.java).setup().get() - activity.onBackPressed() + // Back is dispatched through OnBackPressedDispatcher rather than the deprecated + // onBackPressed(), which on androidx.activity 1.13 routes through NavigationEventInput. + activity.onBackPressedDispatcher.onBackPressed() - // With router == null, the elvis-safe delegation returns null, which is != true, - // so the activity's fallback path runs (unhandled back + super.onBackPressed()). + // With router == null, the callback's delegation returns null, which is != true, so the + // fallback path runs (unhandled back + dispatcher fall-through). assertThat(activity.unhandledBackPressedInvocations).isEqualTo(1) assertThat(activity.isFinishing).isTrue() } @@ -321,6 +323,46 @@ class RibActivityTest { } } + /** Activity whose back-press handling and [onUnhandledBackPressed] calls are observable. */ + private class BackPressActivity : RibActivity() { + var unhandledBackPressCount = 0 + private set + + private lateinit var backAwareInteractor: BackAwareInteractor + + override fun onCreate(savedInstanceState: android.os.Bundle?) { + setTheme(R.style.Theme_AppCompat) + super.onCreate(savedInstanceState) + } + + override fun createRouter(parentViewGroup: ViewGroup): ViewRouter<*, *> { + val view = FrameLayout(this) + val presenter = object : ViewPresenter(view) {} + val component: InteractorComponent, *> = mock { + on { presenter() } doReturn presenter + } + backAwareInteractor = BackAwareInteractor(presenter) + return object : + ViewRouter(view, backAwareInteractor, component) {} + } + + override fun onUnhandledBackPressed() { + unhandledBackPressCount++ + } + + fun setHandlesBackPress(handles: Boolean) { + backAwareInteractor.handlesBackPress = handles + } + } + + private class BackAwareInteractor( + presenter: ViewPresenter<*>, + ) : Interactor, FakeRouter<*>>(presenter) { + var handlesBackPress = false + + override fun handleBackPress(): Boolean = handlesBackPress + } + private class EmptyRouter( view: FrameLayout, interactor: Interactor, *>, @@ -343,6 +385,50 @@ class RibActivityTest { } } + @Test + fun backPress_whenRouterHandles_activityRemainsRunning() { + val activity = Robolectric.buildActivity(BackPressActivity::class.java).setup().get() + activity.setHandlesBackPress(true) + + activity.onBackPressedDispatcher.onBackPressed() + + assertThat(activity.isFinishing).isFalse() + assertThat(activity.unhandledBackPressCount).isEqualTo(0) + } + + @Test + fun backPress_whenRouterDoesNotHandle_callsOnUnhandledBackPressed() { + val activity = Robolectric.buildActivity(BackPressActivity::class.java).setup().get() + activity.setHandlesBackPress(false) + + activity.onBackPressedDispatcher.onBackPressed() + + assertThat(activity.unhandledBackPressCount).isEqualTo(1) + } + + @Test + fun backPress_whenRouterDoesNotHandle_activityFinishes() { + val activity = Robolectric.buildActivity(BackPressActivity::class.java).setup().get() + activity.setHandlesBackPress(false) + + activity.onBackPressedDispatcher.onBackPressed() + + assertThat(activity.isFinishing).isTrue() + } + + @Test + fun backPress_dispatchedViaOnBackPressedDispatcher_routerIsConsulted() { + // Regression guard: back press must be wired through OnBackPressedDispatcher so that + // Predictive Back works. If it were only handled via the deprecated onBackPressed() + // override, triggering the dispatcher would bypass the router entirely. + val activity = Robolectric.buildActivity(BackPressActivity::class.java).setup().get() + activity.setHandlesBackPress(false) + + activity.onBackPressedDispatcher.onBackPressed() + + assertThat(activity.unhandledBackPressCount).isEqualTo(1) + } + companion object { private const val TEST_BUNDLE_KEY = "test_bundle_key" private const val TEST_BUNDLE_VALUE = "test_bundle_value"