Et si on faisait le Flutter Clock Contest en React Native

Axel de Sainte Marie
13 min readFeb 28, 2020

--

Ce 25 février ont été annoncé les résultats du Flutter Clock Contest, organisé par Flutter et Lenovo pour la Lenovo Smart Clock. 850 projets ont été soumis pour ce concours, à travers 86 pays ! Pour répondre au concours, il fallait récupérer le projet Flutter Clock qui vous donnait accès à une app affichant une horloge analogique de base et une autre numérique. Le but du concours était de proposer un design original pour les deux horloges, avec un affichage pour la météo, et ceci disponible en Light Mode et Dark Mode.

Alors pour ce nouvel article, je me suis penché sur ce concours mais en souhaitant faire ma propose version, non pas en Flutter, mais en React Native pour voir ce que cela pouvait donner.

Comme je l’avais fait pour la mise en place d’un onbaording, je suis parti avec la logique de ne réaliser ce projet qu’avec les bibliothèques livrées ‘de base’ avec React Native. J’ai donc éliminé les bibliothèque SVG qui sont un choix “évident” quand il s’agit de dessiner et d’animer des formes géométriques (ne serait-ce que pour les aiguilles), et je me suis concentré sur mes souvenirs de ce qui peut être fait en CSS avec l’ouil transform.

Je ne me concentrerai ici que sur la création d’une horloge analogique, et je m’inspirerai de l’horloge créée par Dominik Roszkowski pour le Flutter Clock Contest, que j’ai trouvée simple et élégante.

Horloge de Dominik Roszkowski

Création du cadre de l’horloge

Commençons par créer notre page clock.js dans lequel nous allons créer la cadre de l’horloge. Celui-ci sera composé de 3 cercles de tailles différentes ayant le même centre. Le plus grand aura une ombre extérieur, le petit contiendra l’horloge en tant que telle, et le cercle du centre servira à porter l’ombre intérieure permettant l’effet de relief du cadrant.

Initialisation notre page :

import React from ‘react’;import { StyleSheet, View, Text } from ‘react-native’;export default class Clock extends React.Component {
constructor(props) {
super(props);
this.state = {
}
}
componentDidMount() {
}
render() {
return (
<View style={styles.container}>
<Text>Et si on construisait une horloge ?</Text>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: ‘center’,
justifyContent: ‘center’
}
});

Nous allons d’abord construire nos deux cercles. Le plus grand va déterminer la taille de notre horloge. Nous allons construire une horloge qui sera distante de 20 des bords. Pour s’assurer que l’horloge tienne en mode portrait et en mode paysage, nous calculerons sa taille à partir de la plus petite des dimensions de l’écran (hauteur et largeur), et lui enlèverons 40. Cela nous donne le diamètre du cadre (frameSize). En faisant plusieurs test j’ai trouvé qu’une largeur de 20 donnait un cadre fin et élégante. Pour avoir une ombre interne, nous allons utiliser la bordure de notre cercle. Nous aurons donc un cercle de la même taille que le premier mais avec une bordure de 20. Quand au cadran de l’horloge, il peut être collé au cadre ou légèrement ‘écarté’. Je préfère laisser un espace entre le cadre et le cadran. J’ai trouvé qu’une marge de 18 nous donnait un ensemble cohérent. Le diamètre restant disponible à l’intérieur du cadre est de frameSize-40 auquel nous soustrairons donc 36 (clockSize).

Pour effectuer l’effet d’ombre interne, en plus d’ajouter une ombre à la bordure, nous allons ajouter l’attribut de style overflow avec le valeur hidden pour ne pas ‘propager’ cette ombre à l’extérieur du cercle.

Pour connaitre les valeurs à attribuer aux styles permettant de créer les ombres, j’utilise l’outil react-native-shadow-generator suivant qui vous donne les valeurs et le résultat correspondant.

Voilà le code qui nous permet de dessiner le cadre de l’horloge.

