リアルタイムに増減する数字を表示したい、という場面は iOS アプリ開発においてよくあると思います。
例えばライブ配信サービスであれば、経過時間や視聴者数などの表示がそれに該当しますね(12:34 とか 5,678 とか)。
そのような数字を扱う場合の「工夫」について考えていくのが本記事です。
説明のため、数値をスライダーで切り替えられる UI を用意しました。
……どうですか?
気になる人もいれば気にならない人もいると思いますが、アプリ開発者の方々にはこの時点で何か引っ掛かってほしいです。
この UI の問題点は、数字の幅が細かく変化してしまっていることです。
数字がメインコンテンツであれば演出としてアリかもしれませんが、注目させたいコンテンツが他にあるのに数字がガタガタと動いてしまうと気が散ってしまいます。
このような挙動になる原因は、使用しているフォントでそれぞれの数字に異なる幅が設定されていることです。
ではどのように対処すればよいのでしょうか?
SwiftUI にはそのようなフォントに対応するための API がちゃんと存在します。
ステップ 0 - 使用しているフォントの数字を確認する
その API の紹介の前に、まずフォントの話をさせてください。
iOS の標準フォントである San Francisco では、それぞれの数字に異なる幅が設定されています。
標準フォントの数字に異なる幅が設定されているということはつまり、フォントを指定していない iOS アプリは全て本記事の対象となるということです。
ちなみに San Francisco の数字の幅はかなり意図的に設計されているようです。
https://developer.apple.com/jp/fonts/
数字はデフォルトでプロポーショナル幅になっており、日常的に使用する時間やデータの表示画面でも、調和のとれた自然な間隔で表示されます。
一方、例えば iOS に標準搭載されている Helvetica Neue というフォントでは、全ての数字が等しい幅に設定されています(これは「等幅」と呼ばれます)。
もしこのようなフォントを採用している場合は、数字の幅に悩まされることもあまりなさそうですね(もちろん、フォントの採用基準はいろいろあると思われますが)。
では、数字が等幅でないフォントを採用する場合、数字ガタつき問題にどのように対処すればよいのでしょうか。
ステップ 1 - .monospacedDigit()
.monospacedDigit() という modifier は、等幅でない数字を等幅に変換してくれます。
そんな都合の良い API が用意されているのですね。
早速、使用してみましょう。
struct CountView: View {
@State private var counter: Double = 0
var body: some View {
VStack {
Text(counter, format: .number)
.font(.system(size: 48))
.monospacedDigit()
Slider(value: $counter, in: 0...10000, step: 1)
}
}
}
数字を変化させてみると……
とても安定した見た目になりましたね。嬉しい!
これで対応が十分なケースも多いでしょう、
ステップ 2 - 余白調整
ただ、ここからもう一歩踏み込むのが本記事です。
前述の通り San Francisco の数字はあえて異なる幅に設計されているため、その幅を揃えて表示すると間隔が不自然になるのは避けられません。
(違和感ないじゃんと思う方も、次に貼る画像と見比べてみてください)
monospacedDigit() では全ての数字の幅が最も大きい値に統一されてしまうため、元々の幅が小さい文字同士だとかなり間が空いてしまいます。
数字が次々に切り替わるのならいいのですが、数字が数秒間隔で表示される場合なんかはこの不自然な間が目立ってしまいます。
じゃあどうすればいいんだという話ですが、そこで役立つのが .tracking(_:) という modifier です。
ざっくりいうと文字同士の間隔を調整するもので、正の値を与えれば間隔がより大きく、負の値を与えれば小さくなります。
今回は文字の間隔をもう少し詰めたいので、負の値を設定してみましょう。
Text(counter, format: .number)
.font(.system(size: 48))
.monospacedDigit()
.tracking(-0.8)
どうですか。まとまりが良くなったように見えませんか。
大きい文字同士は若干窮屈な印象になってしまうのですが、その分小さい文字同士の間隔が元々の設計に近づき、平均的には自然な見た目に近づいているだろう、というのが私の考えです。
視覚調整の話なので、.tracking(_:) を適用するのが必ずしも正解というわけではありません。
ただ、デザインのブラッシュアップのための手札として忍ばせておいて損はないと思います。
さて、上記の方法では数字の間隔以外も詰まってしまうのでは? と思った方もいるかもしれません。鋭いですね。
数字の間に , や . などの記号が挟まる場合、.tracking(_:) を使用するとそれらの記号と数字の間隔も変化してしまうので、場合によっては記号前後の間隔が不自然に見えてしまいます。
特に時刻表示の : が挟まるケースでは、負の値の tracking(_:) を適用すると窮屈な見た目になってしまうことが多いです。
そんなときのアプローチはシンプルです。
数字同士の間だけを詰めてあげましょう。
struct TimeView: View {
@State private var minutes: Double = 0
private var duration: Duration {
.seconds(Int(minutes) * 60)
}
private var formattedTime: String {
duration.formatted(.time(pattern: .hourMinute))
}
var body: some View {
Text(formattedTime.kerningDigitsOnly(-0.8))
.font(.system(size: 48))
.monospacedDigit()
}
}
extension String {
func kerningDigitsOnly(_ kern: CGFloat) -> AttributedString {
var result = AttributedString(self)
let chars = Array(result.characters)
var startIndex = result.characters.startIndex
for idx in chars.indices.dropLast() {
let currentChar = chars[idx]
let nextChar = chars[idx + 1]
if currentChar.isNumber, nextChar.isNumber {
let range = startIndex ..< result.characters.index(after: startIndex)
result[range].kern = kern
}
startIndex = result.characters.index(after: startIndex)
}
return result
}
}
.kerning(_:) も文字の間隔を調整するための modifier です(詳しくは後述)。
以下のような見た目になり、全体の間隔を詰める場合と比べてより違和感が軽減されているように見えます。
tracking と kerning の使い分けについて
文字の間隔を調整する modifier には .tracking(_:) と .kerning(_:) の二種類があります。
ドキュメントを読むと挙動が少しずつ異なるようですが、本記事ではその厳密な検証を行っていません。
ただタイプグラフィ用語として
- トラッキング: 文字列全体の間隔調整
- カーニング: 特定の文字と文字の間隔調整
という定義があるため、本記事でもそれに準拠して .tracking(_:) と .kerning(_:) を使い分けています。
応用編 - 等幅表示の意外な使いどころ
ここまでの内容は時間経過で切り替わる数字を対象にしてきましたが、実はそれ以外にも数字の等幅処理が有用な場面はあります。
それは数字が縦にズラッと並ぶときです。ランキング画面とかでありそうですよね。
実際に比較してみると……
どうですか、いい感じじゃないですか。
もし他にも等幅処理の使いどころを知っていれば、是非私にも教えてください🙏
さいごに - これは完璧な方法ではない
本記事で紹介した技術は、数字の見た目を調整するための一つの方法にすぎません。
意図して間隔が設計された文字に .monospacedDigit() を適用したら、その時点で何かしら歪みが生じるということは意識しておくべきだと思います。
ただ、より自然な見た目に近づけるためのアプローチはある、ということは覚えて帰ってもらえると嬉しいです![]()

