Skip to content

Commit

Permalink
Add throttle for text context menu updates (#1182)
Browse files Browse the repository at this point in the history
## Proposed Changes
Add throttle equals to double tap delay to show or update context menu
inside Text Field.

# Fixes:
- Disappearing items from context menu.
- Sometimes menu shows items that does not correspond to the selected
text.
  • Loading branch information
ASalavei committed Mar 12, 2024
1 parent 8575810 commit f903233
Show file tree
Hide file tree
Showing 3 changed files with 60 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ internal class UIKitTextInputService(
private val updateView: () -> Unit,
private val rootViewProvider: () -> UIView,
private val densityProvider: () -> Density,
private val viewConfiguration: ViewConfiguration,
private val focusStack: FocusStack<UIView>?,
private val keyboardEventHandler: KeyboardEventHandler,
) : PlatformTextInputService, TextToolbar {
Expand Down Expand Up @@ -112,6 +113,7 @@ internal class UIKitTextInputService(
textUIView?.removeFromSuperview()
textUIView = IntermediateTextInputUIView(
keyboardEventHandler = keyboardEventHandler,
viewConfiguration = viewConfiguration
).also {
rootView.addSubview(it)
it.translatesAutoresizingMaskIntoConstraints = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ import androidx.compose.ui.input.pointer.PointerId
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.interop.LocalUIKitInteropContainer
import androidx.compose.ui.interop.LocalUIKitInteropContext
import androidx.compose.ui.interop.UIKitInteropContainer
import androidx.compose.ui.interop.UIKitInteropContext
import androidx.compose.ui.interop.UIKitInteropTransaction
import androidx.compose.ui.node.TrackInteropContainer
import androidx.compose.ui.platform.AccessibilityMediator
import androidx.compose.ui.platform.AccessibilitySyncOptions
import androidx.compose.ui.platform.DefaultInputModeManager
Expand All @@ -51,23 +53,21 @@ import androidx.compose.ui.semantics.SemanticsOwner
import androidx.compose.ui.uikit.ComposeUIViewControllerConfiguration
import androidx.compose.ui.uikit.LocalKeyboardOverlapHeight
import androidx.compose.ui.uikit.systemDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpRect
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.asCGRect
import androidx.compose.ui.unit.asDpOffset
import androidx.compose.ui.unit.asDpRect
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round
import androidx.compose.ui.unit.roundToIntRect
import androidx.compose.ui.unit.toDpRect
import androidx.compose.ui.unit.toOffset
import androidx.compose.ui.window.FocusStack
import androidx.compose.ui.window.InteractionUIView
import androidx.compose.ui.interop.UIKitInteropContainer
import androidx.compose.ui.node.TrackInteropContainer
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.asCGRect
import androidx.compose.ui.unit.toDpRect
import androidx.compose.ui.window.KeyboardEventHandler
import androidx.compose.ui.window.KeyboardVisibilityListenerImpl
import androidx.compose.ui.window.RenderingUIView
Expand Down Expand Up @@ -245,6 +245,15 @@ internal class ComposeSceneMediator(
NSLayoutConstraint.activateConstraints(value)
}

private val viewConfiguration: ViewConfiguration =
object : ViewConfiguration by EmptyViewConfiguration {
override val touchSlop: Float
get() = with(density) {
// this value is originating from iOS 16 drag behavior reverse engineering
10.dp.toPx()
}
}

private val scene: ComposeScene by lazy {
composeSceneFactory(
::onComposeSceneInvalidate,
Expand Down Expand Up @@ -360,6 +369,7 @@ internal class ComposeSceneMediator(
},
rootViewProvider = { container },
densityProvider = { container.systemDensity },
viewConfiguration = viewConfiguration,
focusStack = focusStack,
keyboardEventHandler = keyboardEventHandler
)
Expand Down Expand Up @@ -698,13 +708,7 @@ internal class ComposeSceneMediator(
override fun calculateLocalPosition(positionInWindow: Offset): Offset =
windowContext.calculateLocalPosition(container, positionInWindow)

override val viewConfiguration = object : ViewConfiguration by EmptyViewConfiguration {
override val touchSlop: Float
get() = with(density) {
// this value is originating from iOS 16 drag behavior reverse engineering
10.dp.toPx()
}
}
override val viewConfiguration = this@ComposeSceneMediator.viewConfiguration
override val inputModeManager = DefaultInputModeManager(InputMode.Touch)
override val textInputService = this@ComposeSceneMediator.uiKitTextInputService
override val textToolbar = this@ComposeSceneMediator.uiKitTextInputService
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,16 @@ import androidx.compose.ui.platform.EmptyInputTraits
import androidx.compose.ui.platform.IOSSkikoInput
import androidx.compose.ui.platform.SkikoUITextInputTraits
import androidx.compose.ui.platform.TextActions
import androidx.compose.ui.platform.ViewConfiguration
import kotlinx.cinterop.COpaquePointer
import kotlinx.cinterop.CValue
import kotlinx.cinterop.readValue
import kotlinx.cinterop.useContents
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import platform.CoreGraphics.CGPoint
import platform.CoreGraphics.CGRect
import platform.CoreGraphics.CGRectIntersectsRect
Expand Down Expand Up @@ -74,10 +80,19 @@ import platform.darwin.NSInteger
@Suppress("CONFLICTING_OVERLOADS")
internal class IntermediateTextInputUIView(
private val keyboardEventHandler: KeyboardEventHandler,
private val viewConfiguration: ViewConfiguration
) : UIView(frame = CGRectZero.readValue()),
UIKeyInputProtocol, UITextInputProtocol {
private var menuMonitoringJob = Job()
private var _inputDelegate: UITextInputDelegateProtocol? = null
var input: IOSSkikoInput? = null
set(value) {
field = value
if (value == null) {
cancelContextMenuUpdate()
}
}

private var _currentTextMenuActions: TextActions? = null
var inputTraits: SkikoUITextInputTraits = EmptyInputTraits

Expand Down Expand Up @@ -403,7 +418,7 @@ internal class IntermediateTextInputUIView(
override fun keyboardType(): UIKeyboardType = inputTraits.keyboardType()
override fun keyboardAppearance(): UIKeyboardAppearance = inputTraits.keyboardAppearance()
override fun returnKeyType(): UIReturnKeyType = inputTraits.returnKeyType()
override fun textContentType(): UITextContentType? = inputTraits.textContentType()
override fun textContentType(): UITextContentType = inputTraits.textContentType()
override fun isSecureTextEntry(): Boolean = inputTraits.isSecureTextEntry()
override fun enablesReturnKeyAutomatically(): Boolean =
inputTraits.enablesReturnKeyAutomatically()
Expand Down Expand Up @@ -469,39 +484,52 @@ internal class IntermediateTextInputUIView(
}
}

private fun shouldReloadContextMenuItems(actions: TextActions): Boolean {
return (_currentTextMenuActions?.copy == null) != (actions.copy == null) ||
(_currentTextMenuActions?.paste == null) != (actions.paste == null) ||
(_currentTextMenuActions?.cut == null) != (actions.cut == null) ||
(_currentTextMenuActions?.selectAll == null) != (actions.selectAll == null)
}

private fun cancelContextMenuUpdate() {
menuMonitoringJob.cancel()
menuMonitoringJob = Job()
}

/**
* Show copy/paste text menu
* @param targetRect - rectangle of selected text area
* @param textActions - available (not null) actions in text menu
*/
fun showTextMenu(targetRect: org.jetbrains.skia.Rect, textActions: TextActions) {
_currentTextMenuActions = textActions
val menu: UIMenuController = UIMenuController.sharedMenuController()
val cgRect = CGRectMake(
x = targetRect.left.toDouble(),
y = targetRect.top.toDouble(),
width = targetRect.width.toDouble(),
height = targetRect.height.toDouble()
)
val isTargetVisible = CGRectIntersectsRect(bounds, cgRect)

if (isTargetVisible) {
if (menu.isMenuVisible()) {
menu.setTargetRect(cgRect, this)
} else {
//TODO: UIMenuController.showMenuFromView is Deprecated since iOS 17
// and not available on iOS 12
menu.showMenuFromView(this, cgRect)
}
} else {
if (menu.isMenuVisible()) {
//TODO: UIMenuController.hideMenu is Deprecated since iOS 17
// and not available on iOS 12
// TODO: UIMenuController is deprecated since iOS 17 and not available on iOS 12
val menu: UIMenuController = UIMenuController.sharedMenuController()
if (shouldReloadContextMenuItems(textActions)) {
menu.hideMenu()
}
cancelContextMenuUpdate()
CoroutineScope(Dispatchers.Main + menuMonitoringJob).launch {
delay(viewConfiguration.doubleTapTimeoutMillis)
menu.showMenuFromView(targetView = this@IntermediateTextInputUIView, cgRect)
}
_currentTextMenuActions = textActions
} else {
hideTextMenu()
}
}

fun hideTextMenu() {
cancelContextMenuUpdate()

_currentTextMenuActions = null
val menu: UIMenuController = UIMenuController.sharedMenuController()
menu.hideMenu()
Expand Down

0 comments on commit f903233

Please sign in to comment.