ComposeでAndroid TV（実装 - focus）

# はじめに

https://qiita.com/a7ther/private/921860a2253646ddff29

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

# focus？どういうこと？

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

これらをユーザが意図したような挙動にするためにはアプリで制御する必要があり、
そのために、
- focusがあたっている位置の保存といつ保存するか
- 保存した位置のfocus復元といつ復元するか

が重要になってきます

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

### 画面 -> ナビゲーション
※ 期待した挙動にはなりますが、もっとよい方法があるかもしれません
![screen-to-nav.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/441643/37951844-b359-1a5a-9cfc-f7b6dd4cc659.png)

#### 期待している挙動
選択中の「Add」iconにフォーカスがあたる

#### 制御しない時の挙動
一番下の「AcUnit」iconにフォーカスがあたる
（今回はそうなりますが、画面でFocusがあたっているitemの位置によって変わります）

#### 制御方法
- focusがあたっている位置の保存
    - いつ保存するか
        - Itemにfocusがあたって、ナビゲーションが開いているとき
        - Itemをclickしたとき
- 保存した位置のfocus復元
    - いつ復元するか
        - Itemにfocusがあたって、ナビゲーションが閉じているとき
    ```Kotlin
    @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)
                }
        ) {
        }
    }
    ```
:::note info
2024/6/7 追記
こちら動作は問題ないのですが、focusが戻ってきた時のselected colorが残ってしまっていました
itemでrequestFocus()するのではなく、その親でrequestFocus()することで解決します
修正commitはこちら
https://github.com/a7ther/andoroid-tv-temp/commit/05f55dc73f9776fc350055a5bef6bdd0e306bf6a
:::

### ナビゲーション -> 画面

![nav-to-screen.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/441643/36342aaa-80fa-e71f-ae8e-65dc3e743243.png)


これは多少毛色が違うのですが、以下を考慮する必要があります
- 矢印でのfocus移動
    - NavHostとは別でMyNavigationDrawerのcontentで画面遷移させる
- 決定によるfocus移動
    - 押下時にfocusを移動させる
    ```kotlin
    @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|
|---|---|---|---|---|
| <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/441643/1d77f1cc-d1ff-f5f5-0cba-5a4d42dfd75d.png" width="300" /> | → | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/441643/16233c03-342f-3850-5316-6e75414a5bc2.png" width="300" /> | → | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/441643/1d77f1cc-d1ff-f5f5-0cba-5a4d42dfd75d.png" width="300" /> |

#### 期待している挙動
2から3に戻ったときに1で選択していたcardにフォーカスがあたる

#### 制御しない時の挙動
初めて表示したときと同じcardにフォーカスがあたる

#### 制御方法
- focusがあたっている位置の保存
    - いつ保存するか
        - cardにfocusが当たったとき
- 保存した位置のfocus復元
    - いつ復元するか
        - 画面を作成した後に、focusを当てるcardがあったとき
    ```Kotlin
    @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アプリにないつらみでした。。
もう少しサンプルアプリを実装しようと思っているため、他のはまりポイントがあれば、続くかもしれません
サンプルのアプリは以下に置いてあるため、実際に動作させながら確認できます

https://github.com/a7ther/andoroid-tv-temp