diff --git a/demos/stack-nav/build.gradle.kts b/demos/stack-nav/build.gradle.kts new file mode 100644 index 000000000..d256a6854 --- /dev/null +++ b/demos/stack-nav/build.gradle.kts @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2025. 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. + */ +plugins { + id("ribs.android.application") +} + +android { + namespace = "com.uber.rib.stacknav" + + defaultConfig { + applicationId = "com.uber.rib.stacknav" + targetSdk = 36 + } +} + +dependencies { + implementation(project(":libraries:rib-android")) + implementation(project(":libraries:rib-router-navigator")) + implementation(libs.androidx.appcompat) +} diff --git a/demos/stack-nav/src/main/AndroidManifest.xml b/demos/stack-nav/src/main/AndroidManifest.xml new file mode 100644 index 000000000..ecc1c4ada --- /dev/null +++ b/demos/stack-nav/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/RootActivity.kt b/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/RootActivity.kt new file mode 100644 index 000000000..e13dce1ca --- /dev/null +++ b/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/RootActivity.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2025. 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.stacknav + +import android.view.ViewGroup +import com.uber.rib.core.RibActivity +import com.uber.rib.core.ViewRouter +import com.uber.rib.stacknav.root.RootInteractor +import com.uber.rib.stacknav.root.RootRouter +import com.uber.rib.stacknav.root.RootView + +class RootActivity : RibActivity() { + + override fun createRouter(parentViewGroup: ViewGroup): ViewRouter<*, *> { + val rootView = RootView(this) + val interactor = RootInteractor() + return RootRouter(rootView, interactor) + } +} diff --git a/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/RootInteractor.kt b/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/RootInteractor.kt new file mode 100644 index 000000000..7c052e3ed --- /dev/null +++ b/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/RootInteractor.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2025. 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.stacknav.root + +import com.uber.rib.core.Bundle +import com.uber.rib.core.EmptyPresenter +import com.uber.rib.core.Interactor +import com.uber.rib.stacknav.root.screen.ScreenInteractor + +/** + * Orchestrates the navigation stack. Pushes the first screen on start, and reacts to each screen's + * request to push the next one. + */ +class RootInteractor : + Interactor(EmptyPresenter()), ScreenInteractor.Listener { + + override fun didBecomeActive(savedInstanceState: Bundle?) { + super.didBecomeActive(savedInstanceState) + router.pushScreen(1) + } + + override fun onPushNextScreen(currentNumber: Int) { + if (currentNumber < MAX_SCREENS) { + router.pushScreen(currentNumber + 1) + } + } + + override fun onBackRequested() { + router.popScreen() + } + + companion object { + const val MAX_SCREENS = 5 + } +} diff --git a/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/RootRouter.kt b/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/RootRouter.kt new file mode 100644 index 000000000..b0f5cdace --- /dev/null +++ b/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/RootRouter.kt @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2025. 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.stacknav.root + +import com.uber.rib.core.BasicViewRouter +import com.uber.rib.core.RouterNavigator +import com.uber.rib.core.StackRouterNavigator +import com.uber.rib.stacknav.root.screen.ScreenInteractor +import com.uber.rib.stacknav.root.screen.ScreenRouter +import com.uber.rib.stacknav.root.screen.ScreenView + +/** + * Manages the screen stack using [StackRouterNavigator]. Each call to [pushScreen] pushes a new + * [ScreenRouter] on top; [handleBackPress] pops it. + */ +class RootRouter( + view: RootView, + interactor: RootInteractor, +) : BasicViewRouter(view, interactor) { + + private val navigator: RouterNavigator = StackRouterNavigator(this) + + fun pushScreen(number: Int) { + val listener = interactor as ScreenInteractor.Listener + navigator.pushState( + ScreenState(number), + object : RouterNavigator.AttachTransition { + override fun buildRouter(): ScreenRouter { + val screenView = ScreenView(view.context, number, RootInteractor.MAX_SCREENS) + return ScreenRouter(screenView, ScreenInteractor(number, listener)) + } + + override fun willAttachToHost( + router: ScreenRouter, + previousState: ScreenState?, + newState: ScreenState, + isPush: Boolean, + ) { + view.addView( + router.view, + android.view.ViewGroup.LayoutParams( + android.view.ViewGroup.LayoutParams.MATCH_PARENT, + android.view.ViewGroup.LayoutParams.MATCH_PARENT, + ), + ) + } + }, + object : RouterNavigator.DetachTransition { + override fun willDetachFromHost( + router: ScreenRouter, + previousState: ScreenState, + newState: ScreenState?, + isPush: Boolean, + ) { + view.removeView(router.view) + } + }, + ) + } + + override fun handleBackPress(): Boolean { + if (navigator.size() > 1) { + navigator.popState() + return true + } + return false + } + + fun popScreen() { + navigator.popState() + } + + override fun willDetach() { + navigator.hostWillDetach() + super.willDetach() + } +} diff --git a/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/RootView.kt b/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/RootView.kt new file mode 100644 index 000000000..a67d497c2 --- /dev/null +++ b/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/RootView.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2025. 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.stacknav.root + +import android.content.Context +import android.widget.FrameLayout + +/** Container that holds whichever screen is currently on top of the stack. */ +class RootView(context: Context) : FrameLayout(context) diff --git a/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/ScreenState.kt b/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/ScreenState.kt new file mode 100644 index 000000000..f33611e93 --- /dev/null +++ b/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/ScreenState.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2025. 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.stacknav.root + +import com.uber.rib.core.RouterNavigatorState + +/** One entry in the navigation stack, identified by screen number. */ +data class ScreenState(val number: Int) : RouterNavigatorState { + override fun stateName() = "screen_$number" +} diff --git a/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/screen/ScreenInteractor.kt b/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/screen/ScreenInteractor.kt new file mode 100644 index 000000000..df903d42b --- /dev/null +++ b/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/screen/ScreenInteractor.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2025. 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.stacknav.root.screen + +import com.uber.rib.core.Bundle +import com.uber.rib.core.EmptyPresenter +import com.uber.rib.core.Interactor + +/** + * Interactor for a single screen. Wires the view's button to [Listener.onPushNextScreen] and cleans + * up on deactivation to avoid leaks. + */ +class ScreenInteractor( + private val screenNumber: Int, + private val listener: Listener, +) : Interactor(EmptyPresenter()) { + + interface Listener { + fun onPushNextScreen(currentNumber: Int) + fun onBackRequested() + } + + override fun didBecomeActive(savedInstanceState: Bundle?) { + super.didBecomeActive(savedInstanceState) + router.view.onNextClicked = { listener.onPushNextScreen(screenNumber) } + router.view.onBackClicked = { listener.onBackRequested() } + } + + override fun willResignActive() { + router.view.onNextClicked = null + router.view.onBackClicked = null + super.willResignActive() + } +} diff --git a/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/screen/ScreenRouter.kt b/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/screen/ScreenRouter.kt new file mode 100644 index 000000000..3226e0298 --- /dev/null +++ b/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/screen/ScreenRouter.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2025. 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.stacknav.root.screen + +import com.uber.rib.core.BasicViewRouter + +/** Leaf router for a single numbered screen. No children. */ +class ScreenRouter( + view: ScreenView, + interactor: ScreenInteractor, +) : BasicViewRouter(view, interactor) diff --git a/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/screen/ScreenView.kt b/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/screen/ScreenView.kt new file mode 100644 index 000000000..7c236e923 --- /dev/null +++ b/demos/stack-nav/src/main/kotlin/com/uber/rib/stacknav/root/screen/ScreenView.kt @@ -0,0 +1,174 @@ +/* + * Copyright (C) 2025. 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.stacknav.root.screen + +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.util.TypedValue +import android.view.Gravity +import android.widget.Button +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat + +/** + * Full-screen view for a single stack entry. Includes a top navigation bar with an explicit back + * button (hidden on the root screen), the screen number in the centre, and a push button. + */ +class ScreenView(context: Context, screenNumber: Int, maxScreens: Int) : FrameLayout(context) { + + var onNextClicked: (() -> Unit)? = null + var onBackClicked: (() -> Unit)? = null + + private val screenColors = + listOf( + Color.parseColor("#1565C0"), // deep blue + Color.parseColor("#2E7D32"), // deep green + Color.parseColor("#E65100"), // deep orange + Color.parseColor("#6A1B9A"), // deep purple + Color.parseColor("#B71C1C"), // deep red + ) + + init { + val bg = screenColors[(screenNumber - 1) % screenColors.size] + setBackgroundColor(bg) + + val dp = { n: Int -> + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, n.toFloat(), resources.displayMetrics) + .toInt() + } + + // ── Top navigation bar ────────────────────────────────────────────────── + val navBar = + LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setBackgroundColor(Color.parseColor("#22000000")) + setPadding(dp(4), dp(4), dp(16), dp(4)) + } + + // Back button: a TextView styled as a tappable nav item + val backBtn = + TextView(context).apply { + text = "‹ Back" + setTextColor(Color.WHITE) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) + typeface = Typeface.DEFAULT_BOLD + setPadding(dp(12), dp(8), dp(16), dp(8)) + visibility = if (screenNumber > 1) VISIBLE else INVISIBLE + isClickable = true + isFocusable = true + setOnClickListener { onBackClicked?.invoke() } + background = null + } + + val navTitle = + TextView(context).apply { + text = "Screen $screenNumber" + setTextColor(Color.WHITE) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) + typeface = Typeface.DEFAULT_BOLD + gravity = Gravity.CENTER + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + } + + // Invisible placeholder mirrors the back button width so the title stays centred + val navEndSpacer = + TextView(context).apply { + text = "‹ Back" + setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) + setPadding(dp(12), dp(8), dp(16), dp(8)) + visibility = INVISIBLE + } + + navBar.addView(backBtn) + navBar.addView(navTitle) + navBar.addView(navEndSpacer) + + // ── Body content ──────────────────────────────────────────────────────── + val bigNumber = + TextView(context).apply { + text = "$screenNumber" + setTextColor(Color.WHITE) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 96f) + typeface = Typeface.DEFAULT_BOLD + gravity = Gravity.CENTER + } + + val depth = + TextView(context).apply { + text = "Depth $screenNumber of $maxScreens" + setTextColor(Color.parseColor("#CCFFFFFF")) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) + gravity = Gravity.CENTER + setPadding(0, dp(4), 0, dp(40)) + } + + val nextButton = + Button(context).apply { + text = + if (screenNumber < maxScreens) "Push Screen ${screenNumber + 1} →" else "Stack is full" + isEnabled = screenNumber < maxScreens + setTextColor(Color.WHITE) + setBackgroundColor(Color.parseColor("#33FFFFFF")) + setPadding(dp(32), dp(12), dp(32), dp(12)) + setOnClickListener { onNextClicked?.invoke() } + } + + val body = + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + addView(bigNumber) + addView(depth) + addView(nextButton) + } + + // ── Root layout: nav bar pinned to top, body fills the rest ───────────── + val root = + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + addView( + navBar, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ), + ) + addView(body, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)) + } + + addView(root, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + + // Push nav-bar content below the status bar on edge-to-edge displays (Android 15+). + // The background colour already extends behind the status bar, which looks intentional. + ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets -> + val statusBarHeight = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top + navBar.setPadding(dp(4), statusBarHeight + dp(4), dp(16), dp(4)) + insets + } + } + + // Insets are dispatched once at window-attach time. Screens pushed later onto the stack + // are attached after that initial dispatch, so we re-request here to guarantee delivery. + override fun onAttachedToWindow() { + super.onAttachedToWindow() + ViewCompat.requestApplyInsets(this) + } +} diff --git a/demos/stack-nav/src/main/res/drawable-hdpi/ub__ic_launcher.png b/demos/stack-nav/src/main/res/drawable-hdpi/ub__ic_launcher.png new file mode 100644 index 000000000..807d48f3c Binary files /dev/null and b/demos/stack-nav/src/main/res/drawable-hdpi/ub__ic_launcher.png differ diff --git a/demos/stack-nav/src/main/res/drawable-mdpi/ub__ic_launcher.png b/demos/stack-nav/src/main/res/drawable-mdpi/ub__ic_launcher.png new file mode 100644 index 000000000..73900cc83 Binary files /dev/null and b/demos/stack-nav/src/main/res/drawable-mdpi/ub__ic_launcher.png differ diff --git a/demos/stack-nav/src/main/res/drawable-xhdpi/ub__ic_launcher.png b/demos/stack-nav/src/main/res/drawable-xhdpi/ub__ic_launcher.png new file mode 100644 index 000000000..4450b84e5 Binary files /dev/null and b/demos/stack-nav/src/main/res/drawable-xhdpi/ub__ic_launcher.png differ diff --git a/demos/stack-nav/src/main/res/drawable-xxhdpi/ub__ic_launcher.png b/demos/stack-nav/src/main/res/drawable-xxhdpi/ub__ic_launcher.png new file mode 100644 index 000000000..0039c30c2 Binary files /dev/null and b/demos/stack-nav/src/main/res/drawable-xxhdpi/ub__ic_launcher.png differ diff --git a/demos/stack-nav/src/main/res/drawable-xxxhdpi/ub__ic_launcher.png b/demos/stack-nav/src/main/res/drawable-xxxhdpi/ub__ic_launcher.png new file mode 100644 index 000000000..e35a057e7 Binary files /dev/null and b/demos/stack-nav/src/main/res/drawable-xxxhdpi/ub__ic_launcher.png differ diff --git a/demos/stack-nav/src/main/res/values/strings.xml b/demos/stack-nav/src/main/res/values/strings.xml new file mode 100644 index 000000000..fa946e9a7 --- /dev/null +++ b/demos/stack-nav/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Stack Nav Demo + diff --git a/demos/stack-nav/src/main/res/values/styles.xml b/demos/stack-nav/src/main/res/values/styles.xml new file mode 100644 index 000000000..74c694c31 --- /dev/null +++ b/demos/stack-nav/src/main/res/values/styles.xml @@ -0,0 +1,3 @@ + +