Creando una aplicación móvil usando expo

Pantalla final
Pantalla final

Vamos a crear una aplicación móvil usando react-native con Expo tomando como base una aplicación web https://tambo.cristianbgp.com/ hecha con React. Se trata de una aplicación que te muestra una lista de tiendas ordenadas según la cercanía.

Creando el proyecto

Para comenzar necesitamos tener instalado expo-cli, para instalarlo globalmente podemos usar npm o yarn.

Usando npm

npm install expo-cli --global

Usando yarn

yarn global add expo-cli

Una vez tenemos expo-cli podemos usar init para crear un nuevo proyecto

expo-cli init tambo

Ahora tendremos una estructura de archivos similar a

  • tambo
    • App.js
    • abel.config.js
    • app.json
    • package.json
    • yarn.lock
    • .expo-shared
      • assets.json
    • assets
      • icon.png
      • splash.png

Podemos usar yarn start y seguir las indicaciones para abrir la aplicación en nuestros emuladores android y/o iOS

Pantalla inicial
Pantalla inicial

Paquetes que usaremos

Ahora instalemos las dependencias que vamos a usar

yarn add expo-constants expo-location react-native-svg swr

expo-constants: Para obtener algunas dimensiones constantes del dispositivo

expo-location: Para obtener las coordenadas del dispositivo

react-native-svg: Para poder usar svg dentro de react-native

swr: Para fetchear nuestra data

Código

Ahora ya estamos listos para comenzar y añadiremos el siguiente código a App.js que es nuestro archivo principal

// App.js
import React from "react";
import {
  StyleSheet,
  View,
  Text,
  SafeAreaView,
  Picker,
  Platform,
  ActivityIndicator,
} from "react-native";
import Constants from "expo-constants";
import * as Location from "expo-location";
import StoresList from "./components/store-list";
const statusBarHeight = Constants.statusBarHeight;

function Loader() {
  return (
    <View style={styles.loader}>
      <ActivityIndicator size="large" color="#000" />
    </View>
  );
}

export default function App() {
  const [selectedFilter, setSelectedFilter] = React.useState("");
  const [location, setLocation] = React.useState(null);
  const [errorMsg, setErrorMsg] = React.useState(null);
  const [refreshLocation, setRefreshLocation] = React.useState(true);

  React.useEffect(() => {
    async function getLocation() {
      let { status } = await Location.requestPermissionsAsync();
      if (status !== "granted") {
        setErrorMsg("Permission to access location was denied");
      }

      let location = await Location.getCurrentPositionAsync({});
      setLocation(location);
      setRefreshLocation(false);
    }
    if (refreshLocation) {
      getLocation();
    }
  }, [refreshLocation, setRefreshLocation]);

  function filterBy(store) {
    if (selectedFilter !== "") {
      return store[selectedFilter];
    } else {
      return true;
    }
  }

  return (
    <SafeAreaView
      style={[
        styles.container,
        Platform.OS === "android" && { marginTop: statusBarHeight },
      ]}
    >
      <View style={styles.header}>
        <Text style={[styles.title, { color: "#a74a93" }]}>Tambo+</Text>
        <Text style={styles.title}>cerca</Text>
      </View>
      {location ? (
        <>
          <View style={styles.filter}>
            <Text style={styles.subTitle}>Filter by:</Text>
            <Picker
              selectedValue={selectedFilter}
              style={styles.picker}
              itemStyle={styles.pickerItem}
              onValueChange={(itemValue) => setSelectedFilter(itemValue)}
            >
              <Picker.Item label="None" value="" />
              <Picker.Item label="24 hours" value="allday" />
              <Picker.Item label="ATM" value="atm" />
            </Picker>
          </View>
          <React.Suspense fallback={<Loader />}>
            <StoresList
              location={location}
              filterBy={filterBy}
              refreshLocation={refreshLocation}
              setRefreshLocation={setRefreshLocation}
            />
          </React.Suspense>
        </>
      ) : (
        <Loader />
      )}
      {errorMsg && <Text style={styles.error}>{errorMsg}</Text>}
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#f5df53",
    alignItems: "center",
  },
  header: {
    marginVertical: 20,
    alignItems: "center",
  },
  title: {
    fontSize: 34,
    fontWeight: "700",
  },
  filter: {
    alignItems: "center",
    marginBottom: 10,
  },
  subTitle: {
    fontSize: 20,
    fontWeight: "500",
  },
  picker: {
    width: 200,
    height: Platform.OS === "ios" ? 88 : 44,
  },
  pickerItem: {
    height: Platform.OS === "ios" ? 88 : 44,
  },
  loader: {
    justifyContent: "center",
    alignItems: "center",
    margin: 20,
  },
  error: {
    fontSize: 18,
    paddingHorizontal: 50,
    marginVertical: 20,
    color: "red",
  },
});

