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: with:
java-version: '11' java-version: '11'
- name: Checkout repository - 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 name: Run unit tests
with: with:
arguments: testDebug --stacktrace 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 - name: (Fail-only) upload test report
if: failure() if: failure()
uses: actions/upload-artifact@27bce4eee761b5bc643f46a8dfb41b430c8d05f6 uses: actions/upload-artifact@27bce4eee761b5bc643f46a8dfb41b430c8d05f6

View file

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

View file

@ -18,5 +18,9 @@ enum class Destination(
companion object { companion object {
val startDestination = Hottest 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.local.LobstersPost
import dev.msfjarvis.lobsters.data.remote.LobstersPagingSource import dev.msfjarvis.lobsters.data.remote.LobstersPagingSource
import dev.msfjarvis.lobsters.data.repo.LobstersRepository import dev.msfjarvis.lobsters.data.repo.LobstersRepository
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class LobstersViewModel @Inject constructor( class LobstersViewModel @Inject constructor(

View file

@ -39,12 +39,12 @@ internal fun Project.configureForAllProjects() {
google() google()
mavenCentral() mavenCentral()
jcenter() { jcenter() {
content { content {
// Indirect dependencies // Indirect dependencies
// https://youtrack.jetbrains.com/issue/IDEA-261387 // https://youtrack.jetbrains.com/issue/IDEA-261387
includeModule("org.jetbrains.trove4j", "trove4j") includeModule("org.jetbrains.trove4j", "trove4j")
includeGroup("org.jetbrains.kotlinx") includeGroup("org.jetbrains.kotlinx")
} }
} }
} }
tasks.withType<KotlinCompile> { tasks.withType<KotlinCompile> {
@ -107,6 +107,8 @@ internal fun TestedExtension.configureCommonAndroidOptions() {
exclude("**/*.txt") exclude("**/*.txt")
exclude("**/*.kotlin_module") exclude("**/*.kotlin_module")
exclude("**/plugin.properties") exclude("**/plugin.properties")
exclude("META-INF/AL2.0")
exclude("META-INF/LGPL2.1")
} }
compileOptions { compileOptions {

View file

@ -8,7 +8,8 @@ private const val DAGGER_HILT_VERSION = "2.32-alpha"
object Plugins { object Plugins {
const val androidGradlePlugin = "com.android.tools.build:gradle:7.0.0-alpha05" 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 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 kotlinGradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.30"
const val jsemver = "com.github.zafarkhaja:java-semver:0.9.0" const val jsemver = "com.github.zafarkhaja:java-semver:0.9.0"
const val sqldelightGradlePlugin = "com.squareup.sqldelight:gradle-plugin:1.4.4" 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 activity = "androidx.activity:activity-compose:1.3.0-alpha02"
const val compiler = "androidx.compose.compiler:compiler:$COMPOSE_VERSION" 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 foundation = "androidx.compose.foundation:foundation:$COMPOSE_VERSION"
const val foundationLayout = "androidx.compose.foundation:foundation-layout:$COMPOSE_VERSION" const val foundationLayout = "androidx.compose.foundation:foundation-layout:$COMPOSE_VERSION"
const val lifecycleViewModel = "androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha01" 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 daggerHilt = "com.google.dagger:hilt-android-testing:$DAGGER_HILT_VERSION"
const val junit = "junit:junit:4.13.1" const val junit = "junit:junit:4.13.1"
const val mockWebServer = "com.squareup.okhttp3:mockwebserver3-junit4:5.0.0-alpha.2" 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 { object AndroidX {
private const val version = "1.3.1-alpha02" private const val version = "1.3.1-alpha02"
const val runner = "androidx.test:runner:$version" 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 com.android.build.gradle.internal.dsl.BaseAppModuleExtension
import org.gradle.api.Project
import java.util.* import java.util.*
import org.gradle.api.Project
private const val KEYSTORE_CONFIG_PATH = "keystore.properties" private const val KEYSTORE_CONFIG_PATH = "keystore.properties"

View file

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