Skip to content

Commit 6ec5423

Browse files
authored
android: fix avatar focusable (#538)
-Apply focusable() directly to outer Box instead of nested with clickable() -Explicitly handle focus -Simplify focusable area Fixes tailscale/corp/#23762 Signed-off-by: kari-ts <[email protected]> Signed-off-by: kari-ts <[email protected]>
1 parent 18b8c78 commit 6ec5423

File tree

2 files changed

+65
-38
lines changed

2 files changed

+65
-38
lines changed

android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,28 @@
33

44
package com.tailscale.ipn.ui.view
55

6+
import androidx.compose.foundation.background
67
import androidx.compose.foundation.clickable
8+
import androidx.compose.foundation.focusable
79
import androidx.compose.foundation.interaction.MutableInteractionSource
810
import androidx.compose.foundation.layout.Box
11+
import androidx.compose.foundation.layout.padding
912
import androidx.compose.foundation.layout.size
1013
import androidx.compose.foundation.shape.CircleShape
1114
import androidx.compose.material.icons.Icons
1215
import androidx.compose.material.icons.filled.Person
1316
import androidx.compose.material3.Icon
17+
import androidx.compose.material3.MaterialTheme
1418
import androidx.compose.material3.ripple
1519
import androidx.compose.runtime.Composable
20+
import androidx.compose.runtime.mutableStateOf
1621
import androidx.compose.runtime.remember
1722
import androidx.compose.ui.Alignment
1823
import androidx.compose.ui.Modifier
1924
import androidx.compose.ui.draw.clip
25+
import androidx.compose.ui.focus.onFocusChanged
26+
import androidx.compose.ui.graphics.Color
27+
import androidx.compose.ui.platform.LocalFocusManager
2028
import androidx.compose.ui.res.stringResource
2129
import androidx.compose.ui.unit.dp
2230
import coil.annotation.ExperimentalCoilApi
@@ -27,22 +35,56 @@ import com.tailscale.ipn.ui.model.IpnLocal
2735
@OptIn(ExperimentalCoilApi::class)
2836
@Composable
2937
fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50, action: (() -> Unit)? = null) {
30-
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size.dp).clip(CircleShape)) {
31-
var modifier = Modifier.size((size * .8f).dp)
32-
action?.let {
33-
modifier =
34-
modifier.clickable(
35-
interactionSource = remember { MutableInteractionSource() },
36-
indication = ripple(bounded = false),
37-
onClick = action)
38-
}
39-
Icon(
40-
imageVector = Icons.Default.Person,
41-
contentDescription = stringResource(R.string.settings_title),
42-
modifier = modifier)
38+
var isFocused = remember { mutableStateOf(false) }
39+
val focusManager = LocalFocusManager.current
4340

44-
profile?.UserProfile?.ProfilePicURL?.let { url ->
45-
AsyncImage(model = url, modifier = Modifier.size((size * 1.2f).dp), contentDescription = null)
41+
// Outer Box for the larger focusable and clickable area
42+
Box(
43+
contentAlignment = Alignment.Center,
44+
modifier = Modifier
45+
.padding(4.dp)
46+
.size((size * 1.5f).dp) // Focusable area is larger than the avatar
47+
.clip(CircleShape) // Ensure both the focus and click area are circular
48+
.background(
49+
if (isFocused.value) MaterialTheme.colorScheme.surface
50+
else Color.Transparent,
51+
)
52+
.onFocusChanged { focusState ->
53+
isFocused.value = focusState.isFocused
54+
}
55+
.focusable() // Make this outer Box focusable (after onFocusChanged)
56+
.clickable(
57+
interactionSource = remember { MutableInteractionSource() },
58+
indication = ripple(bounded = true), // Apply ripple effect inside circular bounds
59+
onClick = {
60+
action?.invoke()
61+
focusManager.clearFocus() // Clear focus after clicking the avatar
62+
}
63+
)
64+
) {
65+
// Inner Box to hold the avatar content (Icon or AsyncImage)
66+
Box(
67+
contentAlignment = Alignment.Center,
68+
modifier = Modifier
69+
.size(size.dp)
70+
.clip(CircleShape)
71+
) {
72+
if (profile?.UserProfile?.ProfilePicURL != null) {
73+
AsyncImage(
74+
model = profile.UserProfile.ProfilePicURL,
75+
modifier = Modifier.size(size.dp).clip(CircleShape),
76+
contentDescription = null
77+
)
78+
} else {
79+
Icon(
80+
imageVector = Icons.Default.Person,
81+
contentDescription = stringResource(R.string.settings_title),
82+
modifier = Modifier
83+
.size((size * 0.8f).dp)
84+
.clip(CircleShape) // Icon size slightly smaller than the Box
85+
)
86+
}
87+
}
4688
}
47-
}
4889
}
90+

android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.padding
2222
import androidx.compose.foundation.layout.size
2323
import androidx.compose.foundation.layout.statusBars
2424
import androidx.compose.foundation.lazy.LazyColumn
25-
import androidx.compose.foundation.shape.CircleShape
2625
import androidx.compose.foundation.shape.RoundedCornerShape
2726
import androidx.compose.material.icons.Icons
2827
import androidx.compose.material.icons.outlined.ArrowDropDown
@@ -187,28 +186,14 @@ fun MainView(
187186
}
188187
},
189188
trailingContent = {
190-
Box(
191-
modifier =
192-
Modifier.weight(1f)
193-
.focusable()
194-
.clickable { navigation.onNavigateToSettings() }
195-
.padding(8.dp),
196-
contentAlignment = Alignment.CenterEnd) {
197-
when (user) {
198-
null -> SettingsButton { navigation.onNavigateToSettings() }
199-
else ->
200-
Box(
201-
contentAlignment = Alignment.Center,
202-
modifier =
203-
Modifier.size(42.dp).clip(CircleShape).focusable().clickable {
204-
navigation.onNavigateToSettings()
205-
}) {
206-
Avatar(profile = user, size = 36) {
207-
navigation.onNavigateToSettings()
208-
}
209-
}
210-
}
189+
Box(modifier = Modifier.padding(8.dp), contentAlignment = Alignment.CenterEnd) {
190+
when (user) {
191+
null -> SettingsButton { navigation.onNavigateToSettings() }
192+
else -> {
193+
Avatar(profile = user, size = 36) { navigation.onNavigateToSettings() }
211194
}
195+
}
196+
}
212197
})
213198

214199
when (state) {

0 commit comments

Comments
 (0)