Clickable and focusable can now be focused in non-touch modes
Also adds focus state support in ripple and Material components.
Bug: b/202856230
Fixes: b/152525426
Fixes: b/163725615
Test: Screenshot tests / modifier tests
Relnote: "Modifier.clickable and Modifier.toggleable can now be focused by default when using a keyboard. Ripple has been updated to support focus state, so components such as Button will now appear focused when necessary."
Change-Id: I2161d9c5e4ef4d039fb8f90cb43a94f0f15c0795
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ClickableTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ClickableTest.kt
index 2f8b9d6..745c482 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ClickableTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ClickableTest.kt
@@ -20,6 +20,7 @@
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
+import androidx.compose.foundation.interaction.FocusInteraction
import androidx.compose.foundation.interaction.HoverInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -29,16 +30,25 @@
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
+import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.focus.FocusManager
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.InputMode
+import androidx.compose.ui.input.InputModeManager
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.InspectableValue
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsActions
@@ -434,7 +444,7 @@
fun clickableTest_interactionSource() {
val interactionSource = MutableInteractionSource()
- var scope: CoroutineScope? = null
+ lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
@@ -455,7 +465,7 @@
val interactions = mutableListOf<Interaction>()
- scope!!.launch {
+ scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
@@ -499,7 +509,7 @@
fun clickableTest_interactionSource_immediateRelease() {
val interactionSource = MutableInteractionSource()
- var scope: CoroutineScope? = null
+ lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
@@ -520,7 +530,7 @@
val interactions = mutableListOf<Interaction>()
- scope!!.launch {
+ scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
@@ -549,7 +559,7 @@
fun clickableTest_interactionSource_immediateCancel() {
val interactionSource = MutableInteractionSource()
- var scope: CoroutineScope? = null
+ lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
@@ -570,7 +580,7 @@
val interactions = mutableListOf<Interaction>()
- scope!!.launch {
+ scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
@@ -595,7 +605,7 @@
fun clickableTest_interactionSource_immediateDrag() {
val interactionSource = MutableInteractionSource()
- var scope: CoroutineScope? = null
+ lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
@@ -620,7 +630,7 @@
val interactions = mutableListOf<Interaction>()
- scope!!.launch {
+ scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
@@ -646,7 +656,7 @@
fun clickableTest_interactionSource_dragAfterTimeout() {
val interactionSource = MutableInteractionSource()
- var scope: CoroutineScope? = null
+ lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
@@ -671,7 +681,7 @@
val interactions = mutableListOf<Interaction>()
- scope!!.launch {
+ scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
@@ -710,7 +720,7 @@
fun clickableTest_interactionSource_cancelledGesture() {
val interactionSource = MutableInteractionSource()
- var scope: CoroutineScope? = null
+ lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
@@ -731,7 +741,7 @@
val interactions = mutableListOf<Interaction>()
- scope!!.launch {
+ scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
@@ -766,7 +776,7 @@
val interactionSource = MutableInteractionSource()
var emitClickableText by mutableStateOf(true)
- var scope: CoroutineScope? = null
+ lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
@@ -789,7 +799,7 @@
val interactions = mutableListOf<Interaction>()
- scope!!.launch {
+ scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
@@ -828,7 +838,7 @@
fun clickableTest_interactionSource_hover() {
val interactionSource = MutableInteractionSource()
- var scope: CoroutineScope? = null
+ lateinit var scope: CoroutineScope
rule.setContent {
scope = rememberCoroutineScope()
@@ -847,7 +857,7 @@
val interactions = mutableListOf<Interaction>()
- scope!!.launch {
+ scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
@@ -881,7 +891,7 @@
fun clickableTest_interactionSource_hover_and_press() {
val interactionSource = MutableInteractionSource()
- var scope: CoroutineScope? = null
+ lateinit var scope: CoroutineScope
rule.setContent {
scope = rememberCoroutineScope()
@@ -900,7 +910,7 @@
val interactions = mutableListOf<Interaction>()
- scope!!.launch {
+ scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
@@ -928,6 +938,123 @@
}
}
+ @Test
+ fun clickableTest_interactionSource_focus_inTouchMode() {
+ val interactionSource = MutableInteractionSource()
+
+ lateinit var scope: CoroutineScope
+
+ val focusRequester = FocusRequester()
+
+ rule.setContent {
+ scope = rememberCoroutineScope()
+ Box {
+ BasicText(
+ "ClickableText",
+ modifier = Modifier
+ .testTag("myClickable")
+ .focusRequester(focusRequester)
+ .combinedClickable(
+ interactionSource = interactionSource,
+ indication = null
+ ) {}
+ )
+ }
+ }
+
+ val interactions = mutableListOf<Interaction>()
+
+ scope.launch {
+ interactionSource.interactions.collect { interactions.add(it) }
+ }
+
+ rule.runOnIdle {
+ assertThat(interactions).isEmpty()
+ }
+
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ }
+
+ // Touch mode by default, so we shouldn't be focused
+ rule.runOnIdle {
+ assertThat(interactions).isEmpty()
+ }
+ }
+
+ @Test
+ fun clickableTest_interactionSource_focus_inKeyboardMode() {
+ val interactionSource = MutableInteractionSource()
+
+ lateinit var scope: CoroutineScope
+
+ val focusRequester = FocusRequester()
+
+ lateinit var focusManager: FocusManager
+
+ val keyboardInputModeManager = object : InputModeManager {
+ override val inputMode = InputMode.Keyboard
+
+ @OptIn(ExperimentalComposeUiApi::class)
+ override fun requestInputMode(inputMode: InputMode) = true
+ }
+
+ rule.setContent {
+ scope = rememberCoroutineScope()
+ focusManager = LocalFocusManager.current
+ CompositionLocalProvider(LocalInputModeManager provides keyboardInputModeManager) {
+ Box {
+ BasicText(
+ "ClickableText",
+ modifier = Modifier
+ .testTag("myClickable")
+ .focusRequester(focusRequester)
+ .combinedClickable(
+ interactionSource = interactionSource,
+ indication = null
+ ) {}
+ )
+ }
+ }
+ }
+
+ val interactions = mutableListOf<Interaction>()
+
+ scope.launch {
+ interactionSource.interactions.collect { interactions.add(it) }
+ }
+
+ rule.runOnIdle {
+ assertThat(interactions).isEmpty()
+ }
+
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ }
+
+ // Keyboard mode, so we should now be focused and see an interaction
+ rule.runOnIdle {
+ assertThat(interactions).hasSize(1)
+ assertThat(interactions.first()).isInstanceOf(FocusInteraction.Focus::class.java)
+ }
+
+ rule.runOnIdle {
+ focusManager.clearFocus()
+ }
+
+ rule.runOnIdle {
+ assertThat(interactions).hasSize(2)
+ assertThat(interactions.first()).isInstanceOf(FocusInteraction.Focus::class.java)
+ assertThat(interactions[1])
+ .isInstanceOf(FocusInteraction.Unfocus::class.java)
+ assertThat((interactions[1] as FocusInteraction.Unfocus).focus)
+ .isEqualTo(interactions[0])
+ }
+ }
+
+ // TODO: b/202871171 - add test for changing between keyboard mode and touch mode, making sure
+ // it resets existing focus
+
/**
* Regression test for b/186223077
*
@@ -945,7 +1072,7 @@
// Simulate the long click causing a recomposition, and changing the lambda instance
onLongClick = initialLongClick
- var scope: CoroutineScope? = null
+ lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
@@ -967,7 +1094,7 @@
val interactions = mutableListOf<Interaction>()
- scope!!.launch {
+ scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
@@ -1021,7 +1148,7 @@
// Simulate the long click causing a recomposition, and changing the lambda to be null
onLongClick = initialLongClick
- var scope: CoroutineScope? = null
+ lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
@@ -1043,7 +1170,7 @@
val interactions = mutableListOf<Interaction>()
- scope!!.launch {
+ scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/SelectableTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/SelectableTest.kt
index b74d3a8..cf783e1 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/SelectableTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/SelectableTest.kt
@@ -16,6 +16,7 @@
package androidx.compose.foundation
+import androidx.compose.foundation.interaction.FocusInteraction
import androidx.compose.foundation.interaction.HoverInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -23,15 +24,24 @@
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.testutils.first
+import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusManager
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.InputMode
+import androidx.compose.ui.input.InputModeManager
import androidx.compose.ui.platform.InspectableValue
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsProperties
@@ -180,7 +190,7 @@
fun selectableTest_interactionSource() {
val interactionSource = MutableInteractionSource()
- var scope: CoroutineScope? = null
+ lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
@@ -202,7 +212,7 @@
val interactions = mutableListOf<Interaction>()
- scope!!.launch {
+ scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
@@ -238,7 +248,7 @@
val interactionSource = MutableInteractionSource()
var emitSelectableText by mutableStateOf(true)
- var scope: CoroutineScope? = null
+ lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
@@ -262,7 +272,7 @@
val interactions = mutableListOf<Interaction>()
- scope!!.launch {
+ scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
@@ -302,7 +312,7 @@
fun selectableTest_interactionSource_hover() {
val interactionSource = MutableInteractionSource()
- var scope: CoroutineScope? = null
+ lateinit var scope: CoroutineScope
rule.setContent {
scope = rememberCoroutineScope()
@@ -322,7 +332,7 @@
val interactions = mutableListOf<Interaction>()
- scope!!.launch {
+ scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
@@ -352,6 +362,127 @@
}
@Test
+ fun selectableTest_interactionSource_focus_inTouchMode() {
+ val interactionSource = MutableInteractionSource()
+
+ lateinit var scope: CoroutineScope
+
+ val focusRequester = FocusRequester()
+
+ rule.setContent {
+ scope = rememberCoroutineScope()
+ Box {
+ Box(
+ Modifier
+ .focusRequester(focusRequester)
+ .selectable(
+ selected = true,
+ interactionSource = interactionSource,
+ indication = null,
+ onClick = {}
+ )
+ ) {
+ BasicText("SelectableText")
+ }
+ }
+ }
+
+ val interactions = mutableListOf<Interaction>()
+
+ scope.launch {
+ interactionSource.interactions.collect { interactions.add(it) }
+ }
+
+ rule.runOnIdle {
+ assertThat(interactions).isEmpty()
+ }
+
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ }
+
+ // Touch mode by default, so we shouldn't be focused
+ rule.runOnIdle {
+ assertThat(interactions).isEmpty()
+ }
+ }
+
+ @Test
+ fun selectableTest_interactionSource_focus_inKeyboardMode() {
+ val interactionSource = MutableInteractionSource()
+
+ lateinit var scope: CoroutineScope
+
+ val focusRequester = FocusRequester()
+
+ lateinit var focusManager: FocusManager
+
+ val keyboardInputModeManager = object : InputModeManager {
+ override val inputMode = InputMode.Keyboard
+
+ @OptIn(ExperimentalComposeUiApi::class)
+ override fun requestInputMode(inputMode: InputMode) = true
+ }
+
+ rule.setContent {
+ scope = rememberCoroutineScope()
+ focusManager = LocalFocusManager.current
+ CompositionLocalProvider(LocalInputModeManager provides keyboardInputModeManager) {
+ Box {
+ Box(
+ Modifier
+ .focusRequester(focusRequester)
+ .selectable(
+ selected = true,
+ interactionSource = interactionSource,
+ indication = null,
+ onClick = {}
+ )
+ ) {
+ BasicText("SelectableText")
+ }
+ }
+ }
+ }
+
+ val interactions = mutableListOf<Interaction>()
+
+ scope.launch {
+ interactionSource.interactions.collect { interactions.add(it) }
+ }
+
+ rule.runOnIdle {
+ assertThat(interactions).isEmpty()
+ }
+
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ }
+
+ // Keyboard mode, so we should now be focused and see an interaction
+ rule.runOnIdle {
+ assertThat(interactions).hasSize(1)
+ assertThat(interactions.first()).isInstanceOf(FocusInteraction.Focus::class.java)
+ }
+
+ rule.runOnIdle {
+ focusManager.clearFocus()
+ }
+
+ rule.runOnIdle {
+ assertThat(interactions).hasSize(2)
+ assertThat(interactions.first()).isInstanceOf(FocusInteraction.Focus::class.java)
+ assertThat(interactions[1])
+ .isInstanceOf(FocusInteraction.Unfocus::class.java)
+ assertThat((interactions[1] as FocusInteraction.Unfocus).focus)
+ .isEqualTo(interactions[0])
+ }
+ }
+
+ // TODO: b/202871171 - add test for changing between keyboard mode and touch mode, making sure
+ // it resets existing focus
+
+ @Test
fun selectableTest_testInspectorValue_noIndication() {
rule.setContent {
val modifier = Modifier.selectable(false) {} as InspectableValue
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ToggleableTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ToggleableTest.kt
index 3de6204..a01e6a7 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ToggleableTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ToggleableTest.kt
@@ -16,6 +16,7 @@
package androidx.compose.foundation
+import androidx.compose.foundation.interaction.FocusInteraction
import androidx.compose.foundation.interaction.HoverInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -27,15 +28,24 @@
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.selection.triStateToggleable
import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.testutils.first
+import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusManager
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.InputMode
+import androidx.compose.ui.input.InputModeManager
import androidx.compose.ui.platform.InspectableValue
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsProperties
@@ -275,7 +285,7 @@
fun toggleableTest_interactionSource() {
val interactionSource = MutableInteractionSource()
- var scope: CoroutineScope? = null
+ lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
@@ -297,7 +307,7 @@
val interactions = mutableListOf<Interaction>()
- scope!!.launch {
+ scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
@@ -333,7 +343,7 @@
val interactionSource = MutableInteractionSource()
var emitToggleableText by mutableStateOf(true)
- var scope: CoroutineScope? = null
+ lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
@@ -357,7 +367,7 @@
val interactions = mutableListOf<Interaction>()
- scope!!.launch {
+ scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
@@ -397,7 +407,7 @@
fun toggleableTest_interactionSource_hover() {
val interactionSource = MutableInteractionSource()
- var scope: CoroutineScope? = null
+ lateinit var scope: CoroutineScope
rule.setContent {
scope = rememberCoroutineScope()
@@ -417,7 +427,7 @@
val interactions = mutableListOf<Interaction>()
- scope!!.launch {
+ scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
@@ -447,6 +457,127 @@
}
@Test
+ fun toggleableTest_interactionSource_focus_inTouchMode() {
+ val interactionSource = MutableInteractionSource()
+
+ lateinit var scope: CoroutineScope
+
+ val focusRequester = FocusRequester()
+
+ rule.setContent {
+ scope = rememberCoroutineScope()
+ Box {
+ Box(
+ Modifier
+ .focusRequester(focusRequester)
+ .toggleable(
+ value = true,
+ interactionSource = interactionSource,
+ indication = null,
+ onValueChange = {}
+ )
+ ) {
+ BasicText("ToggleableText")
+ }
+ }
+ }
+
+ val interactions = mutableListOf<Interaction>()
+
+ scope.launch {
+ interactionSource.interactions.collect { interactions.add(it) }
+ }
+
+ rule.runOnIdle {
+ assertThat(interactions).isEmpty()
+ }
+
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ }
+
+ // Touch mode by default, so we shouldn't be focused
+ rule.runOnIdle {
+ assertThat(interactions).isEmpty()
+ }
+ }
+
+ @Test
+ fun toggleableTest_interactionSource_focus_inKeyboardMode() {
+ val interactionSource = MutableInteractionSource()
+
+ lateinit var scope: CoroutineScope
+
+ val focusRequester = FocusRequester()
+
+ lateinit var focusManager: FocusManager
+
+ val keyboardInputModeManager = object : InputModeManager {
+ override val inputMode = InputMode.Keyboard
+
+ @OptIn(ExperimentalComposeUiApi::class)
+ override fun requestInputMode(inputMode: InputMode) = true
+ }
+
+ rule.setContent {
+ scope = rememberCoroutineScope()
+ focusManager = LocalFocusManager.current
+ CompositionLocalProvider(LocalInputModeManager provides keyboardInputModeManager) {
+ Box {
+ Box(
+ Modifier
+ .focusRequester(focusRequester)
+ .toggleable(
+ value = true,
+ interactionSource = interactionSource,
+ indication = null,
+ onValueChange = {}
+ )
+ ) {
+ BasicText("ToggleableText")
+ }
+ }
+ }
+ }
+
+ val interactions = mutableListOf<Interaction>()
+
+ scope.launch {
+ interactionSource.interactions.collect { interactions.add(it) }
+ }
+
+ rule.runOnIdle {
+ assertThat(interactions).isEmpty()
+ }
+
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ }
+
+ // Keyboard mode, so we should now be focused and see an interaction
+ rule.runOnIdle {
+ assertThat(interactions).hasSize(1)
+ assertThat(interactions.first()).isInstanceOf(FocusInteraction.Focus::class.java)
+ }
+
+ rule.runOnIdle {
+ focusManager.clearFocus()
+ }
+
+ rule.runOnIdle {
+ assertThat(interactions).hasSize(2)
+ assertThat(interactions.first()).isInstanceOf(FocusInteraction.Focus::class.java)
+ assertThat(interactions[1])
+ .isInstanceOf(FocusInteraction.Unfocus::class.java)
+ assertThat((interactions[1] as FocusInteraction.Unfocus).focus)
+ .isEqualTo(interactions[0])
+ }
+ }
+
+ // TODO: b/202871171 - add test for changing between keyboard mode and touch mode, making sure
+ // it resets existing focus
+
+ @Test
fun toggleableText_testInspectorValue_noIndication() {
rule.setContent {
val modifier = Modifier.toggleable(value = true, onValueChange = {}) as InspectableValue
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Clickable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Clickable.kt
index e752677..83b26bf 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Clickable.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Clickable.kt
@@ -394,8 +394,6 @@
*/
internal expect val TapIndicationDelay: Long
-@Composable
-@Suppress("ComposableModifierFactory")
internal fun Modifier.genericClickableWithoutGesture(
gestureModifiers: Modifier,
interactionSource: MutableInteractionSource,
@@ -426,6 +424,7 @@
return this
.then(semanticModifier)
.indication(interactionSource, indication)
- .hoverable(interactionSource = interactionSource)
+ .hoverable(enabled = enabled, interactionSource = interactionSource)
+ .focusableInNonTouchMode(enabled = enabled, interactionSource = interactionSource)
.then(gestureModifiers)
}
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Focusable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Focusable.kt
index 18786ba..582e52c 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Focusable.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Focusable.kt
@@ -27,10 +27,13 @@
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
+import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusTarget
import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.input.InputMode
import androidx.compose.ui.layout.RelocationRequester
import androidx.compose.ui.layout.relocationRequester
+import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.semantics.focused
import androidx.compose.ui.semantics.semantics
@@ -122,3 +125,25 @@
Modifier
}
}
+
+// TODO: b/202856230 - consider either making this / a similar API public, or add a parameter to
+// focusable to configure this behavior.
+/**
+ * [focusable] but only when not in touch mode - when [LocalInputModeManager] is
+ * not [InputMode.Touch]
+ */
+internal fun Modifier.focusableInNonTouchMode(
+ enabled: Boolean,
+ interactionSource: MutableInteractionSource?
+) = composed(
+ inspectorInfo = debugInspectorInfo {
+ name = "focusableInNonTouchMode"
+ properties["enabled"] = enabled
+ properties["interactionSource"] = interactionSource
+ }
+) {
+ val inputModeManager = LocalInputModeManager.current
+ Modifier
+ .focusProperties { canFocus = inputModeManager.inputMode != InputMode.Touch }
+ .focusable(enabled, interactionSource)
+}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Indication.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Indication.kt
index d85462d..e15bfe4 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Indication.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Indication.kt
@@ -17,6 +17,7 @@
package androidx.compose.foundation
import androidx.compose.foundation.interaction.InteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.runtime.Composable
@@ -149,13 +150,14 @@
private class DefaultDebugIndicationInstance(
private val isPressed: State<Boolean>,
- private val isHovered: State<Boolean>
+ private val isHovered: State<Boolean>,
+ private val isFocused: State<Boolean>,
) : IndicationInstance {
override fun ContentDrawScope.drawIndication() {
drawContent()
if (isPressed.value) {
drawRect(color = Color.Black.copy(alpha = 0.3f), size = size)
- } else if (isHovered.value) {
+ } else if (isHovered.value || isFocused.value) {
drawRect(color = Color.Black.copy(alpha = 0.1f), size = size)
}
}
@@ -165,8 +167,9 @@
override fun rememberUpdatedInstance(interactionSource: InteractionSource): IndicationInstance {
val isPressed = interactionSource.collectIsPressedAsState()
val isHovered = interactionSource.collectIsHoveredAsState()
+ val isFocused = interactionSource.collectIsFocusedAsState()
return remember(interactionSource) {
- DefaultDebugIndicationInstance(isPressed, isHovered)
+ DefaultDebugIndicationInstance(isPressed, isHovered, isFocused)
}
}
}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/selection/Toggleable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/selection/Toggleable.kt
index 8d79b16..d954661 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/selection/Toggleable.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/selection/Toggleable.kt
@@ -19,6 +19,7 @@
import androidx.compose.foundation.Indication
import androidx.compose.foundation.PressedInteractionSourceDisposableEffect
import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.focusableInNonTouchMode
import androidx.compose.foundation.gestures.detectTapAndPress
import androidx.compose.foundation.handlePressInteraction
import androidx.compose.foundation.hoverable
@@ -269,6 +270,7 @@
this
.then(semantics)
.indication(interactionSource, indication)
- .hoverable(interactionSource = interactionSource)
+ .hoverable(enabled = enabled, interactionSource = interactionSource)
+ .focusableInNonTouchMode(enabled = enabled, interactionSource = interactionSource)
.then(gestures)
}
\ No newline at end of file
diff --git a/compose/material/material-ripple/src/commonMain/kotlin/androidx/compose/material/ripple/Ripple.kt b/compose/material/material-ripple/src/commonMain/kotlin/androidx/compose/material/ripple/Ripple.kt
index 1ae18ca..e6ca169 100644
--- a/compose/material/material-ripple/src/commonMain/kotlin/androidx/compose/material/ripple/Ripple.kt
+++ b/compose/material/material-ripple/src/commonMain/kotlin/androidx/compose/material/ripple/Ripple.kt
@@ -23,6 +23,7 @@
import androidx.compose.foundation.Indication
import androidx.compose.foundation.IndicationInstance
import androidx.compose.foundation.interaction.DragInteraction
+import androidx.compose.foundation.interaction.FocusInteraction
import androidx.compose.foundation.interaction.HoverInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.InteractionSource
@@ -247,7 +248,6 @@
private var currentInteraction: Interaction? = null
fun handleInteraction(interaction: Interaction, scope: CoroutineScope) {
- // TODO: handle focus states
when (interaction) {
is HoverInteraction.Enter -> {
interactions.add(interaction)
@@ -255,6 +255,12 @@
is HoverInteraction.Exit -> {
interactions.remove(interaction.enter)
}
+ is FocusInteraction.Focus -> {
+ interactions.add(interaction)
+ }
+ is FocusInteraction.Unfocus -> {
+ interactions.remove(interaction.focus)
+ }
is DragInteraction.Start -> {
interactions.add(interaction)
}
@@ -274,6 +280,7 @@
if (newInteraction != null) {
val targetAlpha = when (interaction) {
is HoverInteraction.Enter -> rippleAlpha.value.hoveredAlpha
+ is FocusInteraction.Focus -> rippleAlpha.value.focusedAlpha
is DragInteraction.Start -> rippleAlpha.value.draggedAlpha
else -> 0f
}
@@ -319,26 +326,24 @@
/**
* @return the [AnimationSpec] used when transitioning to [interaction], either from a previous
* state, or no state.
- *
- * TODO: handle focus states
*/
private fun incomingStateLayerAnimationSpecFor(interaction: Interaction): AnimationSpec<Float> {
return when (interaction) {
- is DragInteraction.Start -> TweenSpec(durationMillis = 45, easing = LinearEasing)
is HoverInteraction.Enter -> DefaultTweenSpec
+ is FocusInteraction.Focus -> TweenSpec(durationMillis = 45, easing = LinearEasing)
+ is DragInteraction.Start -> TweenSpec(durationMillis = 45, easing = LinearEasing)
else -> DefaultTweenSpec
}
}
/**
* @return the [AnimationSpec] used when transitioning away from [interaction], to no state.
- *
- * TODO: handle focus states
*/
private fun outgoingStateLayerAnimationSpecFor(interaction: Interaction?): AnimationSpec<Float> {
return when (interaction) {
- is DragInteraction.Start -> TweenSpec(durationMillis = 150, easing = LinearEasing)
is HoverInteraction.Enter -> DefaultTweenSpec
+ is FocusInteraction.Focus -> DefaultTweenSpec
+ is DragInteraction.Start -> TweenSpec(durationMillis = 150, easing = LinearEasing)
else -> DefaultTweenSpec
}
}
diff --git a/compose/material/material-ripple/src/commonMain/kotlin/androidx/compose/material/ripple/RippleTheme.kt b/compose/material/material-ripple/src/commonMain/kotlin/androidx/compose/material/ripple/RippleTheme.kt
index 09118d6..3197721 100644
--- a/compose/material/material-ripple/src/commonMain/kotlin/androidx/compose/material/ripple/RippleTheme.kt
+++ b/compose/material/material-ripple/src/commonMain/kotlin/androidx/compose/material/ripple/RippleTheme.kt
@@ -104,7 +104,7 @@
* RippleAlpha defines the alpha of the ripple / state layer for different [Interaction]s.
*
* @property draggedAlpha the alpha used when the ripple is dragged
- * @property focusedAlpha not currently supported
+ * @property focusedAlpha the alpha used when the ripple is focused
* @property hoveredAlpha the alpha used when the ripple is hovered
* @property pressedAlpha the alpha used when the ripple is pressed
*/
diff --git a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/ButtonScreenshotTest.kt b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/ButtonScreenshotTest.kt
index 58a3991..c3f82d1 100644
--- a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/ButtonScreenshotTest.kt
+++ b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/ButtonScreenshotTest.kt
@@ -21,6 +21,9 @@
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.testutils.assertAgainstGolden
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.hasClickAction
@@ -121,4 +124,32 @@
.captureToImage()
.assertAgainstGolden(screenshotRule, "button_hover")
}
+
+ @Test
+ fun focus() {
+ val focusRequester = FocusRequester()
+
+ rule.setMaterialContent {
+ Box(Modifier.requiredSize(200.dp, 100.dp).wrapContentSize()) {
+ Button(
+ onClick = { },
+ modifier = Modifier
+ // Normally this is only focusable in non-touch mode, so let's force it to
+ // always be focusable so we can test how it appears
+ .focusProperties { canFocus = true }
+ .focusRequester(focusRequester)
+ ) { }
+ }
+ }
+
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ }
+
+ rule.waitForIdle()
+
+ rule.onRoot()
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "button_focus")
+ }
}
\ No newline at end of file
diff --git a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/CheckboxScreenshotTest.kt b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/CheckboxScreenshotTest.kt
index 0d8bbba..b133192 100644
--- a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/CheckboxScreenshotTest.kt
+++ b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/CheckboxScreenshotTest.kt
@@ -22,6 +22,9 @@
import androidx.compose.testutils.assertAgainstGolden
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.test.ExperimentalTestApi
@@ -226,6 +229,33 @@
assertToggeableAgainstGolden("checkbox_hover")
}
+ @Test
+ fun checkBoxTest_focus() {
+ val focusRequester = FocusRequester()
+
+ rule.setMaterialContent {
+ Box(wrap.testTag(wrapperTestTag)) {
+ Checkbox(
+ modifier = wrap
+ // Normally this is only focusable in non-touch mode, so let's force it to
+ // always be focusable so we can test how it appears
+ .focusProperties { canFocus = true }
+ .focusRequester(focusRequester),
+ checked = true,
+ onCheckedChange = { }
+ )
+ }
+ }
+
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ }
+
+ rule.waitForIdle()
+
+ assertToggeableAgainstGolden("checkbox_focus")
+ }
+
private fun assertToggeableAgainstGolden(goldenName: String) {
// TODO: replace with find(isToggeable()) after b/157687898 is fixed
rule.onNodeWithTag(wrapperTestTag)
diff --git a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/FloatingActionButtonScreenshotTest.kt b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/FloatingActionButtonScreenshotTest.kt
index 08cac4f..f8efa44 100644
--- a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/FloatingActionButtonScreenshotTest.kt
+++ b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/FloatingActionButtonScreenshotTest.kt
@@ -23,6 +23,9 @@
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.testutils.assertAgainstGolden
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.hasClickAction
@@ -142,4 +145,34 @@
.captureToImage()
.assertAgainstGolden(screenshotRule, "fab_hover")
}
+
+ @Test
+ fun focus() {
+ val focusRequester = FocusRequester()
+
+ rule.setMaterialContent {
+ Box(Modifier.requiredSize(100.dp, 100.dp).wrapContentSize()) {
+ FloatingActionButton(
+ onClick = { },
+ modifier = Modifier
+ // Normally this is only focusable in non-touch mode, so let's force it to
+ // always be focusable so we can test how it appears
+ .focusProperties { canFocus = true }
+ .focusRequester(focusRequester)
+ ) {
+ Icon(Icons.Filled.Favorite, contentDescription = null)
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ }
+
+ rule.waitForIdle()
+
+ rule.onRoot()
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "fab_focus")
+ }
}
diff --git a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/MaterialRippleThemeTest.kt b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/MaterialRippleThemeTest.kt
index d1509f7..50e5533 100644
--- a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/MaterialRippleThemeTest.kt
+++ b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/MaterialRippleThemeTest.kt
@@ -26,6 +26,7 @@
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.indication
+import androidx.compose.foundation.interaction.FocusInteraction
import androidx.compose.foundation.interaction.HoverInteraction
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -129,6 +130,28 @@
}
@Test
+ fun bounded_lightTheme_highLuminance_focused() {
+ val interactionSource = MutableInteractionSource()
+
+ val contentColor = Color.White
+
+ val scope = rule.setRippleContent(
+ interactionSource = interactionSource,
+ bounded = true,
+ lightTheme = true,
+ contentColor = contentColor
+ )
+
+ assertRippleMatches(
+ scope,
+ interactionSource,
+ FocusInteraction.Focus(),
+ "ripple_bounded_light_highluminance_focused",
+ calculateResultingRippleColor(contentColor, rippleOpacity = 0.24f)
+ )
+ }
+
+ @Test
fun bounded_lightTheme_highLuminance_dragged() {
val interactionSource = MutableInteractionSource()
@@ -195,6 +218,28 @@
}
@Test
+ fun bounded_lightTheme_lowLuminance_focused() {
+ val interactionSource = MutableInteractionSource()
+
+ val contentColor = Color.Black
+
+ val scope = rule.setRippleContent(
+ interactionSource = interactionSource,
+ bounded = true,
+ lightTheme = true,
+ contentColor = contentColor
+ )
+
+ assertRippleMatches(
+ scope,
+ interactionSource,
+ FocusInteraction.Focus(),
+ "ripple_bounded_light_lowluminance_focused",
+ calculateResultingRippleColor(contentColor, rippleOpacity = 0.12f)
+ )
+ }
+
+ @Test
fun bounded_lightTheme_lowLuminance_dragged() {
val interactionSource = MutableInteractionSource()
@@ -261,6 +306,28 @@
}
@Test
+ fun bounded_darkTheme_highLuminance_focused() {
+ val interactionSource = MutableInteractionSource()
+
+ val contentColor = Color.White
+
+ val scope = rule.setRippleContent(
+ interactionSource = interactionSource,
+ bounded = true,
+ lightTheme = false,
+ contentColor = contentColor
+ )
+
+ assertRippleMatches(
+ scope,
+ interactionSource,
+ FocusInteraction.Focus(),
+ "ripple_bounded_dark_highluminance_focused",
+ calculateResultingRippleColor(contentColor, rippleOpacity = 0.12f)
+ )
+ }
+
+ @Test
fun bounded_darkTheme_highLuminance_dragged() {
val interactionSource = MutableInteractionSource()
@@ -329,6 +396,29 @@
}
@Test
+ fun bounded_darkTheme_lowLuminance_focused() {
+ val interactionSource = MutableInteractionSource()
+
+ val contentColor = Color.Black
+
+ val scope = rule.setRippleContent(
+ interactionSource = interactionSource,
+ bounded = true,
+ lightTheme = false,
+ contentColor = contentColor
+ )
+
+ assertRippleMatches(
+ scope,
+ interactionSource,
+ FocusInteraction.Focus(),
+ "ripple_bounded_dark_lowluminance_focused",
+ // Low luminance content in dark theme should use a white ripple by default
+ calculateResultingRippleColor(Color.White, rippleOpacity = 0.12f)
+ )
+ }
+
+ @Test
fun bounded_darkTheme_lowLuminance_dragged() {
val interactionSource = MutableInteractionSource()
@@ -396,6 +486,28 @@
}
@Test
+ fun unbounded_lightTheme_highLuminance_focused() {
+ val interactionSource = MutableInteractionSource()
+
+ val contentColor = Color.White
+
+ val scope = rule.setRippleContent(
+ interactionSource = interactionSource,
+ bounded = false,
+ lightTheme = true,
+ contentColor = contentColor
+ )
+
+ assertRippleMatches(
+ scope,
+ interactionSource,
+ FocusInteraction.Focus(),
+ "ripple_unbounded_light_highluminance_focused",
+ calculateResultingRippleColor(contentColor, rippleOpacity = 0.24f)
+ )
+ }
+
+ @Test
fun unbounded_lightTheme_highLuminance_dragged() {
val interactionSource = MutableInteractionSource()
@@ -462,6 +574,28 @@
}
@Test
+ fun unbounded_lightTheme_lowLuminance_focused() {
+ val interactionSource = MutableInteractionSource()
+
+ val contentColor = Color.Black
+
+ val scope = rule.setRippleContent(
+ interactionSource = interactionSource,
+ bounded = false,
+ lightTheme = true,
+ contentColor = contentColor
+ )
+
+ assertRippleMatches(
+ scope,
+ interactionSource,
+ FocusInteraction.Focus(),
+ "ripple_unbounded_light_lowluminance_focused",
+ calculateResultingRippleColor(contentColor, rippleOpacity = 0.12f)
+ )
+ }
+
+ @Test
fun unbounded_lightTheme_lowLuminance_dragged() {
val interactionSource = MutableInteractionSource()
@@ -528,6 +662,28 @@
}
@Test
+ fun unbounded_darkTheme_highLuminance_focused() {
+ val interactionSource = MutableInteractionSource()
+
+ val contentColor = Color.White
+
+ val scope = rule.setRippleContent(
+ interactionSource = interactionSource,
+ bounded = false,
+ lightTheme = false,
+ contentColor = contentColor
+ )
+
+ assertRippleMatches(
+ scope,
+ interactionSource,
+ FocusInteraction.Focus(),
+ "ripple_unbounded_dark_highluminance_focused",
+ calculateResultingRippleColor(contentColor, rippleOpacity = 0.12f)
+ )
+ }
+
+ @Test
fun unbounded_darkTheme_highLuminance_dragged() {
val interactionSource = MutableInteractionSource()
@@ -596,6 +752,29 @@
}
@Test
+ fun unbounded_darkTheme_lowLuminance_focused() {
+ val interactionSource = MutableInteractionSource()
+
+ val contentColor = Color.Black
+
+ val scope = rule.setRippleContent(
+ interactionSource = interactionSource,
+ bounded = false,
+ lightTheme = false,
+ contentColor = contentColor
+ )
+
+ assertRippleMatches(
+ scope,
+ interactionSource,
+ FocusInteraction.Focus(),
+ "ripple_unbounded_dark_lowluminance_focused",
+ // Low luminance content in dark theme should use a white ripple by default
+ calculateResultingRippleColor(Color.White, rippleOpacity = 0.12f)
+ )
+ }
+
+ @Test
fun unbounded_darkTheme_lowLuminance_dragged() {
val interactionSource = MutableInteractionSource()
@@ -721,6 +900,57 @@
}
@Test
+ fun customRippleTheme_focused() {
+ val interactionSource = MutableInteractionSource()
+
+ val contentColor = Color.Black
+
+ val rippleColor = Color.Red
+ val expectedAlpha = 0.5f
+ val rippleAlpha = RippleAlpha(expectedAlpha, expectedAlpha, expectedAlpha, expectedAlpha)
+
+ val rippleTheme = object : RippleTheme {
+ @Composable
+ override fun defaultColor() = rippleColor
+
+ @Composable
+ override fun rippleAlpha() = rippleAlpha
+ }
+
+ var scope: CoroutineScope? = null
+
+ rule.setContent {
+ scope = rememberCoroutineScope()
+ MaterialTheme {
+ CompositionLocalProvider(LocalRippleTheme provides rippleTheme) {
+ Surface(contentColor = contentColor) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ RippleBoxWithBackground(
+ interactionSource,
+ rememberRipple(),
+ bounded = true
+ )
+ }
+ }
+ }
+ }
+ }
+
+ val expectedColor = calculateResultingRippleColor(
+ rippleColor,
+ rippleOpacity = expectedAlpha
+ )
+
+ assertRippleMatches(
+ scope!!,
+ interactionSource,
+ FocusInteraction.Focus(),
+ "ripple_customtheme_focused",
+ expectedColor
+ )
+ }
+
+ @Test
fun customRippleTheme_dragged() {
val interactionSource = MutableInteractionSource()
diff --git a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/RadioButtonScreenshotTest.kt b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/RadioButtonScreenshotTest.kt
index 99fe9e3..c34f5cc 100644
--- a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/RadioButtonScreenshotTest.kt
+++ b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/RadioButtonScreenshotTest.kt
@@ -24,6 +24,9 @@
import androidx.compose.testutils.assertAgainstGolden
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.captureToImage
@@ -114,6 +117,31 @@
}
@Test
+ fun radioButtonTest_focused() {
+ val focusRequester = FocusRequester()
+
+ rule.setMaterialContent {
+ Box(wrap.testTag(wrapperTestTag)) {
+ RadioButton(
+ selected = false,
+ onClick = {},
+ modifier = Modifier
+ // Normally this is only focusable in non-touch mode, so let's force it to
+ // always be focusable so we can test how it appears
+ .focusProperties { canFocus = true }
+ .focusRequester(focusRequester)
+ )
+ }
+ }
+
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ }
+
+ assertSelectableAgainstGolden("radioButton_focused")
+ }
+
+ @Test
fun radioButtonTest_disabled_selected() {
rule.setMaterialContent {
Box(wrap.testTag(wrapperTestTag)) {
diff --git a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/SwitchScreenshotTest.kt b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/SwitchScreenshotTest.kt
index ad24ccf..59ce852 100644
--- a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/SwitchScreenshotTest.kt
+++ b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/SwitchScreenshotTest.kt
@@ -26,6 +26,9 @@
import androidx.compose.testutils.assertAgainstGolden
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
@@ -255,6 +258,33 @@
assertToggeableAgainstGolden("switch_hover")
}
+ @Test
+ fun switchTest_focus() {
+ val focusRequester = FocusRequester()
+
+ rule.setMaterialContent {
+ Box(wrapperModifier) {
+ Switch(
+ checked = true,
+ onCheckedChange = { },
+ modifier = Modifier
+ // Normally this is only focusable in non-touch mode, so let's force it to
+ // always be focusable so we can test how it appears
+ .focusProperties { canFocus = true }
+ .focusRequester(focusRequester)
+ )
+ }
+ }
+
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ }
+
+ rule.waitForIdle()
+
+ assertToggeableAgainstGolden("switch_focus")
+ }
+
private fun assertToggeableAgainstGolden(goldenName: String) {
rule.onNodeWithTag(wrapperTestTag)
.captureToImage()
diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Button.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Button.kt
index 47e85a9..c866582 100644
--- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Button.kt
+++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Button.kt
@@ -19,6 +19,7 @@
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.VectorConverter
import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.interaction.FocusInteraction
import androidx.compose.foundation.interaction.HoverInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.InteractionSource
@@ -328,7 +329,6 @@
*/
val IconSpacing = 8.dp
- // TODO: b/152525426 add support for focused states
/**
* Creates a [ButtonElevation] that will animate between the provided values according to the
* Material specification for a [Button].
@@ -363,7 +363,7 @@
* is pressed.
* @param disabledElevation the elevation to use when the [Button] is not enabled.
* @param hoveredElevation the elevation to use when the [Button] is enabled and is hovered.
- * @param focusedElevation not currently supported.
+ * @param focusedElevation the elevation to use when the [Button] is enabled and is focused.
*/
@Suppress("UNUSED_PARAMETER")
@Composable
@@ -374,12 +374,19 @@
hoveredElevation: Dp = 4.dp,
focusedElevation: Dp = 4.dp,
): ButtonElevation {
- return remember(defaultElevation, pressedElevation, disabledElevation, hoveredElevation) {
+ return remember(
+ defaultElevation,
+ pressedElevation,
+ disabledElevation,
+ hoveredElevation,
+ focusedElevation
+ ) {
DefaultButtonElevation(
defaultElevation = defaultElevation,
pressedElevation = pressedElevation,
disabledElevation = disabledElevation,
hoveredElevation = hoveredElevation,
+ focusedElevation = focusedElevation
)
}
}
@@ -491,6 +498,7 @@
private val pressedElevation: Dp,
private val disabledElevation: Dp,
private val hoveredElevation: Dp,
+ private val focusedElevation: Dp,
) : ButtonElevation {
@Composable
override fun elevation(enabled: Boolean, interactionSource: InteractionSource): State<Dp> {
@@ -504,6 +512,12 @@
is HoverInteraction.Exit -> {
interactions.remove(interaction.enter)
}
+ is FocusInteraction.Focus -> {
+ interactions.add(interaction)
+ }
+ is FocusInteraction.Unfocus -> {
+ interactions.remove(interaction.focus)
+ }
is PressInteraction.Press -> {
interactions.add(interaction)
}
@@ -525,6 +539,7 @@
when (interaction) {
is PressInteraction.Press -> pressedElevation
is HoverInteraction.Enter -> hoveredElevation
+ is FocusInteraction.Focus -> focusedElevation
else -> defaultElevation
}
}
@@ -541,6 +556,7 @@
val lastInteraction = when (animatable.targetValue) {
pressedElevation -> PressInteraction.Press(Offset.Zero)
hoveredElevation -> HoverInteraction.Enter()
+ focusedElevation -> FocusInteraction.Focus()
else -> null
}
animatable.animateElevation(
diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Elevation.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Elevation.kt
index 56e3198..bb9c2f4 100644
--- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Elevation.kt
+++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Elevation.kt
@@ -22,6 +22,7 @@
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.TweenSpec
import androidx.compose.foundation.interaction.DragInteraction
+import androidx.compose.foundation.interaction.FocusInteraction
import androidx.compose.foundation.interaction.HoverInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.PressInteraction
@@ -81,6 +82,7 @@
is PressInteraction.Press -> DefaultIncomingSpec
is DragInteraction.Start -> DefaultIncomingSpec
is HoverInteraction.Enter -> DefaultIncomingSpec
+ is FocusInteraction.Focus -> DefaultIncomingSpec
else -> null
}
}
@@ -96,6 +98,7 @@
is PressInteraction.Press -> DefaultOutgoingSpec
is DragInteraction.Start -> DefaultOutgoingSpec
is HoverInteraction.Enter -> HoveredOutgoingSpec
+ is FocusInteraction.Focus -> DefaultOutgoingSpec
else -> null
}
}
diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/FloatingActionButton.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/FloatingActionButton.kt
index ef23afe..59d7247 100644
--- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/FloatingActionButton.kt
+++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/FloatingActionButton.kt
@@ -18,6 +18,7 @@
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.VectorConverter
+import androidx.compose.foundation.interaction.FocusInteraction
import androidx.compose.foundation.interaction.HoverInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.InteractionSource
@@ -206,7 +207,6 @@
* Contains the default values used by [FloatingActionButton]
*/
object FloatingActionButtonDefaults {
- // TODO: b/152525426 add support for focused states
/**
* Creates a [FloatingActionButtonElevation] that will animate between the provided values
* according to the Material specification.
@@ -238,7 +238,8 @@
* pressed.
* @param hoveredElevation the elevation to use when the [FloatingActionButton] is
* hovered.
- * @param focusedElevation not currently supported.
+ * @param focusedElevation the elevation to use when the [FloatingActionButton] is
+ * focused.
*/
@Suppress("UNUSED_PARAMETER")
@Composable
@@ -248,11 +249,12 @@
hoveredElevation: Dp = 8.dp,
focusedElevation: Dp = 8.dp,
): FloatingActionButtonElevation {
- return remember(defaultElevation, pressedElevation, hoveredElevation) {
+ return remember(defaultElevation, pressedElevation, hoveredElevation, focusedElevation) {
DefaultFloatingActionButtonElevation(
defaultElevation = defaultElevation,
pressedElevation = pressedElevation,
hoveredElevation = hoveredElevation,
+ focusedElevation = focusedElevation
)
}
}
@@ -265,7 +267,8 @@
private class DefaultFloatingActionButtonElevation(
private val defaultElevation: Dp,
private val pressedElevation: Dp,
- private val hoveredElevation: Dp
+ private val hoveredElevation: Dp,
+ private val focusedElevation: Dp
) : FloatingActionButtonElevation {
@Composable
override fun elevation(interactionSource: InteractionSource): State<Dp> {
@@ -279,6 +282,12 @@
is HoverInteraction.Exit -> {
interactions.remove(interaction.enter)
}
+ is FocusInteraction.Focus -> {
+ interactions.add(interaction)
+ }
+ is FocusInteraction.Unfocus -> {
+ interactions.remove(interaction.focus)
+ }
is PressInteraction.Press -> {
interactions.add(interaction)
}
@@ -297,6 +306,7 @@
val target = when (interaction) {
is PressInteraction.Press -> pressedElevation
is HoverInteraction.Enter -> hoveredElevation
+ is FocusInteraction.Focus -> focusedElevation
else -> defaultElevation
}
@@ -306,6 +316,7 @@
val lastInteraction = when (animatable.targetValue) {
pressedElevation -> PressInteraction.Press(Offset.Zero)
hoveredElevation -> HoverInteraction.Enter()
+ focusedElevation -> FocusInteraction.Focus()
else -> null
}
animatable.animateElevation(
diff --git a/compose/ui/ui-test/src/androidAndroidTest/kotlin/androidx/compose/ui/test/PrintToStringTest.kt b/compose/ui/ui-test/src/androidAndroidTest/kotlin/androidx/compose/ui/test/PrintToStringTest.kt
index fada0e7..183c479 100644
--- a/compose/ui/ui-test/src/androidAndroidTest/kotlin/androidx/compose/ui/test/PrintToStringTest.kt
+++ b/compose/ui/ui-test/src/androidAndroidTest/kotlin/androidx/compose/ui/test/PrintToStringTest.kt
@@ -130,6 +130,7 @@
| [Disabled]
| |-Node #X at (l=X, t=X, r=X, b=X)px
| Role = 'Button'
+ | Focused = 'false'
| Text = '[Button]'
| Actions = [OnClick, GetTextLayoutResult]
| MergeDescendants = 'true'