SwiftUI: リストのアイコンサイズ問題
リストの削除アイコンのサイズで悩んでいます。
リストを編集モードにすると、行頭にSF Symbolsでいうところの"minus.circle.fill"
アイコンがつくのですが、これが機種ごとに違うサイズで表示されます。
もう少し言うと、テキストのフォントも違うサイズで表示されます。
ですが、フォントのほうはサイズを何も指定しなければどのテキストも一律サイズが変わるのでまだよいです。
アイコンはサイズ指定しないと揃わないのでやっかいです。
iPhone 14 Pro に合わせてアイコンサイズを調整したら、Pro Maxではアイコンのサイズが違う分だけずれてしまいました。
GeometryReader
ではこのサイズ取れないし、どうしましょうか。
2023/6/3 訂正。
後続のコメントでもありますが、機種による違いは「ほとんど」なく、上記の画像での違いは「さらに大きな文字」の設定によるものが原因でした。
よくよく調べると、「設定」→「アクセシビリティ」→「さらに大きな文字」でフォントサイズを変えると、アイコンもそれにつれて大きさが変わります。
どうすっかな。
Image(systemName: "plus.circle.fill")
.resizable()
.frame(width: 16, height: 16)
.foregroundColor(.green)
とかってやっているこの.frame(width: 16, height: 16)
の部分を、
.frame(width: UIFontMetrics.default.scaledValue(for: 16),
height: UIFontMetrics.default.scaledValue(for: 16))
のようにすれば良さそうですね。.padding
なんかもこれで調整する感じかも。
ちょっとやってみます。
…これだけではダメそうですね。
「さらに大きな文字」はこれで良いのですが、機種ごとの違いが埋まらない。
訂正。
機種ごとの違いが埋まったのですが、「さらに大きな文字」の分がこちらが期待する比率になってなさそう。
あと、アプリを起動した状態で、「さらに大きな文字」のオンオフを切り替えたりしても、View
の再描画処理が走りません。
走らないんだけど、フォントの大きさは変わります。
けど、再描画処理のなかでImage
のサイズを指定していて、仮にそこにUIFontMetrics.default.scaledValue
を噛ませていたとしても、そもそも再描画処理が実行されないのでサイズの再計算も行われず、もとのサイズのままのアイコンが表示されます。
アプリを再起動すればサイズは変わって表示されます。あくまで起動したまま「さらに大きな文字」設定を変更した場合の話です。
やっかい。
もとい、で、そもそもリストにアイコンイメージを表示するときにどんなサイズが使われているのか確認することにしました。
struct ScaleView: View {
@State var text: String = ""
var body: some View {
VStack {
EditButton()
List {
ForEach(0 ..< 5, id: \.self) { idx in
if (idx == 0) {
HStack {
Image(systemName: "plus.circle.fill")
.foregroundColor(.green)
Text("Sample 1")
Spacer()
}
.font(.largeTitle)
}
else if (idx == 1) {
HStack {
Image(systemName: "plus.circle.fill")
.resizable()
.frame(width: 22, height: 22)
.foregroundColor(.green)
Text("Sample 2")
Spacer()
}
.font(.largeTitle)
}
else if (idx == 2) {
HStack {
Image(systemName: "plus.circle.fill")
.foregroundColor(.green)
Text("Sample 3")
.font(.largeTitle)
Spacer()
}
}
else if (idx == 3) {
HStack {
Image(systemName: "plus.circle.fill")
.foregroundColor(.green)
Text("Sample 4")
Spacer()
}
}
else if (idx == 4) {
HStack {
Image(systemName: "plus.circle.fill")
.foregroundColor(.green)
TextField("Sample 5", text: $text)
Spacer()
}
.font(.largeTitle)
}
}
.onMove { indexSet, index in
}
.onDelete { indexSet in
}
}
}
}
}
通常設定の場合
編集モードに入ったときの削除アイコンは、テキストのサイズによって変わったりしませんが、自前で付けたアイコンは、サイズの指定の仕方で色々と変わります。
「さらに大きな文字」設定の場合
重ねて比較
分かること
- 自動で付く削除アイコンも、大きさが変わる。
- イメージのサイズ指定したSample 2以外はアイコンのサイズが変わる。
- Sample 4なんかは元々自前の方が小さかったのに、「さらに大きな文字」だと自前のほうが大きくなる。
自動で付く削除アイコンは、テキストとは違う比率で大きくなっています。
上下左右の埋め草も多少変わっています。
ちゃんと調べると何か分かるのかしら。
自動で付く削除アイコンの大きさをいくつかの機種、「さらに大きな文字」の複数の設定で試して確認しました。
結果から言うと、どの機種でもアイコンの大きさは変わりませんでした。「さらに大きな文字」の設定だけに依存します。
XS | S | M | L | XL | XXL | XXXL | AX1 | AX2 | AX3 | AX4 | AX5 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
frameに指定するサイズ | 18 | 19 | 21.5 | 22 | 24.5 | 27 | 29 | 28 | 33 | 32 | 37.5 | 42 |
機種によってUIFontMetricsの値が違うということらしいので、編集モードにしたときに機種によってずれるのは、おそらく埋め草分がずれるのだろうと思います[1]。
途中でアイコンのサイズが逆転する(大きい文字サイズにするとアイコンが逆に小さくなる)箇所があるんですね。
↑の記事でUIFontMetricsの値が検証されていますが、アイコンは.title1
と.largeTitle
の中間ぐらいだけどもそのどちらとも違う比率で推移しています。
こんな比率、自前で持ちたくないですね。どうすっかな。
-
埋め草のずれについては後でまた調べます ↩︎
あと、アプリを起動した状態で、「さらに大きな文字」のオンオフを切り替えたりしても、Viewの再描画処理が走りません。
走らないんだけど、フォントの大きさは変わります。
けど、再描画処理のなかでImageのサイズを指定していて、仮にそこにUIFontMetrics.default.scaledValueを噛ませていたとしても、そもそも再描画処理が実行されないのでサイズの再計算も行われず、もとのサイズのままのアイコンが表示されます。
↑の記事を参考に、ビューに以下の1行を入れるとアイコンのサイズも更新されることが分かりました。
@Environment(\.sizeCategory) var sizeCategory
なんでかは分かりません。
@Environment(\.sizeCategory) var sizeCategory
はdeprecatedみたいです。
@Environment(\.dynamicTypeSize) var dynamicTypeSize
で、同様にアイコンサイズが更新されます。
UIFontMetrics.default.scaledValue
を噛ませるのと同じことを、プロパティラッパー@ScaledMetric
でできますね。
@ScaledMetric var imageSize = 22.0
と書いておくと、Dynamic Typeの設定に応じて勝手にimageSize
の値が変わります。
スケールの比率は元のフォントのサイズによって変わります。UIFontMetrics
の場合、UIFontMetrics(forTextStyle: .largeTitle).scaledVale(for: 22.0)
のような書き方で、.largeTitle
のスケーリングを使うことができますが、@ScaledMetric
の場合に同じことをするには@ScaledMetric(relativeTo: .largeTitle) var imageSize = 22.0
と書きます。
ちなみにリストの削除アイコンは、ここまで書いてきたとおり、これらのどのフォントサイズの比率とも違う比率なので、この機能を使っても一致させられません。
嫌だなと思いながらも、モディファイアを書いてしまいました。
struct DynamicTypeIconSizeModifier<IconT> : ViewModifier where IconT: View {
@Environment(\.dynamicTypeSize) var dynamicTypeSize
func body(content: Content) -> some View {
let size = self.imageSize
content
.frame(width: size, height: size)
}
var imageSize: CGFloat {
switch dynamicTypeSize {
case .xSmall: return 18
case .small: return 19
case .medium: return 21.5
case .large: return 22
case .xLarge: return 24.5
case .xxLarge: return 27
case .xxxLarge: return 29
case .accessibility1: return 28
case .accessibility2: return 33
case .accessibility3: return 32
case .accessibility4: return 37.5
case .accessibility5: return 42
default: return 22
}
}
}
extension Image {
func dynamicTypeIconSize() -> some View {
self
.resizable()
.modifier(DynamicTypeIconSizeModifier<Image>())
}
}
これを使うと以下のように書けます。
Image(systemName: "plus.circle.fill")
.dynamicTypeIconSize()
.foregroundColor(.green)
モディファイアがこの名前でいいのか分かりませんが、これでリストの削除(-)アイコンと一致するサイズの追加(+)アイコンになります。
埋め草も含めて、iPhone 14 Pro MaxとiPhone 8のシミュレータ(iOS 16.4)で調べました。
実機は違うんじゃないかというのも心配。
以下のリストが編集モードのとき、「Sample 1」の行にだけ削除ボタンが現れ、「Sample 2」の行はプラスアイコンが頭に付く、という表示になります。
List {
ForEach(0 ..< 2, id: \.self) { idx in
if (idx == 0) {
HStack {
Text("Sample 1")
Spacer()
}
}
else if (idx == 1) {
HStack {
Spacer()
.frame(width: size1)
Image(systemName: "plus.circle.fill")
.resizable()
.frame(width: size2, height: size2)
.foregroundColor(.green)
Spacer()
.frame(width: size3)
Text("Sample 2")
Spacer()
}
.deleteDisabled(true)
}
}
.onMove { indexSet, index in
}
.onDelete { indexSet in
}
}
Sample 2の行は、プラスアイコンの前後にSpacer()
で空きを取っています。
この前後の空きとアイコンサイズとを表にしました。
ピッチリ合わせにいったことで、ちょっと前回の表から若干数字が変わっています。
frameに指定するサイズ | XS | S | M | L | XL | XXL | XXXL | AX1 | AX2 | AX3 | AX4 | AX5 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
iPhone 14 Pro Max | アイコン左 | 1.25 | 1.25 | 1.25 | 1.25 | 1 | 0.5 | 1.25 | 2.25 | 3 | 2.5 | 3.25 | 3.5 |
iPhone 14 Pro Max | アイコン | 17.75 | 19 | 20.5 | 21.75 | 24.25 | 26.75 | 29.25 | 28 | 33 | 31.75 | 37 | 42 |
iPhone 14 Pro Max | アイコン右 | 17.5 | 17.5 | 17.25 | 17 | 17 | 17 | 17 | 18.25 | 19 | 18.75 | 19.75 | 19.75 |
iPhone 8 | アイコン左 | 1.5 | 1.5 | 1.5 | 1 | 1 | 1 | 1 | 2.5 | 3 | 2.5 | 2.75 | 3.5 |
iPhone 8 | アイコン | 17.75 | 19 | 20.5 | 21.5 | 24 | 26.5 | 29.25 | 28 | 33 | 31.5 | 37 | 42 |
iPhone 8 | アイコン右 | 17 | 17.5 | 17 | 17.25 | 16.75 | 17 | 17.25 | 18.5 | 19 | 18.75 | 19.5 | 19.5 |
アイコンのサイズは表の上では機種によって少し数字が違うのですが、それはスケーリングの誤差の取り扱いの違いによるもので、実体としては機種によらず同じと考えて良いのではないかと思います。
(上の表は0.25刻みで確認したのですが、実際にはそれよりちょっとずれている部分もあります)
こうやって表にしてみると、思ったよりも機種の差が少ないことに気付きます。
最初の疑問がなんだったのか、もう少し調べる必要がありそうです。
改めて最初のコードをiPhone 14 Pro/Pro Maxで実行して比較してみました。
単に機種ごとに「さらに大きな文字」の設定が違うだけでした。どちらも.large
にしたらずれはありませんでした(おそらく拡大して細かく見るとずれているんでしょうが、気付かないレベル)。
なので個人的には、1つ前の表のiPhone 14 Pro Maxの数字を使ってアイコンと前後の空白のサイズを設定して並べようと思います。
いったん、今回作った部分を載せておきます。
struct PlusListIcon: View {
@Environment(\.dynamicTypeSize) var dynamicTypeSize
var body: some View {
HStack {
let sizes = getDynamicTypeSizes()
Spacer()
.frame(width: sizes.left)
Image(systemName: "plus.circle.fill")
.resizable()
.frame(width: sizes.icon, height: sizes.icon)
.foregroundColor(.green)
Spacer()
.frame(width: sizes.right)
}
}
private func getDynamicTypeSizes() -> (left: CGFloat, icon: CGFloat, right: CGFloat) {
return DynamicTypeIconSizeHelper.getDynamicTypeSizes(dynamicTypeSize: self.dynamicTypeSize)
}
}
struct PlusListIconButton: View {
@Environment(\.dynamicTypeSize) var dynamicTypeSize
@Environment(\.isEnabled) var isEnabled
var action: () -> (Void)
var body: some View {
HStack {
let sizes = getDynamicTypeSizes()
Spacer()
.frame(width: sizes.left)
Button(action: self.action, label: {
Image(systemName: "plus.circle.fill")
.resizable()
.frame(width: sizes.icon, height: sizes.icon)
.foregroundColor(isEnabled ? .green : Color(red: 0.3, green: 0.8, blue: 0.3))
})
Spacer()
.frame(width: sizes.right)
}
}
private func getDynamicTypeSizes() -> (left: CGFloat, icon: CGFloat, right: CGFloat) {
return DynamicTypeIconSizeHelper.getDynamicTypeSizes(dynamicTypeSize: self.dynamicTypeSize)
}
}
class DynamicTypeIconSizeHelper {
static func getDynamicTypeSizes(dynamicTypeSize: DynamicTypeSize) -> (left: CGFloat, icon: CGFloat, right: CGFloat) {
let left: CGFloat
let icon: CGFloat
let right: CGFloat
switch dynamicTypeSize {
case .xSmall:
left = 1.25
icon = 17.75
right = 17.5
case .small:
left = 1.25
icon = 19
right = 17.5
case .medium:
left = 1.25
icon = 20.5
right = 17.25
case .large:
left = 1.25
icon = 21.75
right = 17
case .xLarge:
left = 1
icon = 24.25
right = 17
case .xxLarge:
left = 0.5
icon = 26.75
right = 17
case .xxxLarge:
left = 1.25
icon = 29.25
right = 17
case .accessibility1:
left = 2.25
icon = 28
right = 18.25
case .accessibility2:
left = 3
icon = 33
right = 19
case .accessibility3:
left = 2.5
icon = 31.75
right = 18.75
case .accessibility4:
left = 3.25
icon = 37
right = 19.75
case .accessibility5:
left = 3.5
icon = 42
right = 19.75
@unknown default:
left = 1.25
icon = 21.75
right = 17
}
return (left: left, icon: icon, right: right)
}
}
単にプラスアイコンのPlusListIcon
と、アイコン部分がボタンになっているPlustListIconButton
とになります。
使い方は以下のような感じです。
var body: some View {
List {
ForEach (0 ..< 3, id: \.self) { idx in
if (idx == 0) {
HStack {
Text("Text Item")
}
}
else if (idx == 1) {
HStack {
PlusListIcon()
Text("Icon, not button")
}
.deleteDisabled(true)
}
else if (idx == 2) {
HStack {
PlusListIconButton(action: {
print("add")
})
Text("Plus Button")
}
.deleteDisabled(true)
}
}
.onDelete { indexSet in }
.onMove { indexSet, index in }
}
.environment(\.editMode, .constant(.active))
}
ボタンのほうは、.disabled(true)
モディファイアをつけると、薄緑になって押せなくなります。
今回、isEnabled
というEnvironmentValues
を使いましたが、これどの子ビューまで行っているのか気になりますね。
editMode
と同様、割とややこしいことになってそう。
そんなことないのか、EditButton
みたいに別のビューじゃないから。