Opublikowano 9 min. czytania

Ten tutorial pokaże Ci proces tworzenia prostej aplikacji czatu w React Native. Zamiast tradycyjnego, tekstowego wyglądu, wykorzystamy Google Maps do wizualizacji treści, które będziemy pobierać w czasie rzeczywistym z Firebase Realtime Database.

Utworzenie projektu

Będziemy używać Expo CLI, ponieważ narzędzie to znacznie ułatwia proces developmentu i budowania plików do umieszczenia w Google Play i App Store. Wywołaj w konsoli poniższe komendy:

# Instalacja Expo CLI
npm install -g expo-cli

# Utworzenie nowego projektu
expo init mapchat

# Przejście do katalogu projektu
cd mapchat

# Uruchomienie aplikacji
expo start

W tym momencie w katalogu mapchat posiadamy strukturę przykładowej, niewielkiej aplikacji, która jest gotowa do uruchomienia w symulatorze lub na fizycznym urządzeniu za pomocą aplikacji Expo Client.

Przykładowa aplikacja

Instalacja pozostałych zależności

Do zbudowania aplikacji będziemy potrzebować także:

  • react-native-maps - do wyświetlenia mapy i markerów z treściami wiadomości,
  • expo-location - do pobierania aktualnej lokalizacji urządzenia,
  • firebase - do zapisu i odczytów wiadomości z Firebase Realtime Database,
  • moment - do łatwego formatowania dat.

Zainstaluj wszystkie powyższe zależności:

npm install react-native-maps expo-location firebase moment

Wyświetlenie mapy

Najpierw utwórz plik src/helpers/map.js zawierający funkcję pomocniczą, która zwraca obiekt z danymi wyświetlanego regionu w formie wymaganej przez react-native-maps:

// src/helpers/map.js

export function getRegion(latitude, longitude, distance) {
    const oneDegreeOfLatitudeInMeters = 111.32 * 1000;

    const latitudeDelta = distance / oneDegreeOfLatitudeInMeters;
    const longitudeDelta = distance / (oneDegreeOfLatitudeInMeters * Math.cos(latitude * (Math.PI / 180)));

    return {
        latitude,
        longitude,
        latitudeDelta,
        longitudeDelta
    }
};

Funkcja ta na podstawie podanych współrzędnych geograficznych i dystansu (w metrach) wylicza wartości latitudeDelta oraz longitudeDelta, które posłużą nam do kontrolowania zoomu mapy.

Następnie otwórz istniejący plik App.js i usuń z niego niepotrzebne rzeczy zamieniając dotychczasową zawartość na poniższą:

// App.js

import React, { Component } from 'react';
import { StyleSheet, View, Text } from 'react-native';
import MapView from 'react-native-maps';
import { getRegion } from './src/helpers/map';

export default class App extends Component {
  state = {}

  componentDidMount() {}

  onChangeText() {}

  onSendPress() {}

  getLocation = async () => {}

  render() {
    return (
      <View style={styles.container}>
        <MapView
          ref={(ref) => this.map = ref}
          style={styles.map}
          initialRegion={getRegion(48.860831, 2.341129, 160000)}
        />
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center'
  },
  map: {
    ...StyleSheet.absoluteFillObject
  }
});

W powyższym kodzie zaimportowaliśmy komponenty, które nam na tę chwilę są potrzebne, a następnie wewnątrz klasy App zadeklarowaliśmy pusty na chwilę obecną stan aplikacji i puste metody, które później zostaną objaśnione i wypełnione właściwym kodem. W dalszej części wyświetlamy komponent MapView, podstawiając we właściwości initialRegion obiekt zwrócony przez naszą funkcję pomocniczą getRegion. Ponadto za pomocą właściwości ref utworzyliśmy referencję do mapy, dzięki czemu będziemy mogli odwoływać się do niej na późniejszych etapach z innych miejsc w kodzie.

Wywołanie funkcji getRegion(48.860831, 2.341129, 160000) (zawarłem tu współrzędne Paryża) zwraca obiekt:

Object {
  "latitude": 48.860831,
  "latitudeDelta": 1.437297879985627,
  "longitude": 2.341129,
  "longitudeDelta": 2.1847076799791414,
}

