feat: native tab panel overlay, webapp height no longer reserved

This commit is contained in:
2026-08-16 21:41:05 +08:00
parent 0154ebc999
commit 9fc3272923
3 changed files with 90 additions and 8 deletions
@@ -6,12 +6,15 @@ import android.content.Intent
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.text.TextUtils
import android.util.Base64
import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
@@ -19,6 +22,9 @@ import android.view.WindowInsetsController
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.PopupWindow
import android.widget.TextView
import androidx.core.app.NotificationManagerCompat
import com.google.gson.Gson
import top.yeij.hearth.app.AndroidAppSource
@@ -51,11 +57,12 @@ class MainActivity : Activity() {
// Placeholder manifest/catalog URLs; replace with the real server on device
private const val MANIFEST_URL = "https://example.com/hearth/manifest.json"
private const val CATALOG_URL = "https://example.com/hearth/catalog.json"
// 内容 WebView 顶部预留高度(dp):顶栏(约 54dp)+ 横向标签面板(约 45dp),
// 取 104dp 留余量,确保标签面板完全不被内容 WebView 覆盖
// Content WebView top reserve (dp): topbar (~54dp) + horizontal tab panel
// (~45dp); use 104dp with margin so the tab panel is never covered
private const val TOPBAR_HEIGHT_DP = 104
// 内容 WebView 顶部预留高度(dp):默认只留顶栏(约 54dp;标签面板弹出时
// 通过 setContentTopMargin 临时增大,收起后恢复,避免一直压缩 webapp 高度
// Content WebView top reserve (dp): default only the topbar (~54dp); the tab
// panel temporarily grows it via setContentTopMargin and restores on collapse,
// avoiding permanently shrinking the webapp height
private const val TOPBAR_HEIGHT_DP = 56
// 全局侧边栏收起宽度(dp),内容 WebView 左侧预留,避免挡住侧边栏
// Global sidebar collapsed width (dp); content WebViews reserve it on the
// left to avoid covering the sidebar
@@ -197,6 +204,7 @@ class MainActivity : Activity() {
brightness = brightnessProvider,
serverUrl = serverUrlProvider,
wallpaper = wallpaperProvider,
onShowTabPanel = { showNativeTabPanel() },
)
root = FrameLayout(this)
setContentView(root)
@@ -347,6 +355,67 @@ class MainActivity : Activity() {
}
}
// 弹出原生标签面板(PopupWindow,在内容 WebView 之上,不占用预留高度)
// Show the native tab panel (PopupWindow above the content WebView, no reserved height)
private fun showNativeTabPanel() {
val tabs = webAppContainer.tabs()
if (tabs.isEmpty()) return
val container = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(8), dp(8), dp(8), dp(8))
background = GradientDrawable().apply {
setColor(0xE6202024.toInt())
cornerRadius = dp(16).toFloat()
}
}
val popup = PopupWindow(container, dp(220), ViewGroup.LayoutParams.WRAP_CONTENT, true)
popup.isOutsideTouchable = true
tabs.forEach { tab ->
val row = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(12), dp(8), dp(12), dp(8))
if (tab.active) background = GradientDrawable().apply {
setColor(Color.parseColor("#ff6900"))
cornerRadius = dp(10).toFloat()
}
}
val name = TextView(this).apply {
text = tab.name
textSize = 14f
setTextColor(Color.WHITE)
isSingleLine = true
ellipsize = TextUtils.TruncateAt.END
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
val close = TextView(this).apply {
text = ""
textSize = 14f
setTextColor(Color.WHITE)
setPadding(dp(10), 0, 0, 0)
setOnClickListener {
bridge.closeWebApp(tab.id)
popup.dismiss()
}
}
row.addView(name)
row.addView(close)
row.setOnClickListener {
bridge.switchTab(tab.id)
popup.dismiss()
}
container.addView(row)
}
popup.showAtLocation(root, Gravity.TOP or Gravity.END, dp(16), dp(64))
}
// dp 转 px
// dp to px
private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt()
// 检测通知使用权是否已授予,未授予时打日志提示(UI 引导入口留后续)
// Check whether notification access is granted; log a hint when not
// (the settings UI entry is deferred to a later task)
@@ -69,6 +69,11 @@ class JsBridge(
// 壁纸提供接口(base64
// Wallpaper provider (base64)
private val wallpaper: WallpaperProvider? = null,
// 标签面板回调:由 MainActivity 实现,弹出原生标签面板(PopupWindow,在内容
// WebView 之上,不占用预留高度)
// Tab panel callback implemented by MainActivity: shows a native tab panel
// (PopupWindow above the content WebView, no reserved height)
private val onShowTabPanel: (() -> Unit)? = null,
) {
private val gson = com.google.gson.Gson()
@@ -212,6 +217,14 @@ class JsBridge(
Log.d("HearthBridge", "hideWebApps called")
}
// 弹出原生标签面板(H5 顶栏标签按钮点击时调用)
// Show the native tab panel (called when the H5 topbar tab button is tapped)
@android.webkit.JavascriptInterface
fun showTabPanel() {
Log.d("HearthBridge", "showTabPanel called")
onShowTabPanel?.invoke()
}
// 清单内存缓存:首次拉取成功后缓存,后续复用(避免 openWebApp 重复 I/O
// 空结果(离线失败)不缓存,下次调用重试拉取,避免空清单被永久缓存
// In-memory manifest cache: cache only on a successful non-empty fetch (avoid