20191223のiOSに関する記事は18件です。

古いXcodeで新しいOSの端末を動作させる

新しいiOSがリリースされたとき、それに対応したバージョンにXcodeをアップデートすることができない場合があります。
このような場合でもXcodeのバージョンをそのままに新しいiOSの端末でアプリをデバッグ実行できるようにする方法があります。

  1. 新しいXcodeのdmgをダウンロードし、インストール
  2. 新しいXcodeを起動すると、インストールのダイアログが表示されることがあるので、そのままインストール
  3. 新しいXcodeを右クリックし、パッケージの内容を表示をクリック
  4. 下記パスに新しいOSのフォルダがありますので、それをコピー
    • Contents/⁨Developer/⁨Platforms/⁨iPhoneOS.platform⁩/⁨DeviceSupport/XX.X
  5. 使用するXcodeへ上記と同じ階層へペースト

以上の操作により、利用できるようになりますが、うまく行かないこともありますので、参考程度にお願いします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

人種も育ってきた環境も違う二人がともに生活を始め、価値観の違いを埋めながら成長し、まだ見ぬ未来に希望をつなげる話

はじめに

先にタイトルのオチを書いてしまいましょう!

人種も育ってきた環境も違う二人がともに生活を始め、価値観の違いを埋めながら成長し、まだ見ぬ未来に希望をつなげる話

OSもこれまでの保守会社も違うiOSとAndroidアプリを一つの会社がまとめて受け持つことになり、過去の実装方針や思想の違いを感じながら保守を行い、いつ行われるかわからない次のリニューアルに向けで準備をすすめる話です。

弊社では現在デイリーアクティブユーザー120万人超えのモバイルアプリの保守をしています。
2011年から8年間iOSの保守のみを受け持って来たのですが、その実績から今年の5月末にAndroidもやってほしいと言われ、是非にということで、そのお話をお受けしました。
私自身がiOSよりもAndroid寄りのエンジニアなので、いつかAndroidの保守もやれればと思っていたことが、ついに現実になったのです!!

ところが、そんなに甘い話ではなかった

Androidは今まで保守していた会社が複数あり、内部の構造はかなり煩雑
クラスの命名規則が意味不明、変数はキャメルケースとスネークケースが入り混じり、可読性最悪
更に前回行ったUIリニューアルで問題が悪化、ライフサイクルを無視した書き方やそもそもライフサイクルが正常に呼ばれないなどたくさんの問題を抱えていました。

さて本題

色々すっ飛ばしますが、あの手この手を使って修正を行い、最低限想定通りな挙動をすることろまで修正を行うことができました。
この半年間何度ココロが折れそうになったことか。。。。(←ただ愚痴りたかっただけw)
でも今回のテーマは未来に希望を繋げる話です。
過去は過去ということで一旦水に流して本当に流せるのか?おい!、次のリニューアルにどんなことをやってやろうか考えてワクワクしたいと思います。
夢は大きく持って勉強していきましょ

言語

選択肢としてはほぼ一択と言っていいほどですよね
もし新規案件だとしたらフレームワーク使うという方法もあるかもしれません

Android

  • Java
  • Kotlin

iOS

  • Objective-c
  • Swift

アーキテクチャ

ここはそれなりに選択肢が多くなります。
初めてAndroidでMVVMに触れたときはかなりの衝撃で、それ以来AndroidではMVVMを溺愛しております
iOSのMVVMにはRxSwiftが不可欠、ならばいっそのことという感じで、Clean Architectureを選択

Android

  • MVP
  • MVVM
  • Clean Architecture
  • Flux

iOS

  • MVC
  • MVP
  • MVVM
  • Clean Architecture
  • Flux
  • Redux
  • VIPER

Rx

正直まだRxはちゃんと使ったことがありません、今回引き継いたAndroid版で使っていて正直意味不明
学習コストが高いと言われるのはやはり間違っていないのだなという印象、でもこれがないと始まらないんですよね。。。

Android

  • RxJava
  • RxAndroid
  • RxKotlin

iOS

  • RxSwift
  • RxCocoa

通信系

Androidに関してはVolleyはそもそもDeprecatedですし。。。
OkHttpとRetrofitの組み合わせはについては、何も言うことはありませんね
iOSはAlamofireをラップしたライブラリMoyaを選択します
更にシンプルに実装ができるそう

Android

  • Retrofit2
  • OkHttp3
  • Volley

iOS

  • Alamofire
  • Moya

画像系

画像系からはすでに使ったことのあるものをチョイスしました

Android

  • glide
  • picasso

iOS

  • SDWebImage
  • AlamofireImage

DB

やはりレガシーなDBより高速でわかりやすいRealmを使用します

Android

  • MySQL
  • Realm

iOS

  • CoreData
  • Realm

Jsonパーサー

Jsonパーサーも同じく使ったことがあるものを選択です

Android

  • gson
  • Jackson

iOS

  • Codable
  • SwiftyJSON

DI

Android

せっかくkotlinを使うのだらkoinを使いましょう

  • dagger2
  • koin

その他

その他にも便利そうなライブラリをいくつか

Android

  • Data Binding Library
  • LiveData
  • MergeRecyclerAdapter

iOS

  • FrameAccessor
  • NSLayoutAnchor

まとめ

という感じで、未来に向けてボクの考える最強のアプリ開発設計です。
でもまだまだ抜けはある気がする。。。
もしこれが全部使いこなすことができれば、それなりのレベルの開発者になれるはず。
そしてどんどん新しい技術が出ているので、これだけにこだわらずアンテナを広げていきたいなと思います!

そう言えば先日弊社が猫会社として取材されました!!
https://sippo.asahi.com/article/12960031
猫が好きな方はぜひ読んでみてください♪

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOS 13でMapKitに新しく追加されたPOI(Point of Interest)フィルタリングを触ってみる

はじめに

WWDC19で発表された内容が濃すぎて,
調べて使ってみたいけど扱えていない内容がまだまだたくさんです?

今回はそのひとつで iOS 13 から新しく MapKit に追加され
Point of Interest(POI) フィルタリングを触ってみました。

このセッションの短い時間ではありましたが紹介されています。(10分あたりから)
What’s New in MapKit and MapKit JS
https://developer.apple.com/videos/play/wwdc2019/236/

MapKit における POI とは

MapKit における POI とは Map に表示されている,
レストランとか学校など様々な場所表示です。それぞれアイコンもあります。
言われてみればあーあれかってなりますよね。
スクリーンショット 2019-12-23 18.22.04.png
種類的には,執筆時点で 40種類あります。(セッション資料より)
Apple Park みたいな特別な場所は専用のものが用意されているようです。
スクリーンショット 2019-12-23 18.22.04.png
MapKit チームはデベロッパにもっとこういうのあったらどうか?と
ユースケースや提案があれば是非頼むというスタンスのようです。

参考:MKPointOfInterestCategory
https://developer.apple.com/documentation/mapkit/mkpointofinterestcategory?language=swift

iOS 12 までの POI のフィルタリング

通常の Map アプリでは情報が多くてもいいと思います。
都市部だと明らかにアイコンの乱立がありユーザの目移りが問題になります。
それぞれのアプリの Map の用途によって
POI のフィルタリング(出し分け)ができるとアプリの UX が上がりそうです。

iOS 12 までは,POI の全表示 or 全非表示はできていました。
MapView の showsPointsOfInterest に Bool 値を与えていました。
コードは下記です。

let mapView = MKMapView()
mapView.showsPointsOfInterest = false

こんな感じで POI が非表示になります。全部非表示にはならないんだなぁ。
純正 Map 自体も更新されているだろうし,APIの方がついていってない感じ。

デフォルト 設定OFF
IMG_1919.PNG IMG_1920.PNG

参考:showsPointsOfInterest
https://developer.apple.com/documentation/mapkit/mkmapview/1452102-showspointsofinterest?language=swift

iOS 13 以降の POI フィルタリング

今までは,POI を全表示 or 全非表示しかできなかったですが,
iOS 13 からフィルタリングが可能になりました。

showsPointsOfInterest
iOS 13 以降では,レファレンスの通り Deprecated になっています。
代わりに pointOfInterestFilter を使うように変わっています。

参考:pointOfInterestFilter
https://developer.apple.com/documentation/mapkit/mkmapview/3143417-pointofinterestfilter?language=swift

pointOfInterestFilter を使って,POI のフィルタリングができます。
前述した MKPointOfInterestCategory が出し分けとして利用されます。

以下 3種類の POI フィルタリングを紹介します。

都合によりスクショを貼っていますが,
マップの拡大・縮小を行なっても設定は維持されます。

この POI だけ表示したい,ケース

POI が多いので レストランやカフェ,フードマーケットだけ表示させたいなぁ〜
そういう場合は,MKPointOfInterestCategory を格納する Array を用意して,
レストランとカフェ,フードマーケットをフィルタリングします。
include なので直感的でわかりやすいですね。

let category: [MKPointOfInterestCategory] = [.restaurant, .cafe, .foodMarket]
let filter = MKPointOfInterestFilter(including: category)
mapView.pointOfInterestFilter = filter

実行結果はこちらです。まだ多い気もするけどだいぶ絞れましたね。
よく見るとデフォルトだと表示されないレストランも表示されています。
これでレストラン探しが捗りそうな気がしますね!

デフォルト フィルタリング後
IMG_0521.PNG IMG_0523.PNG

この POI だけ表示したくない,ケース

逆に飲食店を非表示にしたいなぁ〜 みたいなケースがあったとしましょう。

逆に exclude で該当のカテゴリのみ非表示にできます。

let category: [MKPointOfInterestCategory] = [.restaurant, .foodMarket, .cafe]
let filter = MKPointOfInterestFilter(excluding: category)
mapView.pointOfInterestFilter = filter

こちらも飲食店が非表示になって表示されるようになったお店などがあります。

デフォルト フィルタリング後
IMG_0521.PNG IMG_0524.PNG

全部表示しない,ケース

該当のアノテーション以外の情報はいらないんや〜
という場合は excludingAll を使います。

let filter = MKPointOfInterestFilter.excludingAll
mapView.pointOfInterestFilter = filter

結果は下記です(左側)。
今までの showsPointsOfInterestfalse のときよりも POI が消えてますね。
あまりないケースとは思います。

[新]全て非表示 [旧]全て非表示
IMG_1920.PNG IMG_1920.PNG

具体的なユースケース(妄想)?

POIのフィルタリングはむやみに設定するものではなく,
アプリを利用するユーザのことを意識して行うべきです。
何個か妄想してみました。

例1

例えば,飲食店のアプリでお店の場所を示すのに住所と Map を掲載するとします。
お店に駐車場がないので周りのコインパーキングなどを利用してほしいとします。
この場合,駐車場のアイコンだけを表示するようにすると
ユーザは他のロケーションに目移りせずに駐車場の位置を意識できます。

let category: [MKPointOfInterestCategory] = [.parking]]
let filter = MKPointOfInterestFilter(including: category)
mapView.pointOfInterestFilter = filter
フィルタリング前 フィルタリング後
IMG_0526.PNG IMG_0525.PNG

例2

つぎは海外カンファレンスなどのイベント系アプリを考えてみます。
同じく会場の場所を示すのに住所と Map を掲載するとします。
世界各国のデベロッパが会場のある街(都市,国)に訪れます。
ユーザが気になるのは会場近くのレストラン,ホテル,駅なのではないでしょうか?
あるいは観光する人もいるかもしれません。

let category: [MKPointOfInterestCategory]
    = [.hotel,
       .restaurant, .cafe,
       .airport, .publicTransport,
       .beach, .amusementPark, .nightlife]
let filter = MKPointOfInterestFilter(including: category)
mapView.pointOfInterestFilter = filter
フィルタリング前 フィルタリング後
IMG_0527.PNG IMG_0528.PNG

うーんイマイチかな。マップ拡大しないと恩恵少ない。
WWDC は会場から近いホテルがおすすめです。(私はフェアモント派)
VTA ライトレール使えるので駅近もいいですね。
現地では,体調第一でいろいろ決めると良いです。時差もありますしね。
来年も行きたいなぁ?

SwiftUI でも使える

SwiftUI でも MapKit を使っているので,書き方はほぼ同じです。
SwiftUI での MapView の使い方の復習と
POIフィルタリングを記事にしようと思っていたのですが計画狂っちゃいました。

ContentView.swift
import SwiftUI
struct ContentView: View {
    var body: some View {
        ScrollView {
            VStack(alignment: .leading) {
                // MapView Part
                MapView()
                    .frame(height: 300.0)
                // 省略
            }
        }
    }
}

UIViewRepresentable に準拠させます。
makeUIView 関数内で frame のコード書いても無視されるっぽくて
初期化する View で設定するとちゃんと意図通りに動くようです。

MapView.swift
import SwiftUI
import MapKit    // これが必須

struct MapView: UIViewRepresentable {

    func makeUIView(context: Context) -> MKMapView {
        let mapView = MKMapView(frame: .zero)
        return mapView
    }

    // Required
    func updateUIView(_ uiView: MKMapView, context: UIViewRepresentableContext<MapView>) {
        let center = CLLocationCoordinate2DMake(緯度, 経度)
        let span = MKCoordinateSpan(latitudeDelta: 0.002, longitudeDelta: 0.002)
        let region = MKCoordinateRegion(center: center, span: span)
        uiView.setRegion(region, animated: true)

        // POIフィルタリング(UIKitと使い方は変わらない)
        let category: [MKPointOfInterestCategory] = [.parking, .publicTransport]
        let filter = MKPointOfInterestFilter(including: category)
        uiView.pointOfInterestFilter = filter

        // アノテーションを表示
        let annotation = MKPointAnnotation()
        annotation.coordinate = center
        annotation.title = "アノテーションタイトル"
        uiView.addAnnotation(annotation)
    }
}

Enjoy SwiftUI vol2 でライブコーディングした
サンプルアプリのイベント詳細画面を更新してみました。
フィルタリングは [.parking, .publicTransport] にしています。
駐車場や駅の情報が確認できますね。

RPReplay_Final1577104328.gif

サンプルコードはこちらです。
https://github.com/MilanistaDev/StudyGroupEventFetcherForSwiftUI

connpass の API 叩いてリストに表示する部分の記事もよろしければご覧ください。
【SwiftUI】外部APIを叩いて取得した結果をListに表示する
https://qiita.com/MilanistaDev/items/64dca8c9d5099a19529e

おわりに

今回は,iOS 13 の Mapkit に新しく追加された
POI のフィルタリングについて書きました。

頭の片隅に置いて,これからは Map を実装する際は,
Map 上の POI のことを少し意識してみてはいかがでしょうか?
ユーザの目線で考えることが大事です。

ご覧いただきありがとうございました!

今年も多くの方に支えられて楽しく1年過ごすことができました。
来年も引き続きアウトプット駆動で精進していこうと思っておリます!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React Nativeを始めて4ヶ月の人間が1週間でどこまでアプリを作れるか?

React Native Advent Calendar 2019 21日目の記事です。

はじめに

