post Image
ジェスチャーやカスタムトランジションを利用して入力時やコンテンツ表示時に一工夫を加えたUIの実装ポイントまとめ

Outline

1. はじめに

新年あけましておめでとうございます。本年も何卒宜しくお願いいたいします。

年末年始に珍しくまとまった時間が取れたということもありまして、個人的にも復習しておきたいと感じていた、カスタムトランジションやGestureRecognizerを活用した部分の実装を、1つのなるべくアプリに近しい形のサンプルとしてまとめて見たいと思い取り組んでいました。

今回作成したサンプルに関しては、表示画像のタップ時やUICollectionViewの選択時等の「処理のトリガーとなる部分でGestureRecognizerを活用」「一覧表示→詳細遷移のタイミングでのカスタムトランジション」を織り交ぜて、入力画面や表示タイミングのポイントになる部分のUIに一工夫を加えてみた形にしてみました。

サンプルの全体的な動き:

gesture_and_transition.gif

  • 2017.02.18: 追記

この記事内容での実装ポイントをピックアップした内容をこちらの勉強会にて発表してきましたので、下記の資料もご参考なれば幸いです。

スライド:指の動きや遷移時等のアニメーションを生かしたUIのサンプル解説
勉強会:Swiftビギナーズ勉強会 第21回 at ファンコミュニケーションズ様(渋谷)

  • 2017.01.09: 追記

本サンプルにクレジット表記及びそのロジックの追加と日にち・レシピ選択画面の文言を変更しました。
(文言の表記が本記事のキャプチャ画面とは若干異なりますが、処理メイン部分のロジックはほとんど変更していません)

※こちらのサンプルはPullRequestやIssue等はお気軽にどうぞ!

2. 今回の参考にした実装例とサンプル概要について

今回の実装に関しては、参考にしたアプリは特にありませんが、下記に紹介するサンプル及び解説記事を参考にした上で、レシピのサムネイル画像と現在月の日にちを選択してアーカイブ&レシピの一覧&詳細表示機能を追加して自分なりにまとめてみました。

サンプルのタイトルが「直感レシピ」となっているのは画像の印象で作って見たいレシピを複数選択して、アーカイブしたレシピを後で見ることができるようにしたかったので、このような形にしました。

(あと私が料理が趣味なので、なかなか何を作るか決めきれない時に画像から直感でレシピを選択してみたら面白いかもという思いもありました笑)

★2-1. メイン機能作成において参考にした記事

レシピ画像の一覧から画像をドラッグ&ドロップで選択する機能やアーカイブしたレシピ画像の表示部分の遷移に関する部分については下記のサンプルや記事を参考に作成しました。

UICollectionViewDragAndDropSelectedItemsに関しては元はObjective-Cのサンプルなので既存コードを元にSwift3系に書き直し&機能追加を、iOS Animation Tutorial: Custom View Controller Presentation Transitionsに関しては既存のコードがSwift2系のものだったのでSwift3系に書き直した上でカスタムトランジション部分のクラスについてはリファクタリングをしました。

また上記のサンプルや記事を元に追加した主な機能としては、

  • 現在月の表示を元に祝祭日と曜日付きの日付のボタン表示機能
  • SwiftyJSON & Alamofireを利用したAPI通信機能(楽天レシピカテゴリ別ランキングAPI使用)
  • APIから取得した画像表示時キャッシュ機能
  • 選択したデータのアーカイブ機能
  • ContainerViewを重ねてスライド式メニューを表示する機能

となり、データ取得部分やデータ永続化に関するではライブラリも活用した実装をしています。

上記のサンプル及び記事に関しては、実装のポイントがつかみやすい形でソースの記述されていたりやポイントとなる部分が解説されていましたので、非常に参考になりました。この場をお借りして感謝の意を述べたいと思います。

★2-2. 今回のサンプルについて

サンプルのキャプチャ画像1:

sample_capture1.jpg

サンプルのキャプチャ画像2:

sample_capture2.jpg

サンプルのキャプチャ画像3:

sample_capture3.jpg

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

  • Xcode8.2
  • Swift3.0.2
  • MacOS Sierra (Ver10.12.2)

使用外部APIについて:

今回のレシピデータに関しては、楽天レシピカテゴリ別ランキングAPIを利用しています。

api_reference.jpg

本サンプルでのレシピ選択部分で使用していますが、このAPIを利用した際に取得できるレシピーデータは最大4件までになります。

今回の使用方法としては、あらかじめにカテゴリーのパラメータ文字列の一覧を定義したStructファイルを用意しておき、APIアクセスの際のパラメータ(今回の場合は「カテゴリID(パラメータ名:categoryId)」)にカテゴリを定義した部分からランダムでカテゴリID取得してセットするような形にしています。

また、APIへのアクセス部分の実装に関しては下記のファイルを参照頂ければと思います。

  • CategoryList.swift (カテゴリ一覧定義 & ランダムで1件取得するメソッド)
  • APIManager.swift (楽天レシピカテゴリ別ランキングAPIへのデータ取得用)
  • MakeRecipeController.swift (loadApiData(categoryId: String)内に処理記載)

※ サンプル内でAPIキーの部分に関しては除外していますので、お手元でご確認の場合にはAPIキーの取得を行っていただけますと幸いです。

また楽天Webサービスのページでは下記のようにAPIのテストフォームも用意されているので、取得したいデータに関するパラメータのテストも行いやすく、APIの種類も沢山あるので活用してみると面白いかもしれませんね。

api_test_form.jpg

使用ライブラリ:

前述した楽天レシピカテゴリ別ランキングAPIから取得したデータをUICollectionViewのセル内に表示する際の処理にはAlamofireとSwiftyJSONを、画像のキャッシュさせて読み込みの高速化を図るためのライブラリに関しては今回はSDwebImageではなくKingFisher(SDWebImageのSwift版)を使用し、またレシピのアーカイブデータの保存に関してはRealmを使用しました。

おまけとして月別のカレンダー部分の祝祭日表示に関しては、自分が開発・保守を行っている日本の祝祭日判定ロジックライブラリのCalculateCalendarLogicを実運用での検証も兼ねて使用しています。

ライブラリ名 ライブラリの機能概要
RealmSwift アプリ内のデータベース
SwiftyJSON JSONデータの解析をしやすくする
Alamofire HTTPないしはHTTPSのネットワーク通信用
KingFisher 画像URLからの非同期での画像表示とキャッシュサポート
CalculateCalendarLogic 日本の祝祭日の判定

Podfile内の設定は下記のようになります。
(CalculateCalendarLogicに関しては、開発時点でPod化していなかったので本サンプルでは手動で導入しています。)

platform :ios, '9.0'
swift_version = '3.0'

target 'DraggableImageForm' do
  use_frameworks!
  pod 'RealmSwift'
  pod 'Alamofire'
  pod 'SwiftyJSON'
  pod 'Kingfisher'

  post_install do |installer|
    installer.pods_project.targets.each do |target|
      target.build_configurations.each do |config|
        config.build_settings['SWIFT_VERSION'] = '3.0'
      end
    end
  end
end

3. UILongPressGestureRecogniserとUICollectionViewで画像のドラッグ&ドロップを行う部分の実装ポイント

本サンプルの画面の設計の全体図(Storyboardの概略図)は下記のような構成になっています。

一番最初に表示されるコントローラーに関してはViewController.swiftの画面になりますが、この画面に関してはサブメニュー用のContainerViewメインコンテンツ表示用のContainerViewの制御用の部分になるので、実質的にはレシピアーカイブを作成するViewController(MakeRecipeController.swift)の画面が表示されています。

本サンプルのStoryboard概略図:

storyboard.jpg

アプリを起動した際にいちばん最初に表示されるレシピアーカイブを作成するための画面になりますが、UIの操作概要をまとめると下記のような操作になります。

  1. UIScrollViewに配置したボタンから対象の日付を日にちのボタンをタップして選択する
  2. 「Add」ボタンを押すとUICollectionViewにAPIから取得したデータ4件が表示されます。また続けてタップボタンを押すとさらに別カテゴリのデータが4件表示されます(重複もありえる)
  3. UICollectionViewの任意のセルを長押しすると選択した画像が指で動かせるようになるので、ヒットエリア(実体はUIButton)までドラッグして指を離すとそのレシピが選択状態になり、ヒットエリア以外で指を離すと選択状態にならずに画像表示が元に戻る
  4. 日にちとレシピデータが選択されている状態でヒットエリアを押下すると、ポップアップが表示されレシピのアーカイブデータ登録フォームが表示される
  5. 選択したデータを全て解除及びレシピ画像を一旦クリアする場合には「Reset」ボタンを押下すると選択データと表示レシピデータを削除する

