Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions Sources/SkipUI/Skip/HitTesting.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright 2025 Skip
// SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
package skip.ui

import androidx.compose.ui.Modifier
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.node.PointerInputModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.unit.IntSize

public fun Modifier.skipHitTesting(enabled: Boolean): Modifier {
return if (enabled) this else this then SkipHitTestingElement
}

private object SkipHitTestingElement : ModifierNodeElement<SkipHitTestingNode>() {
override fun equals(other: Any?): Boolean {
return other === this
}

override fun hashCode(): Int {
return javaClass.hashCode()
}

override fun create(): SkipHitTestingNode {
return SkipHitTestingNode()
}

override fun update(node: SkipHitTestingNode) {
}

override fun InspectorInfo.inspectableProperties() {
name = "skipHitTesting"
properties["enabled"] = false
}
}

@OptIn(ExperimentalComposeUiApi::class)
private class SkipHitTestingNode : Modifier.Node(), PointerInputModifierNode {
override fun onPointerEvent(pointerEvent: PointerEvent, pass: PointerEventPass, bounds: IntSize) {
// Intentionally do nothing. Gesture handlers are disabled in SkipUI when
// allowsHitTesting(false) is active.
//android.util.Log.d("SkipUI-HitTesting", "skipHitTesting onPointerEvent pass=$pass pointers=${pointerEvent.changes.size}")
}

override fun onCancelPointerInput() {
//android.util.Log.d("SkipUI-HitTesting", "skipHitTesting onCancelPointerInput")
}

override fun sharePointerInputWithSiblings(): Boolean {
return true
}
}
63 changes: 35 additions & 28 deletions Sources/SkipUI/SkipUI/Commands/ContextMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class ContextMenuModifier: RenderModifier {
let nestedMenu = remember { mutableStateOf<Menu?>(nil) }
let coroutineScope = rememberCoroutineScope()
let contentContext = context.content()
let isContextMenuEnabled = EnvironmentValues.shared.isEnabled && EnvironmentValues.shared._isHitTestingEnabled
let replaceMenu: (Menu?) -> Void = { menu in
coroutineScope.launch {
delay(200)
Expand All @@ -47,39 +48,45 @@ class ContextMenuModifier: RenderModifier {
}
}
ComposeContainer(eraseAxis: true, modifier: context.modifier) { modifier in
// Use pointerInput on the Initial pass to detect long press without consuming
// events, so that child clickable handlers (e.g. Buttons) still receive taps.
// Standard APIs like combinedClickable consume the down event on the Main pass,
// which prevents nested clickables from firing.
Box(modifier: modifier.pointerInput(true) {
let slop = viewConfiguration.touchSlop
awaitEachGesture {
let down = awaitPointerEvent(pass: PointerEventPass.Initial)
if let start = down.changes.firstOrNull({ $0.pressed })?.position {
let longPressed = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
var active = true
while active {
if let c = awaitPointerEvent(pass: PointerEventPass.Initial).changes.firstOrNull() {
active = c.pressed && abs(c.position.x - start.x) <= slop && abs(c.position.y - start.y) <= slop
} else {
active = false
let interactionModifier: Modifier
if isContextMenuEnabled {
// Use pointerInput on the Initial pass to detect long press without consuming
// events, so that child clickable handlers (e.g. Buttons) still receive taps.
// Standard APIs like combinedClickable consume the down event on the Main pass,
// which prevents nested clickables from firing.
interactionModifier = modifier.pointerInput(true) {
let slop = viewConfiguration.touchSlop
awaitEachGesture {
let down = awaitPointerEvent(pass: PointerEventPass.Initial)
if let start = down.changes.firstOrNull({ $0.pressed })?.position {
let longPressed = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
var active = true
while active {
if let c = awaitPointerEvent(pass: PointerEventPass.Initial).changes.firstOrNull() {
active = c.pressed && abs(c.position.x - start.x) <= slop && abs(c.position.y - start.y) <= slop
} else {
active = false
}
}
} == nil
if longPressed {
isMenuExpanded.value = true
// Consume remaining pointer events so the child's tap handler
// does not fire when the finger lifts
var pressed = true
while pressed {
let event = awaitPointerEvent(pass: PointerEventPass.Initial)
event.changes.forEach { $0.consume() }
pressed = event.changes.any({ $0.pressed })
}
}
} == nil
if longPressed {
isMenuExpanded.value = true
// Consume remaining pointer events so the child's tap handler
// does not fire when the finger lifts
var pressed = true
while pressed {
let event = awaitPointerEvent(pass: PointerEventPass.Initial)
event.changes.forEach { $0.consume() }
pressed = event.changes.any({ $0.pressed })
}
}
}
}
}) {
} else {
interactionModifier = modifier
}
Box(modifier: interactionModifier) {
content.Render(context: contentContext)
DropdownMenu(expanded: isMenuExpanded.value, onDismissRequest: {
isMenuExpanded.value = false
Expand Down
8 changes: 5 additions & 3 deletions Sources/SkipUI/SkipUI/Controls/Button.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ public struct Button : View, Renderable {
/// Render a button in the current style.
@Composable static func RenderButton(label: View, context: ComposeContext, role: ButtonRole? = nil, isEnabled: Bool = EnvironmentValues.shared.isEnabled, action: () -> Void) {
let buttonStyle = EnvironmentValues.shared._buttonStyle
let isHitTestingEnabled = EnvironmentValues.shared._isHitTestingEnabled
ComposeContainer(modifier: context.modifier) { modifier in
switch buttonStyle {
case .bordered:
Expand All @@ -142,7 +143,7 @@ public struct Button : View, Renderable {
} else {
colors = ButtonDefaults.filledTonalButtonColors()
}
var options = Material3ButtonOptions(onClick: action, modifier: modifier, enabled: isEnabled, shape: ButtonDefaults.filledTonalShape, colors: colors, elevation: ButtonDefaults.filledTonalButtonElevation())
var options = Material3ButtonOptions(onClick: action, modifier: modifier, enabled: isEnabled && isHitTestingEnabled, shape: ButtonDefaults.filledTonalShape, colors: colors, elevation: ButtonDefaults.filledTonalButtonElevation())
if let updateOptions = EnvironmentValues.shared._material3Button {
options = updateOptions(options)
}
Expand Down Expand Up @@ -180,7 +181,7 @@ public struct Button : View, Renderable {
} else {
colors = ButtonDefaults.buttonColors()
}
var options = Material3ButtonOptions(onClick: action, modifier: modifier, enabled: isEnabled, shape: ButtonDefaults.shape, colors: colors, elevation: ButtonDefaults.buttonElevation())
var options = Material3ButtonOptions(onClick: action, modifier: modifier, enabled: isEnabled && isHitTestingEnabled, shape: ButtonDefaults.shape, colors: colors, elevation: ButtonDefaults.buttonElevation())
if let updateOptions = EnvironmentValues.shared._material3Button {
options = updateOptions(options)
}
Expand All @@ -207,6 +208,7 @@ public struct Button : View, Renderable {
/// - Parameters:
/// - action: Pass nil if the given modifier already includes `clickable`
@Composable static func RenderTextButton(label: View, context: ComposeContext, role: ButtonRole? = nil, isPlain: Bool = false, isEnabled: Bool = EnvironmentValues.shared.isEnabled, action: (() -> Void)? = nil) {
let isHitTestingEnabled = EnvironmentValues.shared._isHitTestingEnabled
var foregroundStyle: ShapeStyle
if role == .destructive {
foregroundStyle = Color(colorImpl: { MaterialTheme.colorScheme.error })
Expand All @@ -219,7 +221,7 @@ public struct Button : View, Renderable {
}

var modifier = context.modifier
if let action {
if let action, isHitTestingEnabled {
modifier = modifier.clickable(onClick: action, enabled: isEnabled)
}
let contentContext = context.content(modifier: modifier)
Expand Down
5 changes: 5 additions & 0 deletions Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,11 @@ extension EnvironmentValues {
set { setBuiltinValue(key: "_isNavigationRoot", value: newValue, defaultValue: { nil }) }
}

var _isHitTestingEnabled: Bool {
get { builtinValue(key: "_isHitTestingEnabled", defaultValue: { true }) as! Bool }
set { setBuiltinValue(key: "_isHitTestingEnabled", value: newValue, defaultValue: { true }) }
}

var _isSearching: MutableState<Bool>? {
get { builtinValue(key: "_isSearching", defaultValue: { nil }) as! MutableState<Bool>? }
set { setBuiltinValue(key: "_isSearching", value: newValue, defaultValue: { nil }) }
Expand Down
3 changes: 3 additions & 0 deletions Sources/SkipUI/SkipUI/System/Gesture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,9 @@ final class GestureModifier: RenderModifier {
guard EnvironmentValues.shared.isEnabled else {
return modifier
}
guard EnvironmentValues.shared._isHitTestingEnabled else {
return modifier
}

// Compose wants you to collect all e.g. tap gestures into a single pointerInput modifier, so we collect all our gestures
let gestures: kotlin.collections.MutableList<ModifiedGesture<Any>> = mutableListOf()
Expand Down
17 changes: 10 additions & 7 deletions Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,16 @@ extension View {
// SKIP @bridge
public func allowsHitTesting(_ enabled: Bool) -> any View {
#if SKIP
if enabled {
return self
} else {
return ModifiedContent(content: self, modifier: RenderModifier {
return $0.modifier.clickable(enabled: false, onClick: {})
})
}
return ModifiedContent(content: self, modifier: RenderModifier { renderable, context in
var context = context
context.modifier = context.modifier.skipHitTesting(enabled: enabled)
EnvironmentValues.shared.setValues {
$0.set_isHitTestingEnabled(enabled)
return ComposeResult.ok
} in: {
renderable.Render(context: context)
}
})
#else
return self
#endif
Expand Down
Loading