4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ComposeでAndroid TV(実装 - focus)

Last updated at Posted at 2024-01-09

はじめに

こちらのつづきです
サンプルアプリを実装していくなかで、
focus制御にはまったので、はまりポイントを書こうと思います

focus?どういうこと?

Android TVの操作性はfocusが全てです
このfocus制御は全てよしなにやってくれるだろうと思っていたのですが、そんなに甘いものではありませんでした
例えば、以下のようなケースはアプリで制御しないとユーザが意図したような挙動にはなりません

  • ナビゲーションと画面間をfocusが移動する時の挙動
  • 画面を戻った時のfocusの挙動

これらをユーザが意図したような挙動にするためにはアプリで制御する必要があり、
そのために、

  • focusがあたっている位置の保存といつ保存するか
  • 保存した位置のfocus復元といつ復元するか

が重要になってきます

具体例

ナビゲーションと画面間をfocusが移動する時の挙動

画面 -> ナビゲーション

※ 期待した挙動にはなりますが、もっとよい方法があるかもしれません
screen-to-nav.png

期待している挙動

選択中の「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

ナビゲーション -> 画面

nav-to-screen.png

これは多少毛色が違うのですが、以下を考慮する必要があります

  • 矢印での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アプリにないつらみでした。。
もう少しサンプルアプリを実装しようと思っているため、他のはまりポイントがあれば、続くかもしれません
サンプルのアプリは以下に置いてあるため、実際に動作させながら確認できます

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?