Open15

SwiftUI: リストのアイコンサイズ問題

kabeyakabeya

リストの削除アイコンのサイズで悩んでいます。

リストを編集モードにすると、行頭にSF Symbolsでいうところの"minus.circle.fill"アイコンがつくのですが、これが機種ごとに違うサイズで表示されます。
もう少し言うと、テキストのフォントも違うサイズで表示されます。
ですが、フォントのほうはサイズを何も指定しなければどのテキストも一律サイズが変わるのでまだよいです。
アイコンはサイズ指定しないと揃わないのでやっかいです。

iPhone 14 Pro に合わせてアイコンサイズを調整したら、Pro Maxではアイコンのサイズが違う分だけずれてしまいました。

GeometryReaderではこのサイズ取れないし、どうしましょうか。


2023/6/3 訂正。
後続のコメントでもありますが、機種による違いは「ほとんど」なく、上記の画像での違いは「さらに大きな文字」の設定によるものが原因でした。

kabeyakabeya

よくよく調べると、「設定」→「アクセシビリティ」→「さらに大きな文字」でフォントサイズを変えると、アイコンもそれにつれて大きさが変わります。

どうすっかな。

kabeyakabeya
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なんかもこれで調整する感じかも。
ちょっとやってみます。

kabeyakabeya

…これだけではダメそうですね。
「さらに大きな文字」はこれで良いのですが、機種ごとの違いが埋まらない。


訂正。
機種ごとの違いが埋まったのですが、「さらに大きな文字」の分がこちらが期待する比率になってなさそう。

kabeyakabeya

あと、アプリを起動した状態で、「さらに大きな文字」のオンオフを切り替えたりしても、Viewの再描画処理が走りません。
走らないんだけど、フォントの大きさは変わります。

けど、再描画処理のなかでImageのサイズを指定していて、仮にそこにUIFontMetrics.default.scaledValueを噛ませていたとしても、そもそも再描画処理が実行されないのでサイズの再計算も行われず、もとのサイズのままのアイコンが表示されます。

アプリを再起動すればサイズは変わって表示されます。あくまで起動したまま「さらに大きな文字」設定を変更した場合の話です。

やっかい。

kabeyakabeya

もとい、で、そもそもリストにアイコンイメージを表示するときにどんなサイズが使われているのか確認することにしました。

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なんかは元々自前の方が小さかったのに、「さらに大きな文字」だと自前のほうが大きくなる。

自動で付く削除アイコンは、テキストとは違う比率で大きくなっています。
上下左右の埋め草も多少変わっています。

ちゃんと調べると何か分かるのかしら。

kabeyakabeya

自動で付く削除アイコンの大きさをいくつかの機種、「さらに大きな文字」の複数の設定で試して確認しました。
結果から言うと、どの機種でもアイコンの大きさは変わりませんでした。「さらに大きな文字」の設定だけに依存します。

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]

途中でアイコンのサイズが逆転する(大きい文字サイズにするとアイコンが逆に小さくなる)箇所があるんですね。

https://spinners.work/posts/kudakurage-ios-dynamic-type-design/

↑の記事でUIFontMetricsの値が検証されていますが、アイコンは.title1.largeTitleの中間ぐらいだけどもそのどちらとも違う比率で推移しています。

こんな比率、自前で持ちたくないですね。どうすっかな。

脚注
  1. 埋め草のずれについては後でまた調べます ↩︎

kabeyakabeya

あと、アプリを起動した状態で、「さらに大きな文字」のオンオフを切り替えたりしても、Viewの再描画処理が走りません。
走らないんだけど、フォントの大きさは変わります。
けど、再描画処理のなかでImageのサイズを指定していて、仮にそこにUIFontMetrics.default.scaledValueを噛ませていたとしても、そもそも再描画処理が実行されないのでサイズの再計算も行われず、もとのサイズのままのアイコンが表示されます。

https://www.hackingwithswift.com/quick-start/swiftui/how-to-use-dynamic-type-with-a-custom-font

↑の記事を参考に、ビューに以下の1行を入れるとアイコンのサイズも更新されることが分かりました。

@Environment(\.sizeCategory) var sizeCategory

なんでかは分かりません。

kabeyakabeya

@Environment(\.sizeCategory) var sizeCategoryはdeprecatedみたいです。

@Environment(\.dynamicTypeSize) var dynamicTypeSizeで、同様にアイコンサイズが更新されます。

kabeyakabeya

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と書きます。

ちなみにリストの削除アイコンは、ここまで書いてきたとおり、これらのどのフォントサイズの比率とも違う比率なので、この機能を使っても一致させられません。

kabeyakabeya

嫌だなと思いながらも、モディファイアを書いてしまいました。

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)

モディファイアがこの名前でいいのか分かりませんが、これでリストの削除(-)アイコンと一致するサイズの追加(+)アイコンになります。

kabeyakabeya

埋め草も含めて、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刻みで確認したのですが、実際にはそれよりちょっとずれている部分もあります)

こうやって表にしてみると、思ったよりも機種の差が少ないことに気付きます。

最初の疑問がなんだったのか、もう少し調べる必要がありそうです。

kabeyakabeya

改めて最初のコードをiPhone 14 Pro/Pro Maxで実行して比較してみました。

単に機種ごとに「さらに大きな文字」の設定が違うだけでした。どちらも.largeにしたらずれはありませんでした(おそらく拡大して細かく見るとずれているんでしょうが、気付かないレベル)。

なので個人的には、1つ前の表のiPhone 14 Pro Maxの数字を使ってアイコンと前後の空白のサイズを設定して並べようと思います。

kabeyakabeya

いったん、今回作った部分を載せておきます。

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)モディファイアをつけると、薄緑になって押せなくなります。

kabeyakabeya

今回、isEnabledというEnvironmentValuesを使いましたが、これどの子ビューまで行っているのか気になりますね。
editModeと同様、割とややこしいことになってそう。

そんなことないのか、EditButtonみたいに別のビューじゃないから。