Ga naar de hoofdinhoud

1. Expo & Core Components

24-01-2024Ongeveer 31 minutenOngeveer 4662 woorden

1. Expo & Core Components

Tijdens deze les bouwen we een eenvoudige To-Do applicatie waarin taken toegevoegd kunnen worden en als afgewerkt of niet-afgewerkt gemarkeerd kunnen worden.

De startbestanden voor deze les zijn anders opgebouwd dan bij de frontend-lessen. Omwille van de complexiteit van het project is een deel van de lesinhoud al geïmplementeerd omdat er anders te veel bestanden verplaatst moeten worden binnen het project, wat verwarrend kan zijn.

Startbestanden

Waarschuwing

De code in deze lessen is HEEL gevoelig aan lange, geneste padnamen. Als je op Windows machine werkt, is het ten zeerste aangeraden om je projecten in c:\mobile_dev te plaatsen en geen diepere mappenstructuur te gebruiken.

Het compileren van een applicatie kan tot 20 minuten duren (de eerste keer), als je dan minutenlang moet wachten op uiteindelijk een foutmelding te krijgen ben je heel veel tijd verloren. Begin dus op een pad dat zo dicht mogelijk bij de root van je filesystem ligt en vermijd dit soort problemen.

Expo

Doorheen deze lessenreeks maken we gebruik van Expo, een meta-framework voor React Native waarmee we snel een eenvoudig mobiele applicaties kunnen bouwen.

Het is perfect mogelijk om een React Native applicatie te bouwen zonder Expo, maar dit is aanzienlijk complexer en vereist dat je zelf aanpassingen doet aan het Android en iOS project. Daarnaast is het lastiger om routing werkende te krijgen en om native modules (camera, bluetooth, ...) te gebruiken.

Project aanmaken

Bovenstaande startbestanden bevatten reeds een React Native project waarin ESLint en Prettier geïntegreerd zijn. Ondanks dat de startbestanden reeds een project bevatten, is het wel cruciaal dat je zelf een project kan aanmaken, daarom bespreken we procedure hieronder.

Een project aanmaken kan via onderstaand commando. Voor de configuratie van ESLint en Prettier verwijzen we door naar de appendix.

Vie de --template parameter kunnen we kiezen uit enkele templates. Deze les kiezen we voor de Blank (TypeScript) template, voor de volgende lessen vertrekken we van de Navigation (TypeScript) template.

? Choose a template: » - Use arrow-keys. Return to submit.
    Blank
>   Blank (TypeScript) - blank app with TypeScript enabled
    Navigation (TypeScript)
    Blank (Bare)

Vervolgens moeten we de naam van de app ingeven, waarna onderstaande uitvoer verschijnt.

✅ Your project is ready!

To run your project, navigate to the directory and run one of the following pnpm commands.

- cd mobile_dev_lecture1_example
- pnpm run android
- pnpm run ios # you need to use macOS to build the iOS project - use the Expo app if you need to do iOS development without a Mac
- pnpm run web

Als we onderstaand commando uitvoeren wordt de app gecompileerd.

Vervolgens typen we de letter 'a' in om het project te openen in een Android emulator of 'i' om het project te openen in een iOS emulator. Daarna opent de emulator en wordt Expo Go geïnstalleerd[1] op de emulator. Eens deze installatie gedaan is, wordt Expo Go gebruikt om de applicatie te openen.

De standaard Expo app
Figuur 1: De standaard Expo app

Core components

Voordat we de To-Do app uitwerken, kijken we eerste naar de enkele basiscomponenten en hoe we deze een opmaak kunnen geven in React Native. React Native code lijkt natuurlijk heel sterk op de code die we in frontend frameworks geschreven hebben, maar komt niet volledig overeen.

Om dit te illustreren benaderen we applicatie eerst als een normale React applicatie.

/App.tsx
export default function App() {
  return <div>Een eerste React Native App</div>
}

Als we bovenstaande code uitvoeren (via pnpm android), krijgen we onderstaande foutmelding te zien in de console.

l
ERROR Error: Text strings must be rendered within a <Text> component.

De reden voor deze fout is dat je in React Native enkel gebruik kan maken van core components. Door een recente update van Expo kan je hier een uitzondering op maken[2], maar daar gaan we verder niet op in.

Begrip: Core Components

Omdat React Native de UI die in JavaScript geschreven is converteert naar native UI-elementen, is het aantal componenten die door React Native aangeboden worden eerder beperkt. Klassieke HTML tags kunnen niet gebruikt worden.

Elk van de core components is gelinkt aan een vast UI-element in de Android of iOS SDK. Alhoewel de standaard componenten eenvoudig zijn, kunnen deze wel gebruikt worden om complexere en grotere componenten te bouwen.

