106: Decouple navigation from UI composables r=msfjarvis a=Skrilltrax

Navigation library recommends to decouple the navigation from the composables. They specially points to pass lambdas instead of passing the NavController directly.

https://developer.android.com/jetpack/compose/navigation#testing

Co-authored-by: Aditya Wasan <adityawasan55@gmail.com>
Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
bors[bot] 2021-02-12 08:20:28 +00:00 committed by GitHub
commit 9f88bf15ab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 185 additions and 41 deletions

View file

@ -18,13 +18,24 @@ jobs:
with:
java-version: '11'
- name: Checkout repository
uses: actions/checkout@c952173edf28a2bd22e1a4926590c1ac39630461
uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f
- uses: burrunan/gradle-cache-action@v1
- uses: burrunan/gradle-cache-action@03c71a8ba93d670980695505f48f49daf43704a6
name: Run unit tests
with:
arguments: testDebug --stacktrace
- name: Run instrumentation tests
uses: reactivecircus/android-emulator-runner@08b092e904025fada32a01b711af1e7ff7b7a4a3
with:
api-level: 23
target: default
script: |
adb shell settings put global animator_duration_scale 0
adb shell settings put global transition_animation_scale 0
adb shell settings put global window_animation_scale 0
./gradlew :app:connectedDebugAndroidTest
- name: (Fail-only) upload test report
if: failure()
uses: actions/upload-artifact@27bce4eee761b5bc643f46a8dfb41b430c8d05f6

View file

@ -48,6 +48,7 @@ dependencies {
implementation(Dependencies.ThirdParty.accompanist)
implementation(Dependencies.ThirdParty.Moshi.lib)
testImplementation(Dependencies.Testing.junit)
androidTestImplementation(Dependencies.AndroidX.Compose.activity)
androidTestImplementation(Dependencies.Testing.daggerHilt)
androidTestImplementation(Dependencies.Testing.uiTest)
androidTestImplementation(Dependencies.Testing.AndroidX.Compose.uiTestJunit4)
}

View file

@ -0,0 +1,115 @@
package dev.msfjarvis.lobsters
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertContentDescriptionEquals
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onChildAt
import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import dev.msfjarvis.lobsters.ui.main.LobstersBottomNav
import dev.msfjarvis.lobsters.ui.navigation.Destination
import dev.msfjarvis.lobsters.ui.theme.LobstersTheme
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class BottomNavigationLayoutTest {
@get:Rule
val composeTestRule = createComposeRule()
@Before
fun setUp() {
composeTestRule.setContent {
LobstersTheme {
var mutableDestination by remember { mutableStateOf(Destination.startDestination) }
LobstersBottomNav(
currentDestination = mutableDestination,
navigateToDestination = { mutableDestination = it },
jumpToIndex = {}
)
}
}
}
@Test
fun bottomNavItemCountTest() {
// Test to make sure total items are equal to enum objects present in Destination
composeTestRule.onNodeWithTag("LobstersBottomNav")
.assertExists()
.assertIsDisplayed()
.onChildren()
.assertCountEquals(Destination.values().size)
}
@Test
fun bottomNavItemTest() {
// Check hottest BottomNavItem is rendered correctly
composeTestRule.onNodeWithTag("LobstersBottomNav")
.assertExists()
.assertIsDisplayed()
.onChildAt(0)
.assertTextEquals("Hottest")
.assertContentDescriptionEquals("Hottest")
.assertHasClickAction()
// Check saved BottomNavItem is rendered correctly
composeTestRule.onNodeWithTag("LobstersBottomNav")
.assertExists()
.assertIsDisplayed()
.onChildAt(1)
.assertTextEquals("Saved")
.assertContentDescriptionEquals("Saved")
.assertHasClickAction()
}
@Test
fun bottomNavItemSelectedTest() {
// Check hottest BottomNav item is selected
composeTestRule.onNodeWithTag("LobstersBottomNav")
.assertExists()
.assertIsDisplayed()
.onChildAt(0)
.assertIsSelected()
.assertTextEquals("Hottest")
// Check saved BottomNav item is not selected
composeTestRule.onNodeWithTag("LobstersBottomNav")
.assertExists()
.assertIsDisplayed()
.onChildAt(1)
.assertIsNotSelected()
// Select the saved BottomNav item
composeTestRule.onNodeWithTag("LobstersBottomNav")
.onChildAt(1)
.performClick()
// Check hottest BottomNav item is not selected
composeTestRule.onNodeWithTag("LobstersBottomNav")
.assertExists()
.assertIsDisplayed()
.onChildAt(0)
.assertIsNotSelected()
// Check saved BottomNav item is selected
composeTestRule.onNodeWithTag("LobstersBottomNav")
.assertExists()
.assertIsDisplayed()
.onChildAt(1)
.assertIsSelected()
.assertTextEquals("Saved")
}
}