import React from ‘react’;
import { StyleSheet, View, Dimensions } from ‘react-native’;
export default class Clock extends React.Component {
[…]
render() {
let frameSize = Dimensions.get('screen').width > Dimensions.get('screen').height ? Dimensions.get('screen').height : Dimensions.get('screen').width;
frameSize = frameSize - 40;
const frameWidth = 20;
const clockSize = frameSize-2*frameWidth-36;
return (
<View style={styles.container}>
<View style={[styles.shadow, styles.frame, { width: frameSize, height: frameSize, borderRadius: frameSize / 2 }]}>
<View style={[styles.innerShadow, styles.innerFrame, { width: frameSize, height: frameSize, borderRadius: frameSize / 2, borderWidth: frameWidth }]}>
<View style={[{ width: clockSize, height: clockSize, borderRadius: clockSize / 2, justifyContent: 'center', alignItems: 'center' }]}>
</View>
</View>
</View>
</View>
);
}
}
const styles = StyleSheet.create({
[...]
shadow: {
shadowColor: “#000”,
shadowOffset: {
width: 0,
height: 5,
},
shadowOpacity: 0.27,
shadowRadius: 4.65,
elevation: 6
},
frame: {
backgroundColor: '#fff',
justifyContent: 'center',
alignItems: 'center'
},
innerShadow: {
shadowColor: “#000”,
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.23,
shadowRadius: 2.62,
elevation: 4
},
innerFrame: {
overflow: 'hidden',
borderColor: '#fff',
justifyContent: 'center',
alignItems: 'center'
}
});
Le cadre de notre horloge

Prise en charge du passage Light Mode / Dark Mode

Le concours demandait de prendre en charge le Light mode et le Dark mode. Il serait trop lourd de traiter ici de la prise en charge de la valeur système du mode en cours, ainsi que de la mise en place d’un thème pour l’app. Nous allons donc simplement simuler le changement de mode via un booléen dans le state, que nous nommerons isLightMode (que nous initierons à true pour être en Light Mode par défaut). Nous créerons 2 objets darkModeTheme et lightModeTheme pour stocker les valeurs des couleurs en fonction du mode choisis.

Initions ces thèmes, et ajoutons un bouton en haut à droite de l’écran pour passe un mode à l’autre, en modifiant ainsi notre code (j’en ai profité pour mettre à jour les couleurs) :

import React from ‘react’;
import { StyleSheet, View, Dimensions, TouchableOpacity, Text} from ‘react-native’;
const lightModeTheme = {
background:’#E0E0E0',
shadow:'#9E9E9E',
frame:’#E0E0E0',
text:'#123456'
}
const darkModeTheme = {
background:’#3C4043',
shadow:'#212121',
frame:’#3C4043',
text:'#fff'
}
export default class Clock extends React.Component {
constructor(props) {
super(props);
this.state = {
isLightMode:true
}
}
[...]
render() {
const colorTheme = this.state.isLightMode ? lightModeTheme : darkModeTheme;
const colorName = this.state.isLightMode ? 'Light Mode' : 'Dark Mode';
[...]
return (
<View style={[styles.container, {backgroundColor:colorTheme.background}]}>
<View style={[styles.shadow, styles.frame, { shadowColor: colorTheme.shadow, backgroundColor: colorTheme.frame, width: frameSize, height: frameSize, borderRadius: frameSize / 2 }]}>
<View style={[styles.innerShadow, styles.innerFrame, { shadowColor: colorTheme.shadow, width: frameSize, height: frameSize, borderRadius: frameSize / 2, borderWidth: frameWidth, borderColor: theme.frame }]}>
<View style={[{ width: clockSize, height: clockSize, borderRadius: clockSize / 2, justifyContent: ‘center’, alignItems: ‘center’ }]}>
</View>
</View>
</View>
<TouchableOpacity style={styles.modeButton} onPress={() => this.setState({isLightMode:!this.state.isLightMode})}>
<Text style={{color:colorTheme.text}}>{colorName}</Text>
</TouchableOpacity>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: ‘center’,
justifyContent: ‘center’
},
shadow: {
shadowOffset: {
width: 0,
height: 5,
},
shadowOpacity: 1,
shadowRadius: 4.65,
elevation: 6
},
frame: {
justifyContent: ‘center’,
alignItems: ‘center’
},
innerShadow: {
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 1,
shadowRadius: 2.62,
elevation: 4
},
innerFrame: {
overflow: ‘hidden’,
justifyContent: ‘center’,
alignItems: ‘center’
},
modeButton: {
position:'absolute',
top:30,
right:30
}
});
Mise en place d’un switch entre Light Mode et Dark Mode

Création de l’horloge : marquage des heures

Jusque là nous étions dans la partie facile. Le but est maintenant de créer les repères des heures. Nous avons donc 12 repères à placer sur le cercle de notre horloge. Pour trouver la position de ces 12 points nous allons faire appel à nos souvenirs de géométrie pour calculer la position d’un point sur un cercle dans une repère à deux dimensions.

