公開して半年ほど経った壁紙アプリに、ある日「VoiceOver で読み上げると『ボタン、ボタン、ボタン』しか言ってくれません」というレビューが届いたことがありました。星は2つ。私は2014年から個人開発でアプリを出していて、当時 iOS / Android 合わせて累計5,000万ダウンロードを少し超えたあたり——にもかかわらず、アクセシビリティの仕上げを「いつかちゃんとやる」リストに入れたまま6年以上放置していたわけです。
そのレビューをきっかけに VoiceOver を実際に当てて自分のアプリを操作してみると、視覚的には完成して見えていた画面が、音声だけでは何ひとつ意味を成していないことが分かりました。アイコンボタンには accessibilityLabel がなく、カード型のリストはタップ可能領域とラベルがバラバラに読まれ、設定画面のスイッチは状態が読み上げられありません。VoiceOver は別世界のためのものではなく、自分が普段見落としている「画面が本来伝えるべきこと」を、率直に教えてくれる検査装置だったわけです。
その後、Rork で新規にアプリを立ち上げるたびに、最初の3日間を VoiceOver と Dynamic Type の対応に丸ごと使う ようになりました。設計が固まる前に組み込んだほうが圧倒的に早く、後付けで直すよりも実装の見通しが良くなるからです。以下に、Rork で出力した React Native + Expo プロジェクトに、VoiceOver・Dynamic Type・Reduce Motion を本番品質で組み込むための実装メモを残します。公式ドキュメントには断片的にしか書かれていないラベル設計・フォーカス管理・回帰テストの落とし穴を中心に、コードと実数値で示します。
アクセシビリティ対応が個人開発の収益にも効く三つの理由
「アクセシビリティ対応は理念としては正しいけれど、個人開発の限られた時間でそこまで手を回せない」——以前の私もそう思っていました。実際に着手してみると、収益の観点でも次の3つで効いてくることが分かったので、共有しておきます。
ひとつめは App Store / Google Play の審査リスクの低下 です。Apple は明示的に拒絶することは少ないものの、Google Play の Pre-launch Report ではアクセシビリティの問題が警告として出ます。私は2024年に1本、コントラスト比と読み上げ未対応の警告だけで「審査結果待ち」が48時間延びた経験があります。リリース日を決め打ちしている個人開発では、この遅延は精神的にもけっこう堪えます。
ふたつめは Dynamic Type 対応によるレビュー評価の改善 です。日本では中高年層が壁紙アプリのコアユーザーで、システム文字サイズを「最大」または「アクセシビリティ最大」に設定している方が一定数います。私のアプリの GA4 を見ると、device.user_agent に ContentSizeCategoryAccessibility を含むセッションが約 7.2 % を占めていました。この層に対してテキストが切れていると、レビューの低評価が直に増えます。Dynamic Type 対応を一通り入れたあとの3ヶ月で、平均レビュー点が 4.2 から 4.5 に上がった経験があります。
みっつめは VoiceOver 対応がそのまま「画面構造の見直し」になる ことです。読み上げ順序を整える過程で、視覚的にはなんとなく機能していた UI のロジック不備が浮き彫りになります。冗長な領域、押せそうで押せない装飾、意味不明なアイコン——これらを潰した結果、Dynamic Type 対応・国際化(多言語化)・将来の iPadOS 対応が同時に楽になります。アクセシビリティは外向きの理念であると同時に、内向きの 設計レビューの強制装置 でもあるわけです。
VoiceOver のラベルを「視覚カード = 音声カード」で揃える
VoiceOver で読み上げが破綻する一番大きな原因は、視覚的にひとつのカードに見える要素が、音声では複数の独立した要素として読まれてしまうことです。例えば下のような商品カードを Rork が生成したとします。
// Before: 音声で4回スワイプしないとカード全体が伝わらない
< Pressable onPress = { () => navigate ( 'Detail' , { id }) } >
< Image source = { { uri: thumbnail } } style = { styles.thumb } />
< Text style = { styles.title } > { title } </ Text >
< Text style = { styles.price } > { `¥${ price . toLocaleString () }` } </ Text >
< View style = { styles.badge } >
< Text style = { styles.badgeText } >NEW</ Text >
</ View >
</ Pressable >
VoiceOver はこの中の Image・Text・Text・Text を別々の要素として認識します。スワイプ4回ぶんを使わないと「画像、タイトル、価格、NEW」と読み上げが終わりません。視覚では一目で把握できる情報なのに、音声では4ステップに分解されてしまうわけです。
これを ひとつの音声カード にまとめます。
// After: 1スワイプで意味のひと固まりとして読まれる
< Pressable
onPress = { () => navigate ( 'Detail' , { id }) }
accessible = { true }
accessibilityRole = "button"
accessibilityLabel = { `${ title }、価格 ${ price . toLocaleString () } 円${ isNew ? '、新着' : ''}` }
accessibilityHint = "ダブルタップで詳細を開きます"
>
< Image
source = { { uri: thumbnail } }
style = { styles.thumb }
accessible = { false } // 親が読み上げを担うので除外
/>
< Text style = { styles.title } accessible = { false } > { title } </ Text >
< Text style = { styles.price } accessible = { false } > { `¥${ price . toLocaleString () }` } </ Text >
{ isNew && (
< View style = { styles.badge } accessible = { false } >
< Text style = { styles.badgeText } >NEW</ Text >
</ View >
) }
</ Pressable >
ポイントは3つです。親の Pressable に accessible={true} と accessibilityLabel を集約し、子の Image と Text には accessible={false} を明示します。accessibilityRole="button" を付けることで VoiceOver が「ボタン」と末尾に読み上げてくれるため、ユーザーは操作可能な要素だと認識できます。そして accessibilityHint で「ダブルタップで何が起きるか」を補足する——Hint は VoiceOver の設定で読み上げをオフにしているユーザーには読まれないので、Label の重複にはなりません。
数値表現にも一手間かけます。¥1,200 を VoiceOver にそのまま渡すと「えん、いち、てん、にひゃく」と読まれることがあります。Apple は通貨記号を見れば賢く読んでくれる場合もありますが、Android の TalkBack 含めて挙動が安定しないので、ラベル内では日本語に書き下す のが私の本番運用です。¥${price} ではなく ${price.toLocaleString()} 円 と書きます。
カード上の小ボタンを accessibilityActions で読みやすくする
カード全体は1音声要素にまとめたいけれど、お気に入りボタンや共有ボタンといった 副次的なアクション はカード内に残したい——これがリスト UI で一番難しいところです。素直に Pressable をネストすると、VoiceOver は親と子の両方を別々に読み上げて、結局スワイプ回数が増えます。
iOS では accessibilityActions を使うと、ひとつのカード要素にカスタムアクションを束ねることができます。VoiceOver のロータースワイプで「お気に入りに追加」「共有」のような追加操作が選べるようになり、画面のスワイプ数を増やさずに副次操作を提供できます。
const cardActions = [
{ name: 'favorite' , label: isFav ? 'お気に入りから削除' : 'お気に入りに追加' },
{ name: 'share' , label: '共有' },
];
< Pressable
onPress = { () => navigate ( 'Detail' , { id }) }
accessible = { true }
accessibilityRole = "button"
accessibilityLabel = { `${ title }、価格 ${ price . toLocaleString () } 円` }
accessibilityActions = { cardActions }
onAccessibilityAction = { ( e ) => {
if (e.nativeEvent.actionName === 'favorite' ) toggleFavorite (id);
if (e.nativeEvent.actionName === 'share' ) shareItem (id);
} }
>
{ /* 中身は accessible={false} で固める */ }
</ Pressable >
これでカードあたりの音声要素は1つに保ちつつ、ロータースワイプ経由で副次操作が可能になります。Android の TalkBack は accessibilityActions を完全にはサポートしていないので、その場合は通常のサブボタンを残し、accessibilityLabel を「お気に入りに追加、ボタン」のように明示しておきます。プラットフォームで挙動が違う場合は、Platform.OS === 'ios' で分岐させて両方を実装するのが本番運用での妥協点です。
Dynamic Type に追従する文字サイズ設計の現実解
Dynamic Type は、iOS の設定 →「アクセシビリティ」→「画面表示とテキストサイズ」で 310% まで拡大 できます。React Native の Text はデフォルトで OS のフォントスケーリングに追従しますが、fontSize をハードコーディングしていると、レイアウトがすぐに崩れます。
私が個人開発の本番で採用している方針は次の3点です。第一に、フォントサイズはタイポグラフィプリセットに集約 すること。任意の数値を直接書かありません。第二に、allowFontScaling は基本的に true のままにします。第三に、maxFontSizeMultiplier に 1.6 を上限 として設定し、それ以上の拡大では UI が破綻する画面では別レイアウトに切り替える。
// src/theme/typography.ts
export const typography = {
caption: { fontSize: 12 , lineHeight: 16 , maxFontSizeMultiplier: 1.6 },
body: { fontSize: 15 , lineHeight: 22 , maxFontSizeMultiplier: 1.6 },
title: { fontSize: 20 , lineHeight: 26 , maxFontSizeMultiplier: 1.4 },
display: { fontSize: 28 , lineHeight: 34 , maxFontSizeMultiplier: 1.2 },
} as const ;
// 使い方
< Text style = { [styles.title, typography.title] } > { title } </ Text >
maxFontSizeMultiplier を 1.6 にしている理由は、 iOS の「アクセシビリティ最大」設定で約 3.1 倍まで拡大されるところを、UI が破綻しない範囲に 意図的に抑える ためです。完全に追従させると、ボタンラベルが3行になったり、リストセルが画面外にはみ出して操作不能になります。「ユーザーの設定を尊重する」のと「破綻した UI でユーザーを混乱させる」のはトレードオフで、私は破綻させないほうを優先しています。ただし accessibilityLabel 経由で VoiceOver が読み上げる内容には文字サイズ制限がかからないので、視力に依存しないユーザーには影響しません。
Dynamic Type が 拡大されていることを検出して別レイアウトに切り替える 場合は、PixelRatio.getFontScale() を使います。
import { PixelRatio } from 'react-native' ;
const fontScale = PixelRatio. getFontScale ();
const useExpandedLayout = fontScale >= 1.5 ;
return useExpandedLayout ? < VerticalCardLayout { ... props } /> : < HorizontalCardLayout { ... props } />;
fontScale >= 1.5 を閾値にしているのは、検証した結果、横並びのカードが縦並びに切り替わるのがその辺りだったからです。Rork で生成された画面の場合、useExpandedLayout 用の縦並びレイアウトを最初から1つ用意しておくと、後付けの修正が楽です。
Reduce Motion / Reduce Transparency を尊重する
VoiceOver と並んで個人開発で見落とされがちなのが、「視差効果を減らす」(Reduce Motion)と「透明度を下げる」(Reduce Transparency) の設定です。前者は前庭神経系の不調や乗り物酔いを持つユーザー、後者はコントラストの確保が必要なユーザーが利用します。
React Native では AccessibilityInfo 経由でこれらの設定を読めます。
import { AccessibilityInfo, useColorScheme } from 'react-native' ;
import { useEffect, useState } from 'react' ;
export function useAccessibilityPrefs () {
const [ reduceMotion , setReduceMotion ] = useState ( false );
const [ reduceTransparency , setReduceTransparency ] = useState ( false );
useEffect (() => {
AccessibilityInfo. isReduceMotionEnabled (). then (setReduceMotion);
AccessibilityInfo. isReduceTransparencyEnabled (). then (setReduceTransparency);
const subM = AccessibilityInfo. addEventListener ( 'reduceMotionChanged' , setReduceMotion);
const subT = AccessibilityInfo. addEventListener ( 'reduceTransparencyChanged' , setReduceTransparency);
return () => { subM. remove (); subT. remove (); };
}, []);
return { reduceMotion, reduceTransparency };
}
私はこれを 広告まわりのアニメーションに必ず適用 しています。AdMob のリワード広告を出すときに、表示前に派手なパララックスを入れていた時期があったのですが、レビューで「広告画面で気持ち悪くなる」というコメントが何件か届いたのをきっかけに、Reduce Motion が ON のユーザーには即時遷移に切り替える分岐を入れました。
const { reduceMotion } = useAccessibilityPrefs ();
function showRewardAd () {
if (reduceMotion) {
rewardedAd. show (); // アニメーションなしで即時表示
return ;
}
Animated. timing (parallaxY, { toValue: 1 , duration: 350 , useNativeDriver: true })
. start (() => rewardedAd. show ());
}
Reduce Transparency が ON のときは、半透明のオーバーレイカードを 不透明なソリッドカード に差し替えます。とくに広告ラベルや「閉じる」ボタンは半透明の上に乗せると視認性が極端に落ちるので、不透明を本番のデフォルト挙動として設計しておくのが安全です。
モーダル・スクリーン遷移のフォーカス管理
VoiceOver でいちばん利用者がストレスを感じるのは、画面遷移後に どこからフォーカスが始まるのか予測できない ことです。標準では React Navigation は遷移後の画面の先頭から自動的に読み上げを始めますが、モーダル表示や下からスライドする UI ではフォーカスが取り残されることがあります。
明示的に「ここから読み始めて」という指定が必要な場面では、AccessibilityInfo.setAccessibilityFocus を使います。
import { findNodeHandle } from 'react-native' ;
const titleRef = useRef < Text >( null );
useEffect (() => {
const node = findNodeHandle (titleRef.current);
if (node) AccessibilityInfo. setAccessibilityFocus (node);
}, []);
return (
< View >
< Text ref = { titleRef } accessibilityRole = "header" >設定</ Text >
{ /* ... */ }
</ View >
);
モーダルを閉じたあとに、モーダルを開いた元のボタンへフォーカスを戻す のも忘れがちです。React Native のモーダルは内部で別ウィンドウを作るので、閉じた瞬間にフォーカスが意図しない要素に飛ぶことがあります。モーダル側のトリガー要素に ref を保持しておき、onDismiss でフォーカスを戻すようにしています。
const triggerRef = useRef < View >( null );
< Pressable
ref = { triggerRef }
onPress = { () => setModalVisible ( true ) }
accessibilityLabel = "フィルター設定を開く"
>
< Text >フィルター</ Text >
</ Pressable >
< Modal
visible = { modalVisible }
onDismiss = { () => {
const node = findNodeHandle (triggerRef.current);
if (node) AccessibilityInfo. setAccessibilityFocus (node);
} }
>
{ /* モーダル中身 */ }
</ Modal >
この一手間で「モーダルを閉じたあとに迷子になる」という体験を一掃できます。地味ですが、VoiceOver ユーザーから届くレビューはこういう積み重ねで大きく変わる印象です。
Detox + accessibilityIdentifier で回帰を防ぐ
アクセシビリティ対応のいちばんの敵は、新機能の追加で 静かに壊れていく ことです。ある画面に新しいカードを追加したら、そのカードに accessibilityLabel を付け忘れていた——これは E2E テストで機械的に検出するのが現実的です。
私は Detox で次のような最小テストをすべての主要画面に並べています。
// e2e/home.e2e.js
describe ( 'Home screen accessibility' , () => {
beforeEach ( async () => {
await device. launchApp ({ newInstance: true });
});
it ( 'all interactive elements have accessibility labels' , async () => {
await expect ( element (by. id ( 'home-tab' ))). toBeVisible ();
// by.id は accessibilityIdentifier に対応
await expect ( element (by. id ( 'card-list-0' ))). toHaveLabel ( / . + 、価格 \d / );
await expect ( element (by. id ( 'settings-button' ))). toHaveLabel ( '設定を開く' );
});
it ( 'VoiceOver order is logical' , async () => {
// accessibilityElements の順序が視覚順序と一致しているかをテスト
const labels = await element (by. id ( 'card-list' )). getAttributes ();
expect (labels.children?. length ). toBeGreaterThan ( 0 );
});
});
すべての主要な操作要素に accessibilityIdentifier(React Native では testID)を付けるルールにしておくと、Detox から「ラベルが空でないか」をリグレッションテストとして回せます。CI 上で実機ビルドの Detox を回すのは個人開発では負担が重いので、私は週1回 GitHub Actions の cron で iOS Simulator 上の Detox を回し、toHaveLabel 系のアサーションだけはほぼ全画面で実行しています。
提出前のチェックリスト
最後に、Rork でビルドした成果物を提出する前に通している、自分用のチェックリストを置いておきます。すべて30分以内に終わる項目です。
第一に、VoiceOver をオンにしてホーム画面から末端機能まで 片手で1回操作する 。スワイプ・ダブルタップだけでタスクが完了するか確認します。途中で詰まる箇所があれば accessibilityLabel か accessibilityActions が足りていない可能性が高いです。
第二に、設定アプリでフォントサイズを「アクセシビリティ最大」にして、全タブを開く 。テキストが切れている画面、押せなくなったボタン、はみ出した要素を控えておく。maxFontSizeMultiplier を 1.6 程度に抑えていれば破綻はかなり防げます。
第三に、Reduce Motion と Reduce Transparency をオンにして、広告画面・課金画面・ガイダンス画面 の3箇所を試す。お金が動く画面で動きが激しいと心理的ハードルが大幅に上がるので、これらは特に丁寧に確認します。
第四に、Detox の toHaveLabel テストを CI で実行し、 accessibilityLabel が空になっている要素がないか機械的に検出します。
そして第五に、Pre-launch Report(Google Play)と App Store Connect のアクセシビリティ関連の警告を読む。ここで赤い警告が残っているなら、提出は延期します。レビューが48時間遅延するくらいなら、1日延ばして直したほうが結果として早く公開できる場面が多いです。
私は16歳でインターネットに触れて、独学でプログラミングを始めた1997年から、技術を「誰もが触れる自然な言語」として扱いたいと思ってきました。アクセシビリティ対応は、その願いをコードのなかで具体化する作業に近い感覚があります。離れて暮らす自分の子どもたちが大きくなったとき、視力や聴力に何か困難を抱えていても、私が作ったアプリで快適に過ごせる——そう信じられる状態でリリースしたいと、毎回思いながら作業しています。