Analicemos un poco lo que tenemos en App.js

React Native

Si es la primera vez que ven React Native podrán darse cuenta que los únicos elementos que podemos renderizar son los que recibimos de react-native(View y Text por ejemplo) y que la forma de dar estilos a nuestros elementos es con un objeto que se lo pasamos al prop style, además de que los nombres de los estilos son similares a los que usamos en css.

SafeAreaView

Estamos usando un SafeAreaView para que nuestros componentes no ocupen desde la barra superior en nuestros smartphones, ésto es porque hay smartphones que tienen camera notches y la pantalla de la aplicacion se extiende totalmente, sin embargo, SafeAreaView sólo funciona en iOS, así que para solucionarlo en Android usamos Constants.statusBarHeight de expo-constants para asignar ese valor un como un marginTop.

Location

Usamos expo-location y corremos un efecto de React para obtener las coordenadas del dispositivo, si no obtenemos los permisos de localización mostramos un mensaje de error en errorMsg.

Picker

Usamos Picker y Picker.Item para poder crear nuestro filtro con opciones, es similar a lo que haríamos en web con select y option. Picker tiene un prop onValueChange donde podemos actualizar nuestro selectedFilter.

StoresList

Ahora pasemos a otro componente StoresList, que es el que se encarga de renderizar nuestra lista de tiendas.

// /components/stores-list.js
import React from "react";
import { View, FlatList, StyleSheet } from "react-native";
import useSWR from "swr";
import StoreCard from "./store-card";

function fetcher(url) {
  return fetch(url).then((res) => res.json());
}

export default function StoresList({
  location,
  filterBy,
  refreshLocation,
  setRefreshLocation,
}) {
  const {
    data: responseStores,
    revalidate,
  } = useSWR(
    `https://tambo-api.herokuapp.com/nearest?currentLatitude=${location.coords.latitude}&currentLongitude=${location.coords.longitude}`,
    fetcher,
    { suspense: true }
  );

  function onRefresh() {
    setRefreshLocation(true);
    revalidate();
  }

  return (
    <View style={styles.container}>
      <FlatList
        data={responseStores.filter(filterBy)}
        renderItem={({ item: store, index }) => {
          const isFirst = index === 0;
          return (
            <View style={styles.cardExtraContainer}>
              <StoreCard key={store.id} store={store} isFirst={isFirst} />
            </View>
          );
        }}
        keyExtractor={(item) => item.id}
        onRefresh={onRefresh}
        refreshing={refreshLocation}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  cardExtraContainer: {
    alignItems: "center",
  },
});

Podemos notar que para renderizar nuestra lista usamos un componente de react-native llamado FlatList que nos permite crear listas que uno puede scrollear. En el prop data le damos nuestra lista en un array, en el prop renderItem le tenemos que dar el componente que queremos renderizar por cada item y en el prop keyExtractor le tenemos que dar un valor único por cada item(en nuestro caso, cada item tiene un id).

Obtener la data

Para poder obtener la data del api usamos swr, el cual nos pide la url del endpoint de la data y una función fetcher(en nuestro caso usamos un fetcher básico). Además podemos suspender nuestro componente y mostrar otro componente mientras aún no recibimos la data del api. Por eso StoresList está envuelto en React.Suspense que tiene un fallback mostrando un componente Loader.

StoreCard

Ahora pasemos al componente que se encarga de renderizar la información de cada tienda

// /components/store-card.js
import React from "react";
import {
  View,
  Text,
  StyleSheet,
  TouchableOpacity,
  Linking,
} from "react-native";
import { ATMIcon, AllDayIcon, MapPinIcon } from "./icons";

