Open8

SwiftUI: セグメントコントロールにアイコンとテキストを入れたい件

kabeyakabeya

要はPickerpickerStyle(.segmented)にしたときに、アイコン(SFSymbols)とテキストを一緒に表示したいというわけですね。

ですが結論から言うと、標準ではできません(iOS 17.5およびmacOS 14.4.1時点)。

このあとの動画を見ると分かりますが、個々のセルの中の要素としてHStackとかで並べてもだめなんですね。ImageTextを分けてセルにしてしまう。カスタムビューを作っても、中のImageTextを分けにきます。なんと強情な。

どういう仕組みなのか、各ビューから.tagの値自体を拾ってきて、でも、それを分けたセルに振り分け直すようなことをしているんですね。
動画のサンプル用のコードでは、「写真」のアイコンとテキストをHStackで囲んで、そこに.tag(0)を振り、「効果」には.tag(1)を振り、「設定」には.tag(2)を振っています。
ですが、実際の動作では、「写真」のアイコンが0、テキストが1、「効果」のアイコンが2、それ以降のセルは割当なし、というようなことになります。
ちょっと訳が分かりません。

なので、自前で作りました。(たぶん作っている人も多いんじゃないかという気はしますが)
動作の様子は以下の動画で。後ほどコードを載せます。

kabeyakabeya

コントロール自体は以下になります。

struct CustomSegmentedPicker: View {
    typealias CellInfo = (imageSystemName: String, labelText: String)
    var cells: [CellInfo]
    var cellSize: CGSize = CGSizeMake(80, 32)
    var stackAxis: Axis = .horizontal
    var cellButtonCornerRadius: CGFloat = 8
    var verticalPadding: CGFloat = 3
    @Binding var selection: Int
    
    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: cellButtonCornerRadius)
                .fill(.white)
                .frame(width: cellSize.width, height: cellSize.height)
                .offset(x: (CGFloat(selection) - CGFloat(cells.count)/2 + 0.5) * cellSize.width, y: 0)
            HStack(spacing: 0){
                ForEach(cells.indices, id: \.self) { cellIndex in
                    let cellInfo = cells[cellIndex]
                    CellButton(cellInfo: cellInfo)
                    .frame(width: cellSize.width, height: cellSize.height)
                    .clipShape(RoundedRectangle(cornerRadius: cellButtonCornerRadius))
                    .onTapGesture {
                        withAnimation {
                            self.selection = cellIndex
                        }
                    }
                    if cellIndex != cells.index(before: cells.endIndex) {
                        Divider()
                            .frame(height: cellSize.height * 0.8)
                    }

                }
            }
            .padding(3)
            .background(.black.opacity(0.05))
            .clipShape(RoundedRectangle(cornerRadius: cellButtonCornerRadius))
        }
    }
    
    @ViewBuilder
    func CellButton(cellInfo: CellInfo) -> some View {
        if stackAxis == .horizontal {
            HStack {
                Image(systemName: cellInfo.imageSystemName)
                Text(cellInfo.labelText)
                    .font(.footnote)
            }
        }
        else {
            VStack(spacing: verticalPadding) {
                Image(systemName: cellInfo.imageSystemName)
                    .font(.title2)
                    .fontWeight(.light)
                Text(cellInfo.labelText)
                    .font(.caption2)
            }
        }
    }
}
kabeyakabeya

動画のサンプル画面のコードは以下です。

MySegmentCellViewを作ったらさすがに1個になるでしょう、と思ったんですが、そうならないんですね。

struct MySegmentCellView: View {
    var body: some View {
        HStack {
            Image(systemName: "photo")
            Text("写真")
        }
    }
}

struct ContentView: View {
    @State var tab: Int = 0
    @State var selection: Int = 0
    