上記はサンプルが想定している動きをまとめたものになりますが、この説明の中でポイントとなる部分を図解で示したものを下記に掲載します。

MakeRecipeController.swiftでの各ボタンの役割:

この画面の一番下にあるボタンは画像ドラッグ時のヒットエリアの役割と同時に、正常データ時の登録処理用のポップアップ表示を行うという2つの役割を持っています。

button_settings_area.jpg

レシピ表示のUICollectionViewのセルを長押しした場合の動き:

セルを長押しするとUILongTapGestureRecognizerが発動するような実装を行いますが、その中でドラッグ対象の画像をセットしたUIImageViewの変数を用意しておき、UIImageViewの変数を動かすような形で実現させています。

activate_longtap_gesture.jpg

任意のレシピを選択した場合の表示:

任意のセルから画像データを取得し、ヒットエリアにぶつかったタイミングで指を離すと登録がされる形になっていますが、その際にこのレシピが選ばれたということがわかるように、該当データをセル表示から消すようにしています。

drag_and_dataadd.jpg

こちらは参考資料になりますが、本サンプルを作成するにあたっての自分で起こしたラフスケッチ(実装前に主な機能の組み合わせや実装方針をまとめたノートの一部)も載せています。

余談にはなりますが、複数の機能を組み合わせたUIを作成する場合には、いきなり実装を試みると設計の落とし所がブレて取っ散らかることが私は多かったので習慣として実装前には概要やパーツの重なりに関する部分をまとめるようにしています。

機能作成時のラフスケッチ:

draggable_rough.jpg

機能を無理やり詰め込んでしまった形にはなっているかもしれませんが、Webのフォームでもよくあるような、選択エリアに画像をドラッグ&ドロップしてファイルをアップロードする機構をイメージしてみましたので、今回のような動きについては、項目選択のユーザーアクションの際に一工夫を加える際のアイデアとしても良いかなと思います。

★3-1. APIからのデータ取得部分とそれぞれのボタンに関する説明

本サンプルは「楽天レシピカテゴリ別ランキングAPI」を利用してデータの取得を行なっていますが、このAPIではあらかじめ定義されている大カテゴリーの上位4件のレシピデータを取得するような形になるので、追加ボタン押下時に新たに取得したデータを表示用データの格納している配列に追加していくような形をとっています。

API経由でのデータ取得処理についても、全体ロジックをコントローラーの中に押し込めるのではなく、下記の図のようにそれぞれの役割に応じてStructに分解した上で必要なタイミングで使用するような形をとっています。

(この形式にした場合は使用するAPIの各エンドポイントに合わせての調整もしやすくなると思います)

api_handling.jpg

UICollectionViewにデータを読み込むないしは、データのリセットを行う各ボタンのaddTargetの定義は下記のようにしています。初期化の処理をfileprivateのメソッドに切り出して、viewDidLoadの実行タイミングで実行させるような構成となっています。

MakeRecipeController.swift
//UI表示の初期化を行う(ViewDidLoad内でこのメソッドを実行)
fileprivate func initDefaultUiSetting() {

    ・・・(省略)・・・

    //ボタンに関するターゲットの設定を行う
    reloadDataButton.addTarget(self, action: #selector(MakeRecipeController.reloadButtonTapped(button:)), for: .touchUpInside)
    reloadDataButton.layer.cornerRadius = CGFloat(reloadDataButton.frame.width / 2)

    resetDataButton.addTarget(self, action: #selector(MakeRecipeController.resetButtonTapped(button:)), for: .touchUpInside)
    resetDataButton.layer.cornerRadius = CGFloat(resetDataButton.frame.width / 2)
}

//Addボタンを押した時のアクション
func reloadButtonTapped(button: UIButton) {
    loadApiData(categoryId: CategoryList.fetchTargetCategory())
}

//Resetボタンを押した時のアクション
func resetButtonTapped(button: UIButton) {
    initTargetMessageSetting()
}

上記の図解でもあるように「楽天レシピカテゴリ別ランキングAPI」からレシピデータを取得する部分の実際の処理部分は、loadApiData(categoryId: CategoryList.fetchTargetCategory())メソッドの部分になります。

(今回はAPIにアクセスするエンドポイントが1つしかないので、ランダムに1件選択した大カテゴリーを引数に取るメソッドにまとめた形にしました。)

APIの仕様や通信中の画面状態のハンドリングも考慮したデータの取得処理部分のメソッド内の全体処理は下記のようになります。

MakeRecipeController.swift
//Alamofireでの楽天レシピAPIからランキング上位のレシピ情報を選択カテゴリーを元に取得する
fileprivate func loadApiData(categoryId: String) {

    //通信中はCollectionViewの操作をロックする
    receiptCollectionView.isUserInteractionEnabled = false
    receiptCollectionView.alpha = 0.35

    //楽天APIへのアクセスを行う
    let parameterList = ["format" : "json", "applicationId" : CommonSetting.apiKey, "categoryId" : categoryId]
    let api = ApiManager(path: "/Recipe/CategoryRanking/20121121", method: .get, parameters: parameterList)
    api.request(success: { (data: Dictionary) in

        //取得結果のデータにSwiftyJSONを適用する
        let jsonList = JSON(data)
        let results = jsonList["result"]

        //取得した結果を表示用の配列に格納する
        for (_, result) in results {

            //レシピの公開日をyyyy:MM:ddの形式にする
            let recipePublishday = String(describing: result["recipePublishday"])
            let published = recipePublishday.substring(to: recipePublishday.index(recipePublishday.startIndex, offsetBy: 10))

            //CollectionViewへ表示するためのデータをまとめたタプル
            let targetData = (
                String(describing: result["recipeId"]),
                String(describing: result["recipeIndication"]),
                published,
                String(describing: result["recipeTitle"]),
                String(describing: result["foodImageUrl"]),
                String(describing: result["recipeUrl"])
                ) as (id: String, indication: String, published: String, title: String, image: String, url: String)

            //表示用のデータを追加する
            self.apiDataList.append(targetData)
        }

        //CollectionViewをリロードする
        self.receiptCollectionView.isUserInteractionEnabled = true
        self.receiptCollectionView.alpha = 1
        self.receiptCollectionView.reloadData()

    }, fail: { (error: Error?) in

        //エラーハンドリングを行う(AlertControllerを表示)
        let errorAlert = UIAlertController(
            title: "通信状態エラー",
            message: "データの取得に失敗しました。通信状態の良い場所ないしはお持ちのWiftに接続した状態で再度更新ボタンを押してお試し下さい。",
            preferredStyle: UIAlertControllerStyle.alert
        )
        errorAlert.addAction(
            UIAlertAction(
                title: "OK",
                style: UIAlertActionStyle.default,
                handler: nil
            )
        )
        self.receiptCollectionView.isUserInteractionEnabled = true
        self.receiptCollectionView.alpha = 1
        self.present(errorAlert, animated: true, completion: nil)
    })
}

上記のロジックをざっくりと解説すると、APIManagaer.swift部分では通信時の成功時・失敗時の基本的なハンドリング処理とAPIへのアクセスに関する初期設定や変数に関する定義全般を記述しています。

そして表示対象のコントローラー内ではAPIManagaer.swiftのインスタンスを作成した上で、APIのエンドポイント・リクエストメソッド・各種パラメータを設定した上でアクセスを行い、

  • 成功時: → レスポンスデータを整形し表示用の配列にデータを追加する
  • 失敗時: → ポップアップで通信失敗のアラートを表示

という形の処理と表示用のUICollectionViewの見た目に関する調整を行うようにまとめています。

APIManagaer.swift部分の作成にあたりましては下記の記事を参考に作成しました。

★3-2. UICollectionViewCellにUILongPressGestureRecognizerを付与する部分

選択するレシピを表示する部分に関しては、UICollectionViewCellに表示するデータを設定する際の処理のタイミングでセル全体にUILongPressGestureRecognizerを付与するような形にします。

また、UILongPressGestureRecognizerが発動した際に実行されるメソッドにて、どのセルが対象なのかを判定するために、セルに識別用のプロパティ(tag)にインデックスの値を指定する形にします。

MakeRecipeController.swift
//セルに表示する値を設定する
internal func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {

    //セルの定義を行う
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "RecipeCell", for: indexPath) as! RecipeCell

    //セルへ受け渡された値を設定する
    let targetData = apiDataList[indexPath.row]
    cell.recipeNameLabel.text = targetData.title
    cell.recipeDateLabel.text = targetData.published
    cell.recipeCategoryLabel.text = targetData.indication

    //Kingfisherのキャッシュを活用した画像データの設定
    let url = URL(string: targetData.image)
    cell.recipeImageView.image = nil
    cell.recipeImageView.kf.indicatorType = .activity
    cell.recipeImageView.kf.setImage(with: url)

    //cellのタグを決定する(LongPressGestureRecognizerからの逆引き用に設定)
    cell.tag = indexPath.row

    //LongPressGestureRecognizerの定義を行う
    let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(MakeRecipeController.longPressCell(sender:)))

    //イベント発生までのタップ時間:0.24秒
    longPressGesture.minimumPressDuration = 0.24

    //指のズレを許容する範囲:10px
    longPressGesture.allowableMovement = 10.0

    //セルに対してLongPressGestureRecognizerを付与する
    cell.addGestureRecognizer(longPressGesture)

    return cell
}