Aby zmniejszyć/zwiększyć zoom mapy, wystarczy zmienić wartość trzeciego parametru (distance), co spowoduje wyliczenie odpowiednio innych wartości latitudeDelta i longitudeDelta, od których zależy stopień powiększenia.

Zwróć także uwagę na to, że do komponentu MapView przekazaliśmy właściwość style, w której nakazujemy rozciągnięcie kontenera na całą możliwą szerokość i wysokość (StyleSheet.absoluteFill to szybszy odpowiednik dla position: 'absolute', left: 0, right: 0, top: 0, bottom: 0). Właśnie brak rozciągnięcia kontenera z mapą jest jednym z najczęstszych problemów z niewyświetlaniem mapy.

Aktualny stan aplikacji - wyświetlona mapa

Pobieranie lokalizacji urządzenia

Lokalizacja będzie nam potrzebna do zapisania w bazie danych wraz z treścią wysyłanej wiadomości przez użytkownika w celu wyświetlenia markera w odpowiednim miejscu na mapie. Oprócz tego przyda się ona do zmiany domyślnie wyświetlanego regionu mapy na region z okolicy użytkownika. Będziemy ją pobierać za pomocą zainstalowanej wcześniej paczki expo-location.

Dodaj w górnej części pliku App.js:

// App.js

(...)
import * as Location from 'expo-location';
import * as Permissions from 'expo-permissions';
(...)

Następnie wewnątrz klasy App zdefiniuj domyślny stan aplikacji, w którym będziemy przechowywać lokalizację urządzenia:

// App.js

(...)
  state = {
    location: {
      latitude: null,
      longitude: null
    }
  }
(...)

W dalszej części tego samego pliku, wypełnij pustą dotychczas metodę getLocation:

// App.js

(...)
  getLocation = async () => {
    let { status } = await Permissions.askAsync(Permissions.LOCATION);

    if (status === 'granted') {
      let location = await Location.getCurrentPositionAsync({});

      this.setState({
        location: {
          latitude: location.coords.latitude,
          longitude: location.coords.longitude
        }
      });

      this.map.animateToRegion(getRegion(location.coords.latitude, location.coords.longitude, 16000));
    }
  }
(...)

Powoduje ona wyświetlenie zapytania o zgodę na dostęp do lokalizacji urządzenia. W przypadku udzielenia zgody, lokalizacja jest pobierana i zostaje umieszczona w stanie aplikacji. Następnie za pomocą referencji do mapy, wywołujemy na niej animateToRegion(), powodując płynne przewinięcie mapy w rejon lokalizacji użytkownika.

Chcielibyśmy, aby ta metoda wywołała się od razu po uruchomieniu aplikacji, dlatego wywołajmy ją w metodzie componentDidMount:

// App.js

(...)
  componentDidMount() {
    this.getLocation();
  }
(...)

Konfiguracja Firebase

Utwórz plik src/config/firebase.js o następującej zawartości:

// src/config/firebase.js

import * as firebase from 'firebase';

const firebaseConfig = {
    apiKey: "",
    authDomain: "",
    databaseURL: "",
    projectId: "",
    storageBucket: "",
    messagingSenderId: "",
    appId: ""
};

firebase.initializeApp(firebaseConfig);

export default firebase;

Następnie zaloguj się do konsoli Firebase, utwórz w niej nowy projekt, po czym przejdź do sekcji Project settings > Firebase SDK snippet > Config i skopiuj wyświetlone tam dane konfiguracyjne do stałej firebaseConfig w powyższym pliku.

Teraz pozostaje nam zaimportować firebase w pliku App.js:

// App.js

(...)
import firebase from './src/firebase';
(...)

Tworzenie pola tekstowego na wiadomość

