SwiftUI: セグメントコントロールにアイコンとテキストを入れたい件
要はPicker
をpickerStyle(.segmented)
にしたときに、アイコン(SFSymbols)とテキストを一緒に表示したいというわけですね。
ですが結論から言うと、標準ではできません(iOS 17.5およびmacOS 14.4.1時点)。
このあとの動画を見ると分かりますが、個々のセルの中の要素としてHStack
とかで並べてもだめなんですね。Image
とText
を分けてセルにしてしまう。カスタムビューを作っても、中のImage
とText
を分けにきます。なんと強情な。
どういう仕組みなのか、各ビューから.tag
の値自体を拾ってきて、でも、それを分けたセルに振り分け直すようなことをしているんですね。
動画のサンプル用のコードでは、「写真」のアイコンとテキストをHStack
で囲んで、そこに.tag(0)
を振り、「効果」には.tag(1)
を振り、「設定」には.tag(2)
を振っています。
ですが、実際の動作では、「写真」のアイコンが0、テキストが1、「効果」のアイコンが2、それ以降のセルは割当なし、というようなことになります。
ちょっと訳が分かりません。
なので、自前で作りました。(たぶん作っている人も多いんじゃないかという気はしますが)
動作の様子は以下の動画で。後ほどコードを載せます。
コントロール自体は以下になります。
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)
}
}
}
}
動画のサンプル画面のコードは以下です。
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)
}
}
}
ちょっと疑問なのは、
このドキュメントに
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でも同じようにアイコンとラベルが分かれてしまいます。
なんかやり方があるんでしょうか。
あと、同じ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のツールバーは、テキストのみ、アイコンのみ、アイコン+テキストを混在させることができていて、それはそれは気持ち悪かったのですが、最近はアイコンのみかアイコン+テキストかを選ぶ、で統一されていますね。
でも、macOSでも同じようにアイコンとラベルが分かれてしまいます。
なんかやり方があるんでしょうか。
AppKit使えばいいという話なんですかね。できないのはSwiftUIって話で。
デモの背景を黄緑にしているのは、標準コントロールが背景を透過表示するからそれに合わせて色味を調整した、というのが理由ですね。
縦書きのほうは、アイコンとテキストのサイズ感をタブビューに合わせるような調整をしています。
.fontWeight
がiOS16以降でしか使えないので、そこも対応OSをどうするかで調整の余地があります。
フォントサイズとかウェイトで画像のサイズを調整するのはどうなのという疑問もありますし。