Skip to content

Commit

Permalink
Handle key events at a platform level instead of Modifier
Browse files Browse the repository at this point in the history
This is done so that we can track key events without the requirement of having a component focused. This is needed because when the Popup is displayed, no component have focus and we need to focus on the first item when KEY_DOWN is pressed.
  • Loading branch information
alexstyl committed May 2, 2024
1 parent 95819b5 commit 7e0e929
Show file tree
Hide file tree
Showing 8 changed files with 154 additions and 64 deletions.
2 changes: 1 addition & 1 deletion demo/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<uses-permission android:name="android.permission.INTERNET"/>

<application>
<activity android:name="MainActivity" android:exported="true">
<activity android:name="com.composables.ui.MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
Expand Down
11 changes: 5 additions & 6 deletions demo/src/commonMain/kotlin/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,14 @@ import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.composables.ui.Menu
import com.composables.ui.MenuButton
import com.composables.ui.MenuContent
import com.composables.ui.MenuItem

@Composable
fun App() {
Box(Modifier.fillMaxSize().background(Brush.linearGradient(listOf(Color(0xFFFBDA61), Color(0xFFFFC371)))).padding(vertical = 40.dp, horizontal = 12.dp),
contentAlignment = Alignment.TopCenter) {
Box(
Modifier.fillMaxSize().background(Brush.linearGradient(listOf(Color(0xFFFBDA61), Color(0xFFFFC371))))
.padding(vertical = 40.dp, horizontal = 12.dp),
contentAlignment = Alignment.TopCenter
) {
Menu {
MenuButton(Modifier.clip(RoundedCornerShape(6.dp)).background(Color.White)) {
Row(
Expand Down
25 changes: 25 additions & 0 deletions menu/src/androidMain/kotlin/Utils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import android.view.KeyEvent
import android.view.View.OnKeyListener
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.LocalView

@Composable
internal actual fun KeyDownHandler(onEvent: (KeyDownEvent) -> Boolean) {
val view = LocalView.current

DisposableEffect(Unit) {
val listener = OnKeyListener { _, keyCode, event ->
if (event.action == KeyEvent.ACTION_DOWN) {
onEvent(KeyDownEvent(Key(keyCode)))
} else {
false
}
}
view.setOnKeyListener(listener)
onDispose {
view.setOnKeyListener(null)
}
}
}
5 changes: 5 additions & 0 deletions menu/src/appleMain/kotlin/Utils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import androidx.compose.runtime.Composable

@Composable
internal actual fun KeyDownHandler(onEvent: (KeyDownEvent) -> Boolean) {
}
114 changes: 57 additions & 57 deletions menu/src/commonMain/kotlin/Menu.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
package com.composables.ui

import androidx.compose.animation.*
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
Expand All @@ -9,10 +7,10 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.*
import androidx.compose.ui.input.key.*
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.semantics.Role
Expand All @@ -23,49 +21,61 @@ import androidx.compose.ui.window.PopupProperties
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun Menu(modifier: Modifier = Modifier, contents: @Composable MenuScope.() -> Unit) {
val scope = rememberSaveable { MenuScope() }
val scope = remember { MenuScope() }
val coroutineScope = rememberCoroutineScope()
var focusManager: FocusManager? by mutableStateOf(null)
focusManager = LocalFocusManager.current

var hasFocus by remember { mutableStateOf(false) }

if (hasFocus) {
KeyDownHandler { event ->
when (event.key) {
Key.DirectionDown -> {
if (scope.expanded.not()) {
scope.expanded = true
coroutineScope.launch {
// wait for the Popup to be displayed.
// There is no official API to wait for this to happen
delay(50)
scope.menuFocusRequester.requestFocus()
scope.currentFocusManager?.moveFocus(FocusDirection.Enter)
}
true
} else {
if (scope.hasMenuFocus.not()) {
scope.menuFocusRequester.requestFocus()
scope.currentFocusManager?.moveFocus(FocusDirection.Enter)
} else
scope.currentFocusManager?.moveFocus(FocusDirection.Next)
true
}
}

Box(modifier.onKeyEvent { keyEvent ->
if (keyEvent.type != KeyEventType.KeyDown) {
return@onKeyEvent false
}
Key.DirectionUp -> {
scope.currentFocusManager?.moveFocus(FocusDirection.Previous)
true
}

return@onKeyEvent when (keyEvent.key) {
Key.DirectionDown -> {
if (scope.expanded.not()) {
scope.expanded = true
coroutineScope.launch {
// wait for the Popup to be displayed.
// There is no official API to wait for this to happen
delay(50)
scope.menuFocusRequester.requestFocus()
}
Key.Escape -> {
scope.expanded = false
scope.currentFocusManager?.clearFocus()
true
} else {
focusManager?.moveFocus(FocusDirection.Next)
false
}
}

Key.Escape -> {
scope.expanded = false
focusManager?.clearFocus()
true
else -> false
}

else -> false
}
}
Box(modifier.onFocusChanged {
hasFocus = it.hasFocus
}) {
scope.currentFocusManager = LocalFocusManager.current
scope.contents()
}
}


@Composable
fun MenuScope.MenuButton(modifier: Modifier = Modifier, contents: @Composable () -> Unit) {
Box(modifier = modifier.clickable(role = Role.DropdownList) { this.expanded = expanded.not() }) {
Expand All @@ -77,7 +87,8 @@ fun MenuScope.MenuButton(modifier: Modifier = Modifier, contents: @Composable ()
class MenuScope {
internal var expanded by mutableStateOf(false)
internal val menuFocusRequester = FocusRequester()

internal var currentFocusManager by mutableStateOf<FocusManager?>(null)
internal var hasMenuFocus by mutableStateOf<Boolean>(false)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
Expand All @@ -86,13 +97,17 @@ class MenuScope {

if (expanded != other.expanded) return false
if (menuFocusRequester != other.menuFocusRequester) return false
if (currentFocusManager != other.currentFocusManager) return false
if (hasMenuFocus != other.hasMenuFocus) return false

return true
}

override fun hashCode(): Int {
var result = expanded.hashCode()
result = 31 * result + menuFocusRequester.hashCode()
result = 31 * result + (currentFocusManager?.hashCode() ?: 0)
result = 31 * result + hasMenuFocus.hashCode()
return result
}
}
Expand Down Expand Up @@ -168,44 +183,28 @@ fun MenuScope.MenuContent(
val popupPositionProvider = DropdownMenuPositionProvider(offset, density)
val expandedState = remember { MutableTransitionState(false) }
expandedState.targetState = expanded
var focusManager by remember { mutableStateOf<FocusManager?>(null) }
focusManager = LocalFocusManager.current
currentFocusManager = LocalFocusManager.current

if (expandedState.currentState || expandedState.targetState || !expandedState.isIdle) {
val groupRequester = remember { FocusRequester() }
Popup(
properties = PopupProperties(focusable = true, dismissOnBackPress = true),
properties = PopupProperties(focusable = true, dismissOnBackPress = true, dismissOnClickOutside = true),
onDismissRequest = {
expanded = false
focusManager?.clearFocus()
currentFocusManager?.clearFocus()
},
popupPositionProvider = popupPositionProvider
) {
focusManager = LocalFocusManager.current
currentFocusManager = LocalFocusManager.current
AnimatedVisibility(
visibleState = expandedState,
enter = showTransition,
exit = hideTransition,
modifier = Modifier.focusRequester(groupRequester)
modifier = Modifier.focusRequester(groupRequester).onFocusChanged {
hasMenuFocus = it.hasFocus
}
) {
Column(modifier.focusRequester(menuFocusRequester)
.onKeyEvent {
if (it.type == KeyEventType.KeyDown) {
when (it.key) {
Key.DirectionDown -> {
focusManager?.moveFocus(FocusDirection.Down)
return@onKeyEvent true
}

Key.DirectionUp -> {
focusManager?.moveFocus(FocusDirection.Up)
return@onKeyEvent true
}
}
}
false
}
) {
Column(modifier.focusRequester(menuFocusRequester)) {
contents()
}
}
Expand All @@ -229,6 +228,7 @@ fun MenuScope.MenuItem(
onClick = {
onClick()
expanded = false
currentFocusManager?.clearFocus()
},
indication = LocalIndication.current
)
Expand Down
7 changes: 7 additions & 0 deletions menu/src/commonMain/kotlin/Utils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import androidx.compose.runtime.Composable
import androidx.compose.ui.input.key.Key

internal data class KeyDownEvent(val key: Key)

@Composable
internal expect fun KeyDownHandler(onEvent: (KeyDownEvent) -> Boolean)
25 changes: 25 additions & 0 deletions menu/src/jvmMain/kotlin/Utils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.input.key.Key
import java.awt.KeyEventDispatcher
import java.awt.KeyboardFocusManager
import java.awt.event.KeyEvent

@Composable
internal actual fun KeyDownHandler(onEvent: (KeyDownEvent) -> Boolean) {
DisposableEffect(Unit) {
val dispatcher = KeyEventDispatcher { keyEvent ->
if (keyEvent.id == KeyEvent.KEY_PRESSED) {
val keyDownEvent = KeyDownEvent(Key(keyEvent.keyCode))
onEvent(keyDownEvent)
} else {
false
}
}
val keyboardFocusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager()
keyboardFocusManager.addKeyEventDispatcher(dispatcher)
onDispose {
keyboardFocusManager.removeKeyEventDispatcher(dispatcher)
}
}
}
29 changes: 29 additions & 0 deletions menu/src/wasmJsMain/kotlin/Utils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.input.key.Key
import kotlinx.browser.document
import org.w3c.dom.events.Event
import org.w3c.dom.events.KeyboardEvent

@Composable
internal actual fun KeyDownHandler(onEvent: (KeyDownEvent) -> Boolean) {
DisposableEffect(Unit) {
// workaround until KT-64565 is fixed: https://youtrack.jetbrains.com/issue/KT-64565/Kotlin-wasm-removeEventListener-function-did-not-remove-the-event-listener
var isDisabled = false

val listener: (Event) -> Unit = { e ->
if (isDisabled.not()) {
val keyboardEvent = e as KeyboardEvent
val result = onEvent(KeyDownEvent(Key(keyboardEvent.keyCode.toLong())))
if (result) {
e.preventDefault()
}
}
}
document.addEventListener("keydown", listener, true)
onDispose {
document.removeEventListener("keydown", listener, true)
isDisabled = true
}
}
}

0 comments on commit 7e0e929

Please sign in to comment.