| /* |
| * Copyright 2022 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package androidx.tv.material |
| |
| import androidx.compose.foundation.background |
| import androidx.compose.foundation.border |
| import androidx.compose.foundation.focusable |
| import androidx.compose.foundation.layout.Box |
| import androidx.compose.foundation.layout.Column |
| import androidx.compose.foundation.layout.Spacer |
| import androidx.compose.foundation.layout.fillMaxSize |
| import androidx.compose.foundation.layout.height |
| import androidx.compose.foundation.layout.size |
| import androidx.compose.foundation.layout.width |
| import androidx.compose.foundation.shape.RoundedCornerShape |
| import androidx.compose.foundation.text.BasicText |
| import androidx.compose.runtime.Composable |
| import androidx.compose.runtime.LaunchedEffect |
| import androidx.compose.runtime.getValue |
| import androidx.compose.runtime.mutableStateOf |
| import androidx.compose.runtime.remember |
| import androidx.compose.runtime.setValue |
| import androidx.compose.ui.Modifier |
| import androidx.compose.ui.focus.FocusRequester |
| import androidx.compose.ui.focus.focusRequester |
| import androidx.compose.ui.graphics.Color |
| import androidx.compose.ui.input.key.NativeKeyEvent |
| import androidx.compose.ui.platform.testTag |
| import androidx.compose.ui.test.assertIsDisplayed |
| import androidx.compose.ui.test.assertIsFocused |
| import androidx.compose.ui.test.junit4.createComposeRule |
| import androidx.compose.ui.test.onNodeWithTag |
| import androidx.compose.ui.test.onNodeWithText |
| import androidx.compose.ui.unit.DpRect |
| import androidx.compose.ui.unit.dp |
| import androidx.test.platform.app.InstrumentationRegistry |
| import org.junit.Rule |
| import org.junit.Test |
| |
| class TabRowTest { |
| |
| @get:Rule |
| val rule = createComposeRule() |
| |
| @Test |
| fun tabRow_firstTabIsSelected() { |
| val tabs = constructTabs() |
| val firstTab = tabs[0] |
| |
| setContent(tabs) |
| |
| rule.onNodeWithTag(firstTab).assertIsFocused() |
| } |
| |
| @Test |
| fun tabRow_dPadRightMovesFocusToSecondTab() { |
| val tabs = constructTabs() |
| val firstTab = tabs[0] |
| val secondTab = tabs[1] |
| |
| setContent(tabs) |
| |
| // First tab should be focused |
| rule.onNodeWithTag(firstTab).assertIsFocused() |
| |
| rule.waitForIdle() |
| |
| // Move to next tab |
| performKeyPress(NativeKeyEvent.KEYCODE_DPAD_RIGHT) |
| |
| rule.waitForIdle() |
| |
| // Second tab should be focused |
| rule.onNodeWithTag(secondTab).assertIsFocused() |
| } |
| |
| @Test |
| fun tabRow_dPadLeftMovesFocusToPreviousTab() { |
| val tabs = constructTabs() |
| val firstTab = tabs[0] |
| val secondTab = tabs[1] |
| val thirdTab = tabs[2] |
| |
| setContent(tabs) |
| |
| // First tab should be focused |
| rule.onNodeWithTag(firstTab).assertIsFocused() |
| |
| rule.waitForIdle() |
| |
| // Move to next tab |
| performKeyPress(NativeKeyEvent.KEYCODE_DPAD_RIGHT) |
| |
| rule.waitForIdle() |
| |
| // Second tab should be focused |
| rule.onNodeWithTag(secondTab).assertIsFocused() |
| |
| // Move to next tab |
| performKeyPress(NativeKeyEvent.KEYCODE_DPAD_RIGHT) |
| |
| rule.waitForIdle() |
| |
| // Third tab should be focused |
| rule.onNodeWithTag(thirdTab).assertIsFocused() |
| |
| // Move to previous tab |
| performKeyPress(NativeKeyEvent.KEYCODE_DPAD_LEFT) |
| |
| rule.waitForIdle() |
| |
| // Second tab should be focused |
| rule.onNodeWithTag(secondTab).assertIsFocused() |
| |
| // Move to previous tab |
| performKeyPress(NativeKeyEvent.KEYCODE_DPAD_LEFT) |
| |
| rule.waitForIdle() |
| |
| // First tab should be focused |
| rule.onNodeWithTag(firstTab).assertIsFocused() |
| } |
| |
| @Test |
| fun tabRow_changeActiveTabOnClick() { |
| val tabs = constructTabs(count = 2) |
| |
| val firstPanel = "Panel 1" |
| val secondPanel = "Panel 2" |
| |
| setContent( |
| tabs, |
| contentBuilder = @Composable { |
| var focusedTabIndex by remember { mutableStateOf(0) } |
| var activeTabIndex by remember { mutableStateOf(focusedTabIndex) } |
| TabRowSample( |
| tabs = tabs, |
| selectedTabIndex = activeTabIndex, |
| onFocus = { focusedTabIndex = it }, |
| onClick = { activeTabIndex = it }, |
| buildTabPanel = @Composable { index, _ -> |
| BasicText(text = "Panel ${index + 1}") |
| }, |
| indicator = @Composable { tabPositions -> |
| // FocusedTab's indicator |
| TabRowDefaults.PillIndicator( |
| currentTabPosition = tabPositions[focusedTabIndex], |
| activeColor = Color.Blue.copy(alpha = 0.4f), |
| inactiveColor = Color.Transparent, |
| ) |
| |
| // SelectedTab's indicator |
| TabRowDefaults.PillIndicator( |
| currentTabPosition = tabPositions[activeTabIndex] |
| ) |
| } |
| ) |
| } |
| ) |
| |
| rule.onNodeWithText(firstPanel).assertIsDisplayed() |
| |
| // Move focus to next tab |
| performKeyPress(NativeKeyEvent.KEYCODE_DPAD_RIGHT) |
| |
| rule.waitForIdle() |
| |
| rule.onNodeWithText(firstPanel).assertIsDisplayed() |
| rule.onNodeWithText(secondPanel).assertDoesNotExist() |
| |
| // Click on the new focused tab |
| performKeyPress(NativeKeyEvent.KEYCODE_DPAD_CENTER) |
| |
| rule.onNodeWithText(firstPanel).assertDoesNotExist() |
| rule.onNodeWithText(secondPanel).assertIsDisplayed() |
| } |
| |
| private fun setContent( |
| tabs: List<String>, |
| contentBuilder: @Composable () -> Unit = { |
| var selectedTabIndex by remember { mutableStateOf(0) } |
| TabRowSample( |
| tabs = tabs, |
| selectedTabIndex = selectedTabIndex, |
| onFocus = { selectedTabIndex = it } |
| ) |
| }, |
| ) { |
| rule.setContent { |
| contentBuilder() |
| } |
| |
| rule.waitForIdle() |
| |
| // Move the focus TabRow |
| performKeyPress(NativeKeyEvent.KEYCODE_DPAD_DOWN) |
| |
| rule.waitForIdle() |
| } |
| } |
| |
| @Composable |
| private fun TabRowSample( |
| tabs: List<String>, |
| selectedTabIndex: Int, |
| onFocus: (index: Int) -> Unit = {}, |
| onClick: (index: Int) -> Unit = onFocus, |
| buildTab: @Composable ((index: Int, tab: String) -> Unit) = @Composable { index, tab -> |
| TabSample( |
| selected = selectedTabIndex == index, |
| onFocus = { onFocus(index) }, |
| onClick = { onClick(index) }, |
| modifier = Modifier.testTag(tab), |
| ) |
| }, |
| indicator: @Composable (tabPositions: List<DpRect>) -> Unit = @Composable { tabPositions -> |
| TabRowDefaults.PillIndicator(currentTabPosition = tabPositions[selectedTabIndex]) |
| }, |
| buildTabPanel: @Composable ((index: Int, tab: String) -> Unit) = @Composable { _, tab -> |
| BasicText(text = tab) |
| }, |
| ) { |
| val fr = remember { FocusRequester() } |
| |
| Column( |
| modifier = Modifier |
| .fillMaxSize() |
| .background(Color.Black) |
| ) { |
| // Added so that this can get focus and pass it to the tab row |
| Box( |
| modifier = Modifier |
| .size(50.dp) |
| .focusRequester(fr) |
| .background(Color.White) |
| .focusable() |
| ) |
| |
| // Send focus to button |
| LaunchedEffect(Unit) { |
| fr.requestFocus() |
| } |
| |
| TabRow( |
| selectedTabIndex = selectedTabIndex, |
| indicator = indicator, |
| separator = { Spacer(modifier = Modifier.width(12.dp)) }, |
| ) { |
| tabs.forEachIndexed { index, tab -> buildTab(index, tab) } |
| } |
| |
| buildTabPanel(selectedTabIndex, tabs[selectedTabIndex]) |
| } |
| } |
| |
| @Composable |
| private fun TabSample( |
| selected: Boolean, |
| modifier: Modifier = Modifier, |
| onFocus: () -> Unit = {}, |
| onClick: () -> Unit = {}, |
| tag: String = "Tab", |
| ) { |
| Tab( |
| selected = selected, |
| onFocus = onFocus, |
| onClick = onClick, |
| modifier = modifier |
| .width(100.dp) |
| .height(50.dp) |
| .testTag(tag) |
| .border(2.dp, Color.White, RoundedCornerShape(50)) |
| ) {} |
| } |
| |
| private fun performKeyPress(keyCode: Int, count: Int = 1) { |
| for (i in 1..count) { |
| InstrumentationRegistry |
| .getInstrumentation() |
| .sendKeyDownUpSync(keyCode) |
| } |
| } |
| |
| private fun constructTabs( |
| count: Int = 3, |
| buildTab: (index: Int) -> String = { "Season $it" } |
| ): List<String> = (0 until count).map(buildTab) |