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:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user