初めまして。
ecbo株式会社でエンジニアをしている江原(@yakiniku)と申します。
今年の8月にecbo株式会社に転職してきて初めてアプリの開発に携わったので、何かしらアウトプットしたいと思い、今回React Native Advent Calendar参加させていただきました。(土日体調崩して公開遅れました。:bow_tone1:

内容はReact Nativeを始めて4ヶ月の人間が1週間でどこまでアプリが作れるかという内容です。(担当する曜日の1週間前に決めました。そのため内容は薄いのと、他の方の記事と比べて役になるような内容はほとんどないのでご了承ください。)

1週間で作った成果物

僕自身幼稚園から料理趣味でやっていて、日頃料理のメモをよく取るので個人で使いやすいようなメモアプリにしました。

output.gif

gifなので画質が荒くてすみません。
この1週間で終わっている内容として
- Splash Screenの設定
- 起動時のLottie作成
- BottomMenuの作成
- 投稿ボタンからフォームへの遷移
- TextInputの追加実装
- 画面ロックの設定
が完了しました。

平日普通に働いてるのもあり、あまり進んでいませんが起動からフォームまでの導線がある程度できたのは良かったかなと思います。
現状の開発でそこまで実装の沼にハマった部分はないですが、今後FireStore周りやAlgoliaを使う予定なのでそこで実装の沼にハマる気がしますw

今後の開発として
- settingページ作成
- FireStoreに投稿
- FireStoreからデータを取得
- listページ作成
- Algoliaを使っての検索
- Bitriseを使ってのbuild
- App Distributionを使ってのアプリ配布
- Android端末での開発

を実装する予定です。
ここで得られた知見があったらどんどん記事にしようかと思ってます。

モジュール一覧

現状使用しているものは以下の内容です。
- lodash
- lottie-react-native
- react-native-splash-screen
- react-native-vector-icons
- react-navigation
- react-navigation-stack
- react-navigation-tabs
- typescript
- react-native-keyboard-aware-scroll-view

まとめ

アプリを開発してまだまだ日が浅いですが、Webとは違った開発の楽しさがあるので
またReactをいじったことがあってアプリを作りたい人にはかなりおすすめです。

あと個人的にはreact-navigationはかなり使いやすいなと思いました。
ドキュメントは英語ですが、機能がかなり充実しているので汎用性が高いモジュールかなと思います。

今後もReact Nativeに関して色々発信できたらと思いますので宜しくお願い致します。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

忘備録-iOSの通信できるアプリ

趣味でiOSアプリ開発をかじっていた自分が、改めてiOS開発を勉強し始めた際に、曖昧に理解していたところや知らなかったところをメモしています。いつか書き直します。

参考文献

この記事は以下の書籍の情報を参考にして執筆しました。

メイン実行ループとスレッドの基本

イベント処理の概要図

image.png

メインスレッドとワーカースレッド

メインキュー : イベントキューの中のUIの操作や更新を行うためのキュー。
メインスレッド : メインキューからイベントを取り出して処理をするスレッド。

メイン処理で時間がかかる処理を行うと、そのUIの更新処理がされないためアプリがフリーズして見える。

ワーカスレッド : 時間がかかる処理を行う用のスレッド。メインキューとは別のスレッドを持つ。UIの更新はできない。

image.png

通信を行うサンプルコード

gooラボ様のひらがな化APIを叩くサンプル。
入力した文字列の読み方を表示する。

APIsample.gif

image.png

storyboard
image.png

import UIKit

class ViewController: UIViewController {

  @IBOutlet weak var resultDisplay: UITextView!
  @IBOutlet weak var textField: UITextField!
  override func viewDidLoad() {
    super.viewDidLoad()
    // Do any additional setup after loading the view.
  }

  @IBAction func tapButton(_ sender: Any) {
    if let text = textField.text {
      hiraganaConversion(sentence: text)
    }
  }

  func hiraganaConversion(sentence: String) {
    let url = URL(string: "https://labs.goo.ne.jp/api/hiragana")
    guard let requestUrl = url else { return }

    let httpBody = getHttpBody(sentence: sentence, outPutType: "hiragana")
    let postUrl = createPostUrl(requestUrl: requestUrl, httpBody: httpBody)
    postRequest(request: postUrl)
  }

  func getHttpBody(sentence: String, outPutType: String) -> String{
    let appId = ****API_Keyを入力してください*****
    let outPutType = outPutType
    return "app_id=\(appId)&sentence=\(sentence)&output_type=\(outPutType)"
  }

  func createPostUrl(requestUrl: URL, httpBody: String) -> URLRequest {
    var request = URLRequest(url: requestUrl)
    request.httpMethod = "POST"
    request.httpBody = httpBody.data(using: .utf8)
    return request
  }

  func postRequest(request: URLRequest){
    let session = URLSession.shared
    let task = session.dataTask(with: request) { (data: Data?, response: URLResponse?, error: Error?) in
      // 通信終了後の処理

      // エラーの場合、本当はちゃんとエラー表示とかするべき
      guard error == nil else { return }
      guard let data = data else { return }
      do {
        let decoder = JSONDecoder()
        // ここでスネークケースの変数名をキャメルケースに置き換えている
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        let result = try decoder.decode(ResultSet.self, from: data)
        print(result.converted)
        // ラベルに描画する処理を実施。ここでメインキューにイベントを渡す
        DispatchQueue.main.async {
          self.resultDisplay.text = result.converted
        }
      } catch {
        print("error")
      }
    }
    // 通信開始
    task.resume()
  }

}

class ResultSet: Codable {
  let converted: String
  let outputType: String
  let requestId: String
//  // スネークケースの変数名をキャメルケースに置き換える方法その2
//  private enum CodingKeys: String, CodingKey{
//    case converted
//    case outputType = "output_type"
//    case requestId = "request_id"
//  }
}

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

忘備録-iOSのフレームワークとアニメーション

趣味でiOSアプリ開発をかじっていた自分が、改めてiOS開発を勉強し始めた際に、曖昧に理解していたところや知らなかったところをメモしています。いつか書き直します。

参考文献

この記事は以下の書籍の情報を参考にして執筆しました。

フレームワーク

フレームワーク : 目的別にまとめた機能の集まり。
UIKitもフレームワーク。

iOSが用意しているフレームワークは大きく4つのレイヤーに分かれている。

Cocoa Touch : iOSアプリを作成するための主要なフレームワークが含まれる。アプリのUI、タッチ操作、プッシュ通知など。
Media : サウンド、グラフィックス、オーディオなど。
Core Services : アプリの基本的なサービスに必要な機能。位置情報の取得、ソーシャル機能など。
Core OS : テクノロジーの土台となる機能。外部とのハードウェアとの通信を明示的に扱う際などに使う。

フレームワークとサイズ

公式のフレームワークを使ってもアプリのサイズは変わらない。
iPhoneの実機に最初から組み込まれている。しかしiOSのバージョンによって使えないものもある。
例えばiCloudを操作する機能CloudKitはiOS8からしか搭載されていないので、iOS7以下の実機だと動作しない。

フレームワークをimportする理由

フレームワークをimportすると、アプリのビルド時間が長くなり、アプリの起動時間が遅くなるので必要なものだけをできるだけ絞って使うのがいい。

iOSのアニメーション処理

iOSではCore Animationと呼ばれるアニメーションの基盤機能を利用して画面の動きを実現している。
Core Animationを利用するとビューの状態変化をアニメーションで表現できる。
Core Animationは一度ビューをビットマップ画像に変換し、グラフィック処理用のハードウェアでアニメーションの描画処理を行なっている。

UIViewのレイヤー

レイヤーはアニメーションの視覚的処理を管理している。
レイヤーはビューに性質がよく似た軽量オブジェクト。
ビューの視覚的な情報をプロパティとして持っている。

簡単なアニメーションを実装1

ボタンをタップした時Viewの角を丸くする。

角丸アニメーション.gif

storyboard
image.png

ViewController
import UIKit

enum AnimationKey: String {
  case cornerRadius
}

class ViewController: UIViewController {

  @IBOutlet weak var greenView: UIView!

  override func viewDidLoad() {
    super.viewDidLoad()
    // Do any additional setup after loading the view.
  }

  @IBAction func tapStartButton(_ sender: Any) {
    viewToRound(view: greenView, duration: 3.0)
  }

  func viewToRound(view: UIView, duration: Double = 1.0) {
    let animation = CABasicAnimation(keyPath: AnimationKey.cornerRadius.rawValue)

    animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
    animation.fromValue = 0
    animation.toValue = 100
    animation.duration = duration
    view.layer.add(animation, forKey: AnimationKey.cornerRadius.rawValue)
    view.layer.cornerRadius = 100
  }  
}

処理のメモ

    // インスタンスを生成する際に対象となるプロパティを指定する。
    // 角を丸めるアニメーションを実施するので文字列でcornerRadiusを指定する。
    let animation = CABasicAnimation(keyPath: AnimationKey.cornerRadius.rawValue)

    // アニメーション変化のタイミングを指定
          /* CAMediaTimingFunctionのインスタンス生成時に指定するプロパティは
          default:標準, linear:一定速度, easeIn:段々速度上昇, easeOut:段々速度減少,
          easeInEaseOut:速度上昇後速度減少 */
    animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)

    // アニメーション開始時の値
    animation.fromValue = 0

    // アニメーション終了時の値
    animation.toValue = 100


    // アニメーション時間
    animation.duration = duration

    // 作成したアニメーションのインスタンスをレイヤーに設定
    // 角を丸めるアニメーションを実施するので文字列でcornerRadiusを指定する。
    view.layer.add(animation, forKey: AnimationKey.cornerRadius.rawValue)

    // アニメーション後のViewの値を指定する。指定しないと元の値のまま表示される。
    view.layer.cornerRadius = 100

簡単なアニメーションを実装2

ボタンを押すたびに画像を表示してゆっくり消していく。

(AudioToolBoxなどをimportして画像を表示するときに音を鳴らしたかった)

音符表示アニメーション.gif

storyboard
image.png

import UIKit

enum Icon: String {
  case icon0 = "noteIcon0"
  case icon1 = "noteIcon1"
  case icon2 = "noteIcon2"
  case icon3 = "noteIcon3"
  case icon4 = "noteIcon4"
  case icon5 = "noteIcon5"
}

class IconViewController: UIViewController {
  @IBOutlet weak var iconImageView: UIImageView!
  @IBOutlet weak var button: UIButton!

  override func viewDidLoad() {
    super.viewDidLoad()
    // Do any additional setup after loading the view.
  }

  @IBAction func buttonTap(_ sender: Any) {
    self.button.isEnabled = false
    setIcon()
  }

  func setIcon(){
    let randomInt = Int.random(in: 0...5)

    switch randomInt {
    case 0:
      iconImageView.image = UIImage(named:Icon.icon0.rawValue)
    case 1:
      iconImageView.image = UIImage(named:Icon.icon1.rawValue)
    case 2:
      iconImageView.image = UIImage(named:Icon.icon2.rawValue)
    case 3:
      iconImageView.image = UIImage(named:Icon.icon3.rawValue)
    case 4:
      iconImageView.image = UIImage(named:Icon.icon4.rawValue)
    case 5:
      iconImageView.image = UIImage(named:Icon.icon5.rawValue)
    default:
      iconImageView.image = UIImage(named:Icon.icon1.rawValue)
    }

    // アニメーション処理
    UIView.animate(withDuration: 2.0, animations: {
      self.iconImageView.alpha = 0
    }, completion: { (Bool) in
      self.iconImageView.image = nil
      self.iconImageView.alpha = 1.0
      self.button.isEnabled = true
    })
  }

  func reloadView(){
    // モーダルを使わない明示的な画面遷移
    if let nextIconViewController = storyboard?.instantiateInitialViewController() as? IconViewController{
      nextIconViewController.modalPresentationStyle = .fullScreen
      present(nextIconViewController, animated: false, completion: nil)
    }
    return
  }

}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SwiftUIで非同期で画像を表示する方法

はじめに

この記事は、ObservableObjectを使って非同期に画像をダウンロードする方法を紹介します。
List表示時などに使うと便利だと思います。
TIPSとして活用してもらえたらと思います。

作り方

Step1.準備

画像を用意します。今回は、いらすとやの画像を活用させていただきます。

スマートフォンを使うペンギンのイラスト
https://www.irasutoya.com/2019/07/blog-post_4.html

画像のURLはhttps://1.bp.blogspot.com/-_CVATibRMZQ/XQjt4fzUmjI/AAAAAAABTNY/nprVPKTfsHcihF4py1KrLfIqioNc_c41gCLcBGAs/s400/animal_chara_smartphone_penguin.pngでした。

Step2.非同期で画像をダウンロードする

ObservableObjectを活用して、データを取得します。
バッググラウンドスレッドで画像を読み込み、@Publishedした変数に格納するときはメインスレッドで更新します。
(Data Flow Through SwiftUI20:05あたりのスライド87ページより)

ImageDownloader.swift
import Foundation

class ImageDownloader : ObservableObject {
    @Published var downloadData: Data? = nil

    func downloadImage(url: String) {

        guard let imageURL = URL(string: url) else { return }

        DispatchQueue.global().async {
            let data = try? Data(contentsOf: imageURL)
            DispatchQueue.main.async {
                self.downloadData = data
            }
        }
    }
}

Step3. SwiftUIで扱えるUIパーツを作る

ObservableObjectを使い、画像読み込み終わるとUIImageを使い表示します。
読み込み中の場合は、ランアップできないので今回はSF Symbolの画像を表示します。
SF Symbolの画像以外に任意の画像に置き換えればいいと思います。

URLImage.swift
import SwiftUI

struct URLImage: View {

    let url: String
    @ObservedObject private var imageDownloader = ImageDownloader()

    init(url: String) {
        self.url = url
        self.imageDownloader.downloadImage(url: self.url)
    }

    var body: some View {
        if let imageData = self.imageDownloader.downloadData {
            let img = UIImage(data: imageData)
            return VStack {
                Image(uiImage: img!).resizable()
            }
        } else {
            return VStack {
                Image(uiImage: UIImage(systemName: "icloud.and.arrow.down")!).resizable()
            }
        }
    }
}

Step4.SwiftUIから読み込む

Step3で作成したURLImageをSwiftUIから呼び出します。
画像のURLを引数に呼び出すと自動で画像ダウンロードされたら表示されるようになります。

ContentView.swift
import SwiftUI

struct ContentView: View {

    var body: some View {
        VStack {
            URLImage(url: "https://1.bp.blogspot.com/-_CVATibRMZQ/XQjt4fzUmjI/AAAAAAABTNY/nprVPKTfsHcihF4py1KrLfIqioNc_c41gCLcBGAs/s400/animal_chara_smartphone_penguin.png")
                .aspectRatio(contentMode: .fit)
        }
    }
}

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

参考サイト

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOSエンジニア、AppCode使ってみる

この記事は CyberAgent 20 新卒 Advent Calendar 2019 の 10 日目です!

AppCodeとはSwift、Objective-C用のIDE(Integrated development environment)です。
iOS、macOS、tvOSのアプリケーションを作る上で利用されるIDEは通常はXcodeですが、今回はJetBrainsのIntelliJ IDEAの一つである、AppCodeを紹介したいと思います。

僕がAppCodeを使う理由

シンタックスハイライトが安定してる

逆になんでXcodeのシンタックスハイライトが不安定なのか分かりません。
皆さんもXcodeのシンタックスハイライトが利かない問題でイライラする事ありますよね、それが完全に無くなります。
AppCodeはプロジェクト起動時にモジュールとシンボルのロード、モジュールマップの生成、などなどを行うので最初のロードが終わればシンタックスハイライトが崩れることはありませんでした。
僕がAppCodeを使い始めた理由はこれです。

リファクタリング

AppCodeはこれの為に有ると言っても過言では無い、リファクタリングのし易さです。
関数の名前や戻り値、パラメータの追加や削除、型の変更などを行えるChange signature機能、Classの関数をOverrideする時に利用できるショートカット、Xcodeでも最近実装された処理を関数化する機能など利用すると便利な機能が沢山あります。Xcodeでは実装されていなかったり、実装されていても失敗する事が多い、関数の名前を変更した時に関数の呼び出し元の名前も変更する機能や呼び出し元の肩を変更する機能なども安定して実行してくれるので助かります。
Xcodeのリファクタリング機能を使うとビルドが通らなくなって結局手動で編集しないといけない事が多かったのですが、AppCodeのリファクタリング機能を使って手動で変更しないといけなかった事はほとんどありません。

カスタマイズ性

フォントやテキストの色はもちろんカスタマイズできますが、キーボードのショートカット、UI(メニューやツールバー)のカスタマイズ、quick listと言ってショートカットで出すことのできるAppCodeのショートカットをカスタマイズしておく事ができる機能などもあり、カスタマイズがほとんどできないXcodeと比べると自分に合ったIDEを作れるのは長く使ってるとどんどん便利になっていくのが実感できます。
IntelliJ IDEAをベースに作られてるのでAndroid Studioを利用した事が有る人は既にカスタマイズ済のプロファイルをそのまま使う事も可能です。

デバッグ

AppCodeはXcodeと同じくに内臓されてるLLDB debuggerを利用します。なのでXcodeで出来るデバッグはAppCodeでもできます。ブレイクポイントを置いて変数を監視したり、条件付きブレイクポイントの利用などです。
ですが、Xcodeよりもコードを一行一行デバッグする際の機能充実しています。一行一行次のコードに移動、行の中で何が実行されるのか表示、現在の関数の呼び出し元に戻る、行の関数や処理の中にブレイクポイントが入っていてもスキップする、などなどXcodeよりもデバッガーの機能を多く利用できます。

ちょっとしたデメリット

UI系に弱い

AppCodeを使う上でデメリットもあります。それはUI系の機能や編集はAppCodeでは利用、実行できない点です。StoryBoard、Xibの編集やコードとの紐付けを行う時にはXcodeを利用しないといけません、なので移動コストはかかります。
他にもXcodeの機能の一つであるUI InspectorはAppCodeでは使用できません。

解決策

もちろん解決策はあります。
StoryBoardやXibの編集は同じプロジェクトをXcodeでも開いておけば、AppCodeでStoryBoardやXibを編集しようとすると自動的にXcodeプロジェクトの方に移動してファイルを開いてくれる為、移動コストは無いに等しいです。

UI InspectorがAppCodeで利用できない点に関しては、Revealと言うアプリを利用すれば解決します。下の方でRevealの紹介を行ってます。

Revealの紹介

RevealとはiOSアプリやmacOSアプリのView構造を見る事が出来るUI Inspectorの上位互換のような年額6000円の有料アプリです。

screenshot-inspect.jpg

レイアウト崩れの検証やUIのデバッグを行う際に便利です。

UI Inspectorよりも優れている点としてはViewの情報(色や制約)をアプリが起動している最中に編集して即座に反映される、Live Editsと言う機能が付いている事や

live-edits.jpg

Viewの細かい情報を見る事が出来る機能が付いている点です。

comprehensive-inspectors.jpg

Revealの代替、Lookin

上で紹介した通り、Revealは年額6000円の有料アプリです。UI Inspectorの上位互換とは言えXcodeで利用できる機能のアプリの為に年額6000円支払うだけの価値が有るかと言われると微妙な所です。実際に僕も体験版を利用しただけで購入したことはありませんでした。

ですが最近、Revealとほとんど同じ機能を持ったオープンソースのアプリ、Lookinと言うものが公開されました。

preview_en_1x.jpg

Revealの目玉機能で有るLive EditsやViewの細かい情報を見る事ももちろん可能となっています。

最後に

AppCodeはJetBrainsが作っているIDEと言う事もありアップデートも頻繁に行われています。Xcodeを使っている上でちょっと不便だと思った事がある方は是非使ってみてはいかがでしょうか。

AppCodeとLookinで良いコーディングライフを。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[swift]SearchBarやTextField にてUIMenuControllerを非表示にする

どんなもの?

スクリーンショット 2019-12-23 17.48.31.png

長押しすると出てくるやつです。
そもそもこのバー自体出したくなかったので対応しました。

ソース

    // UIMenuController を非表示にする
    override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
        OperationQueue.main.addOperation({
            UIMenuController.shared.setMenuVisible(false, animated: false)
        })
        return super.canPerformAction(action, withSender: sender)
    }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOS13対応したら、SKProductsRequest周りでつまずいた

これiOS13、Xcode11 私はこうしてつまずいた Advent Calendar 2019 の23日目を担当するアガツマです
普段は、ビジネス版マッチングアプリ yenta のiOS版を開発をしています。

前日の記事
iOS13のメインスレッドチェック厳格化とNotificationCenterの組合せでつまづいた
と少し被るのですが、1つのケースとして誰かの役に立てたら幸いです!

つまずいた経緯

iOS13対応の諸々を完了し、アプリの課金プランについて記述している画面を開こうとしたところクラッシュ。。。
xcodeでは、以下のようなエラーメッセージが出力されていました。

Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Modifications to the layout engine must not be performed from a background thread after it has been accessed from the main thread.'

UIの更新は Main Thread で行うべきというのは以前からありましたが、iOS13に対応したらこのエラーが出るように。。。。

環境設定

iOS 13.2.2
Xcode11.2.0

やったこと

原因となっている箇所を特定

Main Thread Checker を活用して、Main Threadで呼ばれるべき処理が、実行時に Main Thread以外で呼ばれていないかをチェック!

Main Thread Checker 設定方法

Breakpoint Editor を開く
スクリーンショット 2019-12-23 15.09.03.png

Runtime Issue Breakpoint を追加
スクリーンショット 2019-12-23 15.13.17.png

追加した際に出る設定画面で type を Main Thread Checker を選択
スクリーンショット 2019-12-23 15.09.31.png

これで、実行時に Main Thread以外でUI更新をしている部分で処理が止まるので、原因となっている部分を特定しに行きました。

原因の特定と調査

このような過程で原因を調査していったところ、、、
SKProductsRequestDelegateメソッドの中で Main Thread 以外でUIに関する処理をしていることがわかりました。

SKProductsRequestは、アプリ内課金アイテムの情報を取得する際に利用しているのですが、色々と調べてみると下記2つのメソッドが Main Thread 以外で実行されるようになったみたいです。
なぜ、その様になったかはまだはっきりとはわかっていません。。。

この2つのメソッドが Main Thread で実行されることを想定していた実装をしている場合は注意が必要です。

func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {}
func request(_ request: SKRequest, didFailWithError error: Error) {}

改善案

上で挙げた2つのメソッド内の処理を、DispatchQueue.main.syncを用いて明示的にメインスレッド Main Thread で実行されるようにしました。
ここではproductsRequest(...)の例のみ記載します。

修正前のコード

// MARK: - SKProductsRequestDelegate
extension ViewController: SKProductsRequestDelegate {
    func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
        //...(省略)...

        // 以下の処理でクラッシュする(Main Thread 以外でUI更新している)
        collectionView.reloadData()
        button.isHidden = false
    }
}

修正後のコード

// MARK: - SKProductsRequestDelegate
extension ViewController: SKProductsRequestDelegate {
    func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
        DispatchQueue.main.sync { // 書く
            //...(省略)...

            // Main Threadで実行される
            collectionView.reloadData()
            button.isHidden = false
        }
    }
}

さいごに

iOS13に対応したことで、想定外の部分でクラッシュする画面が出てくるのはなかなかつまづくポイントだなと思います。
このようなアップデート時にテストを入念にするというのはもちろんですが、iOSの自動テストなどを組み込みアップデートにより起こってしまう問題を検知する方法もしっかり模索していかなければいけないなと思いました。

参考

Are SKProductsRequestDelegate methods always called on the main thread?
Multithreaded rendering only crashes on iOS 13
【iOS】iOS13からSKProductsRequestのDelegateメソッドがメインスレッドで呼ばれなくなった

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Google検索結果の「もっと見る」をiOSショートカットでページ表示にする