Het is belangrijk om op te merken dat verschillende cruciale componenten, zoals een checkbox, radio button, ... niet beschikbaar zijn in React Native. Als je deze wilt gebruiken, moet je de component zelf bouwen, of gebruikmaken van een community library.

View & Text Components

De View component is de basis bouwsteen die je voor alle andere componenten moet gebruiken. Het ingangspunt van elke applicatie moet een View zijn.

Deze component is vergelijkbaar met een <div> en kan dan ook, net zoals een <div>, nul, één of meerdere kinderen bevatten. Daarnaast komt ook de styling overeen met een <div>, een View is dus een container zonder opmaak die standaard de volledige breedte van een scherm in beslag neemt.

De Text component dient, zoals de naam doet vermoeden, om tekst weer te geven.

Deze component is vergelijkbaar met een paragraph (<p>) in HTML, er zijn wel enkele belangrijke verschillen. Text componenten kunnen genest worden, terwijl dat voor een <p> tag verboden is. Verder kan een Text component een newline bevatten, de '\n' literal produceert een linebreak in de resulterende app.

Via deze twee componenten kunnen we bovenstaande code omvormen naar iets dat wel door React Native gecompileerd kan worden.

/App.tsx
import {Text, View} from 'react-native'

export default function App() {
  return (
    <View>
      <Text>Een eerste React Native App</Text>
    </View>
  )
}

Alhoewel de app nu compileert, zijn er duidelijk nog visuele problemen. De tekst staat onder de statusbalk en is dus niet leesbaar.

Layout problemen
Figuur 2: Layout problemen

SafeAreaView

Een smartphone bevat verschillende elementen die de content van een applicatie kunnen overlappen. Denk hierbij aan een notch, een afgerond scherm, een home button, ... Het is vanzelfsprekend dat de applicatie niet oder deze elementen mag staan, om dit probleem op te lossen kunnen we gebruik maken van de safe-area-context library.

Merk op dat we pnpx expo gebruiken om de library te installeren, dit garandeert dat de versie van react-native-safe-area-context die geïnstalleerd wordt compatibel is met de versie van Expo die we gebruiken.

De library bevat twee componenten, SafeAreaProvider zorgt ervoor dat de SafeAreaView[3] component werkt. Deze laatste component voeg automatisch marge toe aan de randen van de view zodat er geen overlap is met hardware-elementen.

Info

Als onderstaande code geen zichtbaar resultaat geeft, kan je de cache leegmaken met onderstaand commando:

/App.tsx
import {Text} from 'react-native'
import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context'

export default function App() {
  return (
    <SafeAreaProvider>
      <SafeAreaView>
        <Text>Een eerste React Native App</Text>
      </SafeAreaView>
    </SafeAreaProvider>
  )
}
Een eerste app in React Native
Figuur 3: Een eerste app in React Native

Theming

De app ziet er nog heel basic uit, het is altijd interessanter als een app een eigen thema heeft. Daarom is er in de startbestanden al een context en bijhorende provider voorzien.

Begrip: Styling in React Native

Om de opmaak van een React Native applicatie te implementeren wordt iets gebruikt dat op CSS lijkt, maar niet helemaal overeen komt. Er zijn enkele belangrijke verschillen tussen CSS en React Native styling.

  • Id's, klassen en andere CSS-selectors kunnen niet gebruikt worden
  • Lay-out gebeurt uitsluitend met flexbox. Er zijn wel enkele verschillen met CSS-flexbox.
    • flexDirection heeft column als default
    • alignContent heeft flex-start als default
    • flexShrink heeft 0 als default
    • flex kan slechts een waarde krijgen, een getal.
  • Er zijn twee mogelijke eenheden rem, em, px, ... worden niet gebruikt.
    • Als de hoogte en/of breedte zonder eenheid (en als integer) genoteerd wordt, verwijst dit naar density-independent pixels.
    • Als de hoogte en/of breedte als 'xx%' genoteerd wordt, verwijst dit naar procentuele afmetingen.
  • Een View component moet een vaste hoogte of breedte hebben of moet de regel flex: xx krijgen waarbij xx een integer groter dan of gelijk aan 11 is. Als dit niet het geval is, zijn de kinderen niet zichtbaar.
  • Er is GEEN overerving van View naar Tekst, dit betekent dat dingen als tekst kleur voor elke Text component individueel aangepast moet worden. Behalve als de Text component in een andere Text component genest is.

Opmaak wordt gedefinieerd via de StyleSheet namespace en wordt volledig in JavaScript genoteerd.

import {View, Text, StyleSheet} from 'react-native'
import {FunctionComponent} from 'react'
import {StatusBar} from 'expo-status-bar'

