* react-native-avoid-softinput HarmonyOS example.
*
* This page keeps the component path and the module path separate so the demo
* can prove the same API surface exposed by upstream Android/RN code.
*/
import * as React from 'react';
import {
Platform,
SafeAreaView,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
TurboModuleRegistry,
View,
} from 'react-native';
import type { TurboModule } from 'react-native';
import { AvoidSoftInput, AvoidSoftInputView, useSoftInputState } from '@hxa-rn/react-native-avoid-softinput';
import type { SoftInputEasing } from '@hxa-rn/react-native-avoid-softinput';
const EASINGS: SoftInputEasing[] = ['linear', 'easeIn', 'easeOut', 'easeInOut'];
type SceneKey = 'component' | 'module' | 'events';
interface ListenerSpec extends TurboModule {
addListener: (eventName: string) => void;
removeListeners: (count: number) => void;
}
const nativeModule = TurboModuleRegistry.get<ListenerSpec>('AvoidSoftInput');
function DemoText({
children,
numberOfLines,
style,
testID,
}: {
children: React.ReactNode;
numberOfLines?: number;
style?: object | object[];
testID?: string;
}): React.JSX.Element {
return (
<Text allowFontScaling={false} numberOfLines={numberOfLines} style={style} testID={testID}>
{children}
</Text>
);
}
function ActionButton({
title,
onPress,
selected = false,
compact = false,
testID,
}: {
title: string;
onPress: () => void;
selected?: boolean;
compact?: boolean;
testID?: string;
}): React.JSX.Element {
return (
<TouchableOpacity
activeOpacity={0.76}
onPress={onPress}
style={[styles.button, selected && styles.buttonSelected, compact && styles.buttonCompact]}
testID={testID}
>
<DemoText style={[styles.buttonText, selected && styles.buttonTextSelected]}>{title}</DemoText>
</TouchableOpacity>
);
}
function NumberStepper({
label,
value,
onChange,
step,
testID,
}: {
label: string;
value: number;
onChange: (value: number) => void;
step: number;
testID: string;
}): React.JSX.Element {
return (
<View style={styles.settingRow} testID={testID}>
<View style={styles.settingLabel}>
<DemoText style={styles.settingTitle}>{label}</DemoText>
<DemoText style={styles.settingValue}>{value}</DemoText>
</View>
<View style={styles.stepper}>
<ActionButton compact title="-" onPress={() => onChange(Math.max(0, value - step))} />
<ActionButton compact title="+" onPress={() => onChange(value + step)} />
</View>
</View>
);
}
function LogPanel({ title, lines }: { title: string; lines: string[] }): React.JSX.Element {
return (
<View style={styles.logBox} collapsable={false}>
<DemoText style={styles.logTitle}>{title}</DemoText>
<ScrollView style={styles.logScroll} nestedScrollEnabled>
{lines.length === 0 ? (
<DemoText style={styles.logEmpty}>等待输入框聚焦或按钮调用</DemoText>
) : (
lines.map((line, i) => (
<DemoText key={`${i}-${line}`} style={styles.logLine}>
{line}
</DemoText>
))
)}
</ScrollView>
</View>
);
}
function Field({
testID,
placeholder,
value,
onChangeText,
numeric = false,
}: {
testID: string;
placeholder: string;
value: string;
onChangeText: (value: string) => void;
numeric?: boolean;
}): React.JSX.Element {
return (
<TextInput
allowFontScaling={false}
keyboardType={numeric ? 'numeric' : 'default'}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor="#758195"
style={styles.input}
testID={testID}
value={value}
/>
);
}
export default function App(): React.JSX.Element {
const platformOS = Platform.OS as string;
const isHarmony = platformOS === 'harmony';
const softInputState = useSoftInputState();
const [scene, setScene] = React.useState<SceneKey>('component');
const [moduleLog, setModuleLog] = React.useState<string[]>([]);
const [componentLog, setComponentLog] = React.useState<string[]>([]);
const [lastAction, setLastAction] = React.useState('等待操作');
const [moduleEnabled, setModuleEnabled] = React.useState(true);
const [moduleOffsetText, setModuleOffsetText] = React.useState('40');
const [moduleShowDelayText, setModuleShowDelayText] = React.useState('0');
const [moduleShowDurationText, setModuleShowDurationText] = React.useState('660');
const [moduleHideDelayText, setModuleHideDelayText] = React.useState('0');
const [moduleHideDurationText, setModuleHideDurationText] = React.useState('220');
const [moduleEasingIdx, setModuleEasingIdx] = React.useState(0);
const [viewEnabled, setViewEnabled] = React.useState(true);
const [viewOffset, setViewOffset] = React.useState(24);
const [viewEasingIdx, setViewEasingIdx] = React.useState(0);
const [viewShowDelay, setViewShowDelay] = React.useState(0);
const [viewShowDuration, setViewShowDuration] = React.useState(660);
const [viewHideDelay, setViewHideDelay] = React.useState(0);
const [viewHideDuration, setViewHideDuration] = React.useState(220);
const [componentInput, setComponentInput] = React.useState('');
const [moduleInput, setModuleInput] = React.useState('');
const [eventsInput, setEventsInput] = React.useState('');
const pushModuleLog = React.useCallback((line: string) => {
setModuleLog((prev) => [`${new Date().toISOString().slice(11, 19)} ${line}`, ...prev].slice(0, 24));
}, []);
const pushComponentLog = React.useCallback((line: string) => {
setComponentLog((prev) => [`${new Date().toISOString().slice(11, 19)} ${line}`, ...prev].slice(0, 24));
}, []);
React.useEffect(() => {
const shown = AvoidSoftInput.onSoftInputShown(({ softInputHeight }) => {
pushModuleLog(`device shown height(vp)=${softInputHeight}`);
});
const hidden = AvoidSoftInput.onSoftInputHidden(({ softInputHeight }) => {
pushModuleLog(`device hidden height(vp)=${softInputHeight}`);
});
const heightChange = AvoidSoftInput.onSoftInputHeightChange(({ softInputHeight }) => {
pushModuleLog(`device heightChange height(vp)=${softInputHeight}`);
});
const offsetChange = AvoidSoftInput.onSoftInputAppliedOffsetChange(({ appliedOffset }) => {
pushModuleLog(`device appliedOffset(vp)=${appliedOffset}`);
});
return () => {
shown.remove();
hidden.remove();
heightChange.remove();
offsetChange.remove();
};
}, [pushModuleLog]);
React.useEffect(() => {
AvoidSoftInput.setEnabled(scene === 'module' && moduleEnabled);
setLastAction(scene === 'module' ? `模块 setEnabled(${moduleEnabled})` : '当前由组件 props 控制避让');
return () => {
AvoidSoftInput.setEnabled(false);
};
}, [moduleEnabled, scene]);
const callModuleNumberMethod = React.useCallback(
(name: string, rawValue: string, call: (value: number) => void) => {
const value = Number(rawValue) || 0;
call(value);
const line = `${name}(${value})`;
pushModuleLog(line);
setLastAction(line);
},
[pushModuleLog],
);
const switchModuleEasing = React.useCallback(() => {
const next = (moduleEasingIdx + 1) % EASINGS.length;
const easing = EASINGS[next] as SoftInputEasing;
setModuleEasingIdx(next);
AvoidSoftInput.setEasing(easing);
pushModuleLog(`setEasing(${easing})`);
setLastAction(`setEasing(${easing})`);
}, [moduleEasingIdx, pushModuleLog]);
const callAndroidOnlyMethods = React.useCallback(() => {
AvoidSoftInput.setAdjustNothing();
AvoidSoftInput.setAdjustPan();
AvoidSoftInput.setAdjustResize();
AvoidSoftInput.setAdjustUnspecified();
AvoidSoftInput.setDefaultAppSoftInputMode();
AvoidSoftInput.setShouldMimicIOSBehavior(true);
const note = isHarmony
? 'Harmony:JS facade 直接返回,验证兼容入口无害'
: 'Android:已调用 6 个窗口模式方法';
pushModuleLog(note);
setLastAction(note);
}, [isHarmony, pushModuleLog]);
const callListenerTemplate = React.useCallback(() => {
nativeModule?.addListener('softInputShown');
nativeModule?.removeListeners(1);
const note = 'addListener/removeListeners 样板调用完成';
pushModuleLog(note);
setLastAction(note);
}, [pushModuleLog]);
return (
<SafeAreaView style={styles.safeRoot}>
<View style={styles.topSpacer} />
<View style={styles.header}>
<DemoText testID="app-title" style={styles.title} numberOfLines={1}>
react-native-avoid-softinput
</DemoText>
<DemoText style={styles.subtitle}>HarmonyOS · Platform.OS={platformOS}</DemoText>
</View>
<View testID="hook-state" style={styles.statusStrip}>
<DemoText style={styles.statusItem}>shown: {String(softInputState.isSoftInputShown)}</DemoText>
<DemoText style={styles.statusItem}>键盘高度 (vp): {softInputState.softInputHeight}</DemoText>
<DemoText style={styles.statusAction} numberOfLines={1}>
{lastAction}
</DemoText>
</View>
<View style={styles.tabs}>
<ActionButton compact selected={scene === 'component'} testID="tab-component" title="组件" onPress={() => setScene('component')} />
<ActionButton compact selected={scene === 'module'} testID="tab-module" title="模块" onPress={() => setScene('module')} />
<ActionButton compact selected={scene === 'events'} testID="tab-events" title="事件" onPress={() => setScene('events')} />
</View>
{scene === 'component' ? (
<AvoidSoftInputView
avoidOffset={viewOffset}
easing={EASINGS[viewEasingIdx]}
enabled={viewEnabled}
hideAnimationDelay={viewHideDelay}
hideAnimationDuration={viewHideDuration}
onSoftInputAppliedOffsetChange={(e) =>
pushComponentLog(`view appliedOffset(vp)=${e.nativeEvent.appliedOffset}`)
}
onSoftInputHeightChange={(e) => pushComponentLog(`view heightChange(vp)=${e.nativeEvent.softInputHeight}`)}
onSoftInputHidden={(e) => pushComponentLog(`view hidden height(vp)=${e.nativeEvent.softInputHeight}`)}
onSoftInputShown={(e) => pushComponentLog(`view shown height(vp)=${e.nativeEvent.softInputHeight}`)}
showAnimationDelay={viewShowDelay}
showAnimationDuration={viewShowDuration}
style={styles.scene}
testID="avoid-view"
>
<ScrollView
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
showsVerticalScrollIndicator={false}
>
<View style={styles.sectionHeader}>
<DemoText style={styles.sectionTitle}>组件避让验证</DemoText>
<DemoText style={styles.sectionHint}>输入框在 AvoidSoftInputView 内部,聚焦后观察避让和组件事件</DemoText>
</View>
<Field
onChangeText={setComponentInput}
placeholder="聚焦验证组件避让"
testID="component-input"
value={componentInput}
/>
<Field
onChangeText={setComponentInput}
placeholder="底部输入框:键盘弹起时不应被遮挡"
testID="bottom-input"
value={componentInput}
/>
<View style={styles.switchRow}>
<ActionButton selected={viewEnabled} title={`enabled=${String(viewEnabled)}`} onPress={() => setViewEnabled((v) => !v)} />
<ActionButton
title={`easing=${EASINGS[viewEasingIdx]}`}
onPress={() => setViewEasingIdx((v) => (v + 1) % EASINGS.length)}
/>
</View>
<DemoText style={styles.groupTitle}>View 7 props</DemoText>
<NumberStepper label="avoidOffset (vp)" onChange={setViewOffset} step={8} testID="view-avoid-offset" value={viewOffset} />
<NumberStepper
label="showAnimationDelay"
onChange={setViewShowDelay}
step={50}
testID="view-show-delay"
value={viewShowDelay}
/>
<NumberStepper
label="showAnimationDuration"
onChange={setViewShowDuration}
step={50}
testID="view-show-duration"
value={viewShowDuration}
/>
<NumberStepper
label="hideAnimationDelay"
onChange={setViewHideDelay}
step={50}
testID="view-hide-delay"
value={viewHideDelay}
/>
<NumberStepper
label="hideAnimationDuration"
onChange={setViewHideDuration}
step={50}
testID="view-hide-duration"
value={viewHideDuration}
/>
<LogPanel lines={componentLog} title="组件事件" />
</ScrollView>
</AvoidSoftInputView>
) : null}
{scene === 'module' ? (
<View style={styles.scene} testID="module-scene">
<ScrollView
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
showsVerticalScrollIndicator={false}
>
<View style={styles.sectionHeader}>
<DemoText style={styles.sectionTitle}>AvoidSoftInput module</DemoText>
<DemoText style={styles.sectionHint}>普通 View 根路径,不嵌套 AvoidSoftInputView</DemoText>
</View>
<View style={styles.switchRow}>
<ActionButton
selected={moduleEnabled}
title={`setEnabled(${String(moduleEnabled)})`}
onPress={() => setModuleEnabled((v) => !v)}
testID="module-enabled"
/>
<ActionButton title={`setEasing(${EASINGS[moduleEasingIdx]})`} onPress={switchModuleEasing} />
</View>
<View style={styles.formPair}>
<Field
numeric
onChangeText={setModuleOffsetText}
placeholder="offset"
testID="input-avoid-offset"
value={moduleOffsetText}
/>
<ActionButton
title="setAvoidOffset"
onPress={() => callModuleNumberMethod('setAvoidOffset', moduleOffsetText, AvoidSoftInput.setAvoidOffset)}
/>
</View>
<View style={styles.formPair}>
<Field
numeric
onChangeText={setModuleShowDelayText}
placeholder="show delay"
testID="input-show-delay"
value={moduleShowDelayText}
/>
<ActionButton
title="setShowAnimationDelay"
onPress={() =>
callModuleNumberMethod('setShowAnimationDelay', moduleShowDelayText, AvoidSoftInput.setShowAnimationDelay)
}
/>
</View>
<View style={styles.formPair}>
<Field
numeric
onChangeText={setModuleShowDurationText}
placeholder="show duration"
testID="input-show-duration"
value={moduleShowDurationText}
/>
<ActionButton
title="setShowAnimationDuration"
onPress={() =>
callModuleNumberMethod(
'setShowAnimationDuration',
moduleShowDurationText,
AvoidSoftInput.setShowAnimationDuration,
)
}
/>
</View>
<View style={styles.formPair}>
<Field
numeric
onChangeText={setModuleHideDelayText}
placeholder="hide delay"
testID="input-hide-delay"
value={moduleHideDelayText}
/>
<ActionButton
title="setHideAnimationDelay"
onPress={() =>
callModuleNumberMethod('setHideAnimationDelay', moduleHideDelayText, AvoidSoftInput.setHideAnimationDelay)
}
/>
</View>
<View style={styles.formPair}>
<Field
numeric
onChangeText={setModuleHideDurationText}
placeholder="hide duration"
testID="input-hide-duration"
value={moduleHideDurationText}
/>
<ActionButton
title="setHideAnimationDuration"
onPress={() =>
callModuleNumberMethod(
'setHideAnimationDuration',
moduleHideDurationText,
AvoidSoftInput.setHideAnimationDuration,
)
}
/>
</View>
<View style={styles.switchRow}>
<ActionButton title="Android 专属兼容入口" onPress={callAndroidOnlyMethods} testID="android-only-methods" />
<ActionButton title="listener 样板" onPress={callListenerTemplate} testID="listener-template" />
</View>
<Field
onChangeText={setModuleInput}
placeholder="聚焦验证模块根路径避让"
testID="module-input"
value={moduleInput}
/>
<Field
onChangeText={setModuleInput}
placeholder="底部输入框:模块路径验证"
testID="module-bottom-input"
value={moduleInput}
/>
<LogPanel lines={moduleLog} title="模块 device 事件" />
</ScrollView>
</View>
) : null}
{scene === 'events' ? (
<View style={styles.scene} testID="events-scene">
<ScrollView
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
showsVerticalScrollIndicator={false}
>
<View style={styles.sectionHeader}>
<DemoText style={styles.sectionTitle}>状态与事件</DemoText>
<DemoText style={styles.sectionHint}>useSoftInputState、4 个 device 事件、4 个组件事件</DemoText>
</View>
<Field onChangeText={setEventsInput} placeholder="聚焦后观察状态和事件日志" testID="event-input" value={eventsInput} />
<LogPanel lines={moduleLog} title="device event log" />
<LogPanel lines={componentLog} title="component event log" />
</ScrollView>
</View>
) : null}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeRoot: {
flex: 1,
backgroundColor: '#f7f8fb',
},
topSpacer: {
height: 20,
},
header: {
paddingHorizontal: 14,
paddingBottom: 6,
},
title: {
color: '#111827',
fontSize: 14,
fontWeight: '800',
lineHeight: 18,
},
subtitle: {
color: '#526070',
fontSize: 11,
lineHeight: 15,
marginTop: 1,
},
statusStrip: {
alignItems: 'center',
backgroundColor: '#eef2f7',
borderBottomColor: '#d9e0ea',
borderBottomWidth: 1,
borderTopColor: '#d9e0ea',
borderTopWidth: 1,
flexDirection: 'row',
gap: 6,
minHeight: 30,
paddingHorizontal: 10,
},
statusItem: {
color: '#1f2937',
fontSize: 10,
fontWeight: '700',
},
statusAction: {
color: '#42526b',
flex: 1,
fontSize: 10,
textAlign: 'right',
},
tabs: {
backgroundColor: '#ffffff',
borderBottomColor: '#dfe5ee',
borderBottomWidth: 1,
flexDirection: 'row',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 7,
},
scene: {
flex: 1,
},
content: {
gap: 8,
padding: 10,
paddingBottom: 52,
},
sectionHeader: {
gap: 3,
marginBottom: 2,
},
sectionTitle: {
color: '#111827',
fontSize: 14,
fontWeight: '800',
lineHeight: 19,
},
sectionHint: {
color: '#526070',
fontSize: 10,
lineHeight: 14,
},
groupTitle: {
color: '#364152',
fontSize: 11,
fontWeight: '800',
lineHeight: 15,
marginTop: 2,
},
switchRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
formPair: {
alignItems: 'center',
flexDirection: 'row',
gap: 8,
},
settingRow: {
alignItems: 'center',
backgroundColor: '#ffffff',
borderColor: '#dfe5ee',
borderRadius: 6,
borderWidth: 1,
flexDirection: 'row',
minHeight: 38,
paddingHorizontal: 8,
paddingVertical: 4,
},
settingLabel: {
flex: 1,
minWidth: 0,
},
settingTitle: {
color: '#263141',
fontSize: 11,
fontWeight: '700',
lineHeight: 15,
},
settingValue: {
color: '#526070',
fontSize: 10,
lineHeight: 14,
marginTop: 1,
},
stepper: {
flexDirection: 'row',
gap: 6,
},
input: {
backgroundColor: '#ffffff',
borderColor: '#cfd8e4',
borderRadius: 6,
borderWidth: 1,
color: '#111827',
flex: 1,
fontSize: 12,
minHeight: 36,
paddingHorizontal: 10,
},
button: {
alignItems: 'center',
backgroundColor: '#ffffff',
borderColor: '#b8c4d4',
borderRadius: 6,
borderWidth: 1,
justifyContent: 'center',
minHeight: 34,
minWidth: 78,
paddingHorizontal: 10,
},
buttonCompact: {
minHeight: 30,
minWidth: 42,
paddingHorizontal: 8,
},
buttonSelected: {
backgroundColor: '#174ea6',
borderColor: '#174ea6',
},
buttonText: {
color: '#1f2937',
fontSize: 11,
fontWeight: '700',
lineHeight: 14,
textAlign: 'center',
},
buttonTextSelected: {
color: '#ffffff',
},
logBox: {
backgroundColor: '#101828',
borderRadius: 6,
minHeight: 158,
overflow: 'hidden',
},
logTitle: {
backgroundColor: '#1d2939',
color: '#ffffff',
fontSize: 11,
fontWeight: '800',
lineHeight: 15,
paddingHorizontal: 8,
paddingVertical: 6,
},
logScroll: {
maxHeight: 220,
paddingHorizontal: 8,
paddingVertical: 6,
},
logEmpty: {
color: '#98a2b3',
fontSize: 10,
lineHeight: 15,
},
logLine: {
color: '#d0d5dd',
fontSize: 10,
lineHeight: 15,
},
});