UILongPressGestureRecognizerに関する処理や活用及び、基本的な事項の確認に関しては下記の記事が参考になりました。

★3-3. UILongPressGestureRecognizerで実行されるメソッド内部の処理に関する部分

上記のUICollectionViewCellに付与したUILongPressGestureRecognizerが発動した際に実行されるlongPressCell(sender: UILongPressGestureRecognizer)メソッドの内部処理に関する解説になります。

1.セルに設定したタグ値を元に選択された該当のセルを判定する

この部分に関しては、longPressCell(sender: UILongPressGestureRecognizer)の引数senderから該当のタグ値(Int型)と長押しされた位置を取得し、タグ値を元にして該当のセルを調べて該当するセルとそのセル内にある画像データを取得するためのロジックとなります。

また上記のロジックで取得した取得した画像データと長押しされた位置から割り出した中心位置を、変数var draggableImageView: UIImageView!(ドラッグ移動が可能なUIImageView)に設定して画面の一番上に表示させることによって、UICollectionViewCellの中からサムネイル画像が抜き出て、かつ指の動きに合わせてサムネイル画像を動かすことができるようにしている形となります。

MakeRecipeController.swift
//セルを長押しした際(UILongPressGestureRecognizerで実行された際)に発動する処理
func longPressCell(sender: UILongPressGestureRecognizer) {

    //長押ししたセルのタグ名と現在位置を設定する
    let targetTag: Int = (sender.view?.tag)!
    let pressPoint: CGPoint = sender.location(ofTouch: 0, in: self.view)

    //タグの値(=indexPath.row)の値を元にデータを抽出する
    let selectedData: (id: String, indication: String, published: String, title: String, image: String, url: String) = apiDataList[targetTag]

    //対象の画像サイズと中心位置を算出する
    let targetWidth = RecipeCell.cellOfSize().width
    let targetHeight = RecipeCell.cellOfSize().height
    let centerX = pressPoint.x - (targetWidth / 2)
    let centerY = pressPoint.y - (targetHeight / 2)

    //長押し対象のセルに配置されていたものを格納するための変数
    var targetImage: UIImage? = nil
    var targetCell: RecipeCell? = nil

    //CollectionView内の要素で該当のセルのものを抽出する
    for targetView in receiptCollectionView.subviews {
        if targetView is RecipeCell {
            let cc: RecipeCell = targetView as! RecipeCell
            if cc.tag == targetTag {

                //該当のセルとその中に配置されているUIImageを抽出する
                targetCell = cc
                targetImage = targetCell?.recipeImageView.image
                break
            }
        }
    }

    ・・・(省略)・・・    
}

2.それぞれの状態ごとの処理内容を記載する

以降はsenderのstateプロパティの状態を元にそれぞれの処理を記載していく形になります。
それぞれの状態時に関する処理内容の概要は、

  • UIGestureRecognizerState.began(開始時):指の動きに合わせて動くUIImageViewの変数「draggableImageView」のサムネイル画像表示や初期位置の決定・該当のUICollectionViewのセルに配置したサムネイル画像を隠す処理
  • UIGestureRecognizerState.changed(指を動かしている時):指の動きに合わせて「draggableImageView」の位置を変更し、ヒットエリアとなる下部に配置したボタンと重なっているかを判定する処理
  • UIGestureRecognizerState.ended(指を離した時):指の離した際の位置がヒットエリアに重なっている場合にのみ、そのサムネイル画像のレシピデータを選択した状態にして、「draggableImageView」の中を掃除する処理

のようになります。

MakeRecipeController.swift
//セルを長押しした際(UILongPressGestureRecognizerで実行された際)に発動する処理
func longPressCell(sender: UILongPressGestureRecognizer) {

    ・・・(省略)・・・ 

    //UILongPressGestureRecognizerが開始された際の処理
    if sender.state == UIGestureRecognizerState.began {

        //ドラッグ可能なImageViewを作成する
        draggableImageView = UIImageView()
        draggableImageView.contentMode = .scaleAspectFill
        draggableImageView.clipsToBounds = true
        draggableImageView.frame = CGRect(x: centerX, y: centerY, width: targetWidth, height:targetHeight)

        //対象のサムネイル画像を取得する処理に置き換える
        draggableImageView.image = targetImage
        self.view.addSubview(draggableImageView)

        //セル内のサムネイル画像を表示させないようにする
        targetCell?.recipeImageView.isHidden = true

    //UILongPressGestureRecognizerが動作中の際の処理
    } else if sender.state == UIGestureRecognizerState.changed {

        //動いた分の距離を加算する
        draggableImageView.frame = CGRect(x: centerX, y: centerY, width: targetWidth, height:targetHeight)

        //ドラッグ可能なImageViewとぶつかる範囲の設定
        let minX = dragAreaButton.frame.origin.x - targetWidth
        let maxX = dragAreaButton.frame.origin.x + dragAreaButton.frame.size.width
        let minY = dragAreaButton.frame.origin.y - targetHeight
        let maxY = dragAreaButton.frame.origin.y + dragAreaButton.frame.size.height

        if ((minX <= centerX && centerX <= maxX) && (minY <= centerY && centerY <= maxY)) {

            //ぶつかる範囲内にドラッグ可能なImageViewがある場合
            dragAreaButton.backgroundColor = ColorConverter.colorWithHexString(hex: WebColorLists.lightOrangeCode.rawValue)

            isSelectedFlag = true

        } else {

            //ぶつかる範囲内にドラッグ可能なImageViewがない場合
            dragAreaButton.backgroundColor = UIColor.lightGray
            isSelectedFlag = false
        }

    //UILongPressGestureRecognizerが終了した際の処理
    } else if sender.state == UIGestureRecognizerState.ended {

        //ドラッグ可能なImageViewを削除する
        targetImage = nil
        draggableImageView.image = nil
        draggableImageView.removeFromSuperview()

        //対象のcellにあるImageViewを表示
        targetCell?.recipeImageView.isHidden = false

        //ぶつかる範囲の基準となるボタンの色を戻す
        dragAreaButton.backgroundColor = UIColor.lightGray

        if isSelectedFlag {

            //データ追加の際には一時的にcollectionViewを選択不可にしておく
            receiptCollectionView.isPrefetchingEnabled = false

            //登録できる最大数以下の場合は選択時の処理を行う
            if selectedDataList.count < RecipeSetting.recipeMaxCount {

                //選択済みデータに追加する処理を作成する
                selectedDataList.append(selectedData)

                //CollectionView内のデータを1件削除して更新する処理を作成する
                apiDataList.remove(at: targetTag)

                //レシピの現在選択数を表示する
                selectedRecipeCountLabel.text = MessageSetting.getCountRecipeMessage(count: selectedDataList.count)

                //collectionViewをリロードする
                receiptCollectionView.reloadData()
            }
        }

        //選択状態フラグをリセットする
        isSelectedFlag = false
        receiptCollectionView.isPrefetchingEnabled = true
    }
}

