feat: добавить floating toolbar (Copy/Select All) и Magnifier (API 28+) для text selection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 15:14:30 +05:00
parent 7fcf1195e1
commit e825a1ef30

View File

@@ -8,24 +8,41 @@ import android.view.HapticFeedbackConstants
import android.view.View
import android.widget.Toast
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.delay
data class LayoutInfo(
val layout: Layout,
@@ -94,6 +111,7 @@ class TextSelectionHelper {
handleViewProgress = 1f
view?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
showToolbar = false
}
fun updateSelectionStart(charOffset: Int) {
@@ -132,6 +150,53 @@ class TextSelectionHelper {
fun endHandleDrag() {
movingHandle = false
showFloatingToolbar()
}
var showToolbar by mutableStateOf(false)
private set
fun showFloatingToolbar() {
if (isInSelectionMode && !movingHandle) {
showToolbar = true
}
}
fun hideFloatingToolbar() {
showToolbar = false
}
private var magnifier: android.widget.Magnifier? = null
private var magnifierView: View? = null
fun setMagnifierView(view: View?) {
magnifierView = view
}
fun showMagnifier(x: Float, y: Float) {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.P) return
val view = magnifierView ?: return
if (!movingHandle) return
if (magnifier == null) {
magnifier = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
android.widget.Magnifier.Builder(view)
.setSize(200, 80)
.setCornerRadius(16f)
.build()
} else {
android.widget.Magnifier(view)
}
}
val info = layoutInfo ?: return
val localX = (x - info.windowX).coerceIn(0f, view.width.toFloat())
val localY = (y - info.windowY).coerceIn(0f, view.height.toFloat())
magnifier?.show(localX, localY)
}
fun hideMagnifier() {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.P) return
magnifier?.dismiss()
magnifier = null
}
fun getCharOffsetFromCoords(x: Int, y: Int): Int {
@@ -174,6 +239,8 @@ class TextSelectionHelper {
isActive = false
handleViewProgress = 0f
movingHandle = false
showToolbar = false
hideMagnifier()
}
}
@@ -183,6 +250,68 @@ private val HighlightCorner = 6.dp
private val HighlightColor = PrimaryBlue.copy(alpha = 0.3f)
private val HandleColor = PrimaryBlue
@Composable
private fun FloatingToolbarPopup(
helper: TextSelectionHelper
) {
val context = LocalContext.current
LaunchedEffect(helper.isActive, helper.movingHandle) {
if (helper.isActive && !helper.movingHandle && !helper.showToolbar) {
delay(200)
helper.showFloatingToolbar()
}
}
if (!helper.showToolbar || !helper.isInSelectionMode) return
val info = helper.layoutInfo ?: return
val layout = info.layout
val startLine = layout.getLineForOffset(helper.selectionStart.coerceIn(0, info.text.length))
val toolbarY = (layout.getLineTop(startLine) + info.windowY - 52).coerceAtLeast(0)
val toolbarX = ((helper.startHandleX + helper.endHandleX) / 2f - 80f).coerceAtLeast(0f)
Popup(
alignment = Alignment.TopStart,
offset = IntOffset(toolbarX.toInt(), toolbarY)
) {
Row(
modifier = Modifier
.shadow(4.dp, RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF333333))
.padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Copy",
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier
.clickable { helper.copySelectedText(context) }
.padding(horizontal = 12.dp, vertical = 8.dp)
)
val allSelected = helper.selectionStart <= 0 &&
helper.selectionEnd >= info.text.length
if (!allSelected) {
Text(
text = "Select All",
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier
.clickable {
helper.selectAll()
helper.hideFloatingToolbar()
}
.padding(horizontal = 12.dp, vertical = 8.dp)
)
}
}
}
}
@Composable
fun TextSelectionOverlay(
helper: TextSelectionHelper,
@@ -196,6 +325,7 @@ fun TextSelectionOverlay(
val highlightCornerPx = with(density) { HighlightCorner.toPx() }
Box(modifier = modifier.fillMaxSize()) {
FloatingToolbarPopup(helper = helper)
Canvas(
modifier = Modifier
.fillMaxSize()
@@ -225,10 +355,12 @@ fun TextSelectionOverlay(
when {
startRect.contains(Offset(x, y)) -> {
helper.beginHandleDrag(isStart = true, x, y)
helper.hideFloatingToolbar()
change.consume()
}
endRect.contains(Offset(x, y)) -> {
helper.beginHandleDrag(isStart = false, x, y)
helper.hideFloatingToolbar()
change.consume()
}
else -> {
@@ -238,9 +370,11 @@ fun TextSelectionOverlay(
}
change.pressed && helper.movingHandle -> {
helper.moveHandle(change.position.x, change.position.y)
helper.showMagnifier(change.position.x, change.position.y)
change.consume()
}
!change.pressed && helper.movingHandle -> {
helper.hideMagnifier()
helper.endHandleDrag()
change.consume()
}