この記事は株式会社ビットキー Advent Calendar 2022 10日目の記事になります。
こんにちは、workhubAppを作っている新山(あらさん)です。本を衝動買いして積ん読が天まで届くくらいに積み上がってきました。消化するころには次のタワーが出来上がっているので悩んでいます。
この記事を書くに至った動機
自分はWEBエンジニアの経歴を持っていないので実体験を伴った変化の軌跡を見てきたわけではありませんが、TailwindCSSなどのユーティリティファーストと呼ばれるCSSの設計が広く採用されてきているようです。これはWeb開発の文脈でCSSとHTMLを大規模になってもより良く開発するための工夫や考え方の到達点ではないかと感じています。
そこで宣言的UIを前提にしたSwiftUIは性質としてHTMLとCSSの持っていた設計方法と似た概念を持つのではないか、と予想したためWeb開発の世界から知見を吸収できればいいなと思いこの記事を書くことにしました。
TailwindなCSSと現在のアプリ開発
Web開発の文脈を知らない状態から考察を開始するためまずは「ユーティリティファーストなCSSが何者でどこからやってきたのか」について紐解いていきます。
ユーティリティファーストとはざっくりと一言で表すとCSSがHTMLに依存する構造(セマンティックCSS)からHTMLがCSSに依存する構造(ファンクショナルCSS)への転換です。セマンティックCSSとはCSSの名前を見るとどこの要素で使われるのかが分かることを目的として設計する方針であり、ファンクショナルCSSは一つができるデザイン要素を小さく分割してHTML側で利用するものを書き連ねる方針です。
セマンティックCSSのメリットには同じ意味を持つCSSであれば付け替えがしやすいと信じられていることがあります。デメリットとして全体の見た目の整合性をとることが難しくなります。
引用: https://yuheiy.hatenablog.com/entry/2020/05/25/021342
<div class="author-bio">
<img class="author-bio__image" src="https://cdn-images-1.medium.com/max/1600/0*o3c1g40EXj65Fq9k." alt="">
<div class="author-bio__content">
<h2 class="author-bio__name">Adam Wathan</h2>
<p class="author-bio__body">
Adam is a rad dude who likes TDD, Active Record, and garlic bread with cheese. He also hosts a decent podcast and has never had a really great haircut.
</p>
</div>
</div>
対してファンクショナルCSSのメリットには機能として同じものであれば同じ要素を参照することにより全体に対して一貫性を持たせます。
引用: https://yuheiy.hatenablog.com/entry/2020/05/25/021342
<div class="card rounded shadow">
<a href="..." class="block">
<img class="block fit" src="...">
</a>
<div class="py-3 px-4 border-b border-dark-soft flex-spaced flex-y-center">
<div class="text-ellipsis mr-4">
<a href="..." class="text-lg text-medium">
Test-Driven Laravel
</a>
</div>
<a href="..." class="link-softer">
@icon('link')
</a>
</div>
<div class="flex text-lg text-dark">
<div class="py-2 px-4 border-r border-dark-soft">
@icon('currency-dollar', 'icon-sm text-dark-softest mr-4')
<span>$3,475</span>
</div>
<div class="py-2 px-4">
@icon('user', 'icon-sm text-dark-softest mr-4')
<span>25</span>
</div>
</div>
</div>
この転換が見直されている要因についてざっくりとした自分の理解では
- 適切にCSSの粒度を細かく保ち組み合わせること(ファンクショナルな思想)の浸透
- 対応するブラウザによってCSSで表現される見た目が似たものになってきたこと
- ReactなどのフレームワークではコンポーネントとしてCSSが適用済みの要素を使い回すことができることによりCSSレベルのセマンティックは徐々に必要性を失ってきたこと
上記の要素によるものではないかと考えています。これを俯瞰してみるとiOSやAndroidの開発と近くなってきたことに相当します。iOS, Androidであれば実現される表現は大きく異なる場合はないでしょう。またAutoLayoutの要素はレスポンシブデザインの文脈になるため現在のWeb開発とモバイル開発に違いはないように思います。
ここから得られる知見として小さなデザインを組み合わせることを徹底するとより良い開発を実現できるのでは、という考え方です。
参考元: CSSのユーティリティクラスと「関心の分離」——いかにしてユーティリティファーストにたどり着いたか(翻訳)(https://yuheiy.hatenablog.com/entry/2020/05/25/021342)
SwiftUIのデザイン設計
iOSであればUIKit・SwiftUIの見た目は標準でアニメーションや色、インタラクションに対応したUIコンポーネントが提供されています。例えばリストやホイールピッカーなどを考えてください、標準のコンポーネントを利用するだけで指に要素が追従するアニメーションやフェードアウトによる透明度の変更などが発生します。これらに独自の要素を付け加えることにより最終的に画面が構成されます。標準フレームワークで実現できることは素直に標準フレームワークの機能の上で実装すると良いです、例えばボタンを作る場合にはButtonStyleの利用は標準のフレームワークに準拠することは良い選択です。これらに頼らず全ての見た目をアプリケーションで実装した場合にはアプリらしさの部分も全てゼロから作り込むことになります。別のフレームワークをみるとFlutterはこれを独自に描画することで実現していますが、多大なるコストの上に実現しているものです。一つのアプリケーションで真似することはあまりにも無謀でしょう。
さて、SwiftUIの見た目を整える方法はmodifierによって主に実現されます。
ここでTailwind CSSの設計に習って考えてみます。高さ、背景色、文字要素、マージンなどはSwiftUIの標準のmodifierが使えます。利用用途ごとに定数を入れ込んだユーティリティを作ることを考えましょう。enumを使って実装をすると実現できる表現の幅がひと目で分かります。
smallPadding()
などでも良いですが一貫性を保つ強制力が減少することに注意してください。enumでは勝手にcaseを追加してもひと目で分かり、万が一case漏れをした場合にはビルドは通らなくなります、またpaddingに関する責務だけを過不足なく負うという点で実装を局所化することに成功しますが、prefix+表現
にすることで秩序を保つことが難しくなります。その上smallPaddingという設計を使った場合にはsmallFontSizeなどが生成されることが予期されます。これはXcodeでのコード補完をする場合にsmallから始まる必要のないサジェストが大量に生成されることを許容する記法です。
Swiftでは引数の型が異なれば適した実装を呼び出せるため、標準APIの引数を拡張する気持ちで記述をするとコードリーディングの段階で読みやすく、どこに実現したいmodifierが存在するのか酷く悩まずに見つけることができます。
struct MyView: View {
var body: some View {
VStack(spacing: 0) {
Text("Text1")
.padding(.small)
.background(.blue)
Text("Text2")
.padding(.medium)
.background(.green)
Text("Text3")
.padding(.large)
.background(.red)
Text("Text4")
.padding(.xlarge)
.background(.purple)
}
}
}
enum Design {
enum Padding {
case small, medium, large, xlarge
}
}
extension View {
@ViewBuilder
func padding(_ context: Design.Padding) -> some View {
switch context {
case .small:
self.padding(4)
case .medium:
self.padding(8)
case .large:
self.padding(16)
case .xlarge:
self.padding(32)
}
}
}
ユーティリティファーストな文脈ではマークアップで見た目を細かく決められるようにファンクショナルにデザインを構成できるようにせよ、という大きな知見を得ています。ここから分かる実装方針は複数のmodifierを利用した大きなユーティリティを作成しようとした場合にはその実装を疑え、という単純な結論です。
なぜmodifierでできることを限定したユーティリティを作りたいのかを原点に立ち返って考えると分かりやすいです。私達はユーティリティを作成した恩恵としてデザインの一貫性を求めています。padding(4)
などを各所に散りばめたとして管理することは辛いはずです。気がつけば異なる画面間でデザイン差分が発生してしまうことでしょう。小さなユーティリティを利用することで一貫性を保つというシンプルな解決ができるはずです。
視点を変えて大きなユーティリティのメリットを考えましょう、一つのViewに対して一つのmodifierを書けばデザインが完了するということは大きなメリットのように感じます。しかし大きなユーティリティはそのViewでしか使えない可能性を内包しています。小さいユーティリティを組み合わせると制約によりできることが限られつつ柔軟なデザイン要素として扱えます。しかし大きなユーティリティではできません。これはセマンティックCSSからファンクショナルCSSへ表現方法が見直されたプロセスに逆行しています。
大きなユーティリティの存在は基本的に実現したい画面要素を作る中でViewとして実現されるはずです。例えば何かしらのテキストを装飾したいとき、そのテキストを装飾するViewを作るとViewの再利用ができます。Viewのレイアウトは作成した小さなユーティリティを組み合わせて実現されているはずです。わざわざユーティリティのレベルで詳細に言及しなくても良いのです。
extensionとViewModifierのどちらを採用するべき?
ここで少しだけ実装方法に関する言及をします。extension View { func modifier() -> some View }
とstruct Mod: ViewModifier { ... }
のどちらを採用して開発をすると良いのでしょうか。ViewModifierで定義したものはViewに対する拡張メソッドとして.modifier(Mod())
とすれば同じように記述できます。
struct PaddingMod: ViewModifier {
let context: Design.Padding
func body(content: Content) -> some View {
switch context {
case .small:
content.padding(4)
case .medium:
content.padding(8)
case .large:
content.padding(16)
case .xlarge:
content.padding(32)
}
}
}
extension View {
func padding(with context: Design.Padding) -> some View {
self.modifier(PaddingMod(context: context))
}
}
一つの観点として実際に評価された結果を見てみるとどちらが良さそうか分かりそうです。よってstructが内部で認識する型を出力してあげましょう。このような場合には、modifierとしてdumpのような関数を定義して呼び出すと内部で持たれている型が簡単に見えます。これでどうなっているかを少し覗いてみましょう。
extension View {
func dump() -> some View {
Swift.dump(self)
return self
}
}
/// extension Viewで定義した場合
> SwiftUI._ConditionalContent<
SwiftUI._ConditionalContent<
SwiftUI.ModifiedContent<SwiftUI.Text, SwiftUI._PaddingLayout>,
SwiftUI.ModifiedContent<SwiftUI.Text, SwiftUI._PaddingLayout>
>,
SwiftUI._ConditionalContent<
SwiftUI.ModifiedContent<SwiftUI.Text, SwiftUI._PaddingLayout>,
SwiftUI.ModifiedContent<SwiftUI.Text, SwiftUI._PaddingLayout>
>
>
/// ViewModifierで定義した場合
> SwiftUI.ModifiedContent<SwiftUI.Text, App.PaddingMod>
得られた型は上記のようになりました。ここで考えたいことはSwiftUIは"structural identity"として型の構造を見ているということです。つまりextension Viewによって生成されるViewはあまりにも複雑な型を持ってしまい無駄が生じるように見えます。またこれは一つのmodifierを適用しただけということに注意してください。状態によって分けられるものを適用した場合には線形に膨れ上がり、あまりにも巨大な型を利用することになってしまいます。SwiftUIが型を見ていると信じて内部的に効率が良い動かし方をすることを期待する場合にはModifiedContentを利用できるViewModifierを定義して利用することが良さそうに見えます。
ユーティリティファーストであるCSSの知見を活かすとは
結論は単純です、大きなユーティリティはロジックと同様に組まない、Fat Design Utilityを作らないということです。
何事も使い回せる状態の粒度を見極めるということが大事ですね。
個人のアドベントカレンダーの宣伝にはなりますが、一人アドベントカレンダーも今年挑戦中です、そちらも応援していただけると嬉しいです! → https://qiita.com/advent-calendar/2022/arasan01
明日の株式会社ビットキー Advent Calendar 2022は、同じくworkhub Appの斎藤さんが担当します、楽しみですね。