Soit x et y les coordonnées de notre point, Cx et Cy les coordonnées du centre de notre cercle, ⍺ l’angle créer par notre point par rapport à la position du point à l’angle 0, et R le rayon du cercle. Les coordonnées du point se calculent ainsi.

Notre premier point à placer sera celui correspondant à midi, qui est positionné à l’angle Pi. Ensuite chaque point correspondant à une heure sera situé à un angle Pi/6 du précédent point (2Pi pour le cercle, divisé par 12 points correspondant à chaque heure). Nous pouvons donc créer une boucle itérant 12 fois pour positionner nos points sur le cercle de l’horloge. Nous symboliserons les heures par des ronds de diamètre 4. Si on implémente le code tel que, nous remarquerons que les points sont légèrement décalés vers la droite. En effet le point est positionné à la droite de sa position sur le cercle. Nous allons donc soustraire 2 (moitié de sa largeur) à sa position pour le centrer.

import React from ‘react’;import { StyleSheet, View, Dimensions, Text, TouchableOpacity } from ‘react-native’;const lightModeTheme = {
[...]
tick:’#212121'
}
const darkModeTheme = {
[...]
tick:’#212121'
}
export default class Clock extends React.Component {
[...]
render() {
[...]
//Rayon de l'horloge
const clockRadius = (clockSize) / 2;
//Centre de l'horloge
const clockCenter = { x: clockRadius, y: clockRadius };
//Initialisation de la vue contenant les points représentant chaque heure
let hoursView = [];
for (let i = 0; i < 12; i++) {
const clockAngle = Math.PI + i * Math.PI / 6;
hoursView.push(<View
style={{
position: 'absolute',
top: this.getPointAbscissa(clockAngle, clockRadius, clockCenter),
left: this.getPointOrdinate(clockAngle, clockRadius, clockCenter)-2}}>
<View style={{ width: 4, height: 4, borderRadius:2, backgroundColor: colorTheme.tick }} />
</View>)
}

return (
[...]
<View style={[{ width: clockSize, height: clockSize, borderRadius: clockSize / 2, justifyContent: 'center', alignItems: 'center' }]}>
{hoursView}
</View>
[...]
);
}
getPointAbscissa = (angle, radius, center) => {
return center.x + radius * Math.cos(angle);
}
getPointOrdinate = (angle, radius, center) => {
return center.y + radius * Math.sin(angle);
}
}
Le marquage des heures de notre horloge

Il faut maintenant transformer ces points en trait. Si vous changer le code pour mettre des traits de 4 de large et de 30 de haut, en enlevant l’arrondie, vous allez obtenir le résultat ci-dessous.

<View 
style={{
position: ‘absolute’,
top: this.getPointAbscissa(clockAngle, clockRadius, clockCenter),
left: this.getPointOrdinate(clockAngle, clockRadius, clockCenter)-2
}}>
<View style={{ width: 4, height: 30, backgroundColor: colorTheme.tick }} />
</View>

Il faut donc tourner chaque trait vers le centre. Nous allons donc utiliser l’attribut de style transform.rotate pour effectuer une rotation du trait. Nous savons déjà que nos traits marque un angle de Pi/6 avec le précédent, donc la rotation suivra le même angle, multiplié par la position du trait.

<View
style={{
transform: [
{ rotate: (-i * (Math.PI / 6)) + ‘rad’ }
],
position: ‘absolute’,
top: this.getPointAbscissa(clockAngle, clockRadius, clockCenter),
left: this.getPointOrdinate(clockAngle, clockRadius, clockCenter) — 2
}}>
<View style={{ width: 4, height: 30, backgroundColor: colorTheme.tick }} />
</View>

La rotation est effectuée autour du centre du trait. Il nous suffit donc de le remonter de la moitié de sa hauteur pour le replacer à la bonne position. Nous allons soustraire 15 de sa position top. Nous profiterons de ce changement de code, pour que l’épaisseur des traits de midi, 3h, 6h et 9h soit plus épais (nous passerons à une largeur de 6), et nous réduirons de 20 le diamètre de l’horloge pour que celle-ci soit légèrement ‘décollée’ du cadre.

