LazyVerticalGrid restore focus

 


const val GRID_COLUMN_COUNT = 4


@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun MoviesScreenTv(
viewModel: MoviesViewModel = koinViewModel(),
onMovieClick: (MovieItem) -> Unit,
onFocusBackToTab: FocusRequester? = null,
//contentEntryRequester: FocusRequester,
homeLeft: FocusRequester?,
homeRight: FocusRequester? = null

//onShowFilters: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
val gridState = rememberLazyGridState()
val scope = rememberCoroutineScope()

val shouldLoadMore = remember {
derivedStateOf {
if (uiState.movies.size < 4) {
false
} else {
val lastVisibleItem = gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
val totalItems = uiState.movies.size
lastVisibleItem >= totalItems - 4
}
}
}

LaunchedEffect(shouldLoadMore.value) {
val canLoad = uiState.canLoadMore && !uiState.isLoading && !uiState.isLoadingMore
if (shouldLoadMore.value && canLoad) {
viewModel.loadMoreMovies()
}
}

//ALL CONTAINER FOCUS HANDLE
// 1. Создаем FocusRequester'ы
val parentFocusRequester = remember { FocusRequester() }
val childFocusRequester = remember { FocusRequester() }

// 2. Модификатор для родительского контейнера
val parentModifier = Modifier
.focusRequester(parentFocusRequester)
.focusProperties {
onExit = {
parentFocusRequester.saveFocusedChild() // Сохраняем текущий фокус при выходе
FocusRequester.Default
}
enter = {
if (parentFocusRequester.restoreFocusedChild()) {
FocusRequester.Cancel // Если восстановили фокус - отменяем стандартное поведение
} else {
childFocusRequester // Иначе фокусируемся на дочернем элементе
}
}
}

// 3. Модификатор для дочернего элемента
val childModifier = Modifier.focusRequester(childFocusRequester)

val x2 = FocusRequesterModifiers(parentModifier, childModifier)
//ALL CONTAINER FOCUS HANDLE



Column(modifier = x2.parentModifier.fillMaxSize().focusGroup()/*FOCUS_GROUP*/) {
if (uiState.isLoading && uiState.movies.isEmpty()) {
LoadingViewTv(modifier = Modifier.fillMaxSize())
} else if (uiState.error != null && uiState.movies.isEmpty()) {

val backToUpOrHomeModifier = Modifier.focusProperties {
onFocusBackToTab?.let { up = it }
homeLeft?.let { left = it }
}

ErrorViewTv(
modifier = backToUpOrHomeModifier,
message = uiState.error ?: "",
onRetry = {
viewModel.refreshMovies()
})
} else {


var lastFocusedIndex by remember { mutableStateOf<Int?>(0) }

LazyVerticalGrid(
columns = GridCells.Fixed(GRID_COLUMN_COUNT),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize().focusGroup(),//@@@@
content = {
itemsIndexed(uiState.movies) { index, movie ->

val modifier = Modifier.focusEdges(
index = index,
gridColumnCount = GRID_COLUMN_COUNT,
homeLeft = homeLeft,
homeRight = homeRight,
scope = scope,
onFocusBackToTab = onFocusBackToTab
)

AnimatedVisibility(
visible = true,
enter = fadeIn(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
),
exit = fadeOut()
) {


MovieCardTv(
modifier = modifier.then(if (index == lastFocusedIndex) x2.childModifier else Modifier)
.onFocusChanged { state ->
if (state.isFocused) {
lastFocusedIndex = index
}
},
movie = movie,
onClick = { onMovieClick(movie) }
)


// LaunchedEffect(lastFocusedIndex) {
// if (lastFocusedIndex == index) {
// itemFocusRequester.requestFocus()
// }
// }
}
}

if (uiState.isLoadingMore) {
item(span = { GridItemSpan(maxLineSpan) }) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
LoadingViewTv(
style = MaterialTheme.typography.bodyLarge
)
}
}
}
},
state = gridState
)

LaunchedEffect(Unit) {
parentFocusRequester.requestFocus(scope = scope)
}
}
}
}

fun Modifier.focusEdges(
index: Int,
gridColumnCount: Int,
homeLeft: FocusRequester? = null,
homeRight: FocusRequester? = null,
scope: CoroutineScope,
onFocusBackToTab: FocusRequester? = null
): Modifier {
var m = this

if (onFocusBackToTab != null && index < gridColumnCount) {
m = m.focusProperties { up = onFocusBackToTab }
}

m = m.onKeyEvent { keyEvent ->
if (keyEvent.type != KeyEventType.KeyDown) return@onKeyEvent false

when (keyEvent.key) {
Key.DirectionLeft -> {
if (index % gridColumnCount == 0 && homeLeft != null) {
homeLeft.requestFocus(scope = scope)
true
} else false
}

Key.DirectionRight -> {
if ((index + 1) % gridColumnCount == 0 && homeRight != null) {
homeRight.requestFocus(scope = scope)
true
} else false
}

else -> false
}
}

return m
}

