SwiftUIを学んでみて

はじめに

はじめまして。アイスタイルAdvent Calender2021の12/19担当のhayakawatです。
現在はアプリ開発グループでiOS版の@cosmeの開発を担当しています。

先日、Xcode13とiOS15がリリースされました。
それに伴いiOS版@cosmeはサポートOSがiOS13〜15になりました。
つまり、サポートOSの下限がiOS13になったことによりSwiftUIをプロダクトに導入することができるようになりました。そして@cosmeアプリでも一部導入が開始されている画面もあります。
しかし自分はSwiftUIがリリースされた当時、軽くチュートリアルを触ってみて「ふ〜ん、こんな感じね!UIを全部コードで書くってコード量多くなりそう。なんかネストも深くて読みにくいな〜。まぁまだ使う場面ないでしょ!」と、あまり触れてこなかった経緯があります。
今ではSwiftUIをプロダクトに組み込むことが可能になったので、プロダクトでも利用できるようにSwiftUIを使ったアプリをコツコツ個人開発しています。その開発の中でSwiftUIに対して思ったことの話になります。

SwiftUIとは

SwiftでUIを構築しやすくしたフレームワークです。
Xcode11から使用できます。またApple製品の全てのプラットフォームに対応しています。
ReactやFlutterなどの宣言的UIフレームワークのSwift版と考えても良いかもしれません。

参考: https://developer.apple.com/xcode/swiftui/

比較

まずは例として簡単なリスト形式(iOSではテーブルビュー)のアプリをStoryboadとコードで構築したものと、SwiftUIで構築したものを比較してみます。

Storyboadとコードで構築する方法

①メインとなるテーブルビュー画面のクラスファイル


class ViewController: UITableViewController { let fruits = ["リンゴ", "オレンジ", "バナナ", "ブドウ", "イチゴ", "パイナップル", "ドラゴンフルーツ"] override func viewDidLoad() { super.viewDidLoad() tableView.register(UINib(nibName: "TableViewCell", bundle: nil), forCellReuseIdentifier: "Cell") } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return fruits.count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! TableViewCell cell.label.text = fruits[indexPath.row] return cell } }

②テーブルに表示するセルのクラスファイル

class TableViewCell: UITableViewCell {

    @IBOutlet weak var label: UILabel!

    override func awakeFromNib() {
        super.awakeFromNib()
    }

}

さらに…
①の見た目を編集するStoryboardファイルが1つ

②の見た目を編集するxibファイルが1つ

の4つのファイルを使ってこのようなアプリができます。

SwiftUIで構築する方法

次に同じようなリスト形式のアプリをSwiftUIで構築してみます。

メインとなるテーブルビューを表示するクラスファイル