iOSのショートカットを使ってGoogle検索結果をカスタマイズする

 (記載している内容は2019年12月23日現在/iOS13.3において確認)
 iPhoneのSafariでGoogle検索を行うと、2ページ目以降の検索結果を表示する際にページを切り替えるのではなく、「もっと見る」をクリックするとページ内で読み込んで無限スクロールするようになっている。
 これはFirefox(iOS版)でも同様の動作となるが、Google Chrome(iOS版)では「もっと見る」ではなく「次へ」でページを切り替えるようになっている。

 どちらが良いかは一長一短あるが、

  • 検索結果を表示した後にブラバすると高い確率でページ先頭に戻ってしまう
  • ブラバすると再読み込みが走り、場合によっては真っ白な画面で固まる
  • 『問題が起きたため、このWebページが再読み込みされました。』というメッセージが出る(そして再読み込みは失敗する…)

などの現象が発生することから、SafariでもChromeと同じように「次へ」でページを切り替えるようにしたい。

やること

  • Safariで表示しているGoogleの検索結果のURLをコピーする
  • コピーしたURLに「&start=10」を追加する
    • Googleの検索結果のカスタマイズのうち、パラメータとして &start=[検索項目数] を設定すると、検索項目の何項目から表示するかを設定できる。1ページあたり10項目のようで、2ページ目から表示したい場合は10を設定すれば良い。
    • ショートカットのレシピ上の処理としては、追加ではなく置き換えとした。(もっともらしく見える「テキストを結合」というアクションがあるが、使い方がよくわからなかった・・・)
  • 追加済みのURLでページを再表示
  • 一連の動作をiOSのショートカットに登録して呼び出す

実際のレシピ

  • 新規ショートカットを作成する
  • 「+アクションを追加」で以下のアクションの追加と設定を行う
    • 「Safari Webページの詳細を取得」を追加し、『ページのURL』をSafariのWebページから取得というアクションに設定する
    • 「URLを展開」アクションを追加する
    • 「入力からテキストを取得」アクションを追加する
    • 「テキストを置き換え」アクションを追加し、テキストの『client=safari』を『client=safari&start=10』に置き換えというアクションに設定する
    • 「URLを開く」アクションを追加する
  • 適当な名前、例えば『Google検索2ページ目』とし、レシピを保存する
  • 保存したレシピのプロパティ(…)から、「共有シートに表示」をオンにする

IMG_7124.PNG

使い方

  • Safariで普通にGoogle検索をする
  • Safariの共有ボタン(下部メニュー中央)から『Google検索2ページ目』を選択する
  • 2ページ目以降が「次へ」でページ遷移したときと同じ状態で見れる kekka_m.png ※「共有シートに表示」をオンにしただけでは、リストの下の方に表示されるので、画像の例ではリストの一番下にある「アクションを編集…」から『よく使う項目』に作成したレシピを指定しています。

他の使い方

  • 今回の用途とは関係ないが、例えば『tbs=li:1』を追加するレシピに変更すれば、検索ツールから「すべての結果」→「完全一致」に変更するアクションにすることも出来る。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

画像処理で自炊書籍画像を読みやすく加工する 初級編

この記事は KLab Engineer Advent Calendar 2019 24日目の記事です。
AdventCalendarどころか、Qiitaへの、もっと言えばネットに向けた技術エントリの投稿自体が初めてになります suzuna-honda といいます。よろしくお願いします。

はじめに

私は書籍をスキャンして電子書籍として読む、いわゆる「本の自炊」を趣味としています。
不眠症のケがあり、眠くなるまでの暇潰しとして、自炊した本を読書するのが日課になっています。枕の横にはiPadが欠かせません。

どの程度、深く、この「本の自炊」と向き合っているかといいますと、

  • 書籍のスキャン
  • 1冊ごとにパッケージング、管理の為のタグ付け
  • スキャンした画像の画質調整、端末(iPad/iPhone)への最適化
  • 端末(iPad/iPhone)へのデータ転送、閲覧

までの一連のワークフロー、ほぼ全てのツール/ビューワを自作して揃えています。

ハードウェア系、要はスキャナーやiPad/iPhoneに関しては流石に自作しようがなく、仕方なく市販品をそのまま使っています。一方、ソフトウェア系で唯一自作できていないのが「スキャナーを動かすスキャン用アプリ」です。某メーカーの業務用スキャナーを愛用しており、この機種にはSDKが存在していまして、ドライバレベルで叩けるスキャン用アプリを作ることが出来る、という触れ込みなのですが、メーカーに問い合わせたところ「法人じゃないとSDKは出せない」と返答されてしまいました。ので泣く泣く自作を断念しています。この理由の為だけに、いつか法人を作るかもしれません。

動いているもの【PR】

自作ビューワ上で実際にどのようなUXを得られているのか、は、動画を観てもらうのが手っ取り早いですね。

iOSネイティブアプリとして製作しています。開発初期はObjective-Cで書いていましたが、今はSwiftに書き直してあります。高速化の為に一部C++で書いています。

「1/120秒以上メインスレッド/ユーザーを待たせない」というポリシーを最優先しています。悪魔的なサクサク感がウリです。故に、この手の電子書籍ビューワによくあるページ移動アニメーションすらも一切省いてあります。待たされるのが嫌いなのでそうしているだけで、出来ないわけじゃないんですよ。

書籍閲覧時の高速ページ移動は、サムネイルで誤魔化したりはしていません。1ページ1ページ通常通りの画像をデコードし表示しています。
「物理な本をパラパラめくってぼんやり中身を確認する、あの感覚をどうやって電子書籍でエミュレーションするか」という命題に対して、私なりの答えとして圧倒的なスピードを実現しました。「なんとなく本の中身が分かる」という目的には多少近づけた、かな、と自負しています。
完璧な解答は別の方向性な気もしていますので、試行錯誤の日々は続きますが。あとシンプルに速すぎる気もしますね。でも操作していてめっちゃ気持ちいいんですよこれ。

書籍データはWifi経由で端末のストレージに落とすことが出来、一度ダウンロードしておけばキャッシュされオフラインでいつでも閲覧が可能になります。ファイルサーバは普通のその辺にあるNASで、SMBで繋いでいます。

タッチパネルによる操作も出来ますが、bluetoothコントローラを用いておおよその操作が可能となっています。普段はこちらをメインで利用しています。指で画面が隠れたり画面が指紋で汚れるのが嫌というのもありますが、寒い冬場に、掛け布団から手を出さなくても閲覧可能にしたかったのが一番の理由です。

語りたいネタはまだまだうんざりするくらいありますが、ビューワの解説は本エントリの本筋ではないのでこれくらいにしておきます。
(友人にこの文章のレビューをしていただいたところ「炎上対策でタイトルにPRつけろ」と指摘されたので付けました。がこのビューワも後述のツールも何もかも全部、「私」専用です。どこを探しても売ってたりはしませんので悪しからず)
一度このようなUI/UXに慣れてしまうと、kindleに代表される既存の電子書籍はどれもこれも、ビューワの出来も微妙、画像データの画質も微妙、と身体が受け付けなくなります。それが良いことなのか悪いことなのかは、よく分かりません。

そして快適な閲覧環境を実現するには、ビューワ側の最適化も勿論のこと、書籍データ側の工夫(K.U.F.U.)も大きなウェイトを占めます。そんなわけでこのエントリでは...

本題はじめ

本をスキャンして得られた画像データを、どのように画質調整して実際に表示する画像データに加工するか、といった部分に関していくつかピックアップして雑に解説してみようと思います。

スキャンした直後の素の状態の画像データサンプルです。サイトに上げる為、解像度の25%縮小のみ行っています。

original.png

引用全ての出典元 著者:九井諒子, 書名:ダンジョン飯 第6巻, 出版社:KADOKAWA, 出版年:2018.

使っているスキャナーの最大性能である600dpiでスキャンしています。画質調整に対する耐久力という意味で解像度は最も大きなファクターです。とにかく高いに越したことはありません。
スキャナー側での自動的な画像処理/調整は極力行わせない設定にしており、なるべく無加工な生の状態のままの画像データを出力させ、これを素材としてストレージに保存してあります。画質調整は非可逆な操作となりますので、一度何か変更を加えてしまうと、その後により良い画質調整アルゴリズムを思いついたとしても取り返しがつかなくなります。ので、何も加工していないデータを残しておくことはとても重要です。ストレージこそ圧迫しますが、今はHDD安いので気にしないのが丸いです。

そしてこれが最終的な画質調整まで完了させた、iPadで実際に閲覧される画像データです。iPad Pro 11inchに最適化されており、こちらも50%縮小のみ行っています。

final.png

見比べて頂けると分かりますが、だいぶ印象が変わりますよね。個々人で好みの差はあると思いますが、私個人としては断然すっきり読みやすくなっていると認識しています。画質調整は全自動で行っており、手動で発生する手間は「エンコード」と書かれたボタンを一つ押すだけです。

(サイズの縮小によってこの画像では変わってしまっているとは思いますが、)4bppつまり16階調のグレイスケールにまで色数を落としています。これによりファイルサイズを大きく低減させ、読み込みとデコードの高速化を図ることが出来ます。より強く階調数を落とすとファイルサイズは更に小さくなりますが、私の目で劣化の判別不可能なギリギリのラインが16階調でした。ただしディザは必須です。それなりに高品質なディザを入れないと16階調でも結構きつい絵になります。とはいえ、ディザはファイルサイズが膨らむ、という諸刃の剣だったりも...。バランス加減が問われます。

これより先、真面目な画像処理の専門家からすると噴飯もの、ツッコミどころが多々あるかと思います。所詮はお遊びですので、生暖かい目でお読みください。
なおOpenCVなどを活用するのが正解なのは分かりきっているのですが、あくまで趣味なので敢えて全コード自作しています。

カラー印刷/単色印刷の判定

単色印刷された紙面と、カラー印刷された紙面とでは、画質調整で動ける範囲が大きく変わります。単色印刷の場合、紙の質感は基本的にノイズと判断でき「単色のインク」と「紙」の2軸のみを考慮の対象に絞れるので、ドラスティックな画質調整アプローチを取ることができます。アドが取れます。

ここで問題となるのが「カラー印刷なのか単色印刷なのかを判定」する方法です。
この程度でも、私のような初心者レベルの人間には結構ハードルが高いのです。

画像処理に少しでも慣れてる人ならば、「色空間HSVにしてHueの標準偏差でもみて閾値超えてたらカラー判定するだけじゃね?しょうもな」くらいはパッと出てくるかと思いますが、そこまで単純な話ではありません。
なぜかと言うと、スキャンした紙は特に古い本だと「日焼け」していることが多いんですね。
本のページを開く側(小口)は真っ赤に焼けていて、内側(のど)は綺麗な淡黄色、綺麗なグラデーションになっている...なんて状態もレアケースではありません。このような紙の状態では、人間の目にはモノクロのページに見えていても、パコソンさんにそれを理解させるのはなかなかに大変なのです。他にも、単色印刷だけどインクが青色だったり、と、シンプルなカラー/グレスケ判定の枠に収めるのが難しいケースが稀によくあるのが自炊画像の扱いの難しさです。今回用意したサンプル画像(↑)も、グレイスケールか?って言われたら違いますよね。

現状、私が行っている判定方法は、複数のアプローチを組み合わせています。

1つ目の方法は先程書いたような、HueやSaturationなどの偏り/ばらつきから経験則的に判定する手段です。
破綻するケースが多々あり信頼性には欠けますが、パラメータの調整でなんとかやりくりしています。

2つ目の方法は減色を用います。
k-means法という有名なアルゴリズムを少し弄って、

  1. 画像をn*n(非常に小さい解像度)まで平均画素法で縮小処理を行い、それぞれの色を代表色とします。
  2. オリジナル画像の全ピクセルに対して、代表色から一番近いものを選び、クラスタとしてグルーピングします。
  3. クラスタ内の画素の色の平均を取り、新たな代表色とします。
  4. 代表色が閾値以上に近いクラスタを同じクラスタとしてまとめます。
  5. 代表色が変化せずクラスタ数も落ち着くまで2-4を繰り返します。
  6. クラスタ数が多く残ったらカラー、少なければ単色印刷と判定します。

日焼けの度合いが激しいと誤判定しますが、意外とそれっぽく判定できています。
減色アルゴリズムを使ってカラー判定、というアイディアは自分で考えました。自賛になりますがそこそこ気に入っています。

3つ目の方法はヒストグラムを用います。
日焼けした紙、というものは大抵の紙質において青>緑>赤の順に情報量が削られた状態とみなすことができます。この削られ具合は経験則上、ある程度リニアになっています。ので、RGB毎にヒストグラムを用意し、その形状が相似形であればモノクロ印刷であると判断してもいいのでは、という考え方です。問題点として、この手法では青いインクなど特殊な単色印刷は認識できません。

デバッグ用に出力している、先程の画像データサンプルのヒストグラム情報です(他の情報も混ざっていますが気にしないでください)。ここまで綺麗に相似形だとモノクロ画像だろうな、と判断しても問題なさそうですよね。

histgram.png

以上の3つの手法を経験則で組み合わせて、総合的に判定を行っています。

結構な手間と時間を掛けて調整はしているのですが、誤判定はまだ偶に起きています。「経験則というのがダメなんじゃないですか?」という答えは既に出ているのですが、そこは最終段にて触れますので...

紙の形状に切り取る

サンプル画像のオリジナルを確認して頂けると分かりますが、紙の形状よりも少し大きめにスキャンしています、ので、実際の紙の形状を判定する必要があります。スキャナに紙の形状/サイズを自動判定させることもできますが、誤爆して変な形状に切り取られてしまうことがままあり、現在は自前で判定することにしています。

処理は上下左右の辺毎にそれぞれ行います。適当な間隔で、端から中央へ向けてピクセルをサンプリングを進め、閾値以上の色を見つけた座標を保持しておきます。これらの座標群に対して、実際の紙の形状を構成する直線を求めます。典型的な最小二乗法の出番ですね。とはいえ、単なる最小二乗法ではノイズに弱く使い物になりませんので、ロバスト推定と呼ばれるもう少し賢いアルゴリズムを扱います。ここでは、その中でもRANSACとLMedSというアルゴリズム、これらを足して3で割ったような代物を実装しています。

  1. 座標郡の中からランダムに数点選択する。今回は直線なので2点でOK
  2. 選択した2点から直線の式を作成
  3. 選ばれなかった座標群と2で求めた直線との最短距離の分散を求める
  4. 1から3を複数回繰り返し、そのうち最も分散の小さくなった直線を選択する
  5. 4辺でそれぞれ同じ様に直線を求め、形状を把握し外枠を切り落とす

RANSACのような閾値を決めるのが難しい、LMedSのような中央値だと色々と問題が出る、と試行錯誤した結果このような実装になっています。

これにより求まる直線が以下のようになります。

形状判定画像-0000.png

白い点がサンプリングで見つけた座標郡、赤い線がロバスト推定で求められた直線です。綺麗に紙の形状を拾っていますね。申し分ない精度です!

なお、紙の形状は綺麗な直線になっておらず、弓形に反っていたりすることがそこそこありますので、求める先を直線ではなくn次の曲線等にした方が、より正しい結果を得られるかとは思います。↑の画像を見ても確認出来ますが、左辺側が微妙に紙の形状に対して対応しきれておらず、左下がはみ出てしまっていますね。しかし、私のようなアリアハン住民には少々荷が勝ちすぎており、そこまで手が回っていません。

また余談ではありますが、RANSACにしろLMedSにしろ乱数を用いるので、結果が冪等ではなくなってしまう点が懸念点として挙げられます。私のツールではエンコードした結果に対してCRC32のタグを付けデータ化けの検証をビューワ側で行っていますが、同じ設定でエンコードしてもCRCが異なるデータが出来てしまうんですよね。現状では乱数シードを固定して急場を凌いでいますが、対策としてはお粗末に過ぎます。

紙の形状を矩形に修正する

スキャンした紙の形状はたいてい、綺麗な矩形(長方形,つまり内角が全て90度)にはなっていません。これは製本の時点で裁断が傾いていることもありますし、本屋が下手なヤスリがけをしたせいかもしれませんし、私が裁断機で本を裁断した際に真っ直ぐ切ることができなかったからかもしれません(だいたいにおいて最後のケースです)。画像データはきっちり矩形でないと扱いにくいので、矩形への変形を行います。縁が垂直に切り落としても問題なさそうな空白であれば、縁を垂直に切り落とします。そうでないならアフィン変換で画像を歪ませます。多少歪んだ程度では気付かれない程度に人間の目は鈍感ですので問題ありません。

断ち切り判定画像-0000.png

水平/垂直に縞模様のラインの通りに、紙の形状とカラーを調査し情報量を測定しています。

今回のサンプル画像の場合、どうやら紙の左側だけ大きく傾いているようですね。端っこに情報はなく空白っぽいので、垂直に切り落としてしまって問題なさそうです。これで綺麗な矩形が手に入りました!

印刷時の傾きを補正する

紙の本というものは割と適当で、想像よりも傾いた状態で印刷されているものです。紙のまま読むのであれば表示領域が平面ではないのであまり気にはなりませんが、電子書籍だと平たい画面の上に表示するのでこの傾きが地味に気になります。特に漫画はコマが傾いているのが目にはっきり映ってしまいます。サンプル画像も、よく見ると、ほーんの僅かですが傾いているのが見て取れるかと思います。

漫画のコマにしろ小説の文字にしろ、直線を求めてその傾きを垂直/水平に補正してやれば良さそうです。というわけで、素直に直線を検出しましょう。まさにその為にあるような、Hough変換という便利なアルゴリズムがあります。

Hough変換を行うためには輪郭抽出をまずは行う必要がありますので、それを含めて順番に流れを追っていきます。

1) デノイズ(雑音低減)の為、画像を縮小し、ガウシアンブラーを適当に掛ける
傾き判定画像-0000 2-ガウシアン.jpg

2) エッジ強度を求める
傾き判定画像-0000 3-エッジ強度.jpg

3) エッジ勾配を求める
傾き判定画像-0000 4-エッジ勾配.jpg

4) 細線化
傾き判定画像-0000 5-細線化.jpg

5) Hysteresisで2値化
傾き判定画像-0000 6-2値化ヒステリシス.jpg
これで輪郭抽出ができました。ここからが本番です。

6) 2値化画像にHough変換を掛ける
傾き判定画像-0000 7-ハフ変換画像.png

7) ピーク値をいくつかピックアップしHough逆変換、複数の直線の式が求まる
傾き判定画像-0000 8-最終画像-X.png

なんだか知らないうちにそれっぽい直線が手に入りました!sugoi!tanoshii!
先人の知恵というものは本当に素晴らしいですね。

Hough変換を用いると、上記のように直線や円など画像の特徴を検出することができます。文章書くのが面倒くさくなってきたので説明をざっくりデバッグ用画像で済ませてしまいましたが、やっていることは教科書どおりです。詳しく知りたい方はググりましょう(Qiitaでこの言い草!)。

外れ値検出

求まった複数の直線の式のうち、あまりに角度が付きすぎている、他の直線と乖離が激しい、といった外れ値(outlier)/異常値を取り除くことで、目的の傾きが手に入ります。この「外れ値検出」も定番のアルゴリズムが先達によっていくつも用意されていますが、私はこのように実装しています。

  1. それぞれの直線の傾きを用意する
  2. 順番に1つずつ省いては標準偏差を求める
  3. 2のうち最も標準偏差の低くなった時に省いた傾きをoutlierと判断し、除去する
  4. 2-3を繰り返し、閾値以下の標準偏差になるか傾きの数が2つになったら打ち切る
  5. 残った傾きのうち最も傾きが小さいものを拾う

つまりは、一連の直線の傾きのうち、より密度が高いものを選定しています。なんていうアルゴリズムになるんでしょうね?よく分かっていません。こんな適当な考え方と実装でも、ちゃんと動いているのですから懐が広いです。

傾きが求まれば、あとはアフィン変換で回転するだけなので簡単ですね。

ほかにも