View file

@ -4,7 +4,6 @@ import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.BottomNavigation
import androidx.compose.material.BottomNavigationItem
@ -16,9 +15,9 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.KEY_ROUTE
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@ -31,13 +30,12 @@ import dev.msfjarvis.lobsters.ui.navigation.Destination
import dev.msfjarvis.lobsters.ui.posts.HottestPosts
import dev.msfjarvis.lobsters.ui.posts.SavedPosts
import dev.msfjarvis.lobsters.ui.theme.LobstersTheme
import dev.msfjarvis.lobsters.ui.urllauncher.UrlLauncher
import dev.msfjarvis.lobsters.ui.urllauncher.LocalUrlLauncher
import dev.msfjarvis.lobsters.ui.urllauncher.UrlLauncher
import dev.msfjarvis.lobsters.ui.viewmodel.LobstersViewModel
import dev.msfjarvis.lobsters.util.IconResource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlinx.coroutines.launch
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@ -64,12 +62,28 @@ fun LobstersApp() {
val savedPosts by viewModel.savedPosts.collectAsState()
val hottestPostsListState = rememberLazyListState()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute =
navBackStackEntry?.arguments?.getString(KEY_ROUTE) ?: Destination.startDestination.route
val currentDestination = Destination.getDestinationFromRoute(currentRoute)
val navigateToDestination: (destination: Destination) -> Unit = { destination ->
navController.navigate(destination.route) {
launchSingleTop = true
popUpTo(navController.graph.startDestination) { inclusive = false }
}
}
val jumpToIndex: (Int) -> Unit = {
coroutineScope.launch {
hottestPostsListState.snapToItemIndex(it)
}
}
Scaffold(
bottomBar = {
LobstersBottomNav(
navController,
hottestPostsListState,
coroutineScope,
currentDestination,
navigateToDestination,
jumpToIndex,
)
},
) { innerPadding ->
@ -96,14 +110,11 @@ fun LobstersApp() {
@Composable
fun LobstersBottomNav(
navController: NavHostController,
hottestPostsListState: LazyListState,
coroutineScope: CoroutineScope,
currentDestination: Destination,
navigateToDestination: (destination: Destination) -> Unit,
jumpToIndex: (index: Int) -> Unit,
) {
BottomNavigation {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute =
navBackStackEntry?.arguments?.getString(KEY_ROUTE) ?: Destination.startDestination.route
BottomNavigation(modifier = Modifier.testTag("LobstersBottomNav")) {
Destination.values().forEach { screen ->
BottomNavigationItem(
icon = {
@ -113,18 +124,13 @@ fun LobstersBottomNav(
)
},
label = { Text(stringResource(id = screen.labelRes)) },
selected = currentRoute == screen.route,
selected = currentDestination == screen,
alwaysShowLabels = false,
onClick = {
if (screen.route != currentRoute) {
navController.navigate(screen.route) {
launchSingleTop = true
popUpTo(navController.graph.startDestination) { inclusive = false }
}
if (screen != currentDestination) {
navigateToDestination(screen)
} else if (screen.route == Destination.Hottest.route) {
coroutineScope.launch {
hottestPostsListState.snapToItemIndex(0)
}
jumpToIndex(0)
}
}
)

View file

@ -18,5 +18,9 @@ enum class Destination(
companion object {
val startDestination = Hottest
fun getDestinationFromRoute(route: String): Destination {
return values().firstOrNull { it.route == route } ?: error("Incorrect route passed")
}
}
}

View file

@ -9,12 +9,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dev.msfjarvis.lobsters.data.local.LobstersPost
import dev.msfjarvis.lobsters.data.remote.LobstersPagingSource
import dev.msfjarvis.lobsters.data.repo.LobstersRepository
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class LobstersViewModel @Inject constructor(

View file

@ -107,6 +107,8 @@ internal fun TestedExtension.configureCommonAndroidOptions() {
exclude("**/*.txt")
exclude("**/*.kotlin_module")
exclude("**/plugin.properties")
exclude("META-INF/AL2.0")
exclude("META-INF/LGPL2.1")
}
compileOptions {

View file

@ -8,7 +8,8 @@ private const val DAGGER_HILT_VERSION = "2.32-alpha"
object Plugins {
const val androidGradlePlugin = "com.android.tools.build:gradle:7.0.0-alpha05"
const val androidGradlePlugin_lintModel = "com.android.tools.lint:lint-model:30.0.0-alpha05"
const val daggerGradlePlugin = "com.google.dagger:hilt-android-gradle-plugin:${DAGGER_HILT_VERSION}"
const val daggerGradlePlugin =
"com.google.dagger:hilt-android-gradle-plugin:${DAGGER_HILT_VERSION}"
const val kotlinGradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.30"
const val jsemver = "com.github.zafarkhaja:java-semver:0.9.0"
const val sqldelightGradlePlugin = "com.squareup.sqldelight:gradle-plugin:1.4.4"
@ -37,7 +38,8 @@ object Dependencies {
const val activity = "androidx.activity:activity-compose:1.3.0-alpha02"
const val compiler = "androidx.compose.compiler:compiler:$COMPOSE_VERSION"
const val constraintLayout = "androidx.constraintlayout:constraintlayout-compose:1.0.0-alpha02"
const val constraintLayout =
"androidx.constraintlayout:constraintlayout-compose:1.0.0-alpha02"
const val foundation = "androidx.compose.foundation:foundation:$COMPOSE_VERSION"
const val foundationLayout = "androidx.compose.foundation:foundation-layout:$COMPOSE_VERSION"
const val lifecycleViewModel = "androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha01"
@ -94,13 +96,16 @@ object Dependencies {
const val daggerHilt = "com.google.dagger:hilt-android-testing:$DAGGER_HILT_VERSION"
const val junit = "junit:junit:4.13.1"
const val mockWebServer = "com.squareup.okhttp3:mockwebserver3-junit4:5.0.0-alpha.2"
const val uiTest = "androidx.compose.ui:ui-test:$COMPOSE_VERSION"
object AndroidX {
private const val version = "1.3.1-alpha02"
const val runner = "androidx.test:runner:$version"
const val rules = "androidx.test:rules:$version"
object Compose {
const val uiTestJunit4 = "androidx.compose.ui:ui-test-junit4:$COMPOSE_VERSION"
}
}
}
}

View file

@ -4,8 +4,8 @@
*/
import com.android.build.gradle.internal.dsl.BaseAppModuleExtension
import org.gradle.api.Project
import java.util.*
import org.gradle.api.Project
private const val KEYSTORE_CONFIG_PATH = "keystore.properties"

View file

@ -6,10 +6,10 @@
import com.android.build.gradle.internal.plugins.AppPlugin
import com.github.zafarkhaja.semver.Version
import org.gradle.api.Plugin
import org.gradle.api.Project
import java.io.OutputStream
import java.util.*
import org.gradle.api.Plugin
import org.gradle.api.Project
private const val VERSIONING_PROP_FILE = "version.properties"
private const val VERSIONING_PROP_VERSION_NAME = "versioning-plugin.versionName"