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