🕒

[SwiftUI] `buttonStyle`を支える技術

2023/11/07に公開

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())

BorderedProminentButtonStyleが適用されたボタンのスクリーンショット

それから、なぜVStackのメソッドとして呼び出したbuttonStyleが全てのボタンに反映されるのでしょうか。

最後にBorderedProminentButtonStyle()のように明示的にイニシャライザを呼ぶ代わりに.borderedProminentを使うこともできます。これは一体どのような記法でしょうか。

Button("Globe", systemImage: "globe") {}
    .buttonStyle(.borderedProminent) // これは何

BorderedProminentButtonStyleが適用されたボタンのスクリーンショット

この記事ではButtonStyleのように振る舞う「ClockStyle」を作っていくことで、buttonStyleを支えるSwiftの言語機能を見ていきます。

ClockView

今回作るのはClockViewClockStyleです。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を調べてみましょう。

https://developer.apple.com/documentation/swiftui/buttonstyle

ドキュメントによると、ButtonStyleはプロトコルであり、func makeBody(configuration: ButtonStyleConfiguration) -> some Viewなるメソッドが必要です。引数のconfigurationはボタンに関する情報が含まれていて、labelisPressedroleにアクセスすることができます。これを元にボタンの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())

時刻がテキスト表示されているスクリーンショット

とてもいい感じです!

型を消す

今のところClockStyleClockViewの型パラメータになっていて、このおかげでスタイルを自由に変更できます。

しかし、Buttonのドキュメントを見てみると、ButtonStyleButtonの型パラメータではありません。もしもButtonStyleがそのままの型でButtonに渡されているのなら、型が明示的に注入されている必要があります。従って、ここにはトリックがあるはずです。

https://developer.apple.com/documentation/swiftui/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を書き直します。styleany 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 ViewViewではないからですany Viewはあくまで任意のViewに準拠した型を代入できるような型にすぎず、それ自体がViewの制約を満たしているわけではありません。実際、Viewに必要なassociatedtypeであるBodyany Viewは持っていないので、(any View).Bodyのように関連型を取り出そうとしてもエラーになります。

そこで登場するのがAnyViewです。このイニシャライザは以下のように定義されています。

https://developer.apple.com/documentation/swiftui/anyview/init(_:)

init<V>(_ view: V) where V : View

上記の説明を読むとany Viewをこのイニシャライザに渡せるというのは矛盾するように思えてくるのではないでしょうか。initViewに適合した型を要求していますから、any Viewを渡すのは無理そうです。

ここで動いているのがSwift 5.7で導入されたImplicitly Opened Existentialsです。

https://github.com/apple/swift-evolution/blob/main/proposals/0352-implicit-open-existentials.md

この機能の詳細な仕様は少し複雑なのですが、ざっくりいうと、any Pはそのままではジェネリック関数に渡せないけど、その中身から暗黙に実際の型を取り出すことでジェネリック関数に渡せるようにする、という機能です。この機能は完全に暗黙に動くので、気づかないうちにあちこちで効いています。

つまりどういうことかというと、any ViewAnyView(_:)に渡すとき、勝手に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を定義する方法はドキュメントに記載されているので、この通りにします。

https://developer.apple.com/documentation/swiftui/environmentvalues/

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())

このように表示されます🎉
時刻がテキスト表示されているViewが縦に2つ並んだスクリーンショット

clockStyle(.default)を実現する

では、最後の疑問であるこいつを倒していきましょう。

最後にBorderedProminentButtonStyle()のように明示的にイニシャライザを呼ぶ代わりに.borderedProminentを使うこともできます。これは一体どのような記法でしょうか。

実際、ボタンの場合は例えば以下のように書くことができます。

VStack {
    Button(...)
    Button(...)
    Button(...)
}
    .buttonStyle(.bordered)

一見するとButtonStyleenumで、そのcaseborderedかと思ってしまいそうですが、すでに見た通りそんなことはありません。buttonStyle(_:)の定義を見てみましょう。

func buttonStyle<S: ButtonStyle>(_ style: S) -> some View

つまり、.borderedBorderedButtonStyle型で、.defaultDefaultButtonStyleなのです。そんなことがあり得るのでしょうか。例えば以下のようなメンバーを定義しても、buttonStyle(_:)にはうまく渡せません。

extension BorderedButtonStyle {
    static var bordered: Self { Self() }
}

結論から言うと、これはそういう機能です。Swift 5.5で導入され、プロトコルを特別なパターンで拡張することでこのような記法が可能になります。

https://github.com/apple/swift-evolution/blob/main/proposals/0299-extend-generic-static-member-lookup.md

// `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()

可愛い〜
黄色いアナログ時計風のViewのスクリーンショット

Discussion