[SwiftUI] `buttonStyle`を支える技術
SwiftUIを使っているとしばしばお世話になるbuttonStyleですが、その動きはよく考えると不思議です。
VStack {
Button("Sun", systemImage: "sun.fill") {}
Button("Moon", systemImage: "sun.fill") {}
Button("Star", systemImage: "star.fill") {}
}
.buttonStyle(BorderedButtonStyle())

まず、BorderedButtonStyleという型の値をスタイルとして渡していますが、BorderedProminentButtonStyleのような異なる型の値を渡すこともできます。このように色々な型が渡されますが、内部的にはどのように処理されているのでしょうか。
Button("Globe", systemImage: "globe") {}
.buttonStyle(BorderedProminentButtonStyle())

それから、なぜVStackのメソッドとして呼び出したbuttonStyleが全てのボタンに反映されるのでしょうか。
最後にBorderedProminentButtonStyle()のように明示的にイニシャライザを呼ぶ代わりに.borderedProminentを使うこともできます。これは一体どのような記法でしょうか。
Button("Globe", systemImage: "globe") {}
.buttonStyle(.borderedProminent) // これは何

この記事ではButtonStyleのように振る舞う「ClockStyle」を作っていくことで、buttonStyleを支えるSwiftの言語機能を見ていきます。
ClockView
今回作るのはClockViewとClockStyleです。ClockView()は時計を表示し、ClockStyleはそのスタイルを指定します。まずはバージョン1として以下のようにClockViewを作りました。まだ、スタイルはありません。
struct ClockView: View {
var body: some View {
TimelineView(.periodic(from: .now, by: 0.25)) { info in
Text(info.date.description)
}
}
}
これで非常にシンプルな時計ができました。

ClockStyle
ClockViewのスタイルを表現したいわけですが、どうすればいいでしょうか。ButtonStyleを調べてみましょう。
ドキュメントによると、ButtonStyleはプロトコルであり、func makeBody(configuration: ButtonStyleConfiguration) -> some Viewなるメソッドが必要です。引数のconfigurationはボタンに関する情報が含まれていて、labelとisPressedとroleにアクセスすることができます。これを元にボタンのbodyを組み立てるわけです。
ClockStyleも同様にしましょう。プロトコルとしてfunc makeBody(configuration: ClockStyleConfiguration) -> some Viewを作ります。ClockStyleConfigurationはとりあえず表示してほしい時刻を入れておきます。
protocol ClockStyle {
associatedtype Body: View
@ViewBuilder func makeBody(configuration: ClockStyleConfiguration) -> Body
}
struct ClockStyleConfiguration {
var date: Date
}
スタイルの定義ができたので、デフォルトスタイルを用意しておきましょう。
struct DefaultClockStyle: ClockStyle {
var format: Date.FormatStyle
init(format: Date.FormatStyle = .dateTime) {
self.format = format
}
func makeBody(configuration: ClockStyleConfiguration) -> some View {
Text(configuration.date, format: format)
.monospacedDigit()
.monospaced()
.padding()
.background {
RoundedRectangle(cornerRadius: 30)
.fill(.thinMaterial)
}
}
}
スタイルをClockViewにあてたいですが、今のところ方法がありません。一旦ClockViewをジェネリックな型にした上で直接渡すことにしましょう。
struct ClockView<S: ClockStyle>: View {
var style: S
init(style: S) {
self.style = style
}
var body: some View {
TimelineView(.periodic(from: .now, by: 0.25)) { info in
style.makeBody(configuration: ClockStyleConfiguration(date: info.date))
}
}
}
これで、次のようにするとスタイルが適用されるようになりました。
ClockView(style: DefaultClockStyle())

