Open8

Xcode UIKit チュートリアルを一通りやってみる 1. Listビューの作成

ぽちぽち

チュートリアル勉強メモ

UIKit 概要

最初にUIKitの背景を知るようにとのことで、要約まとめ。

  • 幅広いAPI ・・・ iOSアプリ構築に必要な core objects, views, controlls などのライブラリが揃ってる。
  • 構成レイアウト・・・ 再利用可能な小さなコンポーネントからCollectionViewを作成。ステートドリブン型でセルを更新。柔軟にビジュアルレイアウトを作成し。
ステートドリブン型

・・・先に「状態(ステート)」の解析を行い、その状態ごとに必要なイベントの解析を行う。
詳しくは、ここがわかりやすい
https://monoist.itmedia.co.jp/mn/articles/1211/07/news003_3.html

  • 正確なUI表示••• Auto Layout, Interface BuilderいずれでView構築しても、あらゆるAppleデバイス・向きにも対応。
  • iOS13以前の構築基盤・・・ iOS13以前のレガシーアプリの保守または拡張には、UIKitが必要
  • 手厚いサポート・・・ Appleドキュメントだけでなく、UIKitコミュニティから幅広いコンテンツと手厚いサポートが受けられる
  • SwiftUIと相互運用・・・ 両方のフレームワークの長所を生かした運用ができる。
ぽちぽち

リマインダーアプリ「Today」の作成

目標

  • 毎日のリマインダーを表示
  • ユーザーがその日のタスクに集中できるようにする
  • ビジュアルデザインとアニメーションを使用し、静かで落ち着いたインターフェースの提供

Todayの概要

  • メイン画面:ユーザーのリマインダー表示

  • ナビゲーションバー:カテゴリー(Today, Future, All) をセグメント型のピッカーを用いてフィルター表示

  • ナビゲーションバー:+ 追加ボタンで、リマインダーを新規作成

  • 完了ボタンを押すことでリマインダーが完了 → 進行状況の円が塗りつぶされていく

  • 詳細画面:タイトル/日・時の締切期限/追加メモなどのリマインダーに関する情報

  • 編集ボタンをタップして編集

  • 編集画面:編集のためのフィールドとピッカーが表示

  • リマインダー追加画面は、この編集画面を再利用。 モーダルで表示
     
    リストの作成へ

ぽちぽち

「Today」の作成

Section1. プロジェクトを作成する

まずは、Todayプロジェクトを作成。Interfaceは、Storyboard。

Section2. CollectionViewControllerの追加

ViewControllerをStoryboardに追加していく。

ViewController

Viewとデータモデルの橋渡し
View階層管理、View内コンテンツの更新、ユーザーインターフェイス内のイベント応答

 
Interface Builder を使用して、CollectionViewControllerを作成していくとのこと。

*Step1
Main.storyboardにあるView Controller Scene をドキュメントアウトラインから消去
→後々リスト表示できるViewControllerに置き換えるため。

*Step2
ライブラリーから、CollectionViewControllerを検索

*Step3
CollectionViewControllerをライブラリーからキャンバスにドラッグ

*Step4

CollectionViewからセルテンプレートを削除
→CollectionViewのセルは、Storyboardではなくコードで定義して作成するため。

*Step5

属性インスペクタで、「Is Initial View Controller」にチェック
→シーンを、Storyboardのエントリーポイントとする。

*Step6
Destinationのポップアップメニューから、「iPhone14Pro」を選択
 
次は、リマインダー用のデータモデルを作成していく

ぽちぽち

Section3. リマインダーモデルを作成

UIKitアプリの一般的なデザインパターン:MVC (Model-View-Controller)

正直、MVVMのVMとMVCのCとの役割の違いが分かっていないので、アーキテクチャに関してはもっと勉強が必要

Viewオブジェクト・・・データを視覚的に表現
Modelオブジェクト・・・アプリのデータとビジネスロジックの管理
ViewController・・・ViewとModelが直接的に影響を及ぼさないことを保証
 
ここからデータモデルを作成していく

*Step1

「Models」グループの作成

*Step2
Modelsグループに、Reminder.swiftファイルの作成

