16
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【SwiftUI】Viewコンポーネントの抽象化について考える

Last updated at Posted at 2022-09-12

はじめに

SARAH iOSアプリケーションへのSwiftUI導入が進む中で、Viewコンポーネントの抽象度についてベストプラクティスを考える機会が多々あります。例えばボタンを一つ作るにしても、「背景色は外から自由に指定可能にするべきか?じゃあフォントサイズは?」といった具合に「どこまでやる?」がつきまといます。
そんなViewコンポーネントの抽象化を、SwiftUIの特性を活かしつつ深ぼっていこうと思います。

今回の題材

IMG_3961.png

SARAHアプリには飲食店のメニューを一皿単位で検索できる画面があり、テキストだけでなく各種タグを選んで検索ができます。今回はそのタグ選択フォーム(以降ファセットと呼びます)を題材にします。

ファセットの特徴

  • タイトルラベルが左上につく
  • ファセットリストは横スクロールする
  • ファセットはタップ可能
  • ファセット一つの構成は Image + Text

とりあえずベタで書いてみる

import SwiftUI

/// Main View
struct ContentView: View {
  var body: some View {
    HorizontalFacetsList(
      genres: [
        .init(id: 111, name: "ラーメン"),
        .init(id: 222, name: "カレー"),
        .init(id: 333, name: "ステーキ"),
        .init(id: 444, name: "パスタ"),
        .init(id: 555, name: "餃子"),
      ]
    )
  }
}

/// ファセットリストコンポーネント
struct HorizontalFacetsList: View {
  // DataSource - "ジャンル"
  struct Genre: Identifiable {
    let id: Int
    let name: String
  }
  let genres: [Genre]

  var body: some View {
    VStack(alignment: .leading, spacing: 10) {
      // タイトルラベル
      Text("ジャンル")
        .font(.system(size: 14, weight: .medium))

      // 横スクロールのリスト
      ScrollView(.horizontal, showsIndicators: false) {
        LazyHStack(spacing: 6) {
          ForEach(genres) { genre in
            // ファセット一つのボタン ※詳細なコードは割愛
            FacetButton(
              title: genre.name,
              icon: Image(systemName: "tag"),
              action: { print("tapped \(genre.name)") }
            )
          }
        }
      }
      .frame(height: 32)
    }
  }
}

プレビュー
スクリーンショット 2022-08-26 12.44.47.png
ベタで書いたので、端的に言えば抽象度が最も低い(具象度が最も高い)ファセットリストが出来上がりました。再利用性はほぼゼロで、全く同じデザイン要件の全く同じデータを表示する場面でしか使い回すことはできません。

どの粒度に抽象化する?

再利用したい(できそうな)シチュエーションを、他箇所のUI要件と照らし合わせたり、未来の要件を想像しながら考えてみます。

  • ファセットリストの構成は同じだがスタイルが異なる場面
    • ファセット間のマージンを変えたい
    • ファセットボタンの角丸具合が変えたい
    • タイトルラベルあり/なしに対応した
  • レイアウトは同じだがデータソースが異なる場面
    • Genreリスト以外のデータも柔軟に表示させられるようにしたい
  • 横スクロールリストの機能だけを使い回したい場面
    • "ファセットリスト"としてだけ利用されるのは勿体無い
    • 「横スクロールピッカー」的な感じで、内部のUI(Cellの部分)を柔軟に差し替えられるようにしたい
  • etc

一例ですが、大体こんな感じでしょうか。
これらのシチュエーションに対応するために、少しずつ抽象化していってみます。

抽象化する

1. ファセットリストの構成は一緒だがスタイルが異なる場面

まずベタ書きの状態から、レイアウトの基本構成は変えずにスタイルだけを外から指定できるようにしてみます。SwiftUIは俗に言う「宣言的UI」に属するため、レイアウトの決定はイニシャライザで行うことになります。

/// Main View
struct ContentView: View {
  var body: some View {
    HorizontalFacetsList(
      genres: [
        .init(id: 111, name: "ラーメン"),
        .init(id: 222, name: "カレー"),
        .init(id: 333, name: "ステーキ"),
        .init(id: 444, name: "パスタ"),
        .init(id: 555, name: "餃子"),
      ],
+     title: "ジャンル",
+     facetSpacing: 6,
+     facetHeight: 32,
+     icon: Image(systemName: "tag")) { genre in
+       print("tapped \(genre.name)")
+     }
    )
  }
}