struct ContentView: View {
    let fruits = ["リンゴ", "オレンジ", "バナナ", "ブドウ", "イチゴ", "パイナップル", "ドラゴンフルーツ", "スイカ"]
    var body: some View {
        List(fruits, id: \.self) { fruit in
            Text(fruit)
        }
        .listStyle(PlainListStyle())
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

これだけです。ほかにStoryboardやxibファイルを使ったりはしません。
なぜ、この素晴らしさに過去の自分は気づけなかったのか…
それは後ほど振り返るとして、たったこれだけで先ほどと同様のアプリを作ることができます。

SwiftUIの所感

コードの見通しが良い

SwiftUIでは何の部品を置いて、その部品が何を表示し、デザインを設定するということを段階的に記述します。例えば先程の例だと、List形式でTextをfruits配列分繰り返しで表示して、Textにはfruitの中身を表示する。さらにリストの見た目にはPlainListStyle(従来のテーブルビューの見た目)を適用する。となります。
Storyboadとコードで構築する方法に比べてまとまりがあって可読性が上がりました。

状態の管理が楽

SwiftUIではデータバインディングをする仕組みがもともと備わっています。
ほんの一例ですが以下のようにViewの更新のきっかけとなるプロパティに@State属性を与えることで
Viewが値をモニタリングし、変更があった場合に更新してくれます。

struct ContentView: View {
    @State private var isLike = false

    var body: some View {
        Button(action: {
            // isLikeの値を切り替える
            self.isLike.toggle()
        })
        Text(isLike ? "LIKE" : "NOT LIKE")
    }
}

プレビュー機能

エディタの隣にプレビューを表示させることができます。コードが更新されるたびにプレビューも更新されるので、いちいちビルドをしなくてもレイアウトを確認することができます。アプリの規模によってはビルドに時間がかかることがあるので、これはかなり嬉しいです。

差分が確認しやすい

Storyboardやxibの実体はXMLで構築されているため、XcodeのGUI上で変更を行うとXMLの差分としてGitで出力されます。例えば、セルの見た目を制御するxibファイルの実体は下記のようになっています。(一部抜粋)

<label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" text="フルーツが表示されるラベル" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Lg9-pH-MKM">
    <rect key="frame" x="16" y="12" width="304" height="20"/>
    <fontDescription key="fontDescription" type="system" pointSize="17"/>
    <nil key="textColor"/>
    <nil key="highlightedColor"/>
 </label>

では、セルのレイアウトを少し変更した時の差分を見てみます。
xibの差分

diff --git a/SampleListApp/TableViewCell.xib b/SampleListApp/TableViewCell.xib
index f2602b3..d9e8825 100644
--- a/SampleListApp/TableViewCell.xib
+++ b/SampleListApp/TableViewCell.xib
@@ -4,6 +4,7 @@
     <dependencies>
         <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19144"/>
         <capability name="Safe area layout guides" minToolsVersion="9.0"/>
+        <capability name="System colors in document resources" minToolsVersion="11.0"/>
         <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
     </dependencies>
     <objects>
@@ -16,15 +17,15 @@
                 <rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
                 <autoresizingMask key="autoresizingMask"/>
                 <subviews>
-                    <label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" text="フルー
ツが表示されるラベル" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Lg9-pH-MKM">
-                        <rect key="frame" x="16" y="12" width="304" height="20"/>
-                        <fontDescription key="fontDescription" type="system" pointSize="17"/>
-                        <nil key="textColor"/>
+                    <label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" text="フルー
ツが表示されるラベル" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Lg9-pH-MKM">
+                        <rect key="frame" x="16" y="12" width="288" height="20"/>
+                        <fontDescription key="fontDescription" type="system" weight="black" pointSize="20"/>
+                        <color key="textColor" systemColor="systemBlueColor"/>
                         <nil key="highlightedColor"/>
                     </label>
                 </subviews>
                 <constraints>
-                    <constraint firstAttribute="trailing" secondItem="Lg9-pH-MKM" secondAttribute="trailing" id="asi-z7-jVC"/>
+                    <constraint firstAttribute="trailing" secondItem="Lg9-pH-MKM" secondAttribute="trailing" constant="16" id="Lxb-vq-dNy"/>
                     <constraint firstItem="Lg9-pH-MKM" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="16" id="daJ-zG-MXx"/>
                     <constraint firstAttribute="bottom" secondItem="Lg9-pH-MKM" secondAttribute="bottom" constant="12" id="fwB-YB-eZa"/>
                     <constraint firstItem="Lg9-pH-MKM" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" constant="12" id="q63-UO-ek8"/>
@@ -37,4 +38,9 @@
             <point key="canvasLocation" x="137.68115942028987" y="153.34821428571428"/>
         </tableViewCell>
     </objects>
+    <resources>
+        <systemColor name="systemBlueColor">
+            <color red="0.0" green="0.47843137254901963" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
+        </systemColor>
+    </resources>
 </document>

この差分はセルのレイアウトをどう変更したか予想してみてください。
Gitの差分でこのように出力されると高さを変えたのか、配置の制約を変えたのか、どこを変更したかが分かりにくく、レイアウトのレビューをする際は一度対象の作業ブランチをcheckoutしないといけません。
もちろんXMLをすらすらと読むことができる方もいると思いますが、全員が全員そうというわけではありません。

SwiftUIの差分

diff --git a/SampleListAppSwiftUI/ContentView.swift b/SampleListAppSwiftUI/ContentView.swift
index c9bb506..a6d2842 100644
--- a/SampleListAppSwiftUI/ContentView.swift
+++ b/SampleListAppSwiftUI/ContentView.swift
@@ -4,7 +4,13 @@ struct ContentView: View {
     @State private var fruits = ["リンゴ", "オレンジ", "バナナ", "ブドウ", "イチゴ", "パイナップル", "ドラゴンフルーツ", "スイカ"]
     var body: some View {
         List(fruits, id: \.self) { fruit in
-            Text(fruit)
+            HStack {
+                Spacer()
+                Text(fruit)
+                    .font(.system(size: 20).weight(.heavy))
+                    .foregroundColor(.blue)
+                Spacer()
+            }
         }
         .listStyle(PlainListStyle())
     }

こちらは、SwiftUIで同じレイアウト変更を施した差分になります。
差分を見るとテキストの左右をスペースで囲み中央配置にし、fontサイズを20、ウェイトをヘビー、フォントカラーをブルーにしていることが直感的に分かります。
SwiftUIはレイアウトもSwiftオンリーで構築できるので、変更が直感的に分かりやすく、レビューする際に何をどう変更したかが分かりやすくなっていてチーム開発をする際は非常にありがたかったりします。

UIKitを完全に補完するものではない

これは自分で開発しているときに困ったことなのですが、メモ帳アプリ作っていてメモを入力する画面を作る時にUITextView(テキストを入力するView)を使いたかったのですが、SwiftUIにはありません。
なのでTxetFieldを拡張するといった工夫が必要でした。
まだ、SwiftUIがどこまでできるかを全て把握しているわけではないのですが、ほかにもUIKitで簡単にできたことがまだSwiftUIではできないといったことがあるかもしれません。
しかし、SwiftUIもまだ登場したばかりのフレームワークなのでこれからどんどん差がなくなっていくと思います。

複雑なUIが構築しにくい

例に出したList形式のアプリなどの型にハマったアプリはさっくり作ることができるのですが、より複雑なUIを構築するのは少し大変そうだなあと思いました。
SwiftUIの扱いに慣れていけばできるようになるのかもしれませんが…
しかし、逆に言うと僕のようなデザイン経験がない人でもある程度の水準を満たしたアプリを作れるのは、アプリ開発の敷居を下げるという意味でもいいことなのかもしれません。

反省会

過去の自分を振り返ってみると、当時は大規模なアプリ開発をしたことがありませんでした。
アイスタイルに入社してからたくさんのクラスファイルやStoryboardがあるアプリを見たので、その経験もあってかコードの見通しが良いSwiftUIに魅力を感じることができたのかもしれません。
また、以前にデザインを考えるのも、HTML、CSSを書くのも億劫だがどうしてもポートフォリオサイトを作りたい時にFlutter Webを知り、さっくりポートフォリオサイトを完成させることができた経験もSwiftUIのような宣言的にUIを構築するのに憧れた理由の一つでしょう。
まとめてみるとSwiftUIにはおいしい部分が多くもっと早くから勉強しておけばよかったなーという感想です。これから@cosmeにも導入していけたらなと思います。
最後までご覧いただきありがとうございました。

2年目iOSエンジニア