Merge "Add TextArea demo and ExposedDropdownMenu to catalog" into androidx-main
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/lazy/LazyListHeaders.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListHeaders.kt
index 044999d..2dbeee8 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListHeaders.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListHeaders.kt
@@ -16,7 +16,7 @@
package androidx.compose.foundation.lazy
-import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.util.fastForEachIndexed
/**
* This method finds the sticky header in composedItems list or composes the header item if needed.
@@ -28,12 +28,13 @@
* @param startContentPadding the padding before the first item in the list
*/
internal fun findOrComposeLazyListHeader(
- composedVisibleItems: MutableList<LazyMeasuredItem>,
+ composedVisibleItems: MutableList<LazyListPositionedItem>,
itemProvider: LazyMeasuredItemProvider,
headerIndexes: List<Int>,
- startContentPadding: Int
-): LazyMeasuredItem? {
- var alreadyVisibleHeaderItem: LazyMeasuredItem? = null
+ startContentPadding: Int,
+ layoutWidth: Int,
+ layoutHeight: Int,
+): LazyListPositionedItem? {
var currentHeaderOffset: Int = Int.MIN_VALUE
var nextHeaderOffset: Int = Int.MIN_VALUE
@@ -52,9 +53,10 @@
}
}
- composedVisibleItems.fastForEach { item ->
+ var indexInComposedVisibleItems = -1
+ composedVisibleItems.fastForEachIndexed { index, item ->
if (item.index == currentHeaderListPosition) {
- alreadyVisibleHeaderItem = item
+ indexInComposedVisibleItems = index
currentHeaderOffset = item.offset
} else {
if (item.index == nextHeaderListPosition) {
@@ -68,10 +70,7 @@
return null
}
- val headerItem = alreadyVisibleHeaderItem
- ?: itemProvider.getAndMeasure(DataIndex(currentHeaderListPosition)).also {
- composedVisibleItems.add(0, it)
- }
+ val measuredHeaderItem = itemProvider.getAndMeasure(DataIndex(currentHeaderListPosition))
var headerOffset = if (currentHeaderOffset != Int.MIN_VALUE) {
maxOf(-startContentPadding, currentHeaderOffset)
@@ -81,9 +80,14 @@
// if we have a next header overlapping with the current header, the next one will be
// pushing the current one away from the viewport.
if (nextHeaderOffset != Int.MIN_VALUE) {
- headerOffset = minOf(headerOffset, nextHeaderOffset - headerItem.size)
+ headerOffset = minOf(headerOffset, nextHeaderOffset - measuredHeaderItem.size)
}
- headerItem.offset = headerOffset
- return headerItem
+ return measuredHeaderItem.position(headerOffset, layoutWidth, layoutHeight).also {
+ if (indexInComposedVisibleItems != -1) {
+ composedVisibleItems[indexInComposedVisibleItems] = it
+ } else {
+ composedVisibleItems.add(0, it)
+ }
+ }
}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt
index 3da26e5..e58d28f 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt
@@ -209,9 +209,10 @@
val layoutHeight =
constraints.constrainHeight(if (isVertical) mainAxisUsed else maxCrossAxis)
- calculateItemsOffsets(
+ val positionedItems = calculateItemsOffsets(
items = visibleItems,
- mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth,
+ layoutWidth = layoutWidth,
+ layoutHeight = layoutHeight,
usedMainAxisSize = mainAxisUsed,
itemsScrollOffset = visibleItemsScrollOffset,
isVertical = isVertical,
@@ -224,10 +225,12 @@
val headerItem = if (headerIndexes.isNotEmpty()) {
findOrComposeLazyListHeader(
- composedVisibleItems = visibleItems,
+ composedVisibleItems = positionedItems,
itemProvider = itemProvider,
headerIndexes = headerIndexes,
- startContentPadding = startContentPadding
+ startContentPadding = startContentPadding,
+ layoutWidth = layoutWidth,
+ layoutHeight = layoutHeight
)
} else {
null
@@ -241,17 +244,17 @@
canScrollForward = mainAxisUsed > maxOffset,
consumedScroll = consumedScroll,
measureResult = layout(layoutWidth, layoutHeight) {
- visibleItems.fastForEach {
+ positionedItems.fastForEach {
if (it !== headerItem) {
- it.place(this, layoutWidth, layoutHeight)
+ it.place(this)
}
}
// the header item should be placed (drawn) after all other items
- headerItem?.place(this, layoutWidth, layoutHeight)
+ headerItem?.place(this)
},
viewportStartOffset = -startContentPadding,
viewportEndOffset = maximumVisibleOffset,
- visibleItemsInfo = visibleItems,
+ visibleItemsInfo = positionedItems,
totalItemsCount = itemsCount,
)
}
@@ -262,7 +265,8 @@
*/
private fun calculateItemsOffsets(
items: List<LazyMeasuredItem>,
- mainAxisLayoutSize: Int,
+ layoutWidth: Int,
+ layoutHeight: Int,
usedMainAxisSize: Int,
itemsScrollOffset: Int,
isVertical: Boolean,
@@ -271,12 +275,15 @@
reverseLayout: Boolean,
density: Density,
layoutDirection: LayoutDirection
-) {
+): MutableList<LazyListPositionedItem> {
+ val mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth
val hasSpareSpace = usedMainAxisSize < mainAxisLayoutSize
if (hasSpareSpace) {
check(itemsScrollOffset == 0)
}
+ val positionedItems = ArrayList<LazyListPositionedItem>(items.size)
+
if (hasSpareSpace) {
val itemsCount = items.size
val sizes = IntArray(itemsCount) { index ->
@@ -301,13 +308,15 @@
} else {
absoluteOffset
}
- item.offset = relativeOffset
+ val addIndex = if (reverseLayout) 0 else positionedItems.size
+ positionedItems.add(addIndex, item.position(relativeOffset, layoutWidth, layoutHeight))
}
} else {
var currentMainAxis = itemsScrollOffset
items.fastForEach {
- it.offset = currentMainAxis
+ positionedItems.add(it.position(currentMainAxis, layoutWidth, layoutHeight))
currentMainAxis += it.sizeWithSpacings
}
}
+ return positionedItems
}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyMeasuredItem.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyMeasuredItem.kt
index b6e32ea..1e1f276 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyMeasuredItem.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyMeasuredItem.kt
@@ -19,14 +19,16 @@
import androidx.compose.foundation.lazy.layout.LazyLayoutPlaceable
import androidx.compose.ui.Alignment
import androidx.compose.ui.layout.Placeable
+import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.util.fastForEach
/**
* Represents one measured item of the lazy list. It can in fact consist of multiple placeables
* if the user emit multiple layout nodes in the item callback.
*/
internal class LazyMeasuredItem(
- override val index: Int,
+ val index: Int,
private val placeables: Array<LazyLayoutPlaceable>,
private val isVertical: Boolean,
private val horizontalAlignment: Alignment.Horizontal?,
@@ -40,12 +42,12 @@
* is usually representing the spacing after the item.
*/
private val spacing: Int,
- override val key: Any
-) : LazyListItemInfo {
+ val key: Any
+) {
/**
* Sum of the main axis sizes of all the inner placeables.
*/
- override val size: Int
+ val size: Int
/**
* Sum of the main axis sizes of all the inner placeables and [spacing].
@@ -57,8 +59,6 @@
*/
val crossAxisSize: Int
- override var offset: Int = 0
-
init {
var mainAxisSize = 0
var maxCrossAxis = 0
@@ -74,17 +74,18 @@
}
/**
- * Perform placing for all the inner placeables at [offset] main axis position. [layoutWidth]
+ * Calculates positions for the inner placeables at [offset] main axis position. [layoutWidth]
* and [layoutHeight] should be provided to not place placeables which are ended up outside of
* the viewport (for example one item consist of 2 placeables, and the first one is not going
* to be visible, so we don't place it as an optimization, but place the second one).
* If [reverseOrder] is true the inner placeables would be placed in the inverted order.
*/
- fun place(
- scope: Placeable.PlacementScope,
+ fun position(
+ offset: Int,
layoutWidth: Int,
layoutHeight: Int
- ) = with(scope) {
+ ): LazyListPositionedItem {
+ val wrappers = mutableListOf<LazyListPlaceableWrapper>()
val mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth
var mainAxisOffset = if (reverseLayout) {
mainAxisLayoutSize - offset - size
@@ -94,25 +95,72 @@
var index = if (reverseLayout) placeables.lastIndex else 0
while (if (reverseLayout) index >= 0 else index < placeables.size) {
val it = placeables[index].placeable
- if (reverseLayout) index-- else index++
- if (isVertical) {
+ val addIndex = if (reverseLayout) 0 else wrappers.size
+ val placeableOffset = if (isVertical) {
val x = requireNotNull(horizontalAlignment)
.align(it.width, layoutWidth, layoutDirection)
- if (mainAxisOffset + it.height > -startContentPadding &&
- mainAxisOffset < layoutHeight + endContentPadding
- ) {
- it.placeWithLayer(x, mainAxisOffset)
- }
- mainAxisOffset += it.height
+ IntOffset(x, mainAxisOffset)
} else {
val y = requireNotNull(verticalAlignment).align(it.height, layoutHeight)
- if (mainAxisOffset + it.width > -startContentPadding &&
- mainAxisOffset < layoutWidth + endContentPadding
- ) {
- it.placeRelativeWithLayer(mainAxisOffset, y)
+ IntOffset(mainAxisOffset, y)
+ }
+ mainAxisOffset += if (isVertical) it.height else it.width
+ wrappers.add(
+ addIndex,
+ LazyListPlaceableWrapper(placeableOffset, it, placeables[index].parentData)
+ )
+ if (reverseLayout) index-- else index++
+ }
+ return LazyListPositionedItem(
+ offset = offset,
+ index = this.index,
+ key = key,
+ size = size,
+ sizeWithSpacings = sizeWithSpacings,
+ minMainAxisOffset = -startContentPadding,
+ maxMainAxisOffset = mainAxisLayoutSize + endContentPadding,
+ isVertical = isVertical,
+ wrappers = wrappers
+ )
+ }
+}
+
+internal class LazyListPositionedItem(
+ override val offset: Int,
+ override val index: Int,
+ override val key: Any,
+ override val size: Int,
+ val sizeWithSpacings: Int,
+ private val minMainAxisOffset: Int,
+ private val maxMainAxisOffset: Int,
+ private val isVertical: Boolean,
+ private val wrappers: List<LazyListPlaceableWrapper>
+) : LazyListItemInfo {
+
+ fun place(
+ scope: Placeable.PlacementScope,
+ ) = with(scope) {
+ wrappers.fastForEach { wrapper ->
+ val offset = wrapper.offset
+ val placeable = wrapper.placeable
+ if (offset.mainAxis + placeable.mainAxisSize > minMainAxisOffset &&
+ offset.mainAxis < maxMainAxisOffset
+ ) {
+ if (isVertical) {
+ placeable.placeWithLayer(offset)
+ } else {
+ placeable.placeRelativeWithLayer(offset)
}
- mainAxisOffset += it.width
}
}
}
+
+ private val IntOffset.mainAxis get() = if (isVertical) y else x
+ private val Placeable.mainAxisSize get() = if (isVertical) height else width
}
+
+internal class LazyListPlaceableWrapper(
+ val offset: IntOffset,
+ val placeable: Placeable,
+ val parentData: Any?
+)
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)
)
diff --git a/compose/material3/material3/api/current.txt b/compose/material3/material3/api/current.txt
index 05ecf1a..ad1b9a4 100644
--- a/compose/material3/material3/api/current.txt
+++ b/compose/material3/material3/api/current.txt
@@ -106,8 +106,8 @@
}
public final class SurfaceKt {
- method @androidx.compose.runtime.Composable public static void Surface(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape shape, optional long color, optional long contentColor, optional float tonalElevation, optional androidx.compose.foundation.BorderStroke? border, kotlin.jvm.functions.Function0<kotlin.Unit> content);
- method @androidx.compose.runtime.Composable public static void Surface(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape shape, optional long color, optional long contentColor, optional float tonalElevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.foundation.Indication? indication, optional boolean enabled, optional String? onClickLabel, optional androidx.compose.ui.semantics.Role? role, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public static void Surface(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape shape, optional long color, optional long contentColor, optional float tonalElevation, optional float shadowElevation, optional androidx.compose.foundation.BorderStroke? border, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public static void Surface(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape shape, optional long color, optional long contentColor, optional float tonalElevation, optional float shadowElevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.foundation.Indication? indication, optional boolean enabled, optional String? onClickLabel, optional androidx.compose.ui.semantics.Role? role, kotlin.jvm.functions.Function0<kotlin.Unit> content);
method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.unit.Dp> getLocalAbsoluteTonalElevation();
}
diff --git a/compose/material3/material3/api/public_plus_experimental_current.txt b/compose/material3/material3/api/public_plus_experimental_current.txt
index 28ecd4a..b246af7 100644
--- a/compose/material3/material3/api/public_plus_experimental_current.txt
+++ b/compose/material3/material3/api/public_plus_experimental_current.txt
@@ -121,8 +121,8 @@
}
public final class SurfaceKt {
- method @androidx.compose.runtime.Composable public static void Surface(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape shape, optional long color, optional long contentColor, optional float tonalElevation, optional androidx.compose.foundation.BorderStroke? border, kotlin.jvm.functions.Function0<kotlin.Unit> content);
- method @androidx.compose.runtime.Composable public static void Surface(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape shape, optional long color, optional long contentColor, optional float tonalElevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.foundation.Indication? indication, optional boolean enabled, optional String? onClickLabel, optional androidx.compose.ui.semantics.Role? role, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public static void Surface(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape shape, optional long color, optional long contentColor, optional float tonalElevation, optional float shadowElevation, optional androidx.compose.foundation.BorderStroke? border, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public static void Surface(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape shape, optional long color, optional long contentColor, optional float tonalElevation, optional float shadowElevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.foundation.Indication? indication, optional boolean enabled, optional String? onClickLabel, optional androidx.compose.ui.semantics.Role? role, kotlin.jvm.functions.Function0<kotlin.Unit> content);
method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.unit.Dp> getLocalAbsoluteTonalElevation();
}
diff --git a/compose/material3/material3/api/restricted_current.txt b/compose/material3/material3/api/restricted_current.txt
index 05ecf1a..ad1b9a4 100644
--- a/compose/material3/material3/api/restricted_current.txt
+++ b/compose/material3/material3/api/restricted_current.txt
@@ -106,8 +106,8 @@
}
public final class SurfaceKt {
- method @androidx.compose.runtime.Composable public static void Surface(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape shape, optional long color, optional long contentColor, optional float tonalElevation, optional androidx.compose.foundation.BorderStroke? border, kotlin.jvm.functions.Function0<kotlin.Unit> content);
- method @androidx.compose.runtime.Composable public static void Surface(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape shape, optional long color, optional long contentColor, optional float tonalElevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.foundation.Indication? indication, optional boolean enabled, optional String? onClickLabel, optional androidx.compose.ui.semantics.Role? role, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public static void Surface(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape shape, optional long color, optional long contentColor, optional float tonalElevation, optional float shadowElevation, optional androidx.compose.foundation.BorderStroke? border, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public static void Surface(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape shape, optional long color, optional long contentColor, optional float tonalElevation, optional float shadowElevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.foundation.Indication? indication, optional boolean enabled, optional String? onClickLabel, optional androidx.compose.ui.semantics.Role? role, kotlin.jvm.functions.Function0<kotlin.Unit> content);
method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.unit.Dp> getLocalAbsoluteTonalElevation();
}
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/SurfaceTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/SurfaceTest.kt
index 3011778..007999b2 100644
--- a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/SurfaceTest.kt
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/SurfaceTest.kt
@@ -22,17 +22,21 @@
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.testutils.assertPixels
import androidx.compose.testutils.assertShape
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.testTag
@@ -192,6 +196,59 @@
}
}
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+ @Test
+ fun absoluteElevationIsNotUsedForShadows() {
+ rule.setMaterialContent {
+ Column {
+ Box(
+ Modifier
+ .padding(10.dp)
+ .size(10.dp, 10.dp)
+ .semantics(mergeDescendants = true) {}
+ .testTag("top level")
+ ) {
+ Surface(
+ Modifier.fillMaxSize().padding(0.dp),
+ tonalElevation = 2.dp,
+ shadowElevation = 2.dp,
+ color = Color.Blue,
+ content = {}
+ )
+ }
+
+ // Set LocalAbsoluteTonalElevation to increase the absolute elevation
+ CompositionLocalProvider(
+ LocalAbsoluteTonalElevation provides 2.dp
+ ) {
+ Box(
+ Modifier
+ .padding(10.dp)
+ .size(10.dp, 10.dp)
+ .semantics(mergeDescendants = true) {}
+ .testTag("nested")
+ ) {
+ Surface(
+ Modifier.fillMaxSize().padding(0.dp),
+ tonalElevation = 0.dp,
+ shadowElevation = 2.dp,
+ color = Color.Blue,
+ content = {}
+ )
+ }
+ }
+ }
+ }
+
+ val topLevelSurfaceBitmap = rule.onNodeWithTag("top level").captureToImage()
+ val nestedSurfaceBitmap = rule.onNodeWithTag("nested").captureToImage()
+ .asAndroidBitmap()
+
+ topLevelSurfaceBitmap.assertPixels {
+ Color(nestedSurfaceBitmap.getPixel(it.x, it.y))
+ }
+ }
+
/**
* Tests that composed modifiers applied to Surface are applied within the changes to
* [LocalContentColor], so they can consume the updated values.
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Surface.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Surface.kt
index eb9fe03..d2b90c1 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Surface.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Surface.kt
@@ -32,6 +32,7 @@
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
@@ -89,6 +90,10 @@
* @param tonalElevation When [color] is [ColorScheme.surface], a higher the elevation (surface
* blended with more primary) will result in a darker surface color in light theme and lighter color
* in dark theme.
+ * @param shadowElevation The size of the shadow below the surface. To prevent shadow creep, only
+ * apply shadow elevation when absolutely necessary, such as when the surface requires visual
+ * separation from a patterned background. Note that It will not affect z index of the Surface.
+ * If you want to change the drawing order you can use `Modifier.zIndex`.
* @param border Optional border to draw on top of the surface
*/
@Composable
@@ -98,6 +103,7 @@
color: Color = MaterialTheme.colorScheme.surface,
contentColor: Color = contentColorFor(color),
tonalElevation: Dp = 0.dp,
+ shadowElevation: Dp = 0.dp,
border: BorderStroke? = null,
content: @Composable () -> Unit
) {
@@ -107,6 +113,7 @@
color = color,
contentColor = contentColor,
tonalElevation = tonalElevation,
+ shadowElevation = shadowElevation,
border = border,
content = content,
clickAndSemanticsModifier =
@@ -168,6 +175,8 @@
* @param tonalElevation When [color] is [ColorScheme.surface], a higher the elevation (surface
* blended with more primary) will result in a darker surface color in light theme and lighter color
* in dark theme.
+ * @param shadowElevation The size of the shadow below the surface. Note that It will not affect z index
+ * of the Surface. If you want to change the drawing order you can use `Modifier.zIndex`.
* @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
* for this Surface. You can create and pass in your own remembered [MutableInteractionSource] if
* you want to observe [Interaction]s and customize the appearance / behavior of this Surface in
@@ -190,6 +199,7 @@
color: Color = MaterialTheme.colorScheme.surface,
contentColor: Color = contentColorFor(color),
tonalElevation: Dp = 0.dp,
+ shadowElevation: Dp = 0.dp,
border: BorderStroke? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
indication: Indication? = LocalIndication.current,
@@ -204,6 +214,7 @@
color = color,
contentColor = contentColor,
tonalElevation = tonalElevation,
+ shadowElevation = shadowElevation,
border = border,
content = content,
clickAndSemanticsModifier =
@@ -220,12 +231,13 @@
@Composable
private fun Surface(
- modifier: Modifier = Modifier,
- shape: Shape = RectangleShape,
- border: BorderStroke? = null,
- color: Color = MaterialTheme.colorScheme.primary,
- contentColor: Color = contentColorFor(color),
- tonalElevation: Dp = 0.dp, // This will be used to compute surface tonal colors
+ modifier: Modifier,
+ shape: Shape,
+ color: Color,
+ contentColor: Color,
+ border: BorderStroke?,
+ tonalElevation: Dp, // This will be used to compute surface tonal colors
+ shadowElevation: Dp,
clickAndSemanticsModifier: Modifier,
content: @Composable () -> Unit
) {
@@ -242,6 +254,7 @@
) {
Box(
modifier
+ .shadow(shadowElevation, shape, clip = false)
.then(if (border != null) Modifier.border(border, shape) else Modifier)
.background(color = backgroundColor, shape = shape)
.clip(shape)
diff --git a/compose/ui/ui/api/current.txt b/compose/ui/ui/api/current.txt
index ec30e66..a5f0b94 100644
--- a/compose/ui/ui/api/current.txt
+++ b/compose/ui/ui/api/current.txt
@@ -287,6 +287,9 @@
method public boolean moveFocus(int focusDirection);
}
+ public final class FocusManagerKt {
+ }
+
public final class FocusModifierKt {
method @Deprecated public static androidx.compose.ui.Modifier focusModifier(androidx.compose.ui.Modifier);
method public static androidx.compose.ui.Modifier focusTarget(androidx.compose.ui.Modifier);
@@ -333,6 +336,16 @@
method public static androidx.compose.ui.Modifier focusOrder(androidx.compose.ui.Modifier, androidx.compose.ui.focus.FocusRequester focusRequester, kotlin.jvm.functions.Function1<? super androidx.compose.ui.focus.FocusOrder,kotlin.Unit> focusOrderReceiver);
}
+ public interface FocusProperties {
+ method public boolean getCanFocus();
+ method public void setCanFocus(boolean canFocus);
+ property public abstract boolean canFocus;
+ }
+
+ public final class FocusPropertiesKt {
+ method public static androidx.compose.ui.Modifier focusProperties(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function1<? super androidx.compose.ui.focus.FocusProperties,kotlin.Unit> scope);
+ }
+
public final class FocusRequester {
ctor public FocusRequester();
method public boolean captureFocus();
diff --git a/compose/ui/ui/api/public_plus_experimental_current.txt b/compose/ui/ui/api/public_plus_experimental_current.txt
index 6f69075..a0035e9 100644
--- a/compose/ui/ui/api/public_plus_experimental_current.txt
+++ b/compose/ui/ui/api/public_plus_experimental_current.txt
@@ -358,6 +358,9 @@
method public boolean moveFocus(int focusDirection);
}
+ public final class FocusManagerKt {
+ }
+
public final class FocusModifierKt {
method @Deprecated public static androidx.compose.ui.Modifier focusModifier(androidx.compose.ui.Modifier);
method public static androidx.compose.ui.Modifier focusTarget(androidx.compose.ui.Modifier);
@@ -404,6 +407,16 @@
method public static androidx.compose.ui.Modifier focusOrder(androidx.compose.ui.Modifier, androidx.compose.ui.focus.FocusRequester focusRequester, kotlin.jvm.functions.Function1<? super androidx.compose.ui.focus.FocusOrder,kotlin.Unit> focusOrderReceiver);
}
+ public interface FocusProperties {
+ method public boolean getCanFocus();
+ method public void setCanFocus(boolean canFocus);
+ property public abstract boolean canFocus;
+ }
+
+ public final class FocusPropertiesKt {
+ method public static androidx.compose.ui.Modifier focusProperties(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function1<? super androidx.compose.ui.focus.FocusProperties,kotlin.Unit> scope);
+ }
+
public final class FocusRequester {
ctor public FocusRequester();
method public boolean captureFocus();
diff --git a/compose/ui/ui/api/restricted_current.txt b/compose/ui/ui/api/restricted_current.txt
index 28214e6..e34df74 100644
--- a/compose/ui/ui/api/restricted_current.txt
+++ b/compose/ui/ui/api/restricted_current.txt
@@ -287,6 +287,9 @@
method public boolean moveFocus(int focusDirection);
}
+ public final class FocusManagerKt {
+ }
+
public final class FocusModifierKt {
method @Deprecated public static androidx.compose.ui.Modifier focusModifier(androidx.compose.ui.Modifier);
method public static androidx.compose.ui.Modifier focusTarget(androidx.compose.ui.Modifier);
@@ -333,6 +336,16 @@
method public static androidx.compose.ui.Modifier focusOrder(androidx.compose.ui.Modifier, androidx.compose.ui.focus.FocusRequester focusRequester, kotlin.jvm.functions.Function1<? super androidx.compose.ui.focus.FocusOrder,kotlin.Unit> focusOrderReceiver);
}
+ public interface FocusProperties {
+ method public boolean getCanFocus();
+ method public void setCanFocus(boolean canFocus);
+ property public abstract boolean canFocus;
+ }
+
+ public final class FocusPropertiesKt {
+ method public static androidx.compose.ui.Modifier focusProperties(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function1<? super androidx.compose.ui.focus.FocusProperties,kotlin.Unit> scope);
+ }
+
public final class FocusRequester {
ctor public FocusRequester();
method public boolean captureFocus();
diff --git a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/UiDemos.kt b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/UiDemos.kt
index b19fa00..f415db7 100644
--- a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/UiDemos.kt
+++ b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/UiDemos.kt
@@ -53,6 +53,7 @@
import androidx.compose.ui.demos.gestures.ScrollGestureFilterDemo
import androidx.compose.ui.demos.gestures.VerticalScrollerInDrawerDemo
import androidx.compose.ui.demos.input.TouchModeDemo
+import androidx.compose.ui.demos.focus.ConditionalFocusabilityDemo
import androidx.compose.ui.demos.scroll.BringIntoViewDemo
import androidx.compose.ui.demos.keyinput.KeyInputDemo
import androidx.compose.ui.demos.keyinput.InterceptEnterToSendMessageDemo
@@ -132,7 +133,8 @@
ComposableDemo("Custom Focus Order") { CustomFocusOrderDemo() },
ComposableDemo("FocusManager.moveFocus()") { FocusManagerMoveFocusDemo() },
ComposableDemo("Capture/Free Focus") { CaptureFocusDemo() },
- ComposableDemo("Focus In Scrollable Row") { ScrollableRowFocusDemo() }
+ ComposableDemo("Focus In Scrollable Row") { ScrollableRowFocusDemo() },
+ ComposableDemo("Conditional Focusability") { ConditionalFocusabilityDemo() }
)
)
diff --git a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/focus/ConditionalFocusabilityDemo.kt b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/focus/ConditionalFocusabilityDemo.kt
new file mode 100644
index 0000000..08380dd
--- /dev/null
+++ b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/focus/ConditionalFocusabilityDemo.kt
@@ -0,0 +1,128 @@
+/*
+ * 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.ui.demos.focus
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.Button
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color.Companion.Gray
+import androidx.compose.ui.graphics.Color.Companion.Red
+import androidx.compose.ui.input.InputMode.Companion.Keyboard
+import androidx.compose.ui.platform.LocalInputModeManager
+import androidx.compose.ui.unit.dp
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun ConditionalFocusabilityDemo() {
+ val localInputModeManager = LocalInputModeManager.current
+ val (item1, item2, item3, item4) = remember { FocusRequester.createRefs() }
+ Column {
+ Text(
+ """
+ The items here are focusable. Use the
+ keyboard or DPad to move focus among them.
+
+ The 1st item is focusable in all modes.
+ Notice that when you touch the screen it
+ does not lose focus like the other items.
+
+ The 2nd item's focusability can be
+ controlled by using the button next to it.
+
+ The 3rd item is not focusable in touch mode.
+
+ The 4th item is not focusable in touch mode,
+ but clicking on it will request the system
+ to switch to keyboard mode, and then call
+ request focus.
+ """.trimIndent()
+ )
+ Text(
+ text = "Focusable in all modes",
+ modifier = Modifier
+ .focusAwareBackground()
+ .focusRequester(item1)
+ .clickable { item1.requestFocus() }
+ .focusable()
+ )
+ Row {
+ var item2active by remember { mutableStateOf(false) }
+ Text(
+ text = "focusable item that is " +
+ "${if (item2active) "activated" else "deactivated"}",
+ modifier = Modifier
+ .focusAwareBackground()
+ .focusRequester(item2)
+ .clickable { item2.requestFocus() }
+ .focusProperties { canFocus = item2active }
+ .focusable()
+ )
+ Button(onClick = { item2active = !item2active }) {
+ Text("${if (item2active) "deactivate" else "activate"} item 2")
+ }
+ }
+ Text(
+ text = "Focusable in keyboard mode",
+ modifier = Modifier
+ .focusAwareBackground()
+ .focusRequester(item3)
+ .clickable { item3.requestFocus() }
+ .focusProperties { canFocus = localInputModeManager.inputMode == Keyboard }
+ .focusable()
+ )
+ Text(
+ text = "Request focus by touch",
+ modifier = Modifier
+ .focusAwareBackground()
+ .focusRequester(item4)
+ .clickable {
+ if (localInputModeManager.requestInputMode(Keyboard)) {
+ item4.requestFocus()
+ }
+ }
+ .focusProperties { canFocus = localInputModeManager.inputMode == Keyboard }
+ .focusable()
+ )
+ }
+}
+
+private fun Modifier.focusAwareBackground() = composed {
+ var color by remember { mutableStateOf(Gray) }
+ Modifier
+ .padding(10.dp)
+ .size(150.dp, 50.dp)
+ .background(color)
+ .onFocusChanged { color = if (it.isFocused) Red else Gray }
+}
\ No newline at end of file
diff --git a/compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/FocusSamples.kt b/compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/FocusSamples.kt
index 8f0da4a..9d4ab33 100644
--- a/compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/FocusSamples.kt
+++ b/compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/FocusSamples.kt
@@ -40,6 +40,7 @@
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusOrder
+import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusTarget
import androidx.compose.ui.focus.onFocusChanged
@@ -47,7 +48,9 @@
import androidx.compose.ui.graphics.Color.Companion.Green
import androidx.compose.ui.graphics.Color.Companion.Red
import androidx.compose.ui.graphics.Color.Companion.Transparent
+import androidx.compose.ui.input.InputMode.Companion.Touch
import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.unit.dp
@Sampled
@@ -205,3 +208,21 @@
}
}
}
+
+@Sampled
+@Composable
+fun FocusPropertiesSample() {
+ Column {
+ // Always focusable.
+ Box(modifier = Modifier
+ .focusProperties { canFocus = true }
+ .focusTarget()
+ )
+ // Only focusable in non-touch mode.
+ val inputModeManager = LocalInputModeManager.current
+ Box(modifier = Modifier
+ .focusProperties { canFocus = inputModeManager.inputMode != Touch }
+ .focusTarget()
+ )
+ }
+}
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/PainterModifierTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/PainterModifierTest.kt
index 360b423..d139569 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/PainterModifierTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/PainterModifierTest.kt
@@ -18,6 +18,7 @@
import android.graphics.Bitmap
import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -101,7 +102,7 @@
class PainterModifierTest {
val containerWidth = 100.0f
- val containerHeight = 100.0f
+ private val containerHeight = 100.0f
@get:Rule
val rule = createComposeRule()
@@ -751,6 +752,7 @@
}
}
+@RequiresApi(Build.VERSION_CODES.O)
private fun ComposeTestRule.obtainScreenshotBitmap(width: Int, height: Int = width): Bitmap {
val bitmap = onRoot().captureToImage()
assertEquals(width, bitmap.width)
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/CaptureFocusTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/CaptureFocusTest.kt
index c42fa1c..0dcf0c8 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/CaptureFocusTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/CaptureFocusTest.kt
@@ -16,12 +16,12 @@
package androidx.compose.ui.focus
+import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusStateImpl.Active
import androidx.compose.ui.focus.FocusStateImpl.ActiveParent
import androidx.compose.ui.focus.FocusStateImpl.Captured
-import androidx.compose.ui.focus.FocusStateImpl.Disabled
import androidx.compose.ui.focus.FocusStateImpl.Inactive
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -60,6 +60,7 @@
rule.runOnIdle {
assertThat(success).isTrue()
assertThat(focusState.isCaptured).isTrue()
+ assertThat(focusState.isFocused).isTrue()
}
}
@@ -85,6 +86,7 @@
// Assert.
rule.runOnIdle {
assertThat(success).isFalse()
+ assertThat(focusState.isCaptured).isFalse()
assertThat(focusState.hasFocus).isTrue()
}
}
@@ -116,7 +118,7 @@
}
@Test
- fun disabled_captureFocus_retainsStateAsDisabled() {
+ fun deactivated_captureFocus_retainsStateAsDeactivated() {
// Arrange.
lateinit var focusState: FocusState
val focusRequester = FocusRequester()
@@ -125,7 +127,8 @@
modifier = Modifier
.onFocusChanged { focusState = it }
.focusRequester(focusRequester)
- .then(FocusModifier(Disabled))
+ .focusProperties { canFocus = false }
+ .focusable()
)
}
@@ -137,7 +140,45 @@
// Assert.
rule.runOnIdle {
assertThat(success).isFalse()
- assertThat(focusState).isEqualTo(Disabled)
+ assertThat(focusState.isCaptured).isFalse()
+ assertThat(focusState.isFocused).isFalse()
+ assertThat(focusState.isDeactivated).isTrue()
+ }
+ }
+
+ @Test
+ fun deactivatedParent_captureFocus_retainsStateAsDeactivatedParent() {
+ // Arrange.
+ lateinit var focusState: FocusState
+ val initialFocus = FocusRequester()
+ val focusRequester = FocusRequester()
+ rule.setFocusableContent {
+ Box(
+ modifier = Modifier
+ .onFocusChanged { focusState = it }
+ .focusRequester(focusRequester)
+ .focusProperties { canFocus = false }
+ .focusable()
+ ) {
+ Box(modifier = Modifier
+ .focusRequester(initialFocus)
+ .focusable()
+ )
+ }
+ }
+ rule.runOnIdle { initialFocus.requestFocus() }
+
+ // Act.
+ val success = rule.runOnIdle {
+ focusRequester.captureFocus()
+ }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(success).isFalse()
+ assertThat(focusState.isCaptured).isFalse()
+ assertThat(focusState.hasFocus).isTrue()
+ assertThat(focusState.isDeactivated).isTrue()
}
}
@@ -163,7 +204,11 @@
// Assert.
rule.runOnIdle {
assertThat(success).isFalse()
+ assertThat(focusState.isCaptured).isFalse()
assertThat(focusState.isFocused).isFalse()
}
}
}
+
+private val FocusState.isDeactivated: Boolean
+ get() = (this as FocusStateImpl).isDeactivated
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/ClearFocusTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/ClearFocusTest.kt
index 7f981ea..c556531 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/ClearFocusTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/ClearFocusTest.kt
@@ -18,10 +18,12 @@
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusStateImpl.Active
import androidx.compose.ui.focus.FocusStateImpl.ActiveParent
import androidx.compose.ui.focus.FocusStateImpl.Captured
-import androidx.compose.ui.focus.FocusStateImpl.Disabled
+import androidx.compose.ui.focus.FocusStateImpl.Deactivated
+import androidx.compose.ui.focus.FocusStateImpl.DeactivatedParent
import androidx.compose.ui.focus.FocusStateImpl.Inactive
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.filters.SmallTest
@@ -257,11 +259,11 @@
}
@Test
- fun Disabled_isUnchanged() {
+ fun Deactivated_isUnchanged() {
// Arrange.
- val modifier = FocusModifier(Disabled)
+ val modifier = FocusModifier(Inactive)
rule.setFocusableContent {
- Box(modifier = modifier)
+ Box(modifier = Modifier.focusProperties { canFocus = false }.then(modifier))
}
// Act.
@@ -272,7 +274,131 @@
// Assert.
rule.runOnIdle {
assertThat(cleared).isTrue()
- assertThat(modifier.focusState).isEqualTo(Disabled)
+ assertThat(modifier.focusState.isDeactivated).isTrue()
}
}
-}
\ No newline at end of file
+
+ @Test(expected = IllegalArgumentException::class)
+ fun deactivatedParent_noFocusedChild_throwsException() {
+ // Arrange.
+ val modifier = FocusModifier(DeactivatedParent)
+ rule.setFocusableContent {
+ Box(modifier = modifier)
+ }
+
+ // Act.
+ rule.runOnIdle {
+ modifier.focusNode.clearFocus(forced)
+ }
+ }
+
+ @Test
+ fun deactivatedParent_isClearedAndRemovedFromParentsFocusedChild() {
+ // Arrange.
+ val parent = FocusModifier(ActiveParent)
+ val modifier = FocusModifier(ActiveParent)
+ val child = FocusModifier(Active)
+ rule.setFocusableContent {
+ Box(modifier = parent) {
+ Box(modifier = Modifier.focusProperties { canFocus = false }.then(modifier)) {
+ Box(modifier = child)
+ }
+ }
+ SideEffect {
+ parent.focusedChild = modifier.focusNode
+ modifier.focusedChild = child.focusNode
+ }
+ }
+
+ // Act.
+ val cleared = rule.runOnIdle {
+ modifier.focusNode.clearFocus(forced)
+ }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(cleared).isTrue()
+ assertThat(modifier.focusedChild).isNull()
+ assertThat(modifier.focusState.isDeactivated).isTrue()
+ }
+ }
+
+ @Test
+ fun deactivatedParent_withDeactivatedGrandParent_isClearedAndRemovedFromParentsFocusedChild() {
+ // Arrange.
+ val parent = FocusModifier(ActiveParent)
+ val modifier = FocusModifier(ActiveParent)
+ val child = FocusModifier(Active)
+ rule.setFocusableContent {
+ Box(modifier = Modifier.focusProperties { canFocus = false }.then(parent)) {
+ Box(modifier = Modifier.focusProperties { canFocus = false }.then(modifier)) {
+ Box(modifier = child)
+ }
+ }
+ SideEffect {
+ parent.focusedChild = modifier.focusNode
+ modifier.focusedChild = child.focusNode
+ }
+ }
+
+ // Act.
+ val cleared = rule.runOnIdle {
+ modifier.focusNode.clearFocus(forced)
+ }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(cleared).isTrue()
+ assertThat(modifier.focusedChild).isNull()
+ assertThat(modifier.focusState.isDeactivated).isTrue()
+ }
+ }
+
+ @Test
+ fun deactivatedParent_clearsEntireHierarchy() {
+ // Arrange.
+ val modifier = FocusModifier(ActiveParent)
+ val child = FocusModifier(ActiveParent)
+ val grandchild = FocusModifier(ActiveParent)
+ val greatGrandchild = FocusModifier(ActiveParent)
+ val greatGreatGrandchild = FocusModifier(Active)
+ rule.setFocusableContent {
+ Box(modifier = Modifier.focusProperties { canFocus = false }.then(modifier)) {
+ Box(modifier = child) {
+ Box(modifier = Modifier
+ .focusProperties { canFocus = false }
+ .then(grandchild)
+ ) {
+ Box(modifier = greatGrandchild) {
+ Box(modifier = greatGreatGrandchild)
+ }
+ }
+ }
+ }
+ SideEffect {
+ modifier.focusedChild = child.focusNode
+ child.focusedChild = grandchild.focusNode
+ grandchild.focusedChild = greatGrandchild.focusNode
+ greatGrandchild.focusedChild = greatGreatGrandchild.focusNode
+ }
+ }
+
+ // Act.
+ val cleared = rule.runOnIdle {
+ modifier.focusNode.clearFocus(forced)
+ }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(cleared).isTrue()
+ assertThat(modifier.focusedChild).isNull()
+ assertThat(child.focusedChild).isNull()
+ assertThat(grandchild.focusedChild).isNull()
+ assertThat(modifier.focusState).isEqualTo(Deactivated)
+ assertThat(child.focusState).isEqualTo(Inactive)
+ assertThat(grandchild.focusState).isEqualTo(Deactivated)
+ assertThat(greatGrandchild.focusState).isEqualTo(Inactive)
+ assertThat(greatGreatGrandchild.focusState).isEqualTo(Inactive)
+ }
+ }
+}
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/DeactivatedFocusPropertiesTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/DeactivatedFocusPropertiesTest.kt
new file mode 100644
index 0000000..797119d
--- /dev/null
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/DeactivatedFocusPropertiesTest.kt
@@ -0,0 +1,240 @@
+/*
+ * 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.ui.focus
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class DeactivatedFocusPropertiesTest {
+ @get:Rule
+ val rule = createComposeRule()
+
+ @Test
+ fun notDeactivatedByDefault() {
+ // Arrange.
+ var isDeactivated: Boolean? = null
+ rule.setFocusableContent {
+ Box(modifier = Modifier
+ .onFocusChanged { isDeactivated = it.isDeactivated }
+ .focusTarget()
+ )
+ }
+
+ // Assert.
+ rule.runOnIdle { assertThat(isDeactivated).isFalse() }
+ }
+
+ @Test
+ fun initializedAsNotDeactivated() {
+ // Arrange.
+ var deactivated: Boolean? = null
+ rule.setFocusableContent {
+ Box(modifier = Modifier
+ .focusProperties { canFocus = true }
+ .onFocusChanged { deactivated = it.isDeactivated }
+ .focusTarget()
+ )
+ }
+
+ // Assert.
+ rule.runOnIdle { assertThat(deactivated).isFalse() }
+ }
+
+ @Test
+ fun initializedAsDeactivated() {
+ // Arrange.
+ var isDeactivated: Boolean? = null
+ rule.setFocusableContent {
+ Box(modifier = Modifier
+ .focusProperties { isDeactivated = true }
+ .onFocusChanged { isDeactivated = it.isDeactivated }
+ .focusTarget()
+ )
+ }
+
+ // Assert.
+ rule.runOnIdle { assertThat(isDeactivated).isTrue() }
+ }
+
+ @Test
+ fun leftMostDeactivatedPropertyTakesPrecedence() {
+ // Arrange.
+ var deactivated: Boolean? = null
+ rule.setFocusableContent {
+ Box(modifier = Modifier
+ .focusProperties { canFocus = false }
+ .focusProperties { canFocus = true }
+ .onFocusChanged { deactivated = it.isDeactivated }
+ .focusTarget()
+ )
+ }
+
+ // Assert.
+ rule.runOnIdle { assertThat(deactivated).isTrue() }
+ }
+
+ @Test
+ fun leftMostNonDeactivatedPropertyTakesPrecedence() {
+ // Arrange.
+ var deactivated: Boolean? = null
+ rule.setFocusableContent {
+ Box(modifier = Modifier
+ .focusProperties { canFocus = true }
+ .focusProperties { canFocus = false }
+ .onFocusChanged { deactivated = it.isDeactivated }
+ .focusTarget()
+ )
+ }
+
+ // Assert.
+ rule.runOnIdle { assertThat(deactivated).isFalse() }
+ }
+
+ @Test
+ fun ParentsDeactivatedPropertyTakesPrecedence() {
+ // Arrange.
+ var deactivated: Boolean? = null
+ rule.setFocusableContent {
+ Box(modifier = Modifier.focusProperties { canFocus = false }) {
+ Box(modifier = Modifier
+ .focusProperties { canFocus = true }
+ .onFocusChanged { deactivated = it.isDeactivated }
+ .focusTarget()
+ )
+ }
+ }
+
+ // Assert.
+ rule.runOnIdle { assertThat(deactivated).isTrue() }
+ }
+
+ @Test
+ fun ParentsNotDeactivatedPropertyTakesPrecedence() {
+ // Arrange.
+ var deactivated: Boolean? = null
+ rule.setFocusableContent {
+ Box(modifier = Modifier.focusProperties { canFocus = true }) {
+ Box(modifier = Modifier
+ .focusProperties { canFocus = false }
+ .onFocusChanged { deactivated = it.isDeactivated }
+ .focusTarget()
+ )
+ }
+ }
+
+ // Assert.
+ rule.runOnIdle { assertThat(deactivated).isFalse() }
+ }
+
+ @Test
+ fun deactivatedItemDoesNotGainFocus() {
+ // Arrange.
+ var isFocused: Boolean? = null
+ val focusRequester = FocusRequester()
+ rule.setFocusableContent {
+ Box(modifier = Modifier
+ .focusProperties { canFocus = false }
+ .focusRequester(focusRequester)
+ .onFocusChanged { isFocused = it.isFocused }
+ .focusTarget()
+ )
+ }
+
+ // Act.
+ rule.runOnIdle { focusRequester.requestFocus() }
+
+ // Assert.
+ rule.runOnIdle { assertThat(isFocused).isFalse() }
+ }
+
+ @Test
+ fun deactivatedFocusPropertiesOnNonFocusableParentAppliesToAllChildren() {
+ // Arrange.
+ var isParentDeactivated: Boolean? = null
+ var isChild1Deactivated: Boolean? = null
+ var isChild2Deactivated: Boolean? = null
+ var isGrandChildDeactivated: Boolean? = null
+ rule.setFocusableContent {
+ Column(modifier = Modifier
+ .focusProperties { canFocus = false }
+ .onFocusChanged { isParentDeactivated = it.isDeactivated }
+ ) {
+ Box(modifier = Modifier
+ .onFocusChanged { isChild1Deactivated = it.isDeactivated }
+ .focusTarget()
+ )
+ Box(modifier = Modifier
+ .onFocusChanged { isChild2Deactivated = it.isDeactivated }
+ .focusTarget()
+ ) {
+ Box(modifier = Modifier
+ .onFocusChanged { isGrandChildDeactivated = it.isDeactivated }
+ .focusTarget()
+ )
+ }
+ }
+ }
+
+ // Assert.
+ rule.runOnIdle { assertThat(isParentDeactivated).isTrue() }
+ rule.runOnIdle { assertThat(isChild1Deactivated).isTrue() }
+ rule.runOnIdle { assertThat(isChild2Deactivated).isTrue() }
+ rule.runOnIdle { assertThat(isGrandChildDeactivated).isFalse() }
+ }
+
+ @Test
+ fun focusedItemLosesFocusWhenDeactivated() {
+ // Arrange.
+ var isFocused: Boolean? = null
+ val focusRequester = FocusRequester()
+ var deactivated by mutableStateOf(false)
+ rule.setFocusableContent {
+ Box(modifier = Modifier
+ .focusProperties { canFocus = !deactivated }
+ .focusRequester(focusRequester)
+ .onFocusChanged { isFocused = it.isFocused }
+ .focusTarget()
+ )
+ }
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ assertThat(isFocused).isTrue()
+ }
+
+ // Act.
+ rule.runOnIdle { deactivated = true }
+
+ // Assert.
+ rule.runOnIdle { assertThat(isFocused).isFalse() }
+ }
+}
+
+private val FocusState.isDeactivated: Boolean
+ get() = (this as FocusStateImpl).isDeactivated
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FindFocusableChildrenTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FindFocusableChildrenTest.kt
index 93fd9b8..f3038a1 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FindFocusableChildrenTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FindFocusableChildrenTest.kt
@@ -18,41 +18,60 @@
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
+import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusStateImpl.Inactive
import androidx.compose.ui.graphics.Color.Companion.Red
import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
@MediumTest
-@RunWith(AndroidJUnit4::class)
-class FindFocusableChildrenTest {
+@RunWith(Parameterized::class)
+class FindFocusableChildrenTest(private val excludeDeactivated: Boolean) {
@get:Rule
val rule = createComposeRule()
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "excludeDeactivated = {0}")
+ fun initParameters() = listOf(true, false)
+ }
+
@Test
fun returnsFirstFocusNodeInModifierChain() {
val focusModifier1 = FocusModifier(Inactive)
val focusModifier2 = FocusModifier(Inactive)
val focusModifier3 = FocusModifier(Inactive)
+ val focusModifier4 = FocusModifier(Inactive)
// Arrange.
- // layoutNode--focusNode1--focusNode2--focusNode3
+ // layoutNode--focusNode1--focusNode2--focusNode3--focusNode4
rule.setContent {
- Box(modifier = focusModifier1.then(focusModifier2).then(focusModifier3))
+ Box(
+ modifier = Modifier
+ .then(focusModifier1)
+ .focusProperties { canFocus = false }
+ .then(focusModifier2)
+ .then(focusModifier3)
+ .then(focusModifier4)
+ )
}
// Act.
val focusableChildren = rule.runOnIdle {
- focusModifier1.focusNode.focusableChildren()
+ focusModifier1.focusNode.focusableChildren(excludeDeactivated)
}
// Assert.
rule.runOnIdle {
- assertThat(focusableChildren).containsExactly(focusModifier2.focusNode)
+ if (excludeDeactivated) {
+ assertThat(focusableChildren).containsExactly(focusModifier3.focusNode)
+ } else {
+ assertThat(focusableChildren).containsExactly(focusModifier2.focusNode)
+ }
}
}
@@ -60,20 +79,32 @@
fun skipsNonFocusNodesAndReturnsFirstFocusNodeInModifierChain() {
val focusModifier1 = FocusModifier(Inactive)
val focusModifier2 = FocusModifier(Inactive)
+ val focusModifier3 = FocusModifier(Inactive)
// Arrange.
- // layoutNode--focusNode1--nonFocusNode--focusNode2
+ // layoutNode--focusNode1--nonFocusNode--focusNode2--focusNode3
rule.setContent {
- Box(focusModifier1.background(color = Red).then(focusModifier2))
+ Box(
+ modifier = Modifier
+ .then(focusModifier1)
+ .background(color = Red)
+ .focusProperties { canFocus = false }
+ .then(focusModifier2)
+ .then(focusModifier3)
+ )
}
// Act.
val focusableChildren = rule.runOnIdle {
- focusModifier1.focusNode.focusableChildren()
+ focusModifier1.focusNode.focusableChildren(excludeDeactivated)
}
// Assert.
rule.runOnIdle {
- assertThat(focusableChildren).containsExactly(focusModifier2.focusNode)
+ if (excludeDeactivated) {
+ assertThat(focusableChildren).containsExactly(focusModifier3.focusNode)
+ } else {
+ assertThat(focusableChildren).containsExactly(focusModifier2.focusNode)
+ }
}
}
@@ -81,30 +112,45 @@
fun returnsFirstFocusChildOfEachChildLayoutNode() {
// Arrange.
// parentLayoutNode--parentFocusNode
- // |___________________________________
- // | |
- // childLayoutNode1--focusNode1 childLayoutNode2--focusNode2--focusNode3
+ // |___________________________________________
+ // | |
+ // childLayoutNode1--focusNode1--focusNode2 childLayoutNode2--focusNode3--focusNode4
val parentFocusModifier = FocusModifier(Inactive)
val focusModifier1 = FocusModifier(Inactive)
val focusModifier2 = FocusModifier(Inactive)
val focusModifier3 = FocusModifier(Inactive)
+ val focusModifier4 = FocusModifier(Inactive)
rule.setContent {
Box(modifier = parentFocusModifier) {
- Box(modifier = focusModifier1)
- Box(modifier = focusModifier2.then(focusModifier3))
+ Box(modifier = Modifier
+ .focusProperties { canFocus = false }
+ .then(focusModifier1)
+ .then(focusModifier2)
+ )
+ Box(modifier = Modifier
+ .then(focusModifier3)
+ .focusProperties { canFocus = false }
+ .then(focusModifier4)
+ )
}
}
// Act.
val focusableChildren = rule.runOnIdle {
- parentFocusModifier.focusNode.focusableChildren()
+ parentFocusModifier.focusNode.focusableChildren(excludeDeactivated)
}
// Assert.
rule.runOnIdle {
- assertThat(focusableChildren).containsExactly(
- focusModifier1.focusNode, focusModifier2.focusNode
- )
+ if (excludeDeactivated) {
+ assertThat(focusableChildren).containsExactly(
+ focusModifier2.focusNode, focusModifier3.focusNode
+ )
+ } else {
+ assertThat(focusableChildren).containsExactly(
+ focusModifier1.focusNode, focusModifier3.focusNode
+ )
+ }
}
}
}
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FindParentFocusNodeTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FindParentFocusNodeTest.kt
index ec506c2..494246c 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FindParentFocusNodeTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FindParentFocusNodeTest.kt
@@ -18,22 +18,29 @@
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
+import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusStateImpl.Inactive
import androidx.compose.ui.graphics.Color.Companion.Red
import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
@MediumTest
-@RunWith(AndroidJUnit4::class)
-class FindParentFocusNodeTest {
+@RunWith(Parameterized::class)
+class FindParentFocusNodeTest(private val deactivated: Boolean) {
@get:Rule
val rule = createComposeRule()
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "isDeactivated = {0}")
+ fun initParameters() = listOf(true, false)
+ }
+
@Test
fun noParentReturnsNull() {
// Arrange.
@@ -63,7 +70,13 @@
val modifier4 = FocusModifier(Inactive)
val modifier5 = FocusModifier(Inactive)
rule.setFocusableContent {
- Box(modifier1.then(modifier2).then(modifier3).then(modifier4).then(modifier5)) {}
+ Box(modifier = modifier1
+ .focusProperties { canFocus = !deactivated }
+ .then(modifier2)
+ .then(modifier3)
+ .then(modifier4)
+ .then(modifier5)
+ )
}
// Act.
@@ -87,6 +100,7 @@
rule.setFocusableContent {
Box(
modifier = modifier1
+ .focusProperties { canFocus = !deactivated }
.then(modifier2)
.background(color = Red)
.then(modifier3)
@@ -114,7 +128,10 @@
val parentFocusModifier2 = FocusModifier(Inactive)
val focusModifier = FocusModifier(Inactive)
rule.setFocusableContent {
- Box(modifier = parentFocusModifier1.then(parentFocusModifier2)) {
+ Box(modifier = parentFocusModifier1
+ .focusProperties { canFocus = !deactivated }
+ .then(parentFocusModifier2)
+ ) {
Box(modifier = focusModifier)
}
}
@@ -133,18 +150,26 @@
@Test
fun returnsImmediateParent() {
// Arrange.
+ // greatGrandparentLayoutNode--greatGrandparentFocusNode
+ // |
// grandparentLayoutNode--grandparentFocusNode
// |
// parentLayoutNode--parentFocusNode
// |
// layoutNode--focusNode
+ val greatGrandparentFocusModifier = FocusModifier(Inactive)
val grandparentFocusModifier = FocusModifier(Inactive)
val parentFocusModifier = FocusModifier(Inactive)
val focusModifier = FocusModifier(Inactive)
rule.setFocusableContent {
- Box(modifier = grandparentFocusModifier) {
- Box(modifier = parentFocusModifier) {
- Box(modifier = focusModifier)
+ Box(modifier = greatGrandparentFocusModifier) {
+ Box(modifier = grandparentFocusModifier) {
+ Box(modifier = Modifier
+ .focusProperties { canFocus = !deactivated }
+ .then(parentFocusModifier)
+ ) {
+ Box(modifier = focusModifier)
+ }
}
}
}
@@ -161,19 +186,25 @@
}
@Test
- fun ignoresIntermediateLayoutNodesThatDontHaveFocusNodes() {
+ fun ignoresIntermediateLayoutNodesThatDoNotHaveFocusNodes() {
// Arrange.
// grandparentLayoutNode--grandparentFocusNode
// |
// parentLayoutNode
// |
// layoutNode--focusNode
+ val greatGrandparentFocusModifier = FocusModifier(Inactive)
val grandparentFocusModifier = FocusModifier(Inactive)
val focusModifier = FocusModifier(Inactive)
rule.setFocusableContent {
- Box(modifier = grandparentFocusModifier) {
- Box {
- Box(modifier = focusModifier)
+ Box(modifier = greatGrandparentFocusModifier) {
+ Box(modifier = Modifier
+ .focusProperties { canFocus = !deactivated }
+ .then(grandparentFocusModifier)
+ ) {
+ Box {
+ Box(modifier = focusModifier)
+ }
}
}
}
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusChangedTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusChangedTest.kt
index 781fa34..ec729ec 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusChangedTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusChangedTest.kt
@@ -21,7 +21,6 @@
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusStateImpl.Active
import androidx.compose.ui.focus.FocusStateImpl.Captured
-import androidx.compose.ui.focus.FocusStateImpl.Disabled
import androidx.compose.ui.focus.FocusStateImpl.Inactive
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -65,6 +64,7 @@
fun activeParent_requestFocus() {
// Arrange.
lateinit var focusState: FocusState
+ lateinit var childFocusState: FocusState
val (focusRequester, childFocusRequester) = FocusRequester.createRefs()
rule.setFocusableContent {
Box(
@@ -75,6 +75,7 @@
) {
Box(
modifier = Modifier
+ .onFocusChanged { childFocusState = it }
.focusRequester(childFocusRequester)
.focusTarget()
)
@@ -91,6 +92,7 @@
// Assert.
assertThat(focusState.isFocused).isTrue()
+ assertThat(childFocusState.isFocused).isFalse()
}
}
@@ -118,7 +120,7 @@
}
@Test
- fun disabled_requestFocus() {
+ fun deactivated_requestFocus() {
// Arrange.
lateinit var focusState: FocusState
val focusRequester = FocusRequester()
@@ -127,7 +129,8 @@
modifier = Modifier
.onFocusChanged { focusState = it }
.focusRequester(focusRequester)
- .then(FocusModifier(Disabled))
+ .focusProperties { canFocus = false }
+ .focusTarget()
)
}
@@ -136,7 +139,50 @@
focusRequester.requestFocus()
// Assert.
- assertThat(focusState).isEqualTo(Disabled)
+ assertThat(focusState.isDeactivated).isTrue()
+ }
+ }
+
+ @ExperimentalComposeUiApi
+ @Test
+ fun deactivatedParent_requestFocus() {
+ // Arrange.
+ lateinit var focusState: FocusState
+ lateinit var childFocusState: FocusState
+ val (focusRequester, childFocusRequester) = FocusRequester.createRefs()
+ rule.setFocusableContent {
+ Box(
+ modifier = Modifier
+ .onFocusChanged { focusState = it }
+ .focusRequester(focusRequester)
+ .focusProperties { canFocus = false }
+ .focusTarget()
+ ) {
+ Box(
+ modifier = Modifier
+ .onFocusChanged { childFocusState = it }
+ .focusRequester(childFocusRequester)
+ .focusTarget()
+ )
+ }
+ }
+ rule.runOnIdle {
+ childFocusRequester.requestFocus()
+ assertThat(childFocusState.isFocused).isTrue()
+ assertThat(focusState.hasFocus).isTrue()
+ assertThat(focusState.isFocused).isFalse()
+ assertThat(focusState.isDeactivated).isTrue()
+ }
+
+ rule.runOnIdle {
+ // Act.
+ focusRequester.requestFocus()
+
+ // Assert.
+ assertThat(childFocusState.isFocused).isTrue()
+ assertThat(focusState.hasFocus).isTrue()
+ assertThat(focusState.isFocused).isFalse()
+ assertThat(focusState.isDeactivated).isTrue()
}
}
@@ -244,3 +290,6 @@
}
}
}
+
+private val FocusState.isDeactivated: Boolean
+ get() = (this as FocusStateImpl).isDeactivated
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusEventCountTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusEventCountTest.kt
index dcafd34..af002fb 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusEventCountTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusEventCountTest.kt
@@ -17,12 +17,13 @@
package androidx.compose.ui.focus
import androidx.compose.foundation.layout.Box
-import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusStateImpl.Inactive
import androidx.compose.ui.focus.FocusStateImpl.Active
+import androidx.compose.ui.focus.FocusStateImpl.Deactivated
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -42,12 +43,12 @@
fun initially_onFocusEventIsCalledThrice() {
// Arrange.
val focusStates = mutableListOf<FocusState>()
- val focusReferece = FocusRequester()
+ val focusRequester = FocusRequester()
rule.setFocusableContent {
Box(
modifier = Modifier
.onFocusEvent { focusStates.add(it) }
- .focusRequester(focusReferece)
+ .focusRequester(focusRequester)
.focusTarget()
)
}
@@ -56,7 +57,6 @@
rule.runOnIdle {
assertThat(focusStates).containsExactly(
Inactive, // triggered by onFocusEvent node's onModifierChanged().
- Inactive, // triggered by focus node's onModifierChanged().
Inactive, // triggered by focus node's attach().
)
}
@@ -153,14 +153,13 @@
// Arrange.
val focusStates = mutableListOf<FocusState>()
val focusRequester = FocusRequester()
- lateinit var addFocusTarget: MutableState<Boolean>
+ var addFocusTarget by mutableStateOf(true)
rule.setFocusableContent {
- addFocusTarget = remember { mutableStateOf(true) }
Box(
modifier = Modifier
.onFocusEvent { focusStates.add(it) }
.focusRequester(focusRequester)
- .then(if (addFocusTarget.value) Modifier.focusTarget() else Modifier)
+ .then(if (addFocusTarget) Modifier.focusTarget() else Modifier)
)
}
rule.runOnIdle {
@@ -169,7 +168,7 @@
}
// Act.
- rule.runOnIdle { addFocusTarget.value = false }
+ rule.runOnIdle { addFocusTarget = false }
// Assert.
rule.runOnIdle {
@@ -184,19 +183,18 @@
fun removingInactiveFocusNode_onFocusEventIsCalledOnce() {
// Arrange.
val focusStates = mutableListOf<FocusState>()
- lateinit var addFocusTarget: MutableState<Boolean>
+ var addFocusTarget by mutableStateOf(true)
rule.setFocusableContent {
- addFocusTarget = remember { mutableStateOf(true) }
Box(
modifier = Modifier
.onFocusEvent { focusStates.add(it) }
- .then(if (addFocusTarget.value) Modifier.focusTarget() else Modifier)
+ .then(if (addFocusTarget) Modifier.focusTarget() else Modifier)
)
}
rule.runOnIdle { focusStates.clear() }
// Act.
- rule.runOnIdle { addFocusTarget.value = false }
+ rule.runOnIdle { addFocusTarget = false }
// Assert.
rule.runOnIdle { assertThat(focusStates).containsExactly(Inactive) }
@@ -206,19 +204,18 @@
fun addingFocusTarget_onFocusEventIsCalledThrice() {
// Arrange.
val focusStates = mutableListOf<FocusState>()
- lateinit var addFocusTarget: MutableState<Boolean>
+ var addFocusTarget by mutableStateOf(false)
rule.setFocusableContent {
- addFocusTarget = remember { mutableStateOf(false) }
Box(
modifier = Modifier
.onFocusEvent { focusStates.add(it) }
- .then(if (addFocusTarget.value) Modifier.focusTarget() else Modifier)
+ .then(if (addFocusTarget) Modifier.focusTarget() else Modifier)
)
}
rule.runOnIdle { focusStates.clear() }
// Act.
- rule.runOnIdle { addFocusTarget.value = true }
+ rule.runOnIdle { addFocusTarget = true }
// Assert.
rule.runOnIdle {
@@ -229,4 +226,75 @@
)
}
}
+
+ @Test
+ fun addingEmptyFocusProperties_onFocusEventIsCalledTwice() {
+ // Arrange.
+ val focusStates = mutableListOf<FocusState>()
+ var addFocusProperties by mutableStateOf(false)
+ rule.setFocusableContent {
+ Box(
+ modifier = Modifier
+ .onFocusEvent { focusStates.add(it) }
+ .then(if (addFocusProperties) Modifier.focusProperties {} else Modifier)
+ .focusTarget()
+ )
+ }
+ rule.runOnIdle { focusStates.clear() }
+
+ // Act.
+ rule.runOnIdle { addFocusProperties = true }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(focusStates).containsExactly(
+ Inactive, // triggered by onFocusEvent node's onModifierChanged().
+ Inactive, // triggered by focus node's onModifierChanged().
+ )
+ }
+ }
+
+ @Test
+ fun deactivatingFocusNode_onFocusEventIsCalledOnce() {
+ // Arrange.
+ val focusStates = mutableListOf<FocusState>()
+ var deactiated by mutableStateOf(false)
+ rule.setFocusableContent {
+ Box(
+ modifier = Modifier
+ .onFocusEvent { focusStates.add(it) }
+ .focusProperties { canFocus = !deactiated }
+ .focusTarget()
+ )
+ }
+ rule.runOnIdle { focusStates.clear() }
+
+ // Act.
+ rule.runOnIdle { deactiated = true }
+
+ // Assert.
+ rule.runOnIdle { assertThat(focusStates).containsExactly(Deactivated) }
+ }
+
+ @Test
+ fun activatingFocusNode_onFocusEventIsCalledOnce() {
+ // Arrange.
+ val focusStates = mutableListOf<FocusState>()
+ var deactiated by mutableStateOf(true)
+ rule.setFocusableContent {
+ Box(
+ modifier = Modifier
+ .onFocusEvent { focusStates.add(it) }
+ .focusProperties { canFocus = !deactiated }
+ .focusTarget()
+ )
+ }
+ rule.runOnIdle { focusStates.clear() }
+
+ // Act.
+ rule.runOnIdle { deactiated = false }
+
+ // Assert.
+ rule.runOnIdle { assertThat(focusStates).containsExactly(Inactive) }
+ }
}
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusTargetAttachDetachTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusTargetAttachDetachTest.kt
index 6939cc9..f03c428 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusTargetAttachDetachTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusTargetAttachDetachTest.kt
@@ -17,9 +17,9 @@
package androidx.compose.ui.focus
import androidx.compose.foundation.layout.Box
-import androidx.compose.runtime.MutableState
+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.test.junit4.createComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -40,16 +40,15 @@
// Arrange.
lateinit var focusState: FocusState
val focusRequester = FocusRequester()
- lateinit var observingFocusTarget1: MutableState<Boolean>
+ var observingFocusTarget1 by mutableStateOf(true)
rule.setFocusableContent {
val focusRequesterModifier = Modifier.focusRequester(focusRequester)
val onFocusChanged = Modifier.onFocusChanged { focusState = it }
val focusTarget1 = Modifier.focusTarget()
val focusTarget2 = Modifier.focusTarget()
Box {
- observingFocusTarget1 = remember { mutableStateOf(true) }
Box(
- modifier = if (observingFocusTarget1.value) {
+ modifier = if (observingFocusTarget1) {
onFocusChanged
.then(focusRequesterModifier)
.then(focusTarget1)
@@ -69,7 +68,7 @@
}
// Act.
- rule.runOnIdle { observingFocusTarget1.value = false }
+ rule.runOnIdle { observingFocusTarget1 = false }
// Assert.
rule.runOnIdle { assertThat(focusState.isFocused).isFalse() }
@@ -80,15 +79,14 @@
// Arrange.
lateinit var focusState: FocusState
val focusRequester = FocusRequester()
- lateinit var onFocusChangedHasFocusTarget: MutableState<Boolean>
+ var onFocusChangedHasFocusTarget by mutableStateOf(true)
rule.setFocusableContent {
val focusRequesterModifier = Modifier.focusRequester(focusRequester)
val onFocusChanged = Modifier.onFocusChanged { focusState = it }
val focusTarget = Modifier.focusTarget()
Box {
- onFocusChangedHasFocusTarget = remember { mutableStateOf(true) }
Box(
- modifier = if (onFocusChangedHasFocusTarget.value) {
+ modifier = if (onFocusChangedHasFocusTarget) {
onFocusChanged
.then(focusRequesterModifier)
.then(focusTarget)
@@ -106,7 +104,7 @@
}
// Act.
- rule.runOnIdle { onFocusChangedHasFocusTarget.value = false }
+ rule.runOnIdle { onFocusChangedHasFocusTarget = false }
// Assert.
rule.runOnIdle { assertThat(focusState.isFocused).isFalse() }
@@ -117,13 +115,12 @@
// Arrange.
lateinit var focusState: FocusState
val focusRequester = FocusRequester()
- lateinit var optionalFocusTarget: MutableState<Boolean>
+ var optionalFocusTarget by mutableStateOf(true)
rule.setFocusableContent {
- optionalFocusTarget = remember { mutableStateOf(true) }
Box(
modifier = Modifier.onFocusChanged { focusState = it }
.focusRequester(focusRequester)
- .then(if (optionalFocusTarget.value) Modifier.focusTarget() else Modifier)
+ .then(if (optionalFocusTarget) Modifier.focusTarget() else Modifier)
)
}
rule.runOnIdle {
@@ -132,7 +129,7 @@
}
// Act.
- rule.runOnIdle { optionalFocusTarget.value = false }
+ rule.runOnIdle { optionalFocusTarget = false }
// Assert.
rule.runOnIdle { assertThat(focusState.isFocused).isFalse() }
@@ -143,13 +140,12 @@
// Arrange.
lateinit var focusState: FocusState
val focusRequester = FocusRequester()
- lateinit var optionalFocusTarget: MutableState<Boolean>
+ var optionalFocusTarget by mutableStateOf(true)
rule.setFocusableContent {
- optionalFocusTarget = remember { mutableStateOf(true) }
Box(
modifier = Modifier.onFocusChanged { focusState = it }
.focusRequester(focusRequester)
- .then(if (optionalFocusTarget.value) Modifier.focusTarget() else Modifier)
+ .then(if (optionalFocusTarget) Modifier.focusTarget() else Modifier)
) {
Box(modifier = Modifier.focusTarget())
}
@@ -160,7 +156,7 @@
}
// Act.
- rule.runOnIdle { optionalFocusTarget.value = false }
+ rule.runOnIdle { optionalFocusTarget = false }
// Assert.
rule.runOnIdle { assertThat(focusState.isFocused).isFalse() }
@@ -171,13 +167,12 @@
// Arrange.
lateinit var focusState: FocusState
val focusRequester = FocusRequester()
- lateinit var optionalFocusTarget: MutableState<Boolean>
+ var optionalFocusTarget by mutableStateOf(true)
rule.setFocusableContent {
- optionalFocusTarget = remember { mutableStateOf(true) }
Box(
modifier = Modifier.onFocusChanged { focusState = it }
.focusRequester(focusRequester)
- .then(if (optionalFocusTarget.value) Modifier.focusTarget() else Modifier)
+ .then(if (optionalFocusTarget) Modifier.focusTarget() else Modifier)
) {
Box(modifier = Modifier.focusTarget())
}
@@ -189,7 +184,7 @@
}
// Act.
- rule.runOnIdle { optionalFocusTarget.value = false }
+ rule.runOnIdle { optionalFocusTarget = false }
// Assert.
rule.runOnIdle { assertThat(focusState.isFocused).isFalse() }
@@ -200,15 +195,17 @@
// Arrange.
lateinit var focusState: FocusState
val focusRequester = FocusRequester()
- lateinit var optionalFocusTarget: MutableState<Boolean>
+ var optionalFocusTarget by mutableStateOf(true)
rule.setFocusableContent {
- optionalFocusTarget = remember { mutableStateOf(true) }
Box(
- modifier = Modifier.onFocusChanged { focusState = it }
- .then(if (optionalFocusTarget.value) Modifier.focusTarget() else Modifier)
- .focusRequester(focusRequester)
+ modifier = Modifier
+ .onFocusChanged { focusState = it }
+ .then(if (optionalFocusTarget) Modifier.focusTarget() else Modifier)
) {
- Box(modifier = Modifier.focusTarget())
+ Box(modifier = Modifier
+ .focusRequester(focusRequester)
+ .focusTarget()
+ )
}
}
rule.runOnIdle {
@@ -217,7 +214,7 @@
}
// Act.
- rule.runOnIdle { optionalFocusTarget.value = false }
+ rule.runOnIdle { optionalFocusTarget = false }
// Assert.
rule.runOnIdle { assertThat(focusState.isFocused).isTrue() }
@@ -228,13 +225,12 @@
// Arrange.
lateinit var focusState: FocusState
val focusRequester = FocusRequester()
- lateinit var optionalFocusTarget: MutableState<Boolean>
+ var optionalFocusTarget by mutableStateOf(true)
rule.setFocusableContent {
- optionalFocusTarget = remember { mutableStateOf(true) }
Box(
modifier = Modifier.onFocusChanged { focusState = it }
.then(
- if (optionalFocusTarget.value) {
+ if (optionalFocusTarget) {
Modifier
.focusTarget()
.focusRequester(focusRequester)
@@ -251,7 +247,7 @@
}
// Act.
- rule.runOnIdle { optionalFocusTarget.value = false }
+ rule.runOnIdle { optionalFocusTarget = false }
// Assert.
rule.runOnIdle { assertThat(focusState.isFocused).isFalse() }
@@ -263,9 +259,8 @@
lateinit var focusState: FocusState
lateinit var parentFocusState: FocusState
val focusRequester = FocusRequester()
- lateinit var optionalFocusTargets: MutableState<Boolean>
+ var optionalFocusTargets by mutableStateOf(true)
rule.setFocusableContent {
- optionalFocusTargets = remember { mutableStateOf(true) }
Box(
modifier = Modifier
.onFocusChanged { parentFocusState = it }
@@ -273,7 +268,7 @@
) {
Box(
modifier = Modifier.onFocusChanged { focusState = it }.then(
- if (optionalFocusTargets.value) {
+ if (optionalFocusTargets) {
Modifier.focusTarget()
.focusRequester(focusRequester)
.focusTarget()
@@ -291,7 +286,7 @@
}
// Act.
- rule.runOnIdle { optionalFocusTargets.value = false }
+ rule.runOnIdle { optionalFocusTargets = false }
// Assert.
rule.runOnIdle {
@@ -301,23 +296,269 @@
}
@Test
+ fun removedDeactivatedParentFocusTarget_pointsToNextFocusTarget() {
+ // Arrange.
+ lateinit var focusState: FocusState
+ val focusRequester = FocusRequester()
+ var optionalFocusTarget by mutableStateOf(true)
+ rule.setFocusableContent {
+ Box(
+ modifier = Modifier
+ .onFocusChanged { focusState = it }
+ .then(
+ if (optionalFocusTarget)
+ Modifier
+ .focusProperties { canFocus = false }
+ .focusTarget()
+ else
+ Modifier
+ )
+ ) {
+ Box(modifier = Modifier
+ .focusRequester(focusRequester)
+ .focusTarget()
+ )
+ }
+ }
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ assertThat(focusState.hasFocus).isTrue()
+ assertThat(focusState.isDeactivated).isTrue()
+ }
+
+ // Act.
+ rule.runOnIdle { optionalFocusTarget = false }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(focusState.isFocused).isTrue()
+ assertThat(focusState.isDeactivated).isFalse()
+ }
+ }
+
+ @Test
+ fun removedDeactivatedParentFocusTarget_pointsToNextDeactivatedParentFocusTarget() {
+ // Arrange.
+ lateinit var focusState: FocusState
+ val focusRequester = FocusRequester()
+ var optionalFocusTarget by mutableStateOf(true)
+ rule.setFocusableContent {
+ Box(
+ modifier = Modifier
+ .onFocusChanged { focusState = it }
+ .then(
+ if (optionalFocusTarget)
+ Modifier
+ .focusProperties { canFocus = false }
+ .focusTarget()
+ else
+ Modifier
+ )
+ ) {
+ Box(
+ modifier = Modifier
+ .onFocusChanged { focusState = it }
+ .focusProperties { canFocus = false }
+ .focusTarget()
+ ) {
+ Box(
+ modifier = Modifier
+ .focusRequester(focusRequester)
+ .focusTarget()
+ )
+ }
+ }
+ }
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ assertThat(focusState.hasFocus).isTrue()
+ assertThat(focusState.isDeactivated).isTrue()
+ }
+
+ // Act.
+ rule.runOnIdle { optionalFocusTarget = false }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(focusState.isFocused).isFalse()
+ assertThat(focusState.hasFocus).isTrue()
+ assertThat(focusState.isDeactivated).isTrue()
+ }
+ }
+
+ @Test
+ fun removedDeactivatedParent_parentsFocusTarget_isUnchanged() {
+ // Arrange.
+ lateinit var focusState: FocusState
+ val focusRequester = FocusRequester()
+ var optionalFocusTarget by mutableStateOf(true)
+ rule.setFocusableContent {
+ Box(
+ modifier = Modifier
+ .onFocusChanged { focusState = it }
+ .focusProperties { canFocus = false }
+ .focusTarget()
+ ) {
+ Box(
+ modifier = Modifier.then(
+ if (optionalFocusTarget)
+ Modifier
+ .focusProperties { canFocus = false }
+ .focusTarget()
+ else
+ Modifier
+ )
+ ) {
+ Box(
+ modifier = Modifier
+ .focusRequester(focusRequester)
+ .focusTarget()
+ )
+ }
+ }
+ }
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ assertThat(focusState.isFocused).isFalse()
+ assertThat(focusState.hasFocus).isTrue()
+ assertThat(focusState.isDeactivated).isTrue()
+ }
+
+ // Act.
+ rule.runOnIdle { optionalFocusTarget = false }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(focusState.isFocused).isFalse()
+ assertThat(focusState.hasFocus).isTrue()
+ assertThat(focusState.isDeactivated).isTrue()
+ }
+ }
+
+ @Test
+ fun removedDeactivatedParentAndActiveChild_grandparent_retainsDeactivatedState() {
+ // Arrange.
+ lateinit var focusState: FocusState
+ val focusRequester = FocusRequester()
+ var optionalFocusTarget by mutableStateOf(true)
+ rule.setFocusableContent {
+ Box(
+ modifier = Modifier
+ .onFocusChanged { focusState = it }
+ .focusProperties { canFocus = false }
+ .focusTarget()
+ ) {
+ Box(
+ modifier = Modifier
+ .onFocusChanged { focusState = it }
+ .then(
+ if (optionalFocusTarget)
+ Modifier
+ .focusProperties { canFocus = false }
+ .focusTarget()
+ else
+ Modifier
+ )
+ ) {
+ Box(
+ modifier = Modifier
+ .focusRequester(focusRequester)
+ .then(
+ if (optionalFocusTarget)
+ Modifier.focusTarget()
+ else
+ Modifier
+ )
+ )
+ }
+ }
+ }
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ assertThat(focusState.hasFocus).isTrue()
+ assertThat(focusState.isDeactivated).isTrue()
+ }
+
+ // Act.
+ rule.runOnIdle { optionalFocusTarget = false }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(focusState.isFocused).isFalse()
+ assertThat(focusState.hasFocus).isFalse()
+ assertThat(focusState.isDeactivated).isTrue()
+ }
+ }
+
+ @Test
+ fun removedNonDeactivatedParentAndActiveChild_grandParent_retainsNonDeactivatedState() {
+ // Arrange.
+ lateinit var focusState: FocusState
+ val focusRequester = FocusRequester()
+ var optionalFocusTarget by mutableStateOf(true)
+ rule.setFocusableContent {
+ Box(
+ modifier = Modifier
+ .onFocusChanged { focusState = it }
+ .focusTarget()
+ ) {
+ Box(
+ modifier = Modifier.then(
+ if (optionalFocusTarget)
+ Modifier
+ .focusProperties { canFocus = false }
+ .focusTarget()
+ else
+ Modifier
+ )
+ ) {
+ Box(
+ modifier = Modifier
+ .focusRequester(focusRequester)
+ .then(
+ if (optionalFocusTarget)
+ Modifier.focusTarget()
+ else
+ Modifier
+ )
+ )
+ }
+ }
+ }
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ assertThat(focusState.hasFocus).isTrue()
+ assertThat(focusState.isDeactivated).isFalse()
+ }
+
+ // Act.
+ rule.runOnIdle { optionalFocusTarget = false }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(focusState.isFocused).isFalse()
+ assertThat(focusState.hasFocus).isFalse()
+ assertThat(focusState.isDeactivated).isFalse()
+ }
+ }
+
+ @Test
fun removedInactiveFocusTarget_pointsToNextFocusTarget() {
// Arrange.
lateinit var focusState: FocusState
val focusRequester = FocusRequester()
- lateinit var optionalFocusTarget: MutableState<Boolean>
+ var optionalFocusTarget by mutableStateOf(true)
rule.setFocusableContent {
- optionalFocusTarget = remember { mutableStateOf(true) }
Box(
modifier = Modifier.onFocusChanged { focusState = it }
- .then(if (optionalFocusTarget.value) Modifier.focusTarget() else Modifier)
+ .then(if (optionalFocusTarget) Modifier.focusTarget() else Modifier)
.focusRequester(focusRequester)
.focusTarget()
)
}
// Act.
- rule.runOnIdle { optionalFocusTarget.value = false }
+ rule.runOnIdle { optionalFocusTarget = false }
// Assert.
rule.runOnIdle { assertThat(focusState.isFocused).isFalse() }
@@ -328,13 +569,12 @@
// Arrange.
lateinit var focusState: FocusState
val focusRequester = FocusRequester()
- lateinit var addFocusTarget: MutableState<Boolean>
+ var addFocusTarget by mutableStateOf(false)
rule.setFocusableContent {
- addFocusTarget = remember { mutableStateOf(false) }
Box(
modifier = Modifier.onFocusChanged { focusState = it }
.focusRequester(focusRequester)
- .then(if (addFocusTarget.value) Modifier.focusTarget() else Modifier)
+ .then(if (addFocusTarget) Modifier.focusTarget() else Modifier)
) {
Box(modifier = Modifier.focusTarget())
}
@@ -345,7 +585,7 @@
}
// Act.
- rule.runOnIdle { addFocusTarget.value = true }
+ rule.runOnIdle { addFocusTarget = true }
// Assert.
rule.runOnIdle { assertThat(focusState.isFocused).isFalse() }
@@ -356,13 +596,12 @@
// Arrange.
lateinit var focusState: FocusState
val focusRequester = FocusRequester()
- lateinit var addFocusTarget: MutableState<Boolean>
+ var addFocusTarget by mutableStateOf(false)
rule.setFocusableContent {
- addFocusTarget = remember { mutableStateOf(false) }
Box(
modifier = Modifier.onFocusChanged { focusState = it }
.focusRequester(focusRequester)
- .then(if (addFocusTarget.value) Modifier.focusTarget() else Modifier)
+ .then(if (addFocusTarget) Modifier.focusTarget() else Modifier)
)
}
rule.runOnIdle {
@@ -371,9 +610,109 @@
}
// Act.
- rule.runOnIdle { addFocusTarget.value = true }
+ rule.runOnIdle { addFocusTarget = true }
// Assert.
rule.runOnIdle { assertThat(focusState.isFocused).isFalse() }
}
+
+ @Test
+ fun removingDeactivatedItem_withNoNextFocusTarget() {
+ // Arrange.
+ lateinit var focusState: FocusState
+ var removeDeactivatedItem by mutableStateOf(false)
+ rule.setFocusableContent {
+ Box(
+ modifier = Modifier
+ .onFocusChanged { focusState = it }
+ .then(
+ if (removeDeactivatedItem)
+ Modifier
+ else
+ Modifier
+ .focusProperties { canFocus = false }
+ .focusTarget()
+ )
+ )
+ }
+
+ // Act.
+ rule.runOnIdle { removeDeactivatedItem = true }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(focusState.isFocused).isFalse()
+ assertThat(focusState.isDeactivated).isFalse()
+ }
+ }
+
+ @Test
+ fun removingDeactivatedItem_withInactiveNextFocusTarget() {
+ // Arrange.
+ lateinit var focusState: FocusState
+ var removeDeactivatedItem by mutableStateOf(false)
+ rule.setFocusableContent {
+ Box(
+ modifier = Modifier
+ .onFocusChanged { focusState = it }
+ .then(
+ if (removeDeactivatedItem)
+ Modifier
+ else
+ Modifier
+ .focusProperties { canFocus = false }
+ .focusTarget()
+ )
+ ) {
+ Box(modifier = Modifier.focusTarget())
+ }
+ }
+
+ // Act.
+ rule.runOnIdle { removeDeactivatedItem = true }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(focusState.isFocused).isFalse()
+ assertThat(focusState.isDeactivated).isFalse()
+ }
+ }
+
+ @Test
+ fun removingDeactivatedItem_withDeactivatedNextFocusTarget() {
+ // Arrange.
+ lateinit var focusState: FocusState
+ var removeDeactivatedItem by mutableStateOf(false)
+ rule.setFocusableContent {
+ Box(
+ modifier = Modifier
+ .onFocusChanged { focusState = it }
+ .then(
+ if (removeDeactivatedItem)
+ Modifier
+ else
+ Modifier
+ .focusProperties { canFocus = false }
+ .focusTarget()
+ )
+ ) {
+ Box(modifier = Modifier
+ .focusProperties { canFocus = false }
+ .focusTarget()
+ )
+ }
+ }
+
+ // Act.
+ rule.runOnIdle { removeDeactivatedItem = true }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(focusState.isFocused).isFalse()
+ assertThat(focusState.isDeactivated).isTrue()
+ }
+ }
}
+
+private val FocusState.isDeactivated: Boolean
+ get() = (this as FocusStateImpl).isDeactivated
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusTestUtils.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusTestUtils.kt
index 80e2b01..1094abc 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusTestUtils.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusTestUtils.kt
@@ -51,6 +51,7 @@
width: Int,
height: Int,
focusRequester: FocusRequester? = null,
+ deactivated: Boolean = false,
content: @Composable () -> Unit = {}
) {
Layout(
@@ -59,6 +60,7 @@
.offset { IntOffset(x, y) }
.focusRequester(focusRequester ?: remember { FocusRequester() })
.onFocusChanged { isFocused.value = it.isFocused }
+ .focusProperties { canFocus = !deactivated }
.focusTarget(),
measurePolicy = remember(width, height) {
MeasurePolicy { measurables, constraint ->
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FreeFocusTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FreeFocusTest.kt
index 37eefcd..a0845c9 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FreeFocusTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FreeFocusTest.kt
@@ -21,12 +21,11 @@
import androidx.compose.ui.focus.FocusStateImpl.Active
import androidx.compose.ui.focus.FocusStateImpl.ActiveParent
import androidx.compose.ui.focus.FocusStateImpl.Captured
-import androidx.compose.ui.focus.FocusStateImpl.Disabled
import androidx.compose.ui.focus.FocusStateImpl.Inactive
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
-import com.google.common.truth.Truth
+import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -56,8 +55,8 @@
val success = focusRequester.freeFocus()
// Assert.
- Truth.assertThat(success).isTrue()
- Truth.assertThat(focusState.isFocused).isTrue()
+ assertThat(success).isTrue()
+ assertThat(focusState.isFocused).isTrue()
}
}
@@ -80,8 +79,8 @@
val success = focusRequester.freeFocus()
// Assert.
- Truth.assertThat(success).isFalse()
- Truth.assertThat(focusState.hasFocus).isTrue()
+ assertThat(success).isFalse()
+ assertThat(focusState.hasFocus).isTrue()
}
}
@@ -104,13 +103,13 @@
val success = focusRequester.freeFocus()
// Assert.
- Truth.assertThat(success).isTrue()
- Truth.assertThat(focusState.isFocused).isTrue()
+ assertThat(success).isTrue()
+ assertThat(focusState.isFocused).isTrue()
}
}
@Test
- fun disabled_freeFocus_retainFocusAsDisabled() {
+ fun deactivated_freeFocus_retainFocusAsDeactivated() {
// Arrange.
lateinit var focusState: FocusState
val focusRequester = FocusRequester()
@@ -119,7 +118,8 @@
modifier = Modifier
.onFocusChanged { focusState = it }
.focusRequester(focusRequester)
- .then(FocusModifier(Disabled))
+ .focusProperties { canFocus = false }
+ .then(FocusModifier(Inactive))
)
}
@@ -128,8 +128,8 @@
val success = focusRequester.freeFocus()
// Assert.
- Truth.assertThat(success).isFalse()
- Truth.assertThat(focusState).isEqualTo(Disabled)
+ assertThat(success).isFalse()
+ assertThat(focusState.isDeactivated).isTrue()
}
}
@@ -152,8 +152,11 @@
val success = focusRequester.freeFocus()
// Assert.
- Truth.assertThat(success).isFalse()
- Truth.assertThat(focusState.isFocused).isFalse()
+ assertThat(success).isFalse()
+ assertThat(focusState.isFocused).isFalse()
}
}
}
+
+private val FocusState.isDeactivated: Boolean
+ get() = (this as FocusStateImpl).isDeactivated
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/OneDimensionalFocusSearchNextTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/OneDimensionalFocusSearchNextTest.kt
index 2288a5b..4140f6b 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/OneDimensionalFocusSearchNextTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/OneDimensionalFocusSearchNextTest.kt
@@ -16,6 +16,7 @@
package androidx.compose.ui.focus
+import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.focus.FocusDirection.Companion.Next
@@ -45,7 +46,22 @@
rule.setContentWithInitialRootFocus {}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Next)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Next) }
+
+ // Assert.
+ rule.runOnIdle { assertThat(movedFocusSuccessfully).isFalse() }
+ }
+
+ @Test
+ fun moveFocus_oneDisabledFocusableItem() {
+ // Arrange.
+ val isItemFocused = mutableStateOf(false)
+ rule.setContentWithInitialRootFocus {
+ FocusableBox(isItemFocused, 0, 0, 10, 10, deactivated = true)
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Next) }
// Assert.
rule.runOnIdle { assertThat(movedFocusSuccessfully).isFalse() }
@@ -60,7 +76,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Next)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Next) }
// Assert.
rule.runOnIdle {
@@ -70,6 +86,28 @@
}
@Test
+ fun initialFocus_skipsDeactivatedItem() {
+ // Arrange.
+ val (firstItem, secondItem) = List(2) { mutableStateOf(false) }
+ rule.setContentWithInitialRootFocus {
+ Column {
+ FocusableBox(firstItem, 0, 0, 10, 10, deactivated = true)
+ FocusableBox(secondItem, 0, 0, 10, 10)
+ }
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Next) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isTrue()
+ assertThat(firstItem.value).isFalse()
+ assertThat(secondItem.value).isTrue()
+ }
+ }
+
+ @Test
fun initialFocus_firstItemInCompositionOrderGetsFocus() {
// Arrange.
val (firstItem, secondItem) = List(2) { mutableStateOf(false) }
@@ -79,7 +117,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Next)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Next) }
// Assert.
rule.runOnIdle {
@@ -102,7 +140,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Next)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Next) }
// Assert.
rule.runOnIdle {
@@ -123,7 +161,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Next)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Next) }
// Assert.
rule.runOnIdle {
@@ -144,7 +182,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Next)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Next) }
// Assert.
rule.runOnIdle {
@@ -164,7 +202,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Next)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Next) }
// Assert.
rule.runOnIdle {
@@ -174,6 +212,27 @@
}
@Test
+ fun focusMovesToThirdItem_skipsDeactivatedItem() {
+ // Arrange.
+ val (item1, item2, item3, item4) = List(4) { mutableStateOf(false) }
+ rule.setContentForTest {
+ FocusableBox(item1, 0, 0, 10, 10, initialFocus)
+ FocusableBox(item2, 10, 0, 10, 10, deactivated = true)
+ FocusableBox(item3, 10, 0, 10, 10)
+ FocusableBox(item4, 20, 0, 10, 10)
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Next) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isTrue()
+ assertThat(item3.value).isTrue()
+ }
+ }
+
+ @Test
fun focusMovesToThirdItem() {
// Arrange.
val (item1, item2, item3) = List(3) { mutableStateOf(false) }
@@ -184,7 +243,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Next)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Next) }
// Assert.
rule.runOnIdle {
@@ -194,6 +253,27 @@
}
@Test
+ fun focusMovesToFourthItem() {
+ // Arrange.
+ val (item1, item2, item3, item4) = List(4) { mutableStateOf(false) }
+ rule.setContentForTest {
+ FocusableBox(item1, 0, 0, 10, 10)
+ FocusableBox(item2, 0, 0, 10, 10, deactivated = true)
+ FocusableBox(item3, 10, 0, 10, 10, initialFocus)
+ FocusableBox(item4, 20, 0, 10, 10)
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Next) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isTrue()
+ assertThat(item4.value).isTrue()
+ }
+ }
+
+ @Test
fun focusWrapsAroundToFirstItem() {
// Arrange.
val (item1, item2, item3) = List(3) { mutableStateOf(false) }
@@ -204,7 +284,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Next)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Next) }
// Assert.
rule.runOnIdle {
@@ -214,11 +294,55 @@
}
@Test
+ fun focusWrapsAroundToFirstItem_skippingLastDeactivatedItem() {
+ // Arrange.
+ val (item1, item2, item3, item4) = List(4) { mutableStateOf(false) }
+ rule.setContentForTest {
+ FocusableBox(item1, 0, 0, 10, 10)
+ FocusableBox(item2, 10, 0, 10, 10)
+ FocusableBox(item3, 20, 0, 10, 10, initialFocus)
+ FocusableBox(item4, 10, 0, 10, 10, deactivated = true)
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Next) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isTrue()
+ assertThat(item1.value).isTrue()
+ }
+ }
+
+ @Test
+ fun focusWrapsAroundToFirstItem_skippingFirstDeactivatedItem() {
+ // Arrange.
+ val (item1, item2, item3, item4) = List(4) { mutableStateOf(false) }
+ rule.setContentForTest {
+ FocusableBox(item1, 10, 0, 10, 10, deactivated = true)
+ FocusableBox(item2, 0, 0, 10, 10)
+ FocusableBox(item3, 10, 0, 10, 10)
+ FocusableBox(item4, 20, 0, 10, 10, initialFocus)
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Next) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isTrue()
+ assertThat(item2.value).isTrue()
+ }
+ }
+
+ @Test
fun focusNextOrdering() {
// Arrange.
val (parent1, child1, child2, child3) = List(4) { mutableStateOf(false) }
val (parent2, child4, child5) = List(3) { mutableStateOf(false) }
val (parent3, child6) = List(2) { mutableStateOf(false) }
+ val (parent4, child7, child8, child9, child10) = List(5) { mutableStateOf(false) }
+ val (parent5, child11) = List(2) { mutableStateOf(false) }
rule.setContentWithInitialRootFocus {
FocusableBox(parent1, 0, 0, 10, 10) {
FocusableBox(child1, 0, 0, 10, 10)
@@ -232,37 +356,55 @@
}
FocusableBox(child5, 20, 0, 10, 10)
}
+ FocusableBox(parent4, 0, 10, 10, 10, deactivated = true) {
+ FocusableBox(child7, 0, 10, 10, 10, deactivated = true)
+ FocusableBox(child8, 0, 10, 10, 10)
+ FocusableBox(parent5, 10, 10, 10, 10, deactivated = true) {
+ FocusableBox(child11, 0, 0, 10, 10)
+ }
+ FocusableBox(child9, 20, 0, 10, 10)
+ FocusableBox(child10, 20, 0, 10, 10, deactivated = true)
+ }
}
// Act & Assert.
- focusManager.moveFocus(Next)
+ rule.runOnIdle { focusManager.moveFocus(Next) }
rule.runOnIdle { assertThat(parent1.value).isTrue() }
- focusManager.moveFocus(Next)
+ rule.runOnIdle { focusManager.moveFocus(Next) }
rule.runOnIdle { assertThat(child1.value).isTrue() }
- focusManager.moveFocus(Next)
+ rule.runOnIdle { focusManager.moveFocus(Next) }
rule.runOnIdle { assertThat(child2.value).isTrue() }
- focusManager.moveFocus(Next)
+ rule.runOnIdle { focusManager.moveFocus(Next) }
rule.runOnIdle { assertThat(child3.value).isTrue() }
- focusManager.moveFocus(Next)
+ rule.runOnIdle { focusManager.moveFocus(Next) }
rule.runOnIdle { assertThat(parent2.value).isTrue() }
- focusManager.moveFocus(Next)
+ rule.runOnIdle { focusManager.moveFocus(Next) }
rule.runOnIdle { assertThat(child4.value).isTrue() }
- focusManager.moveFocus(Next)
+ rule.runOnIdle { focusManager.moveFocus(Next) }
rule.runOnIdle { assertThat(parent3.value).isTrue() }
- focusManager.moveFocus(Next)
+ rule.runOnIdle { focusManager.moveFocus(Next) }
rule.runOnIdle { assertThat(child6.value).isTrue() }
- focusManager.moveFocus(Next)
+ rule.runOnIdle { focusManager.moveFocus(Next) }
rule.runOnIdle { assertThat(child5.value).isTrue() }
- focusManager.moveFocus(Next)
+ rule.runOnIdle { focusManager.moveFocus(Next) }
+ rule.runOnIdle { assertThat(child8.value).isTrue() }
+
+ rule.runOnIdle { focusManager.moveFocus(Next) }
+ rule.runOnIdle { assertThat(child11.value).isTrue() }
+
+ rule.runOnIdle { focusManager.moveFocus(Next) }
+ rule.runOnIdle { assertThat(child9.value).isTrue() }
+
+ rule.runOnIdle { focusManager.moveFocus(Next) }
rule.runOnIdle { assertThat(parent1.value).isTrue() }
}
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/OneDimensionalFocusSearchPreviousTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/OneDimensionalFocusSearchPreviousTest.kt
index dc09292..115d70e 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/OneDimensionalFocusSearchPreviousTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/OneDimensionalFocusSearchPreviousTest.kt
@@ -16,6 +16,7 @@
package androidx.compose.ui.focus
+import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.focus.FocusDirection.Companion.Previous
@@ -45,7 +46,22 @@
rule.setContentWithInitialRootFocus {}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Previous)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Previous) }
+
+ // Assert.
+ rule.runOnIdle { assertThat(movedFocusSuccessfully).isFalse() }
+ }
+
+ @Test
+ fun moveFocus_oneDisabledFocusableItem() {
+ // Arrange.
+ val isItemFocused = mutableStateOf(false)
+ rule.setContentWithInitialRootFocus {
+ FocusableBox(isItemFocused, 0, 0, 10, 10, deactivated = true)
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Previous) }
// Assert.
rule.runOnIdle { assertThat(movedFocusSuccessfully).isFalse() }
@@ -60,7 +76,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Previous)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Previous) }
// Assert.
rule.runOnIdle {
@@ -70,6 +86,28 @@
}
@Test
+ fun initialFocus_skipsDeactivatedItem() {
+ // Arrange.
+ val (firstItem, secondItem) = List(2) { mutableStateOf(false) }
+ rule.setContentWithInitialRootFocus {
+ Column {
+ FocusableBox(firstItem, 0, 0, 10, 10)
+ FocusableBox(secondItem, 0, 0, 10, 10, deactivated = true)
+ }
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Previous) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isTrue()
+ assertThat(firstItem.value).isTrue()
+ assertThat(secondItem.value).isFalse()
+ }
+ }
+
+ @Test
fun initialFocus_lastItemInCompositionOrderGetsFocus() {
// Arrange.
val (firstItem, secondItem) = List(2) { mutableStateOf(false) }
@@ -79,7 +117,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Previous)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Previous) }
// Assert.
rule.runOnIdle {
@@ -102,7 +140,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Previous)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Previous) }
// Assert.
rule.runOnIdle {
@@ -123,7 +161,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Previous)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Previous) }
// Assert.
rule.runOnIdle {
@@ -144,7 +182,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Previous)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Previous) }
// Assert.
rule.runOnIdle {
@@ -164,7 +202,28 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Previous)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Previous) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isTrue()
+ assertThat(item2.value).isTrue()
+ }
+ }
+
+ @Test
+ fun focusMovesToSecondItem_skipsDeactivatedItem() {
+ // Arrange.
+ val (item1, item2, item3, item4) = List(4) { mutableStateOf(false) }
+ rule.setContentForTest {
+ FocusableBox(item1, 0, 0, 10, 10)
+ FocusableBox(item2, 10, 0, 10, 10)
+ FocusableBox(item3, 10, 0, 10, 10, deactivated = true)
+ FocusableBox(item4, 20, 0, 10, 10, initialFocus)
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Previous) }
// Assert.
rule.runOnIdle {
@@ -184,7 +243,28 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Previous)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Previous) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isTrue()
+ assertThat(item1.value).isTrue()
+ }
+ }
+
+ @Test
+ fun focusMovesToFirstItem_ignoresDeactivated() {
+ // Arrange.
+ val (item1, item2, item3, item4) = List(4) { mutableStateOf(false) }
+ rule.setContentForTest {
+ FocusableBox(item1, 0, 0, 10, 10)
+ FocusableBox(item2, 10, 0, 10, 10, initialFocus)
+ FocusableBox(item3, 20, 0, 10, 10, deactivated = true)
+ FocusableBox(item4, 20, 0, 10, 10)
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Previous) }
// Assert.
rule.runOnIdle {
@@ -204,7 +284,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Previous)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Previous) }
// Assert.
rule.runOnIdle {
@@ -214,11 +294,56 @@
}
@Test
- fun focusNextOrdering() {
+ fun focusWrapsAroundToLastItem_skippingFirstDeactivatedItem() {
+ // Arrange.
+ val (item1, item2, item3, item4) = List(4) { mutableStateOf(false) }
+ rule.setContentForTest {
+ FocusableBox(item1, 20, 0, 10, 10, deactivated = true)
+ FocusableBox(item2, 0, 0, 10, 10, initialFocus)
+ FocusableBox(item3, 10, 0, 10, 10)
+ FocusableBox(item4, 20, 0, 10, 10)
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Previous) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isTrue()
+ assertThat(item4.value).isTrue()
+ }
+ }
+
+ @Test
+ fun focusWrapsAroundToLastItem_skippingLastDeactivatedItem() {
+ // Arrange.
+ val (item1, item2, item3, item4) = List(4) { mutableStateOf(false) }
+ rule.setContentForTest {
+ FocusableBox(item1, 0, 0, 10, 10, initialFocus)
+ FocusableBox(item2, 10, 0, 10, 10)
+ FocusableBox(item3, 20, 0, 10, 10)
+ FocusableBox(item4, 20, 0, 10, 10, deactivated = true)
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Previous) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isTrue()
+ assertThat(item3.value).isTrue()
+ }
+ }
+
+ @Test
+ fun focusPreviousOrdering() {
// Arrange.
val (parent1, child1, child2, child3) = List(4) { mutableStateOf(false) }
val (parent2, child4, child5) = List(3) { mutableStateOf(false) }
val (parent3, child6) = List(2) { mutableStateOf(false) }
+ val (parent4, child7, child8, child11, child12) = List(5) { mutableStateOf(false) }
+ val (child9, child10) = List(2) { mutableStateOf(false) }
+ val (parent5, child13) = List(2) { mutableStateOf(false) }
rule.setContentWithInitialRootFocus {
FocusableBox(parent1, 0, 0, 10, 10) {
FocusableBox(child1, 0, 0, 10, 10)
@@ -232,38 +357,61 @@
}
FocusableBox(child5, 20, 0, 10, 10)
}
+ FocusableBox(parent4, 0, 10, 10, 10, deactivated = true) {
+ FocusableBox(child7, 0, 10, 10, 10, deactivated = true)
+ FocusableBox(child8, 0, 10, 10, 10)
+ FocusableBox(child9, 0, 10, 10, 10, deactivated = true)
+ FocusableBox(child10, 0, 10, 10, 10)
+ FocusableBox(parent5, 10, 10, 10, 10, deactivated = true) {
+ FocusableBox(child13, 0, 0, 10, 10)
+ }
+ FocusableBox(child11, 20, 0, 10, 10)
+ FocusableBox(child12, 20, 0, 10, 10, deactivated = true)
+ }
}
// Act & Assert.
- focusManager.moveFocus(Previous)
+ rule.runOnIdle { focusManager.moveFocus(Previous) }
+ rule.runOnIdle { assertThat(child11.value).isTrue() }
+
+ rule.runOnIdle { focusManager.moveFocus(Previous) }
+ rule.runOnIdle { assertThat(child13.value).isTrue() }
+
+ rule.runOnIdle { focusManager.moveFocus(Previous) }
+ rule.runOnIdle { assertThat(child10.value).isTrue() }
+
+ rule.runOnIdle { focusManager.moveFocus(Previous) }
+ rule.runOnIdle { assertThat(child8.value).isTrue() }
+
+ rule.runOnIdle { focusManager.moveFocus(Previous) }
rule.runOnIdle { assertThat(child5.value).isTrue() }
- focusManager.moveFocus(Previous)
+ rule.runOnIdle { focusManager.moveFocus(Previous) }
rule.runOnIdle { assertThat(child6.value).isTrue() }
- focusManager.moveFocus(Previous)
+ rule.runOnIdle { focusManager.moveFocus(Previous) }
rule.runOnIdle { assertThat(parent3.value).isTrue() }
- focusManager.moveFocus(Previous)
+ rule.runOnIdle { focusManager.moveFocus(Previous) }
rule.runOnIdle { assertThat(child4.value).isTrue() }
- focusManager.moveFocus(Previous)
+ rule.runOnIdle { focusManager.moveFocus(Previous) }
rule.runOnIdle { assertThat(parent2.value).isTrue() }
- focusManager.moveFocus(Previous)
+ rule.runOnIdle { focusManager.moveFocus(Previous) }
rule.runOnIdle { assertThat(child3.value).isTrue() }
- focusManager.moveFocus(Previous)
+ rule.runOnIdle { focusManager.moveFocus(Previous) }
rule.runOnIdle { assertThat(child2.value).isTrue() }
- focusManager.moveFocus(Previous)
+ rule.runOnIdle { focusManager.moveFocus(Previous) }
rule.runOnIdle { assertThat(child1.value).isTrue() }
- focusManager.moveFocus(Previous)
+ rule.runOnIdle { focusManager.moveFocus(Previous) }
rule.runOnIdle { assertThat(parent1.value).isTrue() }
- focusManager.moveFocus(Previous)
- rule.runOnIdle { assertThat(child5.value).isTrue() }
+ rule.runOnIdle { focusManager.moveFocus(Previous) }
+ rule.runOnIdle { assertThat(child11.value).isTrue() }
}
private fun ComposeContentTestRule.setContentForTest(composable: @Composable () -> Unit) {
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/RequestFocusTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/RequestFocusTest.kt
index 5fda2c1..ad2151b 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/RequestFocusTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/RequestFocusTest.kt
@@ -17,10 +17,12 @@
package androidx.compose.ui.focus
import androidx.compose.foundation.layout.Box
+import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusStateImpl.Active
import androidx.compose.ui.focus.FocusStateImpl.ActiveParent
import androidx.compose.ui.focus.FocusStateImpl.Captured
-import androidx.compose.ui.focus.FocusStateImpl.Disabled
+import androidx.compose.ui.focus.FocusStateImpl.Deactivated
+import androidx.compose.ui.focus.FocusStateImpl.DeactivatedParent
import androidx.compose.ui.focus.FocusStateImpl.Inactive
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.filters.SmallTest
@@ -82,11 +84,11 @@
}
@Test
- fun disabled_isUnchanged() {
+ fun deactivated_isUnchanged() {
// Arrange.
- val focusModifier = FocusModifier(Disabled)
+ val focusModifier = FocusModifier(Inactive)
rule.setFocusableContent {
- Box(modifier = focusModifier)
+ Box(modifier = Modifier.focusProperties { canFocus = false }.then(focusModifier))
}
// Act.
@@ -96,7 +98,7 @@
// Assert.
rule.runOnIdle {
- assertThat(focusModifier.focusState).isEqualTo(Disabled)
+ assertThat(focusModifier.focusState).isEqualTo(Deactivated)
}
}
@@ -150,6 +152,87 @@
}
}
+ @Test(expected = IllegalArgumentException::class)
+ fun deactivatedParent_withNoFocusedChild_throwsException() {
+ // Arrange.
+ val focusModifier = FocusModifier(DeactivatedParent)
+ rule.setFocusableContent {
+ Box(modifier = focusModifier)
+ }
+
+ // Act.
+ rule.runOnIdle {
+ focusModifier.focusNode.requestFocus(propagateFocus)
+ }
+ }
+
+ @Test
+ fun deactivatedParent_propagateFocus() {
+ // Arrange.
+ val focusModifier = FocusModifier(ActiveParent)
+ val childFocusModifier = FocusModifier(Active)
+ rule.setFocusableContent {
+ Box(modifier = Modifier.focusProperties { canFocus = false }.then(focusModifier)) {
+ Box(modifier = childFocusModifier)
+ }
+ }
+ rule.runOnIdle {
+ focusModifier.focusedChild = childFocusModifier.focusNode
+ }
+
+ // Act.
+ rule.runOnIdle {
+ focusModifier.focusNode.requestFocus(propagateFocus)
+ }
+
+ // Assert.
+ rule.runOnIdle {
+ // Unchanged.
+ assertThat(focusModifier.focusState).isEqualTo(DeactivatedParent)
+ assertThat(childFocusModifier.focusState).isEqualTo(Active)
+ }
+ }
+
+ @Test
+ fun deactivatedParent_activeChild_propagateFocus() {
+ // Arrange.
+ val focusModifier = FocusModifier(ActiveParent)
+ val childFocusModifier = FocusModifier(Active)
+ val grandchildFocusModifier = FocusModifier(Inactive)
+ rule.setFocusableContent {
+ Box(modifier = Modifier.focusProperties { canFocus = false }.then(focusModifier)) {
+ Box(modifier = childFocusModifier) {
+ Box(modifier = grandchildFocusModifier)
+ }
+ }
+ }
+ rule.runOnIdle {
+ focusModifier.focusedChild = childFocusModifier.focusNode
+ }
+
+ // Act.
+ rule.runOnIdle {
+ focusModifier.focusNode.requestFocus(propagateFocus)
+ }
+
+ // Assert.
+ rule.runOnIdle {
+ when (propagateFocus) {
+ true -> {
+ assertThat(focusModifier.focusState).isEqualTo(DeactivatedParent)
+ assertThat(childFocusModifier.focusState).isEqualTo(Active)
+ assertThat(grandchildFocusModifier.focusState).isEqualTo(Inactive)
+ }
+ false -> {
+ assertThat(focusModifier.focusState).isEqualTo(DeactivatedParent)
+ assertThat(childFocusModifier.focusState).isEqualTo(Active)
+ assertThat(childFocusModifier.focusedChild).isNull()
+ assertThat(grandchildFocusModifier.focusState).isEqualTo(Inactive)
+ }
+ }
+ }
+ }
+
@Test
fun inactiveRoot_propagateFocusSendsRequestToOwner_systemCanGrantFocus() {
// Arrange.
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalInTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalInTest.kt
index bcfcd7c..5e15e48 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalInTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalInTest.kt
@@ -57,7 +57,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(In)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(In) }
// Assert.
rule.runOnIdle {
@@ -68,6 +68,37 @@
}
/**
+ * ___________________ ____________
+ * | focusedItem | | |
+ * | ______________ | | |
+ * | | deactivated | | | otherItem |
+ * | |_____________| | | |
+ * |__________________| |___________|
+ */
+ @Test
+ fun focusIn_deactivatedChild_doesNotMoveFocus() {
+ // Arrange.
+ val (focusedItem, deactivatedItem, otherItem) = List(3) { mutableStateOf(false) }
+ rule.setContentForTest {
+ FocusableBox(focusedItem, 0, 0, 10, 10, initialFocus) {
+ FocusableBox(deactivatedItem, 10, 0, 10, 10, deactivated = true)
+ }
+ FocusableBox(otherItem, 10, 0, 10, 10)
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(In) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isFalse()
+ assertThat(focusedItem.value).isTrue()
+ assertThat(deactivatedItem.value).isFalse()
+ assertThat(otherItem.value).isFalse()
+ }
+ }
+
+ /**
* _______________
* | focusedItem |
* | _________ |
@@ -86,7 +117,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(In)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(In) }
// Assert.
rule.runOnIdle {
@@ -120,7 +151,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(In)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(In) }
// Assert.
rule.runOnIdle {
@@ -131,6 +162,41 @@
}
}
+ /**
+ * _________________________
+ * | focusedItem |
+ * | ___________________ |
+ * | | child | |
+ * | | _____________ | |
+ * | | | grandchild | | |
+ * | | |____________| | |
+ * | |__________________| |
+ * |________________________|
+ */
+ @Test
+ fun focusIn_skipsImmediateDeactivatedChild() {
+ // Arrange.
+ val (child, grandchild) = List(2) { mutableStateOf(false) }
+ rule.setContentForTest {
+ FocusableBox(focusedItem, 0, 0, 30, 30, initialFocus) {
+ FocusableBox(child, 10, 10, 10, 10, deactivated = true) {
+ FocusableBox(grandchild, 10, 10, 10, 10)
+ }
+ }
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(In) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isTrue()
+ assertThat(focusedItem.value).isFalse()
+ assertThat(child.value).isFalse()
+ assertThat(grandchild.value).isTrue()
+ }
+ }
+
// TODO(b/176847718): After RTL support is added, add a similar test where the topRight child
// is focused.
/**
@@ -160,7 +226,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(In)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(In) }
// Assert.
rule.runOnIdle {
@@ -170,6 +236,43 @@
}
}
+ /**
+ * _______________________________________
+ * | focusedItem |
+ * | _________ _________ _________ |
+ * | | child1 | | child2 | | child3 | |
+ * | |________| |________| |________| |
+ * | _________ _________ _________ |
+ * | | child4 | | child5 | | child6 | |
+ * | |________| |________| |________| |
+ * |______________________________________|
+ */
+ @Test
+ fun focusIn_deactivatedTopLeftChildIsSkipped() {
+ // Arrange.
+ val children = List(6) { mutableStateOf(false) }
+ rule.setContentForTest {
+ FocusableBox(focusedItem, 0, 0, 70, 50, initialFocus) {
+ FocusableBox(children[0], 10, 10, 10, 10, deactivated = true)
+ FocusableBox(children[1], 30, 10, 10, 10)
+ FocusableBox(children[2], 50, 10, 10, 10)
+ FocusableBox(children[3], 10, 30, 10, 10)
+ FocusableBox(children[4], 30, 30, 10, 10)
+ FocusableBox(children[5], 50, 30, 10, 10)
+ }
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(In) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isTrue()
+ assertThat(focusedItem.value).isFalse()
+ assertThat(children.values).containsExactly(false, true, false, false, false, false)
+ }
+ }
+
private fun ComposeContentTestRule.setContentForTest(composable: @Composable () -> Unit) {
setContent {
focusManager = LocalFocusManager.current
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalInitialFocusTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalInitialFocusTest.kt
index 8f7cd87..1831252 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalInitialFocusTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalInitialFocusTest.kt
@@ -24,7 +24,6 @@
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.ExperimentalComposeUiApi
-import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection.Companion.Down
import androidx.compose.ui.focus.FocusDirection.Companion.Left
import androidx.compose.ui.focus.FocusDirection.Companion.Right
@@ -84,7 +83,7 @@
rule.runOnIdle { view.requestFocus() }
// Act.
- focusManager.moveFocus(focusDirection)
+ rule.runOnIdle { focusManager.moveFocus(focusDirection) }
// Assert.
rule.runOnIdle {
@@ -99,6 +98,66 @@
}
@Test
+ fun initialFocus_DeactivatedItemIsSkipped() {
+ // Arrange.
+ lateinit var view: View
+ lateinit var focusManager: FocusManager
+ val isFocused = MutableList(9) { mutableStateOf(false) }
+ rule.setContent {
+ view = LocalView.current
+ focusManager = LocalFocusManager.current
+ Column {
+ Row {
+ FocusableBox(isFocused[0], deactivated = true)
+ FocusableBox(isFocused[1])
+ FocusableBox(isFocused[2])
+ }
+ Row {
+ FocusableBox(isFocused[3])
+ FocusableBox(isFocused[4])
+ FocusableBox(isFocused[5])
+ }
+ Row {
+ FocusableBox(isFocused[6], deactivated = true)
+ FocusableBox(isFocused[7])
+ FocusableBox(isFocused[8])
+ }
+ }
+ }
+ rule.runOnIdle { view.requestFocus() }
+
+ // Act.
+ rule.runOnIdle { focusManager.moveFocus(focusDirection) }
+
+ // Assert.
+ rule.runOnIdle {
+ when (focusDirection) {
+ Up -> assertThat(isFocused.values).containsExactly(
+ false, false, false,
+ false, false, false,
+ false, true, false
+ )
+ Down -> assertThat(isFocused.values).containsExactly(
+ false, true, false,
+ false, false, false,
+ false, false, false
+ )
+ Left -> assertThat(isFocused.values).containsExactly(
+ false, true, false,
+ false, false, false,
+ false, false, false
+ )
+ Right -> assertThat(isFocused.values).containsExactly(
+ false, true, false,
+ false, false, false,
+ false, false, false
+ )
+ else -> error(invalid)
+ }
+ }
+ }
+
+ @Test
fun initialFocus_whenThereIsOnlyOneFocusable() {
// Arrange.
val isFocused = mutableStateOf(false)
@@ -112,7 +171,7 @@
rule.runOnIdle { view.requestFocus() }
// Act.
- focusManager.moveFocus(focusDirection)
+ rule.runOnIdle { focusManager.moveFocus(focusDirection) }
// Assert.
rule.runOnIdle { assertThat(isFocused.value).isTrue() }
@@ -131,44 +190,23 @@
rule.runOnIdle { view.requestFocus() }
// Act.
- focusManager.moveFocus(focusDirection)
+ rule.runOnIdle { focusManager.moveFocus(focusDirection) }
}
@Test
- fun initialFocus_notTriggeredIfActiveElementIsNotRoot() {
+ fun doesNotCrash_whenThereIsOneDeactivatedItem() {
// Arrange.
+ lateinit var view: View
lateinit var focusManager: FocusManager
- var isColumnFocused = false
- val isFocused = MutableList(4) { mutableStateOf(false) }
- val initialFocusRequester = FocusRequester()
rule.setContent {
+ view = LocalView.current
focusManager = LocalFocusManager.current
- Column(
- Modifier
- .focusRequester(initialFocusRequester)
- .onFocusChanged { isColumnFocused = it.isFocused }
- .focusTarget()
- ) {
- Row {
- FocusableBox(isFocused[0])
- FocusableBox(isFocused[1])
- }
- Row {
- FocusableBox(isFocused[2])
- FocusableBox(isFocused[3])
- }
- }
+ FocusableBox(deactivated = true)
}
- rule.runOnIdle { initialFocusRequester.requestFocus() }
+ rule.runOnIdle { view.requestFocus() }
// Act.
- focusManager.moveFocus(focusDirection)
-
- // Assert.
- rule.runOnIdle {
- assertThat(isColumnFocused).isTrue()
- assertThat(isFocused.values).containsExactly(false, false, false, false)
- }
+ rule.runOnIdle { focusManager.moveFocus(focusDirection) }
}
@OptIn(ExperimentalComposeUiApi::class)
@@ -210,7 +248,7 @@
rule.runOnIdle { initialFocusedItem.requestFocus() }
// Act.
- focusManager.moveFocus(focusDirection)
+ rule.runOnIdle { focusManager.moveFocus(focusDirection) }
// Assert.
rule.runOnIdle {
@@ -221,15 +259,68 @@
}
}
}
+
+ @OptIn(ExperimentalComposeUiApi::class)
+ @Test
+ fun movesFocusAmongSiblingsDeepInTheFocusHierarchy_skipsDeactivatedSibling() {
+ // Arrange.
+ lateinit var focusManager: FocusManager
+ val isFocused = MutableList(3) { mutableStateOf(false) }
+ val (item1, item3) = FocusRequester.createRefs()
+ val siblings = @Composable {
+ FocusableBox(isFocused[0], item1)
+ FocusableBox(isFocused[1])
+ FocusableBox(isFocused[2], item3)
+ }
+ val initialFocusedItem = when (focusDirection) {
+ Up, Left -> item3
+ Down, Right -> item1
+ else -> error(invalid)
+ }
+ rule.setContent {
+ focusManager = LocalFocusManager.current
+ FocusableBox {
+ FocusableBox {
+ FocusableBox {
+ FocusableBox {
+ FocusableBox {
+ FocusableBox {
+ when (focusDirection) {
+ Up, Down -> Column { siblings() }
+ Left, Right -> Row { siblings() }
+ else -> error(invalid)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ rule.runOnIdle { initialFocusedItem.requestFocus() }
+
+ // Act.
+ rule.runOnIdle { focusManager.moveFocus(focusDirection) }
+
+ // Assert.
+ rule.runOnIdle {
+ when (focusDirection) {
+ Up, Left -> assertThat(isFocused.values).containsExactly(true, false, false)
+ Down, Right -> assertThat(isFocused.values).containsExactly(false, false, true)
+ else -> error(invalid)
+ }
+ }
+ }
}
@Composable
private fun FocusableBox(
isFocused: MutableState<Boolean> = mutableStateOf(false),
focusRequester: FocusRequester? = null,
+ deactivated: Boolean = false,
content: @Composable () -> Unit = {}
) {
- FocusableBox(isFocused, 0, 0, 10, 10, focusRequester, content)
+ FocusableBox(isFocused, 0, 0, 10, 10, focusRequester, deactivated, content)
}
private val MutableList<MutableState<Boolean>>.values get() = this.map { it.value }
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalOutTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalOutTest.kt
index 5f2a84d..4f33fbd 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalOutTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalOutTest.kt
@@ -58,7 +58,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Out)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Out) }
// Assert.
rule.runOnIdle {
@@ -86,7 +86,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Out)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Out) }
// Assert.
rule.runOnIdle {
@@ -97,6 +97,35 @@
}
/**
+ * _____________________
+ * | parent |
+ * | _______________ |
+ * | | focusedItem | |
+ * | |______________| |
+ * |____________________|
+ */
+ @Test
+ fun focusOut_doesNotfocusOnDeactivatedParent() {
+ // Arrange.
+ val parent = mutableStateOf(false)
+ rule.setContentForTest {
+ FocusableBox(parent, 0, 0, 30, 30, deactivated = true) {
+ FocusableBox(focusedItem, 10, 10, 10, 10, initialFocus)
+ }
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Out) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isFalse()
+ assertThat(focusedItem.value).isTrue()
+ assertThat(parent.value).isFalse()
+ }
+ }
+
+ /**
* __________________________
* | grandparent |
* | ____________________ |
@@ -120,7 +149,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Out)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Out) }
// Assert.
rule.runOnIdle {
@@ -132,6 +161,76 @@
}
/**
+ * __________________________
+ * | grandparent |
+ * | ____________________ |
+ * | | parent | |
+ * | | ______________ | |
+ * | | | focusedItem | | |
+ * | | |_____________| | |
+ * | |___________________| |
+ * |_________________________|
+ */
+ @Test
+ fun focusOut_skipsImmediateParentIfItIsDeactivated() {
+ // Arrange.
+ val (parent, grandparent) = List(2) { mutableStateOf(false) }
+ rule.setContentForTest {
+ FocusableBox(grandparent, 0, 0, 50, 50) {
+ FocusableBox(parent, 10, 10, 30, 30, deactivated = true) {
+ FocusableBox(focusedItem, 10, 10, 10, 10, initialFocus)
+ }
+ }
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Out) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isTrue()
+ assertThat(focusedItem.value).isFalse()
+ assertThat(parent.value).isFalse()
+ assertThat(grandparent.value).isTrue()
+ }
+ }
+
+ /**
+ * __________________________
+ * | grandparent |
+ * | ____________________ |
+ * | | parent | |
+ * | | ______________ | |
+ * | | | focusedItem | | |
+ * | | |_____________| | |
+ * | |___________________| |
+ * |_________________________|
+ */
+ @Test
+ fun focusOut_doesNotChangeIfAllParentsAreDeactivated() {
+ // Arrange.
+ val (parent, grandparent) = List(2) { mutableStateOf(false) }
+ rule.setContentForTest {
+ FocusableBox(grandparent, 0, 0, 50, 50, deactivated = true) {
+ FocusableBox(parent, 10, 10, 30, 30, deactivated = true) {
+ FocusableBox(focusedItem, 10, 10, 10, 10, initialFocus)
+ }
+ }
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Out) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isFalse()
+ assertThat(focusedItem.value).isTrue()
+ assertThat(parent.value).isFalse()
+ assertThat(grandparent.value).isFalse()
+ }
+ }
+
+ /**
* _____________________
* | parent |
* | _______________ | ____________
@@ -151,7 +250,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Right)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Right) }
// Assert.
rule.runOnIdle {
@@ -163,6 +262,39 @@
}
/**
+ * _____________________
+ * | parent |
+ * | _______________ | ___________________ ____________
+ * | | focusedItem | | | deactivatedItem | | nextItem |
+ * | |______________| | |__________________| |___________|
+ * |____________________|
+ */
+ @Test
+ fun focusRight_focusesOnNonDeactivatedSiblingOfParent() {
+ // Arrange.
+ val (parent, deactivated, nextItem) = List(3) { mutableStateOf(false) }
+ rule.setContentForTest {
+ FocusableBox(parent, 0, 0, 30, 30) {
+ FocusableBox(focusedItem, 10, 10, 10, 10, initialFocus)
+ }
+ FocusableBox(deactivated, 40, 10, 10, 10, deactivated = true)
+ FocusableBox(nextItem, 40, 10, 10, 10)
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Right) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isTrue()
+ assertThat(focusedItem.value).isFalse()
+ assertThat(parent.value).isFalse()
+ assertThat(deactivated.value).isFalse()
+ assertThat(nextItem.value).isTrue()
+ }
+ }
+
+ /**
* ___________________________
* | grandparent |
* | _____________________ |
@@ -187,7 +319,7 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Right)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Right) }
// Assert.
rule.runOnIdle {
@@ -230,7 +362,53 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Left)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Left) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isTrue()
+ assertThat(focusedItem.value).isFalse()
+ assertThat(parent.value).isFalse()
+ assertThat(item1.value).isTrue()
+ assertThat(item2.value).isFalse()
+ assertThat(item3.value).isFalse()
+ assertThat(item4.value).isFalse()
+ assertThat(item5.value).isFalse()
+ }
+ }
+
+ /**
+ * _____________________
+ * | parent |
+ * _______________ | _______________ |
+ * | item1 | | | focusedItem | |
+ * |______________| | |______________| |
+ * _______________ | _______________ |
+ * | item2 | | | item4 | |
+ * |______________| | |______________| |
+ * _______________ | _______________ |
+ * | item3 | | | item5 | |
+ * |______________| | |______________| |
+ * |____________________|
+ */
+ @Test
+ fun focusLeft_fromItemOnLeftEdge_movesFocusOutsideDeactivatedParent() {
+ // Arrange.
+ val parent = mutableStateOf(false)
+ val (item1, item2, item3, item4, item5) = List(5) { mutableStateOf(false) }
+ rule.setContentForTest {
+ FocusableBox(item1, 0, 10, 10, 10)
+ FocusableBox(item2, 0, 30, 10, 10)
+ FocusableBox(item3, 0, 50, 10, 10)
+ FocusableBox(parent, 20, 0, 30, 70, deactivated = true) {
+ FocusableBox(focusedItem, 10, 10, 10, 10, initialFocus)
+ FocusableBox(item4, 10, 30, 10, 10)
+ FocusableBox(item5, 10, 50, 10, 10)
+ }
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Left) }
// Assert.
rule.runOnIdle {
@@ -276,7 +454,53 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Right)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Right) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isTrue()
+ assertThat(focusedItem.value).isFalse()
+ assertThat(parent.value).isFalse()
+ assertThat(item1.value).isFalse()
+ assertThat(item2.value).isFalse()
+ assertThat(item3.value).isTrue()
+ assertThat(item4.value).isFalse()
+ assertThat(item5.value).isFalse()
+ }
+ }
+
+ /**
+ * _____________________
+ * | parent |
+ * | _______________ | _______________
+ * | | focusedItem | | | item3 |
+ * | |______________| | |______________|
+ * | _______________ | _______________
+ * | | item1 | | | item4 |
+ * | |______________| | |______________|
+ * | _______________ | _______________
+ * | | item2 | | | item5 |
+ * | |______________| | |______________|
+ * |____________________|
+ */
+ @Test
+ fun focusRight_fromItemOnRightEdge_movesFocusOutsideDeactivatedParent() {
+ // Arrange.
+ val parent = mutableStateOf(false)
+ val (item1, item2, item3, item4, item5) = List(5) { mutableStateOf(false) }
+ rule.setContentForTest {
+ FocusableBox(parent, 0, 0, 30, 70, deactivated = true) {
+ FocusableBox(focusedItem, 10, 10, 10, 10, initialFocus)
+ FocusableBox(item1, 10, 30, 10, 10)
+ FocusableBox(item2, 10, 50, 10, 10)
+ }
+ FocusableBox(item3, 40, 10, 10, 10)
+ FocusableBox(item4, 40, 30, 10, 10)
+ FocusableBox(item5, 40, 50, 10, 10)
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Right) }
// Assert.
rule.runOnIdle {
@@ -319,7 +543,50 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Up)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Up) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isTrue()
+ assertThat(focusedItem.value).isFalse()
+ assertThat(parent.value).isFalse()
+ assertThat(item1.value).isTrue()
+ assertThat(item2.value).isFalse()
+ assertThat(item3.value).isFalse()
+ assertThat(item4.value).isFalse()
+ assertThat(item5.value).isFalse()
+ }
+ }
+
+ /**
+ * _______________ _______________ _______________
+ * | item1 | | item2 | | item3 |
+ * |______________| |______________| |______________|
+ * _________________________________________________________
+ * | parent |
+ * | _______________ _______________ _______________ |
+ * | | focusedItem | | item4 | | item5 | |
+ * | |______________| |______________| |______________| |
+ * |________________________________________________________|
+ */
+ @Test
+ fun focusUp_fromTopmostItem_movesFocusOutsideDeactivatedParent() {
+ // Arrange.
+ val parent = mutableStateOf(false)
+ val (item1, item2, item3, item4, item5) = List(5) { mutableStateOf(false) }
+ rule.setContentForTest {
+ FocusableBox(item1, 10, 0, 10, 10)
+ FocusableBox(item2, 30, 0, 10, 10)
+ FocusableBox(item3, 50, 0, 10, 10)
+ FocusableBox(parent, 0, 20, 70, 30, deactivated = true) {
+ FocusableBox(focusedItem, 10, 10, 10, 10, initialFocus)
+ FocusableBox(item4, 30, 10, 10, 10)
+ FocusableBox(item5, 50, 10, 10, 10)
+ }
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Up) }
// Assert.
rule.runOnIdle {
@@ -362,7 +629,50 @@
}
// Act.
- val movedFocusSuccessfully = focusManager.moveFocus(Down)
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Down) }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(movedFocusSuccessfully).isTrue()
+ assertThat(focusedItem.value).isFalse()
+ assertThat(parent.value).isFalse()
+ assertThat(item1.value).isFalse()
+ assertThat(item2.value).isFalse()
+ assertThat(item3.value).isTrue()
+ assertThat(item4.value).isFalse()
+ assertThat(item5.value).isFalse()
+ }
+ }
+
+ /**
+ * _________________________________________________________
+ * | parent |
+ * | _______________ _______________ _______________ |
+ * | | focusedItem | | item1 | | item2 | |
+ * | |______________| |______________| |______________| |
+ * |________________________________________________________|
+ * _______________ _______________ _______________
+ * | item3 | | item4 | | item5 |
+ * |______________| |______________| |______________|
+ */
+ @Test
+ fun focusDown_fromBottommostItem_movesFocusOutsideDeactivatedParent() {
+ // Arrange.
+ val parent = mutableStateOf(false)
+ val (item1, item2, item3, item4, item5) = List(5) { mutableStateOf(false) }
+ rule.setContentForTest {
+ FocusableBox(parent, 0, 0, 70, 30, deactivated = true) {
+ FocusableBox(focusedItem, 10, 10, 10, 10, initialFocus)
+ FocusableBox(item1, 30, 10, 10, 10)
+ FocusableBox(item2, 50, 10, 10, 10)
+ }
+ FocusableBox(item3, 10, 40, 10, 10)
+ FocusableBox(item4, 30, 40, 10, 10)
+ FocusableBox(item5, 50, 40, 10, 10)
+ }
+
+ // Act.
+ val movedFocusSuccessfully = rule.runOnIdle { focusManager.moveFocus(Down) }
// Assert.
rule.runOnIdle {
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/InputModeTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/InputModeTest.kt
index 2c956f2..13798785 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/InputModeTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/InputModeTest.kt
@@ -44,8 +44,8 @@
@get:Rule
val rule = createComposeRule()
- lateinit var inputModeManager: InputModeManager
- lateinit var view: View
+ private lateinit var inputModeManager: InputModeManager
+ private lateinit var view: View
init {
InstrumentationRegistry.getInstrumentation().setInTouchMode(param.inputMode == Touch)
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/AndroidPointerInputTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/AndroidPointerInputTest.kt
index 284ce94..e474c89 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/AndroidPointerInputTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/AndroidPointerInputTest.kt
@@ -94,7 +94,6 @@
AndroidPointerInputTestActivity::class.java
)
- private lateinit var androidComposeView: AndroidComposeView
private lateinit var container: OpenComposeView
@Before
@@ -238,7 +237,7 @@
@Test
fun dispatchTouchEvent_notMeasuredLayoutsAreMeasuredFirst() {
val size = mutableStateOf(10)
- var latch = CountDownLatch(1)
+ val latch = CountDownLatch(1)
var consumedDownPosition: Offset? = null
rule.runOnUiThread {
container.setContent {
@@ -475,7 +474,7 @@
var didLongPress = false
var didTap = false
var inputLatch = CountDownLatch(1)
- var positionedLatch = CountDownLatch(1)
+ val positionedLatch = CountDownLatch(1)
rule.runOnUiThread {
container.setContent {
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt
index aff897cd..391bd08 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt
@@ -342,6 +342,7 @@
// executed whenever the touch mode changes.
private val touchModeChangeListener = ViewTreeObserver.OnTouchModeChangeListener { touchMode ->
_inputModeManager.inputMode = if (touchMode) Touch else Keyboard
+ _focusManager.fetchUpdatedFocusProperties()
}
private val textInputServiceAndroid = TextInputServiceAndroid(this)
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusManager.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusManager.kt
index c972610..b9c3df2 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusManager.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusManager.kt
@@ -16,17 +16,18 @@
package androidx.compose.ui.focus
-import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection.Companion.Next
-import androidx.compose.ui.focus.FocusDirection.Companion.Out
import androidx.compose.ui.focus.FocusDirection.Companion.Previous
import androidx.compose.ui.focus.FocusStateImpl.Active
import androidx.compose.ui.focus.FocusStateImpl.ActiveParent
import androidx.compose.ui.focus.FocusStateImpl.Captured
-import androidx.compose.ui.focus.FocusStateImpl.Disabled
+import androidx.compose.ui.focus.FocusStateImpl.Deactivated
+import androidx.compose.ui.focus.FocusStateImpl.DeactivatedParent
import androidx.compose.ui.focus.FocusStateImpl.Inactive
+import androidx.compose.ui.node.ModifiedFocusNode
import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.util.fastForEach
interface FocusManager {
/**
@@ -109,25 +110,21 @@
*/
override fun clearFocus(force: Boolean) {
// If this hierarchy had focus before clearing it, it indicates that the host view has
- // focus. So after clearing focus within the compose hierarchy, we should reset the root
- // focus modifier to "Active" to maintain consistency with the host view.
- val rootWasFocused = when (focusModifier.focusState) {
- Active, ActiveParent, Captured -> true
- Disabled, Inactive -> false
- }
-
- if (focusModifier.focusNode.clearFocus(force) && rootWasFocused) {
- focusModifier.focusState = Active
+ // focus. So after clearing focus within the compose hierarchy, we should restore focus to
+ // the root focus modifier to maintain consistency with the host view.
+ val rootInitialState = focusModifier.focusState
+ if (focusModifier.focusNode.clearFocus(force)) {
+ focusModifier.focusState = when (rootInitialState) {
+ Active, ActiveParent, Captured -> Active
+ Deactivated, DeactivatedParent -> Deactivated
+ Inactive -> Inactive
+ }
}
}
/**
* Moves focus in the specified direction.
*
- * Focus moving is still being implemented. Right now, focus will move only if the user
- * specified a custom focus traversal order for the item that is currently focused. (Using the
- * [Modifier.focusOrder()][focusOrder] API).
- *
* @return true if focus was moved successfully. false if the focused item is unchanged.
*/
override fun moveFocus(focusDirection: FocusDirection): Boolean {
@@ -145,30 +142,60 @@
}
val destination = focusModifier.focusNode.focusSearch(focusDirection, layoutDirection)
- if (destination == null || destination == source) {
+ if (destination == source) {
return false
}
- // We don't want moveFocus to set focus to the root, as this would essentially clear focus.
- if (destination.findParentFocusNode() == null) {
- return when (focusDirection) {
- // Skip the root and proceed to the next/previous item from the root's perspective.
- Next, Previous -> {
- destination.requestFocus(propagateFocus = false)
- moveFocus(focusDirection)
- }
- // Instead of moving out to the root, we return false.
- // When we return false the key event will not be consumed, but it will bubble
- // up to the owner. (In the case of Android, the back key will be sent to the
- // activity, where it can be handled appropriately).
- @OptIn(ExperimentalComposeUiApi::class)
- Out -> false
- else -> error("Move focus landed at the root through an unknown path.")
+ // TODO(b/144116848): This is a hack to make Next/Previous wrap around. This must be
+ // replaced by code that sends the move request back to the view system. The view system
+ // will then pass focus to other views, and ultimately return back to this compose view.
+ if (destination == null) {
+ // Check if we need to wrap around (no destination and a non-root item is focused)
+ if (focusModifier.focusState.hasFocus && !focusModifier.focusState.isFocused) {
+ // Next and Previous wraps around.
+ return when (focusDirection) {
+ Next, Previous -> {
+ // Clear Focus to send focus the root node.
+ // Wrap around by requesting focus for the root and then calling moveFocus.
+ clearFocus(force = false)
+
+ if (focusModifier.focusState.isFocused) {
+ moveFocus(focusDirection)
+ } else {
+ false
+ }
+ }
+ else -> false
+ }
}
+ return false
}
+ checkNotNull(destination.findParentFocusNode()) { "Move focus landed at the root." }
+
// If we found a potential next item, call requestFocus() to move focus to it.
destination.requestFocus(propagateFocus = false)
return true
}
+
+ /**
+ * Runs the focus properties block for all [focusProperties] modifiers to fetch updated
+ * [FocusProperties].
+ *
+ * The [focusProperties] block is run automatically whenever the properties change, and you
+ * rarely need to invoke this function manually. However, if you have a situation where you want
+ * to change a property, and need to see the change in the current snapshot, use this API.
+ */
+ fun fetchUpdatedFocusProperties() {
+ focusModifier.focusNode.updateProperties()
+ }
+}
+
+private fun ModifiedFocusNode.updateProperties() {
+ // Update the focus node with the current focus properties.
+ with(modifier.modifierLocalReadScope) {
+ setUpdatedProperties(ModifierLocalFocusProperties.current)
+ }
+ // Update the focus properties for all children.
+ focusableChildren(excludeDeactivated = false).fastForEach { it.updateProperties() }
}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusModifier.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusModifier.kt
index 81121a9..770a4eb 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusModifier.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusModifier.kt
@@ -20,6 +20,9 @@
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.focus.FocusStateImpl.Inactive
+import androidx.compose.ui.modifier.ModifierLocalConsumer
+import androidx.compose.ui.modifier.ModifierLocalProvider
+import androidx.compose.ui.modifier.ModifierLocalReadScope
import androidx.compose.ui.node.ModifiedFocusNode
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.InspectorValueInfo
@@ -36,16 +39,35 @@
// Set this value in AndroidComposeView, and other places where we create a focus modifier
// using this internal constructor.
inspectorInfo: InspectorInfo.() -> Unit = NoInspectorInfo
-) : Modifier.Element, InspectorValueInfo(inspectorInfo) {
+) : ModifierLocalConsumer,
+ ModifierLocalProvider<FocusProperties>,
+ InspectorValueInfo(inspectorInfo) {
// TODO(b/188684110): Move focusState and focusedChild to ModifiedFocusNode and make this
// modifier stateless.
-
var focusState: FocusStateImpl = initialFocus
var focusedChild: ModifiedFocusNode? = null
lateinit var focusNode: ModifiedFocusNode
+
+ lateinit var modifierLocalReadScope: ModifierLocalReadScope
+
+ // Reading the FocusProperties ModifierLocal.
+ override fun onModifierLocalsUpdated(scope: ModifierLocalReadScope) {
+ modifierLocalReadScope = scope
+
+ // Update the focus node with the current focus properties.
+ with(scope) {
+ focusNode.setUpdatedProperties(ModifierLocalFocusProperties.current)
+ }
+ }
+
+ override val key = ModifierLocalFocusProperties
+
+ // Writing the FocusProperties ModifierLocal so that any child focus modifiers don't read
+ // properties that were meant for this focus modifier.
+ override val value = defaultFocusProperties
}
/**
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusNodeUtils.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusNodeUtils.kt
index 08168fb..a2b8995 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusNodeUtils.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusNodeUtils.kt
@@ -26,11 +26,15 @@
// TODO(b/152051577): Measure the performance of findFocusableChildren().
// Consider caching the children.
-internal fun LayoutNode.findFocusableChildren(focusableChildren: MutableList<ModifiedFocusNode>) {
+internal fun LayoutNode.findFocusableChildren(
+ focusableChildren: MutableList<ModifiedFocusNode>,
+ excludeDeactivated: Boolean
+) {
// TODO(b/152529395): Write a test for LayoutNode.focusableChildren(). We were calling the wrong
// function on [LayoutNodeWrapper] but no test caught this.
- outerLayoutNodeWrapper.findNextFocusWrapper()?.let { focusableChildren.add(it) }
- ?: children.fastForEach { it.findFocusableChildren(focusableChildren) }
+ outerLayoutNodeWrapper.findNextFocusWrapper(excludeDeactivated)
+ ?.let { focusableChildren.add(it) }
+ ?: children.fastForEach { it.findFocusableChildren(focusableChildren, excludeDeactivated) }
}
// TODO(b/144126759): For now we always return the first focusable child. We might want to
@@ -42,11 +46,12 @@
* @param queue a mutable list used as a queue for breadth-first search.
*/
internal fun LayoutNode.searchChildrenForFocusNode(
- queue: MutableVector<LayoutNode> = mutableVectorOf()
+ queue: MutableVector<LayoutNode> = mutableVectorOf(),
+ excludeDeactivated: Boolean
): ModifiedFocusNode? {
// Check if any child has a focus Wrapper.
_children.forEach { layoutNode ->
- val focusNode = layoutNode.outerLayoutNodeWrapper.findNextFocusWrapper()
+ val focusNode = layoutNode.outerLayoutNodeWrapper.findNextFocusWrapper(excludeDeactivated)
if (focusNode != null) {
return focusNode
} else {
@@ -56,7 +61,7 @@
// Perform a breadth-first search through the children.
while (queue.isNotEmpty()) {
- val focusNode = queue.removeAt(0).searchChildrenForFocusNode(queue)
+ val focusNode = queue.removeAt(0).searchChildrenForFocusNode(queue, excludeDeactivated)
if (focusNode != null) {
return focusNode
}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusProperties.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusProperties.kt
new file mode 100644
index 0000000..a0f3166
--- /dev/null
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusProperties.kt
@@ -0,0 +1,105 @@
+/*
+ * 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.ui.focus
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.modifier.ModifierLocalConsumer
+import androidx.compose.ui.modifier.ModifierLocalProvider
+import androidx.compose.ui.modifier.ModifierLocalReadScope
+import androidx.compose.ui.modifier.modifierLocalOf
+import androidx.compose.ui.node.ModifiedFocusNode
+import androidx.compose.ui.platform.debugInspectorInfo
+
+/**
+ * A Modifier local that stores [FocusProperties] for a sub-hierarchy.
+ *
+ * @see [focusProperties]
+ */
+internal val ModifierLocalFocusProperties = modifierLocalOf { defaultFocusProperties }
+
+internal val defaultFocusProperties: FocusProperties = FocusPropertiesImpl(canFocus = true)
+
+/**
+ * Properties that are applied to [focusTarget]s that can read the [ModifierLocalFocusProperties]
+ * Modifier Local.
+ *
+ * @see [focusProperties]
+ */
+interface FocusProperties {
+ /**
+ * When set to false, indicates that the [focusTarget] that this is applied to can no longer
+ * take focus. If the [focusTarget] is currently focused, setting this property to false will
+ * end up clearing focus.
+ */
+ var canFocus: Boolean
+}
+
+/**
+ * This modifier allows you to specify properties that are accessible to [focusTarget]s further
+ * down the modifier chain or on child layout nodes.
+ *
+ * @sample androidx.compose.ui.samples.FocusPropertiesSample
+ */
+fun Modifier.focusProperties(scope: FocusProperties.() -> Unit): Modifier = composed(
+ debugInspectorInfo {
+ name = "focusProperties"
+ properties["scope"] = scope
+ }
+) {
+ val rememberedScope by rememberUpdatedState(scope)
+ remember { FocusPropertiesModifier(focusPropertiesScope = rememberedScope) }
+}
+
+internal class FocusPropertiesModifier(
+ val focusPropertiesScope: FocusProperties.() -> Unit
+) : ModifierLocalConsumer, ModifierLocalProvider<FocusProperties> {
+
+ private var parentFocusProperties: FocusProperties? = null
+
+ override fun onModifierLocalsUpdated(scope: ModifierLocalReadScope) {
+ parentFocusProperties = scope.run { ModifierLocalFocusProperties.current }
+ }
+
+ override val key = ModifierLocalFocusProperties
+
+ override val value: FocusProperties
+ get() = defaultFocusProperties.copy {
+ // Populate with the specified focus properties.
+ apply(focusPropertiesScope)
+
+ // current value for deactivated can be overridden by a parent's value.
+ parentFocusProperties?.let {
+ if (it != defaultFocusProperties) {
+ canFocus = it.canFocus
+ }
+ }
+ }
+}
+
+internal fun ModifiedFocusNode.setUpdatedProperties(properties: FocusProperties) {
+ if (properties.canFocus) activateNode() else deactivateNode()
+}
+
+private class FocusPropertiesImpl(override var canFocus: Boolean) : FocusProperties
+
+private fun FocusProperties.copy(scope: FocusProperties.() -> Unit): FocusProperties {
+ return FocusPropertiesImpl(canFocus = canFocus).apply(scope)
+}
\ No newline at end of file
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusState.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusState.kt
index 83d314f..ba5cb16 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusState.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusState.kt
@@ -68,7 +68,10 @@
Captured,
// The focusable component is not currently focusable. (eg. A disabled button).
- Disabled,
+ Deactivated,
+
+ // One of the descendants of this deactivated component is Active.
+ DeactivatedParent,
// The focusable component does not receive any key events. (ie it is not active, nor are any
// of its descendants active).
@@ -77,18 +80,30 @@
override val isFocused: Boolean
get() = when (this) {
Captured, Active -> true
- ActiveParent, Disabled, Inactive -> false
+ ActiveParent, Deactivated, DeactivatedParent, Inactive -> false
}
override val hasFocus: Boolean
get() = when (this) {
- Active, ActiveParent, Captured -> true
- Disabled, Inactive -> false
+ Active, ActiveParent, Captured, DeactivatedParent -> true
+ Deactivated, Inactive -> false
}
override val isCaptured: Boolean
get() = when (this) {
Captured -> true
- Active, ActiveParent, Inactive, Disabled -> false
+ Active, ActiveParent, Deactivated, DeactivatedParent, Inactive -> false
+ }
+
+ /**
+ * Whether the focusable component is deactivated.
+ *
+ * TODO(ralu): Consider making this public when we can add methods to interfaces without
+ * breaking compatibility.
+ */
+ val isDeactivated: Boolean
+ get() = when (this) {
+ Active, ActiveParent, Captured, Inactive -> false
+ Deactivated, DeactivatedParent -> true
}
}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTransactions.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTransactions.kt
index e4b2feb..6399c69 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTransactions.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTransactions.kt
@@ -19,7 +19,8 @@
import androidx.compose.ui.focus.FocusStateImpl.Active
import androidx.compose.ui.focus.FocusStateImpl.ActiveParent
import androidx.compose.ui.focus.FocusStateImpl.Captured
-import androidx.compose.ui.focus.FocusStateImpl.Disabled
+import androidx.compose.ui.focus.FocusStateImpl.Deactivated
+import androidx.compose.ui.focus.FocusStateImpl.DeactivatedParent
import androidx.compose.ui.focus.FocusStateImpl.Inactive
import androidx.compose.ui.node.ModifiedFocusNode
@@ -34,7 +35,7 @@
*/
internal fun ModifiedFocusNode.requestFocus(propagateFocus: Boolean = true) {
when (focusState) {
- Active, Captured, Disabled -> {
+ Active, Captured, Deactivated, DeactivatedParent -> {
// There is no change in focus state, but we send a focus event to notify the user
// that the focus request is completed.
sendOnFocusEvent(focusState)
@@ -69,6 +70,37 @@
}
/**
+ * Activate this node so that it can be focused.
+ *
+ * Deactivated nodes are excluded from focus search, and reject requests to gain focus.
+ * Calling this function activates a deactivated node.
+ */
+internal fun ModifiedFocusNode.activateNode() {
+ when (focusState) {
+ ActiveParent, Active, Captured, Inactive -> { }
+ Deactivated -> focusState = Inactive
+ DeactivatedParent -> focusState = ActiveParent
+ }
+}
+
+/**
+ * Deactivate this node so that it can't be focused.
+ *
+ * Deactivated nodes are excluded from focus search.
+ */
+internal fun ModifiedFocusNode.deactivateNode() {
+ when (focusState) {
+ ActiveParent -> focusState = DeactivatedParent
+ Active, Captured -> {
+ layoutNode.owner?.focusManager?.clearFocus(force = true)
+ focusState = Deactivated
+ }
+ Inactive -> focusState = Deactivated
+ Deactivated, DeactivatedParent -> { }
+ }
+}
+
+/**
* Deny requests to clear focus.
*
* This is used when a component wants to hold onto focus (eg. A phone number field with an
@@ -82,7 +114,7 @@
true
}
Captured -> true
- else -> false
+ ActiveParent, Deactivated, DeactivatedParent, Inactive -> false
}
/**
@@ -98,7 +130,7 @@
true
}
Active -> true
- else -> false
+ ActiveParent, Deactivated, DeactivatedParent, Inactive -> false
}
/**
@@ -116,7 +148,7 @@
}
/**
* If the node is [ActiveParent], we need to clear focus from the [Active] descendant
- * first, before clearing focus of this node.
+ * first, before clearing focus from this node.
*/
ActiveParent -> {
val currentFocusedChild = focusedChild
@@ -129,6 +161,20 @@
}
}
/**
+ * If the node is [DeactivatedParent], we need to clear focus from the [Active] descendant
+ * first, before clearing focus from this node.
+ */
+ DeactivatedParent -> {
+ val currentFocusedChild = focusedChild
+ requireNotNull(currentFocusedChild)
+ currentFocusedChild.clearFocus(forcedClear).also { success ->
+ if (success) {
+ focusState = Deactivated
+ focusedChild = null
+ }
+ }
+ }
+ /**
* If the node is [Captured], deny requests to clear focus, except for a forced clear.
*/
Captured -> {
@@ -140,7 +186,7 @@
/**
* Nothing to do if the node is not focused.
*/
- Inactive, Disabled -> true
+ Inactive, Deactivated -> true
}
}
@@ -159,13 +205,21 @@
// TODO (b/144126759): Design a system to decide which child gets focus.
// for now we grant focus to the first child.
- val focusedCandidate = focusableChildren().firstOrNull()
+ val focusedCandidate = focusableChildren(excludeDeactivated = false).firstOrNull()
if (focusedCandidate == null || !propagateFocus) {
// No Focused Children, or we don't want to propagate focus to children.
- focusState = Active
+ focusState = when (focusState) {
+ Inactive, Active, ActiveParent -> Active
+ Captured -> Captured
+ Deactivated, DeactivatedParent -> error("Granting focus to a deactivated node.")
+ }
} else {
- focusState = ActiveParent
+ focusState = when (focusState) {
+ Inactive, Active, ActiveParent -> ActiveParent
+ Captured -> { Captured; return }
+ Deactivated, DeactivatedParent -> DeactivatedParent
+ }
focusedChild = focusedCandidate
focusedCandidate.grantFocus(propagateFocus)
}
@@ -185,7 +239,7 @@
): Boolean {
// Only this node's children can ask for focus.
- if (!focusableChildren().contains(childNode)) {
+ if (!focusableChildren(excludeDeactivated = false).contains(childNode)) {
error("Non child node cannot request focus.")
}
@@ -215,6 +269,23 @@
false
}
}
+ DeactivatedParent -> {
+ val previouslyFocusedNode = focusedChild
+ if (previouslyFocusedNode == null) {
+ // we use DeactivatedParent and focusedchild == null to indicate an intermediate
+ // state where a parent requested focus so that it can transfer it to a child.
+ focusedChild = childNode
+ childNode.grantFocus(propagateFocus)
+ true
+ } else if (previouslyFocusedNode.clearFocus()) {
+ focusedChild = childNode
+ childNode.grantFocus(propagateFocus)
+ true
+ } else {
+ // Currently focused component does not want to give up focus.
+ false
+ }
+ }
/**
* If this node is not [Active], we must gain focus first before granting it
* to the requesting child.
@@ -241,9 +312,15 @@
*/
Captured -> false
/**
- * Children of a [Disabled] parent should also be [Disabled].
+ * If this node is [Deactivated], send a requestFocusForChild to its parent to attempt to
+ * change its state to [DeactivatedParent] before granting focus to the child.
*/
- Disabled -> error("non root FocusNode needs a focusable parent")
+ Deactivated -> {
+ activateNode()
+ val childGrantedFocus = requestFocusForChild(childNode, propagateFocus)
+ deactivateNode()
+ childGrantedFocus
+ }
}
}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTraversal.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTraversal.kt
index 9098b8e..7d6cea5 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTraversal.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTraversal.kt
@@ -28,7 +28,8 @@
import androidx.compose.ui.focus.FocusStateImpl.Active
import androidx.compose.ui.focus.FocusStateImpl.ActiveParent
import androidx.compose.ui.focus.FocusStateImpl.Captured
-import androidx.compose.ui.focus.FocusStateImpl.Disabled
+import androidx.compose.ui.focus.FocusStateImpl.Deactivated
+import androidx.compose.ui.focus.FocusStateImpl.DeactivatedParent
import androidx.compose.ui.focus.FocusStateImpl.Inactive
import androidx.compose.ui.node.ModifiedFocusNode
import androidx.compose.ui.unit.LayoutDirection
@@ -148,7 +149,11 @@
findActiveFocusNode()?.twoDimensionalFocusSearch(direction)
}
@OptIn(ExperimentalComposeUiApi::class)
- Out -> findActiveFocusNode()?.findParentFocusNode()
+ Out -> {
+ findActiveFocusNode()?.findActiveParent().let {
+ if (it == this) null else it
+ }
+ }
else -> error(invalidFocusDirection)
}
}
@@ -156,7 +161,14 @@
internal fun ModifiedFocusNode.findActiveFocusNode(): ModifiedFocusNode? {
return when (focusState) {
Active, Captured -> this
- ActiveParent -> focusedChild?.findActiveFocusNode()
- Inactive, Disabled -> null
+ ActiveParent, DeactivatedParent -> focusedChild?.findActiveFocusNode()
+ Inactive, Deactivated -> null
}
}
+
+internal fun ModifiedFocusNode.findActiveParent(): ModifiedFocusNode? = findParentFocusNode()?.let {
+ when (focusState) {
+ Active, Captured, Deactivated, DeactivatedParent, Inactive -> it.findActiveParent()
+ ActiveParent -> this
+ }
+ }
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/OneDimensionalFocusSearch.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/OneDimensionalFocusSearch.kt
index d26db77..e495a97 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/OneDimensionalFocusSearch.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/OneDimensionalFocusSearch.kt
@@ -21,70 +21,138 @@
import androidx.compose.ui.focus.FocusStateImpl.Captured
import androidx.compose.ui.focus.FocusDirection.Companion.Next
import androidx.compose.ui.focus.FocusDirection.Companion.Previous
-import androidx.compose.ui.focus.FocusStateImpl.Disabled
+import androidx.compose.ui.focus.FocusStateImpl.Deactivated
+import androidx.compose.ui.focus.FocusStateImpl.DeactivatedParent
import androidx.compose.ui.focus.FocusStateImpl.Inactive
import androidx.compose.ui.node.ModifiedFocusNode
import androidx.compose.ui.util.fastForEach
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.contract
private const val InvalidFocusDirection = "This function should only be used for 1-D focus search"
private const val NoActiveChild = "ActiveParent must have a focusedChild"
-private const val NotYetAvailable = "Implement this after adding API to disable a node"
internal fun ModifiedFocusNode.oneDimensionalFocusSearch(
direction: FocusDirection
-): ModifiedFocusNode = when (direction) {
- Next -> forwardFocusSearch() ?: this
+): ModifiedFocusNode? = when (direction) {
+ Next -> forwardFocusSearch()
Previous -> backwardFocusSearch()
else -> error(InvalidFocusDirection)
}
-private fun ModifiedFocusNode.forwardFocusSearch(): ModifiedFocusNode? = when (focusState) {
- ActiveParent -> {
- val focusedChild = focusedChild ?: error(NoActiveChild)
- focusedChild.forwardFocusSearch()?.let { return it }
+private fun ModifiedFocusNode.forwardFocusSearch(): ModifiedFocusNode? {
+ when (focusState) {
+ ActiveParent, DeactivatedParent -> {
+ val focusedChild = focusedChild ?: error(NoActiveChild)
+ focusedChild.forwardFocusSearch()?.let { return it }
- var currentItemIsAfterFocusedItem = false
- // TODO(b/192681045): Instead of fetching the children and then iterating on them, add a
- // forEachFocusableChild function that does not allocate a list.
- focusableChildren().fastForEach {
- if (currentItemIsAfterFocusedItem) {
- return it
+ // TODO(b/192681045): Instead of fetching the children and then iterating on them, add a
+ // forEachFocusableChild() function that does not allocate a list.
+ focusableChildren(excludeDeactivated = false).forEachItemAfter(focusedChild) { child ->
+ child.forwardFocusSearch()?.let { return it }
}
- if (it == focusedChild) {
- currentItemIsAfterFocusedItem = true
- }
+ return null
}
- null // Couldn't find a focusable child after the current focused child.
+ Active, Captured, Deactivated -> {
+ focusableChildren(excludeDeactivated = false).fastForEach { focusableChild ->
+ focusableChild.forwardFocusSearch()?.let { return it }
+ }
+ return null
+ }
+ Inactive -> return this
}
- Active, Captured -> focusableChildren().firstOrNull()
- Inactive -> this
- Disabled -> TODO(NotYetAvailable)
}
-private fun ModifiedFocusNode.backwardFocusSearch(): ModifiedFocusNode = when (focusState) {
- ActiveParent -> {
- val focusedChild = focusedChild ?: error(NoActiveChild)
- when (focusedChild.focusState) {
- ActiveParent -> focusedChild.backwardFocusSearch()
- Active, Captured -> {
- var previousFocusedItem: ModifiedFocusNode? = null
- // TODO(b/192681045): Instead of fetching the children and then iterating on them, add a
- // forEachFocusableChild() function that does not allocate a list.
- focusableChildren().fastForEach {
- if (it == focusedChild) {
- return previousFocusedItem?.backwardFocusSearch() ?: this
+private fun ModifiedFocusNode.backwardFocusSearch(): ModifiedFocusNode? {
+ when (focusState) {
+ ActiveParent -> {
+ val focusedChild = focusedChild ?: error(NoActiveChild)
+ when (focusedChild.focusState) {
+ ActiveParent -> return focusedChild.backwardFocusSearch() ?: focusedChild
+ DeactivatedParent -> {
+ focusedChild.backwardFocusSearch()?.let { return it }
+ focusableChildren(excludeDeactivated = false).forEachItemBefore(focusedChild) {
+ it.backwardFocusSearch()?.let { return it }
}
- previousFocusedItem = it
+ // backward search returns the parent unless it is the root
+ // (We don't want to move focus to the root).
+ return if (isRoot()) null else this
}
- error(NoActiveChild)
+ Active, Captured -> {
+ focusableChildren(excludeDeactivated = false).forEachItemBefore(focusedChild) {
+ it.backwardFocusSearch()?.let { return it }
+ }
+ // backward search returns the parent unless it is the root
+ // (We don't want to move focus to the root).
+ return if (isRoot()) null else this
+ }
+ Deactivated, Inactive -> error(NoActiveChild)
}
- else -> error(NoActiveChild)
+ }
+ DeactivatedParent -> {
+ val focusedChild = focusedChild ?: error(NoActiveChild)
+ when (focusedChild.focusState) {
+ ActiveParent -> return focusedChild.backwardFocusSearch() ?: focusedChild
+ DeactivatedParent -> {
+ focusedChild.backwardFocusSearch()?.let { return it }
+ focusableChildren(excludeDeactivated = false).forEachItemBefore(focusedChild) {
+ it.backwardFocusSearch()?.let { return it }
+ }
+ return null
+ }
+ Active, Captured -> {
+ focusableChildren(excludeDeactivated = false).forEachItemBefore(focusedChild) {
+ it.backwardFocusSearch()?.let { return it }
+ }
+ return null
+ }
+ Deactivated, Inactive -> error(NoActiveChild)
+ }
+ }
+ // BackwardFocusSearch Searches among siblings of the ActiveParent for a child that is
+ // focused. So this function should never be called when this node is focused. If we
+ // reached here, it indicates that this is an initial focus state, so we run the same logic
+ // as if this node was Inactive. If we can't find an item for initial focus, we return this
+ // root node as the result.
+ Active, Captured, Inactive ->
+ return focusableChildren(excludeDeactivated = true)
+ .lastOrNull()?.backwardFocusSearch() ?: this
+ // BackwardFocusSearch Searches among siblings of the ActiveParent for a child that is
+ // focused. The search excludes deactivated items, so this function should never be called
+ // on a node with a Deactivated state. If we reached here, it indicates that this is an
+ // initial focus state, so we run the same logic as if this node was Inactive. If we can't
+ // find an item for initial focus, we return null.
+ Deactivated ->
+ return focusableChildren(excludeDeactivated = true).lastOrNull()?.backwardFocusSearch()
+ }
+}
+
+private fun ModifiedFocusNode.isRoot() = findParentFocusNode() == null
+
+@OptIn(ExperimentalContracts::class)
+private inline fun <T> List<T>.forEachItemAfter(item: T, action: (T) -> Unit) {
+ contract { callsInPlace(action) }
+ var itemFound = false
+ for (index in indices) {
+ if (itemFound) {
+ action(get(index))
+ }
+ if (get(index) == item) {
+ itemFound = true
}
}
- // The BackwardFocusSearch Searches among siblings of the ActiveParent for a child that is
- // focused. So this function should never be called when this node is focused. If we reached
- // here, it indicates an initial focus state, so we run the same logic as if this node was
- // Inactive.
- Active, Captured, Inactive -> focusableChildren().lastOrNull()?.backwardFocusSearch() ?: this
- Disabled -> TODO(NotYetAvailable)
+}
+
+@OptIn(ExperimentalContracts::class)
+private inline fun <T> List<T>.forEachItemBefore(item: T, action: (T) -> Unit) {
+ contract { callsInPlace(action) }
+ var itemFound = false
+ for (index in indices.reversed()) {
+ if (itemFound) {
+ action(get(index))
+ }
+ if (get(index) == item) {
+ itemFound = true
+ }
+ }
}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusSearch.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusSearch.kt
index cb9c350..d95db18 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusSearch.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusSearch.kt
@@ -23,7 +23,8 @@
import androidx.compose.ui.focus.FocusStateImpl.Active
import androidx.compose.ui.focus.FocusStateImpl.ActiveParent
import androidx.compose.ui.focus.FocusStateImpl.Captured
-import androidx.compose.ui.focus.FocusStateImpl.Disabled
+import androidx.compose.ui.focus.FocusStateImpl.Deactivated
+import androidx.compose.ui.focus.FocusStateImpl.DeactivatedParent
import androidx.compose.ui.focus.FocusStateImpl.Inactive
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.node.ModifiedFocusNode
@@ -46,8 +47,8 @@
): ModifiedFocusNode? {
return when (focusState) {
Inactive -> this
- Disabled -> null
- ActiveParent -> {
+ Deactivated -> null
+ ActiveParent, DeactivatedParent -> {
// If the focusedChild is an intermediate parent, we continue searching among it's
// children, and return a focus node if we find one.
val focusedChild = focusedChild ?: error(NoActiveChild)
@@ -58,13 +59,13 @@
// Use the focus rect of the active node as the starting point and pick one of our
// children as the next focused item.
val activeRect = findActiveFocusNode()?.focusRect() ?: error(NoActiveChild)
- focusableChildren().findBestCandidate(activeRect, direction)
+ focusableChildren(excludeDeactivated = true).findBestCandidate(activeRect, direction)
}
Active, Captured -> {
// The 2-D focus search starts form the root. If we reached here, it means that there
// was no intermediate node that was ActiveParent. This is an initial focus scenario.
// We need to search among this node's children to find the best focus candidate.
- val focusableChildren = focusableChildren()
+ val focusableChildren = focusableChildren(excludeDeactivated = true)
// If there are aren't multiple children to choose from, return the first child.
if (focusableChildren.size <= 1) {
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/DelegatingLayoutNodeWrapper.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/DelegatingLayoutNodeWrapper.kt
index a0af689..a78d9cc 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/DelegatingLayoutNodeWrapper.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/DelegatingLayoutNodeWrapper.kt
@@ -131,16 +131,18 @@
override fun findPreviousFocusWrapper() = wrappedBy?.findPreviousFocusWrapper()
- override fun findNextFocusWrapper() = wrapped.findNextFocusWrapper()
+ override fun findNextFocusWrapper(excludeDeactivated: Boolean): ModifiedFocusNode? {
+ return wrapped.findNextFocusWrapper(excludeDeactivated)
+ }
override fun findLastFocusWrapper(): ModifiedFocusNode? {
var lastFocusWrapper: ModifiedFocusNode? = null
// Find last focus wrapper for the current layout node.
- var next: ModifiedFocusNode? = findNextFocusWrapper()
+ var next: ModifiedFocusNode? = findNextFocusWrapper(excludeDeactivated = false)
while (next != null) {
lastFocusWrapper = next
- next = next.wrapped.findNextFocusWrapper()
+ next = next.wrapped.findNextFocusWrapper(excludeDeactivated = false)
}
return lastFocusWrapper
}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/InnerPlaceable.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/InnerPlaceable.kt
index 63746e2..6c3b85b 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/InnerPlaceable.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/InnerPlaceable.kt
@@ -20,7 +20,8 @@
import androidx.compose.ui.focus.FocusStateImpl.Active
import androidx.compose.ui.focus.FocusStateImpl.ActiveParent
import androidx.compose.ui.focus.FocusStateImpl.Captured
-import androidx.compose.ui.focus.FocusStateImpl.Disabled
+import androidx.compose.ui.focus.FocusStateImpl.Deactivated
+import androidx.compose.ui.focus.FocusStateImpl.DeactivatedParent
import androidx.compose.ui.focus.FocusStateImpl.Inactive
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Canvas
@@ -58,7 +59,7 @@
override fun findPreviousFocusWrapper() = wrappedBy?.findPreviousFocusWrapper()
- override fun findNextFocusWrapper(): ModifiedFocusNode? = null
+ override fun findNextFocusWrapper(excludeDeactivated: Boolean): ModifiedFocusNode? = null
override fun findLastFocusWrapper(): ModifiedFocusNode? = findPreviousFocusWrapper()
@@ -70,16 +71,19 @@
var allChildrenDisabled: Boolean? = null
// TODO(b/192681045): Create a utility like fun LayoutNodeWrapper.forEachFocusableChild{...}
// that does not allocate, but just iterates over all the focusable children.
- focusableChildren().fastForEach {
+ focusableChildren(excludeDeactivated = false).fastForEach {
when (it.focusState) {
- Active, ActiveParent, Captured -> { focusedChild = it; allChildrenDisabled = false }
- Disabled -> if (allChildrenDisabled == null) { allChildrenDisabled = true }
+ Active, ActiveParent, Captured, DeactivatedParent -> {
+ focusedChild = it
+ allChildrenDisabled = false
+ }
+ Deactivated -> if (allChildrenDisabled == null) { allChildrenDisabled = true }
Inactive -> allChildrenDisabled = false
}
}
super.propagateFocusEvent(
- focusedChild?.focusState ?: if (allChildrenDisabled == true) Disabled else Inactive
+ focusedChild?.focusState ?: if (allChildrenDisabled == true) Deactivated else Inactive
)
}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeWrapper.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeWrapper.kt
index 607c31b..4da75b6 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeWrapper.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeWrapper.kt
@@ -702,7 +702,7 @@
* Note: This method only goes to the modifiers that follow the one wrapped by
* this [LayoutNodeWrapper], it doesn't to the children [LayoutNode]s.
*/
- abstract fun findNextFocusWrapper(): ModifiedFocusNode?
+ abstract fun findNextFocusWrapper(excludeDeactivated: Boolean): ModifiedFocusNode?
/**
* Returns the last [focus node][ModifiedFocusNode] found following this [LayoutNodeWrapper].
@@ -878,10 +878,10 @@
// TODO(b/152051577): Measure the performance of focusableChildren.
// Consider caching the children.
- fun focusableChildren(): List<ModifiedFocusNode> {
+ fun focusableChildren(excludeDeactivated: Boolean): List<ModifiedFocusNode> {
// Check the modifier chain that this focus node is part of. If it has a focus modifier,
// that means you have found the only focusable child for this node.
- val focusableChild = wrapped?.findNextFocusWrapper()
+ val focusableChild = wrapped?.findNextFocusWrapper(excludeDeactivated)
// findChildFocusNodeInWrapperChain()
if (focusableChild != null) {
return listOf(focusableChild)
@@ -889,7 +889,9 @@
// Go through all your children and find the first focusable node from each child.
val focusableChildren = mutableListOf<ModifiedFocusNode>()
- layoutNode.children.fastForEach { it.findFocusableChildren(focusableChildren) }
+ layoutNode.children.fastForEach {
+ it.findFocusableChildren(focusableChildren, excludeDeactivated)
+ }
return focusableChildren
}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/ModifiedFocusEventNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/ModifiedFocusEventNode.kt
index f387e3b..7fab9d3 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/ModifiedFocusEventNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/ModifiedFocusEventNode.kt
@@ -37,7 +37,8 @@
// For instance, if the observer is moved to the end of the list, and there is no focus
// modifier following this observer, it's focus state will be invalid. To solve this, we
// always reset the focus state when a focus observer is re-used.
- val focusNode = wrapped.findNextFocusWrapper() ?: layoutNode.searchChildrenForFocusNode()
+ val focusNode = wrapped.findNextFocusWrapper(excludeDeactivated = false)
+ ?: layoutNode.searchChildrenForFocusNode(excludeDeactivated = false)
modifier.onFocusEvent(focusNode?.modifier?.focusState ?: Inactive)
}
}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/ModifiedFocusNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/ModifiedFocusNode.kt
index 0c602b2..77e9c95 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/ModifiedFocusNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/ModifiedFocusNode.kt
@@ -16,6 +16,7 @@
package androidx.compose.ui.node
+import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.focus.FocusModifier
import androidx.compose.ui.focus.FocusOrder
import androidx.compose.ui.focus.FocusState
@@ -23,7 +24,8 @@
import androidx.compose.ui.focus.FocusStateImpl.Active
import androidx.compose.ui.focus.FocusStateImpl.ActiveParent
import androidx.compose.ui.focus.FocusStateImpl.Captured
-import androidx.compose.ui.focus.FocusStateImpl.Disabled
+import androidx.compose.ui.focus.FocusStateImpl.Deactivated
+import androidx.compose.ui.focus.FocusStateImpl.DeactivatedParent
import androidx.compose.ui.focus.FocusStateImpl.Inactive
import androidx.compose.ui.focus.searchChildrenForFocusNode
import androidx.compose.ui.geometry.Rect
@@ -56,7 +58,9 @@
fun focusRect(): Rect = findRoot().localBoundingBoxOf(this, clipBounds = false)
fun sendOnFocusEvent(focusState: FocusState) {
- wrappedBy?.propagateFocusEvent(focusState)
+ if (isAttached) {
+ wrappedBy?.propagateFocusEvent(focusState)
+ }
}
override fun onModifierChanged() {
@@ -64,6 +68,7 @@
sendOnFocusEvent(focusState)
}
+ // TODO(b/202621526) Handle cases where a focus modifier is attached to a node that is focused.
override fun attach() {
super.attach()
sendOnFocusEvent(focusState)
@@ -76,29 +81,44 @@
layoutNode.owner?.focusManager?.clearFocus(force = true)
}
// Propagate the state of the next focus node to any focus observers in the hierarchy.
- ActiveParent -> {
- // Find the next focus node.
- val nextFocusNode = wrapped.findNextFocusWrapper()
- ?: layoutNode.searchChildrenForFocusNode()
- if (nextFocusNode != null) {
- findParentFocusNode()?.modifier?.focusedChild = nextFocusNode
- sendOnFocusEvent(nextFocusNode.focusState)
- } else {
- sendOnFocusEvent(Inactive)
+ ActiveParent, DeactivatedParent -> {
+ val nextFocusNode = wrapped.findNextFocusWrapper(excludeDeactivated = false)
+ ?: layoutNode.searchChildrenForFocusNode(excludeDeactivated = false)
+ val parentFocusNode = findParentFocusNode()
+ if (parentFocusNode != null) {
+ parentFocusNode.modifier.focusedChild = nextFocusNode
+ if (nextFocusNode != null) {
+ sendOnFocusEvent(nextFocusNode.focusState)
+ } else {
+ parentFocusNode.focusState = when (parentFocusNode.focusState) {
+ ActiveParent -> Inactive
+ DeactivatedParent -> Deactivated
+ else -> parentFocusNode.focusState
+ }
+ }
}
}
- // TODO(b/155212782): Implement this after adding support for disabling focus modifiers.
- Disabled -> {}
+ Deactivated -> {
+ val nextFocusNode = wrapped.findNextFocusWrapper(excludeDeactivated = false)
+ ?: layoutNode.searchChildrenForFocusNode(excludeDeactivated = false)
+ sendOnFocusEvent(nextFocusNode?.focusState ?: Inactive)
+ }
// Do nothing, as the nextFocusNode is also Inactive.
Inactive -> {}
}
-
super.detach()
}
override fun findPreviousFocusWrapper() = this
- override fun findNextFocusWrapper() = this
+ @OptIn(ExperimentalComposeUiApi::class)
+ override fun findNextFocusWrapper(excludeDeactivated: Boolean): ModifiedFocusNode? {
+ return if (modifier.focusState.isDeactivated && excludeDeactivated) {
+ super.findNextFocusWrapper(excludeDeactivated)
+ } else {
+ this
+ }
+ }
override fun propagateFocusEvent(focusState: FocusState) {
// Do nothing. Stop propagating the focus change (since we hit another focus node).
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/ModifiedFocusRequesterNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/ModifiedFocusRequesterNode.kt
index 35595f9..7b80f95 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/ModifiedFocusRequesterNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/ModifiedFocusRequesterNode.kt
@@ -35,7 +35,8 @@
// Searches for the focus node associated with this focus requester node.
internal fun findFocusNode(): ModifiedFocusNode? {
- return findNextFocusWrapper() ?: layoutNode.searchChildrenForFocusNode()
+ return findNextFocusWrapper(excludeDeactivated = false)
+ ?: layoutNode.searchChildrenForFocusNode(excludeDeactivated = false)
}
override fun onModifierChanged() {
diff --git a/compose/ui/ui/src/test/kotlin/androidx/compose/ui/focus/FocusManagerTest.kt b/compose/ui/ui/src/test/kotlin/androidx/compose/ui/focus/FocusManagerTest.kt
index e18c76c..0ee1040 100644
--- a/compose/ui/ui/src/test/kotlin/androidx/compose/ui/focus/FocusManagerTest.kt
+++ b/compose/ui/ui/src/test/kotlin/androidx/compose/ui/focus/FocusManagerTest.kt
@@ -19,7 +19,8 @@
import androidx.compose.ui.focus.FocusStateImpl.Active
import androidx.compose.ui.focus.FocusStateImpl.ActiveParent
import androidx.compose.ui.focus.FocusStateImpl.Captured
-import androidx.compose.ui.focus.FocusStateImpl.Disabled
+import androidx.compose.ui.focus.FocusStateImpl.Deactivated
+import androidx.compose.ui.focus.FocusStateImpl.DeactivatedParent
import androidx.compose.ui.focus.FocusStateImpl.Inactive
import androidx.compose.ui.node.InnerPlaceable
import androidx.compose.ui.node.LayoutNode
@@ -65,7 +66,7 @@
assertThat(focusModifier.focusState).isEqualTo(
when (initialFocusState) {
Inactive -> Active
- Active, ActiveParent, Captured, Disabled -> initialFocusState
+ Active, ActiveParent, Captured, Deactivated, DeactivatedParent -> initialFocusState
}
)
}
@@ -74,7 +75,7 @@
fun releaseFocus_changesStateToInactive() {
// Arrange.
focusModifier.focusState = initialFocusState as FocusStateImpl
- if (initialFocusState == ActiveParent) {
+ if (initialFocusState == ActiveParent || initialFocusState == DeactivatedParent) {
val childLayoutNode = LayoutNode()
val child = ModifiedFocusNode(InnerPlaceable(childLayoutNode), FocusModifier(Active))
focusModifier.focusNode.layoutNode._children.add(childLayoutNode)
@@ -88,7 +89,7 @@
assertThat(focusModifier.focusState).isEqualTo(
when (initialFocusState) {
Active, ActiveParent, Captured, Inactive -> Inactive
- Disabled -> initialFocusState
+ Deactivated, DeactivatedParent -> Deactivated
}
)
}
@@ -97,7 +98,7 @@
fun clearFocus_forced() {
// Arrange.
focusModifier.focusState = initialFocusState as FocusStateImpl
- if (initialFocusState == ActiveParent) {
+ if (initialFocusState == ActiveParent || initialFocusState == DeactivatedParent) {
val childLayoutNode = LayoutNode()
val child = ModifiedFocusNode(InnerPlaceable(childLayoutNode), FocusModifier(Active))
focusModifier.focusNode.layoutNode._children.add(childLayoutNode)
@@ -113,7 +114,8 @@
// If the initial state was focused, assert that after clearing the hierarchy,
// the root is set to Active.
Active, ActiveParent, Captured -> Active
- Disabled, Inactive -> initialFocusState
+ Deactivated, DeactivatedParent -> Deactivated
+ Inactive -> Inactive
}
)
}
@@ -122,7 +124,7 @@
fun clearFocus_notForced() {
// Arrange.
focusModifier.focusState = initialFocusState as FocusStateImpl
- if (initialFocusState == ActiveParent) {
+ if (initialFocusState == ActiveParent || initialFocusState == DeactivatedParent) {
val childLayoutNode = LayoutNode()
val child = ModifiedFocusNode(InnerPlaceable(childLayoutNode), FocusModifier(Active))
focusModifier.focusNode.layoutNode._children.add(childLayoutNode)
@@ -138,24 +140,28 @@
// If the initial state was focused, assert that after clearing the hierarchy,
// the root is set to Active.
Active, ActiveParent -> Active
- Captured, Disabled, Inactive -> initialFocusState
+ Deactivated, DeactivatedParent -> Deactivated
+ Captured -> Captured
+ Inactive -> Inactive
}
)
}
@Test
fun clearFocus_childIsCaptured() {
- // Arrange.
- focusModifier.focusState = ActiveParent
- val childLayoutNode = LayoutNode()
- val child = ModifiedFocusNode(InnerPlaceable(childLayoutNode), FocusModifier(Captured))
- focusModifier.focusNode.layoutNode._children.add(childLayoutNode)
- focusModifier.focusedChild = child
+ if (initialFocusState == ActiveParent || initialFocusState == DeactivatedParent) {
+ // Arrange.
+ focusModifier.focusState = initialFocusState as FocusStateImpl
+ val childLayoutNode = LayoutNode()
+ val child = ModifiedFocusNode(InnerPlaceable(childLayoutNode), FocusModifier(Captured))
+ focusModifier.focusNode.layoutNode._children.add(childLayoutNode)
+ focusModifier.focusedChild = child
- // Act.
- focusManager.clearFocus()
+ // Act.
+ focusManager.clearFocus()
- // Assert.
- assertThat(focusModifier.focusState).isEqualTo(ActiveParent)
+ // Assert.
+ assertThat(focusModifier.focusState).isEqualTo(initialFocusState)
+ }
}
}
diff --git a/docs-public/build.gradle b/docs-public/build.gradle
index 88b74ae..66b6df8 100644
--- a/docs-public/build.gradle
+++ b/docs-public/build.gradle
@@ -95,8 +95,8 @@
docs("androidx.core:core-role:1.1.0-alpha02")
docs("androidx.core:core-animation:1.0.0-alpha02")
docs("androidx.core:core-animation-testing:1.0.0-alpha02")
- docs("androidx.core:core:1.7.0-rc01")
- docs("androidx.core:core-ktx:1.7.0-rc01")
+ docs("androidx.core:core:1.7.0")
+ docs("androidx.core:core-ktx:1.7.0")
docs("androidx.core:core-splashscreen:1.0.0-alpha02")
docs("androidx.cursoradapter:cursoradapter:1.0.0")
docs("androidx.customview:customview:1.1.0")
diff --git a/glance/glance-appwidget/integration-tests/demos/src/main/java/androidx/glance/appwidget/demos/ResponsiveAppWidget.kt b/glance/glance-appwidget/integration-tests/demos/src/main/java/androidx/glance/appwidget/demos/ResponsiveAppWidget.kt
index aeef24d..fb4a4db 100644
--- a/glance/glance-appwidget/integration-tests/demos/src/main/java/androidx/glance/appwidget/demos/ResponsiveAppWidget.kt
+++ b/glance/glance-appwidget/integration-tests/demos/src/main/java/androidx/glance/appwidget/demos/ResponsiveAppWidget.kt
@@ -21,7 +21,7 @@
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.Modifier
-import androidx.glance.action.launchActivityAction
+import androidx.glance.action.actionLaunchActivity
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
@@ -66,7 +66,7 @@
)
}
Text(content)
- Button("Button", onClick = launchActivityAction<Activity>())
+ Button("Button", onClick = actionLaunchActivity<Activity>())
}
}
}
diff --git a/glance/glance-appwidget/src/androidAndroidTest/kotlin/androidx/glance/appwidget/GlanceAppWidgetReceiverTest.kt b/glance/glance-appwidget/src/androidAndroidTest/kotlin/androidx/glance/appwidget/GlanceAppWidgetReceiverTest.kt
index 883b4ebb..e6c8408 100644
--- a/glance/glance-appwidget/src/androidAndroidTest/kotlin/androidx/glance/appwidget/GlanceAppWidgetReceiverTest.kt
+++ b/glance/glance-appwidget/src/androidAndroidTest/kotlin/androidx/glance/appwidget/GlanceAppWidgetReceiverTest.kt
@@ -31,7 +31,7 @@
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.Modifier
-import androidx.glance.action.launchActivityAction
+import androidx.glance.action.actionLaunchActivity
import androidx.glance.layout.Box
import androidx.glance.layout.Button
import androidx.glance.layout.Column
@@ -385,7 +385,7 @@
@Test
fun createButton() {
TestGlanceAppWidget.uiDefinition = {
- Button("Button", onClick = launchActivityAction<Activity>(), enabled = false)
+ Button("Button", onClick = actionLaunchActivity<Activity>(), enabled = false)
}
mHostRule.startHost()
diff --git a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/ActionRunnableBroadcastReceiver.kt b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/ActionRunnableBroadcastReceiver.kt
index ce5df1a..8c0e3ab 100644
--- a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/ActionRunnableBroadcastReceiver.kt
+++ b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/ActionRunnableBroadcastReceiver.kt
@@ -22,10 +22,10 @@
import android.content.Intent
import android.net.Uri
import androidx.glance.action.ActionRunnable
-import androidx.glance.action.UpdateAction
+import androidx.glance.action.UpdateContentAction
/**
- * Responds to broadcasts from [UpdateAction] clicks by executing the associated action.
+ * Responds to broadcasts from [UpdateContentAction] clicks by executing the associated action.
*/
internal class ActionRunnableBroadcastReceiver : BroadcastReceiver() {
@@ -39,7 +39,7 @@
"The custom work intent must contain a work class name string using extra: " +
ExtraClassName
}
- UpdateAction.run(context, className)
+ UpdateContentAction.run(context, className)
}
}
diff --git a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/ApplyModifiers.kt b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/ApplyModifiers.kt
index 3ca77ca..5209c78 100644
--- a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/ApplyModifiers.kt
+++ b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/ApplyModifiers.kt
@@ -38,7 +38,7 @@
import androidx.glance.action.LaunchActivityAction
import androidx.glance.action.LaunchActivityClassAction
import androidx.glance.action.LaunchActivityComponentAction
-import androidx.glance.action.UpdateAction
+import androidx.glance.action.UpdateContentAction
import androidx.glance.appwidget.action.LaunchActivityIntentAction
import androidx.glance.layout.Dimension
import androidx.glance.layout.HeightModifier
@@ -121,7 +121,7 @@
)
rv.setOnClickPendingIntent(viewId, pendingIntent)
}
- is UpdateAction -> {
+ is UpdateContentAction -> {
val pendingIntent =
ActionRunnableBroadcastReceiver.createPendingIntent(context, action.runnableClass)
rv.setOnClickPendingIntent(viewId, pendingIntent)
diff --git a/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/RemoteViewsTranslatorKtTest.kt b/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/RemoteViewsTranslatorKtTest.kt
index e6c1d7d..2253063 100644
--- a/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/RemoteViewsTranslatorKtTest.kt
+++ b/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/RemoteViewsTranslatorKtTest.kt
@@ -36,7 +36,7 @@
import androidx.compose.runtime.Composable
import androidx.core.view.children
import androidx.glance.Modifier
-import androidx.glance.action.launchActivityAction
+import androidx.glance.action.actionLaunchActivity
import androidx.glance.appwidget.layout.AndroidRemoteViews
import androidx.glance.appwidget.layout.CheckBox
import androidx.glance.appwidget.layout.LazyColumn
@@ -684,7 +684,7 @@
val rv = runAndTranslate {
Button(
"Button",
- onClick = launchActivityAction<Activity>(),
+ onClick = actionLaunchActivity<Activity>(),
enabled = true
)
}
@@ -700,7 +700,7 @@
val rv = runAndTranslate {
Button(
"Button",
- onClick = launchActivityAction<Activity>(),
+ onClick = actionLaunchActivity<Activity>(),
enabled = false
)
}
diff --git a/glance/glance-wear/src/test/kotlin/androidx/glance/wear/WearCompositionTranslatorTest.kt b/glance/glance-wear/src/test/kotlin/androidx/glance/wear/WearCompositionTranslatorTest.kt
index 9eae920..a8ddeb4e 100644
--- a/glance/glance-wear/src/test/kotlin/androidx/glance/wear/WearCompositionTranslatorTest.kt
+++ b/glance/glance-wear/src/test/kotlin/androidx/glance/wear/WearCompositionTranslatorTest.kt
@@ -22,7 +22,7 @@
import androidx.glance.Modifier
import androidx.glance.background
import androidx.glance.action.clickable
-import androidx.glance.action.launchActivityAction
+import androidx.glance.action.actionLaunchActivity
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Button
@@ -419,7 +419,7 @@
fun canInflateLaunchAction() = fakeCoroutineScope.runBlockingTest {
val content = runAndTranslate {
Text(
- modifier = Modifier.clickable(launchActivityAction(TestActivity::class.java)),
+ modifier = Modifier.clickable(actionLaunchActivity(TestActivity::class.java)),
text = "Hello World"
)
}
@@ -451,7 +451,7 @@
)
Button(
"Hello World",
- onClick = launchActivityAction(TestActivity::class.java),
+ onClick = actionLaunchActivity(TestActivity::class.java),
modifier = Modifier.padding(1.dp),
style = style
)
diff --git a/glance/glance/api/current.txt b/glance/glance/api/current.txt
index de58fc4..190036e 100644
--- a/glance/glance/api/current.txt
+++ b/glance/glance/api/current.txt
@@ -60,14 +60,14 @@
}
public final class LaunchActivityActionKt {
- method public static androidx.glance.action.Action launchActivityAction(android.content.ComponentName componentName);
- method public static <T extends android.app.Activity> androidx.glance.action.Action launchActivityAction(Class<T> activity);
- method public static inline <reified T extends android.app.Activity> androidx.glance.action.Action! launchActivityAction();
+ method public static androidx.glance.action.Action actionLaunchActivity(android.content.ComponentName componentName);
+ method public static <T extends android.app.Activity> androidx.glance.action.Action actionLaunchActivity(Class<T> activity);
+ method public static inline <reified T extends android.app.Activity> androidx.glance.action.Action! actionLaunchActivity();
}
- public final class UpdateActionKt {
- method public static <T extends androidx.glance.action.ActionRunnable> androidx.glance.action.Action updateContentAction(Class<T> runnable);
- method public static inline <reified T extends androidx.glance.action.ActionRunnable> androidx.glance.action.Action! updateContentAction();
+ public final class UpdateContentActionKt {
+ method public static <T extends androidx.glance.action.ActionRunnable> androidx.glance.action.Action actionUpdateContent(Class<T> runnable);
+ method public static inline <reified T extends androidx.glance.action.ActionRunnable> androidx.glance.action.Action! actionUpdateContent();
}
}
diff --git a/glance/glance/api/public_plus_experimental_current.txt b/glance/glance/api/public_plus_experimental_current.txt
index de58fc4..190036e 100644
--- a/glance/glance/api/public_plus_experimental_current.txt
+++ b/glance/glance/api/public_plus_experimental_current.txt
@@ -60,14 +60,14 @@
}
public final class LaunchActivityActionKt {
- method public static androidx.glance.action.Action launchActivityAction(android.content.ComponentName componentName);
- method public static <T extends android.app.Activity> androidx.glance.action.Action launchActivityAction(Class<T> activity);
- method public static inline <reified T extends android.app.Activity> androidx.glance.action.Action! launchActivityAction();
+ method public static androidx.glance.action.Action actionLaunchActivity(android.content.ComponentName componentName);
+ method public static <T extends android.app.Activity> androidx.glance.action.Action actionLaunchActivity(Class<T> activity);
+ method public static inline <reified T extends android.app.Activity> androidx.glance.action.Action! actionLaunchActivity();
}
- public final class UpdateActionKt {
- method public static <T extends androidx.glance.action.ActionRunnable> androidx.glance.action.Action updateContentAction(Class<T> runnable);
- method public static inline <reified T extends androidx.glance.action.ActionRunnable> androidx.glance.action.Action! updateContentAction();
+ public final class UpdateContentActionKt {
+ method public static <T extends androidx.glance.action.ActionRunnable> androidx.glance.action.Action actionUpdateContent(Class<T> runnable);
+ method public static inline <reified T extends androidx.glance.action.ActionRunnable> androidx.glance.action.Action! actionUpdateContent();
}
}
diff --git a/glance/glance/api/restricted_current.txt b/glance/glance/api/restricted_current.txt
index de58fc4..190036e 100644
--- a/glance/glance/api/restricted_current.txt
+++ b/glance/glance/api/restricted_current.txt
@@ -60,14 +60,14 @@
}
public final class LaunchActivityActionKt {
- method public static androidx.glance.action.Action launchActivityAction(android.content.ComponentName componentName);
- method public static <T extends android.app.Activity> androidx.glance.action.Action launchActivityAction(Class<T> activity);
- method public static inline <reified T extends android.app.Activity> androidx.glance.action.Action! launchActivityAction();
+ method public static androidx.glance.action.Action actionLaunchActivity(android.content.ComponentName componentName);
+ method public static <T extends android.app.Activity> androidx.glance.action.Action actionLaunchActivity(Class<T> activity);
+ method public static inline <reified T extends android.app.Activity> androidx.glance.action.Action! actionLaunchActivity();
}
- public final class UpdateActionKt {
- method public static <T extends androidx.glance.action.ActionRunnable> androidx.glance.action.Action updateContentAction(Class<T> runnable);
- method public static inline <reified T extends androidx.glance.action.ActionRunnable> androidx.glance.action.Action! updateContentAction();
+ public final class UpdateContentActionKt {
+ method public static <T extends androidx.glance.action.ActionRunnable> androidx.glance.action.Action actionUpdateContent(Class<T> runnable);
+ method public static inline <reified T extends androidx.glance.action.ActionRunnable> androidx.glance.action.Action! actionUpdateContent();
}
}
diff --git a/glance/glance/src/androidMain/kotlin/androidx/glance/action/Action.kt b/glance/glance/src/androidMain/kotlin/androidx/glance/action/Action.kt
index 71ff4f3..744d5f1 100644
--- a/glance/glance/src/androidMain/kotlin/androidx/glance/action/Action.kt
+++ b/glance/glance/src/androidMain/kotlin/androidx/glance/action/Action.kt
@@ -22,7 +22,7 @@
/**
* An Action defines the actions a user can take. Implementations specify what operation will be
- * performed in response to the action, eg. [launchActivityAction] creates an Action that launches
+ * performed in response to the action, eg. [actionLaunchActivity] creates an Action that launches
* the specified [Activity].
*/
public interface Action
diff --git a/glance/glance/src/androidMain/kotlin/androidx/glance/action/LaunchActivityAction.kt b/glance/glance/src/androidMain/kotlin/androidx/glance/action/LaunchActivityAction.kt
index f75863d..f09201b 100644
--- a/glance/glance/src/androidMain/kotlin/androidx/glance/action/LaunchActivityAction.kt
+++ b/glance/glance/src/androidMain/kotlin/androidx/glance/action/LaunchActivityAction.kt
@@ -26,27 +26,28 @@
/** @suppress */
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-class LaunchActivityComponentAction(val componentName: ComponentName) : LaunchActivityAction
+public class LaunchActivityComponentAction(val componentName: ComponentName) : LaunchActivityAction
/** @suppress */
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-class LaunchActivityClassAction(val activityClass: Class<out Activity>) : LaunchActivityAction
+public class LaunchActivityClassAction(val activityClass: Class<out Activity>) :
+ LaunchActivityAction
/**
* Creates an [Action] that launches the [Activity] specified by the given [ComponentName].
*/
-public fun launchActivityAction(componentName: ComponentName): Action =
+public fun actionLaunchActivity(componentName: ComponentName): Action =
LaunchActivityComponentAction(componentName)
/**
* Creates an [Action] that launches the specified [Activity] when triggered.
*/
-public fun <T : Activity> launchActivityAction(activity: Class<T>): Action =
+public fun <T : Activity> actionLaunchActivity(activity: Class<T>): Action =
LaunchActivityClassAction(activity)
@Suppress("MissingNullability") /* Shouldn't need to specify @NonNull. b/199284086 */
/**
* Creates an [Action] that launches the specified [Activity] when triggered.
*/
-public inline fun <reified T : Activity> launchActivityAction(): Action =
- launchActivityAction(T::class.java)
+public inline fun <reified T : Activity> actionLaunchActivity(): Action =
+ actionLaunchActivity(T::class.java)
diff --git a/glance/glance/src/androidMain/kotlin/androidx/glance/action/UpdateAction.kt b/glance/glance/src/androidMain/kotlin/androidx/glance/action/UpdateContentAction.kt
similarity index 80%
rename from glance/glance/src/androidMain/kotlin/androidx/glance/action/UpdateAction.kt
rename to glance/glance/src/androidMain/kotlin/androidx/glance/action/UpdateContentAction.kt
index 660929f..3ea29c2 100644
--- a/glance/glance/src/androidMain/kotlin/androidx/glance/action/UpdateAction.kt
+++ b/glance/glance/src/androidMain/kotlin/androidx/glance/action/UpdateContentAction.kt
@@ -21,7 +21,7 @@
/** @suppress */
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-class UpdateAction(val runnableClass: Class<out ActionRunnable>) : Action {
+public class UpdateContentAction(val runnableClass: Class<out ActionRunnable>) : Action {
companion object {
public suspend fun run(context: Context, className: String) {
@@ -42,20 +42,22 @@
* implementing class must have a public zero argument constructor, this is used to instantiate
* the class at runtime.
*/
-interface ActionRunnable {
+public interface ActionRunnable {
suspend fun run(context: Context)
}
/**
- * Creates an [Action] that executes a custom [ActionRunnable] and then updates the component view.
+ * Creates an [Action] that executes a custom [ActionRunnable] and then updates the component
+ * content.
*/
-public fun <T : ActionRunnable> updateContentAction(runnable: Class<T>): Action =
- UpdateAction(runnable)
+public fun <T : ActionRunnable> actionUpdateContent(runnable: Class<T>): Action =
+ UpdateContentAction(runnable)
@Suppress("MissingNullability") /* Shouldn't need to specify @NonNull. b/199284086 */
/**
- * Creates an [Action] that executes a custom [ActionRunnable] and then updates the component view.
+ * Creates an [Action] that executes a custom [ActionRunnable] and then updates the component
+ * content.
*/
// TODO(b/201418282): Add the UI update path
-public inline fun <reified T : ActionRunnable> updateContentAction(): Action =
- updateContentAction(T::class.java)
+public inline fun <reified T : ActionRunnable> actionUpdateContent(): Action =
+ actionUpdateContent(T::class.java)
diff --git a/glance/glance/src/test/kotlin/androidx/glance/action/ActionTest.kt b/glance/glance/src/test/kotlin/androidx/glance/action/ActionTest.kt
index 7729138..4edbaa2 100644
--- a/glance/glance/src/test/kotlin/androidx/glance/action/ActionTest.kt
+++ b/glance/glance/src/test/kotlin/androidx/glance/action/ActionTest.kt
@@ -46,23 +46,23 @@
@Test
fun testLaunchActivity() {
- val modifiers = Modifier.clickable(launchActivityAction(TestActivity::class.java))
+ val modifiers = Modifier.clickable(actionLaunchActivity(TestActivity::class.java))
val modifier = checkNotNull(modifiers.findModifier<ActionModifier>())
assertIs<LaunchActivityClassAction>(modifier.action)
}
@Test
fun testUpdate() {
- val modifiers = Modifier.clickable(updateContentAction<TestRunnable>())
+ val modifiers = Modifier.clickable(actionUpdateContent<TestRunnable>())
val modifier = checkNotNull(modifiers.findModifier<ActionModifier>())
- assertIs<UpdateAction>(modifier.action)
+ assertIs<UpdateContentAction>(modifier.action)
}
@Test
fun testLaunchFromComponent() = fakeCoroutineScope.runBlockingTest {
val c = ComponentName("androidx.glance.action", "androidx.glance.action.TestActivity")
- val modifiers = Modifier.clickable(launchActivityAction(c))
+ val modifiers = Modifier.clickable(actionLaunchActivity(c))
val modifier = checkNotNull(modifiers.findModifier<ActionModifier>())
val action = assertIs<LaunchActivityComponentAction>(modifier.action)
val component = assertNotNull(action.componentName)
@@ -74,7 +74,7 @@
fun testLaunchFromComponentWithContext() = fakeCoroutineScope.runBlockingTest {
val c = ComponentName(context, "androidx.glance.action.TestActivity")
- val modifiers = Modifier.clickable(launchActivityAction(c))
+ val modifiers = Modifier.clickable(actionLaunchActivity(c))
val modifier = checkNotNull(modifiers.findModifier<ActionModifier>())
val action = assertIs<LaunchActivityComponentAction>(modifier.action)
val component = assertNotNull(action.componentName)
diff --git a/glance/glance/src/test/kotlin/androidx/glance/layout/ButtonTest.kt b/glance/glance/src/test/kotlin/androidx/glance/layout/ButtonTest.kt
index db37f7b..f43aa10 100644
--- a/glance/glance/src/test/kotlin/androidx/glance/layout/ButtonTest.kt
+++ b/glance/glance/src/test/kotlin/androidx/glance/layout/ButtonTest.kt
@@ -18,7 +18,7 @@
import android.app.Activity
import androidx.glance.action.ActionModifier
import androidx.glance.action.LaunchActivityAction
-import androidx.glance.action.launchActivityAction
+import androidx.glance.action.actionLaunchActivity
import androidx.glance.findModifier
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -40,7 +40,7 @@
@Test
fun createComposableButton() = fakeCoroutineScope.runBlockingTest {
val root = runTestingComposition {
- Button(text = "button", onClick = launchActivityAction<Activity>(), enabled = true)
+ Button(text = "button", onClick = actionLaunchActivity<Activity>(), enabled = true)
}
assertThat(root.children).hasSize(1)
@@ -53,7 +53,7 @@
@Test
fun createDisabledButton() = fakeCoroutineScope.runBlockingTest {
val root = runTestingComposition {
- Button(text = "button", onClick = launchActivityAction<Activity>(), enabled = false)
+ Button(text = "button", onClick = actionLaunchActivity<Activity>(), enabled = false)
}
assertThat(root.children).hasSize(1)
diff --git a/preference/preference/api/current.txt b/preference/preference/api/current.txt
index f8e20c5..4cc0b65 100644
--- a/preference/preference/api/current.txt
+++ b/preference/preference/api/current.txt
@@ -414,6 +414,16 @@
method public int getPreferenceAdapterPosition(androidx.preference.Preference!);
}
+ public abstract class PreferenceHeaderFragmentCompat extends androidx.fragment.app.Fragment implements androidx.preference.PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
+ ctor public PreferenceHeaderFragmentCompat();
+ method public final androidx.slidingpanelayout.widget.SlidingPaneLayout getSlidingPaneLayout();
+ method public androidx.fragment.app.Fragment? onCreateInitialDetailFragment();
+ method public abstract androidx.preference.PreferenceFragmentCompat onCreatePreferenceHeader();
+ method @CallSuper public boolean onPreferenceStartFragment(androidx.preference.PreferenceFragmentCompat caller, androidx.preference.Preference pref);
+ method public final void openPreference(androidx.preference.Preference header);
+ property public final androidx.slidingpanelayout.widget.SlidingPaneLayout slidingPaneLayout;
+ }
+
public class PreferenceManager {
method public androidx.preference.PreferenceScreen! createPreferenceScreen(android.content.Context!);
method public <T extends androidx.preference.Preference> T? findPreference(CharSequence);
diff --git a/preference/preference/api/public_plus_experimental_current.txt b/preference/preference/api/public_plus_experimental_current.txt
index f8e20c5..4cc0b65 100644
--- a/preference/preference/api/public_plus_experimental_current.txt
+++ b/preference/preference/api/public_plus_experimental_current.txt
@@ -414,6 +414,16 @@
method public int getPreferenceAdapterPosition(androidx.preference.Preference!);
}
+ public abstract class PreferenceHeaderFragmentCompat extends androidx.fragment.app.Fragment implements androidx.preference.PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
+ ctor public PreferenceHeaderFragmentCompat();
+ method public final androidx.slidingpanelayout.widget.SlidingPaneLayout getSlidingPaneLayout();
+ method public androidx.fragment.app.Fragment? onCreateInitialDetailFragment();
+ method public abstract androidx.preference.PreferenceFragmentCompat onCreatePreferenceHeader();
+ method @CallSuper public boolean onPreferenceStartFragment(androidx.preference.PreferenceFragmentCompat caller, androidx.preference.Preference pref);
+ method public final void openPreference(androidx.preference.Preference header);
+ property public final androidx.slidingpanelayout.widget.SlidingPaneLayout slidingPaneLayout;
+ }
+
public class PreferenceManager {
method public androidx.preference.PreferenceScreen! createPreferenceScreen(android.content.Context!);
method public <T extends androidx.preference.Preference> T? findPreference(CharSequence);
diff --git a/preference/preference/api/restricted_current.txt b/preference/preference/api/restricted_current.txt
index 4251683..1ca2328 100644
--- a/preference/preference/api/restricted_current.txt
+++ b/preference/preference/api/restricted_current.txt
@@ -439,6 +439,16 @@
method public void onPreferenceVisibilityChange(androidx.preference.Preference!);
}
+ public abstract class PreferenceHeaderFragmentCompat extends androidx.fragment.app.Fragment implements androidx.preference.PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
+ ctor public PreferenceHeaderFragmentCompat();
+ method public final androidx.slidingpanelayout.widget.SlidingPaneLayout getSlidingPaneLayout();
+ method public androidx.fragment.app.Fragment? onCreateInitialDetailFragment();
+ method public abstract androidx.preference.PreferenceFragmentCompat onCreatePreferenceHeader();
+ method @CallSuper public boolean onPreferenceStartFragment(androidx.preference.PreferenceFragmentCompat caller, androidx.preference.Preference pref);
+ method public final void openPreference(androidx.preference.Preference header);
+ property public final androidx.slidingpanelayout.widget.SlidingPaneLayout slidingPaneLayout;
+ }
+
public class PreferenceManager {
ctor @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public PreferenceManager(android.content.Context!);
method public androidx.preference.PreferenceScreen! createPreferenceScreen(android.content.Context!);
diff --git a/preference/preference/build.gradle b/preference/preference/build.gradle
index 6e89cb6..6f18810 100644
--- a/preference/preference/build.gradle
+++ b/preference/preference/build.gradle
@@ -31,6 +31,7 @@
implementation("androidx.collection:collection:1.0.0")
api("androidx.fragment:fragment-ktx:1.3.6")
api("androidx.recyclerview:recyclerview:1.0.0")
+ api("androidx.slidingpanelayout:slidingpanelayout:1.2.0-beta01")
androidTestImplementation(libs.testExtJunit)
androidTestImplementation(libs.testCore)
diff --git a/preference/preference/res/values/dimens.xml b/preference/preference/res/values/dimens.xml
index 403523e..52efb82 100644
--- a/preference/preference/res/values/dimens.xml
+++ b/preference/preference/res/values/dimens.xml
@@ -13,4 +13,6 @@
<!-- The padding at the start of the dropdown menu within a DropDownPreference
TODO: Pending UX discussion in b/110975540 - keeping old behaviour for now.-->
<dimen name="preference_dropdown_padding_start">0dp</dimen>
+ <dimen name="preferences_header_width">384dp</dimen>
+ <dimen name="preferences_detail_width">300dp</dimen>
</resources>
\ No newline at end of file
diff --git a/preference/preference/res/values/ids.xml b/preference/preference/res/values/ids.xml
new file mode 100644
index 0000000..33aaec7
--- /dev/null
+++ b/preference/preference/res/values/ids.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ 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.
+ -->
+
+<resources>
+ <item name="preferences_sliding_pane_layout" type="id" />
+ <item name="preferences_header" type="id" />
+ <item name="preferences_detail" type="id" />
+</resources>
\ No newline at end of file
diff --git a/preference/preference/res/values/integers.xml b/preference/preference/res/values/integers.xml
new file mode 100644
index 0000000..172ff6f
--- /dev/null
+++ b/preference/preference/res/values/integers.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ 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.
+ -->
+
+<resources>
+ <integer name="preferences_header_pane_weight">0</integer>
+ <integer name="preferences_detail_pane_weight">1</integer>
+</resources>
\ No newline at end of file
diff --git a/preference/preference/src/main/java/androidx/preference/PreferenceHeaderFragmentCompat.kt b/preference/preference/src/main/java/androidx/preference/PreferenceHeaderFragmentCompat.kt
new file mode 100644
index 0000000..54220cac
--- /dev/null
+++ b/preference/preference/src/main/java/androidx/preference/PreferenceHeaderFragmentCompat.kt
@@ -0,0 +1,314 @@
+/*
+ * 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.preference
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.OnBackPressedDispatcherOwner
+import androidx.annotation.CallSuper
+import androidx.core.view.doOnLayout
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentContainerView
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.FragmentTransaction
+import androidx.fragment.app.commit
+import androidx.slidingpanelayout.widget.SlidingPaneLayout
+
+/**
+ * [PreferenceHeaderFragmentCompat] implements a two-pane fragment for preferences. The list
+ * pane is a container of preference headers. Tapping on a preference header swaps out the fragment
+ * shown in the detail pane. Subclasses are expected to implement [onCreatePreferenceHeader] to
+ * provide your own [PreferenceFragmentCompat] in the list pane. The preference header hierarchy
+ * is defined by either providing an XML resource or build in code through
+ * [PreferenceFragmentCompat]. In both cases, users need to use a [PreferenceScreen] as the root
+ * component in the hierarchy.
+ *
+ * Usage:
+ *
+ * ```
+ * class TwoPanePreference : PreferenceHeaderFragmentCompat() {
+ * override fun onCreatePreferenceHeader(): PreferenceFragmentCompat {
+ * return PreferenceHeader()
+ * }
+ * }
+ * ```
+ *
+ * [PreferenceHeaderFragmentCompat] handles the fragment transaction when users defines a
+ * fragment or intent associated with the preference header. By default, the initial state fragment
+ * for the detail pane is set to the associated fragment that first found in preference
+ * headers. You can override [onCreateInitialDetailFragment] to provide the custom empty state
+ * fragment for the detail pane.
+ */
+abstract class PreferenceHeaderFragmentCompat :
+ Fragment(),
+ PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
+ private var onBackPressedCallback: OnBackPressedCallback? = null
+
+ /**
+ * Return the [SlidingPaneLayout] this fragment is currently controlling.
+ *
+ * @throws IllegalStateException if the SlidingPaneLayout has not been created by [onCreateView]
+ */
+ val slidingPaneLayout: SlidingPaneLayout
+ get() = requireView() as SlidingPaneLayout
+
+ @CallSuper
+ override fun onPreferenceStartFragment(
+ caller: PreferenceFragmentCompat,
+ pref: Preference
+ ): Boolean {
+ if (caller.id == R.id.preferences_header) {
+ // Opens the preference header.
+ openPreference(pref)
+ return true
+ }
+ if (caller.id == R.id.preferences_detail) {
+ // Opens an preference in detail pane.
+ val frag = childFragmentManager.fragmentFactory.instantiate(
+ requireContext().classLoader,
+ pref.fragment
+ )
+ frag.arguments = pref.extras
+
+ childFragmentManager.commit {
+ setReorderingAllowed(true)
+ replace(R.id.preferences_detail, frag)
+ setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
+ addToBackStack(null)
+ }
+ return true
+ }
+ return false
+ }
+
+ private class InnerOnBackPressedCallback(
+ private val caller: PreferenceHeaderFragmentCompat
+ ) :
+ OnBackPressedCallback(true),
+ SlidingPaneLayout.PanelSlideListener {
+
+ init {
+ caller.slidingPaneLayout.addPanelSlideListener(this)
+ }
+
+ override fun handleOnBackPressed() {
+ caller.slidingPaneLayout.closePane()
+ }
+
+ override fun onPanelSlide(panel: View, slideOffset: Float) {}
+
+ override fun onPanelOpened(panel: View) {
+ // Intercept the system back button when the detail pane becomes visible.
+ isEnabled = true
+ }
+
+ override fun onPanelClosed(panel: View) {
+ // Disable intercepting the system back button when the user returns to the list pane.
+ isEnabled = false
+ }
+ }
+
+ @CallSuper
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ parentFragmentManager.commit {
+ setPrimaryNavigationFragment(this@PreferenceHeaderFragmentCompat)
+ }
+ }
+
+ @CallSuper
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val slidingPaneLayout = buildContentView(inflater)
+ // Now create the header fragment
+ val existingHeaderFragment = childFragmentManager.findFragmentById(
+ R.id.preferences_header
+ )
+ if (existingHeaderFragment == null) {
+ onCreatePreferenceHeader().also { newHeaderFragment ->
+ childFragmentManager.commit {
+ setReorderingAllowed(true)
+ add(R.id.preferences_header, newHeaderFragment)
+ }
+ }
+ }
+ slidingPaneLayout.lockMode = SlidingPaneLayout.LOCK_MODE_LOCKED
+ return slidingPaneLayout
+ }
+
+ private fun buildContentView(inflater: LayoutInflater): SlidingPaneLayout {
+ val slidingPaneLayout = SlidingPaneLayout(inflater.context).apply {
+ id = R.id.preferences_sliding_pane_layout
+ }
+ // Add Preference Header Pane
+ val headerContainer = FragmentContainerView(inflater.context).apply {
+ id = R.id.preferences_header
+ }
+ val headerLayoutParams = SlidingPaneLayout.LayoutParams(
+ resources.getDimensionPixelSize(R.dimen.preferences_header_width),
+ MATCH_PARENT
+ ).apply {
+ weight = resources.getInteger(R.integer.preferences_header_pane_weight).toFloat()
+ }
+ slidingPaneLayout.addView(
+ headerContainer,
+ headerLayoutParams
+ )
+
+ // Add Preference Detail Pane
+ val detailContainer = FragmentContainerView(inflater.context).apply {
+ id = R.id.preferences_detail
+ }
+ val detailLayoutParams = SlidingPaneLayout.LayoutParams(
+ resources.getDimensionPixelSize(R.dimen.preferences_detail_width),
+ MATCH_PARENT
+ ).apply {
+ weight = resources.getInteger(R.integer.preferences_detail_pane_weight).toFloat()
+ }
+ slidingPaneLayout.addView(
+ detailContainer,
+ detailLayoutParams
+ )
+ return slidingPaneLayout
+ }
+
+ /**
+ * Called to supply the preference header for this fragment. The subclasses are expected
+ * to call [setPreferenceScreen(PreferenceScreen)] either directly or via helper methods
+ * such as [setPreferenceFromResource(int)] to set headers.
+ */
+ abstract fun onCreatePreferenceHeader(): PreferenceFragmentCompat
+
+ @CallSuper
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ onBackPressedCallback = InnerOnBackPressedCallback(this)
+ slidingPaneLayout.doOnLayout {
+ onBackPressedCallback!!.isEnabled =
+ slidingPaneLayout.isSlideable && slidingPaneLayout.isOpen
+ }
+ childFragmentManager.addOnBackStackChangedListener {
+ onBackPressedCallback!!.isEnabled = childFragmentManager.backStackEntryCount == 0
+ }
+ val onBackPressedDispatcherOwner = requireContext() as? OnBackPressedDispatcherOwner
+ onBackPressedDispatcherOwner?.let {
+ it.onBackPressedDispatcher.addCallback(
+ viewLifecycleOwner,
+ onBackPressedCallback!!
+ )
+ }
+ }
+
+ override fun onViewStateRestored(savedInstanceState: Bundle?) {
+ super.onViewStateRestored(savedInstanceState)
+ if (savedInstanceState == null) {
+ onCreateInitialDetailFragment()?.let {
+ childFragmentManager.commit {
+ setReorderingAllowed(true)
+ replace(R.id.preferences_detail, it)
+ }
+ }
+ }
+ }
+
+ /**
+ * Override this method to set initial detail fragment that to be shown. The default
+ * implementation returns the first preference that has a fragment defined on
+ * it.
+ *
+ * @return Fragment The first fragment that found in the list of preference headers.
+ */
+ open fun onCreateInitialDetailFragment(): Fragment? {
+ val headerFragment = childFragmentManager.findFragmentById(R.id.preferences_header)
+ as PreferenceFragmentCompat
+ if (headerFragment.preferenceScreen.preferenceCount <= 0) {
+ return null
+ }
+ for (index in 0 until headerFragment.preferenceScreen.preferenceCount) {
+ val header = headerFragment.preferenceScreen.getPreference(index)
+ if (header.fragment == null) {
+ continue
+ }
+ val fragment = header.fragment?.let {
+ childFragmentManager.fragmentFactory.instantiate(
+ requireContext().classLoader,
+ it
+ )
+ }
+ return fragment
+ }
+ return null
+ }
+
+ /**
+ * Swaps out the fragment that associated with preference header. If associated fragment is
+ * unspecified, open the preference with the given intent instead.
+ *
+ * @param header The preference header that selected
+ */
+ fun openPreference(header: Preference) {
+ if (header.fragment == null) {
+ openPreference(header.intent)
+ return
+ }
+ val fragment = header.fragment?.let {
+ childFragmentManager.fragmentFactory.instantiate(
+ requireContext().classLoader,
+ it
+ )
+ }
+
+ fragment?.apply {
+ arguments = header.extras
+ }
+
+ // Clear back stack
+ if (childFragmentManager.backStackEntryCount > 0) {
+ val entry = childFragmentManager.getBackStackEntryAt(0)
+ childFragmentManager.popBackStack(entry.id, FragmentManager.POP_BACK_STACK_INCLUSIVE)
+ }
+
+ childFragmentManager.commit {
+ setReorderingAllowed(true)
+ replace(R.id.preferences_detail, fragment!!)
+ if (slidingPaneLayout.isOpen) {
+ setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
+ }
+ slidingPaneLayout.openPane()
+ }
+ }
+
+ /**
+ * Open preference with the given intent
+ *
+ * @param intent The intent that associated with preference header
+ */
+ private fun openPreference(intent: Intent?) {
+ if (intent == null) return
+ // TODO: Change to use WindowManager ActivityView API
+ startActivity(intent)
+ }
+}
\ No newline at end of file
diff --git a/samples/SupportPreferenceDemos/build.gradle b/samples/SupportPreferenceDemos/build.gradle
index 14670ed..3c25b24 100644
--- a/samples/SupportPreferenceDemos/build.gradle
+++ b/samples/SupportPreferenceDemos/build.gradle
@@ -1,12 +1,16 @@
plugins {
id("AndroidXPlugin")
id("com.android.application")
+ id("kotlin-android")
}
dependencies {
+ implementation(libs.kotlinStdlib)
+ implementation("androidx.core:core-ktx:1.5.0")
implementation(project(":appcompat:appcompat"))
implementation(project(":preference:preference"))
+ implementation(project(":preference:preference-ktx"))
implementation(project(":recyclerview:recyclerview"))
implementation(project(":leanback:leanback"))
implementation(project(":leanback:leanback-preference"))
-}
+}
\ No newline at end of file
diff --git a/samples/SupportPreferenceDemos/src/main/AndroidManifest.xml b/samples/SupportPreferenceDemos/src/main/AndroidManifest.xml
index f25a041..1c0a55da 100644
--- a/samples/SupportPreferenceDemos/src/main/AndroidManifest.xml
+++ b/samples/SupportPreferenceDemos/src/main/AndroidManifest.xml
@@ -67,5 +67,15 @@
</intent-filter>
</activity>
+ <activity
+ android:name="TwoPanePreferences"
+ android:label="@string/twopane_preferences"
+ android:theme="@style/PreferenceTheme"
+ android:exported="true">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN"/>
+ <category android:name="com.example.androidx.preference.SAMPLE_CODE"/>
+ </intent-filter>
+ </activity>
</application>
</manifest>
diff --git a/samples/SupportPreferenceDemos/src/main/java/com/example/androidx/preference/MainActivity.java b/samples/SupportPreferenceDemos/src/main/java/com/example/androidx/preference/MainActivity.java
index e6aba68..05aaf32 100644
--- a/samples/SupportPreferenceDemos/src/main/java/com/example/androidx/preference/MainActivity.java
+++ b/samples/SupportPreferenceDemos/src/main/java/com/example/androidx/preference/MainActivity.java
@@ -24,6 +24,8 @@
import android.widget.ListView;
import android.widget.SimpleAdapter;
+import androidx.annotation.NonNull;
+
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -39,7 +41,7 @@
private static final String NAME = "name";
@Override
- public void onCreate(Bundle savedInstanceState) {
+ public void onCreate(@NonNull Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
SimpleAdapter adapter = new SimpleAdapter(this, getActivityList(),
android.R.layout.simple_list_item_1, new String[]{NAME},
@@ -49,12 +51,13 @@
@Override
@SuppressWarnings("unchecked")
- protected void onListItemClick(ListView l, View v, int position, long id) {
+ protected void onListItemClick(@NonNull ListView l, @NonNull View v, int position, long id) {
Map<String, Object> map = (Map<String, Object>) l.getItemAtPosition(position);
Intent intent = (Intent) map.get(INTENT);
startActivity(intent);
}
+ @NonNull
protected List<Map<String, Object>> getActivityList() {
List<Map<String, Object>> activityList = new ArrayList<>();
@@ -63,7 +66,6 @@
PackageManager pm = getPackageManager();
List<ResolveInfo> list = pm.queryIntentActivities(mainIntent, 0);
-
for (int i = 0; i < list.size(); i++) {
ResolveInfo info = list.get(i);
String label = info.loadLabel(pm).toString();
diff --git a/samples/SupportPreferenceDemos/src/main/java/com/example/androidx/preference/TwoPanePreferences.kt b/samples/SupportPreferenceDemos/src/main/java/com/example/androidx/preference/TwoPanePreferences.kt
new file mode 100644
index 0000000..724783e
--- /dev/null
+++ b/samples/SupportPreferenceDemos/src/main/java/com/example/androidx/preference/TwoPanePreferences.kt
@@ -0,0 +1,80 @@
+/*
+ * 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 com.example.androidx.preference
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import androidx.preference.PreferenceFragmentCompat
+import androidx.preference.PreferenceHeaderFragmentCompat
+
+class TwoPanePreferences : AppCompatActivity() {
+
+ class PreferenceHeaderFragmentCompatImpl : PreferenceHeaderFragmentCompat() {
+
+ class PreferenceHeader : PreferenceFragmentCompat() {
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ setPreferencesFromResource(R.xml.preference_headers, rootKey)
+ }
+ }
+
+ override fun onCreatePreferenceHeader(): PreferenceFragmentCompat {
+ return PreferenceHeader()
+ }
+ }
+
+ class BasicPreferences : PreferenceFragmentCompat() {
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ setPreferencesFromResource(R.xml.preferences_basic, rootKey)
+ }
+ }
+
+ class WidgetPreferences : PreferenceFragmentCompat() {
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ setPreferencesFromResource(R.xml.preferences_widget, rootKey)
+ }
+ }
+
+ class DialogPreferences : PreferenceFragmentCompat() {
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ setPreferencesFromResource(R.xml.preferences_dialog, rootKey)
+ }
+ }
+
+ class AdvancedPreferences : PreferenceFragmentCompat() {
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ setPreferencesFromResource(R.xml.preferences_advanced, rootKey)
+ }
+ }
+
+ class MultiPreferences : PreferenceFragmentCompat() {
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ setPreferencesFromResource(R.xml.preferences_advanced_next, rootKey)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Display preference header fragment as the main content
+ if (savedInstanceState == null) {
+ supportFragmentManager.beginTransaction().replace(
+ android.R.id.content,
+ PreferenceHeaderFragmentCompatImpl()
+ ).commit()
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/SupportPreferenceDemos/src/main/res/values/strings.xml b/samples/SupportPreferenceDemos/src/main/res/values/strings.xml
index e4b9708..acfe9b8 100644
--- a/samples/SupportPreferenceDemos/src/main/res/values/strings.xml
+++ b/samples/SupportPreferenceDemos/src/main/res/values/strings.xml
@@ -22,6 +22,7 @@
<!--Fragment titles-->
<string name="preferences">Preferences</string>
<string name="leanback_preferences">Leanback Preferences</string>
+ <string name="twopane_preferences">TwoPane Preferences</string>
<!--This section is for basic attributes -->
<string name="basic_preferences">Basic attributes</string>
@@ -85,4 +86,18 @@
<string name="title_copyable_preference">Copyable preference</string>
<string name="summary_copyable_preference">Long press on this preference to copy its summary</string>
+ <string name="summary_dialogs">Dialog preferences</string>
+ <string name="summary_basic_preferences">Basic preferences</string>
+ <string name="summary_widgets">widgets</string>
+ <string name="summary_advanced_attributes">Advanced attributes of preferences</string>
+
+ <string name="multi_preference_screen">Multi Preference Screens</string>
+ <string name="title_multi_preference_screen">Next Preference Screen</string>
+ <string name="summary_multi_preference_screen">Click to launch another preference screen</string>
+
+ <string name="title_next_preference_screen">Next Preference Screen</string>
+ <string name="title_information">Basic Information</string>
+ <string name="summary_information">Basic Information about the demo app</string>
+ <string name="title_details">Details</string>
+ <string name="summary_details">Detailed Information about the demo app</string>
</resources>
diff --git a/samples/SupportPreferenceDemos/src/main/res/xml/preference_headers.xml b/samples/SupportPreferenceDemos/src/main/res/xml/preference_headers.xml
new file mode 100644
index 0000000..bd37919
--- /dev/null
+++ b/samples/SupportPreferenceDemos/src/main/res/xml/preference_headers.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ 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.
+ -->
+
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto">
+ <Preference
+ android:key="basic"
+ android:fragment="com.example.androidx.preference.TwoPanePreferences$BasicPreferences"
+ android:summary="@string/summary_basic_preferences"
+ android:title="@string/basic_preferences"
+ android:icon="@android:drawable/sym_def_app_icon"/>
+ <SwitchPreferenceCompat
+ android:key="switch"
+ android:title="@string/title_switch_preference"
+ android:summary="@string/summary_switch_preference"/>
+ <Preference
+ android:key="widgets"
+ android:fragment="com.example.androidx.preference.TwoPanePreferences$WidgetPreferences"
+ android:summary="@string/summary_widgets"
+ android:title="@string/widgets"/>
+ <Preference
+ android:key="dialogs"
+ android:fragment="com.example.androidx.preference.TwoPanePreferences$DialogPreferences"
+ android:summary="@string/summary_dialogs"
+ android:title="@string/dialogs" />
+ <Preference
+ android:key="advanced"
+ android:fragment="com.example.androidx.preference.TwoPanePreferences$AdvancedPreferences"
+ android:summary="@string/summary_advanced_attributes"
+ android:title="@string/advanced_attributes" />
+ <Preference
+ android:key="intent"
+ android:title="@string/title_intent_preference"
+ android:summary="@string/summary_intent_preference">
+ <intent android:action="android.intent.action.VIEW"
+ android:data="http://www.android.com"/>
+ </Preference>
+</PreferenceScreen>
\ No newline at end of file
diff --git a/samples/SupportPreferenceDemos/src/main/res/xml/preferences_advanced.xml b/samples/SupportPreferenceDemos/src/main/res/xml/preferences_advanced.xml
new file mode 100644
index 0000000..a16bb93
--- /dev/null
+++ b/samples/SupportPreferenceDemos/src/main/res/xml/preferences_advanced.xml
@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ 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.
+ -->
+
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto">
+ <PreferenceCategory
+ android:key="advanced"
+ android:title="@string/advanced_attributes"
+ app:initialExpandedChildrenCount="1">
+
+ <Preference
+ android:key="expandable"
+ android:title="@string/title_expandable_preference"
+ android:summary="@string/summary_expandable_preference"/>
+
+ <Preference
+ android:title="@string/title_intent_preference"
+ android:summary="@string/summary_intent_preference">
+
+ <intent android:action="android.intent.action.VIEW"
+ android:data="http://www.android.com"/>
+
+ </Preference>
+
+ <SwitchPreferenceCompat
+ android:key="parent"
+ android:title="@string/title_parent_preference"
+ android:summary="@string/summary_parent_preference"/>
+
+ <SwitchPreferenceCompat
+ android:key="child"
+ android:dependency="parent"
+ android:title="@string/title_child_preference"
+ android:summary="@string/summary_child_preference"/>
+
+ <SwitchPreferenceCompat
+ android:key="toggle_summary"
+ android:title="@string/title_toggle_summary_preference"
+ android:summaryOn="@string/summary_on_toggle_summary_preference"
+ android:summaryOff="@string/summary_off_toggle_summary_preference"/>
+
+ <Preference
+ android:key="copyable"
+ android:title="@string/title_copyable_preference"
+ android:summary="@string/summary_copyable_preference"
+ android:selectable="false"
+ app:enableCopying="true"/>
+ </PreferenceCategory>
+ <PreferenceCategory
+ android:key="multi-screen"
+ android:title="@string/multi_preference_screen">
+ <Preference
+ android:title="@string/title_multi_preference_screen"
+ android:summary="@string/summary_multi_preference_screen"
+ android:fragment="com.example.androidx.preference.TwoPanePreferences$MultiPreferences"/>
+ </PreferenceCategory>
+</PreferenceScreen>
\ No newline at end of file
diff --git a/samples/SupportPreferenceDemos/src/main/res/xml/preferences_advanced_next.xml b/samples/SupportPreferenceDemos/src/main/res/xml/preferences_advanced_next.xml
new file mode 100644
index 0000000..07562e8
--- /dev/null
+++ b/samples/SupportPreferenceDemos/src/main/res/xml/preferences_advanced_next.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ 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.
+ -->
+
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
+ <PreferenceCategory android:title="@string/title_next_preference_screen">
+ <Preference
+ android:key="Information"
+ android:summary="@string/summary_information"
+ android:title="@string/title_information" />
+ <Preference
+ android:key="stylized"
+ android:summary="@string/summary_details"
+ android:title="@string/title_details" />
+ </PreferenceCategory>
+</PreferenceScreen>
\ No newline at end of file
diff --git a/samples/SupportPreferenceDemos/src/main/res/xml/preferences_basic.xml b/samples/SupportPreferenceDemos/src/main/res/xml/preferences_basic.xml
new file mode 100644
index 0000000..ba3be40
--- /dev/null
+++ b/samples/SupportPreferenceDemos/src/main/res/xml/preferences_basic.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ 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.
+ -->
+
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto">
+ <PreferenceCategory android:title="@string/basic_preferences">
+ <Preference
+ android:key="preference"
+ android:summary="@string/summary_basic_preference"
+ android:title="@string/title_basic_preference" />
+ <Preference
+ android:key="stylized"
+ android:summary="@string/summary_stylish_preference"
+ android:title="@string/title_stylish_preference" />
+ <Preference
+ android:icon="@android:drawable/ic_menu_camera"
+ android:key="icon"
+ android:summary="@string/summary_icon_preference"
+ android:title="@string/title_icon_preference" />
+ <Preference
+ android:key="single_line_title"
+ android:summary="@string/summary_single_line_title_preference"
+ android:title="@string/title_single_line_title_preference"
+ app:singleLineTitle="true" />
+ </PreferenceCategory>
+</PreferenceScreen>
\ No newline at end of file
diff --git a/samples/SupportPreferenceDemos/src/main/res/xml/preferences_dialog.xml b/samples/SupportPreferenceDemos/src/main/res/xml/preferences_dialog.xml
new file mode 100644
index 0000000..c39432c
--- /dev/null
+++ b/samples/SupportPreferenceDemos/src/main/res/xml/preferences_dialog.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ 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.
+ -->
+
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto">
+ <PreferenceCategory
+ android:title="@string/dialogs">
+
+ <EditTextPreference
+ android:key="edittext"
+ android:title="@string/title_edittext_preference"
+ app:useSimpleSummaryProvider="true"
+ android:dialogTitle="@string/dialog_title_edittext_preference"/>
+
+ <ListPreference
+ android:key="list"
+ android:title="@string/title_list_preference"
+ app:useSimpleSummaryProvider="true"
+ android:entries="@array/entries"
+ android:entryValues="@array/entry_values"
+ android:dialogTitle="@string/dialog_title_list_preference"/>
+
+ <MultiSelectListPreference
+ android:key="multi_select_list"
+ android:title="@string/title_multi_list_preference"
+ android:summary="@string/summary_multi_list_preference"
+ android:entries="@array/entries"
+ android:entryValues="@array/entry_values"
+ android:dialogTitle="@string/dialog_title_multi_list_preference"/>
+ </PreferenceCategory>
+</PreferenceScreen>
\ No newline at end of file
diff --git a/samples/SupportPreferenceDemos/src/main/res/xml/preferences_widget.xml b/samples/SupportPreferenceDemos/src/main/res/xml/preferences_widget.xml
new file mode 100644
index 0000000..6813927
--- /dev/null
+++ b/samples/SupportPreferenceDemos/src/main/res/xml/preferences_widget.xml
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ 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.
+ -->
+
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto">
+ <PreferenceCategory
+ android:title="@string/widgets">
+
+ <CheckBoxPreference
+ android:key="checkbox"
+ android:title="@string/title_checkbox_preference"
+ android:summary="@string/summary_checkbox_preference"/>
+
+ <SwitchPreferenceCompat
+ android:key="switch"
+ android:title="@string/title_switch_preference"
+ android:summary="@string/summary_switch_preference"/>
+
+ <DropDownPreference
+ android:key="dropdown"
+ android:title="@string/title_dropdown_preference"
+ android:entries="@array/entries"
+ app:useSimpleSummaryProvider="true"
+ android:entryValues="@array/entry_values"/>
+
+ <SeekBarPreference
+ android:key="seekbar"
+ android:title="@string/title_seekbar_preference"
+ android:max="10"
+ android:defaultValue="5"/>
+ </PreferenceCategory>
+</PreferenceScreen>
\ No newline at end of file