Alle <Pressable style={({pressed}) => ({...})}> ersetzt — style-Funktion
droppt auf Android (New Arch) intermittierend width/height, führt zu 0×0
unsichtbaren Elementen. TouchableOpacity mit activeOpacity ist stabil.
Außerdem übrige Pressables (plain style) aus components/ und app/
migriert sowie zwei überschüssige </View>-Tags in chat.tsx + RoomCard.tsx
entfernt die TS-Fehler verursacht haben.
64 Dateien, typecheck sauber.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
223 lines
7.3 KiB
TypeScript
223 lines
7.3 KiB
TypeScript
import { ReactNode, useEffect, useRef, useState } from 'react';
|
|
import {
|
|
Animated,
|
|
Easing,
|
|
Keyboard,
|
|
Modal,
|
|
Platform,
|
|
TouchableOpacity,
|
|
StyleProp,
|
|
View,
|
|
ViewStyle,
|
|
} from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { useColors } from '../lib/theme';
|
|
|
|
/**
|
|
* Universal-Bottom-Sheet für Forms mit TextInput.
|
|
*
|
|
* Pattern (verifiziert auf PostCommentsSheet + EditMailAccountSheet):
|
|
*
|
|
* 1. Outer-Animated.View hat animated `height` (JS-driver) — Sheet WÄCHST
|
|
* bei Tastatur-Open um genau die Tastatur-Höhe.
|
|
* 2. Inner-Animated.View hat `transform: translateY` (Native-driver) —
|
|
* Slide-In/Out smooth. Driver-Mix-Trennung verhindert
|
|
* "Style property 'height' is not supported by native animated module"-Crash.
|
|
* 3. iOS: `paddingBottom: keyboardHeight` shifted Form innerhalb des
|
|
* gewachsenen Sheets über die Tastatur. Android: `windowSoftInputMode=adjustResize`
|
|
* im Manifest schrumpft das Window selbst.
|
|
* 4. Flex-Spacer drückt `children` (Form) automatisch an den Sheet-Bottom-Edge —
|
|
* sitzt direkt über der Tastatur ohne Gap.
|
|
*
|
|
* Anti-Pattern (siehe `docs/internal/RECOVERY_LOG_2026-05-10.md` §7.2):
|
|
* - `useKeyboardAnimation()` aus `react-native-keyboard-controller` liefert
|
|
* in iOS-Modals keine Höhe (separate UIWindow). Hier: plain RN
|
|
* `Keyboard.addListener` für die Höhe.
|
|
* - `Animated.subtract`/`marginBottom: keyboardHeight` mischen JS+Native-Driver
|
|
* auf demselben View → Bouncing oder Crash.
|
|
*
|
|
* Usage:
|
|
* ```tsx
|
|
* <KeyboardAwareSheet
|
|
* visible={visible}
|
|
* onClose={onClose}
|
|
* collapsedHeight={280}
|
|
* header={<HeaderRow title="Passwort" onCancel={onClose} />}
|
|
* >
|
|
* <View style={{ padding: 20, gap: 14 }}>
|
|
* <TextInput ... />
|
|
* <SaveButton ... />
|
|
* </View>
|
|
* </KeyboardAwareSheet>
|
|
* ```
|
|
*/
|
|
export interface KeyboardAwareSheetProps {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
/** Sheet-Höhe wenn Tastatur zu. Eng auf Inhalt zuschneiden — typisch 220-340px. */
|
|
collapsedHeight: number;
|
|
/** Optionaler Header (Cancel/Title-Row). Rendert direkt unter dem Drag-Handle. */
|
|
header?: ReactNode;
|
|
/** Form-Inhalt. Wird per Flex-Spacer an den Sheet-Bottom gedrückt — sitzt
|
|
* damit direkt über der Tastatur sobald die offen ist. */
|
|
children: ReactNode;
|
|
/** Default true — Tap auf Backdrop schließt das Sheet. */
|
|
dismissOnBackdrop?: boolean;
|
|
/** Default true — kleiner Drag-Handle ganz oben am Sheet. */
|
|
showDragHandle?: boolean;
|
|
/** Default true — fügt unten eine Safe-Area-Spacer-Höhe ein (insets.bottom). */
|
|
showSafeAreaSpacer?: boolean;
|
|
/** Default true — interner Flex-Spacer drückt children zum Sheet-Bottom.
|
|
* Auf false setzen wenn der Inhalt seine eigene Scroll-/Flex-Logik hat
|
|
* (z.B. ScrollView mit Provider-Grid, Listen). */
|
|
pushChildrenToBottom?: boolean;
|
|
/** Border-Radius oben. Default 20. */
|
|
topRadius?: number;
|
|
/** Optional zusätzlicher Style für den Sheet-Container. */
|
|
containerStyle?: StyleProp<ViewStyle>;
|
|
}
|
|
|
|
export function KeyboardAwareSheet({
|
|
visible,
|
|
onClose,
|
|
collapsedHeight,
|
|
header,
|
|
children,
|
|
dismissOnBackdrop = true,
|
|
showDragHandle = true,
|
|
showSafeAreaSpacer = true,
|
|
pushChildrenToBottom = true,
|
|
topRadius = 20,
|
|
containerStyle,
|
|
}: KeyboardAwareSheetProps) {
|
|
const colors = useColors();
|
|
const insets = useSafeAreaInsets();
|
|
|
|
const slideY = useRef(new Animated.Value(collapsedHeight)).current;
|
|
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
|
const sheetHeight = useRef(new Animated.Value(collapsedHeight)).current;
|
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
|
|
|
// Slide-In + Backdrop-Fade bei `visible=true`
|
|
useEffect(() => {
|
|
if (visible) {
|
|
slideY.setValue(collapsedHeight);
|
|
backdropOpacity.setValue(0);
|
|
Animated.parallel([
|
|
Animated.timing(slideY, {
|
|
toValue: 0,
|
|
duration: 280,
|
|
easing: Easing.out(Easing.cubic),
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(backdropOpacity, {
|
|
toValue: 1,
|
|
duration: 220,
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start();
|
|
}
|
|
}, [visible, slideY, backdropOpacity, collapsedHeight]);
|
|
|
|
// Sheet-Höhe wächst/schrumpft mit Tastatur
|
|
useEffect(() => {
|
|
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
|
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
|
const showSub = Keyboard.addListener(showEvent, (e) => {
|
|
const h = e.endCoordinates.height;
|
|
setKeyboardHeight(h);
|
|
Animated.timing(sheetHeight, {
|
|
toValue: collapsedHeight + h,
|
|
duration: Platform.OS === 'ios' ? (e.duration ?? 250) : 220,
|
|
easing: Easing.out(Easing.cubic),
|
|
useNativeDriver: false,
|
|
}).start();
|
|
});
|
|
const hideSub = Keyboard.addListener(hideEvent, (e) => {
|
|
setKeyboardHeight(0);
|
|
Animated.timing(sheetHeight, {
|
|
toValue: collapsedHeight,
|
|
duration: Platform.OS === 'ios' ? (e?.duration ?? 250) : 220,
|
|
easing: Easing.out(Easing.cubic),
|
|
useNativeDriver: false,
|
|
}).start();
|
|
});
|
|
return () => {
|
|
showSub.remove();
|
|
hideSub.remove();
|
|
};
|
|
}, [sheetHeight, collapsedHeight]);
|
|
|
|
return (
|
|
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose}>
|
|
{/* Backdrop */}
|
|
<Animated.View
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
|
opacity: backdropOpacity,
|
|
}}
|
|
>
|
|
{dismissOnBackdrop && <TouchableOpacity activeOpacity={1} style={{ flex: 1 }} onPress={onClose} />}
|
|
</Animated.View>
|
|
|
|
{/* Outer: animated height (JS-driver) */}
|
|
<Animated.View
|
|
style={{
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
height: sheetHeight,
|
|
}}
|
|
>
|
|
{/* Inner: animated transform (Native-driver). Driver-Mix vermeiden
|
|
durch zwei verschachtelte Animated.Views. */}
|
|
<Animated.View
|
|
style={[
|
|
{
|
|
flex: 1,
|
|
backgroundColor: colors.bg,
|
|
borderTopLeftRadius: topRadius,
|
|
borderTopRightRadius: topRadius,
|
|
transform: [{ translateY: slideY }],
|
|
paddingBottom: Platform.OS === 'ios' ? keyboardHeight : 0,
|
|
},
|
|
containerStyle,
|
|
]}
|
|
>
|
|
<View style={{ flex: 1 }}>
|
|
{showDragHandle && (
|
|
<View style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 4 }}>
|
|
<View
|
|
style={{
|
|
width: 36,
|
|
height: 4,
|
|
borderRadius: 2,
|
|
backgroundColor: colors.border,
|
|
}}
|
|
/>
|
|
</View>
|
|
)}
|
|
{header}
|
|
{pushChildrenToBottom ? (
|
|
<>
|
|
{/* Flex-Spacer drückt children an den Sheet-Bottom */}
|
|
<View style={{ flex: 1 }} />
|
|
{children}
|
|
</>
|
|
) : (
|
|
<View style={{ flex: 1 }}>{children}</View>
|
|
)}
|
|
{showSafeAreaSpacer && <View style={{ height: insets.bottom }} />}
|
|
</View>
|
|
</Animated.View>
|
|
</Animated.View>
|
|
</Modal>
|
|
);
|
|
}
|