とてもいい感じです!
型を消す
今のところClockStyleはClockViewの型パラメータになっていて、このおかげでスタイルを自由に変更できます。
しかし、Buttonのドキュメントを見てみると、ButtonStyleはButtonの型パラメータではありません。もしもButtonStyleがそのままの型でButtonに渡されているのなら、型が明示的に注入されている必要があります。従って、ここにはトリックがあるはずです。
ここで使われているテクニックは型消去です。これはプロトコルに準拠した型を1つの型にまとめてしまう手法です。
伝統的にはAnyClockStyleを作るのですが、最近はany ClockStyleでも十分です。
`AnyClockStyle`が必要になるケース
「今回は」と書いたのは、any ClockStyleではうまくいかない場面もあるということです。本文でも述べたとおりany ClockStyleそのものはClockStyleに適合しないので、ClockStyleに適合した型が必要なジェネリック型が要求される場合などには使えません。
struct Foo<S: ClockStyle> {}
var clockStyle = DefaultClockStyle()
// Foo<any ClockStyle>は不可
var foo = Foo<any ClockStyle>(clockStyle)
このケースではAnyClockStyleを用意する必要があります。これは伝統的に型消去(Type Erasure)と呼ばれているパターンです。
struct AnyClockStyle: ClockStyle {
var _makeBody: (ClockStyleConfiguration) -> AnyView
init(_ style: some ClockStyle) {
self._makeBody = { AnyView(style.makeBody($0)) }
}
func makeBody(configuration: ClockStyleConfiguration) -> some View {
_makeBody()
}
}
var clockStyle = DefaultClockStyle()
// Foo<AnyClockStyle>は使える
var foo = Foo<AnyClockStyle>(AnyClockStyle(clockStyle))
ただ、今回のケースではこういう面倒なことをしなくても十分使えるものができるので、わざわざAnyClockStyleを作ることはしません。
以下のようにClockViewを書き直します。styleがany ClockStyleになり、任意のClockStyleを受け付けてくれます。これにより<S: ClockStyle>が必要なくなりました。
struct ClockView: View {
var style: any ClockStyle
init(style: some ClockStyle) {
self.style = style
}
var body: some View {
TimelineView(.periodic(from: .now, by: 0.25)) { info in
AnyView(
style.makeBody(configuration: ClockStyleConfiguration(date: info.date))
)
}
}
}
さて、これで動くのですが、この部分の実際の動作はやや複雑なので、少しだけ補足しておきます。ちょっと難しくなります。
style.makeBody(configuration: ...)
ここではany ClockStyleに対してその関連型Body: Viewを返すようなメソッドが呼び出されています。このとき、戻り値の型は自動的にBody: Viewからany Viewにラップされます。つまりstyle.makeBodyは(ClockStyleConfiguration) -> any Viewです。
しかし、以下のように書いてもエラーになります。
TimelineView(.periodic(from: .now, by: 0.25)) { info in
style.makeBody(configuration: ClockStyleConfiguration(date: info.date))
}
これはなぜかというと、any ViewはViewではないからです。any Viewはあくまで任意のViewに準拠した型を代入できるような型にすぎず、それ自体がViewの制約を満たしているわけではありません。実際、Viewに必要なassociatedtypeであるBodyをany Viewは持っていないので、(any View).Bodyのように関連型を取り出そうとしてもエラーになります。
そこで登場するのがAnyViewです。このイニシャライザは以下のように定義されています。
init<V>(_ view: V) where V : View
上記の説明を読むとany Viewをこのイニシャライザに渡せるというのは矛盾するように思えてくるのではないでしょうか。initはViewに適合した型を要求していますから、any Viewを渡すのは無理そうです。
ここで動いているのがSwift 5.7で導入されたImplicitly Opened Existentialsです。
この機能の詳細な仕様は少し複雑なのですが、ざっくりいうと、any Pはそのままではジェネリック関数に渡せないけど、その中身から暗黙に実際の型を取り出すことでジェネリック関数に渡せるようにする、という機能です。この機能は完全に暗黙に動くので、気づかないうちにあちこちで効いています。
つまりどういうことかというと、any ViewをAnyView(_:)に渡すとき、勝手にany Viewの実際の型(例えば、Button)が取り出され、あたかもButtonを渡したかのように振る舞う、ということです。助かりますね。
というわけで、AnyViewのイニシャライザにany Viewを渡すことで、ようやくViewに適合した型を手に入れて、ここまで完成です。
以上でClockViewからClockStyleへの依存を除去することができました。疑問その1は解決です。様々な型でButtonStyleが定義されていても、内部的に型が消去されるので問題なく動くのです。
まず、
BorderedButtonStyleという型の値をスタイルとして渡していますが、BorderedProminentButtonStyleのような異なる型の値を渡すこともできます。このように色々な型が渡されますが、内部的にはどのように処理されているのでしょうか。
しかし、まだClockStyleを明示的にイニシャライザに渡さなければいけません。次はこれを解消していきます。
Environment
SwiftUIの仕組みにEnvironmentがあります。これはビューに環境変数のような値を流し込む仕組みで、親ビューに割り当てた値が自動的に子、孫に伝播していきます。例えばカラースキームはよく利用されていて、Viewの側で@Environment(\.colorScheme) var colorSchemeと書くことで親から流れてきたカラースキームの値を取得することができます。逆に子に新しいカラースキームを指定するにはchildView.environment(\.colorScheme, .dark)のように書くことができて、しばしばXcode Previewsなどで用いられています。
これを使ってany ClockStyleを外部から注入しましょう。新しいEnvironmentを定義する方法はドキュメントに記載されているので、この通りにします。
struct ClockStyleEnvironmentKey: EnvironmentKey {
typealias Value = any ClockStyle
static var defaultValue: any ClockStyle = DefaultClockStyle()
}
extension EnvironmentValues {
var clockStyle: any ClockStyle {
get {
self[ClockStyleEnvironmentKey.self]
}
set {
self[ClockStyleEnvironmentKey.self] = newValue
}
}
}
ClockViewの方は次のようにします。
struct ClockView: View {
@Environment(\.clockStyle) private var style: any ClockStyle
var body: some View {
TimelineView(.periodic(from: .now, by: 0.25)) { info in
AnyView(style.makeBody(configuration: ClockStyleConfiguration(date: info.date)))
}
}
}
これで、次のようにするとスタイルが適用されるようになりました。イニシャライザも不要になりました!
ClockView()
.environment(\.clockStyle, DefaultClockStyle())

