Tính năng tìm cửa hàng/chi nhánh gần vị trí người dùng là một trong những tính năng phổ biến nhất trong các app bán lẻ, F&B, giao hàng. Bài viết này sẽ hướng dẫn bạn xây dựng tính năng này từ đầu, bao gồm: lấy vị trí người dùng, tính khoảng cách, gọi API backend, và hiển thị kết quả lên bản đồ.

Tổng quan kiến trúc

Luồng hoạt động cơ bản gồm 4 bước:

  1. Xin quyền truy cập vị trí (Location Permission)
  2. Lấy tọa độ GPS hiện tại của người dùng
  3. Gửi tọa độ lên backend (hoặc tính toán tại client) để tìm các cửa hàng trong bán kính X km
  4. Hiển thị danh sách/bản đồ các cửa hàng, sắp xếp theo khoảng cách gần nhất

1. Cài đặt thư viện cần thiết

Chúng ta sẽ dùng hai thư viện phổ biến:

npm install react-native-geolocation-service react-native-maps
npx pod-install # nếu build iOS
  • react-native-geolocation-service: lấy tọa độ GPS chính xác hơn API mặc định
  • react-native-maps: hiển thị bản đồ và marker

2. Cấu hình quyền truy cập vị trí

Android

Trong android/app/src/main/AndroidManifest.xml, thêm:

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

iOS

Trong ios/YourApp/Info.plist, thêm:

<key>NSLocationWhenInUseUsageDescription</key>
<string>Ứng dụng cần quyền truy cập vị trí để tìm cửa hàng gần bạn</string>

3. Xin quyền và lấy vị trí người dùng

Tạo một hàm helper để xin quyền và lấy tọa độ:

import { PermissionsAndroid, Platform } from 'react-native';
import Geolocation from 'react-native-geolocation-service';

export async function requestLocationPermission() {
  if (Platform.OS === 'android') {
    const granted = await PermissionsAndroid.request(
      PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
      {
        title: 'Quyền truy cập vị trí',
        message: 'Ứng dụng cần vị trí của bạn để tìm cửa hàng gần nhất',
        buttonPositive: 'Đồng ý',
        buttonNegative: 'Từ chối',
      }
    );
    return granted === PermissionsAndroid.RESULTS.GRANTED;
  }
  // iOS sẽ tự hiện popup khi gọi Geolocation.requestAuthorization
  const auth = await Geolocation.requestAuthorization('whenInUse');
  return auth === 'granted';
}

export function getCurrentLocation() {
  return new Promise((resolve, reject) => {
    Geolocation.getCurrentPosition(
      (position) => {
        resolve({
          latitude: position.coords.latitude,
          longitude: position.coords.longitude,
        });
      },
      (error) => reject(error),
      { enableHighAccuracy: true, timeout: 15000, maximumAge: 10000 }
    );
  });
}

4. Tính khoảng cách bằng công thức Haversine

Nếu bạn chưa có backend hỗ trợ truy vấn địa lý (geospatial query), có thể tính khoảng cách ngay tại client bằng công thức Haversine:

function toRad(value) {
  return (value * Math.PI) / 180;
}

export function calculateDistance(lat1, lon1, lat2, lon2) {
  const R = 6371; // bán kính Trái Đất (km)
  const dLat = toRad(lat2 - lat1);
  const dLon = toRad(lon2 - lon1);
  const a =
    Math.sin(dLat / 2) * Math.sin(dLat / 2) +
    Math.cos(toRad(lat1)) *
      Math.cos(toRad(lat2)) *
      Math.sin(dLon / 2) *
      Math.sin(dLon / 2);
  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  return R * c; // khoảng cách tính bằng km
}

Cách này phù hợp khi số lượng cửa hàng không quá lớn (vài trăm điểm). Nếu có hàng nghìn cửa hàng, nên đẩy việc tính toán này về backend với chỉ mục địa lý (xem phần 6).

5. Lọc và sắp xếp danh sách cửa hàng theo khoảng cách

export function getNearbyStores(userLocation, stores, radiusKm = 5) {
  return stores
    .map((store) => ({
      ...store,
      distance: calculateDistance(
        userLocation.latitude,
        userLocation.longitude,
        store.latitude,
        store.longitude
      ),
    }))
    .filter((store) => store.distance <= radiusKm)
    .sort((a, b) => a.distance - b.distance);
}

