4. Native modules
4. Native modules
Tijdens deze les bouwen we twee applicaties. Aan de hand van een galerij-applicatie bespreken hoe we de camera van een mobiel toestel kunnen gebruiken en hoe we de genomen foto's kunnen wegschrijven naar de galerij van het toestel.
In de tweede helft van de les breiden we de To-Do app uit met een embedded SQLite database
Voor beide voorbeelden is een startproject voorzien waarin de stylesheets en enkele handige componenten voorzien zijn.
Startbestanden
Native modules
Alhoewel we al enkele native modules (zoals react-native-mmkv) gebruikt hebben, is interessant om even stil te staan bij wat een native module is en waar je deze kan vinden.
Onder een native module verstaan we alles wat niet volledig in JavaScript geschreven is. Er zijn twee soorten native modules te onderscheiden, de eerste soort, die te vinden zijn in de Expo SDK zijn doorgaans eenvoudig in gebruik en zeer goed onderhouden. Verder vereisen deze bibliotheken geen prebuild omdat de native code al in de Expo Go app zit.
De tweede soort zijn bibliotheken die niet in de Expo SDK zitten en dus een prebuild vereisen om de nodige code toe te voegen aan de Android applicatie. Dit soort modules zijn te vinden op GitHub op de React Native Directory.
Gallery
Om de camera aan te spreken gebruiken we React Native Vision Camera. Deze native module heeft een veel uitgebreidere API dan de standaard camera-module van Expo en is daarom een betere keuze. We kunnen deze module installeren met onderstaand commando.
Rechten declareren
Zoals veel native modules heeft React Native Vision Camera bepaalde rechten nodig om te kunnen werken. Deze rechten moeten vermeld worden in app.json, vervolgens worden deze tijdens expo prebuild toegevoegd aan AndroidManifest.xml (Android) en info.plist (iOS).
Onderstaande configuratie is één van de twee manieren om rechten te vermelden. Deze optie vereist de minste configuratie omdat de rechten ingeladen worden uit AndroidManifest.xml en info.plist in de native module, i.e. uit de node_modules.
Voor Android we moeten slechts één lijn toevoegen aan app.json, om de app ook op iOS te publiceren moeten we een verklaring toevoegen die uitlegt waarom we de rechten nodig hebben. In deze beschrijving wordt $(PRODUCT_NAME) automatisch vervangen door de naam van de applicatie.
{
"expo": {
...
"plugins": [
...
[
"react-native-vision-camera",
{
"cameraPermissionText": "$(PRODUCT_NAME) needs access to your Camera."
}
]
],
...
}
}Camera UI
De Camera component uit React Native Vision Camera heeft een heel beperkte UI. De component toont enkel een stream van de camera en bevat geen knoppen om een foto of video te nemen of om te wisselen tussen front en back camera. Om dit op te lossen bouwen we een eigen component CameraUI die je kan herbruiken in je eigen project.
Rechten aanvragen
Voordat we de camera kunnen aanspreken moeten we de rechten hiervoor aanvragen. Hierboven hebben we enkel gedefinieerd dat de applicatie de rechten nodig heeft, maar de applicatie moet deze rechten ook nog expliciet aanvragen.
React Native Vision Camera bevat een hook die hiervoor gebruikt kan worden. Enkel de rechten aanvragen is niet voldoende, zodra de gebruiker de rechten enkele keren geweigerd heeft, zal het besturingssysteem deze automatisch weigeren en wordt er geen pop-up meer getoond aan de gebruiker. We moeten dus bijhouden of we de rechten al aangevraagd hebben en als dit het geval is en we nog steeds geen rechten
gekregen hebben, moet er een gepaste foutmelding getoond worden.
De NoPermissionDialog component die hieronder gebruikt wordt is beschikbaar in de startbestanden. Via de isOpen property garanderen we dat het dialoogvenster enkel getoond wordt als de gebruiker de camera-feed probeert te tonen. Via de onCancel property geven we een callback mee die uitgevoerd wordt als de gebruiker op de 'Cancel' knop drukt, de onClose methode wordt in de bovenliggende component gebruikt om de camera-feed te verbergen.
const CameraUI: FunctionComponent<CameraUIProps> = ({onClose}) => {
const {hasPermission: haveCameraPermission, requestPermission: requestCameraPermission} = useCameraPermission()
const [haveRequestedCameraPermission, setHaveRequestedCameraPermission] = useState<boolean>(false)
if (!haveCameraPermission && haveRequestedCameraPermission) {
return <NoPermissionDialog isOpen={showCamera} onCancel={() => onClose(undefined)} />
}
return (
<Modal>
{/* ... */}
</Modal>
)
}Om de rechten aan te vragen moeten we de useEffect hook gebruiken, want hier moet een melding voor getoond worden waarmee de gebruiker de rechten kan toekennen of weigeren. Dit kan duidelijk niet tijdens het renderen van een component, want dat gaat tegen de principes van React in.
We vragen de rechten pas aan als de showCamera property true is, op die manier wordt de gebruiker niet om rechten gevraagd totdat deze echt nodig zijn. Op deze manier is de kans groter dat de gebruiker de rechten toekent. Als je alle nodige rechten aanvraagt bij het openen van de applicatie, is de kans groter dat de gebruiker deze rechten niet wilt geven en de applicatie de-installeert.
const CameraUI: FunctionComponent<CameraUIProps> = ({onClose, showCamera}) => {
const {hasPermission: haveCameraPermission, requestPermission: requestCameraPermission} = useCameraPermission()
const [haveRequestedCameraPermission, setHaveRequestedCameraPermission] = useState<boolean>(false)
useEffect(() => {
if (showCamera && !haveCameraPermission) {
void requestCameraPermission().then(() => setHaveRequestedCameraPermission(true))
}
}, [haveCameraPermission, requestCameraPermission, showCamera])
if (!haveCameraPermission && haveRequestedCameraPermission) {
return <NoPermissionDialog isOpen={showCamera} onCancel={() => onClose(undefined)} />
}
return (
<Modal>
{/* ... */}
</Modal>
)
}Settings openen
Als de gebruiker de rechten twee keer afgewezen heeft, wordt de aanvraag niet meer getoond aan de gebruiker en wijst het besturingssysteem de aanvraag automatisch af. Daarom gebruiken we de Linking API uit React Native. Via de openSettings methode wordt de gebruiker naar de applicatie-instellingen (op OS-niveau) gestuurd en daar kunnen de rechten alsnog toegekend worden.
import {Linking} from 'react-native'
const NoPermissionDialog: FunctionComponent<NoPermissionDialogProps> = ({onCancel, isOpen}) => {
return (
<AlertDialog isOpen={isOpen} size="md">
<AlertDialogBackdrop />
<AlertDialogContent>
<AlertDialogHeader>
<Heading className="text-typography-950 font-semibold" size="md">
No permission to use camera
</Heading>
</AlertDialogHeader>
<AlertDialogBody className="mt-3 mb-4">
<Text size="sm">
We need to be granted permission to use the camera, otherwise the app is unable to function. Please grant us
permission and try again.
</Text>
</AlertDialogBody>
<AlertDialogFooter className="">
<Button variant="outline" action="secondary" onPress={onCancel} size="sm">
<ButtonText>Cancel</ButtonText>
</Button>
<Button size="sm" onPress={() => Linking.openSettings()}>
<ButtonText>Grant</ButtonText>
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}Camera feed tonen
Om de camera-feed te tonen moeten we eerst informatie ophalen over het camera device (front/back) waarvan de stream getoond moet worden. Via de cameraType property duiden we de camera aan, vervolgens bewaren we de gekozen camera in state zodat de gebruiker tussen de camera's kan wisselen.
Zodra we weten welke camera we moeten gebruiken, kunnen we via de useCameraDevice hook informatie over het device opvragen. Deze hook geeft alle informatie terug over de camera, inclusief dingen zoals zoom-mogelijkheden, de aanwezigheid van een flash, ...
const CameraUI: FunctionComponent<CameraUIProps> = ({showCamera, cameraType, onClose}) => {
const {hasPermission: haveCameraPermission, requestPermission: requestCameraPermission} = useCameraPermission()
const [haveRequestedCameraPermission, setHaveRequestedCameraPermission] = useState<boolean>(false)
const [activeCamera, setActiveCamera] = useState<'front' | 'back'>(cameraType === 'front' ? 'front' : 'back')
const cameraDevice = useCameraDevice(activeCamera)
// ...
return (
<Modal>
<View className="flex items-center justify-end h-full">
<Button className="rounded-full p-3.5 h-14 w-14 absolute right-8 top-8" onPress={() => onClose(undefined)}>
<ButtonIcon as={X} size={iconSize} />
</Button>
<Button
size="lg"
className="rounded-full p-3.5 h-14 w-14 absolute right-8 bottom-8"
onPress={() => setActiveCamera(activeCamera === 'front' ? 'back' : 'front')}>
<ButtonIcon as={SwitchCamera} size={iconSize} />
</Button>
<Button variant="outline"
className="border-2 border-neutral-800 rounded-full h-24 w-24 absolute bottom-8 opacity-50 bg-neutral-700 data-[active=true]:bg-neutral-400"/>
</View>
</Modal>
)
}Nu dat alle configuratie gedaan is, kunnen we de camera-feed tonen via de Camera component uit React Native Vision Camera. Deze component heeft drie properties, via device wordt de camera doorgeven, via isActive wordt de feed geactiveerd of verborgen en via photo geven we aan dat de camera gebruikt wordt om foto's te nemen (en geen video's).
Gedeactiveerd betekent niet dat deze component niet gerenderd wordt, maar wel dat het feed niet of wel getoond wordt. Daarom voegen we nog een check toe die een fragment rendert als er geen rechten zijn of als de camera verborgen moet worden.
const CameraUI: FunctionComponent<CameraUIProps> = ({showCamera, cameraType, onClose}) => {
const {hasPermission: haveCameraPermission, requestPermission: requestCameraPermission} = useCameraPermission()
const [haveRequestedCameraPermission, setHaveRequestedCameraPermission] = useState<boolean>(false)
const [activeCamera, setActiveCamera] = useState<'front' | 'back'>(cameraType === 'front' ? 'front' : 'back')
const cameraDevice = useCameraDevice(activeCamera)
// ...
if (!showCamera || !haveCameraPermission) {
return <></>
}
return (
<Modal>
<View className="flex items-center justify-end h-full">
<Camera device={cameraDevice!} isActive={showCamera} style={styles.absoluteFill} photo />
<Button className="rounded-full p-3.5 h-14 w-14 absolute right-8 top-8" onPress={() => onClose(undefined)}>
<ButtonIcon as={X} size={iconSize} />
</Button>
<Button
size="lg"
className="rounded-full p-3.5 h-14 w-14 absolute right-8 bottom-8"
onPress={() => setActiveCamera(activeCamera === 'front' ? 'back' : 'front')}>
<ButtonIcon as={SwitchCamera} size={iconSize} />
</Button>
<Button variant="outline"
className="border-2 border-neutral-800 rounded-full h-24 w-24 absolute bottom-8 opacity-50 bg-neutral-700 data-[active=true]:bg-neutral-400"/>
</View>
</Modal>
)
}Tenslotte kunnen we de CameraUI component gebruiken op de index pagina, als de gebruiker op de knop in de header drukt wordt de camera getoond.
const Index: FunctionComponent = () => {
const navigation = useNavigation()
const [showCamera, setShowCamera] = useState<boolean>(false)
useLayoutEffect(() => {
navigation.setOptions({
headerRight: ({tintColor}: {tintColor: string}) => (
<Button size="lg" className="rounded-full p-3.5" variant="link" onPress={() => setShowCamera(true)}>
<ButtonIcon as={CameraIcon} color={tintColor} size={25} />
</Button>
),
})
}, [])
return (
<>
<CameraUI showCamera={showCamera} cameraType="back" onClose={() => setShowCamera(false)} />
<ScrollView>
<View className="flex content-start justify-between flex-row flex-wrap" />
</ScrollView>
</>
)
}Onderstaande video demonstreert de voorlopige werking van de applicatie.
Foto's bewaren
Om een foto te nemen hebben we een referentie naar de camera stream nodig, hiervoor moeten we useRef gebruiken.
Begrip: useRef voor DOM-manipulatie
De useRef hook kan gebruikt worden om een specifiek HTML-element rechtstreeks aan te spreken en biedt daarmee een alternatief voor de document.getElementById methode die gekend is uit de JavaScript cursus.
Omdat de useRef hook niet steunt op CSS-klassen, ID's of query-selectors is dit een stabielere keuze dan de gekende document.x methodes.
Via de reference kunnen we HTML elementen rechtstreeks manipuleren, bijvoorbeeld om de focus te zetten op een inputveld.
In React Native is er natuurlijk geen klassieke DOM, maar de useRef hook kan nog steeds op dezelfde manier gebruikt worden. Bijvoorbeeld om een TextInput component in focus te brengen.
const SomeComponent: FunctionComponent = () => {
const htmlRef = useRef<HTMLDivElement>(null)
return (
<div>
{/**
* Het element waaraan de ref gelinkt wordt
* moet natuurlijk geen div zijn.
**/}
<div ref={htmlRef}></div>
</div>
)
}Eens de referentie gekoppeld is, kan de takePhoto methode gebruikt worden om een foto te nemen. Via de onClose methode geven we de foto door aan de bovenliggende component. Voordat we de foto doorgeven moeten we het path aanpassen, het pad dat we van de camera krijgen is een filesystem path, maar geen URI. Een URI is een pad dat begint met een protocol, zoals http:// of file://, en om een foto weer te geven is een URI vereist. Via de file:// prefix geven we aan dat de foto bewaard is op het bestandssysteem van het toestel en dat deze van daar uitgelezen moet worden.
const CameraUI: FunctionComponent<CameraUIProps> = ({showCamera, cameraType, onClose}) => {
const {hasPermission: haveCameraPermission, requestPermission: requestCameraPermission} = useCameraPermission()
const [haveRequestedCameraPermission, setHaveRequestedCameraPermission] = useState<boolean>(false)
const [activeCamera, setActiveCamera] = useState<'front' | 'back'>(cameraType === 'front' ? 'front' : 'back')
const cameraDevice = useCameraDevice(activeCamera)
const camera = useRef<Camera>(null)
// ..
return (
<Modal>
<View className="flex items-center justify-end h-full">
<Camera ref={camera}
device={cameraDevice!} isActive={showCamera} style={styles.absoluteFill} photo />
<Button className="rounded-full p-3.5 h-14 w-14 absolute right-8 top-8" onPress={() => onClose(undefined)}>
<ButtonIcon as={X} size={iconSize} />
</Button>
<Button
size="lg"
className="rounded-full p-3.5 h-14 w-14 absolute right-8 bottom-8"
onPress={() => setActiveCamera(activeCamera === 'front' ? 'back' : 'front')}>
<ButtonIcon as={SwitchCamera} size={iconSize} />
</Button>
<Button variant="outline"
className="border-2 border-neutral-800 rounded-full h-24 w-24 absolute bottom-8 opacity-50 bg-neutral-700 data-[active=true]:bg-neutral-400">
onPress={async () => {
const photo = await camera.current?.takePhoto()
onClose(photo ? {...photo, path: `file://${photo.path}`} : undefined)}}/>
</View>
</Modal>
)
}Foto's weergeven
Om de genomen foto's weer te geven gebruiken we de Image component uit React Native. Deze component accepteert een source property waaraan we een object moeten doorgeven met de URI van de foto. De URI kan verwijzen naar een lokale foto (op het besturingssysteem), een foto op het internet of een foto in de assets van de applicatie.
Om de URI's persistent te maken gebruiken we opnieuw MMKV. Daarnaast gebruiken we een component die in de startbestanden aanwezig is om de layout van de foto's correct te krijgen.
Een afbeelding moet, in React Native, altijd een breedte en hoogte hebben, als deze niet opgegeven zijn, wordt de afbeelding niet weergegeven. We gebruiken hieronder de useWindowDimensions hook om de breedte van het scherm op te halen, vervolgens delen we deze door drie en trekken we de marges af van het resultaat. Op basis van de breedte berekenen we tenslotte de hoogte van de afbeelding met de calculateImageHeight functie die in de startbestanden voorzien is.
Via de space-between property van flex-layouts worden de foto's mooi gepositioneerd, maar op de laatste rij heeft dit minder aangename gevolgen. Via de FlexSpaceBetweenLastRowFix component (die voorzien is in de startbestanden) wordt de laatste rij opgevuld met lege View componenten zodat de elementen op de laatste rij links uitgelijnd worden.
const Index: FunctionComponent = () => {
const navigation = useNavigation()
const [showCamera, setShowCamera] = useState<boolean>(false)
const [photos, setPhotos] = useMMKVObject<PhotoFile[]>('lecture3Photos')
const dimension = useWindowDimensions()
const width = Math.ceil(dimension.width / 3 - 4 * 8)
useEffect(() => { /* ... */ }, [])
return (
<>
<CameraUI
showCamera={showCamera}
cameraType="back"
onClose={photo => {
if (photo) {
setPhotos([...(photos ?? []), photo])
}
setShowCamera(false)
}}
/>
<ScrollView>
<View className="m-2 gap-2 flex justify-between flex-row flex-wrap">
{photos?.map((photo, index) => (
<Image
style={[{width, height: calculateImageHeight(width, photo)}]}
source={ {...photo, uri: photo.path} }
key={index}
resizeMode="contain"
/>
))}
<FlexSpaceBetweenLastRowFix itemsPerRow={3} totalItems={photos?.length ?? 0} style={{width}} />
</View>
</ScrollView>
</>
)
}Onderstaande video demonstreert het nemen van foto's.
Foto's bewaren
Alhoewel de foto's momenteel persistent zijn, worden deze bewaard in de cache van het operating system. Dit betekent dat de foto's automatisch verwijderd kunnen worden als de beschikbare opslagruimte vol geraakt en het systeem automatisch ruimte vrij maakt door bestanden te verwijderen.
Om dit probleem op te lossen maken we gebruikt van React Native Camera Roll. Via deze native module krijgen we toegang tot de galerij van het systeem en kunnen we de foto's permanent bewaren.[1]
Deze library heeft (op Android) geen rechten nodig (tenzij je foto's uit de galerij wilt uitlezen die je niet zelf weggeschreven hebt). Toch zullen we de rechten toevoegen omdat deze op een andere manier bewaard moeten worden dan hierboven met React Native Vision Camera. De native module heeft geen ondersteuning voor de verkorte syntax die we hierboven gebruikt hebben, daarom moeten we elk recht individueel vermelden in app.json.
{
"expo": {
...
"ios": {
"supportsTablet": true,
"infoPlist": {
"NSPhotoLibraryUsageDescription": "$(PRODUCT_NAME) needs access to your photo library to save the photos you take and allow you to pick photos from your library.",
"NSPhotoLibraryAddUsageDescription": "$(PRODUCT_NAME) needs access to your photo library to save the photos you take."
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "be.pitgraduaten.gallery"
"permissions": [
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VIDEO",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE"
]
},
...
}Om de foto's weg te schrijven gebruiken we de saveAssets functie van de CameraRoll module.
import {CameraRoll} from '@react-native-camera-roll/camera-roll'
const CameraUI: FunctionComponent<CameraUIProps> = ({showCamera, cameraType, onClose}) => {
const {hasPermission: haveCameraPermission, requestPermission: requestCameraPermission} = useCameraPermission()
const [haveRequestedCameraPermission, setHaveRequestedCameraPermission] = useState<boolean>(false)
const [activeCamera, setActiveCamera] = useState<'front' | 'back'>(cameraType === 'front' ? 'front' : 'back')
const cameraDevice = useCameraDevice(activeCamera)
const camera = useRef<Camera>(null)
// ..
return (
<Modal>
<View className="flex items-center justify-end h-full">
<Camera ref={camera}
device={cameraDevice!} isActive={showCamera} style={styles.absoluteFill} photo />
<Button className="rounded-full p-3.5 h-14 w-14 absolute right-8 top-8" onPress={() => onClose(undefined)}>
<ButtonIcon as={X} size={iconSize} />
</Button>
<Button
size="lg"
className="rounded-full p-3.5 h-14 w-14 absolute right-8 bottom-8"
onPress={() => setActiveCamera(activeCamera === 'front' ? 'back' : 'front')}>
<ButtonIcon as={SwitchCamera} size={iconSize} />
</Button>
<Button variant="outline"
className="border-2 border-neutral-800 rounded-full h-24 w-24 absolute bottom-8 opacity-50 bg-neutral-700 data-[active=true]:bg-neutral-400">
onPress={async () => {
const photo = await camera.current?.takePhoto()
if (photo) {
const galleryPhoto = await CameraRoll.saveAsset(`file://${photo.path}`, {type: 'photo'})
onClose({...photo, path: galleryPhoto.node.image.uri})
}
onClose(undefined)
}}/>
</View>
</Modal>
)
}To-Do
In het tweede deel van de les breiden we het To-Do voorbeeld uit. We voegen geen nieuwe features toe, maar passen de bestaande code aan zodat deze gebruik maken van een SQLite database in de plaats van MMKV.
SQLite
De To-Do app maakt momenteel gebruik van MMKV om data te bewaren, dit is niet noodzakelijk slecht maar is ook niet echt geschikt voor grote hoeveelheden complexe data. Omwille van de manier waarop MMKV data bewaard (geserialiseerde JavaScript-objecten) heb je veel duplicatie (geen foreign keys) en performantie problemen bij grote hoeveelheden data. Daarbovenop is MMKV synchroon, wat betekent dat de UI geblokkeerd wordt als er data opgehaald wordt.
Alles wordt in één groot object bewaard wat betekent dat queries die één specifieke taak ophalen niet mogelijk zijn, je wordt verplicht om de volledige lijst van taken op te halen en deze vervolgens te filteren. Dit is niet noodzakelijk een probleem, maar hoe groter de data, hoe meer kans dat dit nefaste gevolgen heeft voor de performantie.
SQLite biedt hier een oplossing voor, ondanks het feit dan een SQLite database absoluut niet zo krachtig is als SQL Server of Postgres, is het nog steeds een volwaardige relationele database waar met tabellen en foreign keys gewerkt kan worden. Hierdoor kunnen we dingen als pagination en queries die enkel één rij of een subset van alle kolommen ophalen schrijven.
Info
MMKV heeft natuurlijk nog steeds een plaats, applicaties waar de hoeveelheid data klein is kunnen zonder problemen deze eenvoudigere bibliotheek gebruiken.
Maar zelfs dan, is een goed idee om de data die via MMKV bewaard wordt in context te plaatsen zodat de data gedeeld kan worden tussen verschillende componenten. Dit beperkt het aantal IO-operaties en zorgt voor een efficiëntere/snellere applicatie.
We gebruiken de op-sqlite bibliotheek die met onderstaand commando geïnstalleerd kan worden.
Alhoewel het mogelijk is om deze bibliotheek te werken zonder ORM (en dus pure SQL code te schrijven), kiezen we toch om een ORM te gebruiken. Zonder ORM wordt het heel moeilijk om op een efficiënte manier om te gaan met JOINS. De SQLite library geeft een array van objecten terug die een rij voorstellen in het resultaat van de query, vervolgens zou deze data gegroepeerd moeten worden.
Via Drizzle kunnen we snel een database aanmaken en queries schrijven die complexere data ophalen.
Tips
Drizzle kan ook gebruik worden in combinatie met Turso. Aangezien Turso een online SQLite database aanbiedt, kan je Turso gebruiken als online service in je project. Natuurlijk moet je op-sqlite dan wel niet meer gebruiken en verlies je een native module.
Meer informatie over het combineren van Drizzle en Turso vind je in de documentatie.
Drizzle configureren
Hieronder beschrijven we de stappen die nodig zijn om Drizzle te configureren. Deze stappen zijn reeds gedaan in de startbestanden, je kan deze in je eigen project overnemen als je kiest om gebruik te maken van SQLite.
drizzle.config.ts
In drizzle.config.ts wordt de SQL variant, de driver, de file die het schema definieert en de map waarin de migraties zich bevinden gedefinieerd.
import {defineConfig} from 'drizzle-kit'
export default defineConfig({
schema: './db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
driver: 'expo', // <--- very important
})metro.config.js
metro.config.js beschrijft hoe de bundler werkt. Standaard worden .sql bestanden niet mee gekopieerd naar de bundle aangezien deze in een gewone Expo app geen betekenis hebben. Door de configuratie aan te passen worden deze wel gekopieerd en kan Drizzle deze gebruiken om migraties aan te maken.
Merk op dat we op lijn 4 een type comment toevoegen, zo weet de IDE toch wat voor soort data er in het config object zit en kan de resolver property zonder problemen gebruikt worden.
const {getDefaultConfig} = require('expo/metro-config')
const {withNativeWind} = require('nativewind/metro')
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname)
config.resolver.sourceExts.push('sql')
module.exports = withNativeWind(config, {input: './global.css'})babel.config.js
Als laatste stap moeten we de babel configuratie aanpassen.
module.exports = function (api) {
api.cache(true)
return {
presets: [['babel-preset-expo'], 'nativewind/babel'],
plugins: [
[
'module-resolver',
{
root: ['./'],
alias: {
'@': './',
'tailwind.config': './tailwind.config.js',
},
},
],
'react-native-worklets/plugin',
["inline-import", { "extensions": [".sql"] }]
],
}
}Database aanmaken
Via onderstaande code maken we een databaseconnectie aan. Merk op dat we een variable exporteren die de database connectie bevat, zo blijft de connectie open zolang de applicatie in memory zit. De variabele blijft in memory omdat deze globaal is, hierdoor moet de connectie niet telkens opnieuw aangemaakt worden.
import {open} from '@op-engineering/op-sqlite'
import {drizzle} from 'drizzle-orm/op-sqlite'
const opSqliteDb = open({
name: 'todo.sqlite',
})
export const db = drizzle(opSqliteDb)Vervolgens moeten we een schema definiëren. Merk op dat we een integer moeten gebruiken om een boolean te bewaren, sqlite heeft geen boolean datatype. Ook varchar bestaat niet in SQLite, daarom moeten we text gebruiken.
import {int, sqliteTable, text} from 'drizzle-orm/sqlite-core'
export const tasksTable = sqliteTable('Task', {
id: int().primaryKey({autoIncrement: true}),
name: text().notNull(),
completed: int({mode: 'boolean'}).notNull().default(false),
})Het schema moet natuurlijk omgevormd worden in een migratie, hiervoor gebruiken we onderstaand commando.
Dit commando genereert een migratie in de /.drizzle map.

Om de migratie toe te passen moeten we een React component gebruiken, een SQLite database bestaat enkel op het toestel van de gebruiker, er is dus geen server om de migratie naar te pushen. Omdat de data voor de volledige applicatie beschikbaar moet zijn, voeren we de migratie uit in de root layout.
Natuurlijk is het ook nodig om de database te seeden. Via useEffect wachten we tot een migratie succesvol is uitgevoerd, vervolgens seeden we de database. We testen eerst of er al rijen ingevoegd zijn in de database en voegen 50 taken toe als die er nog niet zijn. Merk op dat we een array terugkrijgen van de select, ook als we een query schrijven die exact één element ophaalt. Hier kunnen we niet buiten als we de Drizzle SQL API gebruiken om data op te halen.
import {useMigrations} from 'drizzle-orm/op-sqlite/migrator'
import {db, seed} from '@/db/database'
import migrations from '@/drizzle/migrations'
const RootLayout: FunctionComponent = () => {
const {success} = useMigrations(db, migrations)
useEffect(() => {
if (success) {
void seed().then(() => console.log('Database seeded'))
}
}, [success])
return (
<GestureHandlerRootView>
<ThemeProvider>
<Tabs>
{/* ... */}
</Tabs>
</ThemeProvider>
</GestureHandlerRootView>
)
}import {count} from 'drizzle-orm'
import {tasksTable} from '@/db/schema'
export async function seed() {
const [{count: nbTasks}] = await db.select({count: count()}).from(tasksTable)
if (nbTasks === 0) {
await db.insert(tasksTable).values([
...Array(50)
.fill(0)
.map((_, i) => ({name: `Task ${i + 1}`, completed: i % 2 === 0})),
])
}
}TanStack Query
TanStack Query werkt op exact dezelfde manier als in de cursus Frontend Frameworks, het enige verschil is dat we de initialisatie een beetje moeten aanpassen omdat environment variables anders werken in Expo dan in Vite en omwille van de aard van een SQLite database. Aangezien we met een embedded database werken, is data nooit stale en stellen we de staleTime in op Infinity.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: !__DEV__,
staleTime: Infinity,
},
},
})
const RootLayout: FunctionComponent = () => {
return (
<QueryClientProvider client={queryClient}>
<GestureHandlerRootView>
<ThemeProvider>
<Tabs>
{/* ... */}
</Tabs>
</ThemeProvider>
</GestureHandlerRootView>
</QueryClientProvider>
)
}Nu deze configuratie klaar is, kunnen we queries en mutations schrijven die de data in de SQLite database aanpassen en uitlezen.
Taken uitlezen
Om de taken uit te lezen gebruiken we een eenvoudige select all. Merk op dat we de labels vervangen met een lege array, het implementeren van de labels wordt als oefening gelaten.
import type ITask from '@/models/ITask'
import {tasksTable} from '@/db/schema'
import {db} from '@/db/database'
import {eq} from 'drizzle-orm'
async function getTasks(): Promise<ITask[]> {
const tasks = await db.select().from(tasksTable)
return tasks.map(task => ({...task, labels: []}))
}
async function getTask(id: number): Promise<ITask> {
const [task] = await db.select().from(tasksTable).where(eq(tasksTable.id, id))
return {...task, labels: []}
}Taak toevoegen
Via de returning methode kunnen we de ingevoerde data onmiddellijk terug ophalen.
interface DeleteTasksParams {
id: number
}
async function createTask({name}: CreateTasksParams): Promise<ITask> {
const [task] = await db.insert(tasksTable).values({name}).returning()
return {...task, labels: []}
}Taak verwijderen en updaten
De functies om een taak te verwijderen moeten specifieren welke taak verwijderd of geüpdated moet worden, hiervoor voorzien Drizzle enkele logische operatoren waarmee gefiltered kan worden. We gebruiken hieronder de eq operator die op gelijkheid test en de not operator die de negatie van een expressie bepaald.
async function deleteTask({id}: DeleteTasksParams): Promise<void> {
await db.delete(tasksTable).where(eq(tasksTable.id, id))
}
async function updateTask({id, name, completed}: ITask): Promise<ITask> {
const [task] = await db.update(tasksTable).set({name, completed}).where(eq(tasksTable.id, id)).returning()
return {...task, labels: []}
}
async function toggleTaskStatus({id}: ToggleStatusParams): Promise<ITask> {
const [task] = await db
.update(tasksTable)
.set({completed: not(tasksTable.completed)})
.where(eq(tasksTable.id, id))
.returning()
return {...task, labels: []}
}Queries & mutations
De queries en mutations die gebruik maken van deze functies worden niet besproken aangezien deze geen nieuwe concepten bevatten. Je kan de uitgewerkte code terugvinden in het uitgewerkte voorbeeld.
Voorbeeldcode
Volledig uitgewerkt lesvoorbeeld met commentaar
Als je de afbeeldingen niet in de galerij wilt bewaren, kan je de afbeeldingen ook in het bestandssysteem bewaren via de fileSystem module of opslaan op een online service zoals Firebase of Supabase (geïllustreerd in image-upload voorbeeld). ↩︎