しかし、environment(\.clockStyle, ...)といちいち書くのはやや面倒です。そこで以下のextensionを定義しておきましょう。
extension View {
func clockStyle(_ style: some ClockStyle) -> some View {
self.environment(\.clockStyle, style)
}
}
これで以下のように書けます。かなりbuttonStyle(_:)っぽくなりました!
ClockView()
.clockStyle(DefaultClockStyle())
ところで、実はこれで2つ目の疑問も解決しています。environmentを使えば親ビューから子ビュー全てにスタイルを渡せるのです。
それから、なぜ
VStackで呼び出したbuttonStyleが全てのボタンに反映されるのでしょうか。
実際、次のように書くと
VStack {
ClockView()
ClockView()
}
.clockStyle(DefaultClockStyle())
このように表示されます🎉

clockStyle(.default)を実現する
では、最後の疑問であるこいつを倒していきましょう。
最後に
BorderedProminentButtonStyle()のように明示的にイニシャライザを呼ぶ代わりに.borderedProminentを使うこともできます。これは一体どのような記法でしょうか。
実際、ボタンの場合は例えば以下のように書くことができます。
VStack {
Button(...)
Button(...)
Button(...)
}
.buttonStyle(.bordered)
一見するとButtonStyleがenumで、そのcaseがborderedかと思ってしまいそうですが、すでに見た通りそんなことはありません。buttonStyle(_:)の定義を見てみましょう。
func buttonStyle<S: ButtonStyle>(_ style: S) -> some View
つまり、.borderedはBorderedButtonStyle型で、.defaultはDefaultButtonStyleなのです。そんなことがあり得るのでしょうか。例えば以下のようなメンバーを定義しても、buttonStyle(_:)にはうまく渡せません。
extension BorderedButtonStyle {
static var bordered: Self { Self() }
}
結論から言うと、これはそういう機能です。Swift 5.5で導入され、プロトコルを特別なパターンで拡張することでこのような記法が可能になります。
// `ButtonStyle`に対して`extension`を書くが、
// `where`で`Self`が`BorderedButtonStyle`に一致するときに限定する
extension ButtonStyle where Self == BorderedButtonStyle {
static var bordered: Self { Self() }
}
ClockStyleでも同じことをやってみましょう。
extension ClockStyle where Self == DefaultClockStyle {
static var `default`: Self { Self() }
}
これでこう書けます。
ClockView()
.clockStyle(.default)