*Step3〜Step5
Reminder.swiftに、下記プロパティ宣言

デフォルト値がなくても、初期化子 (init) の作成は不要
Swiftコンパイラは、構造体にデフォルト値がないプロパティが格納されていても、
各構造体にメンバーごとの初期化子を自動で提供

⇧メンバーワイズイニシャライザ。構造体限定。
クラスの場合は、指定イニシャライザorコンビニエンスイニシャライザが必要 (クラスには、継承関係があるため)

*Step6
ファイルの最後に、サンプルリマインダーデータを保存するextensionを追加
#if DEBUG ブロック内に、extensionを置く

 

#if DEBUG

リリース用のアプリをビルドする時に、囲まれたコードがコンパイルされないようにするコンパイルディレクティブ。
このフラグは、デバッグビルドでコードをテストするため or サンプルテストを提供するために使用。
例えば、デバッグ時のみログ表示させるなど。
 
https://qiita.com/Matsulab/items/290274f06dce73d665ac
上記リンク参考
 
#if <条件>, #end if条件付きコンパイルブロックと呼ぶ。
#elseifや#else で条件分岐も可能。

*Step7
Step6の中に、サンプルリマインダーの静的配列を定義
ここに関しては、サンプルなのでコピペした

静的 (static) であるのは、うっかり内部を改竄してしまうのを防ぐため?
 
Collection View自体のコンポーネントから始めて、Collection Viewの外観調整へ

ぽちぽち

Section4 コレクションをリストとして構成していく

Compositional layout を使用して、Collection Viewの外観を構成していく。
Compositional layoutを使用すると、Section, Group, Itemなどの様々なコンポーネントを組み合わせてViewを構築できる。

Sectionは、ItemのGroupを囲む外側のコンテナビューを指す。

https://zenn.dev/chiii/articles/e487b32e787b59
こちらの記事によると、Compositional layoutは、iOS13〜とのこと
 
CollectionViewControllerのバッキングコードをStoryboardと関連付けさせていく

バッキングコード

iOSアプリケーションの場合、ViewControllerやその他のデータ処理を担当するクラスや構造体
ここでは、CollectionViewを制御するためのViewControllerなどのコード

*Step1
名前の変更
ViewController → ReminderListViewController
UIViewController → UICollectionViewController

*Step2
Main.storyboardを開いて、Identityインスペクター上でViewControllerのクラスIDを、先ほどの「ReminderListViewController」に変更

こうすることで、ReminderListViewControllerクラスインスタンスの特殊なプロパティやメソッドにアクセスが可能になる。
 
事前に定義されている構成を足がかりに、リストの表示方法を定義していく。
*Step3
ReminderListViewControllerで、listLayoutメソッドを作成。

private func listLayout() -> UICollectionViewCompositionalLayout {
        var listConfiguration = UICollectionLayoutListConfiguration(appearance: .grouped)
    }

このメソッドによって、グループ化された外観を持つ新しいリスト構成変数を作成する。
 

UICollectionLayoutListConfiguration

https://developer.apple.com/documentation/uikit/uicollectionlayoutlistconfiguration
リストレイアウトにおいて、sectionを作成する。iOS14〜 対応。
引数appearanceで、ヘッダーの外観が変わってくる。

この時点では、戻り値がないのでエラーが出る。

*Step4
listConfigurationを、区切り文字無効、背景色クリアにする。