const Foo: FunctionComponent = () => {
    return (
        <View style={styles.container}>
            <Text style={[styles.textStyle, styles.strikeThrough]}>
                Je kan één of meer stijlen toekennen aan een component.
                De laatste stijl heeft voorrang. 
                De kleur van deze tekst is dus rood.
            </Text>
            <StatusBar style="auto" />
        </View>
    )
}

const styles = StyleSheet.create({
    container: {
        flex: 1,
        backgroundColor: '#282c34',
        alignItems: 'center',
        justifyContent: 'center',
    },
    textStyle: {
        color: '#fff',
        fontSize: 18,
    },
    strikeThrough: {
        color: 'red',
        textDecorationLine: 'line-through',
    },
})

Om de waarde uit de provider uit te lezen, moet deze natuurlijk wel rond de component staan waarin we dit willen uitlezen. Daarom omringen we de TaskPage component uit de startbestanden met de ThemeProvider component.

/App.tsx
import TaskPage from './src/pages/tasks/taskPage'
import ThemeProvider from './src/context/themeProvider'

export default function App() {
  return (
    <ThemeProvider>
      <TaskPage />
    </ThemeProvider>
  )
}

Als we de ThemeContext vervolgens gebruiken om de achtergrondkleur en de bijhorende tekstkleur uit te lezen stoten we op een nieuwe problemen.

/src/pages/tasks/taskPage.tsx
const TaskPage: FunctionComponent = () => {
  const {backgroundColor, textColor} = useContext(ThemeContext)

  return (
    <SafeAreaProvider>
      <SafeAreaView
        style={[{backgroundColor, color: textColor}]}>
        <Text>Een eerste React Native App</Text>
      </SafeAreaView>
    </SafeAreaProvider>
  )
}

Ten eerste geeft TypeScript onderstaande foutmelding:

l
Object literal may only specify known properties, and color does not exist in type

De color property is niet beschikbaar op een View component. In tegenstelling tot klassieke HTML, kan de tekstkleur dus niet globaal ingesteld worden, maar moet dit gebeuren op het individueel niveau van een Text component. De tekstkleur wordt enkel overgeërfd van een bovenliggende Text component, en een Text component kan enkel andere Text componenten als kind hebben, niet bijzonder handig.

/src/pages/tasks/taskPage.tsx
const TaskPage: FunctionComponent = () => {
  const {backgroundColor, textColor} = useContext(ThemeContext)

  return (
    <SafeAreaProvider>
      <SafeAreaView style={[{backgroundColor}]}>
        <Text style={[{color: textColor}]}>Een eerste React Native App</Text>
      </SafeAreaView>
    </SafeAreaProvider>
  )
}

Zelfs na deze aanpassing is de app nog niet ideaal. Zoals onderstaand screenshot demonstreert, wordt de achtergrond slechts op een klein stukje van het scherm toegepast.

Slechte achtergrond
Figuur 4: Slechte achtergrond

Om dit probleem op te lossen moeten we de hoogte van de View component instellen. Hiervoor moeten we gebruik maken van flexbox, de enige manier om een lay-out te bouwen in React Native, zaken zoals CSS-Grid zijn niet beschikbaar[4]. Flexbox werkt op ongeveer dezelfde manier in React Native als in een klassieke website, maar er zijn wel enkele kleine verschillen.

  • flexDirection heeft column als default
  • alignContent heeft flex-start als default
  • flexShrink heeft 0 als default in de plaats van 1
  • flex kan slechts een waarde krijgen, een getal
/src/pages/tasks/taskPage.tsx
const TaskPage: FunctionComponent = () => {
  const {backgroundColor, textColor} = useContext(ThemeContext)

  return (
    <SafeAreaProvider>
      <SafeAreaView style={[{backgroundColor, flex: 1}]}>
        <Text style={[{color: textColor}]}>Een eerste React Native App</Text>
      </SafeAreaView>
    </SafeAreaProvider>
  )
}

Bovenstaande code produceert nu een veel beter resultaat, als is de statusbalk nog steeds een probleem.

Achtergrond op het volledige scherm
Figuur 5: Achtergrond op het volledige scherm

Voordat we naar de statusbalk kijken, is het interessant om eerste de code in de TaskPage te herschrijven. Ook al hebben we maar drie CSS-regels gekoppeld aan de View component, is het nog steeds properder om deze code af te zonderen. We kunnen geen klassieke CSS gebruiken, we moeten de StyleSheet klasse gebruiken. Op deze manier wordt de code afgezonderd en wordt het object met styling niet in elke re-render opnieuw aangemaakt.

De klassen die we definiëren in het stylesheet kunnen meegegeven worden aan de style property van een core-component. We kunnen, via deze property, één stylesheet toekennen, of een array met meerdere stylesheet of objecten. Net zoals in CSS, is het mogelijk om dingen te overschrijven, het laatste element in de array heeft steeds voorrang.