空白箇所を検出してトリミングしたり、
トリミング判定画像-0000.png

ヒストグラムでコントラスト調整(美白化)を行ったり、
グループヒストグラム-0002.png

様々な画質調整が、画像処理をぶん回すことで実現できています。

さらには、リニアワークフローの徹底、シャープネス、モアレ軽減、リサイズアルゴリズム、出力画像フォーマットの選定、etcetc...このツール内だけでも語るべきことはまだまだありますが、それらは次の機会があれば、ということで。

さいごに

時間配分をミスってしまい後半は駆け足になってしまいましたが、どうですか?画像処理、面白くないですか?今回紹介したようなアルゴリズムはどれも初歩中の初歩程度のものばかりですが、それでも実際に役に立つ運用ができています。定番というものは優秀だから定番になるわけですね。

ここから更に高みを目指そうとすると、その先に確実に待っているのは機械学習/深層学習、となってくるわけです。ヒューリスティックな解決法にはどうしても限界があることは、今回紹介した画質調整ツールの制作だけでも十二分に体感しました。

というわけで、まずは機械学習をこの画質調整ツールに取り入れていくのが私の現在の目標となっています。なっていますが、実用として組み込めるようになるのはいつになることでしょう...ま、兎にも角にも人生日々勉強ですね。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOSでのATDD開発(基礎編)

以下の続きです。

システムアーキテクチャ

前記事に記載してますが、再掲。
_Cucumber Workflow (1).jpg

プロジェクトの構成

├─ src
   ├─ test
      ├─ java
      |  └─ jp.co.hoge.Project.E2ETest
      |     ├─ pageobject
      |     └─ steps
      |     └─ utils
      └─ resources
         └─ jp.co.hoge.Project.E2ETest
            └─ feature files
  • pagobject - 後述
  • steps - 実際のテストを記述するファイルとなります。Gherkinで書かれた自然言語とコードの紐付けが行われます。
  • utils - ユーティリティクラス群です。appiumサーバ作成用のクラスなどがあります。
  • feature files - Gherkinでかかれたフィーチャーファイル群です。

PageObject

PageObjectは(テストとは関係なく)ページとユーザの関係を表したオブジェクトのことです。
以下条件を満たします。

  • publicなメソッドはそのページが提供する論理的な処理を表す
  • ページ内部の情報は公開しない
  • テストで使用するアサーションはPageObject内に含まない
  • メソッドはPageObjectを返す
  • ページ全体を表す必要はない
  • 同じアクションでも結果が異なる場合は異なるメソッドとして定義する

PageObjectサンプル

PageObjectは基本的に上記要件を満たしたものを言うのですが、開発の都合上完全に準拠する形にはしませんでした。
例えば今回PageObjectの基底クラスとしてBasePageというPageObjectを用意しています。
基本的に新たにPageObjectを作る際にはこのクラスを継承しています。
このBasePageには要素の存在確認用のpublicメソッドが実装されていますが、上の原則でいうメソッドはPageObjectsを返すに反してはいますが、便宜上存在確認は各PageObjectに実装することにしました。

/// PageObjectのExample
public class SearchPage extends BasePage {
    public SearchPage(IOSDriver driver) {
        super(driver);
    }

    @iOSXCUITFindBy(xpath = "//*[@name=\"検索\"]")
    private IOSElement navigationBar;

    @iOSXCUITFindBy(xpath = "//*[@name=\"アーティストをさがす\"]")
    private IOSElement textSearchField;

    @iOSXCUITFindBy(accessibility = "ランキング")
    private IOSElement ranking;

    ...

    public SearchResultPage searchText(String keyword) {
        textSearchField.setValue(keyword);
        driver.hideKeyboard();
        return new SearchResultPage(driver);
    }

    public SearchSuggestPage showSuggest(String keyword) {
        textSearchField.setValue(keyword);
        return new SearchSuggestPage(driver);
    }

    public Boolean existsNavigationBar() {
        return checkVisibilityOfElement(navigationBar);
    }

    ...

Feature

シナリオを平文で書いたものです。ここで登場するのがGherkinと呼ばれる構文規則です。

Sample

Feature: 探すページを表示する

  Scenario: 探すタブを押下する
    Given アプリを起動し、さがす画面に遷移
    Then さがす画面で、ヘッダに「さがす」と表示される

Steps Keyword

各ステップにはGiven, When, Then, And or Butというキーワードをつかうことができます。
ただし、これらはステップ定義を探す際に考慮されません。

Steps Argument

ステップ定義に引数を渡すことができます。ただし、使える型には制限があります。
https://cucumber.io/docs/cucumber/cucumber-expressions/#parameter-types

// example feature
Scenario: 音楽再生時のコントロールパネルを表示する
    Given さがすタブをタップする
    And テキスト検索から"米津玄師"を入力する
    ...
// example steps definision
@Given("テキスト検索から{string}を入力する")
public void テキスト検索から米津玄師を表示する(String keyword) {
    searchResultPage = searchPage.searchText(keyword);
}

Skip Test

開発途中のステップなど、まだCI上でテストを実行して欲しくないFeatureがある際にignoreアノテーションをつけることによって該当テストの実行をスキップできます。

@ignore
Feature: 広告表示
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

StartTag: invalid element name

背景

チーム開発において、developブランチからプルしてきた際に、xibファイルの変更による「問題の状況」のエラーをみたことがあると思います。検索してすぐに出てこなかったので、一応、記事にしておきます。

問題の状況

gGgRq.png

解決方法:規定のxibファイルのコンフリクトを解消すればいい

この状態の問題は、xibファイル内に変更が加えられたために生じます。

僕の知識の問題ですが、普通にはこのconflictを解消するためにファイルを開くことがXCodeの初期設定ではできません。

きっと開く方法があると思うのですが、特に解決方法にこだわりがなければ、別のエディタで開くとファイルの中身が見れるので、すぐに修復可能です。大抵の場合は、HEADを残して変更をコミットすることになりそうです。

まとめ

以下に参考記事を貼ります。

参考: https://stackoverflow.com/questions/21818821/couldnt-open-xib-file-after-git-pull-invalid-element-name

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Swift] iOS13とフォントのお話

はじめに

Combineを使ってMVVMでNotificationCenterを実装するでも書きましたが、ありがたいことにiOS13以上の案件に携わりました。そこでは、新しくiOS13で追加されたフォント周りに関しても触れることができ、大変良い経験となりました。

また、それに付随してフォントの話で登壇する機会をいただきました。
(画像をクリックすると資料に飛べます)

thumbnail

内容はスライドをみていただくとわかりますが、iOS13周りで増えたフォントに関しての話をさせていただき、、
具体的なコードの話はあまり触れなかったので、こちらで実装面に関して触れていきたいと思います。

主に、フォント周りでできるようになったことを中心に記載していきます。

iOS13とフォント

iOS13からCTFontまわりのものがいくつか増えました。

スクリーンショット 2019-12-23 4.57.03.png

この中のAPIを使ってフォント周りを操作していきます。
それでは実装コードを早速みていきましょう。

カスタムフォントをサクッと使う

資料でもこちらは触れているのでさらっと書いていきます。
UIFontPickerViewControllerを使うことでAppleが用意してくれたフォントを簡単に使うことができます。
(以下、実際に作成された方の動画をお借りしています。)

UIFontPickerViewController.gif

選択したフォントが即座にラベルに反映されているのがみてわかります。

原理としてはとても単純で

スクリーンショット 2019-12-23 6.56.36.png

Delegateを通りしてFontを取得できるので、そちらをセットしているだけです。

自前のカスタムフォントを使えるように設定する

フォントの扱うには、準備が必要なので先に行いましょう。

① フォントをアプリ内に用意する

今回は例として「NotoSans-Black」のフォントを使用します。

Assetにフォントのリソースを追加します。

スクリーンショット 2019-12-23 3.31.10.png

その後、Resource Tagにも追加します。

スクリーンショット 2019-12-23 3.31.00.png

上記で、追加したリソースタグ名を「Font」という名前を設定していますが、これは後々コードでも使用することになります。
(なので各自でユニークな名前を設定してください)

② フォントのentitlementsを追加する

これがないといくら実装してもエラーになります。

「Signin & Capabilities」から「+」で追加しましょう。
(Xcode11からUIが微妙に変わって探しづらいので注意)

スクリーンショット 2019-12-23 5.04.00.png

「fonts」と検索すると該当するものが出てくるので追加します。

スクリーンショット 2019-12-23 4.03.42.png

無事に追加したらチェックをつけましょう。

スクリーンショット 2019-12-23 4.03.53.png

これで準備は完了です。

フォントのインストール / アンインストール

1. フォントをインストールする

いくつかの工程を挟むため段階的に説明していきます。

① リソースアクセスの確認

フォントをインストールするには、まずリソースにアクセスできるか確認する必要があります。
(しないとコードでエラーが出てしまうため)

アクセスできるかを確認するにはNSBundleResourceRequestで確認します。

private var resourceRequest: NSBundleResourceRequest?

func requestFont(tags: Set<String>, fonts: CFArray) {
    resourceRequest = NSBundleResourceRequest(tags: tags)
    resourceRequest?.conditionallyBeginAccessingResources { [weak self] isAvailable in
        if isAvailable {
            debugPrint("is available")
        } else {
            debugPrint("is not available")
        }
    }
}

1つ目の引数tagsは先ほど追加したリソースタグ名になります。
2つ目の引数fontsはインストールしたいフォント名を引数として渡します。

② リソースにアクセスする

アクセスができない場合は、アクセスできるようにします。
(①の処理は初回でfalseになるとはずなのでこちらを通るはず)

func accessFont(fonts: CFArray) {
    resourceRequest?.beginAccessingResources { [weak self] error in
        if error == nil {
            debugPrint("success")
        } else {
            debugPrint("failure", error?.localizedDescription ?? "")
        }
        self?.resourceRequest?.endAccessingResources()
    }
}

③ インストールする

iOS13から追加されたCTFontManagerRegisterFontsWithAssetNamesを使用してインストールします。

func installFont(fonts: CFArray) {
    CTFontManagerRegisterFontsWithAssetNames(fonts, CFBundleGetMainBundle(), .persistent, true) { errors, _ -> Bool in
        if 1 <= CFArrayGetCount(errors) {
            debugPrint("font install failure: \(unsafeBitCast(CFArrayGetValueAtIndex(errors, 0), to: CFError.self).localizedDescription)")
            return false
        } else {
            debugPrint("font install success")
            return true
        }
    }
}

引数にはインストールしたいフォント名を引数として渡します。
エラーをみたい場合はunsafeBitCastで取り出す必要があります。

総括

①~③のコードを連携させたものです。
(単発のコード群だったので、ちゃんと機能するものを記載しておきます。)

private var resourceRequest: NSBundleResourceRequest?

func requestFont(tags: Set<String>, fonts: CFArray) {
    resourceRequest = NSBundleResourceRequest(tags: tags)
    resourceRequest?.conditionallyBeginAccessingResources { [weak self] isAvailable in
        if isAvailable {
            debugPrint("is available")
            self?.installFont(fonts: fonts)
        } else {
            debugPrint("is not available")
            self?.accessFont(fonts: fonts)
        }
    }
}

func accessFont(fonts: CFArray) {
    resourceRequest?.beginAccessingResources { [weak self] error in
        if error == nil {
            self?.installFont(fonts: fonts)
        } else {
            debugPrint("failure", error?.localizedDescription ?? "")
        }
        self?.resourceRequest?.endAccessingResources()
    }
}

func installFont(fonts: CFArray) {
    CTFontManagerRegisterFontsWithAssetNames(fonts, CFBundleGetMainBundle(), .persistent, true) { errors, _ -> Bool in
        if 1 <= CFArrayGetCount(errors) {
            debugPrint("font install failure: \(unsafeBitCast(CFArrayGetValueAtIndex(errors, 0), to: CFError.self).localizedDescription)")
            return false
        } else {
            debugPrint("font install success")
            return true
        }
    }
}

実際の実行コード例は以下になります。

let fontList = ["NotoSans-Black"] as CFArray
let assetList: Set<String> = ["Font"]
requestFont(tags: assetList, fonts: fontList)

実際に設定画面で確認してみるとインストールされていることが確認できます。

スクリーンショット 2019-12-23 4.33.17 2.png

この設定画面からアンインストールすることも可能ですが、次はコードからの実装をみてみましょう。

2. フォントをアンインストールする

アンインストール用の型が決まっているため、それを用意するためにStringのExtensionを用意しました。
CTFontDescriptorの配列を用意する必要があるため、フォント名から取得できるようにStringから拡張できるようにしています。
(個人的な好みでExtensionにしたので、メソッドでもかまいません)

extension String {

    var fontDescriptor: CTFontDescriptor? {
        return (CTFontManagerCopyRegisteredFontDescriptors(.persistent, true) as? [CTFontDescriptor])?.first {
            CTFontDescriptorCopyAttribute($0, kCTFontNameAttribute) as? String == self
        }
    }
}

CTFontManagerCopyRegisteredFontDescriptorsは後々の文章で説明しますが、行なっていることとしては、
「指定したフォント名がインストールされていればCTFontDescriptorを返す」
というExtensionになっています。

では、実際のアンインストールコードをみていきます。

func uninstall(fontDescriptors: [CTFontDescriptor]) {
    CTFontManagerUnregisterFontDescriptors(fontDescriptors as CFArray, .persistent) { errors, _ -> Bool in
        if 1 <= CFArrayGetCount(errors) {
            debugPrint("font uninstall failure: \(unsafeBitCast(CFArrayGetValueAtIndex(errors, 0), to: CFError.self).localizedDescription)")
            return false
        } else {
            debugPrint("font uninstall success")
            return true
        }
    }
}

お気付きの方もいるかもしれませんが、先ほどのインストールメソッドとほとんど変わりません。
実際の実行コード例は以下になります。

let fontList = ["NotoSans-Black"]
let fontDescriptors = fontList.compactMap { $0.fontDescriptor }
uninstall(fontDescriptors: fontDescriptors)

先ほどと同様に設定画面からフォントがアンインストールされているか確認することができます。

フォントをアプリ内で使用する

1. フォントを参照する

CTFontManagerCopyRegisteredFontDescriptors
でそのアプリでいれたフォントを参照することができます。

ただし
他のアプリや外部からインストールしたものは参照できない!!!
ので注意してください。

例として「Noto Sans」の「NotoSans-Bold」をアプリからインストールしていた場合は以下のようになります。

let descriptors = CTFontManagerCopyRegisteredFontDescriptors(.persistent, true) as? [CTFontDescriptor]

print(descriptors ?? [])

printの内容は以下

[UICTFontDescriptor <0x6000026827c0> = {
    NSCTFontFileURLAttribute = "file:///Users/XXXXXX/Library/Developer/CoreSimulator/Devices/60946E20-27DB-42F6-BAA1-35ABB6308F7B/data/Containers/Data/Application/E6695D3B-D062-4C35-9AEE-48DC6F50CD03/tmp/NotoSans-Bold-C91C8631-7E83-45F7-AD21-EC0502A772C5";
    NSFontFamilyAttribute = "Noto Sans";
    NSFontNameAttribute = "NotoSans-Bold";
}]

// ※ XXXXXXはユーザー名

このようにインストール済みのフォント情報を配列で参照することができます。

2. フォントを使用する

取り出したUICTFontDescriptorUIFontでそのまま使用できます。

let descriptors = CTFontManagerCopyRegisteredFontDescriptors(.persistent, true) as? [CTFontDescriptor]

/// 今回はとりあえず1つなので先頭のものを取り出しています。
guard let notoSansBoldDescriptor = descriptors?.first else { return }

label.font = UIFont(descriptor: notoSansBoldDescriptor, size: 30.0)

実際のところフォント情報の配列で返ってくるので、WWDCの動画にもあった「フォント名」をStringの配列にして保持しておくのが良いかと思います。

(以下動画の抜粋)
スクリーンショット 2019-11-04 14.26.17.png

先の自分が記載したコードでは1つのフォント(descriptors?.firstの部分)しか取りだしていませんが、このように「フォント名」をStringの配列で保持することで、必要なフォントを選択できるような実装が可能です。

CTFontDescriptorCopyAttribute を使用することで CTFontDescriptorからフォントの名前だけを取り出すことが可能です。

先のコードの延長として簡易なコードを記載しておきます。

// インストール済みフォント一覧取得
let descriptors = CTFontManagerCopyRegisteredFontDescriptors(.persistent, true) as? [CTFontDescriptor]

// Descriptor の取得
guard let notoSansBoldDescriptor = descriptors?.first else { return }

// Font名 の取得
guard let notoSansBold = CTFontDescriptorCopyAttribute(notoSansBoldDescriptor, kCTFontNameAttribute) as? String else { return }

label.font = UIFont(name: notoSansBold, size: 30.0)

フォントに使用期限を設ける

主にサブスクリプションなどを行う際に必要になるかと思います。
設定することでOS側でフォントを消してくれるようになります。

実際に設定すると、その期限になった時にアラートがでるようになります。

スクリーンショット 2019-12-23 6.27.47.png

設定するためには、info.plistに追加の記載が必要になります。

スクリーンショット 2019-12-23 6.20.03.png

この追加する「FontProviderSubscriptionSupportInfo」ですが、
公式に公開されているAPIではないので補完されてでてきません
なので、plistにコードで直接追加してください。

スクリーンショット 2019-12-23 6.20.12.png

設定項目としては

- warn: 警告を表示するまでの日数
- expire: フォントを削除するまでの日数
- url: openを押した際のscheme設定
- test: テストモードのon/off

となっており、この日数はフォントをインストールしてから換算されます。

warnで設定したアラートを無視し続けると、いずれexpireで設定した日にアラートが出てフォントは自動で削除されます。
testをYesにすると、warn/expireで設定した1日が1分換算になり、すぐテストできるようになります。(ただしシミュレータのみ)

終わりに

iOS13ではSwiftUIやCombineに目がいきがちですが、、、
こういったデザイン周りに関わってくるアップデートもたくさんあり、着目してみると面白いものです。

まだ、情報としてあまり出回っていないので、面白い知見があれば是非お教え願いたいです!

サクッと動作するものを置いておきます
Github: Font_Install_Demo

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UICollectionViewCompositionalLayout & DiffableDataSourceを利用したUIとCombineを利用したMVVMパターンでのAPI通信関連処理との組み合わせた実装の紹介とまとめ

1. はじめに

皆様お疲れ様です。「iOS Advent Calendar」の24日目を担当させて頂きます、fumiyasac(Fumiya Sakai)と申します。何卒よろしくお願い致します。

まずは僕自身の今年のトピックスとしては、技術書典5で頒布した書籍の商業化・業務では新たな現場での新規iOSアプリ開発を通じてRxSwift・Laravel・Nuxt.jsに触れる機会・新たな書籍の執筆&技術系同人誌イベントの参加・iOSDCからリジェクトコンでの2日連続での登壇...等々と昨年以上に変化とバラエティに富んだ1年ではありましたが、何とか楽しく過ごせておりました。

また今年のWWDC19では、WWDC19で押さえておきたいと思ったセッション10選でもまとめられているように、SwiftUIをはじめとして様々な新機能が紹介されたこともありキャッチアップしたいトピックがたくさんありました。

今回はその中でも僕が特に気になった、

  • UICollectionViewCompositionalLayout
  • DiffableDataSource
  • Combine

の3つのトピックに焦点を当てて、これらを活用した 「UICollectionViewを利用した複雑な画面レイアウトを構成する必要があるUI実装事例」 及び 「Combine+MVVMパターンを利用したAPI通信を利用したデータ取得から画面への反映までの処理の実装事例」 をある程度の形にまとめたUIサンプル実装を通して紹介できればと思います。