6. Truy vấn geospatial ở backend (khuyến nghị cho dữ liệu lớn)

Nếu danh sách cửa hàng lớn, bạn nên để backend xử lý lọc bán kính thay vì tải toàn bộ dữ liệu về client. Một số cách phổ biến:

  • MongoDB: dùng chỉ mục 2dsphere và toán tử $geoNear hoặc $nearSphere
  • PostgreSQL + PostGIS: dùng hàm ST_DWithin để lọc theo bán kính
  • Elasticsearch: dùng geo_distance query

Ví dụ gọi API backend từ React Native:

export async function fetchNearbyStores(latitude, longitude, radiusKm = 5) {
  const response = await fetch(
    `https://api.yourapp.com/stores/nearby?lat=${latitude}&lng=${longitude}&radius=${radiusKm}`
  );
  if (!response.ok) {
    throw new Error('Không thể tải danh sách cửa hàng');
  }
  return response.json();
}

Cách này giúp giảm tải cho thiết bị người dùng và tận dụng chỉ mục địa lý để truy vấn nhanh hơn nhiều so với việc tính Haversine cho từng bản ghi.

7. Hiển thị lên bản đồ với react-native-maps

import React, { useEffect, useState } from 'react';
import { View, StyleSheet, ActivityIndicator, Text } from 'react-native';
import MapView, { Marker, PROVIDER_GOOGLE } from 'react-native-maps';
import { requestLocationPermission, getCurrentLocation } from './location';
import { fetchNearbyStores } from './api';

export default function NearbyStoresScreen() {
  const [location, setLocation] = useState(null);
  const [stores, setStores] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function init() {
      try {
        const granted = await requestLocationPermission();
        if (!granted) {
          setError('Bạn cần cấp quyền vị trí để sử dụng tính năng này');
          return;
        }
        const loc = await getCurrentLocation();
        setLocation(loc);

        const nearby = await fetchNearbyStores(loc.latitude, loc.longitude, 5);
        setStores(nearby);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }
    init();
  }, []);

  if (loading) {
    return (
      <View style={styles.center}>
        <ActivityIndicator size="large" />
      </View>
    );
  }

  if (error) {
    return (
      <View style={styles.center}>
        <Text>{error}</Text>
      </View>
    );
  }

  return (
    <MapView
      provider={PROVIDER_GOOGLE}
      style={styles.map}
      initialRegion={{
        latitude: location.latitude,
        longitude: location.longitude,
        latitudeDelta: 0.05,
        longitudeDelta: 0.05,
      }}
      showsUserLocation
    >
      {stores.map((store) => (
        <Marker
          key={store.id}
          coordinate={{
            latitude: store.latitude,
            longitude: store.longitude,
          }}
          title={store.name}
          description={`${store.distance.toFixed(1)} km`}
        />
      ))}
    </MapView>
  );
}

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

8. Những điểm cần lưu ý khi triển khai thực tế

  • Xử lý khi người dùng từ chối quyền vị trí: nên có màn hình hướng dẫn người dùng bật lại quyền trong Settings, thay vì để app "đứng hình".
  • Cache vị trí: dùng maximumAge hợp lý để tránh gọi GPS liên tục, tiết kiệm pin.
  • Debounce khi người dùng di chuyển bản đồ: nếu cho phép tìm kiếm theo vùng bản đồ hiện tại (kiểu "Tìm kiếm khu vực này"), nên debounce việc gọi API để tránh gửi quá nhiều request.
  • Đơn vị bán kính: thống nhất km hoặc dặm giữa client và backend để tránh sai lệch kết quả.
  • Xử lý khi không có kết quả: hiển thị thông báo rõ ràng và gợi ý tăng bán kính tìm kiếm thay vì màn hình trắng.
  • Testing trên thiết bị thật: GPS giả lập trên simulator/emulator thường không chính xác, nên test kỹ trên thiết bị thật trước khi release.

Kết luận

Việc triển khai tính năng tìm cửa hàng gần đây trong React Native không quá phức tạp về mặt kỹ thuật, nhưng cần chú ý đến trải nghiệm người dùng ở các bước xin quyền, xử lý lỗi, và hiệu năng khi dữ liệu lớn. Với dữ liệu nhỏ, tính Haversine tại client là đủ dùng; nhưng khi mở rộng, hãy chuyển việc lọc theo bán kính sang backend với chỉ mục địa lý để đảm bảo tốc độ và khả năng mở rộng.