De dynamische delen van de CSS, i.e. de kleuren uit de ThemeProvider, kunnen niet in de styles variable bewaard worden omdat deze gewijzigd kunnen worden door de gebruiker en dus niet statisch zijn, daarom gebruiken we voor deze properties inline styles.

/src/pages/tasks/taskPage.tsx
import {Text, StyleSheet} from 'react-native'

const TaskPage: FunctionComponent = () => {
  const {backgroundColor, textColor} = useContext(ThemeContext)

  return (
    <SafeAreaProvider>
      <SafeAreaView style={[styles.container, {backgroundColor}]}>
        <Text style={[{color: textColor}]}>Een eerste React Native App</Text>
      </SafeAreaView>
    </SafeAreaProvider>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
})

Statusbar

De StatusBar component uit Expo is een uitbreiding van de standaard React Native component met dezelfde naam. De Expo versie biedt enkele verbeteringen die vooral met styling te maken hebben. Een applicatie functioneert perfect zonder dat deze component gebruikt wordt, maar zoals bovenstaande screenshots illustreren, is het interessanter om deze component toch toe te voegen zodat de statusbar mee gestyled wordt met de rest van de applicatie.

Via de style property kunnen we de tekstkleur aanpassen, maar we kunnen enkel kiezen uit de strings light, dark, auto en inverted.

Aangezien de statusbalk zich in de volledige app op eenzelfde manier moet gedragen, voegen we de StatusBar component toe aan de ThemeProvider.

Info

De StatusBar heeft enkele properties zoals backgroundColor, translucent, ... die niet meer werken op de nieuwste Android versies (en nooit gewerkt hebben op iOS).

Vanaf versie 15 forceert Android dat applicaties edge-to-edge gebruiken. Hierdoor wordt de statusbalk (zowel boven als onderaan) een deel van de applicatie en worden deze niet langer boven de rest van de applicatie gerenderd.

Omdat de statusbalk nu een deel van de applicatie is, krijg deze de achtergrond kleur van de applicatie en is het niet langer mogelijk om deze apart te stylen.

/src/context/themeProvider.tsx
import {StatusBar} from 'expo-status-bar'

const ThemeProvider: FunctionComponent<PropsWithChildren> = ({children}) => {
  return (
    <ThemeContext.Provider value={darkTheme}>
      <StatusBar style="auto" />
      {children}
    </ThemeContext.Provider>
  )
}

Onderstaand screenshot toont het resultaat van de drie meest frequente keuzes. Deze zijn genomen op een toestel waar het OS-thema op dark staat.

Statusbar met style=auto (links), style=light (midden) en style=dark (rechts)
Figuur 6: Statusbar met style=auto (links), style=light (midden) en style=dark (rechts)

Zoals te zien is op screenshot moeten we de style property op light zetten om een goed resultaat te bekomen, dit is verre van ideaal. De statusbalk zou zich automatisch moeten aanpassen aan de hand van het gekozen OS-thema. Dit is dan ook wat de auto waarde doet, maar voorlopig doet deze waarde niets. Het probleem is dat de app het gekozen theme voorlopig negeert, om dit te veranderen moeten we app.json aanpassen.

/app.json
{
  "expo": {
    "name": "mobile_dev_lecture1_example",
    "slug": "mobile_dev_lecture1_example",
    "version": "1.0.0",
    "orientation": "portrait",
    "icon": "./assets/icon.png",
    "userInterfaceStyle": "automatic",
    "newArchEnabled": true,
    "splash": {
      "image": "./assets/splash.png",
      "resizeMode": "contain",
      "backgroundColor": "#ffffff"
    },
    "ios": {
      "supportsTablet": true
    },
    "android": {
      "adaptiveIcon": {
        "foregroundImage": "./assets/adaptive-icon.png",
        "backgroundColor": "#ffffff"
      }
    },
    "web": {
      "favicon": "./assets/favicon.png"
    }
  }
}

Als we de app vervolgens herladen en de style eigenschap op auto zetten, past de statusbar zich automatisch aan.

useColorScheme

De statusbalk past zich nu wel aan, maar het thema van de applicatie nog niet. Hiervoor kunnen we de useColorScheme hook gebruiken.

Begrip: useColorScheme

De useColorScheme hook kan gebruikt worden om het voorkeursthema van de gebruiker uit te lezen.

De hook geeft een string terug die één van onderstaande waarden heeft:

  • light: De gebruiker verkiest een licht thema
  • dark: De gebruiker verkiest een donker thema
  • null: De gebruiker heeft geen voorkeur opgegeven. Normaliter is dit enkel het geval op oudere toestellen waar deze optie nog niet beschikbaar was.
