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.content.Intent
import android.graphics.Color import android.graphics.Color
import android.graphics.PixelFormat import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.Typeface import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper
import android.text.TextUtils import android.text.TextUtils
import android.util.TypedValue import android.util.TypedValue
import android.view.Gravity import android.view.Gravity
@ -19,10 +22,8 @@ import android.widget.FrameLayout
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/// 悬浮窗口服务
class FloatingWindowService : Service() { class FloatingWindowService : Service() {
companion object { companion object {
const val ACTION_UPDATE_CONTENT = "UPDATE_CONTENT" const val ACTION_UPDATE_CONTENT = "UPDATE_CONTENT"
@ -51,11 +52,23 @@ class FloatingWindowService : Service() {
private var sourceTextView: TextView? = null private var sourceTextView: TextView? = null
private var translatedTextView: TextView? = null private var translatedTextView: TextView? = null
private var sourceLoadingView: TextView? = null
private var translatedLoadingView: TextView? = null
private var languageInfoView: TextView? = null private var languageInfoView: TextView? = null
private var timestampView: TextView? = null
private var minimizedView: View? = null private var minimizedView: View? = null
private var expandedView: View? = null private var expandedView: View? = null
private var minimizedTextView: TextView? = null
private var closeButton: 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() { override fun onCreate() {
super.onCreate() super.onCreate()
@ -76,6 +89,7 @@ class FloatingWindowService : Service() {
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() { override fun onDestroy() {
stopAllLoading()
removeFloatingWindow() removeFloatingWindow()
serviceInstance = null serviceInstance = null
super.onDestroy() super.onDestroy()
@ -129,39 +143,73 @@ class FloatingWindowService : Service() {
} }
private fun createMinimizedView(): View { private fun createMinimizedView(): View {
val container = FrameLayout(this) val container = LinearLayout(this).apply {
val lp = FrameLayout.LayoutParams(dp(60), dp(60)) orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
}
val lp = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(48))
val margin = dp(8) val margin = dp(8)
lp.setMargins(margin, margin, margin, margin) lp.setMargins(margin, margin, margin, margin)
container.layoutParams = lp container.layoutParams = lp
container.setPadding(dp(10), dp(8), dp(12), dp(8))
container.minimumWidth = dp(160)
val bg = GradientDrawable().apply { val bg = GradientDrawable().apply {
setColor(0xFFE3F2FD.toInt()) setColor(backgroundBaseColor)
cornerRadius = dp(30).toFloat() cornerRadius = dp(24).toFloat()
setStroke(dp(1), Color.argb(46, 255, 255, 255))
} }
container.background = bg 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 { val icon = TextView(this).apply {
text = "译" text = "V"
setTextColor(0xFF1976D2.toInt()) setTextColor(0xFFFFFFFF.toInt())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
gravity = Gravity.CENTER gravity = Gravity.CENTER
typeface = Typeface.DEFAULT_BOLD typeface = Typeface.DEFAULT_BOLD
background = iconBg
} }
container.addView(
icon, val iconLp = LinearLayout.LayoutParams(dp(28), dp(28))
FrameLayout.LayoutParams( container.addView(icon, iconLp)
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT 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 return container
} }
private fun createExpandedView(): View { private fun createExpandedView(): View {
val container = LinearLayout(this) 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) val margin = dp(8)
lp.setMargins(margin, margin, margin, margin) lp.setMargins(margin, margin, margin, margin)
container.layoutParams = lp container.layoutParams = lp
@ -169,10 +217,12 @@ class FloatingWindowService : Service() {
container.setPadding(dp(16), dp(16), dp(16), dp(16)) container.setPadding(dp(16), dp(16), dp(16), dp(16))
container.visibility = View.GONE container.visibility = View.GONE
container.elevation = dp(12).toFloat() container.elevation = dp(12).toFloat()
expandedContainer = container
val bg = GradientDrawable().apply { val bg = GradientDrawable().apply {
setColor(Color.WHITE) setColor(backgroundBaseColor)
cornerRadius = dp(16).toFloat() cornerRadius = dp(16).toFloat()
setStroke(dp(1), Color.argb(46, 255, 255, 255))
} }
container.background = bg 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 { closeButton = TextView(this).apply {
text = "✕" text = "✕"
setTextColor(0xFF666666.toInt()) setTextColor(Color.argb(210, 255, 255, 255))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
gravity = Gravity.CENTER gravity = Gravity.CENTER
setPadding(dp(8), dp(8), dp(8), dp(8)) setPadding(dp(8), dp(8), dp(8), dp(8))
} }
titleRow.addView(title)
titleRow.addView(closeButton)
languageInfoView = TextView(this).apply { languageInfoView = TextView(this).apply {
text = "中文 → 英文" text = "src → tgt"
setTextColor(0xFF666666.toInt()) setTextColor(Color.argb(235, 255, 255, 255))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) 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 { sourceTextView = TextView(this).apply {
text = "正在识别..." text = ""
setTextColor(0xFF333333.toInt()) setTextColor(Color.argb(235, 255, 255, 255))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
setPadding(0, dp(8), 0, dp(4)) setPadding(0, dp(8), 0, dp(4))
maxLines = 3 maxLines = 3
ellipsize = TextUtils.TruncateAt.END ellipsize = TextUtils.TruncateAt.END
setLineSpacing(dpF(2f), 1.1f)
visibility = View.GONE
} }
translatedTextView = TextView(this).apply { translatedTextView = TextView(this).apply {
text = "翻译中..." text = ""
setTextColor(0xFF1976D2.toInt()) setTextColor(0xFF90CAF9.toInt())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f)
setPadding(0, dp(4), 0, dp(8)) setPadding(0, dp(4), 0, dp(8))
maxLines = 3 maxLines = 3
ellipsize = TextUtils.TruncateAt.END ellipsize = TextUtils.TruncateAt.END
setLineSpacing(dpF(2f), 1.1f)
visibility = View.GONE
} }
timestampView = TextView(this).apply { sourceLoadingView = TextView(this).apply {
text = "" text = ""
setTextColor(0xFF999999.toInt()) setTextColor(Color.argb(235, 255, 255, 255))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 10f) setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
gravity = Gravity.END 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(titleRow)
container.addView(languageInfoView)
container.addView(sourceTextView) container.addView(sourceTextView)
container.addView(sourceLoadingView)
container.addView(translatedTextView) container.addView(translatedTextView)
container.addView(timestampView) container.addView(translatedLoadingView)
applyUiScale(uiScale)
return container return container
} }
@ -270,21 +338,50 @@ class FloatingWindowService : Service() {
private var initialTouchX: Float = 0f private var initialTouchX: Float = 0f
private var initialTouchY: Float = 0f private var initialTouchY: Float = 0f
private var dragging: Boolean = false 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 { override fun onTouch(v: View, event: MotionEvent): Boolean {
val lp = layoutParams ?: return false val lp = layoutParams ?: return false
val wm = windowManager ?: return false val wm = windowManager ?: return false
when (event.action) { when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
initialX = lp.x initialX = lp.x
initialY = lp.y initialY = lp.y
initialTouchX = event.rawX initialTouchX = event.rawX
initialTouchY = event.rawY initialTouchY = event.rawY
if (!isMinimized && isTouchingView(closeButton, event.rawX, event.rawY)
) {
return false
}
dragging = 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 return true
} }
MotionEvent.ACTION_MOVE -> { 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 dx = (event.rawX - initialTouchX).toInt()
val dy = (event.rawY - initialTouchY).toInt() val dy = (event.rawY - initialTouchY).toInt()
if (!dragging && (kotlin.math.abs(dx) > threshold || kotlin.math.abs(dy) > threshold)) { if (!dragging && (kotlin.math.abs(dx) > threshold || kotlin.math.abs(dy) > threshold)) {
@ -304,12 +401,21 @@ class FloatingWindowService : Service() {
return true return true
} }
MotionEvent.ACTION_POINTER_UP -> {
val remainingPointers = event.pointerCount - 1
if (scaling && remainingPointers < 2) {
scaling = false
return true
}
return false
}
MotionEvent.ACTION_UP -> { MotionEvent.ACTION_UP -> {
if (!dragging) updateViewState(!isMinimized) if (!dragging && !scaling) updateViewState(!isMinimized)
dragging = false dragging = false
scaling = false
return true return true
} }
else -> return false else -> return false
} }
} }
@ -320,6 +426,13 @@ class FloatingWindowService : Service() {
isMinimized = minimized isMinimized = minimized
minimizedView?.visibility = if (minimized) View.VISIBLE else View.GONE minimizedView?.visibility = if (minimized) View.VISIBLE else View.GONE
expandedView?.visibility = if (minimized) View.GONE else View.VISIBLE 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") notifyStateChanged(if (minimized) "minimized" else "expanded")
} }
@ -328,26 +441,58 @@ class FloatingWindowService : Service() {
val translatedText = intent.getStringExtra(EXTRA_TRANSLATED_TEXT) val translatedText = intent.getStringExtra(EXTRA_TRANSLATED_TEXT)
val sourceLang = intent.getStringExtra(EXTRA_SOURCE_LANGUAGE) val sourceLang = intent.getStringExtra(EXTRA_SOURCE_LANGUAGE)
val targetLang = intent.getStringExtra(EXTRA_TARGET_LANGUAGE) val targetLang = intent.getStringExtra(EXTRA_TARGET_LANGUAGE)
val timestamp = intent.getLongExtra(EXTRA_TIMESTAMP, 0L)
val isIntermediate = intent.getBooleanExtra(EXTRA_IS_INTERMEDIATE, false) 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 -> languageInfoView?.let { view ->
val langText = (sourceLang ?: "") + " → " + (targetLang ?: "") val langText = (sourceLang ?: "") + " → " + (targetLang ?: "")
view.text = langText view.text = langText
} }
sourceTextView?.let { view -> sourceTextView?.let { view ->
val value = sourceText ?: "" if (hasSource) {
view.text = if (TextUtils.isEmpty(value)) "正在识别..." else value 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 -> translatedTextView?.let { view ->
val value = translatedText ?: "" if (hasTranslated) {
view.text = if (TextUtils.isEmpty(value)) "翻译中..." else value 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 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) { private fun setPosition(intent: Intent) {
@ -386,18 +531,108 @@ class FloatingWindowService : Service() {
channel.invokeMethod("onFloatingWindowClicked", null) channel.invokeMethod("onFloatingWindowClicked", null)
} }
private fun formatTimestamp(ts: Long): String { private fun dp(value: Int): Int {
if (ts <= 0) return "" return (value * resources.displayMetrics.density).toInt()
return try { }
val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
sdf.format(Date(ts)) private fun dpF(value: Float): Float {
} catch (_: Exception) { 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 { private fun stopSourceLoading() {
return (value * resources.displayMetrics.density).toInt() 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.LinearLayout
import android.widget.TextView import android.widget.TextView
/// 录音悬浮条服务
class RecordingFloatingService : Service() { class RecordingFloatingService : Service() {
companion object { companion object {
const val ACTION_SHOW = "com.example.native_plugin.action.SHOW_RECORDING_FLOATING_BAR" const val ACTION_SHOW = "com.example.native_plugin.action.SHOW_RECORDING_FLOATING_BAR"

Loading…
Cancel
Save