【以前登壇した際の発表資料】

今回の内容(主に2.〜 5.のセクションで解説している内容)につきましては、potatotips #66 (iOS/Android開発Tips共有会)にて登壇の際に利用した資料を下記のリンクにて共有しておりますので、こちらも是非ご活用頂けますと幸いです。

【Githubで公開しているサンプルコード】

この記事で紹介しているサンプルについては下記の2つになります。どちらも画面数や機能は多くはありませんが、どちらもiOS13以降の新機能となる、UICollectionViewCompositionalLayout / NSDiffableDataSource / Combineを活用して普段の業務で利用しているものに少し近しい形にまとめてみたものになります。

※ 「もっとこうした方が良い」というご意見があったり「この実装はあまりよろしくない」等のご意見等が御座いましたらIssueやPullRequest等をお送り頂けますと幸いです!

2. サンプル概要について

本記事では、解説に当たって2種類のサンプルを準備しました。表現しているデザインは異なりますが、アーキテクチャの基本方針は類似した形にしています。

⭐️2-1. サンプル紹介(ComplexCollectionViewStyleExample)

こちらのサンプルについては、

  1. UICollectionViewCompositionalLayoutを活用した少し複雑なレイアウトへの構築
  2. 異なるセクションで取り得るセルのデザインや表示データが異なる場合の表示

をテーマとしたサンプルになります。

【画面デザイン】

sample1_thumbnail1.jpg

sample1_thumbnail2.jpg

【利用したライブラリ】

ライブラリ名 ライブラリの機能概要
Nuke 画像キャッシュ用のライブラリ
FontAwesome.swift 「Font Awesome」アイコンを利用するためのライブラリ
ActiveLabel.swift 押下可能なURLリンク・ハッシュタグ・メンション要素等を作りやすくするライブラリ

⭐️2-2. サンプル紹介(DiffableDataSourceExample)

こちらのサンプルについては、UICollectionViewCompositionalLayoutを利用してPinterestの様なレイアウトを構築する点に加えて、

  1. UICollectionView及びUITableViewでDiffableDataSourceを利用した実装
  2. 頻出のPullToRefreshやスクロール最下部到達時の追加読み込み

を実現してみました。

※ 前述のサンプルよりはシンプルな構成となっています。

【画面デザイン】

sample2_thumbnail1.jpg

sample2_thumbnail2.jpg

【利用したライブラリ】

ライブラリ名 ライブラリの機能概要
PTCardTabBar DesignicなTabBarを実現するライブラリ
AlamofireImage 画像キャッシュ用ライブラリ

⭐️2-3. サンプルに関する補足事項

サンプルで利用しているAPIモックサーバーについて:

今回紹介しているサンプルについては、検証用Mockサーバーをnode.js製の「json-server」を利用しています。

※ 動作方法と環境構築方法については各サンプルのREADMEを参照して下さい。

環境やバージョンについて:

  • Xcode 11.1
  • Swift 5.1
  • MacOS Catalina (Ver10.15.1)

3. UICollectionViewCompositionalLayoutを活用してSectionごとにバリエーションの異なるセルのデザインを構築する

UICollectionViewCompositionalLayoutを活用した場合の大きなメリットとしては、UICollectionViewを利用した複雑なレイアウトを構築する際にも、アプローチがしやすい形になった点だと個人的に感じています。

よくお目にかかるのですぐできるのでは?感じるレイアウトであっても、いざUICollectionViewで構築してみるとなかなか一手間加えないと難しかったという経験はあるかと思います(僕もこのような経験をすることはしばしばあります...?)。しかしUICollectionViewCompositionalLayoutでの実装で置き換えると、従来の実装よりも構築時のイメージがし易くかつシンプルな形で落とし込む事ができる場合も多いと思います。

ここでは、UICollectionViewCompositionalLayoutの実装やレイアウトを考える際に押さえておくと良さそうな点を、実際のレイアウト構築事例を交えながら解説していきます。

⭐️3-1. 1つの画面の中に異なる属性の要素が多数存在する場合を考える

まずはUICollectionViewを利用した実装において、下図のような構造を例に考えてみます。頑張って単一のUICollectionViewとSectionを利用しても実現できるかもしれませんが、各要素毎に複雑なレイアウトの実装が必要な場合やデータ取得先が異なる場合においては、表示要素を小さな単位で切り出すことが多いかと思います。

uicollectionview_complex_layout.png

また、このような画面を構築する際のアプローチの方針の例として、

  • UITableView + UICollectionViewの組み合わせで実現するアプローチ
  • ContainerView + UICollectionViewの組み合わせで実現するアプローチ
  • 「IGListKit」等のライブラリを利用した差分更新と構造管理をするアプローチ

等の選択肢が考えられると思いますが、必要以上に画面を構成するための表示要素が増えると管理が煩雑になってしまう点やレイアウトや表示の整合性を合わせる処理の難易度が上がってしまう場合もありそうです。

このような問題を上手に解決する際のアプローチとしてUICollectionViewCompositionalLayoutを活用するアプローチは今後は主流になっていきそうにも感じています。

⭐️3-2. UICollectionViewCompositionalLayoutにおけるポイントになる部分とレイアウト構築時における考え方

UICollectionViewCompositionalLayoutを利用したUI実装をする場合に、従来までの実装方法と大きく変わる点を簡潔にまとめると、

  1. UICollectionViewCompositionalLayoutを利用したSection毎に定義したレイアウトを組み立てて適用する処理の実装方法
  2. UICollectionViewDiffableDataSourceを利用した各種セル表示要素とDataSourceの実装方法
  3. NSDiffableDataSourceSnapshotを利用した差分更新が考慮された表示要素の反映方法

の3点になります。クラス名も長いので一見すると複雑そうな印象がありますが、実際に表示データやレイアウトを組み立てていく処理を紐解いていくと、個人的な所管にはなりますがセクション毎の構成がつかみやすく、とても美しい構成だと感じています。

【構成要素や概要に関するポイント】

改めて前述した、UICollectionViewCompositionalLayoutを利用したUI実装をする場合において利用するクラスと役割をまとめると下図の様な形になります。

fundamental_point.png

UIに表示するためのデータを格納して管理するNSDiffableDataSourceSnapShot及びデータ反映のためのUICollectionViewDiffableDataSourceについては、セクション毎に表示対象のModelにおいて、データの型が異なる場合でもHashableに適合していれば対応できる形にしています。

この点を踏まえた、本サンプル(ComplexCollectionViewStyleExample)における実装部分の概要をまとめると下記のような形になります。

MainViewController.swift
// MEMO: セクション毎に定義したEnum値

enum MainSection: Int, CaseIterable {
    case FeaturedBanners
    case FeaturedInterviews
    case RecentKeywords
    case NewArrivalArticles
    case RegularArticles
}

// ① UICollectionViewを差分更新するためのNSDiffableDataSourceSnapshot
// → Section毎に表示するModelデータ定義が違うが、Hashableプロトコルに適合している必要がある

private var snapshot: NSDiffableDataSourceSnapshot<MainSection, AnyHashable>!

// ② UICollectionViewを組み立てるためのDataSource
// → Section毎に表示するModelデータ定義が違うが、Hashableプロトコルに適合している必要がある

private var dataSource: UICollectionViewDiffableDataSource<MainSection, AnyHashable>! = nil

// ③ UICollectionViewCompositionalLayoutの設定
// → Section毎に定義したレイアウトを適用する

private lazy var compositionalLayout: UICollectionViewCompositionalLayout = {
    let layout = UICollectionViewCompositionalLayout { [weak self] (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in

        // MainSection毎に定義したレイアウトを適用する
        // → デザインに応じてNSLayoutCollectionを組み立てる

        switch sectionIndex {

        // MainSection: 0 (FeaturedBanners)
        case MainSection.FeaturedBanners.rawValue:
            return self?.createFeaturedBannersLayout()

        // MainSection: 1 (FeaturedInterviews)
        case MainSection.FeaturedInterviews.rawValue:
            return self?.createFeaturedInterviewsLayout()

        // MainSection: 2 (RecentKeywords)
        case MainSection.RecentKeywords.rawValue:
            return self?.createRecentKeywordsLayout()

        // MainSection: 3 (NewArrivalArticles)
        case MainSection.NewArrivalArticles.rawValue:
            return self?.createNewArrivalArticles()

        // MainSection: 4 (RegularArticles)
        case MainSection.RegularArticles.rawValue:
            return self?.createRegularArticles()

        default:
            fatalError()
        }
    }
    return layout
}()

【セル要素・Header・Footer部分の組み立てる場合のポイント】

セル要素を組み立てる処理はUICollectionViewDiffableDataSourceを利用する形になりますが、実際にセルを組み立てる処理についてはクロージャー内にセル要素を組み立てる処理を記載する形となります。

UICollectionViewDiffableDataSource<MainSection, AnyHashable>(collectionView: collectionView) { (collectionView: UICollectionView, indexPath: IndexPath, model: AnyHashable) -> UICollectionViewCell? in ...

    // MEMO: この中にセルを組み立てるための処理を記載する
    // → Section毎に定義するModelが異なる場合にはModelの型で判定する

    // (例) let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! ExampleCollectionViewCell

}

また任意のセクションの中にHeader・Footerが必要な場合には、UICollectionViewDiffableDataSourceのsupplementaryViewProviderプロパティのクロージャー内にHeader・Footer用のUICollectionReusableViewを継承したView要素を組み立てる処理を記載する形となります。

MainViewController.swift
dataSource.supplementaryViewProvider = { (collectionView: UICollectionView, kind: String, indexPath: IndexPath) -> UICollectionReusableView? in

    // MEMO: Header・Footerを組み立てるための処理を記載する
    // → indexPath.sectionでセクションを判定 & kindでelementKindSectionHeader(Footer)を判定

    // (例) let header = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "Header", for: indexPath) as! ExampleCollectionHeaderView
}

この点を踏まえた、本サンプル(ComplexCollectionViewStyleExample)におけるセル要素を組み立てる処理の概要をまとめると下記のような形になります。

MainViewController.swift
final class MainViewController: UIViewController {

    ・・・(省略)・・・

    // MARK: - Override

    override func viewDidLoad() {
        super.viewDidLoad()

        setupCollectionView()

       ・・・(省略)・・・   
    }

    ・・・(省略)・・・

    private func setupCollectionView() {

        // このレイアウトで利用するセル要素・Header・Footerの登録

        // MainSection: 0 (FeaturedBanner)
        collectionView.registerCustomCell(FeaturedCollectionViewCell.self)

        // MainSection: 1 (FeaturedInterview)
        collectionView.registerCustomCell(FeaturedInterviewCollectionViewCell.self)

        // MainSection: 2 (RecentKeyword)
        collectionView.registerCustomCell(KeywordCollectionViewCell.self)
        collectionView.registerCustomReusableHeaderView(KeywordCollectionHeaderView.self)
        collectionView.registerCustomReusableFooterView(KeywordCollectionFooterView.self)

        // MainSection: 3 (NewArrivalArticle)
        collectionView.registerCustomCell(NewArrivalCollectionViewCell.self)
        collectionView.registerCustomCell(PhotoCollectionViewCell.self)
        collectionView.registerCustomReusableHeaderView(NewArrivalCollectionHeaderView.self)

        // MainSection: 4 (RegularArticle)
        collectionView.registerCustomCell(ArticleCollectionViewCell.self)
        collectionView.registerCustomReusableHeaderView(ArticleCollectionHeaderView.self)

        // UICollectionViewDelegateについては従来通り
        collectionView.delegate = self

        // UICollectionViewCompositionalLayoutを利用してレイアウトを組み立てる
        collectionView.collectionViewLayout = compositionalLayout

        // DataSourceはUICollectionViewDiffableDataSourceを利用してUICollectionViewCellを継承したクラスを組み立てる
        dataSource = UICollectionViewDiffableDataSource<MainSection, AnyHashable>(collectionView: collectionView) { (collectionView: UICollectionView, indexPath: IndexPath, model: AnyHashable) -> UICollectionViewCell? in

            switch model {

            // MainSection: 0 (FeaturedBanner)
            case let model as FeaturedBanner:

                let cell = collectionView.dequeueReusableCustomCell(with: FeaturedCollectionViewCell.self, indexPath: indexPath)
                cell.setCell(model)
                return cell

            // MainSection: 1 (FeaturedInterview)
            case let model as FeaturedInterview:

                let cell = collectionView.dequeueReusableCustomCell(with: FeaturedInterviewCollectionViewCell.self, indexPath: indexPath)
                cell.setCell(model)
                return cell

            // MainSection: 2 (RecentKeyword)
            case let model as Keyword:

                let cell = collectionView.dequeueReusableCustomCell(with: KeywordCollectionViewCell.self, indexPath: indexPath)
                cell.setCell(model)
                return cell

            // MainSection: 3 (NewArrivalArticle)
            case let model as NewArrival:

                // MEMO: 3で割って1余るインデックス値の場合は大きなサイズのセルを適用する
                if model.id % 3 == 1 {
                    let cell = collectionView.dequeueReusableCustomCell(with: NewArrivalCollectionViewCell.self, indexPath: indexPath)
                    cell.setCell(model, index: indexPath.row + 1)
                    return cell
                } else {
                    let cell = collectionView.dequeueReusableCustomCell(with: PhotoCollectionViewCell.self, indexPath: indexPath)
                    cell.setCell(model, index: indexPath.row + 1)
                    return cell
                }

            // MainSection: 4 (RegularArticle)
            case let model as Article:

                let cell = collectionView.dequeueReusableCustomCell(with: ArticleCollectionViewCell.self, indexPath: indexPath)
                cell.setCell(model)
                return cell

            default:
                return nil
            }
        }

        // Header・Footerの表記についてもUICollectionViewDiffableDataSourceを利用して組み立てる
        dataSource.supplementaryViewProvider = { (collectionView: UICollectionView, kind: String, indexPath: IndexPath) -> UICollectionReusableView? in

            switch indexPath.section {

            // MainSection: 2 (RecentKeyword)
            case MainSection.RecentKeywords.rawValue:
                if kind == UICollectionView.elementKindSectionHeader {
                    let header = collectionView.dequeueReusableCustomHeaderView(with: KeywordCollectionHeaderView.self, indexPath: indexPath)
                    header.setHeader(
                        title: "最近の「キーワード」をチェック",
                        description: "テレビ番組で人気のお店や特別な日に使える情報をたくさん掲載しております。気になるキーワードはあるけれども「あのお店なんだっけ?」というのが具体的に思い出せない場面が結構あると思います。最新情報に早めにキャッチアップしたい方におすすめです!"
                    )
                    return header
                }
                if kind == UICollectionView.elementKindSectionFooter {
                    let footer = collectionView.dequeueReusableCustomFooterView(with: KeywordCollectionFooterView.self, indexPath: indexPath)
                    return footer
                }

            // MainSection: 3 (NewArrivalArticle)
            case MainSection.NewArrivalArticles.rawValue:
                if kind == UICollectionView.elementKindSectionHeader {
                    let header = collectionView.dequeueReusableCustomHeaderView(with: NewArrivalCollectionHeaderView.self, indexPath: indexPath)
                    header.setHeader(
                        title: "新着メニューの紹介",
                        description: "アプリでご紹介しているお店の新着メニューを紹介しています。新しいお店の発掘やさらなる行きつけのお店の魅力を見つけられるかもしれません。"
                    )
                    return header
                }

            // MainSection: 4 (RegularArticle)
            case MainSection.RegularArticles.rawValue:
                if kind == UICollectionView.elementKindSectionHeader {
                    let header = collectionView.dequeueReusableCustomHeaderView(with: ArticleCollectionHeaderView.self, indexPath: indexPath)
                    header.setHeader(
                        title: "おすすめ記事一覧",
                        description: "よく行くお店からこちらで厳選してみました。というつもりです…。でも結構美味しそうなのではないかと思いますよので是非ともご堪能してみてはいかがでしょうか?"
                    )
                    return header
                }

            default:
                break
            }
            return nil
        }

        ・・・(省略)・・・
    }

    ・・・(省略)・・・
}

※ この部分はもっと実装を整理できる余地がある部分かと思います...?

【UICollectionViewCompositionalLayoutのレイアウト作成時のポイント】

UICollectionViewCompositionalLayoutのレイアウトを組み立てていく際には、レイアウトを構成する4つの要素 「Layout / Section / Group / Item」 の関係に注目して、NSCollectionLayoutSizeを設定していく点がポイントになるかと思います。

uicollectionviewcompositional_layout_fundamental.png

また、本サンプル(ComplexCollectionViewStyleExample)で1つのUICollectionViewに配置しているセクション構築のバリエーションは下記のような形になります。従来の実装方法ではレイアウトが複雑な表現がそれぞれのセクションで展開される形はなかなか実現がしんどく感じることが多い場合もありますが、UICollectionViewCompositionalLayoutのおかげで綺麗にまとめやすい形なのは嬉しいですね。

uicollectionviewcompositional_layout_pattern.png

⭐️3-3. (レイアウト例1) Section内の表示セル要素が横方向にスクロールする表現

まずは、バナー表示カルーセルの様なスクロールをするレイアウト及び、キーワード一覧を横に並べてスクロールを伴う形にするレイアウトを実現するためのコードは下記の様な形になります。セクションを構築する際には「Item → Group → Section」という順番でレイアウトを考えていくとイメージがよりしやすいのではないかと思います。スクロールのバリエーションについてもorthogonalScrollingBehaviorプロパティで決定可能である点や、contentInsetsプロパティを利用した間隔調整もItem・Group・ Sectionで可能な点を活用してより柔軟なレイアウトの構成ができます。

MainViewController.swift
// ① バナー表示カルーセル表現をするレイアウト構築例
private func createFeaturedBannersLayout() -> NSCollectionLayoutSection {

    // 1. Itemのサイズ設定
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalWidth(1.0))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    item.contentInsets = .zero

    // 2. Groupのサイズ設定
    // MEMO: 1列に表示するカラム数を1として設定し、itemのサイズがgroupのサイズで決定する形にしている
    let groupHeight = UIScreen.main.bounds.width * (3 / 8)
    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(groupHeight))
    let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitem: item, count: 1)
    group.contentInsets = .zero

    // 3. Sectionのサイズ設定
    let section = NSCollectionLayoutSection(group: group)
    section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 16, trailing: 0)
    // MEMO: スクロール終了時に水平方向のスクロールが可能で中心位置で止まる
    section.orthogonalScrollingBehavior = .groupPagingCentered
    return section
}

// ② キーワード一覧を横に並べてスクロールを伴う表現をするレイアウト構築例
private func createRecentKeywordsLayout() -> NSCollectionLayoutSection {

    // 1. Itemのサイズ設定
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    item.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 6, bottom: 0, trailing: 6)

    // 2. Groupのサイズ設定
    // MEMO: 1列に表示するカラム数を1として設定し、itemのサイズがgroupのサイズで決定する形にしている
    let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(160), heightDimension: .absolute(40))
    let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitem: item, count: 1)

    // 3. Sectionのサイズ設定
    let section = NSCollectionLayoutSection(group: group)
    // MEMO: HeaderとFooterのレイアウトを決定する
    let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(65.0))
    let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
    let footerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(28.0))
    let footer = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: footerSize, elementKind: UICollectionView.elementKindSectionFooter, alignment: .bottom)
    section.boundarySupplementaryItems = [header, footer]
    section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 6, bottom: 16, trailing: 6)
    // MEMO: スクロール終了時に水平方向のスクロールが可能で速度が0になった位置で止まる
    section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary

    return section
}

⭐️3-4. (レイアウト例2) Instagramのフィード表示のようなDynamicHeightSizing