export default function StoreCard({ store, isFirst }) {
  function handleOnPress() {
    Linking.openURL(
      `https://maps.google.com/?q=${store.latitude},${store.longitude}`
    );
  }

  return (
    <TouchableOpacity
      onPress={handleOnPress}
      style={[styles.container, isFirst && { marginTop: 10 }]}
    >
      <View
        style={{
          flexDirection: "row",
          justifyContent: "space-between",
          alignItems: "center",
        }}
      >
        <Text style={styles.title}>{store.name}</Text>
        <View style={styles.iconContainer}>
          <MapPinIcon />
        </View>
      </View>
      <View style={styles.divider} />
      <Text style={styles.distance}>
        {store.distance < 1000
          ? `${parseInt(store.distance)}m`
          : `${(store.distance / 1000).toFixed(2)}km`}
      </Text>
      <Text style={styles.address}>{store.address.toLowerCase()}</Text>
      <View style={styles.iconsSection}>
        {store.allday && (
          <View style={styles.iconContainer}>
            <AllDayIcon />
          </View>
        )}
        {store.atm && (
          <View style={styles.iconContainer}>
            <ATMIcon />
          </View>
        )}
      </View>
    </TouchableOpacity>
  );
}

const styles = StyleSheet.create({
  container: {
    backgroundColor: "#fff",
    width: "90%",
    borderRadius: 10,
    padding: 20,
    marginBottom: 20,
    //shadow
    shadowColor: "#000",
    shadowOffset: {
      width: 0,
      height: 7,
    },
    shadowOpacity: 0.41,
    shadowRadius: 9.11,
    elevation: 14,
  },
  title: {
    fontSize: 18,
    fontWeight: "700",
    marginBottom: 5,
  },
  divider: {
    borderBottomWidth: 1,
    borderColor: "#efefef",
    marginBottom: 5,
  },
  distance: {
    fontSize: 14,
    lineHeight: 18,
    marginBottom: 5,
    textTransform: "capitalize",
  },
  address: {
    fontSize: 14,
    lineHeight: 18,
    marginBottom: 5,
    textTransform: "capitalize",
  },
  iconsSection: {
    flexDirection: "row",
    justifyContent: "center",
  },
  iconContainer: {
    width: 40,
    height: 40,
    justifyContent: "center",
    alignItems: "center",
  },
});

Redirigir a Google Maps

Nuestro componente StoreCard esta envuelto con TouchableOpacity que viene de react-native para poder tener el evento onPress y crear nuestra función que se ejecuta al momento de hacer "click", nuestra función que se ejecuta es handleOnPress en donde se usa Linking.openURL para poder redireccionar a una url(Linking tambien viene de react-native).

Íconos

Habrán notado que usamos tres íconos en la aplicación, ATMIcon que se muestra si la tienda tiene un cajero, AllDayIcon si la tienda atiende las 24 horas, MapPinIcon simplemente ilustrativo para mostrar que tenemos la ubicación de las tiendas. Todos éstos íconos están en svg, sin embargo, React Native no soporta svg de forma natural, por eso tenemos una forma particular para poder usarlos dentro de React Native.

// /components/icons.js
import React from "react";
import { SvgXml } from "react-native-svg";

const mapPinXml = `
<svg
  xmlns="http://www.w3.org/2000/svg"
  width="24"
  height="24"
  viewBox="0 0 24 24"
  fill="none"
  stroke="black"
  stroke-width="2"
  stroke-linecap="round"
  stroke-linejoin="round"
  class="feather feather-map-pin"
>
  <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
  <circle cx="12" cy="10" r="3"></circle>
</svg>
`;

export function MapPinIcon() {
  return <SvgXml xml={mapPinXml} width="100%" height="100%" />;
}

Éste es un ejemplo con MapPinIcon en el cual usamos react-native-svg para obtener SvgXml y renderizarlo de esa manera. Y En donde necesitemos usarlo lo envolvemos en un componente contenedor View con las medidas que queramos.

<View
  style={{
    width: 40,
    height: 40,
    justifyContent: "center",
    alignItems: "center",
  }}
>
  <MapPinIcon />
</View>

Así tenemos nuestra aplicación tal cual la mostramos al comienzo, pueden encontrar todo el código en Github