/** * Il foglio che sale dal basso, con il velo scuro dietro. * * Un `Modal` con `animationType="slide"` fa scivolare tutto quello che * contiene, velo compreso: sull'iPhone si vedeva la patina nera scendere * dall'alto prima del foglio, e Paolo l'ha notato al primo tocco. Qui il velo * compare subito e a muoversi e' solo il foglio, come fanno i fogli di * sistema. Il `Modal` resta, con l'animazione spenta: e' lui che sta sopra * tutto e che risponde al tasto indietro di Android. * * Chi lo usa passa il contenuto del foglio gia' vestito (fondo, angoli, * padding): qui si decide solo come entra e come esce. Se il fondo non e' * `colori.fondo` lo si dice con `fondo`: e' il colore della striscia che il * foglio allunga sotto di se' per riempire gli angoli della tastiera. */ import { useEffect, useRef, useState } from 'react'; import type { ReactNode } from 'react'; import { Animated, Dimensions, Easing, Keyboard, Modal, Platform, Pressable, StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useColori } from '../tema.ts'; export type PropsFoglio = { visibile: boolean; /** Tocco sul velo, tasto indietro di Android. */ onChiudi: () => void; /** Vero quando dentro c'e' un campo: il foglio si sposta con la tastiera, a salire e a scendere. */ evitaTastiera?: boolean; /** * Chiamata quando il foglio e' sparito del tutto. Serve a chi deve * mostrare un Alert dopo: su iOS un Alert presentato mentre il Modal sta * ancora andando via non compare mai. */ onChiuso?: () => void; /** * Chiamata appena il foglio comincia a salire. È il momento di mettere il * cursore in un campo, così la tastiera sale insieme al foglio. Con * `autoFocus` la tastiera arrivava prima che il foglio fosse pronto a * scansarsi, e sull'iPhone restava coperto (collaudo del 28/09). */ onAperto?: () => void; /** Il fondo del contenuto, se non è `colori.fondo`: la striscia sotto il foglio deve essere dello stesso colore. */ fondo?: string; children: ReactNode; }; /** * La curva della tastiera dell'iPhone, come la ricostruisce chi l'ha misurata: * il foglio si alza e si abbassa con la stessa curva e la stessa durata che * iOS annuncia per la tastiera, così i due bordi restano attaccati. */ const CURVA_TASTIERA = Easing.bezier(0.17, 0.59, 0.4, 0.77); /** Da quanto sotto entra il foglio: piu' della sua altezza, cosi' parte fuori schermo. */ const CORSA = 600; export function Foglio({ visibile, onChiudi, evitaTastiera = false, onChiuso, onAperto, fondo, children }: PropsFoglio) { // Il Modal resta montato finche' il foglio non e' uscito del tutto: se // sparisse insieme a `visibile`, l'uscita non si vedrebbe. const [montato, setMontato] = useState(visibile); const scorrimento = useRef(new Animated.Value(CORSA)).current; // In un ref e non fra le dipendenze: chi lo passa lo rifà a ogni disegno, e // l'effetto qui sotto ripartirebbe rimandando il foglio aperto fuori schermo. const chiuso = useRef(onChiuso); chiuso.current = onChiuso; const aperto = useRef(onAperto); aperto.current = onAperto; // L'ingresso parte da `onShow` del Modal e non da qui: montare il Modal e // animare nello stesso giro non funziona, la vista nativa non c'e' ancora // e il foglio restava fermo fuori schermo, con le righe sotto il bordo. const entra = () => { Animated.timing(scorrimento, { toValue: 0, duration: 280, easing: Easing.out(Easing.cubic), useNativeDriver: true, }).start(({ finished }) => { // Su Android il cursore messo mentre il Modal si sta ancora aprendo non // fa comparire la tastiera: lì lo si mette a foglio arrivato. La // tastiera di Android non si anima insieme al foglio comunque. if (finished && Platform.OS === 'android') aperto.current?.(); }); if (Platform.OS === 'android') return; // Il cursore entra nel campo subito, non a foglio arrivato: così la // tastiera sale insieme al foglio, in un movimento solo. Aspettando la // fine si vedevano due scatti, prima il foglio e poi la tastiera che lo // spingeva ancora su (Paolo, 28/09). Un fotogramma di attesa basta perché // il contenitore che si scansa dalla tastiera abbia già preso le misure: // con `autoFocus`, al montaggio, la tastiera arrivava prima e il foglio // restava sotto. requestAnimationFrame(() => aperto.current?.()); }; useEffect(() => { if (visibile) { scorrimento.setValue(CORSA); setMontato(true); } else { Animated.timing(scorrimento, { toValue: CORSA, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true, }).start(({ finished }) => { if (!finished) return; setMontato(false); // Su iOS «sparito» lo dice il Modal con onDismiss; su Android quel // callback non arriva, e sparito e' adesso. if (Platform.OS !== 'ios') chiuso.current?.(); }); } }, [visibile, scorrimento]); const colori = useColori(); /** * Quanto spazio prende la tastiera sotto il foglio. * * Prima lo faceva KeyboardAvoidingView, ma con la nuova architettura di * React Native il suo spostamento non si anima: il foglio saliva piano e poi * la tastiera lo spingeva su di scatto, e quando la tastiera se ne andava il * foglio cadeva giù in un lampo (Paolo, 28/09, sul suo iPhone). Qui lo * spazio si anima con la durata che iOS annuncia per la tastiera e con la * sua curva, a salire e a scendere. * * Su Android la tastiera non annuncia prima quando arriva, lo dice quando è * arrivata (keyboardDidShow): lì il foglio la raggiunge con un'animazione * breve. Si pensava che la finestra si ridimensionasse da sé, ma dentro un * Modal, con l'app disegnata fino ai bordi, non succede: il foglio restava * sotto la tastiera (collaudo sull'emulatore, 28/09). */ const tastiera = useRef(new Animated.Value(0)).current; useEffect(() => { if (!evitaTastiera || !montato) return; const muovi = (altezza: number, durata: number, curva: (t: number) => number) => Animated.timing(tastiera, { toValue: altezza, duration: durata, easing: curva, // paddingBottom è una misura del layout, e le misure non si animano // col driver nativo: è un movimento breve, il JavaScript ce la fa. useNativeDriver: false, }).start(); // Se la tastiera è già aperta quando il foglio arriva, si parte da lì. const gia = Keyboard.metrics(); tastiera.setValue(gia === undefined ? 0 : gia.height); if (Platform.OS === 'ios') { const sotto = Keyboard.addListener('keyboardWillChangeFrame', (e) => { const altezza = Math.max(0, Dimensions.get('window').height - e.endCoordinates.screenY); muovi(altezza, e.duration > 0 ? e.duration : 250, CURVA_TASTIERA); }); return () => sotto.remove(); } const su = Keyboard.addListener('keyboardDidShow', (e) => muovi(e.endCoordinates.height, 180, Easing.out(Easing.cubic))); const giu = Keyboard.addListener('keyboardDidHide', () => muovi(0, 180, Easing.out(Easing.cubic))); return () => { su.remove(); giu.remove(); }; }, [evitaTastiera, montato, tastiera]); // Con la tastiera su, un foglio alto arriva fino in cima: senza questo // margine il titolo finiva sotto la Dynamic Island. const sopra = useSafeAreaInsets().top; return ( {}} onShow={entra} onDismiss={Platform.OS === 'ios' ? () => chiuso.current?.() : undefined} > {children} {/* Il fondo del foglio continua sotto il suo bordo. La tastiera dell'iPhone ha gli angoli in alto arrotondati, e senza questo negli angoli si vedeva il velo scuro, due spicchi fra il foglio e la tastiera. Senza tastiera sta sotto lo schermo e non si vede. */} ); } const stili = StyleSheet.create({ fondale: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0, 0, 0, 0.4)' }, // Il tetto e il flexShrink lasciano che sia il contenuto a stringersi quando // la tastiera toglie spazio, non i tasti in fondo. foglio: { maxHeight: '100%', flexShrink: 1 }, sotto: { position: 'absolute', left: 0, right: 0, top: '100%', height: 80 }, });