次にInstagramのフィード表示のような1行のセル表示でDynamicHeightSizing(高さが可変になる)表現を考えてみます。高さを可変にしたい場合には、ItemとGroupのサイズを設定する際に高さを予測値を一番データ表示が少ない場合の高さを設定すると良いかと思います。

dynamic_sizing_example.png

MainViewController.swift
private func createFeaturedInterviewsLayout() -> NSCollectionLayoutSection {

    // MEMO: 該当のセルを基準にした高さの予測値を設定する
    let estimatedHeight = UIScreen.main.bounds.width + 180.0

    // 1. Itemのサイズ設定
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(estimatedHeight))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    item.contentInsets = .zero

    // 2. Groupのサイズ設定
    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(estimatedHeight))
    let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
    group.contentInsets = .zero

    // 3. Sectionのサイズ設定
    let section = NSCollectionLayoutSection(group: group)
    section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 16, trailing: 0)

    return section
}

⭐️3-5. (レイアウト例3) Instagramの写真表示のようなMosaicLayout

もう一つUICollectionViewの複雑なレイアウトの実装例としてInstagramの写真表示のようなMosaicLayoutの表現を考えてみます。Groupの入れ子構造を組み合わせてレイアウトを組み立てていく点がポイントになります。

mosaic_layout_example.png

MainViewController.swift
private func createNewArrivalArticles() -> NSCollectionLayoutSection {

    // 1. Itemのサイズ設定
    // MEMO: 全体幅2/3の正方形を作るために左側の幅を.fractionalWidth(0.67)に決める
    let twoThirdItemSet = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.67), heightDimension: .fractionalHeight(1.0)))
    twoThirdItemSet.contentInsets = NSDirectionalEdgeInsets(top: 0.5, leading: 0.5, bottom: 0.5, trailing: 0.5)
    // MEMO: 右側に全体幅1/3の正方形を2つ作るために高さを.fractionalHeight(0.5)に決める
    let oneThirdItem = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(0.5)))
    oneThirdItem.contentInsets = NSDirectionalEdgeInsets(top: 0.5, leading: 0.5, bottom: 0.5, trailing: 0.5)
    // MEMO: 1列に表示するカラム数を2として設定し、Group内のアイテムの幅を1/3の正方形とするためにGroup内の幅を.fractionalWidth(0.33)に決める
    let oneThirdItemSet = NSCollectionLayoutGroup.vertical(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.33), heightDimension: .fractionalHeight(1.0)), subitem: oneThirdItem, count: 2)

    // 2. Groupのサイズ設定
    // MEMO: leadingItem(左側へ表示するアイテム1つ)とtrailingGroup(右側へ表示するアイテム2個のグループ1個)を合わせる
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(0.33)), subitems: [twoThirdItemSet, oneThirdItemSet])

    // 3. Sectionのサイズ設定
    let section = NSCollectionLayoutSection(group: group)
    // MEMO: HeaderとFooterのレイアウトを決定する
    let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
    let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
    section.boundarySupplementaryItems = [header]
    section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 16, trailing: 0)

    return section
}

4. DiffableDataSourceを利用した表示内容の反映と表示用のModelデータに関する部分について

次にDiffableDataSourceを利用した処理に関する部分にも触れてみます。UICollectionViewにおけるDataSourceの更新を反映する処理はreloadData()をはじめ頻出の実装ではありますが、用件や更新タイミングに関する処理がシビアで複雑な場合は、「データとUIの表示状態の食い違い」に注意が必要でした。

新しく登場したDiffableDataSourceは、従来までのperformBatchUpdatesを利用した処理でも難しかった「データとUIの表示状態の食い違いの防止」を内部で解決してくれる点も大きな魅力の1つかと思います。

※iOS13以降であれば、UITableViewを利用した場合でもNSDiffableDataSourceを利用する事が可能です。

⭐️4-1. NSDiffableDataSourceを利用する際における基本的なデータの更新方法

取得したデータの取得〜データの反映までの流れを簡潔にまとめると、

  1. NSDiffableDataSourceSnapshotに定義したセクションに該当するデータをセットする
  2. UICollectionViewDiffableDataSourceのapplyメソッドでDiffableDataSourceSnapshotの内容を反映する

となります。下記は、本サンプル(ComplexCollectionViewStyleExample)におけるセル要素を取得して反映させる処理部分のコードを抜粋したものになります。

MainViewController.swift
// ① NSDiffableDataSourceSnapshotの初期設定
// → Section毎のEnum定義(MainSection)に応じて表示するModelデータ定義が違うが、Hashableプロトコルに適合している必要がある

snapshot = NSDiffableDataSourceSnapshot<MainSection, AnyHashable>()
snapshot.appendSections(MainSection.allCases)
for mainSection in MainSection.allCases {
    snapshot.appendItems([], toSection: mainSection)
}
dataSource.apply(snapshot, animatingDifferences: false)

// ② UICollectionViewを差分更新するためのNSDiffableDataSourceSnapshotの更新
// → APIからのデータ取得ができた際に該当セクションの値を更新してUICollectionViewDiffableDataSource<MainSection, AnyHashable>に反映する
// 補足: 更新時のアニメーション可否はanimatingDifferencesで行う

let featuredInterviews: [FeaturedInterview] = receiverdFeaturedInterviews
snapshot.appendItems(featuredInterviews, toSection: .FeaturedInterviews)
dataSource.apply(snapshot, animatingDifferences: false)

⭐️4-2. NSDiffableDataSourceを利用する際におけるModel作成時におけるポイント

APIモックサーバーを経由して取得するUIに表示するデータについては、JSON経由で取得する想定で作成しているのでDecodableに適合させている点に加えて、NSDiffableDataSourceで利用可能な形にするためにHashableにも適合させる必要があります。
※ 今回は取得したデータをシンプルにUIに反映させるだけの処理なので、IDをハッシュに設定しています。

各セクションで表示データのModel定義及びAPIモックサーバーのエンドポイントは異なりますが、基本的にはDecodable, Hashableに適合した形でJSONの形に合わせた定義としています。下記は、本サンプル(ComplexCollectionViewStyleExample)におけるModel定義の例を抜粋したものになります。

FeaturedInterview.swift
struct FeaturedInterview: Hashable, Decodable {

    let id: Int
    let profileName: String
    let dateString: String
    let imageUrl: String
    let title: String
    let description: String
    let tags: String

    // MARK: - Enum

    private enum Keys: String, CodingKey {
        case id
        case profileName = "profile_name"
        case dateString = "date_string"
        case imageUrl = "image_url"
        case title
        case description
        case tags
    }

    // MARK: - Initializer

    init(from decoder: Decoder) throws {

        // JSONの配列内の要素を取得する
        let container = try decoder.container(keyedBy: Keys.self)

        // JSONの配列内の要素にある値をDecodeして初期化する
        self.id = try container.decode(Int.self, forKey: .id)
        self.profileName = try container.decode(String.self, forKey: .profileName)
        self.dateString = try container.decode(String.self, forKey: .dateString)
        self.imageUrl = try container.decode(String.self, forKey: .imageUrl)
        self.title = try container.decode(String.self, forKey: .title)
        self.description = try container.decode(String.self, forKey: .description)
        self.tags = try container.decode(String.self, forKey: .tags)
    }

    // MARK: - Hashable

    // MEMO: Hashableプロトコルに適合させるための処理

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }

    static func == (lhs: FeaturedInterview, rhs: FeaturedInterview) -> Bool {
        return lhs.id == rhs.id
    }
}

5. Combineを利用してAPIリクエストをMVVMパターンでハンドリングする部分について

こちらも、iOS13から新しく登場したCombineを利用したAPIリクエストをハンドリングするための実装をしています。今年からの実務では「RxSwift + MVVM + ViewModelへのアクセス時に入力(Input)・出力(Output)を明記する」構成でのiOSアプリ開発に触れる時間が多かったので、RxSwiftの部分をCombineを利用した実装にリプレイスしていく方針を試してみました。

combine_rxswift_mvvm.png

Kickstarter-iOSで利用しているViewModelの設計と実装については、下記の資料も参考にするとより理解が深まるかと思います。

⭐️5-1. API通信処理の部分をCombineを利用した実装解説

API通信処理部分をRxSwiftで実装する場合には、Single<T>を利用して成功か失敗かのいずれかのイベントを1度だけ流すことを保証するオペレータを活用した実装や、Alamofireをラップしたライブラリの「Moya」を活用する選択をすることが多いかと思いますが、CombineではSingle<T>と類似した振る舞いをするFuture<Output, Failure>を利用してAPI通信部分の処理を組み立てています。

ここに加えて、それぞれ異なるModel定義に合致したJSONレスポンスの形にうまく対応させるために、T: Decodable & HashableのGenericsにしている点もポイントになります。

これらの点を踏まえた、本サンプル(ComplexCollectionViewStyleExample)におけるAPI通信処理に関する実装をまとめると下記のような形になります。

APIRequestManager.swift
import Foundation
import Combine

// MARK: - Protocol

enum APIError : Error {
    case error(String)
}

protocol APIRequestManagerProtocol {
    func getFeaturedBanners() -> Future<[FeaturedBanner], APIError>
    func getFeaturedInterviews() -> Future<[FeaturedInterview], APIError>
    func getKeywords() -> Future<[Keyword], APIError>
    func getNewArrivals() -> Future<[NewArrival], APIError>
    func getArticles() -> Future<[Article], APIError>
}

class APIRequestManager {

    // MEMO: MockサーバーへのURLに関する情報
    private static let host = "http://localhost:3000/api/mock"
    private static let version = "v1"
    private static let path = "gourmet"

    private let session = URLSession.shared

    // MARK: - Singleton Instance

    static let shared = APIRequestManager()

    private init() {}

    // MARK: - Enum

    private enum EndPoint: String {

        case featuredBanner = "featured_banners"
        case featuredInterview = "featured_interviews"
        case keyword = "keywords"
        case newArrival = "new_arrivals"
        case article = "articles"

        func getBaseUrl() -> String {
            return [host, version, path, self.rawValue].joined(separator: "/")
        }
    }
}

// MARK: - APIRequestManagerProtocol

extension APIRequestManager: APIRequestManagerProtocol {

    // MARK: - Function

    func getFeaturedBanners() -> Future<[FeaturedBanner], APIError> {
        let featuresdBannersAPIRequest = makeUrlForGetRequest(EndPoint.featuredBanner.getBaseUrl())
        return handleSessionTask(FeaturedBanner.self, request: featuresdBannersAPIRequest)
    }

    ・・・(以降は同様にAPIリクエストを実行する処理を実施する)・・・

    // MARK: - Private Function

    private func handleSessionTask<T: Decodable & Hashable>(_ dataType: T.Type, request: URLRequest) -> Future<[T], APIError> {
        return Future { promise in

            let task = self.session.dataTask(with: request) { data, response, error in
                // MEMO: レスポンス形式やステータスコードを元にしたエラーハンドリングをする
                if let error = error {
                    promise(.failure(APIError.error(error.localizedDescription)))
                    return
                }
                guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
                    promise(.failure(APIError.error("Error: invalid HTTP response code")))
                    return
                }
                guard let data = data else {
                    promise(.failure(APIError.error("Error: missing response data")))
                    return
                }
                // MEMO: 取得できたレスポンスを引数で指定した型の配列に変換して受け取る
                do {
                    let hashableObjects = try JSONDecoder().decode([T].self, from: data)
                    promise(.success(hashableObjects))
                } catch {
                    promise(.failure(APIError.error(error.localizedDescription)))
                }
            }
            task.resume()
        }
    }

    private func makeUrlForGetRequest(_ urlString: String) -> URLRequest {
        guard let url = URL(string: urlString) else {
            fatalError()
        }
        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = "GET"
        urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
        return urlRequest
    }
}

⭐️5-2. Combineを利用したModel → ViewModel部分の実装解説

次に、Combineを利用したViewModelについて紹介していきます。Input(何らかの処理を発火させるためのトリガー)Output(処理によって取得できた結果を反映させる変数)の定義を前述したAPI通信処理と組み合わせることによって、

  • 処理の実行: mainViewModel.inputs.●●●Trigger.send()
  • 結果の反映: mainViewModel.outputs.●●●.subscribe(on: RunLoop.main).sink({ ... })

の流れをつくり、ViewControllerにおけるデータの取得処理・反映処理を繋げられる様な形にしています。

本サンプル(ComplexCollectionViewStyleExample)におけるViewModelの実装をまとめると下記のような形になります。

MainViewModel.swift
import Foundation
import Combine

// MARK: - Protocol

protocol MainViewModelInputs {
    var fetchFeaturedBannersTrigger: PassthroughSubject<Void, Never> { get }
    var fetchFeaturedInterviewsTrigger: PassthroughSubject<Void, Never> { get }
    var fetchKeywordsTrigger: PassthroughSubject<Void, Never> { get }
    var fetchNewArrivalsTrigger: PassthroughSubject<Void, Never> { get }
    var fetchArticlesTrigger: PassthroughSubject<Void, Never> { get }
}

protocol MainViewModelOutputs {
    var featuredBanners: AnyPublisher<[FeaturedBanner], Never> { get }
    var featuredInterviews: AnyPublisher<[FeaturedInterview], Never> { get }
    var keywords: AnyPublisher<[Keyword], Never> { get }
    var newArrivals: AnyPublisher<[NewArrival], Never> { get }
    var articles: AnyPublisher<[Article], Never> { get }
}

protocol MainViewModelType {
    var inputs: MainViewModelInputs { get }
    var outputs: MainViewModelOutputs { get }
}

final class MainViewModel: MainViewModelType, MainViewModelInputs, MainViewModelOutputs {

    // MARK: - MainViewModelType

    var inputs: MainViewModelInputs { return self }
    var outputs: MainViewModelOutputs { return self }

    // MARK: - MainViewModelInputs

    let fetchFeaturedBannersTrigger = PassthroughSubject<Void, Never>()
    let fetchFeaturedInterviewsTrigger = PassthroughSubject<Void, Never>()
    let fetchKeywordsTrigger = PassthroughSubject<Void, Never>()
    let fetchNewArrivalsTrigger = PassthroughSubject<Void, Never>()
    let fetchArticlesTrigger = PassthroughSubject<Void, Never>()

    // MARK: - MainViewModelOutputs

    var featuredBanners: AnyPublisher<[FeaturedBanner], Never> {
        return $_featuredBanners.eraseToAnyPublisher()
    }
    var featuredInterviews: AnyPublisher<[FeaturedInterview], Never> {
        return $_featuredInterviews.eraseToAnyPublisher()
    }
    var keywords: AnyPublisher<[Keyword], Never> {
        return $_keywords.eraseToAnyPublisher()
    }
    var newArrivals: AnyPublisher<[NewArrival], Never> {
        return $_newArrivals.eraseToAnyPublisher()
    }
    var articles: AnyPublisher<[Article], Never> {
        return $_articles.eraseToAnyPublisher()
    }

    private let api: APIRequestManagerProtocol

    private var cancellables: [AnyCancellable] = []

    // MARK: - @Published

    // MEMO: このコードではNSDiffableDataSourceSnapshotの差分更新部分で利用する
    @Published private var _featuredBanners: [FeaturedBanner] = []
    @Published private var _featuredInterviews: [FeaturedInterview] = []
    @Published private var _keywords: [Keyword] = []
    @Published private var _newArrivals: [NewArrival] = []
    @Published private var _articles: [Article] = []

    // MARK: - Initializer

    init(api: APIRequestManagerProtocol) {

        // MEMO: 適用するAPIリクエスト用の処理
        self.api = api

        // MEMO: InputTriggerとAPIリクエストをするための処理を結合する
        fetchFeaturedBannersTrigger
            .sink(
                receiveValue: { [weak self] in
                    self?.fetchFeaturedBanners()
                }
            )
            .store(in: &cancellables)
        fetchFeaturedInterviewsTrigger
            .sink(
                receiveValue: { [weak self] in
                    self?.fetchFeaturedInterviews()
                }
            )
            .store(in: &cancellables)
        fetchKeywordsTrigger
            .sink(
                receiveValue: { [weak self] in
                    self?.fetchKeywords()
                }
            )
            .store(in: &cancellables)
        fetchNewArrivalsTrigger
            .sink(
                receiveValue: { [weak self] in
                    self?.fetchNewArrivals()
                }
            )
            .store(in: &cancellables)
        fetchArticlesTrigger
            .sink(
                receiveValue: { [weak self] in
                    self?.fetchArticles()
                }
            )
            .store(in: &cancellables)
    }

    // MARK: - deinit

    deinit {
        cancellables.forEach { $0.cancel() }
    }

    // MARK: - Privete Function

    private func fetchFeaturedBanners() {
        api.getFeaturedBanners()
            .receive(on: RunLoop.main)
            .sink(
                receiveCompletion: { completion in
                    switch completion {
                    // MEMO: 値取得成功時(※本当は厳密にエラーハンドリングする必要がある)
                    case .finished:
                        print("finished fetchFeaturedBanners(): \(completion)")
                    // MEMO: エラー時(※本当は厳密にエラーハンドリングする必要がある)
                    case .failure(let error):
                        print("error fetchFeaturedBanners(): \(error.localizedDescription)")
                    }
                },
                receiveValue: { [weak self] hashableObjects in
                    print(hashableObjects)
                    self?._featuredBanners = hashableObjects
                }
            )
            .store(in: &cancellables)
    }

    private func fetchFeaturedInterviews() {
        api.getFeaturedInterviews()
            .receive(on: RunLoop.main)
            .sink(
                receiveCompletion: { completion in
                    switch completion {
                    // MEMO: 値取得成功時(※本当は厳密にエラーハンドリングする必要がある)
                    case .finished:
                        print("finished fetchFeaturedInterviews(): \(completion)")
                    // MEMO: エラー時(※本当は厳密にエラーハンドリングする必要がある)
                    case .failure(let error):
                        print("error fetchFeaturedInterviews(): \(error.localizedDescription)")
                    }
                },
                receiveValue: { [weak self] hashableObjects in
                    print(hashableObjects)
                    self?._featuredInterviews = hashableObjects
                }
            )
            .store(in: &cancellables)
    }

    private func fetchKeywords() {
        api.getKeywords()
            .receive(on: RunLoop.main)
            .sink(
                receiveCompletion: { completion in
                    switch completion {
                    // MEMO: 値取得成功時(※本当は厳密にエラーハンドリングする必要がある)
                    case .finished:
                        print("finished fetchKeywords(): \(completion)")
                    // MEMO: エラー時(※本当は厳密にエラーハンドリングする必要がある)
                    case .failure(let error):
                        print("error fetchKeywords(): \(error.localizedDescription)")
                    }
                },
                receiveValue: { [weak self] hashableObjects in
                    print(hashableObjects)
                    self?._keywords = hashableObjects
                }
            )
            .store(in: &cancellables)
    }

    private func fetchNewArrivals() {
        api.getNewArrivals()
            .receive(on: RunLoop.main)
            .sink(
                receiveCompletion: { completion in
                    switch completion {
                    // MEMO: 値取得成功時(※本当は厳密にエラーハンドリングする必要がある)
                    case .finished:
                        print("finished fetchNewArrivals(): \(completion)")
                    // MEMO: エラー時(※本当は厳密にエラーハンドリングする必要がある)
                    case .failure(let error):
                        print("error fetchNewArrivals(): \(error.localizedDescription)")
                    }
                },
                receiveValue: { [weak self] hashableObjects in
                    print(hashableObjects)
                    self?._newArrivals = hashableObjects
                }
            )
            .store(in: &cancellables)
    }

