redesign home screen for improvement large screen support

This commit is contained in:
Owen LeJeune
2022-06-15 23:15:37 -04:00
parent eddbf65720
commit a4b7fe91a0
7 changed files with 359 additions and 38 deletions

View File

@@ -63,6 +63,10 @@ dependencies {
def androidx = "1.0.0"
implementation "androidx.window:window:$androidx"
// material
def material = "1.6.1"
implementation "com.google.android.material:material:$material"
// compose
def compose = composeVersion
def compose_material3 = "1.0.0-alpha13"

View File

@@ -1,27 +1,57 @@
package com.owenlejeune.tvtime
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material.Scaffold
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.kieronquinn.monetcompat.app.MonetCompatActivity
import com.owenlejeune.tvtime.ui.navigation.MainNavigationRoutes
import com.owenlejeune.tvtime.extensions.WindowSizeClass
import com.owenlejeune.tvtime.extensions.rememberWindowSizeClass
import com.owenlejeune.tvtime.preferences.AppPreferences
import com.owenlejeune.tvtime.ui.components.RoundedTextField
import com.owenlejeune.tvtime.ui.components.SearchFab
import com.owenlejeune.tvtime.ui.navigation.BottomNavItem
import com.owenlejeune.tvtime.ui.navigation.MainNavGraph
import com.owenlejeune.tvtime.ui.navigation.MainNavItem
import com.owenlejeune.tvtime.ui.screens.MediaDetailView
import com.owenlejeune.tvtime.ui.screens.MediaViewType
import com.owenlejeune.tvtime.ui.screens.PersonDetailView
import com.owenlejeune.tvtime.ui.theme.TVTimeTheme
import com.owenlejeune.tvtime.utils.KeyboardManager
import com.owenlejeune.tvtime.utils.SessionManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent.get
@OptIn(ExperimentalMaterial3Api::class)
class MainActivity : MonetCompatActivity() {
private val searchableScreens = listOf(BottomNavItem.Movies.route, BottomNavItem.TV.route, BottomNavItem.People.route)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -34,41 +64,322 @@ class MainActivity : MonetCompatActivity() {
setContent {
AppKeyboardFocusManager()
TVTimeTheme(monetCompat = monet) {
MyApp(
appNavController = rememberNavController()
val appNavController = rememberNavController()
Box {
MainNavigationRoutes(appNavController = appNavController)
}
}
}
}
}
@Composable
private fun AppScaffold(appNavController: NavHostController, preferences: AppPreferences = get(AppPreferences::class.java)) {
val windowSize = rememberWindowSizeClass()
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val appBarTitle = rememberSaveable { mutableStateOf(BottomNavItem.getByRoute(currentRoute)?.name ?: BottomNavItem.Items[0].name) }
val decayAnimationSpec = rememberSplineBasedDecay<Float>()
val topAppBarScrollState = rememberTopAppBarScrollState()
val scrollBehavior = remember(decayAnimationSpec) {
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(decayAnimationSpec, topAppBarScrollState)
}
val focusRequester = remember { FocusRequester() }
val focusSearchBar = rememberSaveable { mutableStateOf(false) }
val appBarActions = remember { mutableStateOf<@Composable RowScope.() -> Unit>( {} ) }
// todo - scroll state not remember when returing from detail screen
Scaffold (
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
if (windowSize != WindowSizeClass.Expanded) {
TopBar(
title = appBarTitle,
scrollBehavior = scrollBehavior,
appBarActions = appBarActions
)
}
},
floatingActionButton = {
if (currentRoute in searchableScreens && !preferences.persistentSearch && !focusSearchBar.value) {
SearchFab(
focusSearchBar = focusSearchBar,
focusRequester = focusRequester
)
}
},
bottomBar = {
if (windowSize != WindowSizeClass.Expanded) {
BottomNavBar(navController = navController, appBarTitle = appBarTitle)
}
}
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
MainContent(
windowSize = windowSize,
appNavController = appNavController,
navController = navController,
appBarTitle = appBarTitle,
appBarActions = appBarActions,
topBarScrollBehaviour = scrollBehavior
)
}
}
}
@Composable
private fun TopBar(
title: MutableState<String>,
scrollBehavior: TopAppBarScrollBehavior,
appBarActions: MutableState<@Composable (RowScope.() -> Unit)> = mutableStateOf({})
) {
LargeTopAppBar(
title = { Text(text = title.value) },
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults
.largeTopAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.background,
titleContentColor = MaterialTheme.colorScheme.primary
),
actions = appBarActions.value
)
}
@Composable
private fun BottomNavBar(navController: NavController, appBarTitle: MutableState<String>) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
NavigationBar {
BottomNavItem.Items.forEach { item ->
NavigationBarItem(
icon = { Icon(painter = painterResource(id = item.icon), contentDescription = null) },
label = { Text(item.name) },
selected = currentRoute == item.route,
onClick = {
onBottomAppBarItemClicked(
navController = navController,
appBarTitle = appBarTitle,
item = item
)
}
)
}
}
}
private fun onBottomAppBarItemClicked(
navController: NavController,
appBarTitle: MutableState<String>,
item: BottomNavItem
) {
appBarTitle.value = item.name
navController.navigate(item.route) {
navController.graph.startDestinationRoute?.let { screenRoute ->
popUpTo(screenRoute) {
saveState = true
}
}
launchSingleTop = true
restoreState = true
}
}
@Composable
private fun SearchBar(
textState: MutableState<String>,
placeholder: String
) {
RoundedTextField(
modifier = Modifier
.padding(all = 12.dp)
.height(35.dp),
value = textState.value,
onValueChange = { textState.value = it },
placeHolder = stringResource(id = R.string.search_placeholder, placeholder),
trailingIcon = {
Image(
painter = painterResource(id = R.drawable.ic_search),
contentDescription = stringResource(R.string.search_icon_content_descriptor),
colorFilter = ColorFilter.tint(color = MaterialTheme.colorScheme.primary)
)
}
)
}
@Composable
private fun MainContent(
windowSize: WindowSizeClass,
appNavController: NavHostController,
navController: NavHostController,
topBarScrollBehaviour: TopAppBarScrollBehavior,
appBarTitle: MutableState<String>,
appBarActions: MutableState<@Composable (RowScope.() -> Unit)> = mutableStateOf({})
) {
if (windowSize == WindowSizeClass.Expanded) {
DualColumnMainContent(
appNavController = appNavController,
navController = navController,
appBarTitle = appBarTitle,
appBarActions = appBarActions,
topBarScrollBehaviour = topBarScrollBehaviour
)
} else {
SingleColumnMainContent(
appNavController = appNavController,
navController = navController,
appBarTitle = appBarTitle,
appBarActions = appBarActions
)
}
}
@Composable
private fun SingleColumnMainContent(
appNavController: NavHostController,
navController: NavHostController,
appBarTitle: MutableState<String>,
appBarActions: MutableState<@Composable (RowScope.() -> Unit)> = mutableStateOf({})
) {
MainMediaView(
appNavController = appNavController,
navController = navController,
appBarTitle = appBarTitle,
appBarActions = appBarActions
)
}
@Composable
private fun DualColumnMainContent(
appNavController: NavHostController,
navController: NavHostController,
topBarScrollBehaviour: TopAppBarScrollBehavior,
appBarTitle: MutableState<String>,
appBarActions: MutableState<@Composable (RowScope.() -> Unit)> = mutableStateOf({})
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
Row(modifier = Modifier.fillMaxSize()) {
NavigationRail {
Spacer(modifier = Modifier.weight(1f))
BottomNavItem.Items.forEach { item ->
NavigationRailItem(
icon = { Icon(painter = painterResource(id = item.icon), contentDescription = null) },
label = { Text(item.name) },
selected = currentRoute == item.route,
onClick = {
onBottomAppBarItemClicked(
navController = navController,
appBarTitle = appBarTitle,
item = item
)
}
)
}
Spacer(modifier = Modifier.weight(1f))
}
Column {
TopBar(
title = appBarTitle,
scrollBehavior = topBarScrollBehaviour,
appBarActions = appBarActions
)
MainMediaView(
appNavController = appNavController,
navController = navController,
appBarTitle = appBarTitle,
appBarActions = appBarActions
)
}
}
}
@Composable
private fun MainMediaView(
appNavController: NavHostController,
navController: NavHostController,
appBarTitle: MutableState<String>,
appBarActions: MutableState<RowScope.() -> Unit> = mutableStateOf({})
) {
Column {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
if (currentRoute in searchableScreens) {
val textState = remember { mutableStateOf("") }
SearchBar(
textState,
appBarTitle.value
)
}
MainNavGraph(
appNavController = appNavController,
navController = navController,
appBarTitle = appBarTitle,
appBarActions = appBarActions
)
}
}
private object NavConstants {
const val ID_KEY = "id_key"
const val TYPE_KEY = "type_key"
}
@Composable
private fun MainNavigationRoutes(
startDestination: String = MainNavItem.MainView.route,
appNavController: NavHostController,
) {
NavHost(navController = appNavController, startDestination = startDestination) {
composable(MainNavItem.MainView.route) {
AppScaffold(appNavController = appNavController)
}
composable(
MainNavItem.DetailView.route.plus("/{${NavConstants.TYPE_KEY}}/{${NavConstants.ID_KEY}}"),
arguments = listOf(
navArgument(NavConstants.ID_KEY) { type = NavType.IntType },
navArgument(NavConstants.TYPE_KEY) { type = NavType.EnumType(MediaViewType::class.java) }
)
) { navBackStackEntry ->
val args = navBackStackEntry.arguments
val mediaType = args?.getSerializable(NavConstants.TYPE_KEY) as MediaViewType
if (mediaType != MediaViewType.PERSON) {
MediaDetailView(
appNavController = appNavController,
itemId = args.getInt(NavConstants.ID_KEY),
type = mediaType
)
} else {
PersonDetailView(
appNavController = appNavController,
personId = args.getInt(NavConstants.ID_KEY)
)
}
}
}
}
}
@Composable
fun MyApp(
appNavController: NavHostController = rememberNavController()
) {
Box {
MainNavigationRoutes(navController = appNavController)
}
}
@Preview(showBackground = true)
@Composable
fun MyAppPreview() {
MyApp()
}
@Composable
private fun AppKeyboardFocusManager() {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
DisposableEffect(key1 = context) {
val keyboardManager = KeyboardManager.getInstance(context)
keyboardManager.attachKeyboardDismissListener {
focusManager.clearFocus()
}
onDispose {
keyboardManager.release()
@Composable
private fun AppKeyboardFocusManager() {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
DisposableEffect(key1 = context) {
val keyboardManager = KeyboardManager.getInstance(context)
keyboardManager.attachKeyboardDismissListener {
focusManager.clearFocus()
}
onDispose {
keyboardManager.release()
}
}
}
}

View File

@@ -39,7 +39,6 @@ import com.owenlejeune.tvtime.utils.TmdbUtils
private val POSTER_WIDTH = 120.dp
private val POSTER_HEIGHT = 190.dp
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PosterGrid(
fetchMedia: (MutableState<List<TmdbItem>>) -> Unit = {},

View File

@@ -12,7 +12,8 @@ sealed class BottomNavItem(stringRes: Int, val icon: Int, val route: String): Ko
val name = resourceUtils.getString(stringRes)
companion object {
val Items = listOf(Movies, TV, People, Account, Settings)
val Items by lazy { listOf(Movies, TV, People, Account, Settings) }
val SearchableRoutes by lazy { listOf(Movies.route, TV.route, People.route) }
fun getByRoute(route: String?): BottomNavItem? {
return when (route) {

View File

@@ -55,7 +55,7 @@ fun MainNavigationRoutes(
}
@Composable
fun BottomNavigationRoutes(
fun MainNavGraph(
appNavController: NavHostController,
navController: NavHostController,
appBarTitle: MutableState<String>,

View File

@@ -27,7 +27,7 @@ import com.owenlejeune.tvtime.preferences.AppPreferences
import com.owenlejeune.tvtime.ui.components.RoundedTextField
import com.owenlejeune.tvtime.ui.components.SearchFab
import com.owenlejeune.tvtime.ui.navigation.BottomNavItem
import com.owenlejeune.tvtime.ui.navigation.BottomNavigationRoutes
import com.owenlejeune.tvtime.ui.navigation.MainNavGraph
import com.owenlejeune.tvtime.utils.KeyboardManager
import org.koin.java.KoinJavaComponent.get
@@ -88,7 +88,7 @@ fun MainAppView(appNavController: NavHostController, preferences: AppPreferences
}
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
BottomNavigationRoutes(appNavController = appNavController, navController = navController, appBarTitle = appBarTitle, appBarActions = appBarActions)
MainNavGraph(appNavController = appNavController, navController = navController, appBarTitle = appBarTitle, appBarActions = appBarActions)
}
}
}