前置き
こんにちは。@zrn-nsです。
皆さんiPhoneは使っていますか?
iOS搭載のデバイスは価格が高い代わりに、ロングサポートしてくれるのが嬉しいですよね。
僕自身、スマホはiPhone派です。Androidは良い性能のデバイスを買っても、早々にベンダーによるサポートが打ち切られてしまうため、あまり長く使えず勿体ないんですよね。
デバイスのサポート期間が長いというのは、エンジニアとしても大きなメリットになります。
多くの端末が最新OSにアップデートできるため、殆どの場合古いOSをサポートする必要がありません。一般的には、最新の2メジャーバージョンのみをサポートすればよいと言われているので、最新の開発機能を利用することができます。
昨年9月にiOS14が登場したことで、そろそろiOS12のサポートを終了する事ができます。そうなると**iOS13に追加された幾つかのイケてる機能(SwiftUIやCombine.framework、NSDiffableDataSource等)**を使用する事ができるようになります。
これを見据え、それらの機能の予習をしておこうと思います。
今回やること
今回は、iOS13から使えるようになる下記の2機能を使って、簡単なリスト表示機能(引っ張ってリロード、無限ページング付き)を作ってみます。
- Combine.framework
- NSDiffableDataSource
また今後SwiftUIへの以降も見据え、ViewサイドのアーキテクチャとしてMVVMを採用します。
Combine.frameworkとは
iOS13から使える、Apple純正の非同期処理用のフレームワークです。
RxSwiftやReactiveSwiftに代表されるイベント通知の仕組みや、Promise/Futureなどの非同期処理の仕組みが含まれています。
SwiftUIで使用することも想定されているようです。
NSDiffableDataSourceとは
iOS13から使える、UITableViewやUICollectionViewのDataSourceをより簡単に利用できる仕組みです。
これまでは、表示しているデータが変化した場合には、リストに表示されたセルの追加や削除、移動などを手動で管理する(もしくはreloadDataを呼んでまるごと更新するか)する必要がありましたが、NSDiffableDataSourceを利用すれば、最終的なデータの状態を宣言するだけで、セルの追加や削除、移動などの手続きは自動で行ってくれます。
実装
今回作成したサンプルプロジェクトをGithubに上げてありますので、必要に応じてご覧ください。
https://github.com/zrn-ns/CombineSample
ViewModelの実装
ViewModelの全体ソース(クリックで開きます)
final class ViewModel {
@Published private(set) var newsList: [News] = []
@Published private(set) var paging: Paging? = nil {
didSet {
needsToShowPagingCell = paging?.hasNext ?? false
}
}
@Published private(set) var isLoading: Bool = false
@Published private(set) var needsToShowPagingCell: Bool = false
func viewDidLoad(vc: UIViewController) {
router = Router()
router?.viewController = vc
fetchNewsFromServer()
}
func willDisplayNews(_ news: News) {
if newsList.count - newsList.lastIndex(of: news)! < 5 {
fetchNewsFromServer()
}
}
func willDisplayPagingCell() {
fetchNewsFromServer()
}
@objc func pulledDownRefreshControl() {
paging = nil
newsList = []
fetchNewsFromServer()
}
// MARK: - private
private var router: Router?
private var cancellables: Set<AnyCancellable> = []
private func fetchNewsFromServer() {
guard !isLoading else { return }
isLoading = true
NewsRepository.fetchDataFromServer(paging: paging).sink { [weak self] completion in
guard let _self = self else { return }
_self.isLoading = false
switch completion {
case .failure(let error):
_self.router?.showError(error)
case .finished:
break
}
} receiveValue: { [weak self] (newsList: [News], paging: Paging) in
self?.newsList.append(contentsOf: newsList)
self?.paging = paging
}.store(in: &cancellables)
}
}
部分部分に分けて説明します。
ViewModel側のイベントをViewに伝えるための監視可能プロパティ群
@Published private(set) var newsList: [News] = []
@Published private(set) var paging: Paging? = nil {
didSet {
needsToShowPagingCell = paging?.hasNext ?? false
}
}
@Published private(set) var isLoading: Bool = false
@Published private(set) var needsToShowPagingCell: Bool = false
View側からViewModelの変化を監視するためのプロパティです。
@Published
というPropertyWrapperを付与することで、通常のPropertyに監視可能な要素としての振る舞いを付与する事ができます。
また全て@Publishedをつけていますが、これらは簡単に監視可能プロパティを宣言するためであり、必ずしも付与する必要はなく、Publisherなどの監視可能プロパティを自力で宣言する事もできます。
View側のイベントをViewModelに伝えるためのメソッド群
func viewDidLoad(vc: UIViewController) {
router = Router()
router?.viewController = vc
fetchNewsFromServer()
}
func willDisplayNews(_ news: News) {
if newsList.count - newsList.lastIndex(of: news)! < 5 {
fetchNewsFromServer()
}
}
func willDisplayPagingCell() {
fetchNewsFromServer()
}
@objc func pulledDownRefreshControl() {
paging = nil
newsList = []
fetchNewsFromServer()
}
View側でイベントが発生したときに、それをViewModelに伝えるためのメメソッド群です。
イベントに応じて、データの取得などの処理を行っています。
データ取得用メソッド
private func fetchNewsFromServer() {
guard !isLoading else { return }
isLoading = true
NewsRepository.fetchDataFromServer(paging: paging).sink { [weak self] completion in
guard let _self = self else { return }
_self.isLoading = false
switch completion {
case .failure(let error):
_self.router?.showError(error)
case .finished:
break
}
} receiveValue: { [weak self] (newsList: [News], paging: Paging) in
self?.newsList.append(contentsOf: newsList)
self?.paging = paging
}.store(in: &cancellables)
}
Repositoryにアクセスしてデータを取得し、取得できたらViewModelの状態を更新しています。
NewsRepository.fetchDataFromServer(paging:)
の戻り値はPromise
になっており、非同期処理を実現しています。
(Future/PromiseもCombine.frameworkの機能の一部です)
Viewの実装
ViewControllerの全体ソース(クリックで開きます)
private let NewsCellClassName = String(describing: NewsCollectionViewCell.self)
private let NewsCellIdentifier: String = NewsCellClassName
private let PagingCellClassName = String(describing: PagingCollectionViewCell.self)
private let PagingCellIdentifier = PagingCellClassName
class ViewController: UIViewController {
enum Section: Int {
case news
case paging
}
enum Item: Hashable {
case news(news: News)
case paging
}
let viewModel: ViewModel = .init()
// MARK: - lifecycle
override func viewDidLoad() {
super.viewDidLoad()
viewModel.viewDidLoad(vc: self)
setupSubscription()
}
// MARK: - outlet
@IBOutlet private weak var collectionView: UICollectionView! {
didSet {
collectionView.delegate = self
collectionView.dataSource = collectionViewDataSource
collectionView.collectionViewLayout = Self.createLayout()
collectionView.refreshControl = refreshControl
collectionView.register(UINib(nibName: NewsCellClassName, bundle: nil), forCellWithReuseIdentifier: NewsCellIdentifier)
collectionView.register(PagingCollectionViewCell.self, forCellWithReuseIdentifier: PagingCellIdentifier)
}
}
// MARK: - private
private var cancellables: Set<AnyCancellable> = []
private lazy var refreshControl: UIRefreshControl = {
let control = UIRefreshControl()
control.addTarget(viewModel, action: #selector(ViewModel.pulledDownRefreshControl), for: .valueChanged)
return control
}()
private lazy var collectionViewDataSource: UICollectionViewDiffableDataSource<Section, Item> = .init(collectionView: collectionView) {
(collectionView: UICollectionView, indexPath: IndexPath, item: Item) -> UICollectionViewCell? in
// 要素に対して、どのようにセルを生成するかを定義する
switch item {
case .news(let news):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: NewsCellIdentifier, for: indexPath) as! NewsCollectionViewCell
cell.set(.init(news: news))
return cell
case .paging:
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PagingCellIdentifier, for: indexPath) as! PagingCollectionViewCell
cell.startAnimating()
return cell
}
}
/// イベントの購読の登録を行う
private func setupSubscription() {
// ニュース一覧
viewModel.$newsList.receive(on: DispatchQueue.main).removeDuplicates().sink { [weak self] _ in
self?.updateDataSource()
}.store(in: &cancellables)
// ページングセル
viewModel.$needsToShowPagingCell.receive(on: DispatchQueue.main).removeDuplicates().sink { [weak self] _ in
self?.updateDataSource()
}.store(in: &cancellables)
// ロード中表示
viewModel.$isLoading.receive(on: DispatchQueue.main).removeDuplicates().sink { [weak self] isLoading in
UIApplication.shared.isNetworkActivityIndicatorVisible = isLoading
if !isLoading {
self?.refreshControl.endRefreshing()
}
}.store(in: &cancellables)
}
/// CollectionViewのLayoutを作成する
private static func createLayout() -> UICollectionViewLayout {
UICollectionViewCompositionalLayout { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(50))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
return section
}
}
/// CollectionViewのデータソースを更新する
private func updateDataSource() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.news])
snapshot.appendItems(viewModel.newsList.map({ .news(news: $0) }), toSection: .news)
if viewModel.needsToShowPagingCell {
snapshot.appendSections([.paging])
snapshot.appendItems([.paging], toSection: .paging)
}
collectionViewDataSource.apply(snapshot, animatingDifferences: true)
}
}
extension ViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
guard let item = (collectionView.dataSource as? UICollectionViewDiffableDataSource<Section, Item>)?.itemIdentifier(for: indexPath) else {
fatalError("不正な状態")
}
switch item {
case .news(let news):
viewModel.willDisplayNews(news)
case .paging:
viewModel.willDisplayPagingCell()
}
}
}
DiffableDataSource関連のコード
まず、UICollectionViewDiffableDataSource
の生成を行います。
UICollectionViewDiffableDataSource
の宣言時に、ジェネリック型としてセクションの種類と、そこに表示されるデータ(≒セル)の種類を設定します(今回はSection, Itemというenumで表現しました)。
またUICollectionViewDiffableDataSource
のイニシャライザには、データに対してどのようなセルを生成するかをClosureとして定義して渡す必要があります。
- ニュースの要素(
Item.news(news:)
)に対してはNewsCollectionViewCell
を - ページングの要素(
Item.paging
)に対してはPagingCollectionViewCell
を
返すように実装しました。
enum Section: Int {
case news
case paging
}
enum Item: Hashable {
case news(news: News)
case paging
}
private lazy var collectionViewDataSource: UICollectionViewDiffableDataSource<Section, Item> = .init(collectionView: collectionView) {
(collectionView: UICollectionView, indexPath: IndexPath, item: Item) -> UICollectionViewCell? in
switch item {
case .news(let news):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: NewsCellIdentifier, for: indexPath) as! NewsCollectionViewCell
cell.set(.init(news: news))
return cell
case .paging:
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PagingCellIdentifier, for: indexPath) as! PagingCollectionViewCell
cell.startAnimating()
return cell
}
}
次に、レイアウトを組み立てます。
/// CollectionViewのLayoutを作成する
private static func createLayout(collectionViewWidth: CGFloat) -> UICollectionViewLayout {
UICollectionViewCompositionalLayout { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(50))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
return section
}
}
UICollectionViewCompositionalLayoutを利用し、レイアウトを構成します。
sectionIndexで渡されたセクションに対して、どのようにレイアウトを組むのか宣言するのですが、今回はただ単にTableViewのように、縦一列のリストのレイアウトを生成しています。
itemSize
は各要素のサイズを示し、今回はwidthDimensionに.fractionalWidth(1.0)
を設定することで、CollectionViewの横いっぱいに広がるようなレイアウトを実現しています。
またNSCollectionLayout[Section|Group|Item]
という型が登場していますが、それぞれセクション全体、セクション内のグループ、それぞれのアイテムを示しています。
今回は1グループ1アイテムになっているのであまり活用できていませんが、うまく設定すると入れ子のような構造を実現できるようです。
次にDiffableDataSourceを更新するためのメソッド定義です。
NSDiffableDataSourceSnapshotを生成し、それをDiffableDataSourceのapply()
メソッドに渡すことで、自動的に画面が更新されます。
今回はviewModel
に最新のデータが乗っているので、それらをかき集めてSnapshotを生成しています。
/// CollectionViewのデータソースを更新する
private func updateDataSource() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.news])
snapshot.appendItems(viewModel.newsList.map({ .news(news: $0) }), toSection: .news)
if viewModel.needsToShowPagingCell {
snapshot.appendSections([.paging])
snapshot.appendItems([.paging], toSection: .paging)
}
collectionViewDataSource.apply(snapshot, animatingDifferences: true)
}
最後に、下記のように、ここまで作成したdataSourceとLayoutをCollectionViewに設定します。
@IBOutlet private weak var collectionView: UICollectionView! {
didSet {
collectionView.dataSource = collectionViewDataSource
collectionView.collectionViewLayout = Self.createLayout()
collectionView.register(UINib(nibName: NewsCellClassName, bundle: nil), forCellWithReuseIdentifier: NewsCellIdentifier)
collectionView.register(PagingCollectionViewCell.self, forCellWithReuseIdentifier: PagingCellIdentifier)
}
}
MVVM関連のコード
let viewModel: ViewModel = .init()
private var cancellables: Set<AnyCancellable> = []
/// イベントの購読の登録を行う
private func setupSubscription() {
// ニュース一覧
viewModel.$newsList.receive(on: DispatchQueue.main).removeDuplicates().sink { [weak self] _ in
self?.updateDataSource()
}.store(in: &cancellables)
// ページングセル
viewModel.$needsToShowPagingCell.receive(on: DispatchQueue.main).removeDuplicates().sink { [weak self] _ in
self?.updateDataSource()
}.store(in: &cancellables)
// ロード中表示
viewModel.$isLoading.receive(on: DispatchQueue.main).removeDuplicates().sink { [weak self] isLoading in
UIApplication.shared.isNetworkActivityIndicatorVisible = isLoading
if !isLoading {
self?.refreshControl.endRefreshing()
}
}.store(in: &cancellables)
}
override func viewDidLoad() {
super.viewDidLoad()
viewModel.viewDidLoad(vc: self)
setupSubscription()
}
}
ViewModelをローカル変数として保持し、ViewModel内の監視可能プロパティ(Publisher)を監視しています。
ViewModel側で@Published付きで宣言されたnewsList
やneedsToShowPagingCell
などのプロパティは、先頭に$
をつけてアクセスすることで、監視可能なPublisherを取得することができます。
また、@Publishedを使ったPublisherのイベントの発行タイミングはwillSetのタイミングになっており、receive(on: DispatchQueue.main)
を使わずに監視すると、プロパティの実体が変化する前に処理が行われてしまい、意図しない動作をする可能性があるため注意が必要です。
まとめ
今回はiOS13に同梱される予定の機能を幾つか利用してみました。
本当はSwiftUIへの書き換えまでやろうかと思ったのですが、SwiftUIではUIRefreshControlがまだ使えないらしく、一旦後回しにすることにしました😇
(やりようはあるようなので、今後時間があれば試してみようと思います)
まだReactiveProgrammingに慣れていないため、こう書いたほうがいいよ!みたいなのがあれば、ぜひコメントで教えてください🙇♂️