    private func fetchArticles() {
        api.getArticles()
            .receive(on: RunLoop.main)
            .sink(
                receiveCompletion: { completion in
                    switch completion {
                    // MEMO: 値取得成功時(※本当は厳密にエラーハンドリングする必要がある)
                    case .finished:
                        print("finished getArticles(): \(completion)")
                    // MEMO: エラー時(※本当は厳密にエラーハンドリングする必要がある)
                    case .failure(let error):
                        print("error getArticles(): \(error.localizedDescription)")
                    }
                },
                receiveValue: { [weak self] hashableObjects in
                    print(hashableObjects)
                    self?._articles = hashableObjects
                }
            )
            .store(in: &cancellables)
    }
}

⭐️5-3. Combineを利用したViewModel → ViewController部分の実装解説

最後に、Combineを利用したViewControllerの実装について紹介していきます。ViewModelのOutput定義におけるreceiveValue:の中にNSDiffableDataSourceの更新処理を組み合わせることによって、API通信処理と連動したセクション毎に定義したセルのデータ反映をする形にしています。

本サンプル(ComplexCollectionViewStyleExample)におけるViewControllerの実装をまとめると下記のような形になります。

MainViewController.swift
final class MainViewController: UIViewController {

    // MARK: - Variables

    private var cancellables: [AnyCancellable] = []

    // MEMO: API経由の非同期通信からデータを取得するためのViewModel
    private let viewModel: MainViewModel = MainViewModel(api: APIRequestManager.shared)

    // MEMO: UICollectionViewを差分更新するためのNSDiffableDataSourceSnapshot
    private var snapshot: NSDiffableDataSourceSnapshot<MainSection, AnyHashable>!

    // MEMO: UICollectionViewを組み立てるためのDataSource
    private var dataSource: UICollectionViewDiffableDataSource<MainSection, AnyHashable>! = nil

    ・・・(省略)・・・

    // MARK: - deinit

    deinit {
        cancellables.forEach { $0.cancel() }
    }

    // MARK: - Override

    override func viewDidLoad() {
        super.viewDidLoad()

        ・・・(省略)・・・
        bindToMainViewModelOutputs()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        // MEMO: ViewModelのInputsを経由したAPIでのデータ取得処理を実行する
        viewModel.inputs.fetchFeaturedBannersTrigger.send()
        viewModel.inputs.fetchFeaturedInterviewsTrigger.send()
        viewModel.inputs.fetchKeywordsTrigger.send()
        viewModel.inputs.fetchNewArrivalsTrigger.send()
        viewModel.inputs.fetchArticlesTrigger.send()
    }

    ・・・(省略)・・・

    private func bindToMainViewModelOutputs() {

        // 1. ViewModelのOutputsを経由した特集バナーデータの取得とNSDiffableDataSourceSnapshotの入れ替え処理
        viewModel.outputs.featuredBanners
            .subscribe(on: RunLoop.main)
            .sink(
                receiveValue: { [weak self] featuredBanners in
                    guard let self = self else { return }
                    self.snapshot.appendItems(featuredBanners, toSection: .FeaturedBanners)
                    self.dataSource.apply(self.snapshot, animatingDifferences: false)
                }
            )
            .store(in: &cancellables)

        // 2. ViewModelのOutputsを経由した特集インタビューデータの取得とNSDiffableDataSourceSnapshotの入れ替え処理
        viewModel.outputs.featuredInterviews
            .subscribe(on: RunLoop.main)
            .sink(
                receiveValue: { [weak self] featuredInterviews in
                    guard let self = self else { return }
                    self.snapshot.appendItems(featuredInterviews, toSection: .FeaturedInterviews)
                    self.dataSource.apply(self.snapshot, animatingDifferences: false)
                }
            )
            .store(in: &cancellables)

        // 3. ViewModelのOutputsを経由したキーワードデータの取得とNSDiffableDataSourceSnapshotの入れ替え処理
        viewModel.outputs.keywords
            .subscribe(on: RunLoop.main)
            .sink(
                receiveValue: { [weak self] keywords in
                    guard let self = self else { return }
                    self.snapshot.appendItems(keywords, toSection: .RecentKeywords)
                    self.dataSource.apply(self.snapshot, animatingDifferences: false)
                }
            )
            .store(in: &cancellables)

        // 4. ViewModelのOutputsを経由した新着データの取得とNSDiffableDataSourceSnapshotの入れ替え処理
        viewModel.outputs.newArrivals
            .subscribe(on: RunLoop.main)
            .sink(
                receiveValue: { [weak self] newArrivals in
                    guard let self = self else { return }
                    self.snapshot.appendItems(newArrivals, toSection: .NewArrivalArticles)
                    self.dataSource.apply(self.snapshot, animatingDifferences: false)
                }
            )
            .store(in: &cancellables)

        // 5. ViewModelのOutputsを経由した記事データの取得とNSDiffableDataSourceSnapshotの入れ替え処理
        viewModel.outputs.articles
            .subscribe(on: RunLoop.main)
            .sink(
                receiveValue: { [weak self] articles in
                    guard let self = self else { return }
                    self.snapshot.appendItems(articles, toSection: .RegularArticles)
                    self.dataSource.apply(self.snapshot, animatingDifferences: false)
                }
            )
            .store(in: &cancellables)
    }
}

⭐️5-4. それぞれの処理における流れの概略図

本サンプル(ComplexCollectionViewStyleExample)で定義しているViewModelにおける内部的な値の関係と引き渡す流れをまとめると下図の様な形になります。

input_output_viewmodel.png

値の中継地点となる変数@Published private var _article: [Article]を定義しておくことで、BehaviorRelayのような役割を担う形にする点など、RxSwiftのオペレータを利用した場合との細かな相違点はありますが、Combineで提供されている機能を活用することで近しい形のデータフローを作成することができるかと思います。

6. UICollectionViewCompositionalLayout + DiffableDataSource + Combineを利用した無限スクロール&WaterFallLayoutを実現した事例紹介

最後に、これまで紹介してきたサンプル(ComplexCollectionViewStyleExample)とは、別のサンプル(DiffableDataSourceExample)での実装事例を簡単ではありますが紹介していきます。

UICollectionViewCompositionalLayoutを利用した「Pinterestの様なWaterFallLayout」と「Scrollが最下部に達した際に次ページが追加されるような実装とRefreshControl部分」をCombineを利用した実装で実現したUI実装サンプルになります。

⭐️6-1. UICollectionViewCompositionalLayoutを利用したWaterFallLayoutの実装部分を組み立てる

Pinterestの様な、写真の縦横比を維持してかつセルの高さを合わせて変更するような処理については、UICollectionViewを利用する場合においても難しい表現の1つてあると思います。この様な表現をする場合でもUICollectionViewCompositionalLayoutを利用すると、比較的見通しが良い形で実装ができるように思います。

water_fall_layout_introduction.png

本サンプル(DiffableDataSourceExample)では、JSONのレスポンス内に予めサムネイル画像における縦横サイズを持っている形になっているので、この値を利用することで配置対象セルのサイズを決定することができます。

【レイアウトのサイズと配置に関する計算部分の抜粋】

MainViewController.swift
// UICollectionViewCompositionalLayoutを利用したレイアウトを組み立てる処理
private func createWaterFallLayoutSection() -> NSCollectionLayoutSection {

    if snapshot.numberOfItems == 0 {
        return applyForNoItemLayoutSection()
    } else {
        return applyForWaterFallLayoutSection()
    }
}

private func applyForNoItemLayoutSection() -> NSCollectionLayoutSection {

    // MEMO: .absoluteや.estimatedを設定する場合で0を入れると下記のようなログが出ます。
    // → Invalid estimated dimension, must be > 0. NOTE: This will be a hard-assert soon, please update your call site.

    // 1. Itemのサイズ設定
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(0.5))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    item.contentInsets = .zero

    // 2. Groupのサイズ設定
    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(0.5))
    let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
    group.contentInsets = .zero

    // 3. Sectionのサイズ設定
    let section = NSCollectionLayoutSection(group: group)
    section.contentInsets = .zero

    return section
}

private func applyForWaterFallLayoutSection() -> NSCollectionLayoutSection {

    // MEMO: 表示するアイテムが存在する場合は各セルの高さの適用とそれに基くUICollectionView全体の高さを計算する

    // Model内で持っているheightの値を適用することでWaterFallLayoutの様な見た目を実現する
    var leadingGroupHeight: CGFloat = 0.0
    var trailingGroupHeight: CGFloat = 0.0
    var leadingGroupItems: [NSCollectionLayoutItem] = []
    var trailingGroupItems: [NSCollectionLayoutItem] = []

    let photos = snapshot.itemIdentifiers(inSection: .WaterFallLayout)
    let totalHeight = photos.reduce(CGFloat(0)) { $0 + $1.height }
    let columnHeight = CGFloat(totalHeight / 2.0)

    var runningHeight = CGFloat(0.0)

    // 1. Itemのサイズ設定
    for index in 0..<snapshot.numberOfItems {

        let photo = photos[index]
        let isLeading = runningHeight < columnHeight
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(photo.height))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        runningHeight += photo.height

        if isLeading {
            leadingGroupItems.append(item)
            leadingGroupHeight += photo.height
        } else {
            trailingGroupItems.append(item)
            trailingGroupHeight += photo.height
        }
    }

    // 2. Groupのサイズ設定
    let leadingGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), heightDimension: .absolute(leadingGroupHeight))
    let leadingGroup = NSCollectionLayoutGroup.vertical(layoutSize: leadingGroupSize, subitems: leadingGroupItems)

    let trailingGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), heightDimension: .absolute(trailingGroupHeight))
    let trailingGroup = NSCollectionLayoutGroup.vertical(layoutSize: trailingGroupSize, subitems: trailingGroupItems)

    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(max(leadingGroupHeight, trailingGroupHeight)))
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [leadingGroup, trailingGroup])

    // 3. Sectionのサイズ設定
    let section = NSCollectionLayoutSection(group: group)

    return section
}

【JSONで取得したレスポンスをマッピングする部分の抜粋】

PhotoList.swift
import Foundation
import UIKit

// MARK: - Struct (PhotoList)

struct PhotoList: Hashable, Decodable {

    private let uuid = UUID()

    let page: Int
    let photos: [Photo]
    let hasNextPage: Bool

    // MARK: - Enum

    private enum Keys: String, CodingKey {
        case page
        case photos
        case hasNextPage = "has_next_page"
    }

    // MARK: - Initializer

    init(from decoder: Decoder) throws {

        // JSONの配列内の要素を取得する
        let container = try decoder.container(keyedBy: Keys.self)

        // JSONの配列内の要素にある値をDecodeして初期化する
        self.page = try container.decode(Int.self, forKey: .page)
        self.photos = try container.decode([Photo].self, forKey: .photos)
        self.hasNextPage = try container.decode(Bool.self, forKey: .hasNextPage)
    }

    // MARK: - Hashable

    // MEMO: Hashableプロトコルに適合させるための処理
    func hash(into hasher: inout Hasher) {
        hasher.combine(uuid)
    }

    static func == (lhs: PhotoList, rhs: PhotoList) -> Bool {
        return lhs.uuid == rhs.uuid
    }
}

// MARK: - Struct (Photo)

struct Photo: Hashable, Decodable {

    let id: Int
    let title: String
    let summary: String
    let image: Image
    let gift: Gift

    private(set) var height: CGFloat = 0.0

    // MARK: - Enum

    private enum Keys: String, CodingKey {
        case id
        case title
        case summary
        case image
        case gift
    }

    // MARK: - Initializer

    init(from decoder: Decoder) throws {

        // JSONの配列内の要素を取得する
        let container = try decoder.container(keyedBy: Keys.self)

        // JSONの配列内の要素にある値をDecodeして初期化する
        self.id = try container.decode(Int.self, forKey: .id)
        self.title = try container.decode(String.self, forKey: .title)
        self.summary = try container.decode(String.self, forKey: .summary)
        self.image = try container.decode(Image.self, forKey: .image)
        self.gift = try container.decode(Gift.self, forKey: .gift)

        // MEMO: 写真のサイズに基づいて算出した縦横比を利用して適用したセルのサイズを算出する
        let screenHalfWidth = UIScreen.main.bounds.width * 0.5
        let ratio = CGFloat(self.image.height) / CGFloat(self.image.width)
        let titleAndSummaryHeight: CGFloat = 90.0

        self.height = screenHalfWidth * ratio + titleAndSummaryHeight
    }

    // MARK: - Hashable

    // MEMO: Hashableプロトコルに適合させるための処理
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }

    static func == (lhs: Photo, rhs: Photo) -> Bool {
        return lhs.id == rhs.id
    }
}

// MARK: - Photo Extension

extension Photo {

    struct Image: Decodable {
        let url: String
        let width: Int
        let height: Int
    }

    struct Gift: Decodable {
        let flag: Bool
        let price: Int?
    }
}

⭐️6-2. 表示ModelデータのHash値が等しいデータがあった場合は新しいものに上書きする

4章でも軽く触れましたが、表示データのModelはHashableに適合している関係で、データそれぞれにHash値を持っていますが、APIのレスポンスで次ページの内容を取得した際に既に表示したデータが更新のタイミング等で含まれてしまった際にHash値の衝突が発生してしまいます。

本サンプル(DiffableDataSourceExample)では、既に表示しているデータ次ページの内容を取得した際に、更新された内容を反映する必要があるので下記に示したコードの様な形でデータのHash値を比較して、新しいデータが存在する場合には既存で表示しているものを置き換えるようにしています。
(ここでは一意となるidをHash値作成時に利用しています。)

UniqueDataArrayBuilder.swift
import Foundation

struct UniqueDataArrayBuilder {

    // MARK: - Static Function

    // モデル内に定義したハッシュ値の同一性を検証して一意な表示用データ配列を作成する
    static func fillDifferenceOfOldAndNewLists<T: Decodable & Hashable>(_ dataType: T.Type, oldDataList: [T], newDataList: [T]) -> [T] {

        // 引数より受け取った新しいデータ配列
        var newDataList = newDataList

        // 返却用の配列
        var dataList: [T] = []

        // 既存の表示データ配列をループさせて同一のものがある場合は新しいデータへ置き換える
        // ここはもっと綺麗に書ける余地がある部分だと思う...
        for oldData in oldDataList {
            var shouldAppendOldData = true
            for (newIndex, newData) in newDataList.enumerated() {

                // 同一データの確認(写真表示用のモデルはHashableとしているのでidの一致で判定できるようにしている部分がポイント)
                if oldData == newData {
                    shouldAppendOldData = false
                    dataList.append(newData)
                    newDataList.remove(at: newIndex)
                    break
                }
            }
            if shouldAppendOldData {
                dataList.append(oldData)
            }
        }

        // 置き換えたものを除外した新しいデータを後ろへ追加する
        for newData in newDataList {
            dataList.append(newData)
        }
        return dataList
    }
}

⭐️6-3. APIからのデータ取得から画面表示までの流れに関する実装とUIScrollViewDelegateと連動したUI表現に関するまとめ

本サンプル(DiffableDataSourceExample)では、APIリクエストからデータを反映させる部分についても基本的には、これまでの解説で触れてきた「Combine + MVVM』の構成で実装をしています。

Scrollが最下部に達した際に次ページが追加されるような実装については、UIScrollViewDelegateを利用してコンテンツ表示位置が最下部まで到達した時をトリガーとして、ViewModel側に定義した次のページ表示用のAPIリクエストを実行している点がポイントになります。

また、RefreshControlを伴う表示データのリセット処理についても、ViewModel側に別途Input用のトリガーを準備しておき、これまで表示していた内容を一度リセットしてから1ページ目のAPIリクエストを実行して実現させています。

【該当部分におけるViewModelでの実装】

PhotoViewModel.swift
import Foundation
import Combine

// MARK: - Protocol

protocol PhotoViewModelInputs {
    var fetchPhotoTrigger: PassthroughSubject<Void, Never> { get }
    var refreshPhotoTrigger: PassthroughSubject<Void, Never> { get }
}

protocol PhotoViewModelOutputs {
    var photos: AnyPublisher<[Photo], Never> { get }
    var apiRequestStatus: AnyPublisher<APIRequestStatus, Never> { get }
}

protocol PhotoViewModelType {
    var inputs: PhotoViewModelInputs { get }
    var outputs: PhotoViewModelOutputs { get }
}

final class PhotoViewModel: PhotoViewModelType, PhotoViewModelInputs, PhotoViewModelOutputs {

    // MARK: - PhotoViewModelType

    var inputs: PhotoViewModelInputs { return self }
    var outputs: PhotoViewModelOutputs { return self }

    // MARK: - PhotoViewModelInputs

    let fetchPhotoTrigger = PassthroughSubject<Void, Never>()
    let refreshPhotoTrigger = PassthroughSubject<Void, Never>()

    // MARK: - MainViewModelOutputs

    var photos: AnyPublisher<[Photo], Never> {
        return $_photos.eraseToAnyPublisher()
    }
    var apiRequestStatus: AnyPublisher<APIRequestStatus, Never> {
        return $_apiRequestStatus.eraseToAnyPublisher()
    }

    private let api: APIRequestManagerProtocol

    private var nextPageNumber: Int = 1
    private var hasNextPage: Bool = true

    private var cancellables: [AnyCancellable] = []

    // MARK: - @Published

    // MEMO: このコードではNSDiffableDataSourceSnapshotの差分更新部分で利用する
    @Published private var _photos: [Photo] = []
    @Published private var _apiRequestStatus: APIRequestStatus = .none

    // MARK: - Initializer

    init(api: APIRequestManagerProtocol) {

        // MEMO: 適用するAPIリクエスト用の処理
        self.api = api

        // MEMO: ページング処理を伴うAPIリクエスト
        // → 実行時はViewController側でviewModel.inputs.fetchPhotoTrigger.send()で実行する
        fetchPhotoTrigger
            .sink(
                receiveValue: { [weak self] in
                    guard let self = self else { return }

                    // MEMO: 次のページが存在しない場合は以降の処理を実施しないようにする
                    guard self.hasNextPage else {
                        return
                    }
                    self.fetchPhotoList()
                }
            )
            .store(in: &cancellables)

        // MEMO: 現在まで取得したデータのリフレッシュ処理を伴うAPIリクエスト
        // → 実行時はViewController側でviewModel.inputs.refreshPhotoTrigger.send()で実行する
        refreshPhotoTrigger
            .sink(
                receiveValue: { [weak self] in
                    guard let self = self else { return }
                    self.nextPageNumber = 1
                    self.hasNextPage = true
                    self._photos = []
                    self.fetchPhotoList()
                }
            )
            .store(in: &cancellables)
    }

    // MARK: - deinit

    deinit {
        cancellables.forEach { $0.cancel() }
    }

    // MARK: - Privete Function

    private func fetchPhotoList() {

        // APIとの通信処理ステータスを「実行中」へ切り替える
        _apiRequestStatus = .requesting

        // APIとの通信処理を実行する
        api.getPhotoList(perPage: nextPageNumber)
            .receive(on: RunLoop.main)
            .sink(
                receiveCompletion: {  [weak self] completion in
                    guard let self = self else { return }

                    switch completion {

                    // MEMO: 値取得に成功した場合のハンドリング
                    case .finished:

                        // MEMO: APIリクエストの処理結果を成功の状態に更新する
                        self._apiRequestStatus = .requestSuccess
                        print("receiveCompletion finished fetchPhotoList(): \(completion)")

                    // MEMO: 値取得に失敗した場合のハンドリング
                    case .failure(let error):

                        // MEMO: APIリクエストの処理結果を失敗の状態に更新する
                        self._apiRequestStatus = .requestFailure
                        print("receiveCompletion error fetchPhotoList(): \(error.localizedDescription)")
                    }
                },
                receiveValue: { [weak self] hashableObjects in
                    guard let self = self else { return }

                    if let photoList = hashableObjects.first {

                        // MEMO: ViewModel内部処理用の変数を更新する
                        self.nextPageNumber = photoList.page + 1
                        self.hasNextPage = photoList.hasNextPage

                        // MEMO: 表示対象データを差分更新する
                        self._photos = UniqueDataArrayBuilder.fillDifferenceOfOldAndNewLists(Photo.self, oldDataList: self._photos, newDataList: photoList.photos)
                        print("receiveValue fetchPhotoList(): \(photoList)")
                    }
                }
            )
            .store(in: &cancellables)
    }
}

