🥜

Expo Sensorsで歩数計を作ってみた

2024/12/12に公開

歩数計ってくれるの?

できるみたいです。普通はヘルスケアのAPIと接続するヘルスキットなるものが必要なようですが、Expoには歩数計だけならExpo Sensorsで作ることができることが書いてあるように思えました。

ヘルスケア用のライブラリはこれを使うみたいだ?
https://github.com/Kingstinct/react-native-healthkit
https://github.com/agencyenterprise/react-native-health

What Expo Sensors?

A library that provides access to a device's accelerometer, barometer, motion, gyroscope, magnetometer, and pedometer.

デバイスの加速度計、気圧計、モーションセンサー、ジャイロスコープ、地磁気センサー、歩数計へのアクセスを提供するライブラリ。

本当にできるようだ😅

計測しただけだとメモリに保持するだけでデータが消えてしまうので、ローカルのストレージに記録しておく必要がありそう。
AsyncStorageを使うと記録ができた。

AsyncStorage
A library that provides an asynchronous, unencrypted, persistent, key-value storage API.

非同期ストレージ
非同期、非暗号化、永続的なキー・バリュー・ストレージ API を提供するライブラリ。

歩数計の解説はこちらのページに情報が記載してあった。
https://docs.expo.dev/versions/latest/sdk/pedometer/

残念なことに、Androidだけ動作確認ができませんでした😭
やはり設定が違うのかな?
iOSは歩数計が使えていたが。。。。

こちらが完成品です

プロジェクトを作成する。

bunx create-expo-app expo-pedometer -t expo-template-blank-typescript

ライブラリを追加する。

bun install expo-sensors

歩数計は数えたら保存しないとな。内部ストレージを使えるライブラリも追加しておく。

bun add @react-native-async-storage/async-storage

app.jsonでアクセスの許可をする必要がありそうだ。最近は自動でやってくれるらしいが?
Autolinking
しかし生成AIによると本番用では、設定はした方が良いとのこと。

app.json
{
  "expo": {
    "name": "expo-pedometer",
    "slug": "expo-pedometer",
    "version": "1.0.0",
    "orientation": "portrait",
    "icon": "./assets/icon.png",
    "userInterfaceStyle": "light",
    "newArchEnabled": true,
    "splash": {
      "image": "./assets/splash-icon.png",
      "resizeMode": "contain",
      "backgroundColor": "#ffffff"
    },
    "ios": {
      "supportsTablet": true
    },
    "android": {
      "adaptiveIcon": {
        "foregroundImage": "./assets/adaptive-icon.png",
        "backgroundColor": "#ffffff"
      }
    },
    "web": {
      "favicon": "./assets/favicon.png"
    },
    "plugins": [
      [
        "expo-sensors",
        {
          "motionPermission": "$(PRODUCT_NAME)がモーションセンサーへのアクセスを求めています"
        }
      ]
    ]
  }
}

example

iOSでのみ動作が確認できました。Expo Goで起動して家の近所をお散歩して、歩いた歩数を計測するのに使用しました。

App.tsx
import React, { useState, useEffect } from 'react';
import { StyleSheet, Text, View, TouchableOpacity, ScrollView, EventSubscription } from 'react-native';
import { Pedometer } from 'expo-sensors';
import AsyncStorage from '@react-native-async-storage/async-storage';

interface DailySteps {
  date: string;
  steps: number;
}