    var body: some View {
        TabView(selection: $tab) {
            VStack {
                HStack {
                    Text("標準のコントロール")
                        .padding()
                    Spacer()
                }
                Picker("タイプ", selection: $selection) {
                    MySegmentCellView()
                        .tag(0)
                    HStack {
                        Image(systemName: "camera.filters")
                        Text("効果")
                    }
                    .tag(1)
                    HStack {
                        Image(systemName: "slider.horizontal.3")
                        Text("設定")
                    }
                    .tag(2)
                }
                .pickerStyle(.segmented)
                .dynamicTypeSize(.large)
                
                Divider()
                
                HStack {
                    Text("自前のコントロール")
                        .padding()
                    Spacer()
                }
                CustomSegmentedPicker(cells: [
                    (imageSystemName: "photo", labelText: "写真"),
                    (imageSystemName: "camera.filters", labelText: "効果"),
                    (imageSystemName: "slider.horizontal.3", labelText: "設定")
                ], selection: $selection)
                .dynamicTypeSize(.large)
                Divider()
                
                HStack {
                    Text("縦にする場合")
                        .padding()
                    Spacer()
                }
                CustomSegmentedPicker(cells: [
                    (imageSystemName: "photo", labelText: "写真"),
                    (imageSystemName: "camera.filters", labelText: "効果"),
                    (imageSystemName: "slider.horizontal.3", labelText: "設定")
                ], cellSize: CGSizeMake(80, 48), stackAxis: .vertical, selection: $selection)
                .dynamicTypeSize(.large)
                
                Spacer()
                Text("ちなみに↓TagViewのボタン")
            }
            .padding()
            .background(Color(red: 0.8, green: 0.8, blue: 0.3))
            .tabItem {
                VStack {
                    Image(systemName: "photo")
                    Text("写真")
                }
            }.tag(0)
            Text("効果ビュー")
                .tabItem {
                    VStack {
                        Image(systemName: "camera.filters")
                        Text("効果")
                    }
                }.tag(1)
            Text("設定ビュー")
                .tabItem {
                    VStack {
                        Image(systemName: "slider.horizontal.3")
                        Text("設定")
                    }
                }.tag(2)
        }
    }
}
kabeyakabeya

ちょっと疑問なのは、

https://developer.apple.com/design/human-interface-guidelines/segmented-controls

このドキュメントに

macOS

Consider using introductory text to clarify the purpose of a segmented control. When the control uses symbols or interface icons, you could also add a label below each segment to clarify its meaning. If your app includes tooltips, provide one for each segment in a segmented control.

セグメントコントロールの目的を明らかにするために、紹介文言を使用することを検討してください。コントロールがシンボルやアイコンを使う場合、その意味を明らかにするために各セグメントの下にラベルを追加することができます。もしツールチップを含んでいるなら、セグメントコントロールの各セグメントにそれを提供してください。

単純に読むと、macOSではラベルを追加できそうなんですよね。ツールチップ云々は、ツールチップの話なのでラベルの話ではない気がしますし。
でも、macOSでも同じようにアイコンとラベルが分かれてしまいます。
なんかやり方があるんでしょうか。

kabeyakabeya

あと、同じHIGに

Prefer using either text or images — not a mix of both — in a single segmented control. Although individual segments can contain text labels or images, mixing the two in a single control can lead to a disconnected and confusing interface.

って書いてあって、これを「テキストと画像を混在させるな」と単純に読む人がいますが、そういうことではないです(たぶん)。

テキストのセグメント、画像のセグメント、テキストのセグメント、みたいにセグメントごとに表現が違うようなことをすんな、という話ですね。セグメントによってテキストだったり画像だったりするとユーザが混乱するでしょ、という。

実際は標準のPickerが、勝手に分けて混在させるような動きをするんで、開発者が混乱しているんですけどね。

一時期のmacOSのツールバーは、テキストのみ、アイコンのみ、アイコン+テキストを混在させることができていて、それはそれは気持ち悪かったのですが、最近はアイコンのみかアイコン+テキストかを選ぶ、で統一されていますね。

kabeyakabeya

でも、macOSでも同じようにアイコンとラベルが分かれてしまいます。
なんかやり方があるんでしょうか。

AppKit使えばいいという話なんですかね。できないのはSwiftUIって話で。

kabeyakabeya

デモの背景を黄緑にしているのは、標準コントロールが背景を透過表示するからそれに合わせて色味を調整した、というのが理由ですね。

kabeyakabeya

縦書きのほうは、アイコンとテキストのサイズ感をタブビューに合わせるような調整をしています。

.fontWeightがiOS16以降でしか使えないので、そこも対応OSをどうするかで調整の余地があります。
フォントサイズとかウェイトで画像のサイズを調整するのはどうなのという疑問もありますし。