【該当部分におけるViewControllerでの実装(抜粋)】

MainViewController.swift
final class MainViewController: UIViewController {

    // MARK: - Variables

    // UICollectionViewに設置するRefreshControl
    private let mainRefrashControl = UIRefreshControl()

    // MEMO: API経由の非同期通信からデータを取得するためのViewModel
    private let viewModel: PhotoViewModel = PhotoViewModel(api: APIRequestManager.shared)

    // MEMO: Cancellableの保持用(※RxSwiftでいうところのDisposeBagの様なイメージ)
    private var cancellables: [AnyCancellable] = []

    // MEMO: UICollectionViewを差分更新するためのNSDiffableDataSourceSnapshot
    private var snapshot: NSDiffableDataSourceSnapshot<PhotoSection, Photo>!

    // MEMO: UICollectionViewを組み立てるためのDataSource
    private var dataSource: UICollectionViewDiffableDataSource<PhotoSection, Photo>! = nil

    ・・・(省略)・・・

    // MARK: - Override

    override func viewDidLoad() {
        super.viewDidLoad()

        ・・・(省略)・・・

        bindToViewModelOutputs()
    }

    // MARK: - Private Function

    // UICollectionViewにおけるPullToRefresh実行時の処理
    @objc private func executeRefresh() {

        // MEMO: ViewModelに定義した表示データのリフレッシュ処理を実行する
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.36) {
            self.viewModel.inputs.refreshPhotoTrigger.send()
        }
    }

    // ViewModelのOutputとこのViewControllerでのUIに関する処理をバインドする
    private func bindToViewModelOutputs() {

        // MEMO: APIへのリクエスト状態に合わせたUI側の表示におけるハンドリングを実行する
        viewModel.outputs.apiRequestStatus
            .subscribe(on: RunLoop.main)
            .sink(
                receiveValue: { [weak self] status in

                    guard let self = self else { return }
                    switch status {
                    case .requesting:
                        self.mainRefrashControl.beginRefreshing()
                    case .requestFailure:
                        // MEMO: 通信失敗時はアラート表示 & RefreshControlの状態変更
                        self.mainRefrashControl.endRefreshing()
                        self.showAlertWith(completionHandler: nil)
                    default:
                        self.mainRefrashControl.endRefreshing()
                    }
                }
            )
            .store(in: &cancellables)

        // MEMO: APIへのリクエスト状態に合わせたUI側の表示におけるハンドリングを実行する
        viewModel.outputs.photos
            .subscribe(on: RunLoop.main)
            .sink(
                receiveValue: { [weak self] photos in

                    guard let self = self else { return }
                    // MEMO: ID(Identifier)が重複する場合における衝突の回避をする
                    let beforePhoto = self.snapshot.itemIdentifiers(inSection: .WaterFallLayout)
                    self.snapshot.deleteItems(beforePhoto)
                    self.snapshot.appendItems(photos, toSection: .WaterFallLayout)
                    self.dataSource.apply(self.snapshot, animatingDifferences: false)
                }
            )
            .store(in: &cancellables)
    }

    ・・・(省略)・・・
}

・・・(省略)・・・

extension MainViewController: UIScrollViewDelegate {

    // MEMO: NSCollectionLayoutSectionのScroll(section.orthogonalScrollingBehavior)ではUIScrollViewDelegateは呼ばれない
    func scrollViewDidScroll(_ scrollView: UIScrollView) {

        // MEMO: UIRefreshControl表示時は以降の処理を行わない(※APIリクエストの状態とRefreshControlの状態を連動させている点がポイント)
        if mainRefrashControl.isRefreshing {
            return
        }

        // MEMO: UIScrollViewが一番下の状態に達した時にAPIリクエストを実行する
        if scrollView.contentOffset.y + scrollView.frame.size.height > scrollView.contentSize.height {
            viewModel.inputs.fetchPhotoTrigger.send()
        }
    }
}

※ 本サンプル(DiffableDataSourceExample)では、UITableViewにおける類似した表現を実装した画面もありますので、是非見ていただけますと幸いです。

7. 今回紹介した実装における参考資料

UICollectionViewCompositionalLayout及びCombineを利用した実装を進めていく際や特徴の理解を進めていく上で僕が参考にした記事を下記にまとめてみました。

本記事で紹介している記事は英語記事であっても、コードを交えた解説がされているものが多いので、比較的読みやすいかと思いますので少しでも参考になれば幸いです。

⭐️7-1. UICollectionViewCompositionalLayoutを利用した今回の実装をする上での参考資料集

参考記事:

参考コード:

⭐️7-2. Combineを利用した今回の実装をする上での参考資料集

参考記事:

参考コード:

8. あとがき

結構長い記事になってしまって恐縮ではありますが、今回紹介したサンプル実装や記事の執筆を通して感じたことを簡単ではありますがまとめてみました。

⭐️8-1. UICollectionViewを利用した複雑な画面でも実装の見通しが立てやすくなった

iOS13から登場したUICollectionViewCompositionalLayout & DiffableDataSourceを活用したサンプルUIの実装に触れてみると、UICollectionViewを活用した画面レイアウトにおける複雑な表現がよりシンプルかつ直感的になったと感じています。

従来のUICollectionViewを活用して複雑なレイアウトを実装する方法では、UICollectionViewLayoutを継承したクラスを利用して、LayoutAttributesを調整する必要がある点に難しさがあるかと思いますが、その実装方法と比べてもコンパクトな形にまとめることができるのは大きなメリットではないかと思います。

※具体的な実装の事例を挙げると 「Pinterestの様なWaterFallLayout」「Instagramの様なMosaicLayout」 を実現する場合には、その良さをより実感できるかもしれません。

また、場合によっては画面要素や構成するViewControllerを分割して実装する方針を取る必要がありそうなレイアウトについても単一のUICollectionViewの中に上手にまとめ上げることができる点も注目すべき大きな魅力の1つであるように思います。

※もちろん、UIの用件や仕様によっては従来通りの方法を採用した方が良い場合もあるので、画面設計の際の選択肢の1つしてケースバイケースで取捨選択していく方針でも今のところは良さそうに思います。

特にUICollectionViewCompositionalLayoutを利用したUI実装をする際には、

  1. NSCollectionLayoutSection / NSCollectionLayoutGroup / NSCollectionLayoutItem を組み合わせて実現するUICollectionViewCompositionalLayout組み立て方
  2. UICollectionViewDiffableDataSource / NSDiffableDataSourceSnapshotを利用したデータ反映ロジックの構築

の2点に注目すると、より理解がしやすくなるのではないかと感じております。

※iOS13以降では、UITableViewについてもUITableViewDiffableDataSource / NSDiffableDataSourceSnapshotを利用して差分更新のロジックを実現することができます。

⭐️8-2. CombineについてもRxSwift等と比較すると動きのイメージが掴みやすくなる

この記事で解説した2つのサンプルではどちらも「Combine + MVVMパターン」での実装をしていますが、元になっているのは「RxSwift + MVVMパターン」での実装を参考にしています。現段階ではRxSwift等では用意されているものの、Combineでは相当するものがないオペレータやコンポーネントもありますが、比較的シンプルな実装をCombineで置き換えていくような場合には、下記で紹介している 「RxSwiftとCombineを比較したチートシート」 等を参考にしながら進めていくと良いかと思います。

⭐️8-3. iOS12以前でも類似した表現を実現する際に役に立つライブラリのご紹介

今回紹介したUI表現や内部ロジックに関する実装については、iOS13以降で利用可能な機能を活用して実現しているものになりますが、iOS12以前のバージョンもサポートする必要がある場合でも似た様な表現や内部ロジックを実現する必要がある場合には、まずは下記に紹介しているライブラリを活用する形にする方針でも良いかと思います。

様々な要素からなる複雑な画面を差分を考慮して構築する際に役立つライブラリ:

iOS12以前でもCollectionViewCompositionalLayoutの様な構造に対応できるライブラリ:

iOS12以前でもUICollectionViewやUITableViewの差分更新を実現するライブラリ:

⭐️8-4. 最後に

今回は「現在携わっている業務の中で頻出なUI実装を参考にして、iOS13以降の新機能を利用した形にすこしずつ置き換えてみる」というテーマで実装をしたサンプルを元にした解説という形にしました。現在は特にRxSwiftを利用したMVVMパターンやUICollectionViewをフル活用した形の実装に触れる機会が多いということもあったので、慣れ親しんだ実装方法をヒントとして比較しながら実装や検証を進めていくことで、具体的な動きのイメージや構築の流れが掴めてくる実感があったように感じています。

僕自身もiOS13以降から登場した新機能については、まだまだキャッチアップや動作検証が行き届いていない部分も多々あると思いますが、少しでも皆様の参考になれば幸いに思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Xcode11 ABI安定化につまずいた

はじめに

みなさん待望の ABI 安定化が Swift5 でついに達成されました:tada:

ついに ABI 安定化がきたか!(ABI 安定化ってなんだ?:speak_no_evil:

ABI 安定化については下記参考

ほーん:thinking:つまり Swift のバージョンが異なるバイナリ同士をリンクできるようになったてことか:exclamation:

Module Stability

異なるバージョンでいけるってことはこの Carthage でライブラリを導入したやつ(Xcode11.0 Swift5.1)は Xcode11.3 Swift5.1.3 でビルドできるってことか:exclamation:

error1

なんやて:interrobang:

結局ターミナルでもう一度下記コマンドしないとむりでした...

carthage update --platform iOS

なんでや?と思っていたらピンポイントの質問を発見:heart_eyes:

そのライブラリのビルド設定において、Module Stabilityが有効になっていないからです。

(中略)

つまり、Swift 5.0ではABI Stabilityによって異なるバージョンのコンパイラが生成したライブラリをリンクできますが、インポートできないので実質的にサードパーティのライブラリに対してはABI Stabilityの恩恵を受けることはできません。
(インポートの段階で失敗するのでリンクの段階まで到達しないため)

そういうことらしい:open_mouth:

どうやらライブラリの互換を達成するには *.swiftinterface ファイルが必要でライブラリ側の設定で BUILD_LIBRARY_FOR_DISTRIBUTION というフラグを YES にする必要があるらしい。

ほーん...まじかそんなんやってねぇ:scream:全部やってねぇ...)

setting

ここを YES にして carthage update したらとりあえず framework の Modules に *.swiftinterface ファイルができました:clap:

ビルド!!!

error2

:poop::poop::poop:

...よくわかりませんが Xcode11.0, 11.1 <-> Xcode11.2.1, 11.3 は無理でした:scream_cat:

結果
Xcode11.0 (Swift5.1) Xcode11.1 (Swift5.1) :o:
Xcode11.0 (Swift5.1) Xcode11.2.1 (Swift5.1.2) :x:
Xcode11.0 (Swift5.1) Xcode11.3 (Swift5.1.3) :x:
Xcode11.1 (Swift5.1) Xcode11.0 (Swift5.1) :o:
Xcode11.1 (Swift5.1) Xcode11.2.1 (Swift5.1.2) :x:
Xcode11.1 (Swift5.1) Xcode11.3 (Swift5.1.3) :x:
Xcode11.2.1 (Swift5.1.2) Xcode11.0 (Swift5.1) :x:
Xcode11.2.1 (Swift5.1.2) Xcode11.1 (Swift5.1) :x:
Xcode11.2.1 (Swift5.1.2) Xcode11.3 (Swift5.1.3) :o:
Xcode11.3 (Swift5.1.3) Xcode11.0 (Swift5.1) :x:
Xcode11.3 (Swift5.1.3) Xcode11.1 (Swift5.1) :x:
Xcode11.3 (Swift5.1.3) Xcode11.2.1 (Swift5.1.2) :o:

でも、Xcode11.2.1(Swift5.1.2) <-> Xcode11.3(Swift5.1.3) はビルドできたので互換性はあるみたいです。

ということでライブラリを作成する際は BUILD_LIBRARY_FOR_DISTRIBUTIONYES に設定しましょう!!

※Swift のバージョンについて
ターミナルで下記コマンドで調べました

swift -version
Xcode Swift
11.0 5.1 (swiftlang-1100.0.270.13 clang-1100.0.33.7)
11.1 5.1 (swiftlang-1100.0.270.13 clang-1100.0.33.7)
11.2.1 5.1.2 (swiftlang-1100.0.278 clang-1100.0.33.9)
11.3 5.1.3 (swiftlang-1100.0.282.1 clang-1100.0.33.15)

さいごに

早くライブラリ全部 BUILD_LIBRARY_FOR_DISTRIBUTIONYES に設定しなきゃ:speak_no_evil:

[iOSDC Japan 2019 リポート]「ライブラリのインポートとリンクの仕組み完全解説」というセッションを聞いてきましたこの記事とスライドもみなきゃ:laughing:(なんかわかった気になります:neutral_face:

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【UICollectionView】セルを生成する際にsizeForItemAtをCGSize.zeroにするとcellForItemAtの挙動が変わる

はじめに

UICollectionViewのセルを生成する際,サイズにCGSize.zeroを指定するとセルのインスタンスを生成するcollectionView(_:cellForItemAt:)の挙動が変わるのでまとめてみました.

検証

検証のために画面中央にCollectionViewを配置した簡単なViewControllerを用います.

スクリーンショット 2019-12-23 0.05.47.png

今回は,

  • CollectionViewCellというUICollectionViewCellのカスタムクラスを使用
  • UICollectionViewDelegateFlowLayoutのcollectionView(_:sizeForItemAt:)でサイズを返却する

という状況を想定します.

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var collectionView: UICollectionView! {
        didSet {
            self.collectionView.dataSource = self
            self.collectionView.delegate = self
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }
}

extension ViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 10
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CollectionViewCell", for: indexPath) as! CollectionViewCell
        cell.label.text = indexPath.debugDescription
        print("cellForItemAt: \(indexPath)")
        return cell
    }
}

extension ViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {

        print("sizeForItemAt: \(indexPath)")
        return CGSize.zero //ここでindexPathに対応するセルのサイズを返却
    }
}

CGSize(width: 70.0, height: 70.0)を返す場合

通常の固定サイズを返却する場合です.collectionView(_:sizeForItemAt:)が常にCGSize(width: 70.0, height: 70.0)を返すようにします.実際に実行すると想定されているように10個のセルがきちんと表示されています.

スクリーンショット 2019-12-22 23.55.51.png

コンソールは出力は以下のようになります.

sizeForItemAt: [0, 0]
sizeForItemAt: [0, 1]
sizeForItemAt: [0, 2]
sizeForItemAt: [0, 3]
sizeForItemAt: [0, 4]
sizeForItemAt: [0, 5]
sizeForItemAt: [0, 6]
sizeForItemAt: [0, 7]
sizeForItemAt: [0, 8]
sizeForItemAt: [0, 9]
cellForItemAt: [0, 0]
cellForItemAt: [0, 1]
cellForItemAt: [0, 2]
cellForItemAt: [0, 3]
cellForItemAt: [0, 4]
cellForItemAt: [0, 5]
cellForItemAt: [0, 6]
cellForItemAt: [0, 7]
cellForItemAt: [0, 8]
cellForItemAt: [0, 9]

この出力を見てわかるように

  1. 全てのindexPathについてsizeForItemAt (サイズ計算)を実行
  2. 全てのindexPathに対してcellForItemAt (インスタンス生成)を実行

の順に処理が行われます.

sizeForItemAt→cellForItemAtがindexPath毎に逐一実行されるというわけではないんですね.

セルサイズの一部がCGSize.zeroを含む場合

次に特定のindexPathに対して,sizeForItemがCGSize.zeroを返却する場合を考えます.collectionView(_:sizeForItemAt:)を以下のように書き換えました.

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {

    print("sizeForItemAt: \(indexPath)")
    if indexPath.item % 2 == 0 {
      return CGSize.zero
    } else {
      return CGSize(width: 70.0, height: 70.0)
    }
}

この場合で実行すると以下のように画面は表示されます.先ほどの条件と比べて,CGSize.zeroを当ててられたセルは表示されず,表示されたセルの配置も先ほどとズレてしまいます.

image-20191223005410730.png

コンソールの出力を確認するとWarningが出力されています.

sizeForItemAt: [0, 0]
sizeForItemAt: [0, 1]
sizeForItemAt: [0, 2]
sizeForItemAt: [0, 3]
sizeForItemAt: [0, 4]
sizeForItemAt: [0, 5]
sizeForItemAt: [0, 6]
sizeForItemAt: [0, 7]
sizeForItemAt: [0, 8]
sizeForItemAt: [0, 9]
cellForItemAt: [0, 0]
XXXX-XX-XX XX:XX:XX CollectionViewTest[62020:545496] [Warning] Warning once only: Detected a case where constraints ambiguously suggest a size of zero for a collection view cell's content view. We're considering the collapse unintentional and using standard size instead. Cell: <CollectionViewTest.CollectionViewCell: 0x7fa5edd18890; baseClass = UICollectionViewCell; frame = (0 35; 0 0); clipsToBounds = YES; opaque = NO; layer = <CALayer: 0x600003c01460>>
cellForItemAt: [0, 1]
cellForItemAt: [0, 2]
cellForItemAt: [0, 3]
cellForItemAt: [0, 4]
cellForItemAt: [0, 5]
cellForItemAt: [0, 6]
cellForItemAt: [0, 7]
cellForItemAt: [0, 8]
cellForItemAt: [0, 9]

これはCGSize.zeroをCollectionViewCellに対して割り当てたので,意図しない表示崩れを防ぐためにframe = (0 35; 0 0)を当てたというものです.

この処理が入ったため,[0,1]のセルを生成する際,画面上には[0,0]のセルが表示されていないものの,実在しているとみなされ,セルの間のスペースが取られた状態で描画処理が行われているものと考えられます.(要検証)

いずれにせよ,sizeForItemAt→cellForItemAtのライフサイクルは通常の場合と変わりません.

セルサイズが全てCGSize.zeroの場合

最後にcollectionView(_:sizeForItemAt:)が常にCGSize.zeroを返す場合です.全てのセルが表示されていません.

スクリーンショット 2019-12-23 0.05.47.png

セルサイズが全てCGSize.zeroの場合,上記の2つとは異なり,cellForItemAtが実行されません.

sizeForItemAt: [0, 0]
sizeForItemAt: [0, 1]
sizeForItemAt: [0, 2]
sizeForItemAt: [0, 3]
sizeForItemAt: [0, 4]
sizeForItemAt: [0, 5]
sizeForItemAt: [0, 6]
sizeForItemAt: [0, 7]
sizeForItemAt: [0, 8]
sizeForItemAt: [0, 9]

すなわち,CollectionViewが含むセルのサイズが全てCGSize.zeroだった場合はセルのインスタンスの生成を行わないということになります.同じcollectionView(_:sizeForItemAt:)がCGSize.zeroを返す場合であっても,インスタンス自体は存在するが描画はされない「セルサイズの一部がCGSize.zeroを含む場合」とは挙動が異なります.

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む