- バックエンド
- PdM
- 急成長中の福利厚生SaaS
- Other occupations (24)
- Development
- Business
- Other
AndroidとFigmaの「1px」のズレに対処する
Photo by William Warby on Unsplash
Mobile Tech Leadの久保出です。
今回は、Jetpack Composeにおけるプラットフォームと独自のデザインシステムとのギャップを解決する話になります。
目次
デザインシステムにおけるタイポグラフィの重要性
直面した課題
原因
解決策
まとめ
デザインシステムにおけるタイポグラフィの重要性
私たちのチームでは、プロダクト開発の品質と効率を向上させるために、内製のデザインシステムを構築・運用しています。このデザインシステムには、カラーパレットやコンポーネントの状態定義と並んで、タイポグラフィに関する厳密な定義が存在します。
タイポグラフィは、ユーザーが情報を正しく受け取るための基盤となる要素です。定義されたフォントやフォントサイズ、行の高さ(Line Height)、文字間隔などがUI全体で一貫して使われることで、プロダクトの世界観が統一され、ユーザーは直感的に情報を理解しやすくなります。エンジニアとして、このデザイン定義を忠実に再現することが、デザイナーの意図を汲み取り、デザインの価値を最大限に引き出す上で非常に重要だと考えています。
直面した課題
しかし、このタイポグラフィをJetpack Composeで実装する過程で、ある課題に直面しました。特定の小さなフォントサイズにおいて、Figmaのデザイン定義とAndroidアプリ上での表示に、微妙な高さのズレが発生してしまったのです。
具体的には、Figma上で Font size: 12px, Line height: 16px と定義されているタイポグラフィを、ComposeのTextコンポーネントで同じ数値を指定して実装したところ、実際のテキストコンポーネントの高さが 17px(Androidではdp) になってしまう、という問題でした。
不思議なことに、この1ptのズレは、テキストに日本語が含まれる場合にのみ発生し、アルファベットのみの場合はFigmaの定義通り16pxで表示されます。最初はわずかな差だと感じましたが、この小さなズレが積み重なることで、画面全体のレイアウトに無視できない影響を与えかねません。
原因
この現象を詳しく調査した結果、原因はFigmaとAndroidで使用されている日本語フォントが異なることにありました。
私たちのデザインシステムでは、欧文フォントとして Lato を指定しています。しかし、Latoは日本語の文字を含んでいません。そのため、テキストに日本語の文字が含まれると、フォールバックとして別のフォントを利用します。
Figmaではフォールバックとして Hiragino を指定してデザインを作成していました。Androidアプリ上では、指定がなければ自動的にシステムのフォールバックフォント(多くのAndroidデバイスでは Noto Sans CJK)が使用されます。
問題は、HiraginoとNoto Sans CJKでは、フォント固有のパディング情報が異なる点です。Figma上では Hiragino のメトリクスを基準に Line Height が設計されていますが、Android上ではNoto Sans CJKのメトリクスで描画されます。そのため、同じ Line height を指定しても、フォントが持つ固有の余白の違いが、最終的なコンポーネントの高さのズレとして現れていました。
そもそも、私たちのデザインシステムのタイポグラフィ定義自体が、 Font size に対して Line height を非常に小さく設定している、という問題もあります。この設定が、フォント固有のパディング問題をより顕在化させやすい状況を生んでいました。
かといって、Figma側で使われている Hiragino はライセンスの都合上、Androidアプリにバンドルする選択を取れません。また、デザインシステム全体の Line height の定義を見直すことも、プロダクト全体への影響が大きいため現実的ではありませんでした。
解決策
いくつかの解決策を探し、 includeFontPadding や lineHeightStyle などのAPIも検証しましたが、フォント自体を変更しない限り、フォントそのものが持つパディングを削ることはできませんでした。
Androidプラットフォームの挙動を変更することは難しいため、この問題には暫定的な対処を行いました。標準の Text コンポーネントを直接使うのではなく、高さ固定の Box でラップするという手法です。以下はその実装です。
@Composable
internal fun SingleLineText(
text: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current,
) {
var height by remember { mutableStateOf(Dp.Unspecified) }
val density = LocalDensity.current
val boxModifier = if (height.isSpecified) modifier.height(height) else modifier
Box(modifier = boxModifier.semantics(mergeDescendants = true) {}) {
Text(
text = text,
color = color,
overflow = overflow,
maxLines = maxLines,
modifier = Modifier.wrapContentHeight(unbounded = true),
onTextLayout = {
val styleLineHeight = style.lineHeight
height = if (it.lineCount == 1 && styleLineHeight.isSpecified) {
with(density) { styleLineHeight.toDp() }
} else {
Dp.Unspecified
}
onTextLayout(it)
},
style = style,
)
}
}Text には、 textAlign による位置調整も可能ですが、 textAlign は Text 自体のサイズがレンダリングされるテキストより大きい場合にしか効果がなく、今回は利用できません。
このコンポーネントを問題のあるテキスト表示に対して利用することにしました。これにより、タイポグラフィによるズレを解消しました。
まとめ
デザインシステムと、Androidのようなプラットフォームの挙動との間に不一致が生まれることは、開発において決して珍しいことではありません。そのような時、プラットフォームの仕様だからと妥協するのは簡単です。
しかし、デザイナーが1px単位で調整したデザインには、ユーザー体験を向上させるための明確な意図が込められています。その意図を正確に汲み取り、プラットフォームの制約を超える工夫を凝らして実装することこそが、デザインの価値を最大化し、最終的により良いプロダクトをユーザーに届けることに繋がると信じています。
今後も私たちは、このような細部へのこだわりを持ち続け、デザインとエンジニアリングの力を融合させて、プロダクトの価値を高めていきたいと考えています。