import {useColorScheme} from 'react-native'

const Foo: FunctionComponent = () => {
    const colorScheme = useColorScheme()

    if (colorScheme === 'light') {
        return <>{/* A light styled component */}</>
    } else if (colorScheme === 'dark') {
        return <>{/* A dark styled component */}</>
    } else if (colorsScheme === null) {
        return <>{/* A component styled in the app's prefered theme */}</>
    }
}

Via deze hook kunnen we de ThemeProvider uitbreiden zodat het theme zich automatisch aanpast aan het OS-thema.

/src/context/themeProvider.tsx
import {useColorScheme} from 'react-native'

const ThemeProvider: FunctionComponent<PropsWithChildren> = ({children}) => {
  const isDark = useColorScheme() === 'dark'

  return (
    <ThemeContext.Provider value={isDark ? darkTheme : lightTheme}>
      <StatusBar style="auto" />
      {children}
    </ThemeContext.Provider>
  )
}

De app past zich nu volledig aan, zoals geïllustreerd in onderstaande video.

Figuur 7: Theme past zich automatisch aan

StyledText

De startbestanden bevatten reeds een Header component om de titel van de pagina weer te geven en eventueel een knop.

Merk op dat de Header component de achtergrondkleur van de header aanpast aan de hand van de waarde uit de ThemeContext. Omdat dit een andere kleur is dan diegene op de SafeAreaView component, moeten we de achtergrondkleur van de SafeAreaView aanpassen en een nieuwe geneste view toevoegen die de achtergrondkleur van de app instelt.

/src/components/header.tsx
const Header: FunctionComponent<TitleBarProps> = ({title, children}) => {
  const {surfaceColor, textColor} = useContext(ThemeContext)

  return (
    <View style={[style.container, {backgroundColor: surfaceColor}]}>
      <View style={[style.titleContainer]}>
        <Text style={[style.title, {color: textColor}]}>{title}</Text>
      </View>
      <View style={[style.toolbar]}>{children}</View>
    </View>
  )
}

Na bovenstaande aanpassingen ziet de app er als volgt uit.

Header toegevoegd
Figuur 8: Header toegevoegd

Bovenstaande code werkt, maar we gebruiken zowel in de Header als in de TaskPage component de ThemeContext om de tekstkleur op te halen en die vervolgens door te geven aan de Text component. Dit wordt snel heel omslachtig, zeker als de applicatie groter wordt. Daarom is het interessanter om een herbruikbare StyledText component te bouwen die de kleur automatisch toepast.

Omdat de StyledText component een wrapper is rond een Text component, is het noodzakelijk dat alle properties van Text doorgegeven kunnen worden aan de StyledText component. Om dit te realiseren, kunnen we gebruik maken van de ComponentProps helper die door React geëxporteerd wordt.[5]

Begrip: ComponentProps

Via de ComponentProps helper is het mogelijk om een interface/type te generen voor een component die de verwachte properties niet exporteert als interface/type.

De helper ook gebruikt worden om de properties van een klassiek HTML-element te weten te komen, maar hiervoor zijn binnen React ook interfaces voor voorzien die je rechtstreeks kunt importeren.

import {ComponentProps} from 'react'
import Foo from 'foo'

type fooProps = ComponentProps<typeof Foo>
type inputProps = ComponentProps<'input'>

Alle properties, met uitzondering van style en children, kunnen rechtstreeks doorgegeven worden van de StyledText component naar de onderliggende Text component. Aangezien we de tekstkleur standaard willen instellen in de StyledText component en omdat we deze standaard styling moeten kunnen uitbreiden of aanpassen, moeten we de style property van de StyledText component combineren met de styling voor de tekstkleur. De children property moet natuurlijk doorgegeven worden als kind aan de onderliggende Text component.

We vermelden de children en style properties expliciet en gebruiken vervolgende de spread operator om alle andere properties te bundelen in een nieuw object dat we, opnieuw via de spread operator, doorgeven aan de Text component.

De nieuwe component kan vervolgens gebruikt worden in de Header en TaskPage componenten.

/src/components/styledText.tsx
import {ComponentProps, FunctionComponent, useContext} from 'react'

type StyledTextProps = ComponentProps<typeof Text>

const StyledText: FunctionComponent<StyledTextProps> = ({children, style, ...textProps}) => {
  const {textColor} = useContext(ThemeContext)

  return (
    <Text style={[{color: textColor}, style]} {...textProps}>
      {children}
    </Text>
  )
}

Het is duidelijk dat je best altijd gestylede versies van de meest gebruikte core components maakt (tenzij je een componenten library gebruikt).

Buttons

