Skip to content

Commit 3c5b1cf

Browse files
authored
Implement click-to-clear-focus (#2533)
1 parent d7a4c15 commit 3c5b1cf

File tree

15 files changed

+353
-32
lines changed

15 files changed

+353
-32
lines changed

compose/foundation/foundation/src/desktopTest/kotlin/androidx/compose/foundation/ScrollbarTest.kt

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.Box
2626
import androidx.compose.foundation.layout.Column
2727
import androidx.compose.foundation.layout.ColumnScope
2828
import androidx.compose.foundation.layout.PaddingValues
29-
import androidx.compose.foundation.layout.Row
3029
import androidx.compose.foundation.layout.fillMaxHeight
3130
import androidx.compose.foundation.layout.fillMaxSize
3231
import androidx.compose.foundation.layout.fillMaxWidth
@@ -52,6 +51,7 @@ import androidx.compose.runtime.getValue
5251
import androidx.compose.runtime.mutableStateOf
5352
import androidx.compose.runtime.remember
5453
import androidx.compose.runtime.setValue
54+
import androidx.compose.ui.Alignment
5555
import androidx.compose.ui.ExperimentalComposeUiApi
5656
import androidx.compose.ui.InternalComposeUiApi
5757
import androidx.compose.ui.Modifier
@@ -866,38 +866,38 @@ class ScrollbarTest {
866866
rule.setContent {
867867
// Set up a text field that is exactly 10 lines tall, with text that has 20 lines,
868868
// Scrollbar thumb should be 50.dp -- half the scrollbar height
869-
Row{
870-
Box(
871-
modifier = Modifier.width(100.dp)
872-
){
873-
var text by remember {
874-
mutableStateOf(
875-
TextFieldValue(
876-
buildString {
877-
repeat(19) { // 20 lines including the last empty one
878-
append("A\n")
879-
}
869+
Box(
870+
modifier = Modifier.width(100.dp)
871+
) {
872+
var text by remember {
873+
mutableStateOf(
874+
TextFieldValue(
875+
buildString {
876+
repeat(19) { // 20 lines including the last empty one
877+
append("A\n")
880878
}
881-
)
879+
}
882880
)
883-
}
884-
BasicTextField(
885-
value = text,
886-
onValueChange = {
887-
text = it
888-
},
889-
scrollState = scrollState,
890-
maxLines = 10, // Make sure not to give the text field any pixel height
891-
modifier = Modifier
892-
.testTag("textfield"),
893881
)
894882
}
883+
BasicTextField(
884+
value = text,
885+
onValueChange = {
886+
text = it
887+
},
888+
scrollState = scrollState,
889+
maxLines = 10, // Make sure not to give the text field any pixel height
890+
modifier = Modifier
891+
.fillMaxWidth()
892+
.testTag("textfield"),
893+
)
895894

896895
VerticalScrollbar(
897896
adapter = rememberScrollbarAdapter(scrollState),
898897
modifier = Modifier
899898
.width(10.dp)
900899
.height(100.dp)
900+
.align(Alignment.CenterEnd)
901901
.testTag("scrollbar")
902902
)
903903
}

compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/ClickableFocusTest.kt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,23 @@ import androidx.compose.foundation.interaction.FocusInteraction
2020
import androidx.compose.foundation.interaction.InteractionSource
2121
import androidx.compose.foundation.layout.Box
2222
import androidx.compose.foundation.layout.Column
23+
import androidx.compose.foundation.layout.fillMaxWidth
2324
import androidx.compose.foundation.layout.size
2425
import androidx.compose.foundation.selection.selectable
2526
import androidx.compose.foundation.selection.toggleable
27+
import androidx.compose.foundation.text.BasicTextField
28+
import androidx.compose.foundation.text.input.rememberTextFieldState
2629
import androidx.compose.runtime.Composable
2730
import androidx.compose.runtime.CompositionLocalProvider
31+
import androidx.compose.runtime.LaunchedEffect
2832
import androidx.compose.runtime.RecomposeScope
2933
import androidx.compose.runtime.currentRecomposeScope
3034
import androidx.compose.runtime.getValue
3135
import androidx.compose.runtime.mutableStateOf
3236
import androidx.compose.runtime.setValue
3337
import androidx.compose.ui.Modifier
38+
import androidx.compose.ui.focus.FocusRequester
39+
import androidx.compose.ui.focus.focusRequester
3440
import androidx.compose.ui.graphics.Color
3541
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
3642
import androidx.compose.ui.input.key.Key
@@ -39,10 +45,14 @@ import androidx.compose.ui.node.DrawModifierNode
3945
import androidx.compose.ui.platform.testTag
4046
import androidx.compose.ui.test.ExperimentalTestApi
4147
import androidx.compose.ui.test.assertIsFocused
48+
import androidx.compose.ui.test.assertIsNotFocused
49+
import androidx.compose.ui.test.click
50+
import androidx.compose.ui.test.isFocused
4251
import androidx.compose.ui.test.onNodeWithTag
4352
import androidx.compose.ui.test.onRoot
4453
import androidx.compose.ui.test.performClick
4554
import androidx.compose.ui.test.performKeyInput
55+
import androidx.compose.ui.test.performMouseInput
4656
import androidx.compose.ui.test.pressKey
4757
import androidx.compose.ui.test.requestFocus
4858
import androidx.compose.ui.test.runComposeUiTest
@@ -260,4 +270,28 @@ class ClickableFocusTest {
260270
override fun hashCode() = super.hashCode()
261271
override fun equals(other: Any?) = super.equals(other)
262272
}
273+
274+
@Test
275+
fun mouseClickOutsideClearsFocus() = runComposeUiTest {
276+
val focusRequester = FocusRequester()
277+
setContent {
278+
Column(Modifier.size(300.dp, 400.dp)) {
279+
BasicTextField(
280+
state = rememberTextFieldState(),
281+
modifier = Modifier
282+
.testTag("textField")
283+
.focusRequester(focusRequester)
284+
)
285+
LaunchedEffect(Unit) {
286+
focusRequester.requestFocus()
287+
}
288+
Box(Modifier.testTag("box").fillMaxWidth().weight(1f))
289+
}
290+
}
291+
292+
onNodeWithTag("textField").assertIsFocused()
293+
onNodeWithTag("box").performMouseInput { click() }
294+
onNodeWithTag("textField").assertIsNotFocused()
295+
onNode(isFocused()).assertDoesNotExist()
296+
}
263297
}

compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/ComposeDialog.desktop.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,14 @@ class ComposeDialog : JDialog {
152152
get() = composePanel.rootForTestListener
153153
set(value) { composePanel.rootForTestListener = value }
154154

155+
/**
156+
* Controls whether mouse-down on an unfocusable element clears focus.
157+
*/
158+
@ExperimentalComposeUiApi
159+
var isClearFocusOnMouseDownEnabled: Boolean
160+
get() = composePanel.isClearFocusOnMouseDownEnabled
161+
set(value) { composePanel.isClearFocusOnMouseDownEnabled = value }
162+
155163
private val undecoratedWindowResizer = UndecoratedWindowResizer(this)
156164

157165
override fun add(component: Component) = composePanel.add(component)

compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/ComposePanel.desktop.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ package androidx.compose.ui.awt
1818

1919
import androidx.compose.runtime.Composable
2020
import androidx.compose.ui.ComposeFeatureFlags
21+
import androidx.compose.ui.ComposeUiFlags
2122
import androidx.compose.ui.ExperimentalComposeUiApi
2223
import androidx.compose.ui.LayerType
2324
import androidx.compose.ui.awt.RenderSettings.SkiaSurface
2425
import androidx.compose.ui.awt.RenderSettings.SwingGraphics
2526
import androidx.compose.ui.focus.FocusDirection
27+
import androidx.compose.ui.isClearFocusOnMouseDownEnabled
2628
import androidx.compose.ui.scene.ComposeContainer
2729
import androidx.compose.ui.semantics.SemanticsOwner
2830
import androidx.compose.ui.window.WindowExceptionHandler
@@ -120,6 +122,16 @@ class ComposePanel @ExperimentalComposeUiApi constructor(
120122
private var _composeContainer: ComposeContainer? = null
121123
private var _composeContent: (@Composable () -> Unit)? = null
122124

125+
/**
126+
* Controls whether mouse-down on an unfocusable element clears focus.
127+
*/
128+
@ExperimentalComposeUiApi
129+
var isClearFocusOnMouseDownEnabled: Boolean = ComposeUiFlags.isClearFocusOnMouseDownEnabled
130+
set(value) {
131+
field = value
132+
_composeContainer?.isClearFocusOnMouseDownEnabled = value
133+
}
134+
123135
/**
124136
* Determines whether the Compose state in [ComposePanel] should be disposed
125137
* when panel is detached from Swing hierarchy (when [removeNotify] is called).
@@ -279,6 +291,8 @@ class ComposePanel @ExperimentalComposeUiApi constructor(
279291

280292
override fun focusLost(e: FocusEvent) = Unit
281293
})
294+
295+
isClearFocusOnMouseDownEnabled = this@ComposePanel.isClearFocusOnMouseDownEnabled
282296
}
283297
}
284298

compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/ComposeWindow.desktop.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ class ComposeWindow @ExperimentalComposeUiApi constructor(
9393
val semanticsOwners: Collection<SemanticsOwner>
9494
get() = composePanel.semanticsOwners
9595

96+
/**
97+
* Controls whether mouse-down on an unfocusable element clears focus.
98+
*/
99+
@ExperimentalComposeUiApi
100+
var isClearFocusOnMouseDownEnabled: Boolean by composePanel::isClearFocusOnMouseDownEnabled
101+
96102
init {
97103
contentPane.add(composePanel)
98104
}

compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/ComposeWindowPanel.desktop.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ internal class ComposeWindowPanel(
8181
val renderApi by composeContainer::renderApi
8282
val semanticsOwners by composeContainer::semanticsOwners
8383

84+
var isClearFocusOnMouseDownEnabled: Boolean by composeContainer::isClearFocusOnMouseDownEnabled
85+
8486
var isWindowTransparent: Boolean = false
8587
set(value) {
8688
if (field != value) {

compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeContainer.desktop.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ internal class ComposeContainer(
181181
private var isMinimized = false
182182
private var isFocused = false
183183

184+
var isClearFocusOnMouseDownEnabled by mediator::isClearFocusOnMouseDownEnabled
185+
184186
init {
185187
architectureComponentsOwner.enableSavedStateHandles()
186188
setWindow(window)

compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.desktop.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable
2020
import androidx.compose.runtime.CompositionLocalContext
2121
import androidx.compose.runtime.mutableStateSetOf
2222
import androidx.compose.ui.ComposeFeatureFlags
23+
import androidx.compose.ui.ComposeUiFlags
2324
import androidx.compose.ui.awt.AwtEventListener
2425
import androidx.compose.ui.awt.AwtEventListeners
2526
import androidx.compose.ui.awt.DebouncingEdtExecutor
@@ -42,6 +43,7 @@ import androidx.compose.ui.input.pointer.PointerEventType
4243
import androidx.compose.ui.input.pointer.PointerIcon
4344
import androidx.compose.ui.input.pointer.PointerKeyboardModifiers
4445
import androidx.compose.ui.input.pointer.PointerType
46+
import androidx.compose.ui.isClearFocusOnMouseDownEnabled
4547
import androidx.compose.ui.navigationevent.BackNavigationEventInput
4648
import androidx.compose.ui.platform.AwtDragAndDropManager
4749
import androidx.compose.ui.platform.DefaultInputModeManager
@@ -367,6 +369,8 @@ internal class ComposeSceneMediator(
367369

368370
private val composeInvalidationExecutor = DebouncingEdtExecutor()
369371

372+
var isClearFocusOnMouseDownEnabled: Boolean = ComposeUiFlags.isClearFocusOnMouseDownEnabled
373+
370374
init {
371375
// Transparency is used during redrawer creation that triggered by [addNotify], so
372376
// it must be set to correct value before adding to the hierarchy to handle cases
@@ -828,6 +832,8 @@ internal class ComposeSceneMediator(
828832
get() = this@ComposeSceneMediator.rootForTestListener
829833
override val semanticsOwnerListener
830834
get() = this@ComposeSceneMediator.semanticsOwnerListener
835+
override val isClearFocusOnMouseDownEnabled: Boolean
836+
get() = this@ComposeSceneMediator.isClearFocusOnMouseDownEnabled
831837
}
832838

833839
private inner class DesktopPlatformComponent : PlatformComponent {

compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/awt/ComposePanelTest.kt

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.requiredSize
2626
import androidx.compose.foundation.layout.size
2727
import androidx.compose.foundation.layout.sizeIn
2828
import androidx.compose.foundation.lazy.LazyColumn
29+
import androidx.compose.foundation.text.BasicTextField
2930
import androidx.compose.foundation.text.input.rememberTextFieldState
3031
import androidx.compose.foundation.verticalScroll
3132
import androidx.compose.material.Text
@@ -52,9 +53,12 @@ import androidx.compose.ui.input.key.onKeyEvent
5253
import androidx.compose.ui.input.pointer.PointerEventType
5354
import androidx.compose.ui.input.pointer.onPointerEvent
5455
import androidx.compose.ui.layout.onGloballyPositioned
56+
import androidx.compose.ui.platform.testTag
5557
import androidx.compose.ui.sendCharTypedEvents
5658
import androidx.compose.ui.sendKeyEvent
5759
import androidx.compose.ui.sendMouseEvent
60+
import androidx.compose.ui.sendMousePress
61+
import androidx.compose.ui.sendMouseRelease
5862
import androidx.compose.ui.sendMouseWheelEvent
5963
import androidx.compose.ui.unit.dp
6064
import androidx.compose.ui.unit.toSize
@@ -809,4 +813,56 @@ class ComposePanelTest {
809813
}
810814
}
811815
}
816+
817+
@Test
818+
fun testComposePanelClearFocusOnMouseDownEnabled() =
819+
testComposePanelClearFocusOnMouseDownEnabledFlag(true)
820+
821+
@Test
822+
fun testComposePanelClearFocusOnMouseDownDisabled() =
823+
testComposePanelClearFocusOnMouseDownEnabledFlag(false)
824+
825+
fun testComposePanelClearFocusOnMouseDownEnabledFlag(enabled: Boolean) = runApplicationTest {
826+
val focusRequester = FocusRequester()
827+
var textFieldIsFocused = false
828+
829+
val window = JFrame()
830+
try {
831+
window.contentPane.add(ComposePanel().apply {
832+
isClearFocusOnMouseDownEnabled = enabled
833+
setContent {
834+
Column(Modifier.size(300.dp, 400.dp)) {
835+
BasicTextField(
836+
state = rememberTextFieldState(),
837+
modifier = Modifier
838+
.testTag("textField")
839+
.fillMaxWidth()
840+
.height(100.dp)
841+
.focusRequester(focusRequester)
842+
.onFocusChanged {
843+
textFieldIsFocused = it.isFocused
844+
}
845+
)
846+
LaunchedEffect(Unit) {
847+
focusRequester.requestFocus()
848+
}
849+
Box(Modifier.testTag("box").fillMaxWidth().weight(1f))
850+
}
851+
}
852+
})
853+
window.size = Dimension(300, 400)
854+
window.isVisible = true
855+
856+
awaitIdle()
857+
858+
assertThat(textFieldIsFocused).isTrue()
859+
window.sendMousePress(x = 100, y = 300)
860+
window.sendMouseRelease(x = 100, y = 300)
861+
awaitIdle()
862+
863+
assertThat(textFieldIsFocused).isEqualTo(!enabled)
864+
} finally {
865+
window.dispose()
866+
}
867+
}
812868
}

0 commit comments

Comments
 (0)