[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