In de header moet een knop komen te staan waarmee we een nieuwe taak kunnen toevoegen. Het is vanzelfsprekend dat we opnieuw een herbruikbare component bouwen.

Begrip: Pressable & Button

React Native voorziet twee manieren om een knop te definiëren, Button en Pressable.

De Button component is heel gelimiteerd, de component is gestyled en kan niet aangepast worden aan een eigen thema. De Pressable component doet op zich niets, maar kan wel rond een View gezet worden, op deze manier kan een knop gebouwd worden die binnen het thema van de applicatie valt. We kiezen in deze cursus altijd voor de Pressable component.

const Button: FunctionComponent = () => {
    return (
        <Pressable onPress={() => {/* Doe iets als er op de knop gedrukt wordt */}}>
            <View>
                {/* Inhoud van de knop */}
            </View>
        </Pressable>
    )
}

We kunnen de Pressable component dus gebruiken om een custom IconButton component te bouwen. De startbestanden bevatten al een begin voor deze component, we moeten enkel de Pressable wrapper toevoegen.

/src/components/iconButton.tsx
import {Pressable, StyleSheet, View} from 'react-native'

interface IconButtonProps {
  onPress: () => void
}

const IconButton: FunctionComponent<IconButtonProps> = ({onPress}) => {
  return (
    <Pressable onPress={onPress}>
      <View style={[styles.button]} />
    </Pressable>
  )
}

Het is natuurlijk mogelijk om een knop te bouwen waarin gewoon de tekst 'New task' staat, maar dit is niet bepaald een aantrekkelijke UI. Daarom gebruiken we de icon library van Lucide React. Voor een lijst van alle beschikbare iconen kan je lucide.dev/icons raadplegen.

We willen de IconButton natuurlijk zo generiek mogelijk kunnen gebruiken. Zo kunnen we van verschillende iconen een knop maken. We gebruiken hiervoor de LucideIcon en bijhorend LucideProps componenten uit lucide-react-native library. Icon is hier een React component die als Icon={SomeComponent} meegegeven moet worden. Omdat de component doorgegeven wordt zonder </> kunnen we het lucide-icoon in de IconButton component oproepen en de properties instellen.

/src/components/iconButton.tsx
import {LucideIcon, LucideProps} from 'lucide-react-native';

interface IconButtonProps extends LucideProps {
  onPress?: () => void
  Icon: LucideIcon
}

const IconButton: FunctionComponent<IconButtonProps> = ({onPress, Icon, ...iconProps}) => {
  const {textColor} = useContext(ThemeContext)

  return (
    <Pressable onPress={onPress}>
      <View style={[styles.button]}>
        <Icon size={30} color={textColor} {...iconProps} />
      </View>
    </Pressable>
  )
}
Figuur 9: Knop om een nieuwe taak toe te voegen

MMKV

Voordat we de taken kunnen uitlezen, moeten we deze ergens persistent bewaren.

Begrip: MMKV

MMKV is een zeer snelle (en kleine) key-value storage library ontwikkeld door het Chinese WeChat. Deze library presteert in benchmarks aanzienlijk beter de SharedPreferences en UserDefaults, die respectievelijk voorzien worden door Android en iOS.

MMKV kan gebruikt worden om kleine, niet relationele, data te bewaren of om het resultaat van een HTTP request lokaal te cachen. Via de react-native-mmkv library kan MMKV gebruikt worden in een React Native applicatie.

Via de useMMKV... hooks kunnen we (een stuk van) een object of een primitieve waarde bewaren.

const Foo: FuctionComponent = () => {
    const [someString, setSomeString] = useMKKVString('sleutel1')
    const [someBoolean, setSomeBoolean] = useMKKVBoolean('sleutel2')
    const [someNumber, setSomeNumber] = useMKKVNumber('sleutel3')
    const [someObject, setSomeObject] = useMKKVObject<T>('sleutel4')

    return <> {/* JSX-Code */} </>
}

Naast MMKV installeren we ook de expo-crypto library om een UUID te generen voor elke taak.

MMKV is niet bedoeld voor grote en complexe read-operaties, daarom is deze library synchroon. Je zoek dus beter naar een andere oplossing als je complexere queries wilt uitvoeren of met grotere data wilt werken. Maar langs de andere kant betekent dit dat het niet nodig is om TanStack Query te gebruiken. We kunnen dus een eenvoudige hook schrijven die de taken uitleest, CRUD methodes voorziet en eventueel demo taken aanmaakt.

De code voor deze hook is grotendeels gegeven, we moeten de useState hook enkel vervangen met de useMMKVObject hook.

/src/hooks/useTasks.ts
import ITask from '../models/ITask'
import {useMMKVObject} from 'react-native-mmkv'
import {useEffect} from 'react'
import {randomUUID} from 'expo-crypto'