render() {
[…]
const clockSize = frameSize — 2 * frameWidth — 56;
[…]
//Initialisation de la vue contenant les points représentant chaque heure
let hoursView = [];
for (let i = 0; i < 12; i++) {
const clockAngle = Math.PI + i * Math.PI / 6;
//Largeur du trait. Si l’heure est un multiple de 3 on grossit le trait
const widthOfHours = (i % 3 === 0) ? 6 : 4;
hoursView.push(<View
style={{
transform: [
{ rotate: (-i * (Math.PI / 6)) + ‘rad’ }
],
position: ‘absolute’,
top: this.getPointAbscissa(clockAngle, clockRadius, clockCenter) — 15,
left: this.getPointOrdinate(clockAngle, clockRadius, clockCenter) — (widthOfHours / 2)
}}>
<View style={{ width: widthOfHours, height: 30, backgroundColor: colorTheme.tick }} />
</View>)
}
return (
[…]
);
}

Création de l’horloge : les aiguilles

Il nous faut maintenant créer les éléments essentiels d’une horloge, à savoir les 3 aiguilles.

Nous allons avoir l’aiguille des heures qui va être courte et épaisse, celle des minutes longue et plus fine, et celle des secondes aussi fine que les minutes mais d’une couleur différente et plus longue.

Nous allons placer ces 3 aiguilles au centre de l’horloge, et il faudra jouer sur transform.rotate pour les faire tourner sur le cadran. Les heures tournerons de Pi/6 en Pi/6, les minutes et les secondes de Pi/30 en Pi/30. Rappelons nous que la rotation s’effectue selon le centre du trait. Nous positionnerons donc les aiguilles pour que leur centre coincide avec celui de l’horloge, puis nous effectuerons une translation selon les ordonnées pour positionner les aiguilles à leur place. Pour l’aiguille des heures et celle des minutes, nous feront une translation dont la valeur sera égale à la moitié de leur taille, pour que leur extrémité touche le centre de l’horloge. Pour l’aiguille des secondes, nous avons besoin qu’elle dépassent de chaque côté. Nous effectuerons une légère translation pour obtenir le résultat souhaité (la valeur de 20 semble correspondre à un bon équilibre).

Nous aurons donc 3 nouvelles vues dans notre horloge (Je vais placer les aiguilles de manière statique pour afficher 10h10m30s). Nous n’oublierons pas de décaler légèrement les aiguilles pour qu’elles soient centrées (la moitié de leur largeur — ce sont les valeurs hourPadding, minutePadding et secondPadding de notre code). Également, la couleur des aiguilles sera récupérée des thèmes pour les 2 modes.

Pour simplifier les calcules, j’ai donné une taille égale à l’aiguille des heures et à celle des minutes. Puis, j’ai joué avec l’attribut transform.scaleY pour la réduire. Cela permet d’adapter la taille à son souhait, en ne changeant qu’une seule valeur.

<View style={[{ width: clockSize, height: clockSize, borderRadius: clockSize / 2, justifyContent: ‘center’, alignItems: ‘center’ }]}
{hoursView}
<View style={{
transform: [
{ rotate: 10 * Math.PI / 6 + ‘rad’ },
{ scaleY: 0.5 },
{ translateY: -clockSize / 4 }
],
position: ‘absolute’,
left: (clockSize / 2) — hourPadding,
top: clockSize / 4,
width: hourWidth,
height: clockSize / 2,
backgroundColor: colorTheme.hour
}} />
<View style={{
transform: [
{ rotate: 10 * Math.PI / 30 + ‘rad’ },
{ scaleY: 0.7 },
{ translateY: -clockSize / 4 }
],
position: ‘absolute’,
left: (clockSize / 2) — minutePadding,
top: clockSize / 4,
width: minuteWidth,
height: clockSize / 2,
backgroundColor: colorTheme.minute
}} />
<View style={{
transform: [
{ rotate: 30 * Math.PI / 30 + ‘rad’ },
{ scaleY: 1 },
{ translateY: -20 }
],
position: ‘absolute’,
left: (clockSize / 2) — secondePadding,
top: clockSize / 6,
width: secondeWidth,
height: clockSize * 2 / 3,
backgroundColor: colorTheme.second
}} />
</View>
</View>

Il est temps de faire fonctionner notre horloge, en lui ‘donnant’ l’heure. Pour cela nous allons créer 3 variables dans notre state : hours, minutes et seconds. Au lancement de l’horloge (componentDidMount), nous allons initié ses trois valeurs avec les fonctions getHours, getMinutes et getSeconds de l’objet Date. puis nous allons réinitialiser ces 3 valeurs toutes les secondes. Il suffira ensuite d’associer ces valeurs aux 3 aiguilles en remplaçant les valeurs statiques par les valeurs ‘dynamiques’.

