[SwiftUI] Custom Styleの作成のすゝめ
概要
SwiftUIにはButtonStyleやLabelStyleがあり、これらを利用することで見た目を簡単に変更することができます。
例えばButtonStyle
にはBorderedButtonStyleというStyleが用意されており、以下のように指定するだけで、枠線で囲まれた見た目に変更してくれます。
HStack {
Button("Sign In", action: signIn)
Button("Register", action: register)
}
.buttonStyle(.bordered) // styleを指定するだけ
このようにButton
やLabel
のようなSwiftUIが提供しているコンポーネントのStyle
は提供されています。しかし自作したコンポーネントのStyle
も提供したい、と思うきっかけがありました。
本稿では、以下3点を中心に見ていこうと思います。
- Motivation:なぜ作成したいと考えたのか
- Pros & Cons:どのようなメリット・デメリットがあるのか
- Demo:Styleを自作
TL;DR
以下のようにオリジナルなコンポーネントのStyleを作成することができるようになります。
SmoothieView(
smoothie: smoothie,
thumbnailSize: width,
onTap: {}
)
.smoothieStyle(.small)
前提条件
本稿では説明をわかりやすくするために、スムージーを紹介するサービスのiOSアプリを実装している、と仮定します。
仕様
このアプリでは、魅力的な様々なスムージーを提案することを重要なコンセプトとしています。
トップ画面
以下のように、アプリ起動後最初に目に入る「トップ画面」で色々な各地域で有名なスムージーが表示されるように実装しました。
ここでスムージーを表示しているコンポーネントの命名を、スムージーの情報を表しているViewであることからSmoohtieView
とします。
次に各地域で有名なスムージーをより詳細に記載した、「詳細一覧画面」も実装しました。
詳細一覧画面にもスムージーの情報を表しているViewがあるので、そのコンポーネント名をSmoohtieView
とします。
Motivation
ちょっと待ってください。「トップ画面」で既にSmoohtieView
という名前を使ってしまっており、名前が衝突してしまいました。
このとき私が思いついた対応策は2つありました。
- 命名を変更する
- 内部で分岐処理を設ける
命名を変更する
まず命名をそれぞれ分けてしまえばよいと考えました。
画面
という規則性を命名に追加してみました。
- トップ画面の
SmoohtieView
->TopSmoohtieView
- 詳細一覧画面の
SmoohtieView
->DetailedListSmoohtieView
ただTopSmoohtieView
を別の画面でも使いたいとなったときに破綻してしまいます。
そこで次にコンポーネントの特徴を命名に取り込んでみました。
- トップ画面の
SmoohtieView
->CircleSmoohtieView
- 検索結果画面の
SmoohtieView
->LargeSmoohtieView
先程のように破綻してしまうような問題は発生しなさそうですが、なんだかイケていない感じがします。
いずれにせよSmoothie
という命名はこのサービスでは重要なワードであり、それを表すSmoothieView
も重要なワードだと思います。できればSmoohtieView
という命名は崩したくないと考えました。
内部で分岐処理を設ける
ではSmoohtieView
という命名を崩さずに見た目を変えるにはどうすればよいかと考えました。
引数に分岐のために必要な情報を与え、それらをもとにSmoohtieView
の内部で表示を分岐すればよいのではと考えました。
これならSmoohtieView
の命名は崩さなくて済むので良さそうです。
しかし仕様変更でトップの画面SmoohtieView
の文字色を変えたいなどの仕様変更が入ったときに引数が増えたり、別の画面でスムージー情報を表す新しいUIが現れるとさらに分岐が増えるので複雑度が上がってしまいます。
仕様の変更に弱そうで、こちらの案もイマイチだと思いました。
Pros & Cons
ここで先程の解決策のメリット・デメリットを整理します。
命名を変更するメリット・デメリット
-
メリット
- 新しく新規追加しても既存に影響を与えない
- トップの
SmoohtieView
の見た目を変更したいという用件にも対応しやすい
-
デメリット
- 名前空間を汚してしまう
分岐処理を設けるメリット・デメリット
-
メリット
-
SmoohtieView
という命名を使うことができる
-
-
デメリット
- 仕様変更に弱い
これらのメリットを両立出来る方法はないかと考えました。
Demo
既存のButtonStyle
やLabelStyle
に着目しました。
これらはButton
という命名は崩さずに.buttonStyle
というモディファイアにStyle情報を渡すだけで見た目を変えています。
これはまさに私の理想としていたI/Fでした。このI/Fを参考にオリジナルのStyleを作っていきます。
必要なデータを定義
まずはSmoohtieView
を構成するために必要なデータを定義していきます。
struct SmoothieStyleConfiguration {
/// スムージー情報
let smoothie: Smoothie
/// サムネイルサイズ
let thumbnailSize: CGFloat
/// アイテムをタップしたときのコールバック
let onTap: () -> Void
}
Styleの実装
次に独自のStyleを作成するためのprotocolを定義します。
protocol SmoothieStyle {
associatedtype Body: View
@ViewBuilder func makeBody(configuration: SmoothieStyleConfiguration) -> Body
}
そしてSmoothieStyle
に準拠したトップ画面と検索結果画面の2種類分のStyleを作成します。
トップ画面用のSmoohtieView
struct SmallSmoothieStyle: SmoothieStyle {
func makeBody(configuration: SmoothieStyleConfiguration) -> some View {
VStack(spacing: 8) {
thumbnail(configuration.smoothie.id, size: configuration.thumbnailSize)
.frame(width: configuration.thumbnailSize, height: configuration.thumbnailSize)
.clipShape(.circle)
title(configuration.smoothie.title, font: .subheadline)
.lineLimit(2, reservesSpace: true)
}
.frame(width: configuration.thumbnailSize)
}
}
検索結果画面用のSmoohtieView
struct LargeSmoothieStyle: SmoothieStyle {
func makeBody(configuration: SmoothieStyleConfiguration) -> some View {
VStack(alignment: .leading, spacing: 8) {
thumbnail(configuration.smoothie.id, size: configuration.thumbnailSize)
.frame(height: configuration.thumbnailSize * 0.64)
.clipShape(.rect)
title(configuration.smoothie.title, font: .title3)
description(configuration.smoothie.description, font: .caption)
price(configuration.smoothie.price, font: .subheadline)
}
}
}
EnvironmentValuesの作成
.buttonStyle(.borded)
のようなI/FにするためにEnvironmentValuesを作成します。
extension SmoothieStyle where Self == SmallSmoothieStyle {
static var small: Self { Self() }
}
extension SmoothieStyle where Self == LargeSmoothieStyle {
static var large: Self { Self() }
}
struct SmoothieStyleEnvironmentKey: EnvironmentKey {
static var defaultValue: any SmoothieStyle = SmallSmoothieStyle()
}
extension EnvironmentValues {
var smoothieStyle: any SmoothieStyle {
get {
self[SmoothieStyleEnvironmentKey.self]
}
set {
self[SmoothieStyleEnvironmentKey.self] = newValue
}
}
}
extension View {
func smoothieStyle(_ style: some SmoothieStyle) -> some View {
self.environment(\.smoothieStyle, style)
}
}
SmoothieViewの作成
最後に本体のSmoothieView
を実装します。
struct SmoothieView: View {
@Environment(\.smoothieStyle) private var style
let smoothie: Smoothie
let thumbnailSize: CGFloat
let onTap: () -> Void
var body: some View {
let configuration = SmoothieStyleConfiguration(
smoothie: smoothie,
thumbnailSize: thumbnailSize,
onTap: onTap
)
AnyView(style.makeBody(configuration: configuration))
}
}
これで以下のようなI/Fで見た目を簡単に変えることができるようになりました🎉🎉
SmoothieView(
smoothie: smoothie,
thumbnailSize: width,
onTap: {}
)
.smoothieStyle(.small)
まとめ
独自のコンポーネントに対するStyleを作成することで、以下のメリットを得られることができました。
- 名前空間を汚さない
- 仕様変更に強い
また何より個人的にはI/Fがとてもきれいだと思います。
Button
のような基本的なコンポーネントを自作する際や、ドメインに依存した重要なコンポーネントを自作する際は、ぜひ独自のStyleを作ってみてはどうでしょうか?
Discussion