type useTaskReturnValue = {
  tasks: ITask[]
  createTask: (name: string) => void
  toggleTaskStatus: (id: string) => void
}

const useTasks = (key: string = 'tasks'): useTaskReturnValue => {
  const [tasks, setTasks] = useMMKVObject<ITask[]>(key)
  const isUndefined = tasks === undefined

  useEffect(() => {
      if (!tasks) {
          const demoTasks: ITask[] = []
          for (let i = 1; i <= 50; i++) {
              demoTasks.push({
                  name: `Task ${i}`,
                  id: randomUUID(),
                  completed: i % 2 === 0,
              })
          }
          setTasks(demoTasks)
      }
  }, [isUndefined])

  const createTask = (name: string) => {
    setTasks([
        ...(tasks ?? []),
        {
            name,
            completed: false,
            id: randomUUID(),
        },
    ])
  }

  const toggleTaskStatus = (id: string) => {
    const task = tasks?.find(t => t.id === id)
    if (task) {
        task.completed = !task.completed
        setTasks(tasks)
    }
  }

  return {
    tasks: tasks ?? [],
    createTask,
    toggleTaskStatus,
  }
}

Tenslotte kunnen we deze hook dan gebruiken in de ToDoList component die gerenderd wordt als kind van de TaskPage. In ToDoList tonen we elke taak via een ToDoItem component. Zowel de ToDoList als ToDoItem component zijn beschikbaar in de startbestanden.

/src/pages/tasks/taskPage.tsx
const TaskPage: FunctionComponent = () => {
  const {backgroundColor, surfaceColor} = useContext(ThemeContext)

  return (
    <SafeAreaProvider>
      <SafeAreaView style={[{backgroundColor: surfaceColor}, styles.container]}>
        <Header title="Tasks">
          <IconButton onPress={() => alert('Works')} Icon={PlusIcon} />
        </Header>
        <View style={[styles.container, {backgroundColor}]}>
          <ToDoList />
        </View>
      </SafeAreaView>
    </SafeAreaProvider>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  content: {
    margin: 5,
    flex: 1,
  },
})

Zodra we deze code uitvoeren, zien we dat Metro (de bundler) een foutmelding uitprint.

l
Warning: Error: Failed to create a new MMKV instance: The native MMKV Module could not be found.

Om dit probleem op te lossen, moeten we een native Android code generen en deze lokaal compileren. Via onderstaand commando wordt de Android code gegenereerd (en iOS code als je op macOS werkt).

Nadat we dit commando uitvoeren, krijgen we de vraag om het package name in te geven (of wordt dit automatisch gegenereerd en in app.json toegevoegd).

Begrip: Package name

Het package id van je app is een wereldwijd unieke naam die gebruikt wordt om te bepalen of een app al geïnstalleerd is op een toestel en voor een hele reeks andere doeleinden.

Het package id moet in reverse domain name notation formaat genoteerd worden. Als je een app bouwt met als doel deze te publiceren, in plaats van om te leren, is het natuurlijk belangrijk dat je hiervoor een domeinnaam gebruikt die je effectief bezit. Domeinnamen zijn uniek en twee apps zullen dus nooit als "dezelfde app" beschouwd worden.

Expo bewaart de package name in app.json, je kan de naam altijd aanpassen in deze file.

Notitie

Gebruik tijdens deze cursus een appId van de vorm com.achternaam.voornaam.appnaam. De apps die als voorbeeld aangeboden worden krijgen steeds een id van de vorm be.pitgraduaten.appnaam.

De uitvoer van het prebuild commando vraagt ook om de expo-system-ui library te installeren, om van de waarschuwing af te geraken installeren we dit best.

Na deze aanpassingen kunnen we het project opnieuw compileren, maar dit keer met een ander commando dat de native code compileert in de plaats van Expo Go te gebruiken.

Het compilatieproces kan relatief lang duren, zeker de eerste keer.

Waarschuwing

Op het moment van schrijven (1/11/2024) mislukt het compilatieproces als je de blank template gebruikt. De expo-splash-screen library moet geïnstalleerd worden om de app succesvol te compileren.

ScrollView & List

De applicatie start nu wel op, maar de UI ziet er niet uit. De elementen in de lijst zijn volledig samengedrukt, zoals onderstaand screenshot demonstreert.

Slechte lay-out
Figuur 10: Slechte lay-out

ScrollView

Het probleem is dat een React Native UI niet automatisch doorgroeit met de inhoud zoals op een website. De inhoud van een pagina wordt samengedrukt totdat deze op het scherm past. Om dit probleem op te lossen moeten we de ToDoList componenten wrappen in een ScrollView.

/src/pages/tasks/toDoList.tsx
import {ScrollView} from 'react-native'