private func listLayout() -> UICollectionViewCompositionalLayout {
        var listConfiguration = UICollectionLayoutListConfiguration(appearance: .grouped)
        listConfiguration.showsSeparators = false
        listConfiguration.backgroundColor = .clear

他にもUICollectionLayoutListConfigurationでは、各セクションのヘッダーのパディング量などを指定できる

*Step5
リスト構成を含む新しい構成レイアウトを返す。
引数usingに、listConfigurationを入れることで、レイアウトを指定。

 private func listLayout() -> UICollectionViewCompositionalLayout {
        var listConfiguration = UICollectionLayoutListConfiguration(appearance: .grouped)
        listConfiguration.showsSeparators = false
        listConfiguration.backgroundColor = .clear
        return UICollectionViewCompositionalLayout.list(using: listConfiguration)
    }

戻り値を返したので、エラーが消える。

*Step6〜step7
viewDidLoad() メソッド内で、新しいリストレイアウトを作っていく。
リストレイアウトをCollectionViewレイアウトに割り当てる。

override func viewDidLoad() {
        
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
        let listLayout = listLayout()
        collectionView.collectionViewLayout = listLayout
    }
viewDidLoad()

https://developer.apple.com/documentation/uikit/uiviewcontroller/1621495-viewdidload

このメソッドは、ViewControllerがビュー階層をメモリにロードした後に呼び出される。
この階層が、nibファイルからロードされたか、loadView()メソッドでプログラムで作成されたかに関係なく呼びされる。
通常、このメソッドをオーバーライドして、nibファイルからロードされたビューに対して追加の初期化を行う。

 

ライフサイクルにおいて、画面の読み込みが終わった後行われる処理。
ライフサイクルでは、1度だけ呼ばれる。
例えば、背景色の変更やラベルの初期化などViewが読み込まれたタイミングで行いたい処理を書く。
https://qiita.com/AS_atsushi/items/558919a4685a33682088
https://jp-seemore.com/app/16102/
参考

 
今回の場合は、画面の読み込みが終わった後に、collection viewのレイアウトを定義している

ぽちぽち

Section5 data source

現在、CollectionViewにリストセクションを作成した状態
ここから、

  • Collection Viewにセルを登録
  • content configurationを使用してセルの外観を定義
  • セルをデータソースに接続
    データが更新されたときに、ユーザーインターフェースを更新してアニメーション化する、
    差分可能なデータソースを使用していく。

*Step1
新しいセル登録 (cellRegistration) を作成 (viewDidLoad()内)。

CellRegistration

https://developer.apple.com/documentation/uikit/uicollectionview/cellregistration
表示するセルの内容と外観を設定する。
セルを登録したら、データソースのセルプロパイダーから呼び出すdequeCellに渡す。
そうすることで、自動でセルが登録される。

 let cellRegistration = UICollectionView.CellRegistration {
            (cell: UICollectionViewListCell, indexPath: IndexPath, itemIdentifier: String) in
}

*Step2
Itemに対応するリマインダーを取得する。

 let cellRegistration = UICollectionView.CellRegistration {
            (cell: UICollectionViewListCell, indexPath: IndexPath, itemIdentifier: String) in
            let reminder = Reminder.sampleData[indexpath.item]
}
indexpath

対象のセルのセクションとインデックスを保持
今回は、item ノードの(インデックスパスのアイテム要素の値 Int型)indexpathを取得

*Step3
セルのデフォルトのコンテンツ設定を取得

let cellRegistration = UICollectionView.CellRegistration {
            (cell: UICollectionViewListCell, indexPath: IndexPath, itemIdentifier: String) in
            let reminder = Reminder.sampleData[indexpath.item]
            var contentConfiguration = cell.defaultContentConfiguration()
}
defaultContentConfiguration

https://developer.apple.com/documentation/uikit/uitableviewcell/3601058-defaultcontentconfiguration

デフォルトのスタイルが事前に設定されているが、コンテンツは含まれていないので、
デフォルト設定を取得したら、コンテンツに割り当て、他のプロパティをカスタマイズし、現在のcontentとしてセルに割り当てる。

**Step4〜Step5
セルのコンテンツのtextにリマインダーのtitleを設定し、
この設定をセルへと割り当てる。

let cellRegistration = UICollectionView.CellRegistration {
            (cell: UICollectionViewListCell, indexPath: IndexPath, itemIdentifier: String) in
            let reminder = Reminder.sampleData[indexpath.item]
            var contentConfiguration = cell.defaultContentConfiguration()
            contentConfiguration.text = reminder.title
            cell.contentConfiguration = contentConfiguration
}

 
次からは、セルの内容と外観を設定していく。

ぽちぽち

Section5続き

セルを差分可能なデータソースに接続していく。
*Step6
差分可能なデータソースの型エイリアス(typealias)を追加

class ReminderListViewController: UICollectionViewController {
    typealias DataSource = UICollectionViewDiffableDataSource<Int, String>
    
    var dataSource: DataSource!

    override func viewDidLoad() {
//以下略
typealias

型の別名をつけることができる。
例として、Int

typealias Short = Int16

let i : Short = 20
print(i) // 20
print(i.dynamictype) // Int16

別名をつけているだけなので、中身は元のまま。
既存の型(StringやIntなど)以外にも、自身で定義したclassやstructにも適用可能。
typealiasを使用することで、簡潔で可読性が上がる。
参考
https://qiita.com/shimesaba/items/4d19a7e4c67caca73603

今回の場合は、UICollectionViewDiffableDataSource<Int, String> に DataSourceという別名をつけている状態。

Step7
暗黙的にDataSourceをアンラップ (!) する、dataSourceプロパティを追加。

この後、nilでないことを保証するために、dataSourceを初期化する。

Step8〜Step9
DataSourceのinitに、CollectionViewを渡すことで、差分可能なデータソースとCollectionViewを繋げていく

  • 新しいデータソースの作成
    イニシャライザーで、CollectionViewのセル設定を返すクロージャーを渡す。
    クロージャーは、CollectionView内のセルの場所へのIndexPathと、ItemIdentifierを受け入れる。

  • cell registration を使用して、セルをデキューして返す。

dataSource = DataSource(collectionView: collectionView) {
            (collectionView: UICollectionView, indexPath: IndexPath, itemIdentifier: String) in
            return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: itemIdentifier)
        }

 
データソースを作成して初期化して作成するとこまではできた。
次からは、データが変更されたときにデータソースへ通知するところを設定していく。

ぽちぽち

Section6 スナップショットを適用する

データの状態をスナップショットで管理している。
スナップショット:特定の時点でのデータの状態 (Gitの管理方法と同じ感じ)
ここでは、リストViewにサンプルリマインダーのリストを表示するスナップショットを作成して適用させていく。

Step1
差分可能なデータソーススナップショットの型エイリアスを追加。

class ReminderListViewController: UICollectionViewController {
    typealias DataSource = UICollectionViewDiffableDataSource<Int, String>
    typealias Snapshot = NSDiffableDataSourceSnapshot<Int, String>
    
    var dataSource: DataSource!

//以下略

*Step2
viewDidLoad()内に、snapshot変数を追加。

*Step3
このsnapshotにセクションを追加。

*Step4
リマインダーのtitleのみを含んだ新しい配列を作成し、titleをスナップショットの項目として追加。

まとめると下のようになる。

        var snapshot = Snapshot()
        snapshot.appendSections([0])
        var reminderTitles = [String]()
        for reminder in Reminder.sampleData {
            reminderTitles.append(reminder.title)
        }
        snapshot.appendItems(reminderTitles)

ReminderのsampleData要素を1つずつ反復処理で、reminderTitlesに追加している。

*Step5
先ほどのコードをmapを使って簡潔にまとめる。

+ snapshot.appendItems(Reminder.sampleData.map {$0.title})
- var reminderTitles = [String]()
- for reminder in Reminder.sampleData {
-     reminderTitles.append(reminder.title)
- }
- snapshot.appendItems(reminderTitles)
map

https://developer.apple.com/documentation/swift/array/map(_:)-87c4d
配列の全要素に同じ処理を適用するときに使用

//配列の各要素に対して3をかける
配列名.map { $0 * 3} 

こちらの記事に、元の定義からどのようにmap関数が省略されているかが書かれています。
https://qiita.com/eytyet/items/8b46c07f3fbb5a554c73

*Step6~Step7
スナップショットを、データソースに適用し、
このデータソースをCollection Viewに割り当てる。

        var snapshot = Snapshot()
        snapshot.appendSections([0])
        snapshot.appendItems(Reminder.sampleData.map{$0.title})
        dataSource.apply(snapshot)
        
        collectionView.dataSource = dataSource

*Step8
サンプルリマインダーリストを追加するため、アプリをBuild & Run する。
チュートリアルと同じ画面となるか確認するため、今回はシミュレーターをダークモードに設定した。