Potrzebujemy w tym celu komponentu TextInput z react-native, umożliwiającego wpisanie treści wiadomości oraz przycisku (TouchableOpacity) wraz z ikoną (z biblioteki Expo - pełna lista [tutaj](https://expo.github.io/vector-icons/)) odpowiedzialnego za jej wysłanie. Zmodyfikuj więc odpowiednie polecenieimport`, dodając nowe komponenty z React Native:

// App.js

(...)
import { TextInput, TouchableOpacity, ToastAndroid, StatusBar, Keyboard, StyleSheet, View, Text } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
(...)

Dodatkowo importujemy:

  • ToastAndroid - za jego pomocą wyświetlimy komunikat typu toast o wysłaniu wiadomości,
  • StatusBar - pomoże nam pobrać aktualną wysokość status bar'a w celu obliczenia właściwej odległości pola tekstowego od górnej krawędzi ekranu,
  • Keyboard - pozwoli nam skorzystać z metody Keyboard.dismiss() w celu zamknięcia klawiatury po wysłaniu wiadomości.

Do stanu aplikacji dodaj nowe klucze:

// App.js

(...)
  state = {
    (...)
    messageText: null,
    sendButtonActive: false
  }
(...)
  • messageText - tutaj będziemy przechowywać wartość wpisaną do pola tekstowego,
  • sendButtonActive - za pomocą tej wartości będziemy decydować, czy przycisk jest aktywny i pozwala na wysłanie wiadomości (true - aktywny, false - nieaktywny). Wpływ na to będzie miała liczba znaków w polu tekstowym.

Następnie wyświetl input w metodzie render():

// App.js

(...)
  render() {
    return (
      <View style={styles.container}>
        <View style={styles.inputWrapper}>
          <TextInput
            style={styles.input}
            placeholder="Type your message here"
            onChangeText={messageText => this.onChangeText(messageText)}
            value={this.state.messageText}
          />
          <View style={{ ...styles.sendButton, ...(this.state.sendButtonActive ? styles.sendButtonActive : {}) }}>
            <TouchableOpacity onPress={this.onSendPress.bind(this)}>
              <MaterialIcons name="send" size={32} color="#fe4027" />
            </TouchableOpacity>
          </View>
        </View>
        <MapView>(...)</MapView>
      </View>
    );
  }
(...)

Potrzebujemy jeszcze ostylować wstawiony input:

// App.js

(...)
const styles = StyleSheet.create({
  (...)
  inputWrapper: {
    width: '100%',
    position: 'absolute',
    padding: 10,
    top: StatusBar.currentHeight,
    left: 0,
    zIndex: 100
  },
  input: {
    height: 46,
    paddingVertical: 10,
    paddingRight: 50,
    paddingLeft: 10,
    borderColor: 'gray',
    borderWidth: 1,
    borderRadius: 6,
    borderColor: '#ccc',
    backgroundColor: '#fff'
  },
  sendButton: {
    position: 'absolute',
    top: 17,
    right: 20,
    opacity: 0.4
  },
  sendButtonActive: {
    opacity: 1
  }
});

Aktualny stan aplikacji - input na wiadomość

Wysyłanie wiadomości

Nasz komponent TextInput wywołuje już metodę onChangeText za każdym razem, kiedy użytkownik zmieni treść w polu i przekazuje do niej aktualną wartość pola. Wypełnijmy więc tę dotychczas pustą metodę kodem, który odpowiednio zmodyfikuje stan aplikacji, decydując dodatkowo, czy przycisk wysyłania powinien być aktywny (gdy wpisano co najmniej jeden znak).

// App.js

(...)
   onChangeText(messageText) {
    this.setState({
      messageText: messageText,
      sendButtonActive: messageText.length > 0
    });
  }
(...)

Musimy jeszcze obsłużyć zdarzenie wciśnięcia przycisku (TouchableOpacity), który wywołuje metodę onSendPress. Umieśćmy w niej kod, który będzie wysyłał wiadomość do Firebase Realtime Database:

// App.js

(...)
  onSendPress() {
    if (this.state.sendButtonActive) {
      firebase.database().ref('messages').push({
        text: this.state.messageText,
        latitude: this.state.location.latitude,
        longitude: this.state.location.longitude,
        timestamp: firebase.database.ServerValue.TIMESTAMP
      }).then(() => {
        this.setState({ messageText: null });

        ToastAndroid.show('Your message has been sent!', ToastAndroid.SHORT);

        Keyboard.dismiss();
      }).catch((error) => {
        console.log(error);
      });
    }
  }
(...)

Oprócz wartości z pola tekstowego, do lokalizacji /messages w bazie danych wysyłamy także współrzędne (latitude, longitude) oraz aktualny timestamp zwrócony przez serwer Firebase jako czas utworzenia wiadomości.

Jeśli wszystko zostało wykonane poprawnie, w konsoli Firebase będą wyświetlać się nam w czasie rzeczywistym wszystkie wysyłane wiadomości:

Firebase Realtime Database

Wyświetlanie markerów i wiadomości

Markery wyświetlane w miejscu zapisanych współrzędnych będziemy wyświetlać za pomocą komponentu Marker z paczki react-native-maps, a nad nim treść wiadomości w chmurce generowaną przez komponent Callout. Zaimportujmy je:

// App.js

(...)
import MapView, { Marker, Callout } from 'react-native-maps';
import moment from 'moment';
(...)

Zaimportowalismy też zainstalowaną wcześniej bibliotekę moment.js, która pozwoli nam w łatwy sposób zamienić timestamp dodanej wiadomości na czytelniejszy format ("added X minutes ago").

Domyślny stan aplikacji uzupelnij o pustą tablicę messages, do której będą pobierane wiadomości z Firebase:

// App.js

(...)
   state = {
    (...)
    messages: []
  };
(...)

W metodzie componentDidMount zainicjuj pobieranie wiadomości z Firebase:

// App.js

(...)
  componentDidMount() {
    this.getLocationAsync();

    firebase.database().ref('messages').limitToLast(10).on('child_added', (data) => {
      let messages = [...this.state.messages, data.val()];

      this.setState({ messages }, () => {
        let { latitude, longitude } = [...messages].pop();

        this.map.animateToRegion(getRegion(latitude, longitude, 16000));

        if (this.marker !== undefined) {
          setTimeout(() => {
            this.marker.showCallout();
          }, 100);
        }
      });
    });
  }
(...)

Powyższy kod pobiera 10 najnowszych wiadomości, a następnie nasłuchuje na zdarzenie child_added, które zostanie wywołane po każdym dodaniu nowej wiadomości. Podczas każdego takiego zdarzenia, tablica messages w stanie aplikacji jest aktualizowana o nowe wiadomości. Następnie mapa jest przewijana do lokalizacji z najnowszej wiadomości, a za pomocą referencji do ostatnio dodanego markera (którą za chwilę utworzymy), wywoływana jest na nim metoda showCallout() w celu wyświetlenia chmurki z treścią wiadomości.

Teraz pozostaje nam w metodzie render() umieścić Marker i Callout:

// App.js

(...)
  render() {
    return (
      (...)
        <MapView
          ref={(ref) => this.map = ref}
          style={styles.map}
          initialRegion={getRegion(48.860831, 2.341129, 160000)}
        >
          {this.state.messages.map((message, index) => {
            let { latitude, longitude, text, timestamp } = message;

            return (
              <Marker
                ref={(ref) => this.marker = ref}
                key={index}
                identifier={'marker_' + index}
                coordinate={{ latitude, longitude }}
              >
                <Callout>
                  <View>
                    <Text>{text}</Text>
                    <Text style={{'color': '#999'}}>{moment(timestamp).fromNow()}</Text>
                  </View>
                </Callout>
              </Marker>
            )
          })}
        </MapView>
      </View>
    );
  }
(...)

I to wszystko! Nowe wiadomości wyświetlają się na mapie.

Widoki finalnej aplikacji

Kod całej i gotowej do uruchomienia aplikacji możesz zobaczyć w repozytorium na GitHub'ie.

Ćwiczenia dla Ciebie

  1. Przydziel każdemu użytkownikowi (urządzeniu) inny kolor markera na mapie.
  2. Zaimplementuj obsługę przypadku odmowy zgody na dostęp do lokalizacji urządzenia poprzez wyświetlenie komunikatu z odpowiednią informacją.
  3. Dodaj możliwość zmiany trybu wyświetlania nowych markerów tak, aby można było włączyć i wyłączyć automatyczne przewijanie mapy do nowo umieszczonych markerów na mapie.