Browse Source

feat(悬浮窗): 改进悬浮窗UI并添加加载动画和缩放功能

- 重新设计悬浮窗UI样式,包括颜色、圆角和间距
- 添加加载动画效果,显示识别和翻译状态
- 支持双指缩放调整悬浮窗大小
- 优化最小化视图的显示内容
- 移除时间戳显示,改进语言信息展示
newdev_shunjiawei
lxm 9 months ago
parent
commit
ca9fbbbe22
  1. 369
      local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt
  2. 1
      local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt

369
local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt

@ -4,10 +4,13 @@ import android.app.Service
import android.content.Intent
import android.graphics.Color
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.text.TextUtils
import android.util.TypedValue
import android.view.Gravity
@ -19,10 +22,8 @@ import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import io.flutter.plugin.common.MethodChannel
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/// 悬浮窗口服务
class FloatingWindowService : Service() {
companion object {
const val ACTION_UPDATE_CONTENT = "UPDATE_CONTENT"
@ -51,11 +52,23 @@ class FloatingWindowService : Service() {
private var sourceTextView: TextView? = null
private var translatedTextView: TextView? = null
private var sourceLoadingView: TextView? = null
private var translatedLoadingView: TextView? = null
private var languageInfoView: TextView? = null
private var timestampView: TextView? = null
private var minimizedView: View? = null
private var expandedView: View? = null
private var minimizedTextView: TextView? = null
private var closeButton: TextView? = null
private var expandedContainer: LinearLayout? = null
private var baseExpandedWidth: Int = 0
private var uiScale: Float = 1.0f
private val backgroundBaseColor: Int = Color.argb(150, 40, 40, 40)
private val uiHandler: Handler = Handler(Looper.getMainLooper())
private var sourceDotsStep: Int = 0
private var translatedDotsStep: Int = 0
private var sourceDotsRunnable: Runnable? = null
private var translatedDotsRunnable: Runnable? = null
override fun onCreate() {
super.onCreate()
@ -76,6 +89,7 @@ class FloatingWindowService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
stopAllLoading()
removeFloatingWindow()
serviceInstance = null
super.onDestroy()
@ -129,39 +143,73 @@ class FloatingWindowService : Service() {
}
private fun createMinimizedView(): View {
val container = FrameLayout(this)
val lp = FrameLayout.LayoutParams(dp(60), dp(60))
val container = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
}
val lp = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(48))
val margin = dp(8)
lp.setMargins(margin, margin, margin, margin)
container.layoutParams = lp
container.setPadding(dp(10), dp(8), dp(12), dp(8))
container.minimumWidth = dp(160)
val bg = GradientDrawable().apply {
setColor(0xFFE3F2FD.toInt())
cornerRadius = dp(30).toFloat()
setColor(backgroundBaseColor)
cornerRadius = dp(24).toFloat()
setStroke(dp(1), Color.argb(46, 255, 255, 255))
}
container.background = bg
container.elevation = dp(8).toFloat()
container.elevation = dp(10).toFloat()
val iconBg = GradientDrawable().apply {
setColor(Color.argb(42, 255, 255, 255))
cornerRadius = dp(14).toFloat()
}
val icon = TextView(this).apply {
text = "译"
setTextColor(0xFF1976D2.toInt())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
text = "V"
setTextColor(0xFFFFFFFF.toInt())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
gravity = Gravity.CENTER
typeface = Typeface.DEFAULT_BOLD
background = iconBg
}
container.addView(
icon,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
val iconLp = LinearLayout.LayoutParams(dp(28), dp(28))
container.addView(icon, iconLp)
minimizedTextView = TextView(this).apply {
text = "Voitrans"
setTextColor(Color.argb(235, 255, 255, 255))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
}
val textLp = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply {
marginStart = dp(10)
}
container.addView(minimizedTextView, textLp)
val chevron = TextView(this).apply {
text = "›"
setTextColor(Color.argb(150, 255, 255, 255))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
gravity = Gravity.CENTER
setPadding(dp(6), 0, 0, 0)
}
container.addView(chevron)
return container
}
private fun createExpandedView(): View {
val container = LinearLayout(this)
val lp = FrameLayout.LayoutParams(dp(320), ViewGroup.LayoutParams.WRAP_CONTENT)
val maxWidth = dp(360)
val minWidth = dp(280)
val horizontalMargins = dp(8) * 2
val screenWidth = resources.displayMetrics.widthPixels
val targetWidth = (screenWidth - horizontalMargins).coerceIn(minWidth, maxWidth)
baseExpandedWidth = targetWidth
val lp = FrameLayout.LayoutParams(targetWidth, ViewGroup.LayoutParams.WRAP_CONTENT)
val margin = dp(8)
lp.setMargins(margin, margin, margin, margin)
container.layoutParams = lp
@ -169,10 +217,12 @@ class FloatingWindowService : Service() {
container.setPadding(dp(16), dp(16), dp(16), dp(16))
container.visibility = View.GONE
container.elevation = dp(12).toFloat()
expandedContainer = container
val bg = GradientDrawable().apply {
setColor(Color.WHITE)
setColor(backgroundBaseColor)
cornerRadius = dp(16).toFloat()
setStroke(dp(1), Color.argb(46, 255, 255, 255))
}
container.background = bg
@ -185,62 +235,80 @@ class FloatingWindowService : Service() {
)
}
val title = TextView(this).apply {
text = "实时翻译"
setTextColor(0xFF333333.toInt())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
typeface = Typeface.DEFAULT_BOLD
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
}
closeButton = TextView(this).apply {
text = "✕"
setTextColor(0xFF666666.toInt())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
setTextColor(Color.argb(210, 255, 255, 255))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
gravity = Gravity.CENTER
setPadding(dp(8), dp(8), dp(8), dp(8))
}
titleRow.addView(title)
titleRow.addView(closeButton)
languageInfoView = TextView(this).apply {
text = "中文 → 英文"
setTextColor(0xFF666666.toInt())
text = "src → tgt"
setTextColor(Color.argb(235, 255, 255, 255))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
setPadding(0, dp(8), 0, dp(8))
setPadding(dp(10), dp(6), dp(10), dp(6))
}
val langBg = GradientDrawable().apply {
setColor(Color.argb(36, 255, 255, 255))
cornerRadius = dp(10).toFloat()
}
languageInfoView?.background = langBg
val languageInRowLp = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
sourceTextView = TextView(this).apply {
text = "正在识别..."
setTextColor(0xFF333333.toInt())
text = ""
setTextColor(Color.argb(235, 255, 255, 255))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
setPadding(0, dp(8), 0, dp(4))
maxLines = 3
ellipsize = TextUtils.TruncateAt.END
setLineSpacing(dpF(2f), 1.1f)
visibility = View.GONE
}
translatedTextView = TextView(this).apply {
text = "翻译中..."
setTextColor(0xFF1976D2.toInt())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
text = ""
setTextColor(0xFF90CAF9.toInt())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f)
setPadding(0, dp(4), 0, dp(8))
maxLines = 3
ellipsize = TextUtils.TruncateAt.END
setLineSpacing(dpF(2f), 1.1f)
visibility = View.GONE
}
timestampView = TextView(this).apply {
sourceLoadingView = TextView(this).apply {
text = ""
setTextColor(0xFF999999.toInt())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 10f)
gravity = Gravity.END
setTextColor(Color.argb(235, 255, 255, 255))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
setPadding(0, dp(8), 0, dp(4))
visibility = View.GONE
}
translatedLoadingView = TextView(this).apply {
text = ""
setTextColor(0xFF90CAF9.toInt())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f)
setPadding(0, dp(4), 0, dp(8))
visibility = View.GONE
}
val spacer = View(this)
val spacerLp = LinearLayout.LayoutParams(0, 0, 1f)
titleRow.addView(languageInfoView, languageInRowLp)
titleRow.addView(spacer, spacerLp)
titleRow.addView(closeButton)
container.addView(titleRow)
container.addView(languageInfoView)
container.addView(sourceTextView)
container.addView(sourceLoadingView)
container.addView(translatedTextView)
container.addView(timestampView)
container.addView(translatedLoadingView)
applyUiScale(uiScale)
return container
}
@ -270,21 +338,50 @@ class FloatingWindowService : Service() {
private var initialTouchX: Float = 0f
private var initialTouchY: Float = 0f
private var dragging: Boolean = false
private var scaling: Boolean = false
private var initialSpan: Float = 0f
private var initialScale: Float = 1.0f
override fun onTouch(v: View, event: MotionEvent): Boolean {
val lp = layoutParams ?: return false
val wm = windowManager ?: return false
when (event.action) {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
initialX = lp.x
initialY = lp.y
initialTouchX = event.rawX
initialTouchY = event.rawY
if (!isMinimized && isTouchingView(closeButton, event.rawX, event.rawY)
) {
return false
}
dragging = false
scaling = false
return true
}
MotionEvent.ACTION_POINTER_DOWN -> {
if (event.pointerCount >= 2) {
scaling = true
dragging = false
initialSpan = pointerSpan(event)
initialScale = uiScale
return true
}
return true
}
MotionEvent.ACTION_MOVE -> {
if (scaling && event.pointerCount >= 2) {
val span = pointerSpan(event)
if (initialSpan > 0f && span > 0f) {
val next = (initialScale * (span / initialSpan)).coerceIn(0.85f, 1.4f)
setUiScale(next)
}
return true
}
val dx = (event.rawX - initialTouchX).toInt()
val dy = (event.rawY - initialTouchY).toInt()
if (!dragging && (kotlin.math.abs(dx) > threshold || kotlin.math.abs(dy) > threshold)) {
@ -304,12 +401,21 @@ class FloatingWindowService : Service() {
return true
}
MotionEvent.ACTION_POINTER_UP -> {
val remainingPointers = event.pointerCount - 1
if (scaling && remainingPointers < 2) {
scaling = false
return true
}
return false
}
MotionEvent.ACTION_UP -> {
if (!dragging) updateViewState(!isMinimized)
if (!dragging && !scaling) updateViewState(!isMinimized)
dragging = false
scaling = false
return true
}
else -> return false
}
}
@ -320,6 +426,13 @@ class FloatingWindowService : Service() {
isMinimized = minimized
minimizedView?.visibility = if (minimized) View.VISIBLE else View.GONE
expandedView?.visibility = if (minimized) View.GONE else View.VISIBLE
if (minimized) {
stopAllLoading()
minimizedView?.scaleX = 1.0f
minimizedView?.scaleY = 1.0f
}
notifyStateChanged(if (minimized) "minimized" else "expanded")
}
@ -328,26 +441,58 @@ class FloatingWindowService : Service() {
val translatedText = intent.getStringExtra(EXTRA_TRANSLATED_TEXT)
val sourceLang = intent.getStringExtra(EXTRA_SOURCE_LANGUAGE)
val targetLang = intent.getStringExtra(EXTRA_TARGET_LANGUAGE)
val timestamp = intent.getLongExtra(EXTRA_TIMESTAMP, 0L)
val isIntermediate = intent.getBooleanExtra(EXTRA_IS_INTERMEDIATE, false)
val sourceValue = sourceText ?: ""
val translatedValue = translatedText ?: ""
val hasSource = !TextUtils.isEmpty(sourceValue)
val hasTranslated = !TextUtils.isEmpty(translatedValue)
languageInfoView?.let { view ->
val langText = (sourceLang ?: "") + " → " + (targetLang ?: "")
view.text = langText
}
sourceTextView?.let { view ->
val value = sourceText ?: ""
view.text = if (TextUtils.isEmpty(value)) "正在识别..." else value
if (hasSource) {
view.text = sourceValue
view.visibility = View.VISIBLE
stopSourceLoading()
} else {
view.text = ""
view.visibility = View.GONE
if (isIntermediate) {
startSourceLoading()
} else {
stopSourceLoading()
}
}
view.alpha = if (isIntermediate) 0.85f else 1.0f
}
translatedTextView?.let { view ->
val value = translatedText ?: ""
view.text = if (TextUtils.isEmpty(value)) "翻译中..." else value
if (hasTranslated) {
view.text = translatedValue
view.visibility = View.VISIBLE
stopTranslatedLoading()
} else {
view.text = ""
view.visibility = View.GONE
if (hasSource || isIntermediate) {
startTranslatedLoading()
} else {
stopTranslatedLoading()
}
}
view.alpha = if (isIntermediate) 0.7f else 1.0f
}
timestampView?.text = if (timestamp > 0) formatTimestamp(timestamp) else ""
minimizedTextView?.let { view ->
val primary = translatedText?.takeIf { !TextUtils.isEmpty(it) }
val secondary = sourceText?.takeIf { !TextUtils.isEmpty(it) }
view.text = primary ?: secondary ?: "Voitrans"
view.alpha = if (isIntermediate) 0.85f else 1.0f
}
}
private fun setPosition(intent: Intent) {
@ -386,18 +531,108 @@ class FloatingWindowService : Service() {
channel.invokeMethod("onFloatingWindowClicked", null)
}
private fun formatTimestamp(ts: Long): String {
if (ts <= 0) return ""
return try {
val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
sdf.format(Date(ts))
} catch (_: Exception) {
""
private fun dp(value: Int): Int {
return (value * resources.displayMetrics.density).toInt()
}
private fun dpF(value: Float): Float {
return value * resources.displayMetrics.density
}
private fun setUiScale(next: Float) {
uiScale = next
applyUiScale(uiScale)
}
private fun applyUiScale(scale: Float) {
val container = expandedContainer ?: return
if (baseExpandedWidth <= 0) return
languageInfoView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f * scale)
sourceTextView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f * scale)
translatedTextView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f * scale)
sourceLoadingView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f * scale)
translatedLoadingView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f * scale)
val buttonTextSizeSp = (18f * scale).coerceIn(14f, 22f)
closeButton?.setTextSize(TypedValue.COMPLEX_UNIT_SP, buttonTextSizeSp)
val closeHPad = (dp(8) * scale).toInt().coerceIn(dp(6), dp(16))
val closeVPad = (dp(8) * scale).toInt().coerceIn(dp(4), dp(12))
closeButton?.setPadding(closeHPad, closeVPad, closeHPad, closeVPad)
val padding = (dp(16) * scale).toInt().coerceIn(dp(12), dp(24))
container.setPadding(padding, padding, padding, padding)
val maxLines = if (scale >= 1.15f) 4 else 3
sourceTextView?.maxLines = maxLines
translatedTextView?.maxLines = maxLines
}
private fun startSourceLoading() {
val view = sourceLoadingView ?: return
if (view.visibility == View.VISIBLE && sourceDotsRunnable != null) return
sourceDotsStep = 0
view.visibility = View.VISIBLE
val runnable = object : Runnable {
override fun run() {
val count = (sourceDotsStep % 3) + 1
view.text = "•".repeat(count)
sourceDotsStep++
uiHandler.postDelayed(this, 350L)
}
}
sourceDotsRunnable = runnable
uiHandler.post(runnable)
}
private fun dp(value: Int): Int {
return (value * resources.displayMetrics.density).toInt()
private fun stopSourceLoading() {
sourceDotsRunnable?.let { uiHandler.removeCallbacks(it) }
sourceDotsRunnable = null
sourceLoadingView?.visibility = View.GONE
sourceLoadingView?.text = ""
}
}
private fun startTranslatedLoading() {
val view = translatedLoadingView ?: return
if (view.visibility == View.VISIBLE && translatedDotsRunnable != null) return
translatedDotsStep = 0
view.visibility = View.VISIBLE
val runnable = object : Runnable {
override fun run() {
val count = (translatedDotsStep % 3) + 1
view.text = "•".repeat(count)
translatedDotsStep++
uiHandler.postDelayed(this, 350L)
}
}
translatedDotsRunnable = runnable
uiHandler.post(runnable)
}
private fun stopTranslatedLoading() {
translatedDotsRunnable?.let { uiHandler.removeCallbacks(it) }
translatedDotsRunnable = null
translatedLoadingView?.visibility = View.GONE
translatedLoadingView?.text = ""
}
private fun stopAllLoading() {
stopSourceLoading()
stopTranslatedLoading()
}
private fun isTouchingView(view: View?, x: Float, y: Float): Boolean {
if (view == null || view.visibility != View.VISIBLE) return false
val rect = Rect()
if (!view.getGlobalVisibleRect(rect)) return false
return rect.contains(x.toInt(), y.toInt())
}
private fun pointerSpan(event: MotionEvent): Float {
if (event.pointerCount < 2) return 0f
val dx = event.getX(0) - event.getX(1)
val dy = event.getY(0) - event.getY(1)
return kotlin.math.sqrt(dx * dx + dy * dy)
}
}

1
local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt

@ -16,6 +16,7 @@ import android.view.WindowManager
import android.widget.LinearLayout
import android.widget.TextView
/// 录音悬浮条服务
class RecordingFloatingService : Service() {
companion object {
const val ACTION_SHOW = "com.example.native_plugin.action.SHOW_RECORDING_FLOATING_BAR"

Loading…
Cancel
Save