* softInputRuntime —— 用例页一键执行的运行时(信号总线 + 帧采样 + 真实弹收周期)。
*
* 信号来源二选一(按用例页的避让承担方):
* view —— <AvoidSoftInputView> 的 4 个组件事件(onSoftInputShown / onSoftInputHidden /
* onSoftInputHeightChange / onSoftInputAppliedOffsetChange);
* module —— TurboModule 的 4 个 device 事件(经 AvoidSoftInput.onXxx 订阅)。
* 两路信号总是同时接入(供 F04-002 等两侧互证),cycle() 只按 primary 判定弹收进度。
*
* 真实性:focus() 经 TextInput.focus() 真实唤起系统软键盘,dismiss() 经 Keyboard.dismiss() 真实收起;
* 所有读数只来自库抛出的事件流(appliedOffset 逐帧 / shown / hidden / heightChange 载荷),页面不自算位移。
*/
import { Keyboard } from 'react-native';
import type { TextInput } from 'react-native';
import { AvoidSoftInput } from '@hxa-rn/react-native-avoid-softinput';
export type SignalSource = 'view' | 'module';
export type SignalKind = 'shown' | 'hidden' | 'heightChange' | 'appliedOffset';
export interface Signal {
source: SignalSource;
kind: SignalKind;
value: number;
t: number;
}
export interface SourceCounters {
shown: number;
hidden: number;
heightChange: number;
appliedOffset: number;
lastShownHeight: number;
lastHiddenHeight: number;
lastHeight: number;
lastAppliedOffset: number;
}
export interface PhaseMetrics {
frames: number;
peak: number;
last: number;
delayMs: number;
durationMs: number;
quarterRatio: number;
midRatio: number;
signalHeight: number;
}
export interface CycleResult {
show: PhaseMetrics;
hide: PhaseMetrics;
}
export interface CycleOptions {
showBudgetMs?: number;
hideBudgetMs?: number;
onShown?: (show: PhaseMetrics) => void | Promise<void>;
keepShown?: boolean;
}
interface Sample {
t: number;
offset: number;
}
const SIGNAL_TIMEOUT_MS = 8000;
const SETTLE_IDLE_MS = 450;
const SETTLE_EXTRA_MS = 400;
const SETTLE_CAP_MS = 4000;
function emptyCounters(): SourceCounters {
return {
shown: 0,
hidden: 0,
heightChange: 0,
appliedOffset: 0,
lastShownHeight: -1,
lastHiddenHeight: -1,
lastHeight: -1,
lastAppliedOffset: 0,
};
}
function emptyMetrics(): PhaseMetrics {
return {
frames: 0,
peak: 0,
last: 0,
delayMs: -1,
durationMs: -1,
quarterRatio: -1,
midRatio: -1,
signalHeight: -1,
};
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export class SoftInputRuntime {
public readonly counters: Record<SignalSource, SourceCounters> = {
view: emptyCounters(),
module: emptyCounters(),
};
private primary: SignalSource;
private inputRef: { current: TextInput | null };
private listeners: Array<(s: Signal) => void> = [];
private deviceSubs: Array<{ remove: () => void }> = [];
private samples: Sample[] = [];
private recording = false;
private lastSampleT = 0;
constructor(primary: SignalSource, inputRef: { current: TextInput | null }) {
this.primary = primary;
this.inputRef = inputRef;
}
attachDeviceEvents(): () => void {
const subs = [
AvoidSoftInput.onSoftInputShown(({ softInputHeight }) => this.emit('module', 'shown', softInputHeight)),
AvoidSoftInput.onSoftInputHidden(({ softInputHeight }) => this.emit('module', 'hidden', softInputHeight)),
AvoidSoftInput.onSoftInputHeightChange(({ softInputHeight }) => this.emit('module', 'heightChange', softInputHeight)),
AvoidSoftInput.onSoftInputAppliedOffsetChange(({ appliedOffset }) => this.emit('module', 'appliedOffset', appliedOffset)),
];
this.deviceSubs = subs;
return () => {
subs.forEach((s) => s.remove());
this.deviceSubs = [];
};
}
emit(source: SignalSource, kind: SignalKind, value: number): void {
const t = Date.now();
const c = this.counters[source];
if (kind === 'shown') {
c.shown += 1;
c.lastShownHeight = value;
c.lastHeight = value;
} else if (kind === 'hidden') {
c.hidden += 1;
c.lastHiddenHeight = value;
c.lastHeight = value;
} else if (kind === 'heightChange') {
c.heightChange += 1;
c.lastHeight = value;
} else {
c.appliedOffset += 1;
c.lastAppliedOffset = value;
if (source === this.primary && this.recording) {
this.samples.push({ t, offset: value });
this.lastSampleT = t;
}
}
const sig: Signal = { source, kind, value, t };
this.listeners.slice().forEach((fn) => fn(sig));
}
subscribe(fn: (s: Signal) => void): () => void {
this.listeners.push(fn);
return () => {
this.listeners = this.listeners.filter((x) => x !== fn);
};
}
resetCounters(): void {
this.counters.view = emptyCounters();
this.counters.module = emptyCounters();
}
get primarySource(): SignalSource {
return this.primary;
}
get currentOffset(): number {
return this.counters[this.primary].lastAppliedOffset;
}
focus(): boolean {
const node = this.inputRef.current;
if (!node) {
return false;
}
node.focus();
return true;
}
dismiss(): void {
Keyboard.dismiss();
const node = this.inputRef.current;
if (node && node.isFocused && node.isFocused()) {
node.blur();
}
}
waitSignal(kind: SignalKind, timeoutMs: number = SIGNAL_TIMEOUT_MS): Promise<Signal> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
off();
reject(new Error(`等待 ${this.primary} 侧 ${kind} 信号超时(${timeoutMs}ms)`));
}, timeoutMs);
const off = this.subscribe((s) => {
if (s.source === this.primary && s.kind === kind) {
clearTimeout(timer);
off();
resolve(s);
}
});
});
}
* 等偏移帧流静默:从 sinceT 起至少过 budgetMs+SETTLE_EXTRA_MS,且最后一帧后静默 SETTLE_IDLE_MS;上限 budget+cap。
*/
async waitSettle(sinceT: number, budgetMs: number): Promise<void> {
const minEnd = sinceT + budgetMs + SETTLE_EXTRA_MS;
const cap = sinceT + budgetMs + SETTLE_CAP_MS;
for (;;) {
const now = Date.now();
if (now >= cap) {
return;
}
if (now >= minEnd && now - Math.max(this.lastSampleT, sinceT) >= SETTLE_IDLE_MS) {
return;
}
await sleep(60);
}
}
private beginRecording(): void {
this.samples = [];
this.recording = true;
this.lastSampleT = 0;
}
private endRecording(signalT: number, signalHeight: number, baseline: number): PhaseMetrics {
this.recording = false;
const samples = this.samples;
this.samples = [];
const m = emptyMetrics();
m.frames = samples.length;
m.signalHeight = signalHeight;
if (samples.length === 0) {
m.last = baseline;
return m;
}
m.last = samples[samples.length - 1].offset;
for (let i = 0; i < samples.length; i++) {
const abs = Math.abs(samples[i].offset);
if (abs > m.peak) {
m.peak = abs;
}
}
let firstIdx = -1;
for (let i = 0; i < samples.length; i++) {
if (samples[i].offset !== baseline) {
firstIdx = i;
break;
}
}
if (firstIdx < 0) {
return m;
}
const first = samples[firstIdx];
const last = samples[samples.length - 1];
m.delayMs = Math.max(0, Math.round(first.t - signalT));
m.durationMs = Math.round(last.t - first.t);
const span = last.offset - baseline;
const startT: number = first.t - samples[0].t <= 150 ? samples[0].t : first.t - 33;
const ratioAt = (fraction: number): number => {
if (span === 0) {
return -1;
}
const target = startT + (last.t - startT) * fraction;
let prev: Sample = samples[0];
for (let i = 0; i < samples.length; i++) {
const s = samples[i];
if (s.t >= target) {
if (s.t === prev.t) {
return Math.round(((s.offset - baseline) / span) * 100);
}
const k = (target - prev.t) / (s.t - prev.t);
const v = prev.offset + (s.offset - prev.offset) * k;
return Math.round(((v - baseline) / span) * 100);
}
prev = s;
}
return Math.round(((last.offset - baseline) / span) * 100);
};
m.quarterRatio = ratioAt(0.25);
m.midRatio = ratioAt(0.5);
return m;
}
async cycle(opts: CycleOptions = {}): Promise<CycleResult> {
const showBudget = opts.showBudgetMs ?? 660;
const hideBudget = opts.hideBudgetMs ?? 220;
const baseline = this.currentOffset;
this.beginRecording();
if (!this.focus()) {
this.recording = false;
throw new Error('底部输入框未挂载,无法程序化聚焦');
}
const shownSig = await this.waitSignal('shown');
await this.waitSettle(shownSig.t, showBudget);
const show = this.endRecording(shownSig.t, shownSig.value, baseline);
if (opts.onShown) {
await opts.onShown(show);
}
if (opts.keepShown) {
return { show, hide: emptyMetrics() };
}
const hide = await this.dismissAndSettle(hideBudget);
return { show, hide };
}
async dismissAndSettle(hideBudgetMs: number = 220): Promise<PhaseMetrics> {
const baseline = this.currentOffset;
this.beginRecording();
this.dismiss();
const hiddenSig = await this.waitSignal('hidden');
await this.waitSettle(hiddenSig.t, hideBudgetMs);
return this.endRecording(hiddenSig.t, hiddenSig.value, baseline);
}
async awaitExternalHide(hideBudgetMs: number = 220, timeoutMs: number = 120000): Promise<PhaseMetrics> {
const baseline = this.currentOffset;
this.beginRecording();
const hiddenSig = await this.waitSignal('hidden', timeoutMs);
await this.waitSettle(hiddenSig.t, hideBudgetMs);
return this.endRecording(hiddenSig.t, hiddenSig.value, baseline);
}
dispose(): void {
this.deviceSubs.forEach((s) => s.remove());
this.deviceSubs = [];
this.listeners = [];
this.recording = false;
}
}
export function fmtRatio(m: PhaseMetrics): string {
if (m.quarterRatio < 0 || m.midRatio < 0) {
return '未采到';
}
return `${m.quarterRatio}%/${m.midRatio}%`;
}
export function fmtTiming(m: PhaseMetrics): string {
if (m.delayMs < 0) {
return '未采到';
}
return `${m.delayMs}/${m.durationMs}`;
}
export function within(actual: number, expected: number, tol: number): boolean {
return actual >= 0 && Math.abs(actual - expected) <= tol;
}
export function judge(checks: Array<[string, boolean]>): { pass: boolean; reason?: string } {
const failed = checks.filter((c) => !c[1]).map((c) => c[0]);
if (failed.length === 0) {
return { pass: true };
}
return { pass: false, reason: `未达预期:${failed.join(';')}` };
}
export function errorText(e: unknown): string {
if (e instanceof Error) {
return `error: [${e.name}] ${e.message}`;
}
return `error: [unknown] ${String(e)}`;
}