今回の実装に関してはサムネイル画像をドラッグ&ドロップ動作を利用して登録したいレシピデータを追加していくような形のUIにしてみました。

ユーザーの直感的な動作を活用した選択や入力を行うような表現をUIのデザインと合わせて加えてみると、少しゲームのような要素や感覚が加わることもあるので結構面白い表現ができたりするかもしれないと感じました。

★3-4. カレンダー部分の実装に関する部分

カレンダー部分の表示に関しては、今回は現在月のものだけを取得するのでボタンの一覧を取得する下記のようなStructファイルを定義し、祝祭日の判定や土日のボタン色や曜日が決定した状態のUIButtonの配列を返すような処理を記載します。
※ 祝祭日の判定ロジックライブラリの使用方法に関してはREADMEをご参考にして実装してみて下さい。

CalendarView.swift
import UIKit

//カレンダー配置用ボタンを作成する構造体
struct CalendarView {

    //現在日付のカレンダー一覧を取得する
    static func getCalendarOfCurrentButtonList() -> [UIButton] {

        //年・月・最後の日を取得
        let values: (year: Int, month: Int, max: Int) = getCalendarOfCurrentValues()
        let year  = values.year
        let month = values.month
        let max   = values.max

        //ボタンの一覧を入れるための配列
        var buttonArray: [UIButton] = []

        //祝祭日判定用のインスタンス
        let holiday = CalculateCalendarLogic()

        for i in 1...max {

            //カレンダー選択用ボタンを作成する
            let button: UIButton = UIButton()

            //祝祭日の判定を行う
            let holidayFlag = holiday.judgeJapaneseHoliday(year: year, month: month, day: i)

            //曜日の数値を取得する(0:日曜日 ... 6:土曜日)
            let weekday = Weekday.init(year: year, month: month, day: i)
            let weekdayValue = weekday?.rawValue
            let weekdayString = weekday?.englishName

            //タグと日付の設定を行う
            button.setTitle(weekdayString! + "\n" + String(i), for: UIControlState())
            button.titleLabel!.font = UIFont(name: "Arial", size: 12)!
            button.titleLabel!.numberOfLines = 2
            button.titleLabel!.textAlignment = .center
            button.tag = i

            //日曜日or祝祭日の場合の色設定
            if weekdayValue! % 7 == 0 || holidayFlag == true {

                button.backgroundColor = UIColor(red: CGFloat(0.831), green: CGFloat(0.349), blue: CGFloat(0.224), alpha: CGFloat(1.0))

            //土曜日の場合の色設定
            } else if weekdayValue! % 7 == 6 {

                button.backgroundColor = UIColor(red: CGFloat(0.400), green: CGFloat(0.471), blue: CGFloat(0.980), alpha: CGFloat(1.0))

            //平日の場合の色設定
            } else {

                button.backgroundColor = UIColor.lightGray
            }

            //設定したボタンの一覧を配列に入れる
            buttonArray.append(button)
        }

        return buttonArray
    }
    ・・・(省略)・・・
}

現在月に対応する日付・曜日・祝祭日の定義したボタン一覧の配列を作成するロジックができたら、override func viewDidLayoutSubviews()内で定義したカレンダー表示用のUIScrollViewの中のサイズや要素配置のロジックの中で実行するようにします。

MakeRecipeController.swift
//レイアウト処理が完了した際のライフサイクル
override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()

    //UIScrollViewへのボタン配置を行う ※AutoLayoutのConstraintを用いたアニメーションの際には動的に配置する見た目要素は一度だけ実行する
    if layoutOnceFlag == false {

        //コンテンツ用のScrollViewを初期化
        initScrollViewDefinition()

        //カレンダーボタンのリストを取得する
        let targetButtonList: [UIButton] = CalendarView.getCalendarOfCurrentButtonList()

        //スクロールビュー内のサイズを決定する(AutoLayoutで配置を行った場合でもこの部分はコードで設定しないといけない)
        calendarScrollView.contentSize = CGSize(width: CGFloat(CalenderSetting.areaRect * targetButtonList.count), height: calendarScrollView.frame.height)

        //カレンダーのスクロールビュー内にボタンを配置する
        for i in 0...(targetButtonList.count - 1) {

            //メニュー用のスクロールビューにボタンを配置
            calendarScrollView.addSubview(targetButtonList[i])

            //サイズの決定
            targetButtonList[i].frame.size = CGSize(width: CalenderSetting.buttonRect, height: CalenderSetting.buttonRect)

            //中心位置の決定
            targetButtonList[i].center = CGPoint(x: CalenderSetting.centerPos + CalenderSetting.areaRect * i, y: CalenderSetting.centerPos)

            //装飾とターゲットの決定
            targetButtonList[i].layer.cornerRadius = CGFloat(CalenderSetting.buttonRadius)
            targetButtonList[i].addTarget(self, action: #selector(MakeRecipeController.calendarButtonTapped(button:)), for: .touchUpInside)
        }

        //一度だけ実行するフラグを有効化
        layoutOnceFlag = true
    }
}

今回のサンプルでは使用していませんが、override func viewDidLayoutSubviews()での位置定義とAutoLayoutの制約値を変更するアニメーション(layoutIfNeeded()を使用する)が混在するような場合には、パーツの初期配置等の1度だけ実行されれば良い処理に関する考慮すると、AutoLayoutでの配置とviewDidLayoutSubviews内のコードを併用する場合には注意しておくと良いかもしれません。

★3-5. その他課題に関して

本サンプルに関してはKingFisherでAPIから取得した画像URLを使って下記のように画像データを取得しています。

MakeRecipeController.swift
//セルに表示する値を設定する
internal func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {

    ・・・(省略)・・・    

    //Kingfisherのキャッシュを活用した画像データの設定
    let url = URL(string: targetData.image)
    cell.recipeImageView.image = nil
    cell.recipeImageView.kf.indicatorType = .activity
    cell.recipeImageView.kf.setImage(with: url)

    ・・・(省略)・・・
}

UICollectionViewの表示に関しては正しく実行されていますが、UILongPressGestureRecognizerが発動された際のメソッド処理で、画像ドラッグ処理が始まったタイミングで、選ばれたセルではないデータがドラッグ&ドロップ用のUIImageViewに表示されてしまう場合がごく稀にありましたので、念のため共有致します。

もちろん原因や解決方法がつかめた段階でこちらの記事にも追記を行いますm(_ _)m

4. レシピデータ登録時のポップアップ表示まわりとキーボードの高さ分の表示の考慮部分の実装ポイント

本サンプルの設計では、レシピのアーカイブデータを登録する際にポップアップ表示での登録フォームを用意するような形をとっていますが、その中でも今回はキーボードの高さを考慮したポップアップ位置の調整に関する処理部分とRealmを使用したデータの登録処理に関する部分を中心に実装ポイントをまとめました。

★4-1. NotificationCenterを利用してキーボード表示時にキーボードの高さだけポップアップをずらす部分

本サンプルでのポップアップ部分ではメモを入力するUITextFieldと登録ボタンがポップアップの下にあるため、高さが小さな端末の場合にはキーボードでこの部分が隠れてしまうので、キーボードを表示した際にポップアップの場所をキーボードの高さだけ上にずらしてあげるようにします。

keyboard_observer_height.jpg

この処理に関しては、今回はNotificatonCenterを利用してキーボードの表示・非表示のイベントを監視して管理する方法を使用しています。

  • viewWillAppearのタイミング: → キーボードの通知を登録(addObserverメソッドを利用しキーボードの開閉処理を監視対象にする)
  • viewWillDisappearのタイミング: → キーボードの通知を解除(removeObserverメソッドを利用しキーボードの開閉処理を監視対象から外す)