Pour donner un aspect plus proche de la ‘réalité’, je vais ajouter sur l’aiguille des secondes, deux disques : l’un central marquant le pivot, et l’autre sur la partie plus petite de l’aiguille pour faire un contre-poids.

export default class Clock extends React.Component{
constructor(props) {
super(props);
this.state = {
isLightMode: true,
hours: 10,
minutes: 10,
seconds: 30
}
}
componentDidMount() {
this.getTime();
}
render() {
[...]
//Valeurs des variables des heures, minutes et secondes
const { hours, minutes, seconds } = this.state;
return (
<View style={[styles.container, { backgroundColor: colorTheme.background }]}>
<View style={[styles.shadow, styles.frame, { shadowColor: colorTheme.shadow, backgroundColor: colorTheme.frame, width: frameSize, height: frameSize, borderRadius: frameSize / 2 }]}>
<View style={[styles.innerShadow, styles.innerFrame, { shadowColor: colorTheme.shadow, width: frameSize, height: frameSize, borderRadius: frameSize / 2, borderWidth: frameWidth, borderColor: colorTheme.frame }]}>
<View style={[{ width: clockSize, height: clockSize, borderRadius: clockSize / 2, justifyContent: ‘center’, alignItems: ‘center’ }]}>
{hoursView}
<View style={{
transform: [
{ rotate: hours * Math.PI / 6 + ‘rad’ },
{ scaleY: 0.5 },
{ translateY: -clockSize / 4 }
],
position: ‘absolute’,
left: (clockSize / 2) — hourPadding,
top: clockSize / 4,
width: hourWidth,
height: clockSize / 2,
backgroundColor: colorTheme.hour
}} />
<View style={{
transform: [
{ rotate: minutes * Math.PI / 30 + ‘rad’ },
{ scaleY: 0.7 },
{ translateY: -clockSize / 4 }
],
position: ‘absolute’,
left: (clockSize / 2) — minutePadding,
top: clockSize / 4,
width: minuteWidth,
height: clockSize / 2,
backgroundColor: colorTheme.minute
}} />
<View style={{
transform: [
{ rotate: seconds * Math.PI / 30 + ‘rad’ },
{ scaleY: 1 },
{ translateY: -20 }
],
position: ‘absolute’,
left: (clockSize / 2) — secondePadding,
top: clockSize / 6,
width: secondeWidth,
height: clockSize * 2 / 3,
backgroundColor: colorTheme.second,
alignItems: ‘center’,
justifyContent: ‘flex-end’
}}>
<View style={[styles.counterweight, {backgroundColor: colorTheme.second }]} />
</View>
<View style={[styles.pivot, { backgroundColor: colorTheme.second }]} />
</View>
</View>
</View>
<TouchableOpacity style={styles.modeButton} onPress={() => this.setState({ isLightMode: !this.state.isLightMode })}>
<Text style={{ color: colorTheme.text }}>{colorName}</Text>
</TouchableOpacity>
</View>
);
}

getPointAbscissa = (angle, radius, center) => {
return center.x + radius * Math.cos(angle);
}
getPointOrdinate = (angle, radius, center) => {
return center.y + radius * Math.sin(angle);
}
getTime = () => {
const now = new Date();
this.setState({ hours: now.getHours(), minutes: now.getMinutes(), seconds: now.getSeconds() });
setTimeout(() => {
this.getTime();
}, 1000);
}
}
const styles = StyleSheet.create({
[...]
pivot: {
width: 12,
height: 12,
borderRadius: 6
},
counterweight: {
marginBottom: 30,
width: 20,
height: 20,
borderRadius: 10
}
});

Et voilà le résultat final.

Mon horloge du Flutter Clock Contest en React Native

Nous sommes encore un peu loin de la finesse du projet de Dominik Roszkowski, mais la logique de construction de l’horloge est posée. Il reste à intégrer la météo, puis à essayer d’imaginer d’autres éléments pour personnaliser son horloge.

--

--

Axel de Sainte Marie
Axel de Sainte Marie

Written by Axel de Sainte Marie

Show Runner de projets mobiles. Développeur React Native et passionné par les challenges du monde mobile.

No responses yet