diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Label.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Label.kt index 894daf46f61c9..144ed3c7150ed 100644 --- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Label.kt +++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Label.kt @@ -31,13 +31,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned -import kotlinx.coroutines.flow.collectLatest /** * Label component that will append a [label] to [content]. The positioning logic uses @@ -108,22 +108,35 @@ private fun HandleInteractions( interactionSource: MutableInteractionSource, ) { if (enabled) { + // Track all active interactions so that HoverInteraction.Exit during a drag does not + // prematurely dismiss the label. With collectLatest the previous show() coroutine was + // cancelled on every new interaction; if HoverInteraction.Exit arrived while a + // DragInteraction.Start was still active the label disappeared until drag ended. + val activeInteractions = remember { mutableStateListOf() } + val hasActiveInteractions = activeInteractions.isNotEmpty() + LaunchedEffect(interactionSource) { - interactionSource.interactions.collectLatest { interaction -> + interactionSource.interactions.collect { interaction -> when (interaction) { - is PressInteraction.Press, - is DragInteraction.Start, - is HoverInteraction.Enter -> { - state.show(MutatePriority.UserInput) - } - is PressInteraction.Release, - is DragInteraction.Stop, - is HoverInteraction.Exit -> { - state.dismiss() - } + is PressInteraction.Press -> activeInteractions.add(interaction) + is PressInteraction.Release -> activeInteractions.remove(interaction.press) + is PressInteraction.Cancel -> activeInteractions.remove(interaction.press) + is DragInteraction.Start -> activeInteractions.add(interaction) + is DragInteraction.Stop -> activeInteractions.remove(interaction.start) + is DragInteraction.Cancel -> activeInteractions.remove(interaction.start) + is HoverInteraction.Enter -> activeInteractions.add(interaction) + is HoverInteraction.Exit -> activeInteractions.remove(interaction.enter) } } } + + LaunchedEffect(hasActiveInteractions) { + if (hasActiveInteractions) { + state.show(MutatePriority.UserInput) + } else { + state.dismiss() + } + } } } diff --git a/compose/material3/material3/src/skikoTest/kotlin/androidx/compose/material3/LabelTest.kt b/compose/material3/material3/src/skikoTest/kotlin/androidx/compose/material3/LabelTest.kt new file mode 100644 index 0000000000000..c13a642a9e6a8 --- /dev/null +++ b/compose/material3/material3/src/skikoTest/kotlin/androidx/compose/material3/LabelTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2025 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.material3 + +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.HoverInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@OptIn(ExperimentalTestApi::class, ExperimentalMaterial3Api::class) +class LabelTest { + + // Regression test for https://youtrack.jetbrains.com/issue/CMP-8708 + // When a Slider is dragged by its thumb, the thumb's hoverable emits a HoverInteraction.Exit + // as the pointer leaves the thumb bounds *while the drag is still in progress*. The label must + // stay visible as long as a press or drag interaction is still active, instead of being + // dismissed by that mid-drag hover exit. + @Test + fun label_staysVisible_whenHoverExitsWhileStillPressedOrDragging() = runComposeUiTest { + val interactionSource = MutableInteractionSource() + lateinit var scope: CoroutineScope + setContent { + scope = rememberCoroutineScope() + Label( + label = { Text(text = "label", modifier = Modifier.testTag("label")) }, + interactionSource = interactionSource, + ) { + Box(Modifier.size(48.dp).testTag("anchor")) + } + } + + // The label is hidden until the anchor is interacted with. + onNodeWithTag("label").assertDoesNotExist() + + // Emit the exact interaction sequence produced when dragging a Slider thumb: hover the + // thumb, press, start dragging, then exit the hover while the drag is ongoing. + val hoverEnter = HoverInteraction.Enter() + scope.launch { + interactionSource.emit(hoverEnter) + interactionSource.emit(PressInteraction.Press(Offset.Zero)) + interactionSource.emit(DragInteraction.Start()) + interactionSource.emit(HoverInteraction.Exit(hoverEnter)) + } + waitForIdle() + + // A press and a drag are still active, so the label must remain visible. + onNodeWithTag("label").assertIsDisplayed() + } +}