上記のようにポップアップの表示ないしは非表示が始まったタイミングでイベントの監視に関する処理を追記します。

AddController.swift
/* (Lifecycle Functions) */

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

    //通知(キーボード)に関する処理を登録する
    registerNotification()
}

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

    //通知(キーボード)に関する処理を解除する
    unregisterNotification()
}

/* (Observer Functions) */

//通知登録処理
func registerNotification() {

    //キーボードの開閉時の通知登録を行う
    let center: NotificationCenter = NotificationCenter.default
    center.addObserver(self, selector: #selector(AddController.keyboardWillShow(notification:)), name: Notification.Name.UIKeyboardWillShow, object: nil)
    center.addObserver(self, selector: #selector(AddController.keyboardWillHide(notification:)), name: Notification.Name.UIKeyboardWillHide, object: nil)
}

//通知登録解除処理
func unregisterNotification() {

    //キーボードの開閉時の通知登録を解除する
    let center: NotificationCenter = NotificationCenter.default
    center.removeObserver(self, name: Notification.Name.UIKeyboardWillShow, object: nil)
    center.removeObserver(self, name: Notification.Name.UIKeyboardWillHide, object: nil)
}

//Keyboard表示前処理
func keyboardWillShow(notification: Notification) {
    movePopupPosition(notification: notification, showKeyboard: true)
}

//Keyboard非表示前処理
func keyboardWillHide(notification: Notification) {
    movePopupPosition(notification: notification, showKeyboard: false)
}

キーボードの開閉の通知を受け取った際には、movePopupPosition(notification: Notification, showKeyboard: Bool)メソッドが実行されて、キーボードの高さをポップアップの中心位置に加算してアニメーションをするようにしています。また入力が完了してキーボードが隠れた場合には、ポップアップの中心位置を元に戻す(キーボード表示時とは逆の処理)処理をすればOKです。

AddController.swift
class AddController: UIViewController, UITableViewDelegate, UITableViewDataSource, UITextFieldDelegate, SFSafariViewControllerDelegate {

    ・・・(省略)・・・

    //初回キーボード表示用のメンバ変数
    var lastKeyboardFrame: CGRect = CGRect.zero

    ・・・(省略)・・・

    //ポップアップの位置補正を行う
    fileprivate func movePopupPosition(notification: Notification, showKeyboard: Bool) -> () {

        if showKeyboard {

            //keyboardのサイズを取得
            var keyboardFrame: CGRect = CGRect.zero
            if let userInfo = notification.userInfo {
                if let keyboard = userInfo[UIKeyboardFrameEndUserInfoKey] as? NSValue {
                    keyboardFrame = keyboard.cgRectValue
                }
            }

            //前の表示時のキーボード高さが0(初めてキーボードを表示した)の場合にはポップアップ位置をずらす
            //※ キーボードが表示されている状態で次の入力項目に移った場合はこの処理を行わない
            if lastKeyboardFrame.height == 0 {

                lastKeyboardFrame = keyboardFrame
                UIView.animate(withDuration: 0.26, delay: 0, options: UIViewAnimationOptions.curveEaseOut, animations:{

                    //キーボードの分だけ上にずらす
                    self.popupView.center = CGPoint(x: DeviceSize.screenWidth() / 2, y: DeviceSize.screenHeight() / 2 - keyboardFrame.height)

                }, completion: nil)
            }

        } else {

            //キーボードが隠れた場合のアニメーション
            UIView.animate(withDuration: 0.26, delay: 0, options: UIViewAnimationOptions.curveEaseOut, animations:{

                //キーボードの分を元に戻す
                self.popupView.center = CGPoint(x: DeviceSize.screenWidth() / 2, y: DeviceSize.screenHeight() / 2)

            }, completion: { finished in
                self.lastKeyboardFrame = CGRect.zero
            })
        }
    }
}

今回のようにキーボードの開閉に関する処理だけではなく、UITextFieldやUITextViewについても入力開始時・テキスト変更時・編集終了時のNotificationをそれぞれ持っていますので、テキストの入力に関連するような処理の中でもUITextFieldDelegate / UITextViewDelegateだけでの実装が困難な場合に活用できるかと思います。

★4-2. アーカイブデータ(Archive.swift)とレシピデータ(Recipe.swift)で定義したデータを1:nの関係で保存する部分

本サンプルではレシピデータの保存にRealmを使用していますが、1つのアーカイブデータに対して複数のレシピデータが紐づくように下記のようなデータの構成を取るようにしています。

realm_definition.jpg

またそれぞれのクラスの中に登録・削除・データのフェッチに関するメソッドやプライマリキー等の各種設定も行なっておきます。

Archive.swift(レシピのアーカイブに関するクラス):

Archive.swift
import Foundation
import RealmSwift

class Archive: Object {

    //Realmクラスのインスタンス
    static let realm = try! Realm()

    //id
    dynamic var id = 0

    //メモ
    dynamic var memo = ""

    //登録日
    dynamic var created = Date(timeIntervalSince1970: 0)

    //PrimaryKeyの設定
    override static func primaryKey() -> String? {
        return "id"
    }

    //プライマリキーの作成メソッド
    static func getLastId() -> Int {
        if let archive = realm.objects(Archive.self).last {
            return archive.id + 1
        } else {
            return 1
        }
    }

    //新規追加用のインスタンス生成メソッド
    static func create() -> Archive {
        let archive = Archive()
        archive.id = self.getLastId()
        return archive
    }

    //インスタンス保存用メソッド
    func save() {
        try! Archive.realm.write {
            Archive.realm.add(self)
        }
    }

    //インスタンス削除用メソッド
    func delete() {
        try! Archive.realm.write {
            Archive.realm.delete(self)
        }
    }

    //登録日順のデータの全件取得をする
    static func fetchAllCalorieListSortByDate() -> [Archive] {
        let archives = realm.objects(Archive.self).sorted(byProperty: "created", ascending: false)
        var archiveList: [Archive] = []
        for archive in archives {
            archiveList.append(archive)
        }
        return archiveList
    }
}

Recipe.swift(レシピのアーカイブに紐づく選択したレシピデータに関するクラス):

Recipe.swift
import Foundation
import RealmSwift

class Recipe: Object {

    //Realmクラスのインスタンス
    static let realm = try! Realm()

    //id
    dynamic fileprivate var id = 0

    //archive_id
    dynamic var archive_id = 0

    //楽天レシピid
    dynamic var rakuten_id = ""

    //楽天レシピ調理時間のめやす
    dynamic var rakuten_indication = ""

    //楽天レシピ公開日
    dynamic var rakuten_published = ""

    //楽天レシピタイトル
    dynamic var rakuten_title = ""

    //楽天レシピ画像URL
    dynamic var rakuten_image = ""

    //楽天レシピURL
    dynamic var rakuten_url = ""

    //PrimaryKeyの設定
    override static func primaryKey() -> String? {
        return "id"
    }

    //プライマリキーの作成メソッド
    static func getLastId() -> Int {
        if let recipe = realm.objects(Recipe.self).last {
            return recipe.id + 1
        } else {
            return 1
        }
    }

    //新規追加用のインスタンス生成メソッド
    static func create() -> Recipe {
        let recipe = Recipe()
        recipe.id = self.getLastId()
        return recipe
    }

    //インスタンス保存用メソッド
    func save() {
        try! Recipe.realm.write {
            Recipe.realm.add(self)
        }
    }

    //インスタンス削除用メソッド
    func delete() {
        try! Recipe.realm.write {
            Recipe.realm.delete(self)
        }
    }

    //アーカイブIDに紐づくデータを全件取得をする
    static func fetchAllRecipeListByArchiveId(archive_id: Int) -> [Recipe] {
        let recipes = realm.objects(Recipe.self).filter("archive_id = %@", archive_id).sorted(byProperty: "id", ascending: true)
        var recipeList: [Recipe] = []
        for recipe in recipes {
            recipeList.append(recipe)
        }
        return recipeList
    }
}

今回は1:n(多数)の関連性を持たせるためにRecipe.swiftのクラス内にarchive_idというカラムを定義して処理の中で紐付けが行えるように定義しました。

ポップアップ内の登録ボタン押下時の処理に関しては、下記のコードのようにまずはアーカイブデータをまずは1件登録を行った後に、選択した複数のレシピデータを順次登録する形にしています。

AddController.swift
//レシピを登録する時のアクション
@IBAction func saveRecipeAction(_ sender: UIButton) {

    //キーボードを閉じる
    view.endEditing(true)

    //ボタンを非活性状態にする
    closeButton.isEnabled = false
    saveButton.isEnabled = false

    //Realmにデータを保存してポップアップを閉じる
    saveArchiveData()
    removeAnimatePopup()
}

//Realmへの保存処理を行う(Archive:1件・Recipe:n件)
fileprivate func saveArchiveData() {

    //Realmへの登録処理
    let archiveObject = Archive.create()
    let archive_id = Archive.getLastId()
    archiveObject.memo = memoTextField.text!
    archiveObject.created = DateConverter.convertStringToDate(dateTextField.text)
    archiveObject.save()

    for targetData in targetSelectedDataList {
        let recipeObject = Recipe.create()
        recipeObject.archive_id = archive_id
        recipeObject.rakuten_id = targetData.id
        recipeObject.rakuten_indication = targetData.indication
        recipeObject.rakuten_published = targetData.published
        recipeObject.rakuten_title = targetData.title
        recipeObject.rakuten_image = targetData.image
        recipeObject.rakuten_url = targetData.url
        recipeObject.save()
    }
}

少し強引なコードにはなってしまいましたが、今までに慣れ親しんだRailsのActiveRecordやその他のORMの感覚に近しい感覚で処理を書くことができる点やドキュメントやその他サポートの充実ぶりは、本当にRealmを使用して良かったと感じる部分の1つだと思います。

5. レシピアーカイブページからのカスタムトランジションを活用した画面サイズに合わせて遷移をする部分の実装ポイント

メニュー右側のアーカイブボタンを押下すると、現在登録されているレシピのアーカイブデータの一覧を表示する(セル全体にかかる背景画像は登録したレシピにおいて最初の画像を表示)形になっています。

セル内には右下に2つのボタンが配置してあり、

  • レシピ一覧へ: → アーカイブデータに紐づくレシピデータの一覧を表示する
  • 削除する: → 削除確認のアラートが表示されでOKを選ぶと該当データの削除

という形になります。

★5-1. アーカイブページ → 登録レシピ一覧表示 → 登録レシピ詳細表示の一連の流れの設計部分

この部分も遷移に関する設計や遷移時の表現がなかなか整理しにくかったので、下記のような形で画面の動きやアイデアを簡単なラフスケッチを行なって、実際の表現イメージや実装方針を固めることにしました。

gallery_rough.jpg

★5-2. アーカイブページのクロージャーを利用したセル内のボタンに関する部分

この部分はUITableViewに表示しているセルのクラスとXibファイルを分割した形をとっています。その中でセルのクラス内で定義したボタンアクションを介して、UITableViewが配置されているコントローラー側の処理を実行するために、セルのクラス内にクロージャー変数を用意して処理の橋渡しを行います。

archive_button_logic.jpg

セル側(ArchiveCell.swift):

ArchiveCell.swift
class ArchiveCell: UITableViewCell {

    //ArchiveRecipeController.swiftへ処理内容を引き渡すためのクロージャーを設定
    var showGalleryClosure: (() -> ())?
    var deleteArchiveClosure: (() -> ())?

    ・・・(省略)・・・

    /* (Button Actions) */

    //「レシピ一覧へ」ボタン押下時のアクション
    @IBAction func showRecipeGalleryAction(_ sender: UIButton) {
        showGalleryClosure!()
    }

    //「削除する」ボタン押下時のアクション
    @IBAction func deleteRecipeAction(_ sender: UIButton) {
        deleteArchiveClosure!()
    }

    ・・・(省略)・・・
}

テーブルビュー配置側(ArchiveRecipeController.swift):

ArchiveRecipeController.swift
//表示するセルの中身を設定する
internal func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

    //Xibファイルを元にデータを作成する
    let cell = tableView.dequeueReusableCell(withIdentifier: "ArchiveCell") as? ArchiveCell

    //アーカイブデータが空でなければセルにレシピデータを表示する
    if !archiveList.isEmpty {

        //該当のArchiveデータとそれに紐づくRecipeデータを取得
        let archiveData = archiveList[indexPath.row]
        let recipes: [Recipe] = Recipe.fetchAllRecipeListByArchiveId(archive_id: archiveData.id)

        //1枚目の画像を見せておくようにする
        let url = URL(string: (recipes.first?.rakuten_image)!)
        cell?.archiveImageView.kf.indicatorType = .activity
        cell?.archiveImageView.kf.setImage(with: url)

        //レシピギャラリー一覧ページを表示する
        cell?.showGalleryClosure = {

            //遷移元からポップアップ用のGalleryControllerのインスタンスを作成する
            let galleryVC = UIStoryboard(name: "Gallery", bundle: nil).instantiateViewController(withIdentifier: "GalleryController") as! GalleryController

            //ポップアップ用のViewConrollerを設定し、modalPresentationStyle(= .overCurrentContext)と背景色(= UIColor.clear)を設定する
            galleryVC.modalPresentationStyle = .overCurrentContext
            galleryVC.view.backgroundColor = UIColor.clear

            //変数の受け渡しを行う
            galleryVC.recipeData = recipes

            //ポップアップ用のViewControllerへ遷移
            self.present(galleryVC, animated: false, completion: nil)
        }

        //データ表示用のUIAlertControllerを表示する
        cell?.deleteArchiveClosure = {

            //データ削除の確認用ポップアップを表示する
            let deleteAlert = UIAlertController(
                title: "データ削除",
                message: "このデータを削除しますか?(削除をする場合にはこのデータに紐づくレシピデータも一緒に削除されます。)",
                preferredStyle: UIAlertControllerStyle.alert
            )
            deleteAlert.addAction(
                UIAlertAction(
                    title: "OK",
                    style: UIAlertActionStyle.default,
                    handler: { (action: UIAlertAction!) in

                        //Realmから該当データを1件削除する処理
                        for recipe in recipes {
                            recipe.delete()
                        }
                        archiveData.delete()

                        //登録されているデータの再セットを行う
                        self.archiveList = Archive.fetchAllCalorieListSortByDate()
                })
            )
            deleteAlert.addAction(
                UIAlertAction(
                    title: "キャンセル",
                    style: UIAlertActionStyle.cancel,
                    handler: nil
                )
            )                
            self.present(deleteAlert, animated: true, completion: nil)
        }

        //アーカイブデータを取得する
        cell?.archiveDate.text = DateConverter.convertDateToString(archiveData.created)
        cell?.archiveMemo.text = archiveData.memo
    }

    cell?.accessoryType = UITableViewCellAccessoryType.none
    cell?.selectionStyle = UITableViewCellSelectionStyle.none
    return cell!
}

この方法を使用すると、セル側の処理とUITableView配置側の処理をある程度分割することもできますし、もしセル側の処理で得た値を活用する処理をUITableView配置側で行いたいような場合でも対応(クロージャーに引数の型を定義することで対応)できるので、私がサンプルを作成する場合に最近ではこの手法を用いることが多いです。

★5-3. カスタムトランジションを活用したサムネイル画像の拡大・縮小に合わせた画面遷移の部分

本サンプルでカスタムトランジションを適用することで、レシピの一覧から詳細への遷移の際にサムネイル画像の比率やcontentModeの値の状態を保ったまま、タップした画像の位置から画像が拡大をして画面いっぱいに
表示される、下記のイメージのような形の遷移を考えました。

custom_transition_imageview.jpg

サムネイルの比率定義やUIScrollViewへのレシピのサムネイル画像一覧を配置する処理に関してはGalleryController.swift内のoverride func viewDidLayoutSubviews()内を参照していただければと思います。

また、配置したサムネイル画像のUIImageViewにはTapGestureRecognizerを付与しておき、一覧から詳細へ遷移するためのトリガーとなるようにしておきます。
(詳細画面に配置した画面いっぱいのUIImageViewに関しても同様の対応を行います)

GalleryController.swiftにおいてTapGestureRecognizerが発動された際に呼び出されるのはexpandThumbnail(sender: UITapGestureRecognizer)メソッドになり、GalleryDetailController.swiftではbackThumbnail(sender: UITapGestureRecognizer)となります。

★5-4. カスタムトランジションの実装コード部分

上記の内容を考慮して処理や状態管理の概要や実装ポイントになる項目を整理すると下記のようになります。また遷移の方向を決めるためのメンバ変数var presenting: Boolを用意しておき、この値を持って遷移の方向を決めるようにしています。

custom_transition_matome.jpg

まずはカスタムトランジションに関するクラスの定義を行います。

コンテキスト(transitionContext)を元に遷移元・遷移先のViewインスタンスを取得し、メンバ変数originalFrameにはタップしたサムネイル画像の位置やサイズの情報を格納します。

そして下記のような形で拡大・縮小の比率を計算で決定した上でアニメーションを伴って動かすように、animateTransition(using transitionContext: UIViewControllerContextTransitioning)内の処理を記載します。

  • presented: → サムネイル画像の大きさから画面全体の大きさになるようにする
  • dismissed: → 画面全体の大きさからサムネイル画像の大きさになるようにする

※ 参考にしたサンプル内ではバネのような動きを入れていましたが本サンプルでは今回の画面デザイン的にちょっと合わない感じがしたので、その部分の処理は除外しています。

CustomTransition.swift
import UIKit

class CustomTransition: NSObject, UIViewControllerAnimatedTransitioning {

    //トランジションの秒数
    let duration      = 0.18

    //トランジションの方向(present: true, dismiss: false)
    var presenting    = true

    //アニメーション対象なるサムネイル画像の位置やサイズ情報を格納するメンバ変数
    var originalFrame = CGRect.zero

    //アニメーションの時間を定義する
    internal func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return duration
    }

    /**
     * アニメーションの実装を定義する
     * この場合には画面遷移コンテキスト(UIViewControllerContextTransitioningを採用したオブジェクト)
     * → 遷移元や遷移先のViewControllerやそのほか関連する情報が格納されているもの
     */
    internal func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {

        //コンテキストを元にViewのインスタンスを取得する(存在しない場合は処理を終了)
        guard let fromView = transitionContext.view(forKey: UITransitionContextViewKey.from) else {
            return
        }

        guard let toView = transitionContext.view(forKey: UITransitionContextViewKey.to) else {
            return
        }

        //アニメーションの実態となるコンテナビューを作成
        let containerView = transitionContext.containerView

        //遷移先のViewController・始めと終わりのViewのサイズ・拡大値と縮小値を決定する
        var targetView: UIView!
        var initialFrame: CGRect!
        var finalFrame: CGRect!
        var xScaleFactor: CGFloat!
        var yScaleFactor: CGFloat!

        //Case1: 進む場合
        if presenting {

            targetView = toView
            initialFrame = originalFrame
            finalFrame = targetView.frame
            xScaleFactor = initialFrame.width / finalFrame.width
            yScaleFactor = initialFrame.height / finalFrame.height

        //Case2: 戻る場合
        } else {

            targetView = fromView
            initialFrame = targetView.frame
            finalFrame = originalFrame
            xScaleFactor = finalFrame.width / initialFrame.width
            yScaleFactor = finalFrame.height / initialFrame.height
        }

        //アファイン変換の倍率を設定する
        let scaleTransform = CGAffineTransform(scaleX: xScaleFactor, y: yScaleFactor)

        //進む場合の遷移時には画面いっぱいに画像を表示させるようにするための位置補正を行う
        if presenting {
            targetView.transform = scaleTransform
            targetView.center = CGPoint(x: initialFrame.midX, y: initialFrame.midY)
            targetView.clipsToBounds = true
        }

        //アニメーションの実体となるContainerViewに必要なものを追加する
        containerView.addSubview(toView)
        containerView.bringSubview(toFront: targetView)

        UIView.animate(withDuration: duration, delay: 0.0, options: .curveEaseOut, animations: {

            //変数durationでの設定した秒数で拡大・縮小を行う
            targetView.transform = self.presenting ? CGAffineTransform.identity : scaleTransform
            targetView.center = CGPoint(x: finalFrame.midX, y: finalFrame.midY)

        }, completion:{ finished in
            transitionContext.completeTransition(true)
        })
    }
}

