android/ui: fix AndroidTV navigation issues (#424)

updates tailscale/corp#20930

This addresses several issues with AndroidTV navigation:

The search bar is removed until we have a better solution
for D-pad navigation.   This should be addressed when we
switch to a less-customized component.   Full replacement of
the search functionality is beyond the scope of this change.

The back button will now automatically request the focus
on AndroidTV devices by default so there is always at
least one element focussed.

Views with clipboard support are disabled since this
was not functional (nothing was getting copied to
the clipboard).

View with embedded links are removed since these
require touch support and a browser.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
pull/425/head
Jonathan Nobels 1 year ago committed by GitHub
parent 634d51c20b
commit 0ff6be6345
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -20,13 +20,21 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.titledListItem import com.tailscale.ipn.ui.theme.titledListItem
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
@Composable @Composable
fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) { fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) {
val localClipboardManager = LocalClipboardManager.current val localClipboardManager = LocalClipboardManager.current
val modifier =
if (isAndroidTV()) {
Modifier
} else {
Modifier.clickable { localClipboardManager.setText(AnnotatedString(value)) }
}
ListItem( ListItem(
colors = MaterialTheme.colorScheme.titledListItem, colors = MaterialTheme.colorScheme.titledListItem,
modifier = Modifier.clickable { localClipboardManager.setText(AnnotatedString(value)) }, modifier = modifier,
overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } }, overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } },
headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) }, headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) },
supportingContent = supportingContent =

@ -4,6 +4,7 @@
package com.tailscale.ipn.ui.util package com.tailscale.ipn.ui.util
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -41,7 +42,8 @@ object Lists {
title: String, title: String,
bottomPadding: Dp = 0.dp, bottomPadding: Dp = 0.dp,
style: TextStyle = MaterialTheme.typography.titleMedium, style: TextStyle = MaterialTheme.typography.titleMedium,
fontWeight: FontWeight? = null fontWeight: FontWeight? = null,
focusable: Boolean = false
) { ) {
Box( Box(
modifier = modifier =
@ -50,7 +52,8 @@ object Lists {
Text( Text(
title, title,
modifier = modifier =
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding), Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding)
.focusable(focusable),
style = style, style = style,
fontWeight = fontWeight) fontWeight = fontWeight)
} }

