Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ android {
compileSdk = 36

defaultConfig {
minSdk = 21
minSdk = 23
targetSdk = 35
versionCode = 1
versionName = "1.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ android {
compileSdk = 36

defaultConfig {
minSdk = 21
minSdk = 23
}

compileOptions {
Expand Down
3 changes: 2 additions & 1 deletion demos/intellij/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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">
<activity
android:name="com.uber.rib.RootActivity"
android:exported="true">
Expand Down
3 changes: 2 additions & 1 deletion demos/stack-nav/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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">
<activity
android:name="com.uber.rib.stacknav.RootActivity"
android:exported="true">
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions libraries/rib-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -50,6 +51,39 @@ public abstract class RibActivity :
RxActivityEvents {
private var router: ViewRouter<*, *>? = null

private val ribBackPressCallback =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd consider this class implementing this interface to save the object allocation; not sure if that is still relevant on modern android, but it doesn't hurt

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<ActivityLifecycleEvent>(1, 0, BufferOverflow.DROP_OLDEST)

Expand Down Expand Up @@ -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<ViewGroup>(android.R.id.content)
_lifecycleFlow.tryEmit(createOnCreateEvent(savedInstanceState))
val wrappedBundle: Bundle? =
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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>(view) {}
val component: InteractorComponent<ViewPresenter<*>, *> = mock {
on { presenter() } doReturn presenter
}
backAwareInteractor = BackAwareInteractor(presenter)
return object :
ViewRouter<FrameLayout, BackAwareInteractor>(view, backAwareInteractor, component) {}
}

override fun onUnhandledBackPressed() {
unhandledBackPressCount++
}

fun setHandlesBackPress(handles: Boolean) {
backAwareInteractor.handlesBackPress = handles
}
}

private class BackAwareInteractor(
presenter: ViewPresenter<*>,
) : Interactor<ViewPresenter<*>, FakeRouter<*>>(presenter) {
var handlesBackPress = false

override fun handleBackPress(): Boolean = handlesBackPress
}

private class EmptyRouter(
view: FrameLayout,
interactor: Interactor<ViewPresenter<*>, *>,
Expand All @@ -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"
Expand Down
Loading