壁紙アプリの設定画面で「ライト / ダーク / 自動」を切り替えるUIを作り直したときのことです。個人開発では細部にまで手が届く反面、こうした小さな部品の粗さがそのまま体験の質に出ます。最初は @react-native-segmented-control をそのまま置いていたのですが、Androidで見ると角丸も色も浮いて見えて、切り替えの瞬間もパッと切り替わるだけ。指を離した後に「今どこを選んだのか」が視線で追えず、どうにも所在なさげでした。
選択中の位置がスッと滑って移動する。その一拍の動きがあるだけで、画面はぐっと手に馴染みます。私自身、この違いを実機で確かめてから、設定画面の印象がまるで変わりました。「滑るインジケーター」を Reanimated で自作した実装を、幅の実測から公開前の詰めまで順を追って共有いたします。
なぜ標準コンポーネントで止まらなかったか
SegmentedControl は iOS ではネイティブに寄せてくれますが、Android では見た目の一貫性を取りづらく、アニメーションの余地もほとんどありません。両OSで同じ表情にしたい、指標の動きを自分で調整したい——この2点が、自作へ踏み出した理由でした。
判断の目安を、簡単な比較で整理しておきます。
| 観点 | 標準SegmentedControl | Reanimated自作 |
| 両OSの見た目統一 | 難しい | 自由 |
| インジケーターの動き | ほぼ固定 | 調整可能 |
| 実装コスト | 低い | 中 |
| アクセシビリティ | OS任せ | 自分で担保 |
標準で足りるなら標準が最善です。私は、ブランドの色やモーションに合わせたい設定画面・フィルタUIのように動きが体験の一部になる場面だけ自作へ寄せることを推奨します。むやみに自作を増やすと保守が重くなるためです。
設計の核心は「幅を実測してから動かす」
つまずきやすいのはここです。セグメントのラベルは日本語・英語・言語によって幅が変わり、長い言語では短い言語の最大1.5倍ほどにもなります。均等割りを前提に 画面幅 / 個数 で位置を決めると、文字が長い言語で指標がラベルからずれます。
そこで、各セグメントの実寸を onLayout で測り、その配列を使ってインジケーターの translateX と width を決めます。測るのは一度きり。以降の切り替えは測った値を使うだけなので、再レンダーは起きません。
完全な実装
依存は Reanimated のみです。まず幅を蓄える器と、選択中インデックスを共有値で持ちます。
import { useCallback, useMemo, useRef, useState } from "react";
import { LayoutChangeEvent, Pressable, StyleSheet, Text, View } from "react-native";
import Animated, {
useAnimatedStyle,
useDerivedValue,
withSpring,
} from "react-native-reanimated";
type Props = {
options: string[];
value: number;
onChange: (index: number) => void;
};
export function SegmentedControl({ options, value, onChange }: Props) {
// 各セグメントの実測幅(onLayoutで埋まる)
const [widths, setWidths] = useState<number[]>(() => options.map(() => 0));
const measured = useRef(0);
const onSegmentLayout = useCallback(
(index: number) => (e: LayoutChangeEvent) => {
const w = e.nativeEvent.layout.width;
setWidths((prev) => {
if (prev[index] === w) return prev;
const next = [...prev];
next[index] = w;
return next;
});
},
[],
);
// 選択位置までの左端オフセットを事前計算
const offsets = useMemo(() => {
const acc: number[] = [];
let sum = 0;
for (const w of widths) {
acc.push(sum);
sum += w;
}
return acc;
}, [widths]);
const indicator = useDerivedValue(() => {
return {
x: offsets[value] ?? 0,
w: widths[value] ?? 0,
};
}, [value, offsets, widths]);
const indicatorStyle = useAnimatedStyle(() => ({
width: withSpring(indicator.value.w, { damping: 18, stiffness: 180 }),
transform: [{ translateX: withSpring(indicator.value.x, { damping: 18, stiffness: 180 }) }],
}));
return (
<View style={styles.track} accessibilityRole="tablist">
<Animated.View style={[styles.indicator, indicatorStyle]} pointerEvents="none" />
{options.map((label, i) => (
<Pressable
key={label}
onLayout={onSegmentLayout(i)}
onPress={() => onChange(i)}
style={styles.segment}
accessibilityRole="tab"
accessibilityState={{ selected: value === i }}
accessibilityLabel={label}
>
<Text style={[styles.label, value === i && styles.labelActive]}>{label}</Text>
</Pressable>
))}
</View>
);
}
const styles = StyleSheet.create({
track: {
flexDirection: "row",
backgroundColor: "#EEF0F3",
borderRadius: 12,
padding: 4,
},
indicator: {
position: "absolute",
top: 4,
bottom: 4,
left: 4,
borderRadius: 8,
backgroundColor: "#FFFFFF",
},
segment: { paddingVertical: 8, paddingHorizontal: 16 },
label: { fontSize: 15, color: "#6B7280", fontWeight: "500" },
labelActive: { color: "#111827", fontWeight: "700" },
});
なぜ width と translateX の両方をアニメートするのか。ラベル幅が異なる以上、指標も伸縮しなければ端がはみ出します。両方を同じ withSpring に載せることで、移動と伸縮が一体の動きになり、破綻がなくなります。pointerEvents="none" を指標に付けているのは、白い板がタップを吸ってセグメントに届かなくなる事故を回避するためです。
動きを詰める:spring か timing か
最初は withTiming の 200ms で作りましたが、機械的に感じました。withSpring に替え、damping: 18 / stiffness: 180 に落ち着いています。私が使い分けている目安は次の通りです。
| 狙い | 推奨 |
| キビキビ・事務的 | withTiming 160〜200ms |
| やわらかく手に馴染む | withSpring damping 18 / stiffness 180 |
| 跳ねを消したい | dampingを上げる(22前後) |
数値は「速さ」でなく「性格」を決めます。設定画面は落ち着いた spring、ゲーム的なUIは少し跳ねさせる。触りながら手で決めるのが結局いちばん確かでした。
公開前につまずいた3点
初回フレームで指標が消える
初回描画で幅がまだ 0 のとき、指標の幅も 0 になり一瞬見えません。実害は小さいのですが、気になる場合は最初のフレームだけアニメーションを無効化し、実測後に有効化して回避します。
RTLで指標がずれる
RTL(アラビア語など)では並びが反転します。React Native は flexDirection: "row" を自動でミラーリングしますが、translateX は手動計算のため符号がずれます。I18nManager.isRTL を見て、offset の向きを反転する対処が必要です。
スクリーンリーダーに「タブ」と伝わらない
見た目のタブは、スクリーンリーダーにも「タブ」として伝わる必要があります。accessibilityRole="tab" と accessibilityState={{ selected }} を欠かすと、VoiceOver では「ボタン」としか読まれず、選択状態が伝わりません。ここは公開前に必ず実機で確認しています。
体感の変化
数値化しづらい部分ですが、実機(iPhone 13 / Pixel 6)で計測した限り、この指標はUIスレッド上のワークレットで完結するため、切り替え連打でもJSスレッドのフレーム落ちは観測されませんでした。再レンダーは value 変更時のラベル色だけ。動き自体は共有値で走るので、リストのスクロール中に切り替えても引っかかりません。
小さな部品ですが、指を離した後に選択位置がスッと追いかけてくる。その一拍が、アプリ全体の丁寧さの印象を静かに底上げしてくれます。ジェスチャーと組み合わせてドラッグ追従までやりたい方は、Reanimatedとgesture-handlerで作る高度なアニメーションも併せてご覧いただければと思います。
実装の参考になれば幸いです。お読みいただき、ありがとうございました。