From 2437a164e66ca06542f367d0701d2d8806e8b1a7 Mon Sep 17 00:00:00 2001 From: Abe White Date: Thu, 26 Sep 2024 00:19:26 -0500 Subject: [PATCH] Give scrolling gestures precedence over drag --- .../SkipUI/Compose/ComposeContainer.swift | 21 ++++-- .../SkipUI/Compose/ComposeLayouts.swift | 1 - .../Environment/EnvironmentValues.swift | 12 +++- Sources/SkipUI/SkipUI/System/Gesture.swift | 71 ++++++++++++------- 4 files changed, 72 insertions(+), 33 deletions(-) diff --git a/Sources/SkipUI/SkipUI/Compose/ComposeContainer.swift b/Sources/SkipUI/SkipUI/Compose/ComposeContainer.swift index 9143875d..a7b16651 100644 --- a/Sources/SkipUI/SkipUI/Compose/ComposeContainer.swift +++ b/Sources/SkipUI/SkipUI/Compose/ComposeContainer.swift @@ -43,33 +43,37 @@ import androidx.compose.ui.Modifier // because Compose's fillMax modifiers have no effect in the scroll direction. We can't use IntrinsicSize for scrolling // containers, however var modifier = modifier - let inheritedScrollAxes = EnvironmentValues.shared._scrollAxes - var totalScrollAxes = inheritedScrollAxes + let inheritedLayoutScrollAxes = EnvironmentValues.shared._layoutScrollAxes + var totalLayoutScrollAxes = inheritedLayoutScrollAxes if fixedWidth || axis == .vertical { - totalScrollAxes.remove(Axis.Set.horizontal) + totalLayoutScrollAxes.remove(Axis.Set.horizontal) } if !fixedWidth && isFillWidth.value { if fillWidth { modifier = modifier.fillWidth() - } else if inheritedScrollAxes.contains(Axis.Set.horizontal) { + } else if inheritedLayoutScrollAxes.contains(Axis.Set.horizontal) { modifier = modifier.width(IntrinsicSize.Max) } else { modifier = modifier.fillWidth() } } if fixedHeight || axis == .horizontal { - totalScrollAxes.remove(Axis.Set.vertical) + totalLayoutScrollAxes.remove(Axis.Set.vertical) } if !fixedHeight && isFillHeight.value { if fillHeight { modifier = modifier.fillHeight() - } else if inheritedScrollAxes.contains(Axis.Set.vertical) { + } else if inheritedLayoutScrollAxes.contains(Axis.Set.vertical) { modifier = modifier.height(IntrinsicSize.Max) } else { modifier = modifier.fillHeight() } } - totalScrollAxes.formUnion(scrollAxes) + + totalLayoutScrollAxes.formUnion(scrollAxes) + let inheritedScrollAxes = EnvironmentValues.shared._scrollAxes + let totalScrollAxes = inheritedScrollAxes.union(scrollAxes) + modifier = modifier.then(then) EnvironmentValues.shared.setValues { // Setup the initial environment before rendering the container content @@ -78,6 +82,9 @@ import androidx.compose.ui.Modifier } else if eraseAxis { $0.set_layoutAxis(nil) } + if totalLayoutScrollAxes != inheritedLayoutScrollAxes { + $0.set_layoutScrollAxes(totalLayoutScrollAxes) + } if totalScrollAxes != inheritedScrollAxes { $0.set_scrollAxes(totalScrollAxes) } diff --git a/Sources/SkipUI/SkipUI/Compose/ComposeLayouts.swift b/Sources/SkipUI/SkipUI/Compose/ComposeLayouts.swift index 6bd2c3b9..d162340f 100644 --- a/Sources/SkipUI/SkipUI/Compose/ComposeLayouts.swift +++ b/Sources/SkipUI/SkipUI/Compose/ComposeLayouts.swift @@ -53,7 +53,6 @@ import androidx.compose.ui.unit.dp /// Compose a view with the given frame. @Composable func FrameLayout(view: View, context: ComposeContext, minWidth: CGFloat?, idealWidth: CGFloat?, maxWidth: CGFloat?, minHeight: CGFloat?, idealHeight: CGFloat?, maxHeight: CGFloat?, alignment: Alignment) { - let scrollAxes = EnvironmentValues.shared._scrollAxes var thenModifier: Modifier = Modifier if maxWidth == .infinity { if let minWidth, minWidth > 0.0 { diff --git a/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift b/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift index 47f0138d..5f488da7 100644 --- a/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift +++ b/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift @@ -460,6 +460,15 @@ extension EnvironmentValues { set { setBuiltinValue(key: "_layoutAxis", value: newValue, defaultValue: { nil }) } } + /// The axes that are unbound due to scrolling. + /// + /// This is different than `_scrollAxes`, because using a fixed size container or applying a different layout axis + /// can bound a scrollable layout axis and remove it from this set. + var _layoutScrollAxes: Axis.Set { + get { builtinValue(key: "_layoutScrollAxes", defaultValue: { Axis.Set(rawValue: 0) }) as! Axis.Set } + set { setBuiltinValue(key: "_layoutScrollAxes", value: newValue, defaultValue: { Axis.Set(rawValue: 0) }) } + } + var _listItemTint: Color? { get { builtinValue(key: "_listItemTint", defaultValue: { nil }) as! Color? } set { setBuiltinValue(key: "_listItemTint", value: newValue, defaultValue: { nil }) } @@ -520,7 +529,8 @@ extension EnvironmentValues { set { setBuiltinValue(key: "_scrollContentBackground", value: newValue, defaultValue: { nil }) } } - /// While `_scrollAxes` contains the effective scroll directions, this property contains the nominal directions of any ancestor scroll view. + /// While `_scrollAxes` contains the effective scroll directions, this property contains the nominal directions + /// of any ancestor scroll view. var _scrollViewAxes: Axis.Set { get { builtinValue(key: "_scrollViewAxes", defaultValue: { Axis.Set(rawValue: 0) }) as! Axis.Set } set { setBuiltinValue(key: "_scrollViewAxes", value: newValue, defaultValue: { Axis.Set(rawValue: 0) }) } diff --git a/Sources/SkipUI/SkipUI/System/Gesture.swift b/Sources/SkipUI/SkipUI/System/Gesture.swift index 9dcedfbc..e7b9563c 100644 --- a/Sources/SkipUI/SkipUI/System/Gesture.swift +++ b/Sources/SkipUI/SkipUI/System/Gesture.swift @@ -8,11 +8,9 @@ import Foundation #if SKIP import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.drag import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -25,7 +23,8 @@ import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp +import org.burnoutcrew.reorderable.awaitPointerSlopOrCancellation +import kotlin.math.abs #endif public protocol Gesture { @@ -424,7 +423,8 @@ final class GestureModifierView: ComposeModifierView { let dragPositionX = remember { mutableStateOf(Float(0.0)) } let dragPositionY = remember { mutableStateOf(Float(0.0)) } let noMinimumDistance = dragGestures.value.contains { $0.minimumDistance <= 0.0 } - ret = ret.pointerInput(true) { + let scrollAxes = EnvironmentValues.shared._scrollAxes + ret = ret.pointerInput(scrollAxes) { let onDrag: (PointerInputChange, Offset) -> Void = { change, offsetPx in let offsetX = with(density) { offsetPx.x.toDp() } let offsetY = with(density) { offsetPx.y.toDp() } @@ -452,35 +452,58 @@ final class GestureModifierView: ComposeModifierView { dragOffsetY.value = Float(0.0) dragGestures.value.forEach { $0.onDragEnd(location: location, translation: translation) } } - if noMinimumDistance { - detectDragGesturesWithoutMinimumDistance(onDrag: onDrag, onDragEnd: onDragEnd, onDragCancel: onDragCancel) - } else { - detectDragGestures(onDrag: onDrag, onDragEnd: onDragEnd, onDragCancel: onDragCancel) - } + detectDragGesturesWithScrollAxes(onDrag: onDrag, onDragEnd: onDragEnd, onDragCancel: onDragCancel, shouldAwaitTouchSlop: { !noMinimumDistance }, scrollAxes: scrollAxes) } } return ret } } -// This is an adaptation of the internal Compose function here: https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/DragGestureDetector.kt;l=168?q=detectdraggestures&sq= -// SKIP DECLARE: suspend fun PointerInputScope.detectDragGesturesWithoutMinimumDistance(onDrag: (PointerInputChange, Offset) -> Unit, onDragEnd: () -> Unit, onDragCancel: () -> Unit) -func detectDragGesturesWithoutMinimumDistance(onDrag: (PointerInputChange, Offset) -> Void, onDragEnd: () -> Void, onDragCancel: () -> Unit) { +// This is an adaptation of the internal Compose function here: https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/DragGestureDetector.kt +// SKIP DECLARE: suspend fun PointerInputScope.detectDragGesturesWithScrollAxes(onDrag: (PointerInputChange, Offset) -> Unit, onDragEnd: () -> Unit, onDragCancel: () -> Unit, shouldAwaitTouchSlop: () -> Boolean, scrollAxes: Axis.Set) +func detectDragGesturesWithScrollAxes(onDragEnd: () -> Void, onDragCancel: () -> Void, onDrag: (PointerInputChange, Offset) -> Void, shouldAwaitTouchSlop: () -> Bool, scrollAxes: Axis.Set) { + var overSlop: Offset awaitEachGesture { let initialDown = awaitFirstDown(requireUnconsumed: false, pass: PointerEventPass.Initial) - initialDown.consume() - let _ = awaitFirstDown(requireUnconsumed: false) - - let drag: PointerInputChange = initialDown - onDrag(drag, Offset.Zero) - let upEvent = drag(pointerId: drag.id, onDrag: { - onDrag($0, $0.positionChange()) - $0.consume() - }) - if upEvent == nil { - onDragCancel() + let awaitTouchSlop = shouldAwaitTouchSlop() + if (!awaitTouchSlop) { + initialDown.consume() + } + let down = awaitFirstDown(requireUnconsumed: false) + var drag: PointerInputChange? + overSlop = Offset.Zero + if (awaitTouchSlop) { + repeat { + drag = awaitPointerSlopOrCancellation(down.id, down.type) { change, over in + if scrollAxes == Axis.Set.vertical { + if abs(over.x) > abs(over.y) { + change.consume() + } + } else if scrollAxes == Axis.Set.horizontal { + if abs(over.y) > abs(over.x) { + change.consume() + } + } else { + change.consume() + } + overSlop = over + } + } while drag != nil && drag?.isConsumed != true } else { - onDragEnd() + drag = initialDown + } + + if let drag { + onDrag(drag, overSlop) + let didCompleteDrag = drag(pointerId: drag.id, onDrag: { + onDrag($0, $0.positionChange()) + $0.consume() + }) + if didCompleteDrag { + onDragEnd() + } else { + onDragCancel() + } } } }