//@Composable
//fun <T>CG0(
// state: LazyGridState,
// items: List<T>,
// content: LazyGridScope.() -> Unit
//) {
// LazyVerticalGrid(
// columns = GridCells.Fixed(GRID_COLUMN_COUNT),
// contentPadding = PaddingValues(16.dp),
// verticalArrangement = Arrangement.spacedBy(16.dp),
// horizontalArrangement = Arrangement.spacedBy(16.dp),
// modifier = Modifier.fillMaxSize(),
// state = state,
// content = content
// )
//}
@Composable
fun CinemaLazyVerticalGrid(
state: LazyGridState,
content: LazyGridScope.() -> Unit
) {
LazyVerticalGrid(
columns = GridCells.Fixed(GRID_COLUMN_COUNT),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
state = state,
content = content
)
}
//@Composable
//fun TvAdaptiveGrid(
// items: List<Any>,
// state: LazyGridState,
// content: @Composable (item: Any, index: Int) -> Unit,
// home: FocusRequester? = null,
//) {
// CG0(state = state) {
// itemsIndexed(items) { index, item ->
// content(item, index)
// }
// }
//}

/**
* Стандартная адаптивная сетка для TV
*/
@Composable
fun TvAdaptiveGridDefault(
items: List<Any>,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = tvScreenPadding,
horizontalArrangement: androidx.compose.foundation.layout.Arrangement.Horizontal = androidx.compose.foundation.layout.Arrangement.spacedBy(
tvCardSpacing
),
verticalArrangement: androidx.compose.foundation.layout.Arrangement.Vertical = androidx.compose.foundation.layout.Arrangement.spacedBy(
tvCardSpacing
),
content: @Composable (item: Any, index: Int) -> Unit
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = TvSizes.GRID_MIN_SIZE.dp),
contentPadding = contentPadding,
horizontalArrangement = horizontalArrangement,
verticalArrangement = verticalArrangement,
modifier = modifier
) {
itemsIndexed(items) { index, item ->
content(item, index)
}
}
}

Run this build using a Java 11 or newer JVM

 
FAILURE: Build failed with an exception.

* What went wrong:
A problem occurred configuring root project 'PillIdentifier'.
> Could not resolve all artifacts for configuration ':classpath'.
   > Could not resolve com.android.tools.build:gradle:8.8.2.
     Required by:
         root project : > com.android.application:com.android.application.gradle.plugin:8.8.2
         root project : > com.android.library:com.android.library.gradle.plugin:8.8.2
      > Dependency requires at least JVM runtime version 11. This build uses a Java 8 JVM.
   > Could not resolve com.google.gms:google-services:4.4.2.
     Required by:
         root project : > com.google.gms.google-services:com.google.gms.google-services.gradle.plugin:4.4.2
      > Dependency requires at least JVM runtime version 11. This build uses a Java 8 JVM.
   > Could not resolve com.google.firebase:firebase-crashlytics-gradle:3.0.3.
     Required by:
         root project : > com.google.firebase.crashlytics:com.google.firebase.crashlytics.gradle.plugin:3.0.3
      > Dependency requires at least JVM runtime version 17. This build uses a Java 8 JVM.

* Try:
> Run this build using a Java 11 or newer JVM.
> Run this build using a Java 17 or newer JVM.
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.

Ошибка возникает из-за того, что ваша сборка использует Java 8, а для работы с Gradle 8.8.2, Google Services 4.4.2 и Firebase Crashlytics 3.0.3 требуется Java 11+ (а для Crashlytics — даже Java 17)
>gradlew --version        

------------------------------------------------------------
Gradle 8.10.2
------------------------------------------------------------

Build time:    2024-09-23 21:28:39 UTC
Revision:      415adb9e06a516c44b391edff552fd42139443f7

Kotlin:        1.9.24
Groovy:        3.0.22
Ant:           Apache Ant(TM) version 1.10.14 compiled on August 16 2023
Launcher JVM:  1.8.0_431 (Oracle Corporation 25.431-b10)
Daemon JVM:    C:\Program Files\Java\jdk-1.8 (no JDK specified, using current Java home) @@@@@@@@@@@@@ JAVA_HOME path
OS:            Windows 10 10.0 amd64



taskkill /F /IM java.exe

 

 

Modify  JAVA_HOME path
Or set gradle.properties

org.gradle.java.home=C:\\Program Files\\Java\\jdk-17.0.11

Моделирование состояния ViewModel в Android: Руководство по чистым и масштабируемым паттернам

 