@ -6,6 +6,7 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -86,6 +87,7 @@ import com.tailscale.ipn.ui.theme.short
import com.tailscale.ipn.ui.theme.surfaceContainerListItem import com.tailscale.ipn.ui.theme.surfaceContainerListItem
import com.tailscale.ipn.ui.theme.warningButton import com.tailscale.ipn.ui.theme.warningButton
import com.tailscale.ipn.ui.theme.warningListItem import com.tailscale.ipn.ui.theme.warningListItem
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.AutoResizingText import com.tailscale.ipn.ui.util.AutoResizingText
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
@ -316,7 +318,8 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorButton NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorButton
NodeState.RUNNING_AS_EXIT_NODE -> NodeState.RUNNING_AS_EXIT_NODE ->
MaterialTheme.colorScheme.warningButton MaterialTheme.colorScheme.warningButton
NodeState.ACTIVE_NOT_RUNNING -> MaterialTheme.colorScheme.exitNodeToggleButton NodeState.ACTIVE_NOT_RUNNING ->
MaterialTheme.colorScheme.exitNodeToggleButton
else -> MaterialTheme.colorScheme.secondaryButton else -> MaterialTheme.colorScheme.secondaryButton
}, },
onClick = { onClick = {
@ -493,49 +496,61 @@ fun PeerList(
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
var isFocussed by remember { mutableStateOf(false) } var isFocussed by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface)) { var isListFocussed by remember { mutableStateOf(false) }
OutlinedTextField(
modifier = val enableSearch = !isAndroidTV()
Modifier.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 0.dp) if (enableSearch) {
.onFocusChanged { isFocussed = it.isFocused }, Box(modifier = Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface)) {
singleLine = true, OutlinedTextField(
shape = MaterialTheme.shapes.extraLarge, modifier =
colors = MaterialTheme.colorScheme.searchBarColors, Modifier.fillMaxWidth()
leadingIcon = { Icon(imageVector = Icons.Outlined.Search, contentDescription = "search") }, .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 0.dp)
trailingIcon = { .onFocusChanged { isFocussed = it.isFocused },
if (isFocussed) { singleLine = true,
IconButton( shape = MaterialTheme.shapes.extraLarge,
onClick = { colors = MaterialTheme.colorScheme.searchBarColors,
focusManager.clearFocus() leadingIcon = {
onSearch("") Icon(imageVector = Icons.Outlined.Search, contentDescription = "search")
}) { },
Icon( trailingIcon = {
imageVector = if (isFocussed) {
if (searchTermStr.isEmpty()) Icons.Outlined.Close IconButton(
else Icons.Outlined.Clear, onClick = {
contentDescription = "clear search", focusManager.clearFocus()
tint = MaterialTheme.colorScheme.onSurfaceVariant) onSearch("")
} }) {
} Icon(
}, imageVector =
placeholder = { if (searchTermStr.isEmpty()) Icons.Outlined.Close
Text( else Icons.Outlined.Clear,
text = stringResource(id = R.string.search), contentDescription = "clear search",
style = MaterialTheme.typography.bodyLarge, tint = MaterialTheme.colorScheme.onSurfaceVariant)
maxLines = 1) }
}, }
value = searchTermStr, },
onValueChange = { onSearch(it) }) placeholder = {
Text(
text = stringResource(id = R.string.search),
style = MaterialTheme.typography.bodyLarge,
maxLines = 1)
},
value = searchTermStr,
onValueChange = { onSearch(it) })
}
} }
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().background(color = MaterialTheme.colorScheme.surface)) { modifier =
Modifier.fillMaxSize()
.onFocusChanged { isListFocussed = it.isFocused }
.background(color = MaterialTheme.colorScheme.surface)) {
if (showNoResults) { if (showNoResults) {
item { item {
Spacer( Spacer(
Modifier.height(16.dp) Modifier.height(16.dp)
.fillMaxSize() .fillMaxSize()
.focusable(false)
.background(color = MaterialTheme.colorScheme.surface)) .background(color = MaterialTheme.colorScheme.surface))
Lists.LargeTitle( Lists.LargeTitle(
@ -553,17 +568,11 @@ fun PeerList(
} }
first = false first = false
stickyHeader { // Sticky headers are a bit broken on Android TV - they hide their content
Spacer( if (isAndroidTV()) {
Modifier.height(16.dp) item { NodesSectionHeader(peerSet = peerSet) }
.fillMaxSize() } else {
.background(color = MaterialTheme.colorScheme.surface)) stickyHeader { NodesSectionHeader(peerSet = peerSet) }
Lists.LargeTitle(
peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user),
bottomPadding = 8.dp,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold)
} }
itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer -> itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
@ -595,6 +604,18 @@ fun PeerList(
} }
} }
@Composable
fun NodesSectionHeader(peerSet: PeerSet) {
Spacer(Modifier.height(16.dp).fillMaxSize().background(color = MaterialTheme.colorScheme.surface))
Lists.LargeTitle(
peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user),
bottomPadding = 8.dp,
focusable = isAndroidTV(),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold)
}
@Composable @Composable
fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) { fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) {
if (netmap == null) return if (netmap == null) return

@ -5,6 +5,7 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -33,6 +34,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.theme.short import com.tailscale.ipn.ui.theme.short
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
@ -101,14 +103,24 @@ fun PeerDetails(
fun AddressRow(address: String, type: String) { fun AddressRow(address: String, type: String) {
val localClipboardManager = LocalClipboardManager.current val localClipboardManager = LocalClipboardManager.current
// Android TV doesn't have a clipboard, nor any way to use the values, so visible only.
val modifier =
if (isAndroidTV()) {
Modifier.focusable(false)
} else {
Modifier.clickable { localClipboardManager.setText(AnnotatedString(address)) }
}
ListItem( ListItem(
modifier = Modifier.clickable { localClipboardManager.setText(AnnotatedString(address)) }, modifier = modifier,
colors = MaterialTheme.colorScheme.listItem, colors = MaterialTheme.colorScheme.listItem,
headlineContent = { Text(text = address) }, headlineContent = { Text(text = address) },
supportingContent = { Text(text = type) }, supportingContent = { Text(text = type) },
trailingContent = { trailingContent = {
// TODO: there is some overlap with other uses of clipboard, DRY // TODO: there is some overlap with other uses of clipboard, DRY
Icon(painter = painterResource(id = R.drawable.clipboard), null) if (!isAndroidTV()) {
Icon(painter = painterResource(id = R.drawable.clipboard), null)
}
}) })
} }

@ -32,6 +32,7 @@ import com.tailscale.ipn.mdm.ShowHide
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.link import com.tailscale.ipn.ui.theme.link
import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsNav
@ -61,7 +62,7 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
onClick = settingsNav.onNavigateToUserSwitcher) onClick = settingsNav.onNavigateToUserSwitcher)
} }
if (isAdmin) { if (isAdmin && !isAndroidTV()) {
Lists.ItemDivider() Lists.ItemDivider()
AdminTextView { handler.openUri(Links.ADMIN_URL) } AdminTextView { handler.openUri(Links.ADMIN_URL) }
} }

@ -22,12 +22,16 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.theme.topAppBar import com.tailscale.ipn.ui.theme.topAppBar
import com.tailscale.ipn.ui.theme.ts_color_light_blue import com.tailscale.ipn.ui.theme.ts_color_light_blue
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
typealias BackNavigation = () -> Unit typealias BackNavigation = () -> Unit
@ -41,6 +45,12 @@ fun Header(
actions: @Composable RowScope.() -> Unit = {}, actions: @Composable RowScope.() -> Unit = {},
onBack: (() -> Unit)? = null onBack: (() -> Unit)? = null
) { ) {
val f = FocusRequester()
if (isAndroidTV()) {
LaunchedEffect(Unit) { f.requestFocus() }
}
TopAppBar( TopAppBar(
title = { title = {
title?.let { title() } title?.let { title() }
@ -51,21 +61,23 @@ fun Header(
}, },
colors = MaterialTheme.colorScheme.topAppBar, colors = MaterialTheme.colorScheme.topAppBar,
actions = actions, actions = actions,
navigationIcon = { onBack?.let { BackArrow(action = it) } }, navigationIcon = { onBack?.let { BackArrow(action = it, focusRequester = f) } },
) )
} }
@Composable @Composable
fun BackArrow(action: () -> Unit) { fun BackArrow(action: () -> Unit, focusRequester: FocusRequester) {
Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) { Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) {
Icon( Icon(
Icons.AutoMirrored.Filled.ArrowBack, Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Go back to the previous screen", contentDescription = "Go back to the previous screen",
modifier = modifier =
Modifier.clickable( Modifier.focusRequester(focusRequester)
interactionSource = remember { MutableInteractionSource() }, .clickable(
indication = rememberRipple(bounded = false), interactionSource = remember { MutableInteractionSource() },
onClick = { action() })) indication = rememberRipple(bounded = false),
onClick = { action() }))
} }
} }

Loading…
Cancel
Save