package nl.achmea.mobile.components.utilities import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.DisposableEffect 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.Modifier import androidx.compose.ui.composed import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.IntSize import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.useContents import kotlinx.coroutines.launch import platform.Foundation.NSNotificationCenter import platform.Foundation.valueForKey import platform.UIKit.UIFocusDidUpdateNotification import platform.UIKit.UIFocusUpdateContext import platform.UIKit.UIFocusUpdateContextKey import platform.UIKit.UIScreen import platform.darwin.NSObject import kotlin.random.Random @OptIn(ExperimentalForeignApi::class) public actual fun Modifier.iosKeyboardFocusable(): Modifier = this.composed { val focusId = remember { "iosFocusableFix_${Random.nextInt()}" } val bringIntoViewRequester = remember { BringIntoViewRequester() } val coroutineScope = rememberCoroutineScope() var elementSize by remember { mutableStateOf(IntSize.Zero) } val screenHeightPx = (UIScreen.mainScreen.bounds.useContents { size.height } * UIScreen.mainScreen.scale).toFloat() // For as long as the element is visible, observe focus changes // and act if a focus event happened for this element, so we can put it in the middle of the screen so // we ensure the next item is also visible // Note: this results in 1 listener per item on the screen, could be improved by limiting ourselves to just 1 observer // and using a singleton pattern of some sorts to reuse the observer. However with a handful of elements on the screen // this is probably not that big of a bottleneck. DisposableEffect(focusId) { val observer = NSNotificationCenter.defaultCenter.addObserverForName( name = UIFocusDidUpdateNotification, `object` = null, queue = null, ) { notification -> // When a notification happens for a focus event, we haven't linked them to the composable function. // So in this case we read the accessibilityIdentifier from UIKit, which aligns with testTag from compose, // and we have given the testTag a unique focusId so we can check if focus is brought to "this" component. // by comparing them. val userInfo = notification?.userInfo ?: return@addObserverForName val focusContext = userInfo[UIFocusUpdateContextKey] as? UIFocusUpdateContext ?: return@addObserverForName val focusedElement = focusContext.nextFocusedItem ?: return@addObserverForName val identifier = getAccessibilityIdentifier(focusedElement) if (identifier == focusId) { coroutineScope.launch { // This can be improved further, I originally wanted to add some configurable padding to the traversing // direction, so the next item was "just about" visible, but enough to be selectable by the keyboard // but that didn't work, in that case the view almost didn't move until i made the padding very high. // Is it a bug in bringIntoView? perhaps, so instead i opted for a hard place in the middle approach. val spaceAbove = (screenHeightPx - elementSize.height) / 2f val rect = Rect( left = 0f, top = -spaceAbove, right = elementSize.width.toFloat(), bottom = elementSize.height + spaceAbove, ) bringIntoViewRequester.bringIntoView(rect) } } } onDispose { NSNotificationCenter.defaultCenter.removeObserver(observer) } } Modifier .testTag(focusId) .bringIntoViewRequester(bringIntoViewRequester) .onSizeChanged { elementSize = it } } /** * Read accessibilityIdentifier via KVC. Compose Multiplatform maps * Modifier.testTag() to accessibilityIdentifier on iOS. */ private fun getAccessibilityIdentifier(item: Any): String? { return try { (item as? NSObject)?.valueForKey("accessibilityIdentifier") as? String } catch (_: Exception) { null } }