はじめに
SwiftUIで Image
と Text
を左寄せにしつつ、文字列を幅いっぱいに表示したかったのですが、うまくいきませんでした。
実際のコードです。
import SwiftUI
struct MonsterCellView: View {
var iconName: String
var name: String
var body: some View {
HStack(spacing: 32) {
Image(iconName)
.resizable()
.scaledToFit()
.frame(width: 68, height: 68)
Text(name)
.font(.title)
Spacer()
}
.padding(16)
.frame(maxWidth: .infinity)
.background(
Color(.systemBackground)
.cornerRadius(3)
.elevate(elevation: 1)
)
}
}
private extension View {
func elevate(elevation: CGFloat) -> some View {
self.shadow(
color: .init(white: 0, opacity: 0.24),
radius: elevation,
x: 0,
y: elevation
)
}
}
struct MonsterCellView_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 28) {
MonsterCellView(iconName: "uhooi", name: "uhooi")
MonsterCellView(iconName: "uhooi", name: "とてつもなく長い名前のモンスター")
}
.padding(.horizontal, 24)
}
}
プレビューです。
24行目で .padding(_:)
に 16
を指定していますが、明らかに Text
の右側は 16
ピクセル以上空いています。
この原因と解決策を考察したので紹介します。
環境
- OS:macOS Monterey 12.4
- Xcode:13.4.1 (13F100)
- Swift:5.6.1
原因
「 Text
と Spacer
の間にも HStack
の spacing
が適用されるため」です。
これで少しはわかりやすくなったはずです。
Spacer
自体のサイズは(おそらく) 0
ですが、 HStack
の spacing: 32
が Image
と Text
の間のみでなく、 Text
と Spacer
の間にも適用されてしまいます。
HStack(spacing: 32) {
Image(iconName)
.resizable()
.scaledToFit()
.frame(width: 68, height: 68)
// ここに余白が `32` 入る
Text(name)
.font(.title)
// !!!: ここにも余白が `32` 入る
Spacer()
}
いわゆる「 Spacer
の罠」です。
Spacer
はViewを詰めるのに便利ですが、仕様を理解していないとつまづきます。
解決策
解決策をTwitterでいろいろ教えていただいたので紹介します。
1. HStackのspacingを0にして、幅固定のSpacerを入れる
おそらく最もシンプルな解決策です。
HStack
の spacing
を 32
から 0
にして、 Image
と Text
の間に幅 32
の Spacer
を入れます。
import SwiftUI
struct MonsterCellView: View {
var iconName: String
var name: String
var body: some View {
- HStack(spacing: 32) {
+ HStack(spacing: 0) {
Image(iconName)
.resizable()
.scaledToFit()
.frame(width: 68, height: 68)
+ Spacer().frame(width: 32)
Text(name)
.font(.title)
Spacer()
}
.padding(16)
.frame(maxWidth: .infinity)
.background(
Color(.systemBackground)
.cornerRadius(3)
.elevate(elevation: 1)
)
}
}
// ...
先ほどより Text
の右側の余白が小さくなったことがわかります。
ただ HStack
の spacing
は本来 0
にしたくないので、可読性は落ちてしまいます。
あと Spacer
に .frame(width:)
で幅を指定しても、必ずそのサイズに固定されるわけではないかもしれません。
Spacer
の仕様に詳しい方がいたら教えていただきたいです
2. HStackのspacingを0にして、Imageの右側に余白を付ける
解決策1とほぼ同じです。
幅固定の Spacer
を入れる代わりに、 Image
の右側に .padding(_:)
を付けます。
import SwiftUI
struct MonsterCellView: View {
var iconName: String
var name: String
var body: some View {
- HStack(spacing: 32) {
+ HStack(spacing: 0) {
Image(iconName)
.resizable()
.scaledToFit()
.frame(width: 68, height: 68)
+ .padding(.trailing, 32)
Text(name)
.font(.title)
Spacer()
}
.padding(16)
.frame(maxWidth: .infinity)
.background(
Color(.systemBackground)
.cornerRadius(3)
.elevate(elevation: 1)
)
}
}
// ...
好みの問題かもしれませんが、「View間の余白」というより「Image右の余白」という意図が強くなってしまいます。
3. HStackを入れ子にする
spacing
が 0
と 32
の HStack
を用意して、入れ子にします。
import SwiftUI
struct MonsterCellView: View {
var iconName: String
var name: String
var body: some View {
- HStack(spacing: 32) {
- Image(iconName)
- .resizable()
- .scaledToFit()
- .frame(width: 68, height: 68)
- Text(name)
- .font(.title)
+ HStack(spacing: 0) {
+ HStack(spacing: 32) {
+ Image(iconName)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 68, height: 68)
+ Text(name)
+ .font(.title)
}
Spacer()
}
.padding(16)
.frame(maxWidth: .infinity)
.background(
Color(.systemBackground)
.cornerRadius(3)
.elevate(elevation: 1)
)
}
}
// ...
解決策1や2より意図が明確になり、可読性は高いと思います。
しかし HStack
が入れ子になるのが気になります。
4. Spacerを消してframeにalignmentを付ける
個人的に最も好みな解決策です。
Spacer()
を削除し、 .frame()
に alignment: .leading
を付けます。
import SwiftUI
struct MonsterCellView: View {
var iconName: String
var name: String
var body: some View {
HStack(spacing: 32) {
Image(iconName)
.resizable()
.scaledToFit()
.frame(width: 68, height: 68)
Text(name)
.font(.title)
- Spacer()
}
.padding(16)
- .frame(maxWidth: .infinity)
+ .frame(maxWidth: .infinity, alignment: .leading)
.background(
Color(.systemBackground)
.cornerRadius(3)
.elevate(elevation: 1)
)
}
}
// ...
.frame()
に alignment
を指定すると、内部のViewを寄せられることを初めて知りました。
特にデメリットがなく、 .leading
を指定して左寄せなことを明示的にしているので、スマートな解決策だと思います。
ちなみに .frame()
を HStack
でなく Text
に付けてもほぼ同じ結果が得られます。
import SwiftUI
struct MonsterCellView: View {
var iconName: String
var name: String
var body: some View {
HStack(spacing: 32) {
Image(iconName)
.resizable()
.scaledToFit()
.frame(width: 68, height: 68)
Text(name)
.font(.title)
+ .frame(maxWidth: .infinity, alignment: .leading)
}
.padding(16)
- .frame(maxWidth: .infinity, alignment: .leading)
.background(
Color(.systemBackground)
.cornerRadius(3)
.elevate(elevation: 1)
)
}
}
// ...
どちらでもよさそうですが、 .frame()
を Text
に付けるほうが「文字列が幅いっぱいに膨らむ」ことがわかるのでいいかもしれません。
私は Text
に付けるほうが好みです。
5. LabelとLabelStyleを使ってスタイルを外出しする
@noppefoxwolf さんに本記事のアンサー記事を書いてもらい、そこで紹介されていた方法です。
Image
と Text
に関連性がある場合、 Label
としてまとめることで可読性を上げられます。
LabelStyle
を使ってスタイルを外出しすることで、HTMLとCSSのように構造と装飾を分離することもできます。
解決策1〜4のどれかと組み合わせる必要があるので、私は4の後者と組み合わせました。
4の後者との差分を載せます。
import SwiftUI
struct MonsterCellView: View {
var iconName: String
var name: String
var body: some View {
- HStack(spacing: 32) {
- Image(iconName)
- .resizable()
- .scaledToFit()
- .frame(width: 68, height: 68)
- Text(name)
- .font(.title)
- .frame(maxWidth: .infinity, alignment: .leading)
- }
+ Label {
+ Text(name)
+ } icon: {
+ Image(iconName)
+ .resizable()
+ .labelStyle(MonsterCellLabelStyle())
.padding(16)
.background(
Color(.systemBackground)
.cornerRadius(3)
.elevate(elevation: 1)
)
}
}
+ private struct MonsterCellLabelStyle: LabelStyle {
+ func makeBody(configuration: Configuration) -> some View {
+ HStack(spacing: 32) {
+ configuration.icon
+ .scaledToFit()
+ .frame(width: 68, height: 68)
+ configuration.title
+ .font(.title)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ }
+
// ...
Image
と Text
に関連性があることがひと目でわかり、さらに可読性が上がったと思います。
LabelStyleの詳細は以下の記事をご参照ください。
おまけ
おまけです。
1. SpacerにminLengthを指定できる
Spacer
には minLength
を指定できます。
公式ドキュメントにも書いてある通り、 minLength
のデフォルト値は nil
で、 nil
の場合はシステムのデフォルト値が入ります。(それがいくつなのかはわかりませんw)
なので Spacer
の長さを確実に 0
にしたいときは、 Spacer(minLength: 0)
と書くべきです。
2. Spacerは使わないほうがいい?
Spacer()
はできる限り使わないほうが実装ミスが減り、可読性も高くなると思いました。
使いどころとしては、単に全Viewを左(右)寄せにするときではなく、「画像は左寄せ、文字列は右寄せ」など、他の方法だと複雑になるパターンのときに使うとよさそうです。
HStack {
Image(iconName)
.resizable()
.scaledToFit()
.frame(width: 68, height: 68)
Spacer() // !!!: このような場合に `Spacer()` を使うとよさそう
Text(name)
.font(.title)
}
Spacer
の使いどころに詳しい方がいたら教えていただきたいです
2022/07/13, 追記
Twitterで「Spacerはその名の通り、何かの間のスペースを埋めるために使う」と教えていただきました。
なので私が紹介したようなパターンのときに使うので合っていそうです。
おわりに
文字列を左寄せにする方法を考察しました。
みなさんがオススメの解決策はどれでしょうか?
また他に解決策はあるでしょうか?
ぜひコメントなどで教えてください。
今回の考察を通して、比較的シンプルなViewを作るのにも様々なパターンがあることを知り、SwiftUIは可読性を高めるのが難しいと実感しました。
読み手が意図を汲みやすい実装ができるよう、SwiftUI力を高めていきたいです。