diff --git a/Jetchat/README.md b/Jetchat/README.md index d94bdfb81e..ffc2856ada 100644 --- a/Jetchat/README.md +++ b/Jetchat/README.md @@ -12,7 +12,7 @@ project from Android Studio following the steps This sample showcases: * UI state management -* Integration with Architecture Components: Navigation, Fragments, LiveData, ViewModel +* Integration with Architecture Components: Navigation, Fragments, ViewModel * Back button handling * Text Input and focus management * Multiple types of animations and transitions @@ -81,6 +81,8 @@ Tracked in https://issuetracker.google.com/164859446 2. There are only two profiles, clicking on anybody except "me" will show the same data. +3. The app crashes if the text field is focused and the user navigates back. + https://issuetracker.google.com/165034731 ## License ``` diff --git a/Jetchat/app/build.gradle b/Jetchat/app/build.gradle index 94ea28d959..8c21671ec9 100644 --- a/Jetchat/app/build.gradle +++ b/Jetchat/app/build.gradle @@ -67,6 +67,7 @@ android { buildFeatures { compose true + viewBinding true // Disable unused AGP features buildConfig false @@ -98,7 +99,7 @@ dependencies { implementation Libs.AndroidX.Compose.materialIconsExtended implementation Libs.AndroidX.Compose.tooling implementation Libs.AndroidX.Compose.runtime - implementation Libs.AndroidX.Compose.runtimeLivedata + implementation Libs.AndroidX.Compose.viewBinding androidTestImplementation Libs.junit androidTestImplementation Libs.AndroidX.Test.core diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/MainViewModel.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/MainViewModel.kt new file mode 100644 index 0000000000..3d18000fd4 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/MainViewModel.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * 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 + * + * https://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.example.compose.jetchat + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Used to communicate between screens. + */ +class MainViewModel : ViewModel() { + + private val _drawerShouldBeOpened = MutableStateFlow(false) + val drawerShouldBeOpened: StateFlow = _drawerShouldBeOpened + + fun openDrawer() { + _drawerShouldBeOpened.value = true + } + fun resetOpenDrawer() { + _drawerShouldBeOpened.value = false + } +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt index 47f5c557c5..3698c66a6e 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt @@ -17,72 +17,74 @@ package com.example.compose.jetchat import android.os.Bundle -import android.view.Menu +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.GravityCompat -import androidx.drawerlayout.widget.DrawerLayout +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Providers +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.setContent +import androidx.compose.ui.viewinterop.AndroidViewBinding +import androidx.core.os.bundleOf import androidx.navigation.findNavController -import androidx.navigation.ui.AppBarConfiguration -import androidx.navigation.ui.navigateUp -import androidx.navigation.ui.setupWithNavController -import com.google.android.material.navigation.NavigationView +import com.example.compose.jetchat.components.JetchatScaffold +import com.example.compose.jetchat.conversation.BackPressedDispatcherAmbient +import com.example.compose.jetchat.conversation.backPressHandler +import com.example.compose.jetchat.databinding.ContentMainBinding /** - * Main activity for the app. Shows a drawer and a toolbar rendered with traditional Views, for now. + * Main activity for the app. */ class NavActivity : AppCompatActivity() { - private lateinit var appBarConfiguration: AppBarConfiguration - private lateinit var drawerLayout: DrawerLayout + // Used for navigation events between fragments. + private val viewModel: MainViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) + setContent { + Providers(BackPressedDispatcherAmbient provides this) { + val scaffoldState = rememberScaffoldState() - drawerLayout = findViewById(R.id.drawer_layout) - val navView: NavigationView = findViewById(R.id.nav_view) - val navController = findNavController(R.id.nav_host_fragment) - // Passing each menu ID as a set of Ids because each - // menu should be considered as top level destinations. - appBarConfiguration = AppBarConfiguration( - setOf( - R.id.nav_home, - R.id.nav_profile - ), - drawerLayout - ) - navView.setupWithNavController(navController) - } + val openDrawerEvent = viewModel.drawerShouldBeOpened.collectAsState() + if (openDrawerEvent.value) { + // Open drawer and reset state in VM. + scaffoldState.drawerState.open { + viewModel.resetOpenDrawer() + } + } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - // Inflate the menu; this adds items to the action bar if it is present. - menuInflater.inflate(R.menu.main, menu) - return true - } + // Intercepts back navigation when the drawer is open + backPressHandler( + enabled = scaffoldState.drawerState.isOpen, + onBackPressed = { scaffoldState.drawerState.close() }, + highPriority = true + ) - override fun onSupportNavigateUp(): Boolean { - val navController = findNavController(R.id.nav_host_fragment) - return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp() - } - - /** - * Back closes drawer if open. - */ - override fun onBackPressed() { - if (drawerLayout.isDrawerOpen(GravityCompat.START)) { - drawerLayout.closeDrawer(GravityCompat.START) - } else { - super.onBackPressed() + JetchatScaffold( + scaffoldState, + onChatClicked = { + findNavController(R.id.nav_host_fragment) + .popBackStack(R.id.nav_home, true) + scaffoldState.drawerState.close() + }, + onProfileClicked = { + val bundle = bundleOf("userId" to it) + findNavController(R.id.nav_host_fragment).navigate( + R.id.nav_profile, + bundle + ) + scaffoldState.drawerState.close() + } + ) { + // Inflate the XML layout using View Binding: + AndroidViewBinding(ContentMainBinding::inflate) + } + } } } - /** - * Opens the drawer if present. - * - * ]TODO: Replace with compose Scaffold. - */ - fun openDrawer() { - val drawer = findViewById(R.id.drawer_layout) - drawer?.openDrawer(GravityCompat.START) + override fun onSupportNavigateUp(): Boolean { + val navController = findNavController(R.id.nav_host_fragment) + return navController.navigateUp() || super.onSupportNavigateUp() } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt new file mode 100644 index 0000000000..7056068cd8 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt @@ -0,0 +1,170 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * 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 + * + * https://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.example.compose.jetchat.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.AmbientContentColor +import androidx.compose.foundation.Image +import androidx.compose.foundation.Text +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.preferredHeight +import androidx.compose.foundation.layout.preferredSize +import androidx.compose.foundation.layout.preferredWidth +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.AmbientEmphasisLevels +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ProvideEmphasis +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.imageResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import androidx.ui.tooling.preview.Preview +import com.example.compose.jetchat.R +import com.example.compose.jetchat.data.colleagueProfile +import com.example.compose.jetchat.data.meProfile +import com.example.compose.jetchat.theme.JetchatTheme + +@Composable +fun ColumnScope.JetchatDrawer(onProfileClicked: (String) -> Unit, onChatClicked: (String) -> Unit) { + DrawerHeader() + Divider() + DrawerItemHeader("Chats") + ChatItem("composers", true) { onChatClicked("composers") } + ChatItem("droidcon-nyc", false) { onChatClicked("droidcon-nyc") } + DrawerItemHeader("Recent Profiles") + ProfileItem("Ali Conors (you)", meProfile.photo) { onProfileClicked(meProfile.userId) } + ProfileItem("Taylor Brooks", colleagueProfile.photo) { onProfileClicked(colleagueProfile.userId) } +} + +@Composable +private fun DrawerHeader() { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = CenterVertically) { + Image( + vectorResource(id = R.drawable.ic_jetchat), + modifier = Modifier.preferredSize(24.dp) + ) + Image( + vectorResource(id = R.drawable.jetchat_logo), + modifier = Modifier.padding(start = 8.dp) + ) + } +} +@Composable +private fun DrawerItemHeader(text: String) { + ProvideEmphasis(emphasis = AmbientEmphasisLevels.current.medium) { + Text(text, style = MaterialTheme.typography.caption, modifier = Modifier.padding(16.dp)) + } +} + +@Composable +private fun ChatItem(text: String, selected: Boolean, onChatClicked: () -> Unit) { + val background = if (selected) { + Modifier.background(MaterialTheme.colors.primary.copy(alpha = 0.08f)) + } else { + Modifier + } + Row( + modifier = Modifier + .preferredHeight(48.dp) + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .then(background) + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = onChatClicked), + verticalAlignment = CenterVertically + ) { + val mediumEmphasisOnSurface = AmbientEmphasisLevels.current.medium + .applyEmphasis(MaterialTheme.colors.onSurface) + Icon( + vectorResource(id = R.drawable.ic_jetchat), + tint = if (selected) MaterialTheme.colors.primary else mediumEmphasisOnSurface, + modifier = Modifier.padding(8.dp) + ) + ProvideEmphasis(emphasis = AmbientEmphasisLevels.current.medium) { + Text( + text, + style = MaterialTheme.typography.body2, + color = if (selected) MaterialTheme.colors.primary else AmbientContentColor.current, + modifier = Modifier.padding(8.dp) + ) + } + } +} + +@Composable +private fun ProfileItem(text: String, @DrawableRes profilePic: Int?, onProfileClicked: () -> Unit) { + Row( + modifier = Modifier + .preferredHeight(48.dp) + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = onProfileClicked), + verticalAlignment = CenterVertically + ) { + ProvideEmphasis(emphasis = AmbientEmphasisLevels.current.medium) { + val widthPaddingModifier = Modifier.preferredWidth(24.dp).padding(8.dp) + if (profilePic != null) { + Image( + imageResource(id = profilePic), + modifier = widthPaddingModifier.then(Modifier.clip(CircleShape)), + contentScale = ContentScale.Crop + ) + } else { + Spacer(modifier = widthPaddingModifier) + } + Text(text, style = MaterialTheme.typography.body2, modifier = Modifier.padding(8.dp)) + } + } +} + +@Composable +@Preview +fun DrawerPreview() { + JetchatTheme { + Surface { + Column { + JetchatDrawer({}, {}) + } + } + } +} +@Composable +@Preview +fun DrawerPreviewDark() { + JetchatTheme(isDarkTheme = true) { + Surface { + Column { + JetchatDrawer({}, {}) + } + } + } +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatScaffold.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatScaffold.kt new file mode 100644 index 0000000000..21d025ae36 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatScaffold.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * 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 + * + * https://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.example.compose.jetchat.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material.Scaffold +import androidx.compose.material.ScaffoldState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import com.example.compose.jetchat.theme.JetchatTheme + +@Composable +fun JetchatScaffold( + scaffoldState: ScaffoldState = rememberScaffoldState(), + onProfileClicked: (String) -> Unit, + onChatClicked: (String) -> Unit, + content: @Composable (PaddingValues) -> Unit +) { + JetchatTheme { + Scaffold( + scaffoldState = scaffoldState, + drawerContent = { + JetchatDrawer( + onProfileClicked = onProfileClicked, + onChatClicked = onChatClicked, + + ) + }, + bodyContent = content + ) + } +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/BackHandler.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/BackHandler.kt index 6aedac3dff..54891e481f 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/BackHandler.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/BackHandler.kt @@ -27,9 +27,18 @@ import androidx.compose.runtime.staticAmbientOf /** * This [Composable] can be used with a [BackPressedDispatcherAmbient] to intercept a back press (if * [enabled]). + * + * @param onBackPressed (Event) What to do when back is intercepted + * @param enabled (state) When to intercept the back navigation + * @param highPriority (config) Used to make sure this is the first handler in the dispatcher + * */ @Composable -fun backPressHandler(onBackPressed: () -> Unit, enabled: Boolean = true) { +fun backPressHandler( + onBackPressed: () -> Unit, + enabled: Boolean = true, + highPriority: Boolean = false +) { val dispatcher = BackPressedDispatcherAmbient.current.onBackPressedDispatcher // This callback is going to be remembered only if onBackPressed is referentially equal. @@ -43,7 +52,13 @@ fun backPressHandler(onBackPressed: () -> Unit, enabled: Boolean = true) { // Using onCommit guarantees that failed transactions don't incorrectly toggle the // remembered callback. - onCommit(enabled) { + onCommit(enabled, highPriority) { + if (enabled && highPriority) { + // Since the Navigation Component is also intercepting the back event, make sure + // that this is the first callback in the dispatcher. + backCallback.remove() + dispatcher.addCallback(backCallback) + } backCallback.isEnabled = enabled } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt index 2ae27db4e4..a66696af5d 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt @@ -24,14 +24,17 @@ import androidx.compose.runtime.Providers import androidx.compose.ui.platform.ComposeView import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels import androidx.navigation.findNavController -import com.example.compose.jetchat.NavActivity +import com.example.compose.jetchat.MainViewModel import com.example.compose.jetchat.R import com.example.compose.jetchat.data.exampleUiState import com.example.compose.jetchat.theme.JetchatTheme class ConversationFragment : Fragment() { + private val activityViewModel: MainViewModel by activityViewModels() + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -52,8 +55,7 @@ class ConversationFragment : Fragment() { ) }, onNavIconPressed = { - // TODO: Replace with Scaffold - (activity as? NavActivity)?.openDrawer() + activityViewModel.openDrawer() } ) } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt index 484b1a26c0..7c4e092263 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt @@ -21,16 +21,18 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.collectAsState import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels -import com.example.compose.jetchat.NavActivity +import com.example.compose.jetchat.MainViewModel import com.example.compose.jetchat.theme.JetchatTheme class ProfileFragment : Fragment() { private val viewModel: ProfileViewModel by viewModels() + private val activityViewModel: MainViewModel by activityViewModels() override fun onAttach(context: Context) { super.onAttach(context) @@ -47,7 +49,7 @@ class ProfileFragment : Fragment() { return ComposeView(context = requireContext()).apply { setContent { - viewModel.userData.observeAsState().value.let { userData: ProfileScreenState? -> + viewModel.userData.collectAsState().value.let { userData: ProfileScreenState? -> JetchatTheme { if (userData == null) { ProfileError() @@ -55,8 +57,7 @@ class ProfileFragment : Fragment() { ProfileScreen( userData = userData, onNavIconPressed = { - // TODO: Replace with Scaffold - (activity as? NavActivity)?.openDrawer() + activityViewModel.openDrawer() } ) } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileViewModel.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileViewModel.kt index 792b5db25a..5decf4c4d8 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileViewModel.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileViewModel.kt @@ -18,11 +18,11 @@ package com.example.compose.jetchat.profile import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.example.compose.jetchat.data.colleagueProfile import com.example.compose.jetchat.data.meProfile +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow class ProfileViewModel : ViewModel() { @@ -35,8 +35,8 @@ class ProfileViewModel : ViewModel() { _userData.value = if (userId == meProfile.userId) meProfile else colleagueProfile } - private val _userData = MutableLiveData() - val userData: LiveData = _userData + private val _userData = MutableStateFlow(null) + val userData: StateFlow = _userData } @Immutable diff --git a/Jetchat/app/src/main/res/layout/content_main.xml b/Jetchat/app/src/main/res/layout/content_main.xml index ad0f971e35..61198607ea 100644 --- a/Jetchat/app/src/main/res/layout/content_main.xml +++ b/Jetchat/app/src/main/res/layout/content_main.xml @@ -15,20 +15,13 @@ ~ limitations under the License. --> - - - - + app:defaultNavHost="true" + app:navGraph="@navigation/mobile_navigation" /> diff --git a/Jetchat/app/src/main/res/menu/main.xml b/Jetchat/app/src/main/res/menu/main.xml deleted file mode 100644 index a73dd2bfa8..0000000000 --- a/Jetchat/app/src/main/res/menu/main.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - diff --git a/Jetchat/buildSrc/src/main/java/com/example/compose/jetchat/buildsrc/dependencies.kt b/Jetchat/buildSrc/src/main/java/com/example/compose/jetchat/buildsrc/dependencies.kt index 6f71928609..a4bd5776ef 100644 --- a/Jetchat/buildSrc/src/main/java/com/example/compose/jetchat/buildsrc/dependencies.kt +++ b/Jetchat/buildSrc/src/main/java/com/example/compose/jetchat/buildsrc/dependencies.kt @@ -59,6 +59,7 @@ object Libs { const val tooling = "androidx.ui:ui-tooling:$version" const val test = "androidx.compose.test:test-core:$version" const val uiTest = "androidx.ui:ui-test:$version" + const val viewBinding = "androidx.compose.ui:ui-viewbinding:$version" } object Navigation {