Skip to content

Commit

Permalink
fix iOS SelectionHandles crash on scrollable SelectionContainer (#1121)
Browse files Browse the repository at this point in the history
## Proposed Changes

- Update code in iOS SelectionHandles position calculation like in
Android was.

## Testing

Run by hands sample in DemoApp / Components / Selection / 

## Issues Fixed

 - JetBrains/compose-multiplatform#4323

---------

Co-authored-by: Ivan Matkov <ivan.matkov@jetbrains.com>
  • Loading branch information
dima-avdeev-jb and MatkovIvan committed Feb 19, 2024
1 parent dd812f0 commit 246b0bb
Show file tree
Hide file tree
Showing 2 changed files with 183 additions and 59 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,32 @@ package androidx.compose.foundation.text.selection

import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.Handle
import androidx.compose.foundation.text.selection.HandleReferencePoint.BottomMiddle
import androidx.compose.foundation.text.selection.HandleReferencePoint.TopLeft
import androidx.compose.foundation.text.selection.HandleReferencePoint.TopMiddle
import androidx.compose.foundation.text.selection.HandleReferencePoint.TopRight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.isSpecified
import androidx.compose.ui.geometry.takeOrElse
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.ResolvedTextDirection
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import kotlin.math.roundToInt

/**
* Clickable padding of handler
Expand All @@ -59,72 +68,167 @@ internal actual fun SelectionHandle(
handlesCrossed: Boolean,
lineHeight: Float,
modifier: Modifier,
) {
val isLeft = isLeft(isStartHandle, direction, handlesCrossed)
// The left selection handle's top right is placed at the given position, and vice versa.
val handleReferencePoint = if (isLeft) BottomMiddle else TopMiddle
val offset = if (isLeft) Offset.Zero else Offset(0f, -lineHeight)

HandlePopup(positionProvider = offsetProvider, handleReferencePoint = handleReferencePoint, offset = offset) {
SelectionHandleIcon(
modifier = modifier.semantics {
val position = offsetProvider.provide()
this[SelectionHandleInfoKey] = SelectionHandleInfo(
handle = if (isStartHandle) Handle.SelectionStart else Handle.SelectionEnd,
position = position,
anchor = if (isLeft) SelectionHandleAnchor.Left else SelectionHandleAnchor.Right,
visible = position.isSpecified,
)
},
iconVisible = { offsetProvider.provide().isSpecified },
lineHeight = lineHeight,
isLeft = isLeft,
)
}
}

@Composable
/*@VisibleForTesting*/
internal fun SelectionHandleIcon(
modifier: Modifier,
iconVisible: () -> Boolean,
lineHeight: Float,
isLeft: Boolean,
) {
val density = LocalDensity.current
val lineHeightDp = with(density) { lineHeight.toDp() }
Spacer(
modifier
.size(
width = (PADDING + RADIUS) * 2,
height = RADIUS * 2 + PADDING + lineHeightDp
)
.drawSelectionHandle(iconVisible, lineHeight, isLeft)
)
}

internal fun Modifier.drawSelectionHandle(
iconVisible: () -> Boolean,
lineHeight: Float,
isLeft: Boolean
): Modifier = composed {
val density = LocalDensity.current
val paddingPx = with(density) { PADDING.toPx() }
val radiusPx = with(density) { RADIUS.toPx() }
val thicknessPx = with(density) { THICKNESS.toPx() }
val isLeft = isLeft(isStartHandle, direction, handlesCrossed)

val handleColor = LocalTextSelectionColors.current.handleColor
Popup(
popupPositionProvider = remember(isLeft) {
object : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset {
val position = offsetProvider.provide()
val y = if (isLeft) {
position.y - paddingPx - lineHeight - radiusPx * 2
} else {
position.y - lineHeight
}

val positionRounded = IntOffset(position.x.roundToInt(), y.roundToInt())

return IntOffset(
x = anchorBounds.left + positionRounded.x - popupContentSize.width / 2,
y = anchorBounds.top + positionRounded.y
)
}
}
},
properties = PopupProperties(
clippingEnabled = false,
),
) {
Spacer(
modifier.size(
width = (PADDING + RADIUS) * 2,
height = RADIUS * 2 + PADDING + lineHeightDp
this.drawWithCache {
onDrawWithContent {
drawContent()
if (!iconVisible()) return@onDrawWithContent

// vertical line
drawRect(
color = handleColor,
topLeft = Offset(
x = paddingPx + radiusPx - thicknessPx / 2,
y = if (isLeft) paddingPx + radiusPx else 0f
),
size = Size(thicknessPx, lineHeight + radiusPx)
)
.drawWithCache {
onDrawWithContent {
drawContent()
// vertical line
drawRect(
color = handleColor,
topLeft = Offset(
x = paddingPx + radiusPx - thicknessPx / 2,
y = if (isLeft) paddingPx + radiusPx else 0f
),
size = Size(thicknessPx, lineHeight + radiusPx)
)
// handle circle
drawCircle(
color = handleColor,
radius = radiusPx,
center = center.copy(
y = if (isLeft) paddingPx + radiusPx else lineHeight + radiusPx
)
)
}
}
)
// handle circle
drawCircle(
color = handleColor,
radius = radiusPx,
center = center.copy(
y = if (isLeft) paddingPx + radiusPx else lineHeight + radiusPx
)
)
}
}
}

@Composable
internal fun HandlePopup(
offset: Offset,
positionProvider: OffsetProvider,
handleReferencePoint: HandleReferencePoint,
content: @Composable () -> Unit
) {
val popupPositionProvider = remember(handleReferencePoint, positionProvider, offset) {
HandlePositionProvider(handleReferencePoint, positionProvider, offset)
}
Popup(
popupPositionProvider = popupPositionProvider,
properties = PopupProperties(clippingEnabled = false),
content = content,
)
}

// TODO: Move everything below into commonMain source set (remove copy-paste from AndroidSelectionHandles.android.kt)

/**
* The enum that specifies how a selection/cursor handle is placed to its given position.
* When this value is [TopLeft], the top left corner of the handle will be placed at the
* given position.
* When this value is [TopRight], the top right corner of the handle will be placed at the
* given position.
* When this value is [TopMiddle], the handle top edge's middle point will be placed at the given
* position.
*/
internal enum class HandleReferencePoint {
TopLeft,
TopRight,
TopMiddle,
BottomMiddle,
}

/**
* This [PopupPositionProvider] for [HandlePopup]. It will position the selection handle
* to the result of [positionProvider] in its anchor layout.
*
* @see HandleReferencePoint
*/
internal class HandlePositionProvider(
private val handleReferencePoint: HandleReferencePoint,
private val positionProvider: OffsetProvider,
private val offset: Offset,
) : PopupPositionProvider {

/**
* When Handle disappears, it starts reporting its position as [Offset.Unspecified]. Normally,
* Popup is dismissed immediately when its position becomes unspecified, but for one frame a
* position update might be requested by soon-to-be-destroyed Popup. In this case, report the
* last known position as there are no more updates. If the first ever position is provided as
* unspecified, start with [Offset.Zero] default.
*/
private var prevPosition: Offset = Offset.Zero

override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset {
val position = positionProvider.provide().takeOrElse { prevPosition } + offset
prevPosition = position

// We want the cursor to point to the position,
// so adjust the x-axis based on where the handle is pointing.
val xAdjustment = when (handleReferencePoint) {
TopLeft -> 0
TopMiddle, BottomMiddle -> popupContentSize.width / 2
TopRight -> popupContentSize.width
}
val yAdjustment = when (handleReferencePoint) {
TopLeft, TopMiddle, TopRight -> 0
BottomMiddle -> popupContentSize.height
}

val offset = position.round()
val x = anchorBounds.left + offset.x - xAdjustment
val y = anchorBounds.top + offset.y - yAdjustment
return IntOffset(x, y)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,16 @@

package androidx.compose.mpp.demo.components

import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.DisableSelection
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
Expand All @@ -30,6 +34,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

@Composable
Expand Down Expand Up @@ -62,5 +67,20 @@ fun SelectionExample() {
Text("I'm yet another Text() with multiparagraph structure block.\nLet's try to select me!")
}
}
Column(
Modifier
.height(100.dp)
.padding(2.dp)
.border(1.dp, Color.Blue)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
SelectionContainer {
Text(
text = "Select text and scroll\n".repeat(100),
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}

0 comments on commit 246b0bb

Please sign in to comment.