Плохо спроектированные модели создают каскад проблем для каждого компонента, который от них зависит. В случае моделей представления, если они не соответствуют реальным потребностям экрана, другие компоненты (например, ViewModel) вынуждены обходить эти ограничения, что приводит к раздутым, трудным в сопровождении классам, заполненным хаками и обходными путями. Это несоответствие вносит неясность и путаницу, делая код неочевидным и подверженным ошибкам, что увеличивает затраты на его поддержку.

Рассмотрим два популярных способа моделирования состояния ViewModel:

//🎯 Подход 1
data class ScreenState(
    val isLoading: Boolean,
    val isError: Boolean,
    val data: Data?
)

//🎯 Подход 2
sealed interface ScreenState {
    data object Loading : ScreenState
    data object Error : ScreenState
    data class Content(val data: Data) : ScreenState
}

Оба подхода имеют значительные ограничения и часто требуют множества обходных решений. Давайте разберём их плюсы и минусы, а затем рассмотрим третий, более простой и универсальный вариант.

Подход 1: Простые дата-классы

data class ProductListScreenState(
    val isLoading: Boolean,
    val isError: Boolean,
    val productResults: ProductResults?
)

data class ProductResults(
    val products: List<Product>, 
    val canLoadMore: Boolean
)

Представьте страницу со списком товаров, где данные загружаются из удалённого источника. Во время загрузки отображается спиннер, в случае ошибки показывается экран ошибки с возможностью повторной попытки. Это стандартный сценарий «Загрузка-Контент-Ошибка».

Минусы этого подхода:

  • Модель допускает противоречивые состояния: например, isLoading и isError могут быть true одновременно, что приведёт к отображению двух экранов сразу.
  • Если добавить дополнительные состояния (например, isRefreshing или isPaginating), можно получить 2⁴ возможных комбинаций булевых значений, тогда как реально экран поддерживает только 5 состояний.
  • Каждый раз, когда мы обновляем состояние, приходится сбрасывать все остальные флаги, создавая лишний код.
  • Разработчики, работающие с UI, могут запутаться, какие состояния действительно возможны, и будут вынуждены заглядывать в код ViewModel для уточнения.

Улучшение через enum

data class ProductListScreenState(
    val displayState: DisplayState,
    val productResults: ProductResults?
)

enum class DisplayState {
    LOADING, CONTENT, ERROR
}

Этот подход значительно улучшает ситуацию, но всё ещё имеет недостатки. Например, CONTENT подразумевает, что productResults не должен быть null, но это никак не контролируется.

Подход 2: sealed interface

sealed interface ProductListScreenState {
    data object Loading : ProductListScreenState
    data object Error : ProductListScreenState
    data class Content(val productResults: ProductResults) : ProductListScreenState
}

Этот вариант решает проблему противоречивых состояний, так как каждое состояние явно представлено. Однако он затрудняет совместное использование данных между состояниями.

Проблема: если нам нужно отображать приветственное сообщение с именем пользователя, нам придётся хранить это значение отдельно и вручную синхронизировать его со всеми состояниями.

sealed interface ProductListScreenState {
    val fullName: String
    data class Loading(override val fullName: String) : ProductListScreenState
    data class Error(override val fullName: String) : ProductListScreenState
    data class Content(override val fullName: String, val productResults: ProductResults) : ProductListScreenState
}

Такой код становится громоздким и сложным в обслуживании.

Финальный подход: Объединение data class и sealed interface

Используем дата-класс для данных, присутствующих во всех состояниях, и sealed interface для состояний, которые взаимоисключаемы.

data class ProductListScreenState(
    val fullName: String,
    val displayState: DisplayState
)

sealed interface DisplayState {
    data object Loading : DisplayState
    data object Error : DisplayState
    data class Content(val productResults: ProductResults) : DisplayState
}

Масштабируемость модели

Допустим, нам нужно добавить поддержку:

  • Перезагрузки данных при повороте экрана.
  • Pull-to-refresh с индикатором загрузки вверху.
  • Пагинации с индикатором загрузки внизу.

Обновленная модель состояния:

data class ProductListScreenState(
    val fullName: String,
    val displayState: DisplayState? = null
)

sealed interface DisplayState {
    data object Loading : DisplayState
    data object Error : DisplayState
    data class Content(
        val productResults: ProductResults,
        val contentDisplayState: ContentDisplayState? = null
    ) : DisplayState
}

sealed interface ContentDisplayState {
    data object Refreshing : ContentDisplayState
    data object Paginating : ContentDisplayState
    data object PaginationError : ContentDisplayState
}

Теперь состояния чётко структурированы и масштабируемы, а код ViewModel остаётся простым и лаконичным.

Вывод:

Этот гибридный подход объединяет лучшие качества data class и sealed interface, обеспечивая чистоту, масштабируемость и удобочитаемость кода. 🎯