はじめに
こちらのつづきです
サンプルアプリを実装していくなかで、
focus制御にはまったので、はまりポイントを書こうと思います
focus?どういうこと?
Android TVの操作性はfocusが全てです
このfocus制御は全てよしなにやってくれるだろうと思っていたのですが、そんなに甘いものではありませんでした
例えば、以下のようなケースはアプリで制御しないとユーザが意図したような挙動にはなりません
- ナビゲーションと画面間をfocusが移動する時の挙動
- 画面を戻った時のfocusの挙動
これらをユーザが意図したような挙動にするためにはアプリで制御する必要があり、
そのために、
- focusがあたっている位置の保存といつ保存するか
- 保存した位置のfocus復元といつ復元するか
が重要になってきます
具体例
ナビゲーションと画面間をfocusが移動する時の挙動
画面 -> ナビゲーション
※ 期待した挙動にはなりますが、もっとよい方法があるかもしれません
期待している挙動
選択中の「Add」iconにフォーカスがあたる
制御しない時の挙動
一番下の「AcUnit」iconにフォーカスがあたる
(今回はそうなりますが、画面でFocusがあたっているitemの位置によって変わります)
制御方法
- focusがあたっている位置の保存
- いつ保存するか
- Itemにfocusがあたって、ナビゲーションが開いているとき
- Itemをclickしたとき
- いつ保存するか
- 保存した位置のfocus復元
- いつ復元するか
- Itemにfocusがあたって、ナビゲーションが閉じているとき
@OptIn(ExperimentalTvMaterial3Api::class) @Composable fun MyNavigationDrawer( navController: NavHostController, menu1ViewModel: Menu1ViewModel = hiltViewModel(), menu2ViewModel: Menu2ViewModel = hiltViewModel(), menu3ViewModel: Menu3ViewModel = hiltViewModel(), ) { val selectedMenuType: MutableState<MenuType> = rememberSaveable { mutableStateOf(MenuType.MENU1) } val menuTypeList = MenuType.entries Row(Modifier.fillMaxSize()) { Box(modifier = Modifier) { NavigationDrawer( drawerContent = { drawerValue -> Sidebar( drawerValue = drawerValue, selectedMenuType = selectedMenuType, menuTypeList = menuTypeList, ) } ) { when (selectedMenuType.value) { MenuType.MENU1 -> Menu1Screen(navController, menu1ViewModel.uiState) MenuType.MENU2 -> Menu2Screen(navController, menu2ViewModel.uiState) MenuType.MENU3 -> Menu3Screen(navController, menu3ViewModel.uiState) } } } } } @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun Sidebar( drawerValue: DrawerValue, selectedMenuType: MutableState<MenuType>, menuTypeList: List<MenuType>, ) { // focusがあたっている位置の保存 val selectedIndex = rememberSaveable { mutableIntStateOf(0) } // 保存した位置のfocus復元 val focusRequesters = remember { List(size = menuTypeList.size) { FocusRequester() } } LaunchedEffect(selectedIndex.intValue) { selectedMenuType.value = menuTypeList[selectedIndex.intValue] } Column{ menuTypeList.forEachIndexed { index, menuType -> NavigationItem( iconImageVector = menuType.icon, text = menuType.label, drawerValue = drawerValue, selectedIndex = selectedIndex, focusRequesters = focusRequesters, index = index, modifier = Modifier.focusRequester(focusRequesters[index]), ) } } } @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun NavigationItem( iconImageVector: ImageVector, text: String, drawerValue: DrawerValue, selectedIndex: MutableState<Int>, focusRequesters: List<FocusRequester>, index: Int, modifier: Modifier = Modifier, ) { val focusManager = LocalFocusManager.current Box( modifier = modifier .onFocusChanged { if (it.isFocused.not()) return@onFocusChanged when (drawerValue) { // Itemにfocusがあたって、ナビゲーションが開いているとき DrawerValue.Open -> selectedIndex.value = index // Itemにfocusがあたって、ナビゲーションが閉じているとき DrawerValue.Closed -> focusRequesters[selectedIndex.value].requestFocus() } } .clickable { // Itemをclickしたとき selectedIndex.value = index focusManager.moveFocus(FocusDirection.Right) } ) { } }
- いつ復元するか
2024/6/7 追記
こちら動作は問題ないのですが、focusが戻ってきた時のselected colorが残ってしまっていました
itemでrequestFocus()するのではなく、その親でrequestFocus()することで解決します
修正commitはこちら
https://github.com/a7ther/andoroid-tv-temp/commit/05f55dc73f9776fc350055a5bef6bdd0e306bf6a
ナビゲーション -> 画面
これは多少毛色が違うのですが、以下を考慮する必要があります
- 矢印でのfocus移動
- NavHostとは別でMyNavigationDrawerのcontentで画面遷移させる
- 決定によるfocus移動
- 押下時にfocusを移動させる
@OptIn(ExperimentalTvMaterial3Api::class) @Composable fun MyNavigationDrawer( navController: NavHostController, menu1ViewModel: Menu1ViewModel = hiltViewModel(), menu2ViewModel: Menu2ViewModel = hiltViewModel(), menu3ViewModel: Menu3ViewModel = hiltViewModel(), ) { val selectedMenuType: MutableState<MenuType> = rememberSaveable { mutableStateOf(MenuType.MENU1) } val menuTypeList = MenuType.entries Row(Modifier.fillMaxSize()) { Box(modifier = Modifier) { NavigationDrawer( drawerContent = { drawerValue -> Sidebar( drawerValue = drawerValue, selectedMenuType = selectedMenuType, menuTypeList = menuTypeList, ) } ) { // 矢印でのfocus移動 when (selectedMenuType.value) { MenuType.MENU1 -> Menu1Screen(navController, menu1ViewModel.uiState) MenuType.MENU2 -> Menu2Screen(navController, menu2ViewModel.uiState) MenuType.MENU3 -> Menu3Screen(navController, menu3ViewModel.uiState) } } } } } @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun NavigationItem( iconImageVector: ImageVector, text: String, drawerValue: DrawerValue, selectedIndex: MutableState<Int>, focusRequesters: List<FocusRequester>, index: Int, modifier: Modifier = Modifier, ) { val focusManager = LocalFocusManager.current Box( modifier = modifier .onFocusChanged { if (it.isFocused.not()) return@onFocusChanged when (drawerValue) { DrawerValue.Open -> selectedIndex.value = index DrawerValue.Closed -> focusRequesters[selectedIndex.value].requestFocus() } } .clickable { selectedIndex.value = index // 決定によるfocus移動 focusManager.moveFocus(FocusDirection.Right) } ) { } }
画面を戻った時のfocusの挙動
1 | 2 | 3 | ||
---|---|---|---|---|
→ | → |
期待している挙動
2から3に戻ったときに1で選択していたcardにフォーカスがあたる
制御しない時の挙動
初めて表示したときと同じcardにフォーカスがあたる
制御方法
- focusがあたっている位置の保存
- いつ保存するか
- cardにfocusが当たったとき
- いつ保存するか
- 保存した位置のfocus復元
- いつ復元するか
- 画面を作成した後に、focusを当てるcardがあったとき
@OptIn(ExperimentalTvMaterial3Api::class) @Composable fun Menu1Screen( navController: NavHostController, uiState: Menu1ScreenUiState, ) { // focusがあたっている位置の保存 val focusedVideoId = rememberSaveable { mutableStateOf<String?>(null) } // 保存した位置のfocus復元 val focusRequesterMap = remember { mutableMapOf<String, FocusRequester>() } TvLazyColumn( modifier = Modifier, pivotOffsets = PivotOffsets(parentFraction = 0.08f), contentPadding = PaddingValues(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { itemsIndexed(uiState.carousels) { _, carousel -> Text( text = carousel.carouselTitle, style = MaterialTheme.typography.labelLarge, modifier = Modifier, color = Color.White, ) TvLazyRow( modifier = Modifier, pivotOffsets = PivotOffsets(parentFraction = 0.0f), contentPadding = PaddingValues(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), ) { itemsIndexed(carousel.cards) { _, card -> val focusRequester = remember { FocusRequester() } focusRequesterMap[card.videoId] = focusRequester MyCard( data = card, onClick = { navController.navigate(ScreenType.VideoPlayerScreen.createTransitionRoute(card.videoId)) }, focusedVideoId = focusedVideoId, focusRequester = focusRequester, ) } } } } LaunchedEffect(Unit) { // 画面を作成した後に、focusをあてるitemのあったとき focusRequesterMap[focusedVideoId.value]?.requestFocus() } } @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun MyCard( data: MyCardData, onClick: () -> Unit, focusedVideoId: MutableState<String?>, focusRequester: FocusRequester, ) { Column(modifier = Modifier) { var isFocused by rememberSaveable { mutableStateOf(false) } Card( modifier = Modifier .width(300.dp) .wrapContentSize() .focusRequester(focusRequester) .onFocusChanged { if (it.isFocused || it.hasFocus) { isFocused = true // itemにfocusが当たったとき focusedVideoId.value = data.videoId } }, scale = CardDefaults.scale(focusedScale = 1.0f), border = CardDefaults.border( focusedBorder = Border( border = BorderStroke( width = 2.dp, color = Color.White ) ) ), colors = CardDefaults.colors( containerColor = Color.Transparent, ), onClick = onClick, ) { AsyncImage( model = data.imageUrl, contentDescription = data.description, contentScale = ContentScale.Crop, modifier = Modifier .fillMaxWidth() .aspectRatio(1.77f) .clip(RoundedCornerShape(10.dp)) .graphicsLayer { alpha = if (isFocused) 1f else 0.5f }, placeholder = BrushPainter( Brush.linearGradient( listOf( Color.Gray, Color.DarkGray, ) ) ), ) } Column( modifier = Modifier .padding( vertical = 5.dp, ) .width(300.dp) ) { Text( text = data.title, modifier = Modifier, fontSize = 20.sp, color = Color.White, maxLines = 2, overflow = TextOverflow.Ellipsis, ) Text( text = data.description, fontSize = 14.sp, modifier = Modifier .graphicsLayer { alpha = 0.6f } .padding(top = 5.dp), color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } }
- いつ復元するか
つづく?
focus制御はAndroidアプリにないつらみでした。。
もう少しサンプルアプリを実装しようと思っているため、他のはまりポイントがあれば、続くかもしれません
サンプルのアプリは以下に置いてあるため、実際に動作させながら確認できます