そしてこのクラスで定義したカスタムトランジションを適用させるために遷移元(GalleryController.swift)及び遷移先(GalleryDetailController.swift)に該当の処理を記載します。

遷移元(GalleryController.swift)には、UIViewControllerTransitioningDelegateに関する処理を記載やカスタムトランジションに関する処理を記載するような形になります。一覧から詳細へ遷移する場合には、CustomTransition.swift内のメンバ変数にTapGestureで選択したサムネイル画像の情報をoriginalFrameに引き渡しています。

GalleryController.swift
import UIKit
import Kingfisher

class GalleryController: UIViewController, UIScrollViewDelegate, UIViewControllerTransitioningDelegate {

    //タップ時に選択したimageViewを格納するための変数
    var selectedImage: UIImageView?

    ・・・(省略)・・・

    //レイアウト処理が完了した際のライフサイクル
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

            ・・・(省略)・・・

            //サムネイルにタグ名とTapGestureを付与する
            thumbnailImageView.tag = i
            thumbnailImageView.isUserInteractionEnabled = true
            let tapGesture = UITapGestureRecognizer(target: self, action: #selector(GalleryController.expandThumbnail(sender:)))
            thumbnailImageView.addGestureRecognizer(tapGesture)
        }
    }

    //サムネイルを拡大表示するためのアクション
    func expandThumbnail(sender: UITapGestureRecognizer) {

        //遷移対象をサムネイル画像とデータを設定する
        selectedImage = sender.view as? UIImageView
        let tagNumber = sender.view?.tag
        let selectedRecipeData = recipeData[tagNumber!]

        //カスタムトランジションを適用した画面遷移を行う
        let garellyDetail = storyboard!.instantiateViewController(withIdentifier: "GalleryDetailController") as! GalleryDetailController
        garellyDetail.recipe = selectedRecipeData
        garellyDetail.transitioningDelegate = self
        self.present(garellyDetail, animated: true, completion: nil)
    }

    /* (UIViewControllerTransitioningDelegate) */

    /**
     * カスタムトランジションは下記のサンプルをSwift3に置き換えて再実装
     * (実装の詳細はCustomTransition.swiftを参考)
     * 
     * 参考:iOS Animation Tutorial: Custom View Controller Presentation Transitions
     * https://www.raywenderlich.com/113845/ios-animation-tutorial-custom-view-controller-presentation-transitions
     */

    //進む場合のアニメーションの設定を行う
    internal func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {

        transition.originalFrame = selectedImage!.superview!.convert(selectedImage!.frame, to: nil)
        transition.presenting = true
        return transition
    }

    //戻る場合のアニメーションの設定を行う
    internal func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {

        transition.presenting = false
        return transition
    }

    ・・・(省略)・・・
}

