Merge changes from topic "hoverable" into androidx-main
* changes:
Support hoverable indication, use hoverable in other modifiers and components
Implement HoverInteraction and Modifier.hoverable
diff --git a/compose/desktop/desktop/samples/src/jvmMain/kotlin/androidx/compose/desktop/examples/example1/Main.jvm.kt b/compose/desktop/desktop/samples/src/jvmMain/kotlin/androidx/compose/desktop/examples/example1/Main.jvm.kt
index c10893f..87ce6c3 100644
--- a/compose/desktop/desktop/samples/src/jvmMain/kotlin/androidx/compose/desktop/examples/example1/Main.jvm.kt
+++ b/compose/desktop/desktop/samples/src/jvmMain/kotlin/androidx/compose/desktop/examples/example1/Main.jvm.kt
@@ -58,6 +58,7 @@
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Slider
+import androidx.compose.material.Switch
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.TopAppBar
@@ -403,14 +404,25 @@
verticalAlignment = Alignment.CenterVertically
) {
Row {
- Row(modifier = Modifier.padding(4.dp)) {
- Checkbox(
+ Column {
+ Switch(
animation.value,
onCheckedChange = {
animation.value = it
}
)
- Text("Animation")
+ Row(
+ modifier = Modifier.padding(4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ animation.value,
+ onCheckedChange = {
+ animation.value = it
+ }
+ )
+ Text("Animation")
+ }
}
Button(
diff --git a/compose/foundation/foundation/api/current.txt b/compose/foundation/foundation/api/current.txt
index 9f9bbe2..26fd022 100644
--- a/compose/foundation/foundation/api/current.txt
+++ b/compose/foundation/foundation/api/current.txt
@@ -51,6 +51,10 @@
method public static androidx.compose.ui.Modifier focusable(androidx.compose.ui.Modifier, optional boolean enabled, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource);
}
+ public final class HoverableKt {
+ method public static androidx.compose.ui.Modifier hoverable(androidx.compose.ui.Modifier, androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional boolean enabled);
+ }
+
public final class ImageKt {
method @androidx.compose.runtime.Composable public static void Image(androidx.compose.ui.graphics.ImageBitmap bitmap, String? contentDescription, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.Alignment alignment, optional androidx.compose.ui.layout.ContentScale contentScale, optional float alpha, optional androidx.compose.ui.graphics.ColorFilter? colorFilter, optional int filterQuality);
method @androidx.compose.runtime.Composable public static void Image(androidx.compose.ui.graphics.vector.ImageVector imageVector, String? contentDescription, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.Alignment alignment, optional androidx.compose.ui.layout.ContentScale contentScale, optional float alpha, optional androidx.compose.ui.graphics.ColorFilter? colorFilter);
@@ -300,6 +304,23 @@
method @androidx.compose.runtime.Composable public static androidx.compose.runtime.State<java.lang.Boolean> collectIsFocusedAsState(androidx.compose.foundation.interaction.InteractionSource);
}
+ public interface HoverInteraction extends androidx.compose.foundation.interaction.Interaction {
+ }
+
+ public static final class HoverInteraction.Enter implements androidx.compose.foundation.interaction.HoverInteraction {
+ ctor public HoverInteraction.Enter();
+ }
+
+ public static final class HoverInteraction.Exit implements androidx.compose.foundation.interaction.HoverInteraction {
+ ctor public HoverInteraction.Exit(androidx.compose.foundation.interaction.HoverInteraction.Enter enter);
+ method public androidx.compose.foundation.interaction.HoverInteraction.Enter getEnter();
+ property public final androidx.compose.foundation.interaction.HoverInteraction.Enter enter;
+ }
+
+ public final class HoverInteractionKt {
+ method @androidx.compose.runtime.Composable public static androidx.compose.runtime.State<java.lang.Boolean> collectIsHoveredAsState(androidx.compose.foundation.interaction.InteractionSource);
+ }
+
public interface Interaction {
}
diff --git a/compose/foundation/foundation/api/public_plus_experimental_current.txt b/compose/foundation/foundation/api/public_plus_experimental_current.txt
index 0559738..56f8458 100644
--- a/compose/foundation/foundation/api/public_plus_experimental_current.txt
+++ b/compose/foundation/foundation/api/public_plus_experimental_current.txt
@@ -57,6 +57,10 @@
method public static androidx.compose.ui.Modifier focusable(androidx.compose.ui.Modifier, optional boolean enabled, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource);
}
+ public final class HoverableKt {
+ method public static androidx.compose.ui.Modifier hoverable(androidx.compose.ui.Modifier, androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional boolean enabled);
+ }
+
public final class ImageKt {
method @androidx.compose.runtime.Composable public static void Image(androidx.compose.ui.graphics.ImageBitmap bitmap, String? contentDescription, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.Alignment alignment, optional androidx.compose.ui.layout.ContentScale contentScale, optional float alpha, optional androidx.compose.ui.graphics.ColorFilter? colorFilter, optional int filterQuality);
method @androidx.compose.runtime.Composable public static void Image(androidx.compose.ui.graphics.vector.ImageVector imageVector, String? contentDescription, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.Alignment alignment, optional androidx.compose.ui.layout.ContentScale contentScale, optional float alpha, optional androidx.compose.ui.graphics.ColorFilter? colorFilter);
@@ -320,6 +324,23 @@
method @androidx.compose.runtime.Composable public static androidx.compose.runtime.State<java.lang.Boolean> collectIsFocusedAsState(androidx.compose.foundation.interaction.InteractionSource);
}
+ public interface HoverInteraction extends androidx.compose.foundation.interaction.Interaction {
+ }
+
+ public static final class HoverInteraction.Enter implements androidx.compose.foundation.interaction.HoverInteraction {
+ ctor public HoverInteraction.Enter();
+ }
+
+ public static final class HoverInteraction.Exit implements androidx.compose.foundation.interaction.HoverInteraction {
+ ctor public HoverInteraction.Exit(androidx.compose.foundation.interaction.HoverInteraction.Enter enter);
+ method public androidx.compose.foundation.interaction.HoverInteraction.Enter getEnter();
+ property public final androidx.compose.foundation.interaction.HoverInteraction.Enter enter;
+ }
+
+ public final class HoverInteractionKt {
+ method @androidx.compose.runtime.Composable public static androidx.compose.runtime.State<java.lang.Boolean> collectIsHoveredAsState(androidx.compose.foundation.interaction.InteractionSource);
+ }
+
public interface Interaction {
}
diff --git a/compose/foundation/foundation/api/restricted_current.txt b/compose/foundation/foundation/api/restricted_current.txt
index 9f9bbe2..26fd022 100644
--- a/compose/foundation/foundation/api/restricted_current.txt
+++ b/compose/foundation/foundation/api/restricted_current.txt
@@ -51,6 +51,10 @@
method public static androidx.compose.ui.Modifier focusable(androidx.compose.ui.Modifier, optional boolean enabled, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource);
}
+ public final class HoverableKt {
+ method public static androidx.compose.ui.Modifier hoverable(androidx.compose.ui.Modifier, androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional boolean enabled);
+ }
+
public final class ImageKt {
method @androidx.compose.runtime.Composable public static void Image(androidx.compose.ui.graphics.ImageBitmap bitmap, String? contentDescription, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.Alignment alignment, optional androidx.compose.ui.layout.ContentScale contentScale, optional float alpha, optional androidx.compose.ui.graphics.ColorFilter? colorFilter, optional int filterQuality);
method @androidx.compose.runtime.Composable public static void Image(androidx.compose.ui.graphics.vector.ImageVector imageVector, String? contentDescription, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.Alignment alignment, optional androidx.compose.ui.layout.ContentScale contentScale, optional float alpha, optional androidx.compose.ui.graphics.ColorFilter? colorFilter);
@@ -300,6 +304,23 @@
method @androidx.compose.runtime.Composable public static androidx.compose.runtime.State<java.lang.Boolean> collectIsFocusedAsState(androidx.compose.foundation.interaction.InteractionSource);
}
+ public interface HoverInteraction extends androidx.compose.foundation.interaction.Interaction {
+ }
+
+ public static final class HoverInteraction.Enter implements androidx.compose.foundation.interaction.HoverInteraction {
+ ctor public HoverInteraction.Enter();
+ }
+
+ public static final class HoverInteraction.Exit implements androidx.compose.foundation.interaction.HoverInteraction {
+ ctor public HoverInteraction.Exit(androidx.compose.foundation.interaction.HoverInteraction.Enter enter);
+ method public androidx.compose.foundation.interaction.HoverInteraction.Enter getEnter();
+ property public final androidx.compose.foundation.interaction.HoverInteraction.Enter enter;
+ }
+
+ public final class HoverInteractionKt {
+ method @androidx.compose.runtime.Composable public static androidx.compose.runtime.State<java.lang.Boolean> collectIsHoveredAsState(androidx.compose.foundation.interaction.InteractionSource);
+ }
+
public interface Interaction {
}
diff --git a/compose/foundation/foundation/build.gradle b/compose/foundation/foundation/build.gradle
index f31c312..0318f41 100644
--- a/compose/foundation/foundation/build.gradle
+++ b/compose/foundation/foundation/build.gradle
@@ -123,6 +123,7 @@
implementation(project(":compose:test-utils"))
implementation(project(":compose:ui:ui-test-font"))
implementation(project(":test:screenshot:screenshot"))
+ implementation(project(":internal-testutils-runtime"))
implementation("androidx.activity:activity-compose:1.3.1")
implementation(libs.testUiautomator)
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/HighLevelGesturesDemo.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/HighLevelGesturesDemo.kt
index 8d3efe7..05ce920 100644
--- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/HighLevelGesturesDemo.kt
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/HighLevelGesturesDemo.kt
@@ -19,17 +19,20 @@
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.samples.DraggableSample
import androidx.compose.foundation.samples.FocusableSample
+import androidx.compose.foundation.samples.HoverableSample
import androidx.compose.foundation.samples.ScrollableSample
import androidx.compose.foundation.samples.TransformableSample
+import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun HighLevelGesturesDemo() {
- Column {
+ Column(Modifier.verticalScroll(rememberScrollState())) {
DraggableSample()
Spacer(Modifier.height(50.dp))
ScrollableSample()
@@ -37,5 +40,7 @@
TransformableSample()
Spacer(Modifier.height(50.dp))
FocusableSample()
+ Spacer(Modifier.height(50.dp))
+ HoverableSample()
}
}
\ No newline at end of file
diff --git a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/HoverableSample.kt b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/HoverableSample.kt
new file mode 100644
index 0000000..411477d
--- /dev/null
+++ b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/HoverableSample.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2021 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.compose.foundation.samples
+
+import androidx.annotation.Sampled
+import androidx.compose.foundation.background
+import androidx.compose.foundation.hoverable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsHoveredAsState
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+
+@Sampled
+@Composable
+fun HoverableSample() {
+ // MutableInteractionSource to track changes of the component's interactions (like "hovered")
+ val interactionSource = remember { MutableInteractionSource() }
+ val isHovered by interactionSource.collectIsHoveredAsState()
+
+ // the color will change depending on the presence of a hover
+ Box(
+ modifier = Modifier
+ .size(128.dp)
+ .background(if (isHovered) Color.Red else Color.Blue)
+ .hoverable(interactionSource = interactionSource),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(if (isHovered) "Hovered" else "Unhovered")
+ }
+}
\ No newline at end of file
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 8a97de9..2f8b9d6 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.HoverInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
@@ -42,6 +43,7 @@
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertHasClickAction
@@ -58,6 +60,7 @@
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.unit.dp
@@ -820,6 +823,111 @@
}
}
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun clickableTest_interactionSource_hover() {
+ val interactionSource = MutableInteractionSource()
+
+ var scope: CoroutineScope? = null
+
+ rule.setContent {
+ scope = rememberCoroutineScope()
+ Box {
+ BasicText(
+ "ClickableText",
+ modifier = Modifier
+ .testTag("myClickable")
+ .combinedClickable(
+ interactionSource = interactionSource,
+ indication = null
+ ) {}
+ )
+ }
+ }
+
+ val interactions = mutableListOf<Interaction>()
+
+ scope!!.launch {
+ interactionSource.interactions.collect { interactions.add(it) }
+ }
+
+ rule.runOnIdle {
+ assertThat(interactions).isEmpty()
+ }
+
+ rule.onNodeWithTag("myClickable")
+ .performMouseInput { enter(center) }
+
+ rule.runOnIdle {
+ assertThat(interactions).hasSize(1)
+ assertThat(interactions.first()).isInstanceOf(HoverInteraction.Enter::class.java)
+ }
+
+ rule.onNodeWithTag("myClickable")
+ .performMouseInput { exit(Offset(-1f, -1f)) }
+
+ rule.runOnIdle {
+ assertThat(interactions).hasSize(2)
+ assertThat(interactions.first()).isInstanceOf(HoverInteraction.Enter::class.java)
+ assertThat(interactions[1])
+ .isInstanceOf(HoverInteraction.Exit::class.java)
+ assertThat((interactions[1] as HoverInteraction.Exit).enter)
+ .isEqualTo(interactions[0])
+ }
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun clickableTest_interactionSource_hover_and_press() {
+ val interactionSource = MutableInteractionSource()
+
+ var scope: CoroutineScope? = null
+
+ rule.setContent {
+ scope = rememberCoroutineScope()
+ Box {
+ BasicText(
+ "ClickableText",
+ modifier = Modifier
+ .testTag("myClickable")
+ .combinedClickable(
+ interactionSource = interactionSource,
+ indication = null
+ ) {}
+ )
+ }
+ }
+
+ val interactions = mutableListOf<Interaction>()
+
+ scope!!.launch {
+ interactionSource.interactions.collect { interactions.add(it) }
+ }
+
+ rule.runOnIdle {
+ assertThat(interactions).isEmpty()
+ }
+
+ rule.onNodeWithTag("myClickable")
+ .performMouseInput {
+ enter(center)
+ click()
+ exit(Offset(-1f, -1f))
+ }
+
+ rule.runOnIdle {
+ assertThat(interactions).hasSize(4)
+ assertThat(interactions[0]).isInstanceOf(HoverInteraction.Enter::class.java)
+ assertThat(interactions[1]).isInstanceOf(PressInteraction.Press::class.java)
+ assertThat(interactions[2]).isInstanceOf(PressInteraction.Release::class.java)
+ assertThat(interactions[3]).isInstanceOf(HoverInteraction.Exit::class.java)
+ assertThat((interactions[2] as PressInteraction.Release).press)
+ .isEqualTo(interactions[1])
+ assertThat((interactions[3] as HoverInteraction.Exit).enter)
+ .isEqualTo(interactions[0])
+ }
+ }
+
/**
* Regression test for b/186223077
*
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/HoverableTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/HoverableTest.kt
new file mode 100644
index 0000000..634b442
--- /dev/null
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/HoverableTest.kt
@@ -0,0 +1,332 @@
+/*
+ * Copyright 2021 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.compose.foundation
+
+import androidx.compose.foundation.interaction.HoverInteraction
+import androidx.compose.foundation.interaction.Interaction
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsHoveredAsState
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+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.geometry.Offset
+import androidx.compose.ui.platform.InspectableValue
+import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performMouseInput
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class HoverableTest {
+
+ @get:Rule
+ val rule = createComposeRule()
+
+ val hoverTag = "myHoverable"
+
+ @Before
+ fun before() {
+ isDebugInspectorInfoEnabled = true
+ }
+
+ @After
+ fun after() {
+ isDebugInspectorInfoEnabled = false
+ }
+
+ @Test
+ fun hoverableText_testInspectorValue() {
+ rule.setContent {
+ val interactionSource = remember { MutableInteractionSource() }
+ val modifier = Modifier.hoverable(interactionSource) as InspectableValue
+ Truth.assertThat(modifier.nameFallback).isEqualTo("hoverable")
+ Truth.assertThat(modifier.valueOverride).isNull()
+ Truth.assertThat(modifier.inspectableElements.map { it.name }.asIterable())
+ .containsExactly(
+ "interactionSource",
+ "enabled",
+ )
+ }
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @ExperimentalComposeUiApi
+ @Test
+ fun hoverableTest_hovered() {
+ var isHovered = false
+ val interactionSource = MutableInteractionSource()
+
+ rule.setContent {
+ Box(
+ modifier = Modifier
+ .size(128.dp)
+ .testTag(hoverTag)
+ .hoverable(interactionSource)
+ )
+
+ isHovered = interactionSource.collectIsHoveredAsState().value
+ }
+
+ rule.onNodeWithTag(hoverTag).performMouseInput {
+ enter(Offset(64.dp.toPx(), 64.dp.toPx()))
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(isHovered).isTrue()
+
+ rule.onNodeWithTag(hoverTag).performMouseInput {
+ moveTo(Offset(96.dp.toPx(), 96.dp.toPx()))
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(isHovered).isTrue()
+
+ rule.onNodeWithTag(hoverTag).performMouseInput {
+ moveTo(Offset(129.dp.toPx(), 129.dp.toPx()))
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(isHovered).isFalse()
+
+ rule.onNodeWithTag(hoverTag).performMouseInput {
+ moveTo(Offset(96.dp.toPx(), 96.dp.toPx()))
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(isHovered).isTrue()
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @ExperimentalComposeUiApi
+ @Test
+ fun hoverableTest_interactionSource() {
+ val interactionSource = MutableInteractionSource()
+
+ var scope: CoroutineScope? = null
+
+ rule.setContent {
+ scope = rememberCoroutineScope()
+ Box(
+ modifier = Modifier
+ .size(128.dp)
+ .testTag(hoverTag)
+ .hoverable(interactionSource = interactionSource)
+ )
+ }
+
+ val interactions = mutableListOf<Interaction>()
+
+ scope!!.launch {
+ interactionSource.interactions.collect { interactions.add(it) }
+ }
+
+ rule.runOnIdle {
+ Truth.assertThat(interactions).isEmpty()
+ }
+
+ rule.onNodeWithTag(hoverTag).performMouseInput {
+ enter(Offset(64.dp.toPx(), 64.dp.toPx()))
+ }
+
+ rule.runOnIdle {
+ Truth.assertThat(interactions).hasSize(1)
+ Truth.assertThat(interactions.first()).isInstanceOf(HoverInteraction.Enter::class.java)
+ }
+
+ rule.onNodeWithTag(hoverTag).performMouseInput {
+ moveTo(Offset(129.dp.toPx(), 129.dp.toPx()))
+ }
+
+ rule.runOnIdle {
+ Truth.assertThat(interactions).hasSize(2)
+ Truth.assertThat(interactions.first()).isInstanceOf(HoverInteraction.Enter::class.java)
+ Truth.assertThat(interactions[1])
+ .isInstanceOf(HoverInteraction.Exit::class.java)
+ Truth.assertThat((interactions[1] as HoverInteraction.Exit).enter)
+ .isEqualTo(interactions[0])
+ }
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun hoverableTest_interactionSource_resetWhenDisposed() {
+ val interactionSource = MutableInteractionSource()
+ var emitHoverable by mutableStateOf(true)
+
+ var scope: CoroutineScope? = null
+
+ rule.setContent {
+ scope = rememberCoroutineScope()
+ Box {
+ if (emitHoverable) {
+ Box(
+ modifier = Modifier
+ .size(128.dp)
+ .testTag(hoverTag)
+ .hoverable(interactionSource = interactionSource)
+ )
+ }
+ }
+ }
+
+ val interactions = mutableListOf<Interaction>()
+
+ scope!!.launch {
+ interactionSource.interactions.collect { interactions.add(it) }
+ }
+
+ rule.runOnIdle {
+ Truth.assertThat(interactions).isEmpty()
+ }
+
+ rule.onNodeWithTag(hoverTag).performMouseInput {
+ enter(Offset(64.dp.toPx(), 64.dp.toPx()))
+ }
+
+ rule.runOnIdle {
+ Truth.assertThat(interactions).hasSize(1)
+ Truth.assertThat(interactions.first()).isInstanceOf(HoverInteraction.Enter::class.java)
+ }
+
+ // Dispose hoverable, Interaction should be gone
+ rule.runOnIdle {
+ emitHoverable = false
+ }
+
+ rule.runOnIdle {
+ Truth.assertThat(interactions).hasSize(2)
+ Truth.assertThat(interactions.first()).isInstanceOf(HoverInteraction.Enter::class.java)
+ Truth.assertThat(interactions[1])
+ .isInstanceOf(HoverInteraction.Exit::class.java)
+ Truth.assertThat((interactions[1] as HoverInteraction.Exit).enter)
+ .isEqualTo(interactions[0])
+ }
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun hoverableTest_interactionSource_dontHoverWhenDisabled() {
+ val interactionSource = MutableInteractionSource()
+
+ var scope: CoroutineScope? = null
+
+ rule.setContent {
+ scope = rememberCoroutineScope()
+ Box {
+ Box(
+ modifier = Modifier
+ .size(128.dp)
+ .testTag(hoverTag)
+ .hoverable(interactionSource = interactionSource, enabled = false)
+ )
+ }
+ }
+
+ val interactions = mutableListOf<Interaction>()
+
+ scope!!.launch {
+ interactionSource.interactions.collect { interactions.add(it) }
+ }
+
+ rule.runOnIdle {
+ Truth.assertThat(interactions).isEmpty()
+ }
+
+ rule.onNodeWithTag(hoverTag).performMouseInput {
+ enter(Offset(64.dp.toPx(), 64.dp.toPx()))
+ }
+
+ rule.runOnIdle {
+ Truth.assertThat(interactions).isEmpty()
+ }
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun hoverableTest_interactionSource_resetWhenDisabled() {
+ val interactionSource = MutableInteractionSource()
+ var enableHoverable by mutableStateOf(true)
+
+ var scope: CoroutineScope? = null
+
+ rule.setContent {
+ scope = rememberCoroutineScope()
+ Box {
+ Box(
+ modifier = Modifier
+ .size(128.dp)
+ .testTag(hoverTag)
+ .hoverable(interactionSource = interactionSource, enabled = enableHoverable)
+ )
+ }
+ }
+
+ val interactions = mutableListOf<Interaction>()
+
+ scope!!.launch {
+ interactionSource.interactions.collect { interactions.add(it) }
+ }
+
+ rule.runOnIdle {
+ Truth.assertThat(interactions).isEmpty()
+ }
+
+ rule.onNodeWithTag(hoverTag).performMouseInput {
+ enter(Offset(64.dp.toPx(), 64.dp.toPx()))
+ }
+
+ rule.runOnIdle {
+ Truth.assertThat(interactions).hasSize(1)
+ Truth.assertThat(interactions.first()).isInstanceOf(HoverInteraction.Enter::class.java)
+ }
+
+ // Disable hoverable, Interaction should be gone
+ rule.runOnIdle {
+ enableHoverable = false
+ }
+
+ rule.runOnIdle {
+ Truth.assertThat(interactions).hasSize(2)
+ Truth.assertThat(interactions.first()).isInstanceOf(HoverInteraction.Enter::class.java)
+ Truth.assertThat(interactions[1])
+ .isInstanceOf(HoverInteraction.Exit::class.java)
+ Truth.assertThat((interactions[1] as HoverInteraction.Exit).enter)
+ .isEqualTo(interactions[0])
+ }
+ }
+}
\ No newline at end of file
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 a18359c..b74d3a8 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.HoverInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
@@ -29,10 +30,12 @@
import androidx.compose.runtime.setValue
import androidx.compose.testutils.first
import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.InspectableValue
import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertCountEquals
@@ -44,6 +47,7 @@
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.performTouchInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
@@ -293,6 +297,60 @@
}
}
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun selectableTest_interactionSource_hover() {
+ val interactionSource = MutableInteractionSource()
+
+ var scope: CoroutineScope? = null
+
+ rule.setContent {
+ scope = rememberCoroutineScope()
+ Box {
+ Box(
+ Modifier.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.onNodeWithText("SelectableText")
+ .performMouseInput { enter(center) }
+
+ rule.runOnIdle {
+ assertThat(interactions).hasSize(1)
+ assertThat(interactions.first()).isInstanceOf(HoverInteraction.Enter::class.java)
+ }
+
+ rule.onNodeWithText("SelectableText")
+ .performMouseInput { exit(Offset(-1f, -1f)) }
+
+ rule.runOnIdle {
+ assertThat(interactions).hasSize(2)
+ assertThat(interactions.first()).isInstanceOf(HoverInteraction.Enter::class.java)
+ assertThat(interactions[1])
+ .isInstanceOf(HoverInteraction.Exit::class.java)
+ assertThat((interactions[1] as HoverInteraction.Exit).enter)
+ .isEqualTo(interactions[0])
+ }
+ }
+
@Test
fun selectableTest_testInspectorValue_noIndication() {
rule.setContent {
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 a50acae..3de6204 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.HoverInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
@@ -39,6 +40,7 @@
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.state.ToggleableState
+import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertHasClickAction
@@ -56,6 +58,7 @@
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -389,6 +392,60 @@
}
}
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun toggleableTest_interactionSource_hover() {
+ val interactionSource = MutableInteractionSource()
+
+ var scope: CoroutineScope? = null
+
+ rule.setContent {
+ scope = rememberCoroutineScope()
+ Box {
+ Box(
+ Modifier.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.onNodeWithText("ToggleableText")
+ .performMouseInput { enter(center) }
+
+ rule.runOnIdle {
+ assertThat(interactions).hasSize(1)
+ assertThat(interactions.first()).isInstanceOf(HoverInteraction.Enter::class.java)
+ }
+
+ rule.onNodeWithText("ToggleableText")
+ .performMouseInput { exit(Offset(-1f, -1f)) }
+
+ rule.runOnIdle {
+ assertThat(interactions).hasSize(2)
+ assertThat(interactions.first()).isInstanceOf(HoverInteraction.Enter::class.java)
+ assertThat(interactions[1])
+ .isInstanceOf(HoverInteraction.Exit::class.java)
+ assertThat((interactions[1] as HoverInteraction.Exit).enter)
+ .isEqualTo(interactions[0])
+ }
+ }
+
@Test
fun toggleableText_testInspectorValue_noIndication() {
rule.setContent {
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 834b038..e752677 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
@@ -426,5 +426,6 @@
return this
.then(semanticModifier)
.indication(interactionSource, indication)
+ .hoverable(interactionSource = interactionSource)
.then(gestureModifiers)
}
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Hoverable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Hoverable.kt
new file mode 100644
index 0000000..788671a
--- /dev/null
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Hoverable.kt
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2021 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.compose.foundation
+
+import androidx.compose.foundation.interaction.HoverInteraction
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.runtime.DisposableEffect
+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.composed
+import androidx.compose.ui.input.pointer.PointerEventType
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.debugInspectorInfo
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.isActive
+
+/**
+ * Configure component to be hoverable via pointer enter/exit events.
+ *
+ * @sample androidx.compose.foundation.samples.HoverableSample
+ *
+ * @param interactionSource [MutableInteractionSource] that will be used to emit
+ * [HoverInteraction.Enter] when this element is being hovered.
+ * @param enabled Controls the enabled state. When `false`, hover events will be ignored.
+ */
+fun Modifier.hoverable(
+ interactionSource: MutableInteractionSource,
+ enabled: Boolean = true
+): Modifier = composed(
+ inspectorInfo = debugInspectorInfo {
+ name = "hoverable"
+ properties["interactionSource"] = interactionSource
+ properties["enabled"] = enabled
+ }
+) {
+ var hoverInteraction by remember { mutableStateOf<HoverInteraction.Enter?>(null) }
+
+ suspend fun emitEnter() {
+ if (hoverInteraction == null) {
+ val interaction = HoverInteraction.Enter()
+ interactionSource.emit(interaction)
+ hoverInteraction = interaction
+ }
+ }
+
+ suspend fun emitExit() {
+ hoverInteraction?.let { oldValue ->
+ val interaction = HoverInteraction.Exit(oldValue)
+ interactionSource.emit(interaction)
+ hoverInteraction = null
+ }
+ }
+
+ fun tryEmitExit() {
+ hoverInteraction?.let { oldValue ->
+ val interaction = HoverInteraction.Exit(oldValue)
+ interactionSource.tryEmit(interaction)
+ hoverInteraction = null
+ }
+ }
+
+ DisposableEffect(interactionSource) {
+ onDispose { tryEmitExit() }
+ }
+ LaunchedEffect(enabled) {
+ if (!enabled) {
+ emitExit()
+ }
+ }
+
+ if (enabled) {
+ Modifier
+// TODO(b/202505231):
+// because we only react to input events, and not on layout changes, we can have a situation when
+// Composable is under the cursor, but not hovered. To fix that, we have two ways:
+// a. Trigger Enter/Exit on any layout change, inside Owner
+// b. Manually react on layout changes via Modifier.onGloballyPosition, and check something like
+// LocalPointerPosition.current
+ .pointerInput(interactionSource) {
+ val currentContext = currentCoroutineContext()
+ while (currentContext.isActive) {
+ val event = awaitPointerEventScope { awaitPointerEvent() }
+ when (event.type) {
+ PointerEventType.Enter -> emitEnter()
+ PointerEventType.Exit -> emitExit()
+ }
+ }
+ }
+ } else {
+ Modifier
+ }
+}
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 61f30c1..d85462d 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.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
@@ -147,12 +148,15 @@
private object DefaultDebugIndication : Indication {
private class DefaultDebugIndicationInstance(
- private val isPressed: State<Boolean>
+ private val isPressed: State<Boolean>,
+ private val isHovered: 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) {
+ drawRect(color = Color.Black.copy(alpha = 0.1f), size = size)
}
}
}
@@ -160,8 +164,9 @@
@Composable
override fun rememberUpdatedInstance(interactionSource: InteractionSource): IndicationInstance {
val isPressed = interactionSource.collectIsPressedAsState()
+ val isHovered = interactionSource.collectIsHoveredAsState()
return remember(interactionSource) {
- DefaultDebugIndicationInstance(isPressed)
+ DefaultDebugIndicationInstance(isPressed, isHovered)
}
}
}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/interaction/HoverInteraction.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/interaction/HoverInteraction.kt
new file mode 100644
index 0000000..3ae9cec
--- /dev/null
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/interaction/HoverInteraction.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2021 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.compose.foundation.interaction
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import kotlinx.coroutines.flow.collect
+
+// An interface, not a sealed class, to allow adding new types here in a safe way (and not break
+// exhaustive when clauses)
+/**
+ * An interaction related to hover events.
+ *
+ * @see androidx.compose.foundation.hoverable
+ * @see Enter
+ * @see Exit
+ */
+interface HoverInteraction : Interaction {
+ /**
+ * An interaction representing a hover event on a component.
+ *
+ * @see androidx.compose.foundation.hoverable
+ * @see Exit
+ */
+ class Enter : HoverInteraction
+
+ /**
+ * An interaction representing a [Enter] event being released on a component.
+ *
+ * @property enter the source [Enter] interaction that is being released
+ *
+ * @see androidx.compose.foundation.hoverable
+ * @see Enter
+ */
+ class Exit(val enter: Enter) : HoverInteraction
+}
+
+/**
+ * Subscribes to this [MutableInteractionSource] and returns a [State] representing whether this
+ * component is hovered or not.
+ *
+ * [HoverInteraction] is typically set by [androidx.compose.foundation.hoverable] and hoverable
+ * components.
+ *
+ * @return [State] representing whether this component is being hovered or not
+ */
+@Composable
+fun InteractionSource.collectIsHoveredAsState(): State<Boolean> {
+ val isHovered = remember { mutableStateOf(false) }
+ LaunchedEffect(this) {
+ val hoverInteractions = mutableListOf<HoverInteraction.Enter>()
+ interactions.collect { interaction ->
+ when (interaction) {
+ is HoverInteraction.Enter -> hoverInteractions.add(interaction)
+ is HoverInteraction.Exit -> hoverInteractions.remove(interaction.enter)
+ }
+ isHovered.value = hoverInteractions.isNotEmpty()
+ }
+ }
+ return isHovered
+}
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 7fff3e0..8d79b16 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
@@ -21,6 +21,7 @@
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.gestures.detectTapAndPress
import androidx.compose.foundation.handlePressInteraction
+import androidx.compose.foundation.hoverable
import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
@@ -268,5 +269,6 @@
this
.then(semantics)
.indication(interactionSource, indication)
+ .hoverable(interactionSource = interactionSource)
.then(gestures)
}
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/Scrollbar.desktop.kt b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/Scrollbar.desktop.kt
index ac939cd..b4caf02 100644
--- a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/Scrollbar.desktop.kt
+++ b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/Scrollbar.desktop.kt
@@ -25,6 +25,7 @@
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -46,7 +47,6 @@
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
-import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.consumePositionChange
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
@@ -190,7 +190,6 @@
)
// TODO(demin): do we need to stop dragging if cursor is beyond constraints?
-// TODO(demin): add Interaction.Hovered to interactionSource
@Composable
private fun Scrollbar(
adapter: ScrollbarAdapter,
@@ -211,7 +210,7 @@
}
var containerSize by remember { mutableStateOf(0) }
- var isHovered by remember { mutableStateOf(false) }
+ val isHovered by interactionSource.collectIsHoveredAsState()
val isHighlighted by remember {
derivedStateOf {
@@ -253,21 +252,7 @@
)
},
modifier
- .pointerInput(Unit) {
- awaitPointerEventScope {
- while (true) {
- val event = awaitPointerEvent()
- when (event.type) {
- PointerEventType.Enter -> {
- isHovered = true
- }
- PointerEventType.Exit -> {
- isHovered = false
- }
- }
- }
- }
- }
+ .hoverable(interactionSource = interactionSource)
.scrollOnPressOutsideSlider(isVertical, sliderAdapter, adapter, containerSize),
measurePolicy
)
diff --git a/compose/material/material-ripple/build.gradle b/compose/material/material-ripple/build.gradle
index 24c5c5c9..df51954 100644
--- a/compose/material/material-ripple/build.gradle
+++ b/compose/material/material-ripple/build.gradle
@@ -34,7 +34,7 @@
* When updating dependencies, make sure to make the an an analogous update in the
* corresponding block below
*/
- api("androidx.compose.foundation:foundation:1.0.0")
+ api(project(":compose:foundation:foundation"))
api(project(":compose:runtime:runtime"))
implementation(libs.kotlinStdlibCommon)
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 990d1d3..1ae18ca 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.HoverInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.PressInteraction
@@ -246,8 +247,14 @@
private var currentInteraction: Interaction? = null
fun handleInteraction(interaction: Interaction, scope: CoroutineScope) {
- // TODO: handle hover / focus states
+ // TODO: handle focus states
when (interaction) {
+ is HoverInteraction.Enter -> {
+ interactions.add(interaction)
+ }
+ is HoverInteraction.Exit -> {
+ interactions.remove(interaction.enter)
+ }
is DragInteraction.Start -> {
interactions.add(interaction)
}
@@ -266,6 +273,7 @@
if (currentInteraction != newInteraction) {
if (newInteraction != null) {
val targetAlpha = when (interaction) {
+ is HoverInteraction.Enter -> rippleAlpha.value.hoveredAlpha
is DragInteraction.Start -> rippleAlpha.value.draggedAlpha
else -> 0f
}
@@ -312,26 +320,26 @@
* @return the [AnimationSpec] used when transitioning to [interaction], either from a previous
* state, or no state.
*
- * TODO: handle hover / focus states
+ * TODO: handle focus states
*/
private fun incomingStateLayerAnimationSpecFor(interaction: Interaction): AnimationSpec<Float> {
- return if (interaction is DragInteraction.Start) {
- TweenSpec(durationMillis = 45, easing = LinearEasing)
- } else {
- DefaultTweenSpec
+ return when (interaction) {
+ is DragInteraction.Start -> TweenSpec(durationMillis = 45, easing = LinearEasing)
+ is HoverInteraction.Enter -> DefaultTweenSpec
+ else -> DefaultTweenSpec
}
}
/**
* @return the [AnimationSpec] used when transitioning away from [interaction], to no state.
*
- * TODO: handle hover / focus states
+ * TODO: handle focus states
*/
private fun outgoingStateLayerAnimationSpecFor(interaction: Interaction?): AnimationSpec<Float> {
- return if (interaction is DragInteraction.Start) {
- TweenSpec(durationMillis = 150, easing = LinearEasing)
- } else {
- DefaultTweenSpec
+ return when (interaction) {
+ is DragInteraction.Start -> TweenSpec(durationMillis = 150, easing = LinearEasing)
+ is HoverInteraction.Enter -> DefaultTweenSpec
+ 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 b1b1411..09118d6 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
@@ -105,7 +105,7 @@
*
* @property draggedAlpha the alpha used when the ripple is dragged
* @property focusedAlpha not currently supported
- * @property hoveredAlpha not currently supported
+ * @property hoveredAlpha the alpha used when the ripple is hovered
* @property pressedAlpha the alpha used when the ripple is pressed
*/
@Immutable
diff --git a/compose/material/material/api/current.ignore b/compose/material/material/api/current.ignore
new file mode 100644
index 0000000..557e57e
--- /dev/null
+++ b/compose/material/material/api/current.ignore
@@ -0,0 +1,5 @@
+// Baseline format: 1.0
+RemovedMethod: androidx.compose.material.ButtonDefaults#elevation(float, float, float):
+ Removed method androidx.compose.material.ButtonDefaults.elevation(float,float,float)
+RemovedMethod: androidx.compose.material.FloatingActionButtonDefaults#elevation(float, float):
+ Removed method androidx.compose.material.FloatingActionButtonDefaults.elevation(float,float)
diff --git a/compose/material/material/api/current.txt b/compose/material/material/api/current.txt
index f22b93c..bc66cd7 100644
--- a/compose/material/material/api/current.txt
+++ b/compose/material/material/api/current.txt
@@ -81,7 +81,7 @@
public final class ButtonDefaults {
method @androidx.compose.runtime.Composable public androidx.compose.material.ButtonColors buttonColors(optional long backgroundColor, optional long contentColor, optional long disabledBackgroundColor, optional long disabledContentColor);
- method @androidx.compose.runtime.Composable public androidx.compose.material.ButtonElevation elevation(optional float defaultElevation, optional float pressedElevation, optional float disabledElevation);
+ method @androidx.compose.runtime.Composable public androidx.compose.material.ButtonElevation elevation(optional float defaultElevation, optional float pressedElevation, optional float disabledElevation, optional float hoveredElevation, optional float focusedElevation);
method public androidx.compose.foundation.layout.PaddingValues getContentPadding();
method public float getIconSize();
method public float getIconSpacing();
@@ -274,7 +274,7 @@
}
public final class FloatingActionButtonDefaults {
- method @androidx.compose.runtime.Composable public androidx.compose.material.FloatingActionButtonElevation elevation(optional float defaultElevation, optional float pressedElevation);
+ method @androidx.compose.runtime.Composable public androidx.compose.material.FloatingActionButtonElevation elevation(optional float defaultElevation, optional float pressedElevation, optional float hoveredElevation, optional float focusedElevation);
field public static final androidx.compose.material.FloatingActionButtonDefaults INSTANCE;
}
diff --git a/compose/material/material/api/public_plus_experimental_current.txt b/compose/material/material/api/public_plus_experimental_current.txt
index e3736e2..1e5c805 100644
--- a/compose/material/material/api/public_plus_experimental_current.txt
+++ b/compose/material/material/api/public_plus_experimental_current.txt
@@ -162,7 +162,7 @@
public final class ButtonDefaults {
method @androidx.compose.runtime.Composable public androidx.compose.material.ButtonColors buttonColors(optional long backgroundColor, optional long contentColor, optional long disabledBackgroundColor, optional long disabledContentColor);
- method @androidx.compose.runtime.Composable public androidx.compose.material.ButtonElevation elevation(optional float defaultElevation, optional float pressedElevation, optional float disabledElevation);
+ method @androidx.compose.runtime.Composable public androidx.compose.material.ButtonElevation elevation(optional float defaultElevation, optional float pressedElevation, optional float disabledElevation, optional float hoveredElevation, optional float focusedElevation);
method public androidx.compose.foundation.layout.PaddingValues getContentPadding();
method public float getIconSize();
method public float getIconSpacing();
@@ -402,7 +402,7 @@
}
public final class FloatingActionButtonDefaults {
- method @androidx.compose.runtime.Composable public androidx.compose.material.FloatingActionButtonElevation elevation(optional float defaultElevation, optional float pressedElevation);
+ method @androidx.compose.runtime.Composable public androidx.compose.material.FloatingActionButtonElevation elevation(optional float defaultElevation, optional float pressedElevation, optional float hoveredElevation, optional float focusedElevation);
field public static final androidx.compose.material.FloatingActionButtonDefaults INSTANCE;
}
diff --git a/compose/material/material/api/restricted_current.ignore b/compose/material/material/api/restricted_current.ignore
new file mode 100644
index 0000000..557e57e
--- /dev/null
+++ b/compose/material/material/api/restricted_current.ignore
@@ -0,0 +1,5 @@
+// Baseline format: 1.0
+RemovedMethod: androidx.compose.material.ButtonDefaults#elevation(float, float, float):
+ Removed method androidx.compose.material.ButtonDefaults.elevation(float,float,float)
+RemovedMethod: androidx.compose.material.FloatingActionButtonDefaults#elevation(float, float):
+ Removed method androidx.compose.material.FloatingActionButtonDefaults.elevation(float,float)
diff --git a/compose/material/material/api/restricted_current.txt b/compose/material/material/api/restricted_current.txt
index f22b93c..bc66cd7 100644
--- a/compose/material/material/api/restricted_current.txt
+++ b/compose/material/material/api/restricted_current.txt
@@ -81,7 +81,7 @@
public final class ButtonDefaults {
method @androidx.compose.runtime.Composable public androidx.compose.material.ButtonColors buttonColors(optional long backgroundColor, optional long contentColor, optional long disabledBackgroundColor, optional long disabledContentColor);
- method @androidx.compose.runtime.Composable public androidx.compose.material.ButtonElevation elevation(optional float defaultElevation, optional float pressedElevation, optional float disabledElevation);
+ method @androidx.compose.runtime.Composable public androidx.compose.material.ButtonElevation elevation(optional float defaultElevation, optional float pressedElevation, optional float disabledElevation, optional float hoveredElevation, optional float focusedElevation);
method public androidx.compose.foundation.layout.PaddingValues getContentPadding();
method public float getIconSize();
method public float getIconSpacing();
@@ -274,7 +274,7 @@
}
public final class FloatingActionButtonDefaults {
- method @androidx.compose.runtime.Composable public androidx.compose.material.FloatingActionButtonElevation elevation(optional float defaultElevation, optional float pressedElevation);
+ method @androidx.compose.runtime.Composable public androidx.compose.material.FloatingActionButtonElevation elevation(optional float defaultElevation, optional float pressedElevation, optional float hoveredElevation, optional float focusedElevation);
field public static final androidx.compose.material.FloatingActionButtonDefaults INSTANCE;
}
diff --git a/compose/material/material/build.gradle b/compose/material/material/build.gradle
index 4086ced..38af064 100644
--- a/compose/material/material/build.gradle
+++ b/compose/material/material/build.gradle
@@ -122,6 +122,7 @@
implementation(libs.dexmakerMockito)
implementation(libs.mockitoCore)
implementation(libs.mockitoKotlin)
+ implementation(libs.testUiautomator)
}
}
}
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 7194dce..58a3991 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
@@ -27,6 +27,7 @@
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -102,4 +103,22 @@
.captureToImage()
.assertAgainstGolden(screenshotRule, "button_ripple")
}
+
+ @Test
+ fun hover() {
+ rule.setMaterialContent {
+ Box(Modifier.requiredSize(200.dp, 100.dp).wrapContentSize()) {
+ Button(onClick = { }) { }
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .performMouseInput { enter(center) }
+
+ rule.waitForIdle()
+
+ rule.onRoot()
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "button_hover")
+ }
}
\ 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 36fdfc7..0d8bbba 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
@@ -29,6 +29,7 @@
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.performTouchInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
@@ -205,6 +206,26 @@
assertToggeableAgainstGolden("checkbox_animateToUnchecked")
}
+ @Test
+ fun checkBoxTest_hover() {
+ rule.setMaterialContent {
+ Box(wrap.testTag(wrapperTestTag)) {
+ Checkbox(
+ modifier = wrap,
+ checked = true,
+ onCheckedChange = { }
+ )
+ }
+ }
+
+ rule.onNode(isToggleable())
+ .performMouseInput { enter(center) }
+
+ rule.waitForIdle()
+
+ assertToggeableAgainstGolden("checkbox_hover")
+ }
+
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 0a6a4d6..08cac4f 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
@@ -28,6 +28,7 @@
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -121,4 +122,24 @@
.captureToImage()
.assertAgainstGolden(screenshotRule, "fab_ripple")
}
+
+ @Test
+ fun hover() {
+ rule.setMaterialContent {
+ Box(Modifier.requiredSize(100.dp, 100.dp).wrapContentSize()) {
+ FloatingActionButton(onClick = { }) {
+ Icon(Icons.Filled.Favorite, contentDescription = null)
+ }
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .performMouseInput { enter(center) }
+
+ rule.waitForIdle()
+
+ rule.onRoot()
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "fab_hover")
+ }
}
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 ec572de..d1509f7 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.HoverInteraction
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@@ -106,6 +107,28 @@
}
@Test
+ fun bounded_lightTheme_highLuminance_hovered() {
+ val interactionSource = MutableInteractionSource()
+
+ val contentColor = Color.White
+
+ val scope = rule.setRippleContent(
+ interactionSource = interactionSource,
+ bounded = true,
+ lightTheme = true,
+ contentColor = contentColor
+ )
+
+ assertRippleMatches(
+ scope,
+ interactionSource,
+ HoverInteraction.Enter(),
+ "ripple_bounded_light_highluminance_hovered",
+ calculateResultingRippleColor(contentColor, rippleOpacity = 0.08f)
+ )
+ }
+
+ @Test
fun bounded_lightTheme_highLuminance_dragged() {
val interactionSource = MutableInteractionSource()
@@ -150,6 +173,28 @@
}
@Test
+ fun bounded_lightTheme_lowLuminance_hovered() {
+ val interactionSource = MutableInteractionSource()
+
+ val contentColor = Color.Black
+
+ val scope = rule.setRippleContent(
+ interactionSource = interactionSource,
+ bounded = true,
+ lightTheme = true,
+ contentColor = contentColor
+ )
+
+ assertRippleMatches(
+ scope,
+ interactionSource,
+ HoverInteraction.Enter(),
+ "ripple_bounded_light_lowluminance_hovered",
+ calculateResultingRippleColor(contentColor, rippleOpacity = 0.04f)
+ )
+ }
+
+ @Test
fun bounded_lightTheme_lowLuminance_dragged() {
val interactionSource = MutableInteractionSource()
@@ -194,6 +239,28 @@
}
@Test
+ fun bounded_darkTheme_highLuminance_hovered() {
+ val interactionSource = MutableInteractionSource()
+
+ val contentColor = Color.White
+
+ val scope = rule.setRippleContent(
+ interactionSource = interactionSource,
+ bounded = true,
+ lightTheme = false,
+ contentColor = contentColor
+ )
+
+ assertRippleMatches(
+ scope,
+ interactionSource,
+ HoverInteraction.Enter(),
+ "ripple_bounded_dark_highluminance_hovered",
+ calculateResultingRippleColor(contentColor, rippleOpacity = 0.04f)
+ )
+ }
+
+ @Test
fun bounded_darkTheme_highLuminance_dragged() {
val interactionSource = MutableInteractionSource()
@@ -239,6 +306,29 @@
}
@Test
+ fun bounded_darkTheme_lowLuminance_hovered() {
+ val interactionSource = MutableInteractionSource()
+
+ val contentColor = Color.Black
+
+ val scope = rule.setRippleContent(
+ interactionSource = interactionSource,
+ bounded = true,
+ lightTheme = false,
+ contentColor = contentColor
+ )
+
+ assertRippleMatches(
+ scope,
+ interactionSource,
+ HoverInteraction.Enter(),
+ "ripple_bounded_dark_lowluminance_hovered",
+ // Low luminance content in dark theme should use a white ripple by default
+ calculateResultingRippleColor(Color.White, rippleOpacity = 0.04f)
+ )
+ }
+
+ @Test
fun bounded_darkTheme_lowLuminance_dragged() {
val interactionSource = MutableInteractionSource()
@@ -284,6 +374,28 @@
}
@Test
+ fun unbounded_lightTheme_highLuminance_hovered() {
+ val interactionSource = MutableInteractionSource()
+
+ val contentColor = Color.White
+
+ val scope = rule.setRippleContent(
+ interactionSource = interactionSource,
+ bounded = false,
+ lightTheme = true,
+ contentColor = contentColor
+ )
+
+ assertRippleMatches(
+ scope,
+ interactionSource,
+ HoverInteraction.Enter(),
+ "ripple_unbounded_light_highluminance_hovered",
+ calculateResultingRippleColor(contentColor, rippleOpacity = 0.08f)
+ )
+ }
+
+ @Test
fun unbounded_lightTheme_highLuminance_dragged() {
val interactionSource = MutableInteractionSource()
@@ -328,6 +440,28 @@
}
@Test
+ fun unbounded_lightTheme_lowLuminance_hovered() {
+ val interactionSource = MutableInteractionSource()
+
+ val contentColor = Color.Black
+
+ val scope = rule.setRippleContent(
+ interactionSource = interactionSource,
+ bounded = false,
+ lightTheme = true,
+ contentColor = contentColor
+ )
+
+ assertRippleMatches(
+ scope,
+ interactionSource,
+ HoverInteraction.Enter(),
+ "ripple_unbounded_light_lowluminance_hovered",
+ calculateResultingRippleColor(contentColor, rippleOpacity = 0.04f)
+ )
+ }
+
+ @Test
fun unbounded_lightTheme_lowLuminance_dragged() {
val interactionSource = MutableInteractionSource()
@@ -372,6 +506,28 @@
}
@Test
+ fun unbounded_darkTheme_highLuminance_hovered() {
+ val interactionSource = MutableInteractionSource()
+
+ val contentColor = Color.White
+
+ val scope = rule.setRippleContent(
+ interactionSource = interactionSource,
+ bounded = false,
+ lightTheme = false,
+ contentColor = contentColor
+ )
+
+ assertRippleMatches(
+ scope,
+ interactionSource,
+ HoverInteraction.Enter(),
+ "ripple_unbounded_dark_highluminance_hovered",
+ calculateResultingRippleColor(contentColor, rippleOpacity = 0.04f)
+ )
+ }
+
+ @Test
fun unbounded_darkTheme_highLuminance_dragged() {
val interactionSource = MutableInteractionSource()
@@ -417,6 +573,29 @@
}
@Test
+ fun unbounded_darkTheme_lowLuminance_hovered() {
+ val interactionSource = MutableInteractionSource()
+
+ val contentColor = Color.Black
+
+ val scope = rule.setRippleContent(
+ interactionSource = interactionSource,
+ bounded = false,
+ lightTheme = false,
+ contentColor = contentColor
+ )
+
+ assertRippleMatches(
+ scope,
+ interactionSource,
+ HoverInteraction.Enter(),
+ "ripple_unbounded_dark_lowluminance_hovered",
+ // Low luminance content in dark theme should use a white ripple by default
+ calculateResultingRippleColor(Color.White, rippleOpacity = 0.04f)
+ )
+ }
+
+ @Test
fun unbounded_darkTheme_lowLuminance_dragged() {
val interactionSource = MutableInteractionSource()
@@ -491,6 +670,57 @@
}
@Test
+ fun customRippleTheme_hovered() {
+ 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,
+ HoverInteraction.Enter(),
+ "ripple_customtheme_hovered",
+ 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 3b3c67c..99fe9e3 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
@@ -30,6 +30,7 @@
import androidx.compose.ui.test.isSelectable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.performTouchInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
@@ -99,6 +100,20 @@
}
@Test
+ fun radioButtonTest_hovered() {
+ rule.setMaterialContent {
+ Box(wrap.testTag(wrapperTestTag)) {
+ RadioButton(selected = false, onClick = {})
+ }
+ }
+ rule.onNodeWithTag(wrapperTestTag).performMouseInput {
+ enter(center)
+ }
+
+ assertSelectableAgainstGolden("radioButton_hovered")
+ }
+
+ @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 43f12ce..ad24ccf 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
@@ -34,6 +34,7 @@
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@@ -235,6 +236,25 @@
assertToggeableAgainstGolden("switch_animateToUnchecked")
}
+ @Test
+ fun switchTest_hover() {
+ rule.setMaterialContent {
+ Box(wrapperModifier) {
+ Switch(
+ checked = true,
+ onCheckedChange = { }
+ )
+ }
+ }
+
+ rule.onNode(isToggleable())
+ .performMouseInput { enter(center) }
+
+ rule.waitForIdle()
+
+ assertToggeableAgainstGolden("switch_hover")
+ }
+
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 cf6ce1b73..47e85a9 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.HoverInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -327,7 +328,7 @@
*/
val IconSpacing = 8.dp
- // TODO: b/152525426 add support for focused and hovered states
+ // 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].
@@ -338,19 +339,47 @@
* is pressed.
* @param disabledElevation the elevation to use when the [Button] is not enabled.
*/
+ @Deprecated("Use another overload of elevation", level = DeprecationLevel.HIDDEN)
@Composable
fun elevation(
defaultElevation: Dp = 2.dp,
pressedElevation: Dp = 8.dp,
- // focused: Dp = 4.dp,
- // hovered: Dp = 4.dp,
disabledElevation: Dp = 0.dp
+ ): ButtonElevation = elevation(
+ defaultElevation,
+ pressedElevation,
+ disabledElevation,
+ hoveredElevation = 4.dp,
+ focusedElevation = 4.dp,
+ )
+
+ /**
+ * Creates a [ButtonElevation] that will animate between the provided values according to the
+ * Material specification for a [Button].
+ *
+ * @param defaultElevation the elevation to use when the [Button] is enabled, and has no
+ * other [Interaction]s.
+ * @param pressedElevation the elevation to use when the [Button] is enabled and
+ * 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.
+ */
+ @Suppress("UNUSED_PARAMETER")
+ @Composable
+ fun elevation(
+ defaultElevation: Dp = 2.dp,
+ pressedElevation: Dp = 8.dp,
+ disabledElevation: Dp = 0.dp,
+ hoveredElevation: Dp = 4.dp,
+ focusedElevation: Dp = 4.dp,
): ButtonElevation {
- return remember(defaultElevation, pressedElevation, disabledElevation) {
+ return remember(defaultElevation, pressedElevation, disabledElevation, hoveredElevation) {
DefaultButtonElevation(
defaultElevation = defaultElevation,
pressedElevation = pressedElevation,
- disabledElevation = disabledElevation
+ disabledElevation = disabledElevation,
+ hoveredElevation = hoveredElevation,
)
}
}
@@ -461,6 +490,7 @@
private val defaultElevation: Dp,
private val pressedElevation: Dp,
private val disabledElevation: Dp,
+ private val hoveredElevation: Dp,
) : ButtonElevation {
@Composable
override fun elevation(enabled: Boolean, interactionSource: InteractionSource): State<Dp> {
@@ -468,6 +498,12 @@
LaunchedEffect(interactionSource) {
interactionSource.interactions.collect { interaction ->
when (interaction) {
+ is HoverInteraction.Enter -> {
+ interactions.add(interaction)
+ }
+ is HoverInteraction.Exit -> {
+ interactions.remove(interaction.enter)
+ }
is PressInteraction.Press -> {
interactions.add(interaction)
}
@@ -488,6 +524,7 @@
} else {
when (interaction) {
is PressInteraction.Press -> pressedElevation
+ is HoverInteraction.Enter -> hoveredElevation
else -> defaultElevation
}
}
@@ -503,6 +540,7 @@
LaunchedEffect(target) {
val lastInteraction = when (animatable.targetValue) {
pressedElevation -> PressInteraction.Press(Offset.Zero)
+ hoveredElevation -> HoverInteraction.Enter()
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 1a8d1e1..56e3198 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.HoverInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.ui.unit.Dp
@@ -79,6 +80,7 @@
return when (interaction) {
is PressInteraction.Press -> DefaultIncomingSpec
is DragInteraction.Start -> DefaultIncomingSpec
+ is HoverInteraction.Enter -> DefaultIncomingSpec
else -> null
}
}
@@ -93,7 +95,7 @@
return when (interaction) {
is PressInteraction.Press -> DefaultOutgoingSpec
is DragInteraction.Start -> DefaultOutgoingSpec
- // TODO: use [HoveredOutgoingSpec] when hovered
+ is HoverInteraction.Enter -> HoveredOutgoingSpec
else -> null
}
}
@@ -109,7 +111,6 @@
easing = CubicBezierEasing(0.40f, 0.00f, 0.60f, 1.00f)
)
-@Suppress("unused")
private val HoveredOutgoingSpec = TweenSpec<Dp>(
durationMillis = 120,
easing = CubicBezierEasing(0.40f, 0.00f, 0.60f, 1.00f)
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 e6bf730..ef23afe 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.HoverInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -205,7 +206,7 @@
* Contains the default values used by [FloatingActionButton]
*/
object FloatingActionButtonDefaults {
- // TODO: b/152525426 add support for focused and hovered states
+ // TODO: b/152525426 add support for focused states
/**
* Creates a [FloatingActionButtonElevation] that will animate between the provided values
* according to the Material specification.
@@ -215,17 +216,43 @@
* @param pressedElevation the elevation to use when the [FloatingActionButton] is
* pressed.
*/
+ @Deprecated("Use another overload of elevation", level = DeprecationLevel.HIDDEN)
@Composable
fun elevation(
defaultElevation: Dp = 6.dp,
- pressedElevation: Dp = 12.dp
- // focused: Dp = 8.dp,
- // hovered: Dp = 8.dp,
+ pressedElevation: Dp = 12.dp,
+ ): FloatingActionButtonElevation = elevation(
+ defaultElevation,
+ pressedElevation,
+ hoveredElevation = 8.dp,
+ focusedElevation = 8.dp,
+ )
+
+ /**
+ * Creates a [FloatingActionButtonElevation] that will animate between the provided values
+ * according to the Material specification.
+ *
+ * @param defaultElevation the elevation to use when the [FloatingActionButton] has no
+ * [Interaction]s
+ * @param pressedElevation the elevation to use when the [FloatingActionButton] is
+ * pressed.
+ * @param hoveredElevation the elevation to use when the [FloatingActionButton] is
+ * hovered.
+ * @param focusedElevation not currently supported.
+ */
+ @Suppress("UNUSED_PARAMETER")
+ @Composable
+ fun elevation(
+ defaultElevation: Dp = 6.dp,
+ pressedElevation: Dp = 12.dp,
+ hoveredElevation: Dp = 8.dp,
+ focusedElevation: Dp = 8.dp,
): FloatingActionButtonElevation {
- return remember(defaultElevation, pressedElevation) {
+ return remember(defaultElevation, pressedElevation, hoveredElevation) {
DefaultFloatingActionButtonElevation(
defaultElevation = defaultElevation,
- pressedElevation = pressedElevation
+ pressedElevation = pressedElevation,
+ hoveredElevation = hoveredElevation,
)
}
}
@@ -238,6 +265,7 @@
private class DefaultFloatingActionButtonElevation(
private val defaultElevation: Dp,
private val pressedElevation: Dp,
+ private val hoveredElevation: Dp
) : FloatingActionButtonElevation {
@Composable
override fun elevation(interactionSource: InteractionSource): State<Dp> {
@@ -245,6 +273,12 @@
LaunchedEffect(interactionSource) {
interactionSource.interactions.collect { interaction ->
when (interaction) {
+ is HoverInteraction.Enter -> {
+ interactions.add(interaction)
+ }
+ is HoverInteraction.Exit -> {
+ interactions.remove(interaction.enter)
+ }
is PressInteraction.Press -> {
interactions.add(interaction)
}
@@ -262,6 +296,7 @@
val target = when (interaction) {
is PressInteraction.Press -> pressedElevation
+ is HoverInteraction.Enter -> hoveredElevation
else -> defaultElevation
}
@@ -270,6 +305,7 @@
LaunchedEffect(target) {
val lastInteraction = when (animatable.targetValue) {
pressedElevation -> PressInteraction.Press(Offset.Zero)
+ hoveredElevation -> HoverInteraction.Enter()
else -> null
}
animatable.animateElevation(
diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Slider.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Slider.kt
index fdee814..bfec8ff 100644
--- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Slider.kt
+++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Slider.kt
@@ -31,6 +31,7 @@
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.gestures.horizontalDrag
+import androidx.compose.foundation.hoverable
import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.Interaction
@@ -625,6 +626,7 @@
interactionSource = interactionSource,
indication = rememberRipple(bounded = false, radius = ThumbRippleRadius)
)
+ .hoverable(interactionSource = interactionSource)
.shadow(if (enabled) elevation else 0.dp, CircleShape, clip = false)
.background(colors.thumbColor(enabled).value, CircleShape)
)