商品説明の下に「続きを読む」を付けたはずが、たった一行の短い説明にまでトグルが並んでしまう。個人開発で作っていた壁紙アプリの詳細画面で、私自身このちぐはぐさに手が止まったことがあります。長い本文は畳みたい、けれど短い本文にトグルは要らない。この「出す・出さない」を、文字数のような当てにならない目安ではなく、実際に描画された行数で決めたい、というのが今回の話です。
React Native(Rork が生成する標準のアプリはこの土台の上に乗ります)の Text は、numberOfLines を指定すれば末尾を「…」で切り詰めてくれます。ただ、切り詰めが起きたかどうかを教えてはくれません。トグルを正しく出すには、レンダリングされた本文が本当に指定行数を超えているかを、こちら側で測る必要があります。
文字数で判定すると必ずどこかで外れる
最初に思いつくのは「本文が120文字を超えたらトグルを出す」といった閾値です。手軽ですが、これは早晩ずれます。日本語と英語では1行に載る文字数がまるで違いますし、絵文字や URL、改行の入り方でも変わります。同じ100文字でも、横に長い端末では2行に収まり、iPhone の小さな画面では4行になります。文字数は「画面上で何行になるか」をまったく保証しません。
私は一度この閾値方式で出して、日本語では丁度よく見えたのに、英語ロケールに切り替えた瞬間に大量の空振りトグルが出てヒヤリとしました。判定の基準を「入力の長さ」ではなく「描画の結果」に移すのが、遠回りに見えて確実でした。
onTextLayout で「描画された行数」を受け取る
React Native の Text には onTextLayout というコールバックがあります。テキストが実際にレイアウトされたあと、各行の情報を配列で渡してくれます。行数はこの配列の長さで分かります。
肝心なのは、測るときは numberOfLines を付けないことです。付けたまま測ると、レイアウト結果もその行数に丸められてしまい、「本来は5行だが3行に切られた」のか「もともと3行ちょうど」なのかを区別できません。そこで、いったん制限なしで一度だけ測り、その結果を見てトグルの要否を決める、という順番にします。
import { useState, useCallback } from 'react';
import { Text, Pressable, View, type TextLayoutEventData, type NativeSyntheticEvent } from 'react-native';
const COLLAPSED_LINES = 3;
type Props = { children: string };
export function ExpandableText({ children }: Props) {
// needsToggle: 本文が3行を超えるか(=トグルを出すか)
// measured: 一度だけ測り終えたか
const [needsToggle, setNeedsToggle] = useState(false);
const [measured, setMeasured] = useState(false);
const [expanded, setExpanded] = useState(false);
const onTextLayout = useCallback(
(e: NativeSyntheticEvent<TextLayoutEventData>) => {
if (measured) return; // 展開後の再レイアウトで上書きしない
const lineCount = e.nativeEvent.lines.length;
setNeedsToggle(lineCount > COLLAPSED_LINES);
setMeasured(true);
},
[measured],
);
return (
<View>
{/* 測定パス: 画面外に制限なしで一度だけ描画して行数を得る */}
{!measured && (
<Text
onTextLayout={onTextLayout}
style={{ position: 'absolute', opacity: 0, left: 0, right: 0 }}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
{children}
</Text>
)}
{/* 表示パス: 折りたたみ時だけ numberOfLines を効かせる */}
<Text numberOfLines={expanded ? undefined : COLLAPSED_LINES}>
{children}
</Text>
{needsToggle && (
<Pressable
onPress={() => setExpanded((v) => !v)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={expanded ? '本文を折りたたむ' : '本文の続きを読む'}
>
<Text style={{ color: '#2563eb', marginTop: 4 }}>
{expanded ? '閉じる' : '続きを読む'}
</Text>
</Pressable>
)}
</View>
);
}測定用の Text を絶対配置+透明で画面外に一度だけ置き、行数を得たら消す。表示用の Text は折りたたみ時のみ numberOfLines を効かせる。この二枚重ねが、切り詰めの有無を正しく知りつつ、ちらつきを最小に抑える形でした。測定用要素はスクリーンリーダーから隠す指定を必ず添えます。読み上げが本文を二重に拾わないためです。