🍣

[SwiftUI] Custom Styleの作成のすゝめ

2023/12/07に公開

概要

SwiftUIにはButtonStyleLabelStyleがあり、これらを利用することで見た目を簡単に変更することができます。

例えばButtonStyleにはBorderedButtonStyleというStyleが用意されており、以下のように指定するだけで、枠線で囲まれた見た目に変更してくれます。

HStack {
    Button("Sign In", action: signIn)
    Button("Register", action: register)
}
.buttonStyle(.bordered) // styleを指定するだけ

このようにButtonLabelのような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つありました。

  1. 命名を変更する
  2. 内部で分岐処理を設ける

命名を変更する

まず命名をそれぞれ分けてしまえばよいと考えました。

画面という規則性を命名に追加してみました。

  • トップ画面のSmoohtieView -> TopSmoohtieView
  • 詳細一覧画面のSmoohtieView -> DetailedListSmoohtieView

ただTopSmoohtieViewを別の画面でも使いたいとなったときに破綻してしまいます。

そこで次にコンポーネントの特徴を命名に取り込んでみました。

  • トップ画面のSmoohtieView -> CircleSmoohtieView
  • 検索結果画面のSmoohtieView -> LargeSmoohtieView

先程のように破綻してしまうような問題は発生しなさそうですが、なんだかイケていない感じがします。

いずれにせよSmoothieという命名はこのサービスでは重要なワードであり、それを表すSmoothieViewも重要なワードだと思います。できればSmoohtieViewという命名は崩したくないと考えました。

内部で分岐処理を設ける

ではSmoohtieViewという命名を崩さずに見た目を変えるにはどうすればよいかと考えました。
引数に分岐のために必要な情報を与え、それらをもとにSmoohtieViewの内部で表示を分岐すればよいのではと考えました。
これならSmoohtieViewの命名は崩さなくて済むので良さそうです。

しかし仕様変更でトップの画面SmoohtieViewの文字色を変えたいなどの仕様変更が入ったときに引数が増えたり、別の画面でスムージー情報を表す新しいUIが現れるとさらに分岐が増えるので複雑度が上がってしまいます。

仕様の変更に弱そうで、こちらの案もイマイチだと思いました。

Pros & Cons

ここで先程の解決策のメリット・デメリットを整理します。

命名を変更するメリット・デメリット

  • メリット

    • 新しく新規追加しても既存に影響を与えない
    • トップのSmoohtieViewの見た目を変更したいという用件にも対応しやすい
  • デメリット

    • 名前空間を汚してしまう

分岐処理を設けるメリット・デメリット

  • メリット

    • SmoohtieViewという命名を使うことができる
  • デメリット

    • 仕様変更に弱い

これらのメリットを両立出来る方法はないかと考えました。

Demo

既存のButtonStyleLabelStyleに着目しました。
これらは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を作ってみてはどうでしょうか?

GitHubで編集を提案

Discussion