const ToDoList: FunctionComponent = () => {
  const {tasks} = useTasks()

  return (
    <ScrollView>
      {tasks.map(task => <ToDoItem {...task} key={task.id}/>)}
    </ScrollView>
  )
}

Na deze aanpassing worden de taken correct getoond.

Taken met ScrollView
Figuur 11: Taken met ScrollView

LegendList

Alhoewel de taken nu wel zichtbaar zijn, is de code niet optimaal. De ScrollView component gebruik je best enkel voor content die niet in een lijst weergegeven kan worden, dingen zoals een lange post op een social media platform. Voor content die volledig in een lijst weergegeven kan worden gebruik je best een list component.

De FlatList component rendert enkel de elementen die op het scherm staan en enkele extra elementen boven en onderaan het scherm (zodat scrollen vlot gaat). Omdat er minder elementen gerenderd worden is dit natuurlijk veel efficiënter, zeker voor grote lijsten.

Toch gebruiken we de FlatList component niet omdat deze bij grote hoeveelheden data, of op low-end Android toestellen, niet performant is. Shopify heeft dit probleem ook ondervonden en heeft als oplossing de FlashList component gebouwd. Ondertussen heeft een andere ontwikkelaar nog een verbeterde versie ontwikkeld genaamd LegendList. Wij zullen dus gebruik maken van de LegendList component voor zijn optimale performantie. De API voor deze component is vrijwel identiek aan die van FlatList en FlashList.

Zowel de FlatList als LegendList componenten ontvangen hun data via de data property, de renderItem property definieert een functie die één item uit de data array binnen krijgt en rendert naar een ReactNode. Verder gebruiken we de keyExtractor property om het default gedrag van de component te overschrijven, standaard Tenslotte geven wij de recycleItems property de waarde true. Deze property zorgt er voor dat componenten niet telkens worden verwijderd (uit het zicht) en opnieuw aangemaakt (net buiten zicht), in plaats daarvan worden de componenten herbruikt met nieuwe data.

Info

Als je data geen key of id property bevat, moet je een keyExtractor functie meegeven aan de FlatList, FlashList of LegendList component die een unieke key teruggeeft voor elk item in de lijst. Deze key heeft dezelfde functionaliteit als de key property die we in frontend les 2. besproken hebben.

Info

Als de componenten in de lijst interne state bevatten, moet je de recycleItems property op false zetten, anders kunnen er onverwachte bugs optreden. Als alternatief kan je ook gebruik maken van een useEffect in de component om de interne state te resetten telkens de props wijzigen.

/src/pages/tasks/toDoList.tsx
import {LegendList} from "@legendapp/list"

const ToDoList: FunctionComponent = () => {
    const {tasks} = useTasks()

    return (
      <LegendList
        data={tasks}
        renderItem={({item: task}) => <ToDoItem {...task} key={task.id} />}
        recycleItems={true}
      />
    )
}

Taak toevoegen

Het uitgewerkte lesvoorbeeld bevat, naast bovenstaande code, ook nog code om een taak toe te voegen. Deze code wordt opgebouwd met behulp van de Modal en TextInput componenten, maar bevatten verder geen nieuwe concepten. Daarnaast zijn er ook nog enkele hulpcomponenten toegevoegd, we raden geïnteresseerde lezer aan om deze code te bekijken vooraleer aan de oefeningen te beginnen.

Figuur 12: Nieuwe taak toevoegen

Voorbeeldcode

Volledig uitgewerkt lesvoorbeeld met commentaar


  1. Als dit niet het geval is, heb je iets fout gedaan tijdens de installatie van de development environment. ↩︎

  2. Met de release van Expo DOM Components is het mogelijk componenten te maken met HTML code. LET OP: omdat dit in de achtergrond een WebView proxy component maakt, neemt dit net de kracht van native ontwikkeling weg (zie inleiding). Het is echter een handige tool om incrementeel een bestaande website om te zetten naar een app, of om enkele functionaliteiten uit te proberen voor je er veel tijd in stopt door ze native te maken. Je kan de volledige uitleg vinden op Expo DOM Components. ↩︎

  3. Let op, React Native bevat ook een SafeAreaView component, maar deze werkt enkel op iOS. ↩︎

  4. We gaan ervan uit dat flexbox gekend is, diegenen die een refresher nodig hebben verwijzen we door naar MDN en CSS-tricks. Om het resultaat van de verschillende opties interactief te bekijken, verwijzen we naar de React Native documentatie. ↩︎

  5. In deze situatie is het ook mogelijk om de TextProps interface te importeren uit React Native, maar dit is niet voor alle componenten uit alle libraries mogelijk. De ComponentProps helper is een generieke oplossing die altijd werkt. ↩︎