export default function App() {
  const [isPedometerAvailable, setIsPedometerAvailable] = useState('checking');
  const [pastStepCount, setPastStepCount] = useState(0);
  const [currentStepCount, setCurrentStepCount] = useState(0);
  const [subscription, setSubscription] = useState<EventSubscription | null>(null);
  const [stepHistory, setStepHistory] = useState<DailySteps[]>([]);

  const saveStepsToStorage = async (steps: number) => {
    try {
      const today = new Date().toISOString().split('T')[0];
      const existingData = await AsyncStorage.getItem('stepHistory');
      let history: DailySteps[] = existingData ? JSON.parse(existingData) : [];
      
      // 今日のデータがあれば更新、なければ追加
      const todayIndex = history.findIndex(item => item.date === today);
      if (todayIndex !== -1) {
        history[todayIndex].steps = steps;
      } else {
        history.push({ date: today, steps });
      }
      
      // 最新7日分のみ保持
      history = history.slice(-7);
      
      await AsyncStorage.setItem('stepHistory', JSON.stringify(history));
      setStepHistory(history);
    } catch (error) {
      console.error('Failed to save steps:', error);
    }
  };

  const loadStepHistory = async () => {
    try {
      const data = await AsyncStorage.getItem('stepHistory');
      if (data) {
        setStepHistory(JSON.parse(data));
      }
    } catch (error) {
      console.error('Failed to load step history:', error);
    }
  };

  const subscribe = async () => {
    const isAvailable = await Pedometer.isAvailableAsync();
    setIsPedometerAvailable(String(isAvailable));

    if (isAvailable) {
      const end = new Date();
      const start = new Date();
      start.setDate(end.getDate() - 1);

      const pastSteps = await Pedometer.getStepCountAsync(start, end);
      setPastStepCount(pastSteps.steps);

      let lastUpdate = Date.now();
      const newSubscription = Pedometer.watchStepCount(result => {
        const now = Date.now();
        // 100ms以上経過していて、かつ歩数が更新されている場合のみ更新
        if (now - lastUpdate > 100 && result.steps !== currentStepCount) {
          setCurrentStepCount(result.steps);
          saveStepsToStorage(result.steps);
          lastUpdate = now;
        }
      });
      setSubscription(newSubscription as EventSubscription | null);
    }
  };

  const unsubscribe = () => {
    subscription && subscription.remove();
    setSubscription(null);
  };

  useEffect(() => {
    subscribe();
    loadStepHistory();
    return () => unsubscribe();
  }, []);

  return (
    <ScrollView style={styles.container}>
      <Text style={styles.title}>歩数計アプリ</Text>

      <View style={styles.card}>
        <Text style={styles.text}>
          Pedometer: {isPedometerAvailable}
        </Text>
        <Text style={styles.text}>
          現在の歩数: {currentStepCount}
        </Text>
        <Text style={styles.text}>
          過去24時間の歩数: {pastStepCount}
        </Text>
      </View>

      <View style={styles.card}>
        <Text style={styles.subtitle}>過去7日間の記録</Text>
        {stepHistory.slice().reverse().map((day) => (
          <View key={day.date} style={styles.historyItem}>
            <Text style={styles.text}>{day.date}</Text>
            <Text style={styles.text}>{day.steps}</Text>
          </View>
        ))}
      </View>

      <TouchableOpacity
        style={styles.button}
        onPress={subscription ? unsubscribe : subscribe}
      >
        <Text style={styles.buttonText}>
          {subscription ? '停止' : '開始'}
        </Text>
      </TouchableOpacity>
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f5f5f5',
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    textAlign: 'center',
    marginTop: 60,
    marginBottom: 20,
  },
  subtitle: {
    fontSize: 18,
    fontWeight: 'bold',
    marginBottom: 10,
  },
  card: {
    backgroundColor: 'white',
    borderRadius: 10,
    padding: 20,
    margin: 10,
    shadowColor: '#000',
    shadowOffset: {
      width: 0,
      height: 2,
    },
    shadowOpacity: 0.25,
    shadowRadius: 3.84,
    elevation: 5,
  },
  text: {
    fontSize: 16,
    marginVertical: 5,
  },
  historyItem: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    paddingVertical: 5,
    borderBottomWidth: 1,
    borderBottomColor: '#eee',
  },
  button: {
    marginTop: 20,
    backgroundColor: '#007AFF',
    padding: 15,
    borderRadius: 10,
    width: '100%',
  },
  buttonText: {
    color: 'white',
    textAlign: 'center',
    fontSize: 16,
    fontWeight: 'bold',
  },
});

こちらはすでに使用した後の端末ですが歩いた歩数は内部ストレージに保存してます。でも歩数計あるあるなんですけど、こんなに歩いたのかなって数字になってしまう?
これは、SwiftUIで実験したときも同じでしたね。
https://x.com/JBOY83062526/status/1867088808141000742

感想

歩数計機能のついたお散歩アプリを作るのは意外と簡単かと思っていましたがセンサーに関係した機能だと難しいように思えました。
Flutterよりは簡単に実装できましたが、まだまだ課題が残っている😅
Expo最近研究してますが、もしかしたらFlutterよりは開発体験がいいかもしれない?
実は、ReactNativeしか対応してない外部サービスがあったり。。。
Expoだと別物だから使えるかわからませんが???

Discussion