Développement d'applications mobiles

Ressources

ReactJS

ReactNative

Initialisation du projet

bash
npx create-expo-app demo --template blank

cd demo

npx expo install react-native-safe-area-context react-native-root-toast

npx expo start --tunnel

Structure du projet

bash
├── App.js # Point d'entrée du code
├── app.json # Metadonnées du projet: nom, version, configurations...
├── assets # Ressources statiques: images, sons, pdf, etc.
│   ├── icon.png
│   └── splash.png
├── babel.config.js # Configuration de build
├── .expo # Fichiers locaux de l'environnement de build Expo
├── .gitignore
├── package.json # Liste des dépendances JS
└── package-lock.json

Départ

jsx
App.js
import { StatusBar } from 'expo-status-bar';
import { RootSiblingParent } from 'react-native-root-siblings';
import Toast from 'react-native-root-toast';
import { useState } from 'react';
import { StyleSheet, View, ScrollView, Text, TextInput, Switch, Button, TouchableHighlight, Alert } from 'react-native';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';

const EMPTY_TODO = () => { return {
name: null,
done: false
}}

const SEED = [...Array(10).keys()].map((item, index, array) => {
const name = `Todo ${item}`

return {
id: Math.random().toString(16).substring(2),
name: name,
description: `Description ${name} `.repeat(index),
done: (index % 3)
}
})

export default function App() {
const [todos, setTodos] = useState(SEED);
const [newTodo, setNewTodo] = useState(EMPTY_TODO());

function handleAdd() {
if ((newTodo.name?.trim() ?? '') == '') {
Toast.show(
'Provide a Todo name',
{
duration: Toast.durations.SHORT,
backgroundColor: 'red',
textColor: 'white',
}
);
} else {
newTodo.id = Math.random().toString(16).substring(2);
setTodos([newTodo, ...todos]);
setNewTodo(EMPTY_TODO());
}
}

function toggle(id) {
setTodos(
todos.map( t => {
if (t.id == id) {
t.done = !t.done
}

return t;
})
)
}

function handleToggle(todo) {
Alert.alert(null, todo.name, [
{
text: 'Cancel',
onPress: () => { },
style: 'cancel',
},
{
text: todo.done ? 'Incomplete' : 'Completed',
onPress: () => toggle(todo.id)
},
]);
}

return (
<RootSiblingParent>
<StatusBar style="auto" />

<SafeAreaProvider>
<SafeAreaView style={styles.container}>

<Text style={{ fontSize: 28, fontWeight: 'bold', textAlign: 'center' }}>Todoer</Text>

<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>

<TextInput
placeholder='New task'
style={[styles.input, { height: 48, flexGrow: 1 }]}
value={ newTodo.name }
onChangeText={ (text) => setNewTodo({...newTodo, name: text}) }
/>

<Switch
value={ newTodo.done }
onValueChange={ (checked) => setNewTodo({...newTodo, done: checked}) }
/>

</View>

<TextInput
placeholder='Optional description'
style={[styles.input, { width: '100%', height: 96, verticalAlign: 'top' }]}
multiline={ true }
/>

<View style={{ flexDirection: 'row', justifyContent: 'center' }}>
{/* Envelopper dans une View pour eviter une largeur de 100% */}

<Button
title='Add'
color='green'
onPress={ handleAdd }
/>

</View>

{/* Pas la facon ideale pour afficher une liste, simplification pour la demonstration */}
<ScrollView style={{ flexGrow: 1 }}>
{
todos.map((todo, index, array) =>
<TouchableHighlight key={todo.id} onPress={ () => { handleToggle(todo) } }>
<View>

<View
style={{
flexDirection: 'row',
columnGap: 8,
paddingVertical: 16,
paddingHorizontal: 8,
backgroundColor: 'white', /* requis pour highlight */
}}
>
<Ionicons name="checkmark-circle" size={22} color={ todo.done ? 'green' : 'lightgray' } />

<View style={{ flex: 1, flexDirection: 'column' }}>
<Text style={[styles.text, { fontWeight: 'bold' }]}>{ todo.name }</Text>
{
todo.description &&
<Text style={ styles.text }>{ todo.description }</Text>
}
</View>
</View>

{
(index != array.length - 1) &&
<View style={{ height: 1, backgroundColor: 'lightgray' }} />
}

</View>
</TouchableHighlight>
)
}
</ScrollView>

</SafeAreaView>
</SafeAreaProvider>
</RootSiblingParent>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
gap: 16,
justifyContent: 'center',
padding: 16,
},
input: {
borderWidth: 1,
borderColor: 'lightgray',
padding: 8,
},
text: {
fontSize: 18,
},
});

JSX

<Text style={{ fontSize: 28, fontWeight: 'bold', textAlign: 'center' }}>Todoer</Text>

<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>

<TextInput
placeholder='New task'
style={[styles.input, { height: 48, flexGrow: 1 }]}
value={ newTodo.name }
onChangeText={ (text) => setNewTodo({...newTodo, name: text}) }
/>

<Switch
value={ newTodo.done }
onValueChange={ (checked) => setNewTodo({...newTodo, done: checked}) }
/>

</View>

<TextInput
placeholder='Optional description'
style={[styles.input, { width: '100%', height: 96, verticalAlign: 'top' }]}
multiline={ true }
/>

<View style={{ flexDirection: 'row', justifyContent: 'center' }}>
{/* Envelopper dans une View pour eviter une largeur de 100% */}

<Button
title='Add'
color='green'
onPress={ handleAdd }
/>

</View>

State

import { useState } from 'react';

