/**
* 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';
import { FoglioDiSistema } from './FoglioDiSistema.tsx';
import { INTESTAZIONE_DI_SISTEMA } from './Intestazione.tsx';
export type PropsFoglio = {
visibile: boolean;
/** Tocco sul velo, tasto indietro di Android; sull'iPhone, il foglio trascinato giù. */
onChiudi: () => void;
/**
* Vero quando dentro c'e' un campo: il foglio si sposta con la tastiera, a
* salire e a scendere. Solo Android: il foglio di sistema dell'iPhone si
* sposta da sé, e lì `evitaTastiera` e `fondo` non servono.
*/
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;
/**
* Sull'iPhone il foglio è quello di sistema (FoglioDiSistema), di vetro; qui
* sotto c'è il nostro, che resta su Android.
*/
export function Foglio(props: PropsFoglio) {
if (INTESTAZIONE_DI_SISTEMA) {
return (
{props.children}
);
}
return ;
}
function FoglioNostro({ 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 },
});