遷移先(GalleryDetailController.swift)にはシンプルにUIImageViewに付与したTapGestureが発動されたタイミングで戻る遷移をする処理が記載されているだけになっています。

GalleryDetailController.swift
import UIKit
import Kingfisher
import SafariServices

class GalleryDetailController: UIViewController, UIViewControllerTransitioningDelegate, SFSafariViewControllerDelegate {

    ・・・(省略)・・・

    override func viewDidLoad() {
        super.viewDidLoad()

        ・・・(省略)・・・

        //背景のimageViewにタグ名とTapGestureを付与する
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(GalleryDetailController.backThumbnail(sender:)))
        backgroundImageView.addGestureRecognizer(tapGesture)

        ・・・(省略)・・・
    }

    //前の画面に戻るアクションをTapGestureをトリガーにして実行する
    func backThumbnail(sender: UITapGestureRecognizer) {
        presentingViewController?.dismiss(animated: true, completion: nil)
    }

    ・・・(省略)・・・
}

カスタムトランジションを利用した遷移アニメーションのカスタマイズに関しては、工夫の仕方やデザインとの合わせ方によっては、UIを作成する上でもユーザーの目を引いたりインパクトを出す際のワンポイントになり得る部分となるので、今後もさらに研究をして生かしていきたいと感じています。