/// ファセットリストコンポーネント
struct HorizontalFacetsList: View {
  // DataSource - ごはんのジャンル
  struct Genre: Identifiable {
    let id: Int
    let name: String
  }
  let genres: [Genre]

+ // レイアウトやタップアクションは外から指定する
+ let title: String?
+ let facetSpacing: CGFloat
+ let facetHeight: CGFloat
+ let icon: Image
+ let action: (Genre) -> Void

  var body: some View {
    VStack(alignment: .leading, spacing: 10) {
      // タイトルラベル
-     Text("ジャンル")
-       .font(.system(size: 14, weight: .medium))
+     // タイトルの指定がないなら表示しない       
+     if let title = title {
+       Text(title)
+         .font(.system(size: 14, weight: .medium))
+     }

      // 横スクロールのリスト
      ScrollView(.horizontal, showsIndicators: false) {
-       LazyHStack(spacing: 6) {
+       LazyHStack(spacing: facetSpacing) {
          ForEach(genres) { genre in
            // ファセット一つのボタン ※詳細なコードは割愛
            FacetButton(
              title: genre.name,
-             icon: Image(systemName: "tag"),
-             action: { print("tapped \(genre.name)") }
+             icon: icon,
+             action: { action(genre) }
            )
          }
        }
      }
-     .frame(height: 32)
+     .frame(height: facetHeight)
    }
  }
}

ファセットの高さやファセット間のマージン、タイトルあり/なしなどをイニシャライザで指定可能にしたことで、当初のファセットコンポーネントより柔軟性が多少上がりました=抽象度が上がったと言えるでしょう。また、ファセットへのタップアクションもほとんどの場合は外からコールバックで受けたいはずなのでついでに仕込みしました。
しかしまだまだ余剰がありそうです。

2. レイアウトは同じだがデータソースが異なる場面

今の状態ではごはんのジャンル配列[Genre]データしかレンダリングできないコンポーネントということになります。しかし実際SARAHの検索画面でも、ジャンルの他に"最寄り駅"や"都道府県"などさまざまなファセットからごはんを検索できます。それらの多様なデータ型に対応できるようジェネリクスで抽象化してみます。

まずはファセット一つに必要な要素を表現した、HorizontalFacetsItemプロトコルを定義します。これをジャンルオブジェクトに適用します。どんなデータ型であってもファセットリストに表示するデータは複数の配列であるはずなので、Identifiableに準拠させます。

protocol HorizontalFacetsItem: Identifiable {
  var id: ID { get }
  var title: String { get }
}
struct Genre: HorizontalFacetsItem {
  let id: Int
  let name: String
  var title: String { name }
}

HorizontalFacetsItemプロトコルに準拠するデータ配列をイニシャライザで注入できるようファセットコンポーネントをジェネリクス化します。

- struct HorizontalFacetsList: View {
+ struct HorizontalFacetsList<Item: HorizontalFacetsItem>: View {

-   let genres: [Genre]
-   let action: (Genre) -> Void
+   let items: [Item]
+   let action: (Item) -> Void

    var body: some View {
      ...
    }
  }

ファセットコンポーネントを呼び出す際のイニシャライザでデータソースを決定します。

struct ContentView: View {
  var body: some View {
    HorizontalFacetsList(
      items: [
        Genre(id: 111, name: "カレー")
        Genre(id: 222, name: "カレー"),
        Genre(id: 333, name: "ステーキ"),
        Genre(id: 444, name: "パスタ"),
        Genre(id: 555, name: "餃子"),
      ],
      ...
      action: { genre in 
        print(genre.name)
      }
    )
  }
}

これでデータソースの抽象化もできました。例えば先の例の「都道府県のファセット」にも対応できそうです。

protocol HorizontalFacetsItem: Identifiable { ... }
/// 都道府県のデータオブジェクト
struct Prefecture: HorizontalFacetsItem {
  let id: Int
  let name: String
  var title: String { name }
}

struct ContentView: View {
  var body: some View {
    // ファセットコンポーネントに都道府県データ配列を流し込む
    HorizontalFacetsList(
      items: [
        Prefecture(id: 111, name: "東京都"),
        Prefecture(id: 222, name: "神奈川県")
        Prefecture(id: 333, name: "埼玉県"),
        ...
      ],
      ...
      title: "都道府県",
      icon: Image(systemName: "mappin.and.ellipse"),
      action: { prefecture in 
        print(prefecture.name)
      }
    )
  }
}

プレビュー
スクリーンショット 2022-09-02 13.40.35.png

おそらくここまで抽象化されていれば、SARAHの検索画面のファセットはほぼこの共通コンポーネントで賄えそうです。次はもう少し視野を広げ、ファセットとしてだけでなくまた違ったUI要件にも対応できるように深ぼってみます。

3. 横スクロールリストの機能だけを使い回したい場面

「横スクロールするリスト」という更に抽象的なコンポーネントにしてみます。スクロール内部のUI(UICollectionViewで言うところのCell)はどんなViewでも適用可能にします。更にこのコンポーネントに表示させるデータ配列も柔軟に外から指定できるように変えてみます。

「横スクロールするリスト」と言うものを新たに作ろうとするだけでも最低限

  • ScrollView
  • LazyHStack
  • ForEach

これらの標準コンポーネントをネストさせる必要があるので、 横スクロールリストコンポーネントは価値が高そうです。

/// `HorizontalList`に表示するデータプロトコル
protocol HorizontalListItem: Identifiable {
  associatedtype Value
  var id: ID { get }
  var value: Value { get }
}

/// 横スクロールするリストコンポーネント
struct HorizontalList<Content: View, Item: HorizontalListItem>: View {
  let items: [Item]
  let spacing: CGFloat
  let content: (Item) -> Content // 💡

  var body: some View {
    ScrollView(.horizontal, showsIndicators: false) {
      LazyHStack(spacing: spacing) {
        ForEach(items) { item in
          content(item)
        }
      }
    }
  }
}

@ViewBuilderで外からCell部分のViewを指定可能にします
let content: (Item) -> Content

このパターンはSwiftUIプロジェクトでは多用することになると思います。
(Reactで言うところのchildrenのようなパターンでしょうか)

この横スクロールリストコンポーネントでファセットリストを作り直してみます。

protocol HorizontalFacetsItem: HorizontalListItem {
  var title: String { get }
}
struct Genre: HorizontalFacetsItem {
  let id: Int
  let name: String
  var title: String { name }
  var value: Int { id }
}

/// 横スクロールリストコンポーネントを適用したファセットリスト
struct HorizontalFacetsList<Item: HorizontalFacetsItem>: View {
  let items: [Item]
  let title: String?
  let facetSpacing: CGFloat
  let facetHeight: CGFloat
  let icon: Image
  let action: (Item) -> Void

  var body: some View {
    if let title = title {
      Text(title)
        .font(.system(size: 14, weight: .medium))
    }

    HorizontalList(items, spacing: facetSpacing) { item in
      FacetButton(
        title: item.name,
        icon: icon,
        action: { action(item) }
      )
      .frame(height: facetHeight)
    }
  }
}

body内がすっきりして読みやすくなった印象です。

試しに横スクロールリストコンポーネントでファセット以外のUIを並べてみます。

struct Food: HorizontalListItem {
  let id: Int
  let thumbnail: String
  let description: String
  var value: Int { id }
}

struct ContentView: View {
  // たべものデータ配列
  let foods: [Food] = [
    Food(id: 111, thumbnail: "hamburger", description: "クラシックバーガー"),
    Food(id: 222, thumbnail: "ramen", description: "濃厚醤油ラーメン"),
    Food(id: 333, thumbnail: "kaisendon", description: "日替わり海鮮丼"),
  ]

  var body: some View {
    HorizontalList(items: foods, spacing: 6) { food in
      // カードUI
      Button(
        action: { print(food.value) },
        label: {
          VStack(alignment: .center, spacing: 4) {
            // サムネ
            Image(food.thumbnail)
              .resizable()
              .frame(width: 150, height: 150)
            // テキスト
            Text(food.description)
              .foregroundColor(.white)
              .font(.system(size: 14, weight: .bold))
              .padding(.bottom, 4)
          }
        }
      )
      .background(Color.gray)
      .cornerRadius(10)
    }
  }
}

プレビュー
スクリーンショット 2022-09-02 17.38.18.png

よくあるカードUIを並べてみました。なんでもいけそうですね。

おわりに

抽象度を段階的に上げていくことで、再利用できる場面を増やせたと言えるでしょう。これは(賛否両論ありますが...)Atomic Designの考え方ともリンクする部分があり、チームである程度のルール付けは必要なものの、UI実装に対する一つの指針になると考えます。

エンジニア募集中

SARAHでは一皿に特化したごはん情報の投稿・配信・収集・解析するサービスを、toC、toBと多角的に展開しています。
そんなSARAHを一緒に爆進してくれるメンバーを募集中です!興味のある方はぜひ↓の採用窓口からカジュアルにお話を聞きにきてください!
皆さんと一緒に働けるのを楽しみにしています!
また、SARAHではテックブログを運営してるので、是非見てみてください

SARAH Tech Blog Hub

採用窓口

16
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?