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.view.View
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.Canvas 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.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize 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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity 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.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.delay
data class LayoutInfo( data class LayoutInfo(
val layout: Layout, val layout: Layout,
@@ -94,6 +111,7 @@ class TextSelectionHelper {
handleViewProgress = 1f handleViewProgress = 1f
view?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) view?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
showToolbar = false
} }
fun updateSelectionStart(charOffset: Int) { fun updateSelectionStart(charOffset: Int) {
@@ -132,6 +150,53 @@ class TextSelectionHelper {
fun endHandleDrag() { fun endHandleDrag() {
movingHandle = false 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 { fun getCharOffsetFromCoords(x: Int, y: Int): Int {
@@ -174,6 +239,8 @@ class TextSelectionHelper {
isActive = false isActive = false
handleViewProgress = 0f handleViewProgress = 0f
movingHandle = false movingHandle = false
showToolbar = false
hideMagnifier()
} }
} }
@@ -183,6 +250,68 @@ private val HighlightCorner = 6.dp
private val HighlightColor = PrimaryBlue.copy(alpha = 0.3f) private val HighlightColor = PrimaryBlue.copy(alpha = 0.3f)
private val HandleColor = PrimaryBlue 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 @Composable
fun TextSelectionOverlay( fun TextSelectionOverlay(
helper: TextSelectionHelper, helper: TextSelectionHelper,
@@ -196,6 +325,7 @@ fun TextSelectionOverlay(
val highlightCornerPx = with(density) { HighlightCorner.toPx() } val highlightCornerPx = with(density) { HighlightCorner.toPx() }
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
FloatingToolbarPopup(helper = helper)
Canvas( Canvas(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -225,10 +355,12 @@ fun TextSelectionOverlay(
when { when {
startRect.contains(Offset(x, y)) -> { startRect.contains(Offset(x, y)) -> {
helper.beginHandleDrag(isStart = true, x, y) helper.beginHandleDrag(isStart = true, x, y)
helper.hideFloatingToolbar()
change.consume() change.consume()
} }
endRect.contains(Offset(x, y)) -> { endRect.contains(Offset(x, y)) -> {
helper.beginHandleDrag(isStart = false, x, y) helper.beginHandleDrag(isStart = false, x, y)
helper.hideFloatingToolbar()
change.consume() change.consume()
} }
else -> { else -> {
@@ -238,9 +370,11 @@ fun TextSelectionOverlay(
} }
change.pressed && helper.movingHandle -> { change.pressed && helper.movingHandle -> {
helper.moveHandle(change.position.x, change.position.y) helper.moveHandle(change.position.x, change.position.y)
helper.showMagnifier(change.position.x, change.position.y)
change.consume() change.consume()
} }
!change.pressed && helper.movingHandle -> { !change.pressed && helper.movingHandle -> {
helper.hideMagnifier()
helper.endHandleDrag() helper.endHandleDrag()
change.consume() change.consume()
} }