
はじめに
サブスク管理アプリ「SubTrack」の一覧画面は、登録したサービスのアイコンが上から落ちてきて積み重なる、スイカゲーム風の見た目になっています。物理エンジンには matter-js を使い、アイコンをタップすると編集画面に飛べるようになっています。
今回、この一覧画面に「スマホを傾けるとアイコンが転がる」機能を追加しました。加速度センサーで端末の傾きを検知し、matter-jsの重力ベクトルをリアルタイムに書き換えるという、ごくシンプルな仕組みです。実装の流れと、途中でハマったポイントをまとめます。
使ったもの
- expo-sensors の
Accelerometer - 既存の
matter-jsエンジン(重力ベクトルを差し替えるだけ) expo-routerのuseFocusEffect(画面フォーカス中だけセンサーを購読するため)
重力計算をピュア関数として切り出す
加速度センサーの値をそのままmatter-jsの重力に渡すとノイズで画面がガタつくので、指数移動平均で軽くスムージングをかけています。テストしやすいよう、UIから独立したピュア関数として実装しました。
export interface Vector2 {
x: number;
y: number;
}
export const DEFAULT_GRAVITY: Vector2 = { x: 0, y: 1 };
export const TILT_SMOOTHING_ALPHA = 0.15;
export function smoothVector(previous: Vector2, next: Vector2, alpha: number = TILT_SMOOTHING_ALPHA): Vector2 {
return {
x: previous.x + (next.x - previous.x) * alpha,
y: previous.y + (next.y - previous.y) * alpha
};
}
export function gravityFromAcceleration(acceleration: Vector2): Vector2 {
return { x: acceleration.x, y: -acceleration.y };
}
accelerometer.y の符号を反転しているのは、iOSの加速度センサーの座標系と画面(matter-js)の座標系でy軸の向きが逆だからです。ここは実機でしか正確に検証できない部分で、シミュレータでは連続的な傾きをエミュレートできないため、符号の向きは実機を振って確かめました。
センサーの購読は画面フォーカス中だけ
常時購読するとバッテリーを無駄に消費するので、expo-router の useFocusEffect で画面を離れたら購読解除するようにしています。権限が得られなかった場合(Web版や許可拒否時)は、何もせず元の「常に下向き」の重力にフォールバックします。
useFocusEffect(
React.useCallback(() => {
let subscription: ReturnType<typeof Accelerometer.addListener> | null = null;
let cancelled = false;
Accelerometer.requestPermissionsAsync()
.then(({ granted }) => {
if (!granted || cancelled) return;
Accelerometer.setUpdateInterval(100); // 10Hz
subscription = Accelerometer.addListener(({ x, y }) => {
const smoothed = smoothVector(smoothedAccelerationRef.current, { x, y });
smoothedAccelerationRef.current = smoothed;
const gravity = gravityFromAcceleration(smoothed);
const engine = engineRef.current;
if (engine) {
engine.gravity.x = gravity.x;
engine.gravity.y = gravity.y;
}
});
})
.catch(() => {});
return () => {
cancelled = true;
subscription?.remove();
};
}, [])
);
iOSでは NSMotionUsageDescription をInfo.plistに追加しないとセンサーへのアクセスがリジェクトされるので、app.json の expo-sensors プラグイン経由で設定しています。
[
"expo-sensors",
{
"motionPermission": "端末の傾きを検知してアイコンを転がすために使用します。"
}
]
ハマったポイント: 逆さまにするとアイコンが枠から飛び出す
実装してすぐに気づいたバグとして、スマホを逆さまにすると重力が上向きになり、アイコンが表示エリアの上端から飛び出してしまう問題がありました。
原因は物理エンジンの「壁」の構成にありました。もともとこの画面は、アイコンが画面外の上から降ってくる演出のために、床・左壁・右壁の3枚だけで天井がない「コップ」のような作りになっていたのです。重力が常に下向きだった頃はそれで問題なかったのですが、傾きに応じて重力の向きが反転しうるようになったことで、開いている天井からアイコンが飛び出せるようになってしまいました。
最初は「重力のy成分が負(上向き)にならないようクランプする」という応急処置で対応しましたが、これだと逆さまにしたときにアイコンが宙に浮いたような不自然な見た目になります。「入れ物に入っているような感じにしたい」というフィードバックをもらい、天井を追加して箱を完全に閉じる方向に直しました。
const ceiling = Matter.Bodies.rectangle(
width / 2,
-WALL_THICKNESS / 2,
width + WALL_THICKNESS * 2,
WALL_THICKNESS,
{ isStatic: true, friction: 0.6 }
);
天井を追加したことで、アイコンの出現位置も「画面外の上から降ってくる」から「箱の内側の上寄りから少し落ちてくる」に変更が必要になりました(天井にぶつかってしまうため)。見た目のカスケード感は保ちつつ、常に枠内に収まるようにしています。
const maxStartY = Math.max(radius, height - radius);
const startY = Math.min(maxStartY, radius + index * (radius * 1.4));
結果、重力のクランプは撤廃し、逆さまにすると本当に天井へアイコンが張り付く、素直な物理挙動に落ち着きました。
まとめ
- 加速度センサーの値をピュア関数でスムージング・重力ベクトル化することで、UIから切り離してテストできるようにした
- センサー購読は画面フォーカス中だけに限定し、権限がなくても壊れないようフォールバックを用意した
- 「天井のない箱」に可変重力を組み合わせると必ず穴が空くという、地味だが見落としやすい設計上の穴を実機テストとフィードバックで潰した
物理演出は見た目のインパクトが大きい割に、今回のように既存のコンポーネントに数十行足すだけで実現できるのが面白いところです。SubTrackはApp Storeで公開中なので、気になった方はぜひ試してみてください。