6. メインコンテンツ部分とサブメニュー部分においてContainerViewを活用した開閉処理部分の実装ポイント

この部分に関しては、ContainerViewを重ねてサイドメニューとメインコンテンツを表示するようなUIを構築する際の実装ポイントに関する解説を以前行ったこともあり、今回も下記の記事で紹介しているサンプルと似たような形のものを導入してみようと思いました。

以前に作成したサンプルと異なる点に関しては、メニューの開閉に関するボタンが設定されている部分にプロトコルを定義し、実際の処理部分に関してはそれぞれのContainerViewが配置されている部分(ViewController.swift)に開閉に関する処理を記載する形にしています。

★6-1. ViewController.swiftの画面に配置された各ContainerViewに関する設計部分

土台となるViewController(ViewController.swift)上のStoryboardに配置されているContainerViewの重なり順や各種プロトコルの定義に関しての設計図は下記のような形になります。

設計図:

container_view_design.jpg

メニューを開いた際の見え方:

container_view_menu.jpg

それぞれのContainerViewを動かす処理に関しては、その要素が配置されているController側で処理を行う方がより良いかなと感じたので、今回はこのような形にしました。

★6-2. それぞれのContainerViewからメニュー開閉処理用のプロトコルを定義した上でそれぞれのアクションに対する処理を実装する部分

上記の設計図やデザインを踏まえた上でそれぞれの該当箇所にプロトコルを定義していくと下記のような形になります。

メニューを開く処理側のプロトコル定義:

MakeRecipeController.swift
//メニューボタンを開く処理を実装するためのプロトコル
protocol MenuOpenDelegate {
    func openMenuStatus(status: MenuStatus)
}

class MakeRecipeController: UIViewController, UINavigationControllerDelegate, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {

    //メニュー部分開閉用のプロトコルのための変数
    var delegate: MenuOpenDelegate! = nil

    ・・・(省略)・・・

    //メニューボタンを押した時のアクション
    func menuButtonTapped(button: UIButton) {

        //デリゲートメソッドの実行(処理の内容はViewControllerに記載する)
        self.delegate.openMenuStatus(status: MenuStatus.opened)
    }

    ・・・(省略)・・・
}

メニューを閉じる処理側のプロトコル定義:

MenuController.swift
//メニューボタンを閉じる処理を実装するためのプロトコル
protocol MenuCloseDelegate {
    func closeMenuStatus(status: MenuStatus)
}

class MenuController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource {

    ・・・(省略)・・・

    //メニュー部分開閉用のプロトコルのための変数
    var delegate: MenuCloseDelegate!

    ・・・(省略)・・・

    /* (Button Actions) */
    @IBAction func closeMenuAction(_ sender: UIButton) {

        //デリゲートメソッドの実行(処理の内容はViewControllerに記載する)
        self.delegate.closeMenuStatus(status: MenuStatus.closed)
    }

    ・・・(省略)・・・
}

上記のメニューを開くボタン or メニューを閉じるボタンに対応する実体の処理をContainerViewが配置されているコントローラー(ViewController.swift)に記述をします。

ViewController.swift
import UIKit

//メニュー部分の開閉状態管理用のenum
enum MenuStatus {
    case opened
    case closed
}

class ViewController: UIViewController, MenuOpenDelegate, MenuCloseDelegate {

    //各種パーツのOutlet接続
    @IBOutlet weak var mainMenuContainer: UIView!
    @IBOutlet weak var subMenuContainer: UIView!

    override func viewDidLoad() {
        super.viewDidLoad()

        ・・・(省略)・・・
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        //矩形のままでアニメーションをさせるためにコードで再配置する
        mainMenuContainer.frame = CGRect(x: 0, y: 0, width: mainMenuContainer.frame.width, height: mainMenuContainer.frame.height)
        subMenuContainer.frame = CGRect(x: 0, y: 0, width: subMenuContainer.frame.width, height: subMenuContainer.frame.height)
    }

    /**
     * 複雑な遷移時のプロトコルの適用
     *
     * (Case1)
     * UINavigationController → 任意のViewControllerとStoryBoardで設定した際に、
     * 任意のViewControllerに定義したプロトコルを適用させる場合
     *
     * (Case2)
     * ContainerViewで接続された任意のViewControllerに対して、
     * 任意のViewControllerに定義したプロトコルを適用させる場合
     *
     * overrideしたprepareメソッドを利用する。
     * [Step1] それぞれの接続しているSegueに対してIdentifier名を定める
     * [Step2] 下記のidentifierに関連するViewControllerのインスタンスを取得してデリゲートを適用する
     * (UINavigationControllerの場合はちょっと注意)
     *
     * (参考): Containerとの値やり取り方法
     * http://qiita.com/BOPsemi/items/dd65b2b7cd83ec1e82b9
     */
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {

        if segue.identifier == "MakeRecipe" {
            let navigationController = segue.destination as! UINavigationController
            let makeRecipeController = navigationController.viewControllers.first as! MakeRecipeController
            makeRecipeController.delegate = self
        }

        if segue.identifier == "Menu" {
            let menuController = segue.destination as! MenuController
            menuController.delegate = self
        }
    }

    /* MenuOpenDelegate */
    func openMenuStatus(status: MenuStatus) {
        changeMenuStatus(status)
    }

    /* MenuCloseDelegate */
    func closeMenuStatus(status: MenuStatus) {
        changeMenuStatus(status)
    }

    //enumの値に応じてのステータス変更を行う
    fileprivate func changeMenuStatus(_ targetStatus: MenuStatus) {

        if targetStatus == MenuStatus.opened {

            //メニューを表示状態にする
            UIView.animate(withDuration: 0.16, delay: 0, options: .curveEaseOut, animations: {
                self.mainMenuContainer.isUserInteractionEnabled = false
                self.mainMenuContainer.frame = CGRect(x: 0, y: 320, width: self.mainMenuContainer.frame.width, height: self.mainMenuContainer.frame.height)
            }, completion: nil)

        } else {

            //メニューを非表示状態にする
            UIView.animate(withDuration: 0.16, delay: 0, options: .curveEaseOut, animations: {                
                self.mainMenuContainer.isUserInteractionEnabled = true
                self.mainMenuContainer.frame = CGRect(x: 0, y: 0, width: self.mainMenuContainer.frame.width, height: self.mainMenuContainer.frame.height)
            }, completion: nil)
        }
    }

    ・・・(省略)・・・
}

上記のContainerViewに「Embed Segue」で接続されているViewController(今回はMenuController.swift及びMakeRecipeController.swiftに該当)をStoryboardのIdentifierから取得する場合には、従来のSegueにIdentifierを設定する時と同様に「Embed Segue」の部分にIdentifierをInterfaceBuilderを設定する形で設定できます。

7. あとがき

GestureRecognizerやカスタムトランジションの扱いに関しては、iOSアプリ開発に取り組み始めた頃はハマってしまう事が多く、デバッグに結構手こずってしまう事もありましたが、最近では苦手意識も少しずつではありますがなくなりつつあります。

(GestureRecognizerやTouchEventを活用するようなサンプルについては定期的に扱っていければと思っています。)

ユーザーが入力ないしは選択を行うようなタイミングで、ユーザーの指の動きを利用した画面操作を加えることや画面遷移時のアニメーションにほんの少し一工夫を加えることによって、UIに更に目を引くワンポイントを与えることもできるようになると思います。

ただし、この部分に関してはアニメーションの実装の際とも同様に、組み合わせ方を間違えてしまったり、たくさん入れすぎてしまうと却ってユーザーに優しくないUIになってしまうケースもあると思いますので、このようなユーザーの動きと連動するような形のUIを実現する際には、画面のデザインやユーザーが期待する動きともしっかりと相談した上での実装を心がけないといけないと私自身も強く感じました。

(今回のサンプルも、もしかしたら更にマッチするような画面デザインがあるかもしれませんね。。。)

私自身もUI表現に関する部分の実装は、とても楽しくかつ工夫のし甲斐がある部分だと思っていますので、今後とも更に良いUI表現の実装やサンプルショーケースの作成を行っていきたいと思っていますので、これからも何卒宜しくお願い致しますm(_ _)m


『 Swift 』Article List