ようやくbuttonStyle(_:)のような書き心地がclockStyle(_:)でも実現できました!
まとめ
この記事ではここ数年で導入されたSwiftの新機能に触れつつbuttonStyleの仕組みを示しました。SwiftUIは新しい言語機能を次々と利用していて、非常に良い勉強材料だと思います。
おまけ
では、最後にSF Symbolsのようないい感じのClockStyleを作ります。
struct SymbolFilledClockStyle: ClockStyle {
@State private var calendar = Calendar.current
private var handWidthScale: CGFloat = 0.075
private var handCornerRadius: CGFloat = 1/16
private var clockBorderWidth: CGFloat = 1.5
struct Hand: Shape {
var radiusScale: CGFloat
/// 半径を1とする
var lengthScale: CGFloat
var strokeScale: CGFloat
func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize {
let size = proposal.replacingUnspecifiedDimensions()
let minSize = min(size.width, size.height)
return CGSize(
width: minSize * strokeScale,
height: minSize
)
}
func path(in rect: CGRect) -> Path {
RoundedRectangle(cornerSize: CGSize(width: rect.width * radiusScale, height: rect.width * radiusScale))
.offset(y: rect.width * radiusScale)
.path(in: CGRect(
x: rect.minX,
y: rect.minY + rect.height * 1/2 * (1-lengthScale),
width: rect.width,
height: rect.height * 1/2 * lengthScale
))
}
}
private func hand(lengthScale: CGFloat) -> some Shape {
// 針幅は広さのhandWidthScale倍
// 針長は半径のlengthScale倍
// 角丸は幅の0.5倍
Hand(radiusScale: 0.5, lengthScale: lengthScale, strokeScale: handWidthScale)
}
@ViewBuilder
func hourHand(hour: Int?, minute: Int?) -> some View {
let angle = (Double(hour ?? .zero) + Double(minute ?? .zero) / 60).truncatingRemainder(dividingBy: 12) * 360 / 12
hand(lengthScale: 0.5)
.fill(Color(UIColor.systemBackground))
.rotationEffect(Angle(degrees: angle), anchor: .center)
}
@ViewBuilder
func minuteHand(minute: Int?) -> some View {
let angle = Double(minute ?? .zero) * 360 / 60
hand(lengthScale: 0.7)
.fill(Color(UIColor.systemBackground))
.rotationEffect(Angle(degrees: angle), anchor: .center)
}
@ViewBuilder
func secondHand(second: Int?) -> some View {
let angle = Double(second ?? .zero) * 360 / 60
hand(lengthScale: 0.9)
.fill(Color(UIColor.systemBackground))
.rotationEffect(Angle(degrees: angle), anchor: .center)
}
func makeBody(configuration: ClockStyleConfiguration) -> some View {
let dateComponents = calendar.dateComponents([.hour, .minute, .second], from: configuration.date)
let hour = dateComponents.hour
let minute = dateComponents.minute
let second = dateComponents.second
Circle()
.overlay {
secondHand(second: second)
}
.overlay {
minuteHand(minute: minute)
}
.overlay {
hourHand(hour: hour, minute: minute)
}
.overlay(alignment: .center) {
Circle()
.fill(Color(UIColor.systemBackground))
.scaleEffect(handWidthScale)
}
}
}
extension ClockStyle where Self == SymbolFilledClockStyle {
static var filledSymbolic: Self { Self() }
}
できました。
ClockView()
.clockStyle(.filledSymbolic)
.foregroundStyle(Color.yellow)
.padding()
可愛い〜

Discussion