const [todos, setTodos] = useState([]);
const [newTodo, setNewTodo] = useState({ name: null, done: false });

// ...

<Input
placeholder='New task'
style={{ height: 48, flexGrow: 1 }}
value={ newTodo.name }
onChangeText={ (text) => setNewTodo({...newTodo, name: text}) }
/>

<Text>{newTodo.name}</Text>

Components

jsx
Input.js
import { StyleSheet, TextInput } from 'react-native';

export default function Input({style, ...otherProps}) {
return (
<TextInput
style={[styles.input, style]}
{...otherProps}
/>
)
}

const styles = StyleSheet.create({
input: {
borderWidth: 1,
borderColor: 'lightgray',
padding: 8,
},
});
jsx
TodoItem.js
import { Alert, View, Text, TouchableHighlight } from 'react-native';
import { Ionicons } from '@expo/vector-icons';

export default function TodoItem({ todo, toggle }) {
function handleToggle(todo) {
Alert.alert(null, todo.name, [
{
text: 'Cancel',
onPress: () => { },
style: 'cancel',
},
{
text: todo.done ? 'Incomplete' : 'Completed',
onPress: () => toggle(todo.id)
},
]);
}

return (
<TouchableHighlight key={todo.id} onPress={ () => { handleToggle(todo) } }>
<View>

<View
style={{
flexDirection: 'row',
columnGap: 8,
paddingVertical: 16,
paddingHorizontal: 8,
backgroundColor: 'white', /* requis pour highlight */
}}
>
<Ionicons name="checkmark-circle" size={22} color={ todo.done ? 'green' : 'lightgray' } />

<View style={{ flex: 1, flexDirection: 'column' }}>
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>{ todo.name }</Text>
{
todo.description &&
<Text style={{ fontSize: 18 }}>{ todo.description }</Text>
}
</View>
</View>


</View>
</TouchableHighlight>
)
}
jsx
App.js
import { StatusBar } from 'expo-status-bar';
import { RootSiblingParent } from 'react-native-root-siblings';
import Toast from 'react-native-root-toast';
import { useState } from 'react';
import { StyleSheet, View, ScrollView, Text, TextInput, Switch, Button, TouchableHighlight, Alert } from 'react-native';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import Input from './Input';
import TodoItem from './TodoItem';

const EMPTY_TODO = () => { return {
name: null,
done: false
}}

const SEED = [...Array(10).keys()].map((item, index, array) => {
const name = `Todo ${item}`

return {
id: Math.random().toString(16).substring(2),
name: name,
description: `Description ${name} `.repeat(index),
done: (index % 3)
}
})

export default function App() {
const [todos, setTodos] = useState(SEED);
const [newTodo, setNewTodo] = useState(EMPTY_TODO());

function handleAdd() {
if ((newTodo.name?.trim() ?? '') == '') {
Toast.show('Provide a Todo name', {
duration: Toast.durations.SHORT,
backgroundColor: 'red',
textColor: 'white',
});
} else {
newTodo.id = Math.random().toString(16).substring(2);
setTodos([newTodo, ...todos]);
setNewTodo(EMPTY_TODO());
}
}

function toggle(id) {
setTodos(
todos.map( t => {
if (t.id == id) {
t.done = !t.done
}

return t;
})
)
}

function list() {
return (
<ScrollView style={{ flexGrow: 1 }}>
{
/* Pas la facon ideale pour afficher une liste, simplification pour la demonstration */

todos.map((todo, index, array) => {
return (
<View key={ todo.id }>
<TodoItem todo={ todo } toggle={ toggle } />

{
/* Divider */
(index != array.length - 1) &&
<View style={{ height: 1, backgroundColor: 'lightgray' }} />
}
</View>
)
})
}
</ScrollView>
)
}

return (
<RootSiblingParent>
<StatusBar style="auto" />

<SafeAreaProvider>
<SafeAreaView style={styles.container}>

<Text style={{ fontSize: 28, fontWeight: 'bold', textAlign: 'center' }}>Todoer</Text>

<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>

<Input
placeholder='New task'
style={{ height: 48, flexGrow: 1 }}
value={ newTodo.name }
onChangeText={ (text) => setNewTodo({...newTodo, name: text}) }
/>

<Switch
value={ newTodo.done }
onValueChange={ (checked) => setNewTodo({...newTodo, done: checked}) }
/>

</View>

<Input
placeholder='Optional description'
style={{ width: '100%', height: 96, verticalAlign: 'top' }}
multiline={ true }
/>

<View style={{ flexDirection: 'row', justifyContent: 'center' }}>
{/* Envelopper dans une View pour eviter une largeur de 100% */}

<Button
title='Add'
color='green'
onPress={ handleAdd }
/>

</View>

{ list() }

</SafeAreaView>
</SafeAreaProvider>
</RootSiblingParent>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
gap: 16,
justifyContent: 'center',
padding: 16,
},
text: {
fontSize: 18,
},
});

Debugging

Pour utiliser le debugger via l'environnement distant de la machine virtuelle, il faut récupérer l'URL des Devtools manuellement

bash
cd demo

sed -i '70 i console.log(url);' node_modules/@expo/cli/build/src/start/server/middleware/inspector/JsInspector.js

Puis,

  • Redémarrer le serveur de développement
  • Lancer l'application dans Expo Go
  • Appuyer sur j, dans la console du serveur
  • Récupérer l'URL et l'ouvrir dans Chrome/Chromium
    • L'URL ne semble pas changer en relançant la même app sur le même appareil

Le debugger fonctionne uniquement avec un navigateur basé sur Chrome, avec les configurations suivantes:

Go?