根因: Tauri targetSdk 37 强制 edge-to-edge, MainActivity 不处理 IME Insets, 系统不再因键盘缩小窗口, 清单 adjustResize 失效, 键盘直接盖住 WebView。 - 新增 scripts/ci/android-softinput.sh: init 后注入 windowSoftInputMode=adjustResize, 并重写 MainActivity.kt 监听 IME Insets, 键盘可见时把高度加到内容容器底部 (API 30+), 让 WebView 真正变矮, 所有 bottom 弹层自动上移 - CI (android-build.yml) 与本地 build-apk.sh 在 init 之后、build 之前调用该脚本 (幂等) - 新增全局 useKeepInputVisible: 输入框聚焦后用 scroll-into-view-if-needed 滚入可视区, 覆盖页面滚动与 Popup 内部滚动 - 弹层补 max-height + 内部滚动, 防止键盘弹出后顶部被顶出屏幕 - calibrateSafeTop 在键盘弹出时保留旧值, 避免误判导致整页跳动
This commit is contained in:
@@ -123,6 +123,10 @@ jobs:
|
|||||||
cd src-tauri
|
cd src-tauri
|
||||||
npx tauri android init --ci
|
npx tauri android init --ci
|
||||||
|
|
||||||
|
# 软键盘避让: 必须紧跟 init (gen/android 每次全新生成), 在 build 之前完成
|
||||||
|
- name: Inject keyboard insets fix
|
||||||
|
run: bash scripts/ci/android-softinput.sh
|
||||||
|
|
||||||
- name: Inject release signing config
|
- name: Inject release signing config
|
||||||
run: bash scripts/ci/android-signing.sh
|
run: bash scripts/ci/android-signing.sh
|
||||||
env:
|
env:
|
||||||
|
|||||||
Generated
+16
@@ -30,6 +30,7 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
"scroll-into-view-if-needed": "^3.1.0",
|
||||||
"swiper": "^14.4.0",
|
"swiper": "^14.4.0",
|
||||||
"tauri-plugin-hotswap-api": "^0.0.4",
|
"tauri-plugin-hotswap-api": "^0.0.4",
|
||||||
"ws": "^8.21.2",
|
"ws": "^8.21.2",
|
||||||
@@ -3639,6 +3640,12 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/compute-scroll-into-view": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/concurrently": {
|
"node_modules/concurrently": {
|
||||||
"version": "9.2.4",
|
"version": "9.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
|
||||||
@@ -6394,6 +6401,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/scroll-into-view-if-needed": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"compute-scroll-into-view": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/section-matter": {
|
"node_modules/section-matter": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
"scroll-into-view-if-needed": "^3.1.0",
|
||||||
"swiper": "^14.4.0",
|
"swiper": "^14.4.0",
|
||||||
"tauri-plugin-hotswap-api": "^0.0.4",
|
"tauri-plugin-hotswap-api": "^0.0.4",
|
||||||
"ws": "^8.21.2",
|
"ws": "^8.21.2",
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# CI / 本地共用: 修复 Android 软键盘遮挡输入框
|
||||||
|
# 在 `tauri android init` 之后、`tauri android build` 之前运行。
|
||||||
|
#
|
||||||
|
# 背景:
|
||||||
|
# Tauri 2 生成的 MainActivity 调用 enableEdgeToEdge(), 且不处理 IME Insets。
|
||||||
|
# Android 15+ (targetSdk 35+) 强制 edge-to-edge 后, 系统不再因软键盘缩小窗口,
|
||||||
|
# 清单里的 adjustResize 也随之失效 → 键盘直接盖住 WebView, 底部弹层输入框被遮挡。
|
||||||
|
#
|
||||||
|
# 做法:
|
||||||
|
# 1. AndroidManifest.xml 的 MainActivity 补 windowSoftInputMode="adjustResize"
|
||||||
|
# (兼容 API < 35 的旧行为)
|
||||||
|
# 2. 重写 MainActivity.kt: 监听 WindowInsetsCompat.Type.ime(), 键盘可见时
|
||||||
|
# 把 IME 高度作为底部内边距加给内容容器 (等价恢复旧版 adjustResize),
|
||||||
|
# 这样 WebView 真正变矮, 所有 position:fixed / bottom:0 的弹层自动上移。
|
||||||
|
#
|
||||||
|
# 幂等: 重复执行不会重复注入。
|
||||||
|
# 注意: gen/android 被 gitignore, CI 每次 init 都重新生成, 所以每次构建都要跑本脚本。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MANIFEST="src-tauri/gen/android/app/src/main/AndroidManifest.xml"
|
||||||
|
MAIN_ACTIVITY_DIR="src-tauri/gen/android/app/src/main/java"
|
||||||
|
|
||||||
|
if [ ! -f "$MANIFEST" ]; then
|
||||||
|
echo "[softinput] ERROR: 找不到 $MANIFEST (请先运行 tauri android init)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
MAIN_ACTIVITY=$(find "$MAIN_ACTIVITY_DIR" -name MainActivity.kt -print -quit 2>/dev/null || true)
|
||||||
|
if [ -z "$MAIN_ACTIVITY" ]; then
|
||||||
|
echo "[softinput] ERROR: 找不到 MainActivity.kt (请先运行 tauri android init)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[softinput] 注入 Android 软键盘避让配置..."
|
||||||
|
|
||||||
|
python3 - "$MANIFEST" "$MAIN_ACTIVITY" <<'PYEOF'
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
manifest_path, activity_path = sys.argv[1], sys.argv[2]
|
||||||
|
|
||||||
|
# ---------- 1. AndroidManifest.xml: 补 windowSoftInputMode ----------
|
||||||
|
src = open(manifest_path, encoding='utf-8').read()
|
||||||
|
if 'windowSoftInputMode' not in src:
|
||||||
|
m = re.search(r'<activity\b', src)
|
||||||
|
if not m:
|
||||||
|
print('[softinput] ERROR: AndroidManifest.xml 中找不到 <activity>', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
src = src[:m.end()] + '\n android:windowSoftInputMode="adjustResize"' + src[m.end():]
|
||||||
|
open(manifest_path, 'w', encoding='utf-8').write(src)
|
||||||
|
print('[softinput] AndroidManifest.xml: 已注入 windowSoftInputMode="adjustResize"')
|
||||||
|
else:
|
||||||
|
print('[softinput] AndroidManifest.xml: 已有 windowSoftInputMode, 跳过')
|
||||||
|
|
||||||
|
# ---------- 2. MainActivity.kt: IME Insets 处理 ----------
|
||||||
|
src = open(activity_path, encoding='utf-8').read()
|
||||||
|
if '[iboard] keyboard-insets' in src:
|
||||||
|
print('[softinput] MainActivity.kt: 已注入键盘避让, 跳过')
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
m = re.search(r'^\s*package\s+([\w.]+)\s*$', src, flags=re.M)
|
||||||
|
if not m:
|
||||||
|
print('[softinput] ERROR: MainActivity.kt 中找不到 package 声明', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
package = m.group(1)
|
||||||
|
|
||||||
|
kotlin = f'''package {package}
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
|
||||||
|
class MainActivity : TauriActivity() {{
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {{
|
||||||
|
enableEdgeToEdge()
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// [iboard] keyboard-insets
|
||||||
|
// Android 15+ 强制 edge-to-edge 后, 系统不再因软键盘缩小窗口, adjustResize 失效,
|
||||||
|
// 键盘会直接盖住 WebView 底部。这里监听 IME Insets, 键盘可见时把键盘高度
|
||||||
|
// 作为内容容器的底部内边距, 等价恢复旧版 adjustResize 行为。
|
||||||
|
// API < 30 由清单里的 adjustResize 处理, 避免系统已缩小窗口时重复留白。
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {{
|
||||||
|
val content = findViewById<View>(android.R.id.content)
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(content) {{ view, insets ->
|
||||||
|
// 键盘隐藏时 getInsets(ime).bottom 为 0 (AndroidX 在 API < 30 也做了兼容)
|
||||||
|
val imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
|
||||||
|
view.setPadding(0, 0, 0, imeBottom)
|
||||||
|
insets
|
||||||
|
}}
|
||||||
|
ViewCompat.requestApplyInsets(content)
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
'''
|
||||||
|
open(activity_path, 'w', encoding='utf-8').write(kotlin)
|
||||||
|
print(f'[softinput] MainActivity.kt: 已注入 IME Insets 处理 ({activity_path})')
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
# 断言: 防止注入被模板覆盖或路径写错
|
||||||
|
if ! grep -q 'windowSoftInputMode' "$MANIFEST"; then
|
||||||
|
echo "[softinput] ERROR: AndroidManifest.xml 注入未生效" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q '\[iboard\] keyboard-insets' "$MAIN_ACTIVITY"; then
|
||||||
|
echo "[softinput] ERROR: MainActivity.kt 注入未生效" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[softinput] 完成"
|
||||||
@@ -31,6 +31,9 @@ if [ ! -d "gen/android" ]; then
|
|||||||
fi
|
fi
|
||||||
cd "$WIN_PROJECT"
|
cd "$WIN_PROJECT"
|
||||||
|
|
||||||
|
# 0.5 软键盘避让: 注入 MainActivity 的 IME Insets 处理 (幂等, 每次都要跑)
|
||||||
|
bash scripts/ci/android-softinput.sh
|
||||||
|
|
||||||
# Android 后端地址, 写到前端 bundle 里 (覆盖默认的 /api 相对路径)
|
# Android 后端地址, 写到前端 bundle 里 (覆盖默认的 /api 相对路径)
|
||||||
export VITE_API_BASE="${VITE_API_BASE:-https://iboard.honor3.com}"
|
export VITE_API_BASE="${VITE_API_BASE:-https://iboard.honor3.com}"
|
||||||
echo "VITE_API_BASE=$VITE_API_BASE"
|
echo "VITE_API_BASE=$VITE_API_BASE"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import CheckinPage from './pages/checkin/CheckinPage.jsx'
|
|||||||
import TaskPage from './pages/task/TaskPage.jsx'
|
import TaskPage from './pages/task/TaskPage.jsx'
|
||||||
import { useAuth } from './auth/AuthContext.jsx'
|
import { useAuth } from './auth/AuthContext.jsx'
|
||||||
import { PowerSyncProvider } from './lib/powersync/react.jsx'
|
import { PowerSyncProvider } from './lib/powersync/react.jsx'
|
||||||
|
import useKeepInputVisible from './hooks/useKeepInputVisible.js'
|
||||||
|
|
||||||
// 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中)
|
// 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中)
|
||||||
const HOME_PATH = '/checkin'
|
const HOME_PATH = '/checkin'
|
||||||
@@ -31,6 +32,9 @@ function RequireAuth({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
// 全局: 输入框聚焦时滚进可视区 (登录页与内页都覆盖)
|
||||||
|
useKeepInputVisible()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PowerSyncProvider>
|
<PowerSyncProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import scrollIntoView from 'scroll-into-view-if-needed'
|
||||||
|
|
||||||
|
// 全局兜底: 输入框获得焦点后保证它滚进可视区。
|
||||||
|
//
|
||||||
|
// 原生层 (scripts/ci/android-softinput.sh) 已经把软键盘高度从 WebView 里扣掉,
|
||||||
|
// 弹层 (position: fixed; bottom: 0) 会自动上移; 这里只负责把被聚焦的控件
|
||||||
|
// 滚到可视区中间, 覆盖页面滚动、Popup 内部滚动等所有场景。
|
||||||
|
//
|
||||||
|
// 用 scroll-into-view-if-needed (成熟库) 而不是手写滚动计算:
|
||||||
|
// 它会自动处理嵌套滚动容器, 并在目标已可见时不做多余滚动。
|
||||||
|
const FIELD_SELECTOR = 'input, textarea, [contenteditable="true"]'
|
||||||
|
|
||||||
|
function isField(el) {
|
||||||
|
return !!el && typeof el.matches === 'function' && el.matches(FIELD_SELECTOR)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useKeepInputVisible() {
|
||||||
|
useEffect(() => {
|
||||||
|
let timer = 0
|
||||||
|
|
||||||
|
const reveal = (el, behavior) => {
|
||||||
|
if (!isField(el)) return
|
||||||
|
scrollIntoView(el, {
|
||||||
|
behavior,
|
||||||
|
block: 'center',
|
||||||
|
inline: 'nearest',
|
||||||
|
scrollMode: 'if-needed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFocusIn = (e) => {
|
||||||
|
const el = e.target
|
||||||
|
if (!isField(el)) return
|
||||||
|
// 先滚一次 (部分机型聚焦后立刻可用), 等键盘动画结束后再校正一次
|
||||||
|
reveal(el, 'smooth')
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => reveal(el, 'smooth'), 260)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 键盘弹出/收起会改变可视区高度 (原生层已让 WebView 变矮)
|
||||||
|
const onViewportResize = () => {
|
||||||
|
const el = document.activeElement
|
||||||
|
if (!isField(el)) return
|
||||||
|
reveal(el, 'auto')
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('focusin', onFocusIn)
|
||||||
|
window.addEventListener('resize', onViewportResize)
|
||||||
|
window.visualViewport?.addEventListener('resize', onViewportResize)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
document.removeEventListener('focusin', onFocusIn)
|
||||||
|
window.removeEventListener('resize', onViewportResize)
|
||||||
|
window.visualViewport?.removeEventListener('resize', onViewportResize)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
@@ -54,6 +54,9 @@ function calibrateSafeTop() {
|
|||||||
// 只在"纵向且高度差合理"时采用, 避免横屏误判
|
// 只在"纵向且高度差合理"时采用, 避免横屏误判
|
||||||
const isPortrait = viewW <= viewH
|
const isPortrait = viewW <= viewH
|
||||||
const diff = screenH - viewH
|
const diff = screenH - viewH
|
||||||
|
// 软键盘弹出会让 visualViewport 高度骤减 (diff 远大于状态栏高度)。
|
||||||
|
// 此时必须保留上一次算出的值, 否则会被当成"没有状态栏"而改成 24px, 整页跳动。
|
||||||
|
if (isPortrait && diff >= 120 && root.style.getPropertyValue('--safe-top')) return
|
||||||
const measured = isPortrait && diff > 0 && diff < 120 ? diff : 0
|
const measured = isPortrait && diff > 0 && diff < 120 ? diff : 0
|
||||||
|
|
||||||
// 取 env() 与实测值的较大者; 两者都失效时给 24px 保底
|
// 取 env() 与实测值的较大者; 两者都失效时给 24px 保底
|
||||||
|
|||||||
@@ -706,6 +706,21 @@ body.me-safe-top .app-safe-top { height: 0; }
|
|||||||
flex: 1; border: 1px solid var(--color-border); border-radius: 6px; padding: 6px 8px;
|
flex: 1; border: 1px solid var(--color-border); border-radius: 6px; padding: 6px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----- 软键盘避让 (Android) -----
|
||||||
|
原生层把键盘高度从 WebView 里扣掉后, 可视高度会变小;
|
||||||
|
弹层内容超过剩余高度时改为内部滚动, 避免顶部/输入框被顶出屏幕。 */
|
||||||
|
.adm-popup-body {
|
||||||
|
max-height: 100vh;
|
||||||
|
max-height: 100dvh;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.adm-dialog-body {
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
max-height: calc(100dvh - 48px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* ----- 表单弹窗 (打卡项 / 清单) ----- */
|
/* ----- 表单弹窗 (打卡项 / 清单) ----- */
|
||||||
.form-popup { padding: 16px 16px 8px; }
|
.form-popup { padding: 16px 16px 8px; }
|
||||||
.form-title { font-size: 16px; font-weight: 700; color: var(--color-text); margin-bottom: 12px; }
|
.form-title { font-size: 16px; font-weight: 700; color: var(--color-text); margin-bottom: 12px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user