読者です 読者をやめる 読者になる 読者になる

炊きたてのご飯が食べたい

定時に帰れるっていいね。自宅勤務できるっていいね。子どもと炊きたてのご飯が食べられる。アクトインディでは積極的にエンジニアを募集中です。

Swift2 - storyboard で 高さ可変の ScrollView を autolayout で設定する

Swift2 ScrollView Autolayout Storyboard

多くの場面で、高さが可変の ScrollView を作成する事があると思います。むしろ Web と連携するアプリなんかはそのようなケースの方が多いんじゃないでしょうか。極力ストーリーボードの autolayout を使って、高さ可変の ScrollView を設定する方法を紹介したいと思います。

  1. View に ScrollView を配置する
  2. ScrollView の横スクロールを制御する ContentView を ScrollView 配下に追加する
  3. ScrollView の横幅を設定する為の Equal Width を設定
  4. UILable などの高さの要素を持つパーツを追加して ScrollView を動的に制御する

1. View に ScrollView を配置する

View に ScrollView を配置して ScrollView の autolayout 設定は 上下左右全て 0 を指定します。

f:id:t-namikata:20160630235645p:plain

2. ScrollView の横スクロールを制御する ContentView を ScrollView 配下に追加する

ScrollView 配下に View を追加し、ContentView と名前を付けます。 ContentView の autolayout 設定は ScrollView に対して上下左右全て 0 を指定します。

f:id:t-namikata:20160630235636p:plain

この状態では、まだ autolayout の制約エラーで赤い状態になっています。 ScrollView は横にも縦にもスクロールできる状態の View なので、中の要素が空の状態では横幅と高さが決まらないからです。そこで、横幅を決める為の設定と高さを決める為の設定を入れてあげます。

f:id:t-namikata:20160630235640p:plain

3. ScrollView の横幅を設定する為の Equal Width を設定

ScrollView と ContentView を選択し Equal width の設定を入れます。

f:id:t-namikata:20160630235642p:plain

4. UILable などの高さの要素を持つパーツを追加して ScrollView を動的に制御する

親の View の高さは、高さ要素を持つ UILabel や UIButton などを子 View で持つことで、親 View に高さを指定しなくても、子 View の高さによって動的に変化してくれるのが autolayout の一番の利点だと思います。ここでは説明をシンプルにする為に UILable を一つ配置することで ScrollView の高さを動的に変更する方法を紹介したいと思います。

UILabel を ContentView 配下に追加し、ContentView に対して 左: 10 上: 50 右: 10 下: 10 の設定をします。

f:id:t-namikata:20160630235650p:plain

これでようやく Storyboard の autolayout の制約エラーが解消されました。この状態でシミュレーターを起動すると全くスクロールしない状態かと思います。これは、高さがスクリーンサイズを超えていないからスクロール領域がなく、スクロールしない状態になっています。

f:id:t-namikata:20160630235653p:plain

試しに UILable の bottom の autolayout の設定を 1000 とかにしてみて、シミュレーターを起動します。

f:id:t-namikata:20160630235655p:plain

指定した余白分スクロールできることが確認できるかと思います。今回の例では UILable 一つでしたが、複数の要素を ScrollView 上に配置したとしても、同じ原理で ScrollView の高さを動的に保つことができるようになります。

Swift2 - Storyboard で指定した AttributedText のフォントサイズが反映されない

Swift2 UILabel Attribute AttribetedText FontSize

テキストの行間を広げたくて storyboard で UILabel の Text のタイプを Plain から Attributed に変えて font size を 12, line spacing を 5 で設定してあげたところ storyboard ではきちんとフォントサイズと行間が効いていたのですがシミュレーターで確認したら、行間は反映されているんだけどフォントサイズが 17pt と変な挙動をしてました。

問題は iPhone だけに対応するアプリだったので storyboard の画面設定を w:Compact h: Regular で作成していて w:Any, h:Any の設定が残っていた事でした。 w:Any h:Any の設定をきちんと削除する事でシミュレーターにも正しく反映されました。

問題発生と解決の手順

w:Compact h: Regular で Attributed に切り替え、行間を指定する

f:id:t-namikata:20160629221304p:plain

f:id:t-namikata:20160629221307p:plain

f:id:t-namikata:20160629221309p:plain

シミュレーターを起動するとフォントサイズが正しく反映されていない

f:id:t-namikata:20160629221311p:plain

w:Any h:Any に storyboard の画面を切り替え UILable の Text のタイプを Plain に切り替える

f:id:t-namikata:20160629221312p:plain

f:id:t-namikata:20160629221316p:plain

w:C h:R のところに System 17.0 の設定が残っているので削除する

f:id:t-namikata:20160629221318p:plain

w:Compact h: Regular に戻り、再度 Attributed に切り替え、行間を指定する

f:id:t-namikata:20160629221319p:plain

シミュレーターを起動すると正しくフォントサイズが反映されている

f:id:t-namikata:20160629221321p:plain

Swift2 - クラスの継承とプロトコルの適合(採用)の両方の条件を満たす条件をジェネリクスで書く

Swift2 ジェネリクス delegate プロトコル protocol 継承 採用 適合
Swift2.2, Xcode7

modal で呼び出す画面は、どの ViewController からも呼び出される可能性がある為、ページ遷移用の Utility に定義する様にしています。その時に困るのが、 UIViewController を継承して、かつ、モーダルを閉じる delegate ( protocol )を採用しているケースです。例として以下のようなケースを考えたいと思います。

  • modal を呼び出す ParentViewController 。 delegate で modal を閉じる。
  • modal で呼び出される側は ChildViewController
  • storyboard は ViewController と 1 対 1 で分割している

  • ParentViewController

protocol ChildViewControllerDelegate: class {
    func modalDismiss()
}

class ParentViewController: UIViewController, ChildViewControllerDelegate {
    ...

    func modalChild() {
        // TransitionUtility は ChildViewController を modal で呼び出す処理
        TransitionUtility.modalChild(self)
    }

    func modalDismiss() {
        self.dismissViewControllerAnimated(true, completion: nil)
    }
  • ModalViewController
class ModalViewController: UIViewController {
    weak var delegate: ChildViewControllerDelegate?

    ...

    func close() {
        self.delegate.modalDismiss()
    }
}

このような状況で modal を表示する処理を書こうと思うと UIViewController の継承と EditEmailViewControllerDelegate の Protocol の採用でちょっと困ったことになります。

struct TransitionUtility {
  static func modal(viewController: UIViewController) {
        let storyboard = UIStoryboard(name: "Child", bundle: nil)
        guard let modalViewController = storyboard.instantiateViewControllerWithIdentifier("Child") as? ChildViewController else {
            return
        }
        modalViewController.delegate = viewController
        // ↑ viewController は ChildViewControllerDelegate を採用していないので delegate に指定できないよと怒られる。
        viewController.presentViewController(modalViewController, animated: true, completion: nil)      
  }
}
struct TransitionUtility {
  static func modal(viewController: EditEmailViewControllerDelegate) {
        let storyboard = UIStoryboard(name: "Child", bundle: nil)
        guard let modalViewController = storyboard.instantiateViewControllerWithIdentifier("Child") as? ChildViewController else {
            return
        }
        modalViewController.delegate = viewController
        viewController.presentViewController(modalViewController, animated: true, completion: nil)
        // ↑ viewController は EditEmailViewControllerDelegate なので UIViewController の持つ presentViewController は利用できない。
  }
}

このような時に利用できるのがジェネリクスです。ジェネリクスを利用することで UIViewController を継承しており、かつ、ChildViewControllerDelegate を採用しているクラスを引数に定義することができます。実装方法は以下です。

static func modalChild<T where T: UIViewController, T: ChildViewControllerDelegate>(viewController: T) {
    let storyboard = UIStoryboard(name: "Child", bundle: nil)
    guard let modalViewController = storyboard.instantiateViewControllerWithIdentifier("Child") as? ChildViewController else {
        return
    }
    modalViewController.delegate = viewController
    viewController.presentViewController(modalViewController, animated: true, completion: nil)
}

これで ChildViewController の modal の呼び出しは TransitionUtility.modalChild(self) といった形で呼び出せるようになります。

Swift2 - Quick + Mockingjay で Alamofire の非同期通信のテストをする

Swift2 Alamofire ObjectMapper Mockingjay Spec 単体テスト API スタブ stub

API との通信をテストする時、実際にリクエストを投げてしまうとサーバーへの負荷やテスト時間など、コストが多くかかってしまうので、Alamofire のリクエストを Quick と Mockingjay を使ってスタブする方法を紹介したいと思います。

  • Quick: RSpec 風に書ける単体テストライブラリ Quick
  • Mockingjay: URL リクエストをモックできるライブラリ Mockingjay

以前書いた Swift2 Alamofire + ObjectMapper で API クライアントを作成する のリクエストに対してテストを書いてみたいと思います。テストするリクエストは以下です。

API.call(Endpoint.User.Find(id: 1)) { response in
  switch response {
  case .Success(let result):
      print("success \(result)")
  case .Failure(let error):
      print("failure \(error)")
  }
}
import Alamofire
import ObjectMapper

class Endpoint {
    enum User: RequestProtocol {
        typealias ResponseType = UserEntity
        
        case Find(id: Int)
        
        var method: Alamofire.Method {
            switch self {
            case .Find:
                return .GET
            }
        }
        
        var path: String {
            switch self {
            case .Find(let id):
                return "/users/\(id)"
            }
        }
    }
}
import ObjectMapper

class UserEntity: Mappable {
    var id: Int?
    var name: String?
    var email: String?
    var url: String?
    
    required convenience init?(_ map: Map) {
        self.init()
    }
    
    func mapping(map: Map) {
        id <- map["id"]
        name <- map["name"]
        email <- map["email"]
        url <- map["url"]
    }
}
  1. テストを書く
  2. スタブ用の json ファイルを用意する
  3. json ファイルから NSDate に変換する処理は繰り返し利用する為 Helper クラスを用意する

1. テストを書く

テストの完成形は以下の様になります。

  • Quick には toEventually という非同期の処理様のテストメソッドがあるのでそれを利用します
  • Class のプロパティとしてテスト対象のデータを宣言しないと上手くtoEventually メソッドが使えません
  • self.stub のメソッドが Mockingjay によるスタブです。 jsonData で指定した data がレスポンスとして返ります

  • UserSpec

import Quick
import Nimble
import Mockingjay
import ObjectMapper
@testable import projectName

class UserSpec: QuickSpec {
    var user: UserEntity?
    var stubUser: UserEntity?
    var error: NSError?
    
    override func spec() {
        describe("Endpoint.User.Find") {
            context("正常系") {
                beforeEach {
                    let data = SpecHelper.readJSONFile("parent")
                    self.stub(http(.GET, uri: "/users/1"), builder: jsonData(data))
                    let JSONString = String(data: data, encoding: NSUTF8StringEncoding)
                    self.stubUser = Mapper<UserEntity>().map(JSONString)
                }
                afterEach {
                    self.user = nil
                    self.stubUser = nil
                    self.removeAllStubs()
                }
                
                it("指定したユーザー情報が取得できること") {
                    API.call(Endpoint.User.Find(id: 1)) { response in
                        switch(response) {
                        case .Success(let result):
                            self.user = result
                        case .Failure(let error):
                            print(error)
                        }
                    }
                    expect(self.user!.id).toEventually(equal(self.stubUser!.id))
                    expect(self.user!.name).toEventually(equal(self.stubUser!.name))
                    expect(self.user!.email).toEventually(equal(self.stubUs!.email))
                    expect(self.user!.url).toEventually(equal(self.stubUs!.url))
                }
            }
            context("異常形") {
                context("APIがメンテナンス中") {
                    beforeEach {
                        self.stub(http(.GET, uri: "/users/1"), builder: http(503))
                    }
                    afterEach {
                        self.removeAllStubs()
                    }
                    
                    it("503 エラーが返却されること") {
                        API.call(Endpoint.PutFavorite(id: facilityId)) { response in
                            switch response {
                            case .Success(_):
                                break
                            case .Failure(let error):
                                self.error = error
                            }
                        }
                        expect(self.error?.userInfo.description).toEventually(equal("[NSLocalizedFailureReason: Response status code was unacceptable: 503]"))
                        expect(self.error?.code).toEventually(equal(-6003))
                    }
                }
            }
        }        
    }
}

2. スタブ用の json ファイルを用意する

let data = SpecHelper.readJSONFile("parent")
self.stub(http(.GET, uri: "/users/1"), builder: jsonData(data))

Mockingjay では NSData を jsonData に渡してあげることで uri に指定したリクエストをスタブし data をレスポンスとして返してくれます。 json データは記述が多くなることが多いのでレスポンス用の json ファイルを作成します。

  • user.json
{
    "id": 1,
    "name": "ティナ・ブランフォード",
    "email": "tina@example.com",
    "url": "https://ja.wikipedia.org/wiki/ファイナルファンタジーVI"
}

3. json ファイルから NSDate に変換する処理は繰り返し利用する為 Helper クラスを用意する

用意しておくと便利です。

  • SpecHelper.swift
static func readJSONFile(name: String) -> NSData {
    let path: String? = NSBundle(forClass: self).pathForResource(name, ofType: "json")
    let fileHandle: NSFileHandle? = NSFileHandle(forReadingAtPath: path!)
    let data: NSData! = fileHandle?.readDataToEndOfFile()
    
    return data
}

Swift2 - 画像アップロードと通常のリクエストを分けてユーザー体験を向上させる

Swift2 Alamofire upload 非同期 API

リクエスト回数は極力減らした方が良いのでは?といった方針から、画像をアップロードする処理と通常のテキスト情報を送信する処理を一つのリクエストで行っていたのですが、実際に実装してみたところ、リクエストが完了するまで画面にローディングを表示して、操作を中断させる処理を入れていたため、えらく待たされる感が漂ってました。非同期で行われる API の良さも活かしきれていない状況で、リクエスト回数を減らすよりか、より早くユーザーに操作できる環境を提供する方が、操作していて気持ちいいだろうということで、通常のリクエストと画像のリクエストを分けて実装することになったので、その方法について紹介したいと思います。口コミを投稿する機能をサンプルとしたいと思います。

サンプル例

  1. ある宿泊施設に対して口コミを投稿する機能がある
  2. 口コミで投稿する内容は、感想( content ), 評価 ( rating ) , 画像 ※複数可 ( photos ) とする

実装内容

  1. テキスト情報のリクエストを定義する ( /facilities/:facility_id/reviews )
  2. 画像アップロードのリクエストを定義する ( /reviews/:review_id/photos )
  3. 口コミ投稿する際には、画像以外をまずは投稿する。レスポンスに口コミ ID を返すようにし、画像はレスポンスの口コミ ID にアップロードするようにする
  4. 複数枚の画像のアップロードが想定されるので、画像のアップロード中は画面にアップロード中の View を表示する

通常のリクエストはすぐに完了する処理なので、ローディング画面を表示して、操作を中断。画像アップロードは時間がかかるので、ローディング画面ではなく、ページ上部にアップロード中のお知らせを出して、画面を操作は制御しないようにします。画像のアップロードが失敗しても、口コミのテキスト情報の送信は commit されているので、ロールバックはできません。画像のアップロード失敗時は、適切にユーザーに知らせることに注力し、編集から再度画像をアップロードしてもらう形にしようといった割り切り方をしました。

f:id:t-namikata:20160523233117p:plain

1. テキスト情報のリクエストを定義する

(Swift2) Alamofire の upload 用 API クライアントを作成する の記事で作成した API クライアントを利用して、実装したいと思います。リクエストパスは /facilities/:facility_id/reviews とし POST するものとします。

  1. Review 情報のプロパティを定義する
  2. API を call する為の Endpoint を定義する

1. Review 情報のプロパティを定義する

まずは要件に沿って エンティティとなる ReviewEntity クラスを作成します。

  • ReviewEntity.swift
import ObjectMapper

class ReviewEntity: Mappable {
    var id: Int?
    var content = ""
    var rating = 0
    var photoList: [String] = []

    required init?(_ map: Map) {}    
    
    func mapping(map: Map) {
        id <- map["id"]
        content <- map["content"]
        rating <- map["rating"]
        photoList <- map["photos"]
    }
    
    var parameters: [String: AnyObject] {
        return [
            "content": content,
            "rating": rating,
        ]
    }
}

parameters は POST リクエストに利用するプロパティを定義しています。

2. API を call する為の Endpoint を定義する

  • Endpoint.swift
enum Review: RequestProtocol {
    typealias ResponseType = ReviewEntity
    
    case Post(facilityID: Int, parameters: [String: AnyObject])
    
    var method: Alamofire.Method {
        return .POST
    }
    
    var path: String {
        switch self {
        case .Post(let facilityID, _):
            return "/facilities/\(facilityID)/reviews"
        }
    }
    
    var parameters: [String: AnyObject]? {
        switch self {
        case .Post(_, let parameters):
            return parameters
        }
    }        
}

リクエストを投げる際は以下のように記述します。

let facilityID = 1
let parameters = ["content": "楽しかった", "rating": 3]
API.call(Endpoint.Review.Post(facilityID: facilityID, parameters: parameters)) { response in
    switch response {
    case .Success(let result):
        // 成功時の処理を書く
    case .Failure(let error):
        // エラー時の処理を書く
    }
}

2. 画像アップロードのリクエストを定義する ( /reviews/:review_id/photos )

通常のリクエストと同じように、画像のアップロードのリクエストを定義します。

  • Endpoint.swift
class Endpoint {
    class UploadPhoto: UploadProtocol {
        typealias ResponseType = AnyObject
        var id: Int
        var imageData: NSData
        
        init(id: Int, imageData: NSData) {
            self.id = id
            self.imageData = imageData
        }
        
        var path: String {
            return "/reviews/\(self.id)/photos"
        }

        lazy var multipartFormData: (MultipartFormData) -> () = { [weak self](data: MultipartFormData) in
            guard let weakSelf = self else {
                return
            }
            data.appendBodyPart(
                data: weakSelf.imageData,
                name: "photo",
                fileName: "image",
                mimeType: "image/jpeg"
            )
        }
    }
}

リクエストを投げる際は以下のように記述します

let reviewID = 1
let image = UIImage(named: "test")
let humanQuality: CGFloat = 0.8
let imageData = UIImageJPEGRepresentation(image, humanQuality)

API.call(Endpoint.UploadPhoto(id: reviewID, imageData: imageData)) { response in
    switch response {
    case .Success(let result):
        print("success \(result)")
    case .Failure(let error):
        print("failure \(error)")
    }
}

3. 口コミ投稿する際には、画像以外をまずは投稿する。レスポンスに口コミ ID を返すようにし、画像はレスポンスの口コミ ID にアップロードするようにする

簡易的に ViewController で API のリクエストを行いますが、きちんと実装する場合は Model 等で非同期のリクエストは行うようにしてください。

  1. Endpoint.Review.Post でテキスト情報のリクエストを投げる
  2. Endpoint.Review.Post リクエストの Success 処理内で画像をアップロードする Endpoint.UploadPhoto を呼び出す

これでテキスト情報のリクエストと画像アップロードを一連の流れの中で別処理として行うことができるようになりました。

ViewController.swift

import SVProgressHUD

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        let facilityID = 1
        let parameters = ["content": "楽しかった", "rating": 3]
        // テキスト情報の投稿中はユーザーの画面操作を制御する
        SVProgressHUD.showWithStatus("投稿中", maskType: .Black)
        API.call(Endpoint.Review.Post(facilityID: facilityID, parameters: parameters)) { response in
            // API の結果が返ってきたら、ローディングを解除する。このタイミングでユーザーは画面を操作可能となる。
            SVProgressHUD.dismiss()
            switch response {
            case .Success(let result):
                // Endpoint.Review.Post は Endpoint.swift の ResponseType に ReviewEntity を指定しているので
                // result の型は ReviewEntity となる
                self.upload(result.id)
            case .Failure(let error):
                // エラー時の処理を書く
            }
        }
    }

    private upload(reviewID: Int) {
        let imageList = [UIImage(named: "test"), UIImage(named: "test2"), UIImage(named: "test3")]
        let humanQuality: CGFloat = 0.8
        let imageData = UIImageJPEGRepresentation(image, humanQuality)

        for image in imageList {
            // API.call リクエストは非同期処理の為、一つ一つの処理を待たずに画像のアップロードは 3並列で行われる。
            API.call(Endpoint.UploadPhoto(id: reviewID, imageData: imageData)) { response in
                switch response {
                case .Success(let result):
                    print("success \(result)")
                case .Failure(let error):
                    print("failure \(error)")
                }
            }        
        }
    }
}
  1. 複数枚の画像のアップロードが想定されるので、画像のアップロード中は画面にアップロード中の View を表示する

最後に、このままでは画像のリクエストの進捗がユーザーに分からないので、進捗が分かるように View を画面に表示します。

f:id:t-namikata:20160523233117p:plain

(Swift2) Alamofire の upload の進捗を通知で受け取ってプログレスバーを表示するの内容を拡張すれば容易に行えるかと思います。ここでは、処理の流れと、変更を加えた箇所だけ説明します。具体的な実装方法については記事を参照してください。

処理の流れ

  1. 画像アップロードの開始に合わせてアップロード中の View を表示する
  2. 画像アップロードの終了に合わせてアップロード終了の View を表示する
  3. アップロードに失敗した画像があればアップロード失敗のトーストを表示する

最終的に ViewController の実装は以下のようになります。

import SVProgressHUD

class ViewController: UIViewController {
    // 画像アップロードの通知用 View
    private let uploadProgressView = UploadProgressUIView()

    override func viewDidLoad() {
        super.viewDidLoad()

        let facilityID = 1
        let parameters = ["content": "楽しかった", "rating": 3]
        SVProgressHUD.showWithStatus("投稿中", maskType: .Black)
        API.call(Endpoint.Review.Post(facilityID: facilityID, parameters: parameters)) { response in
            SVProgressHUD.dismiss()
            switch response {
            case .Success(let result):
                self.upload(result.id)
            case .Failure(let error):
                // エラー時の処理を書く
            }
        }
    }

    private upload(reviewID: Int) {
        let imageList = [UIImage(named: "test"), UIImage(named: "test2"), UIImage(named: "test3")]
        let humanQuality: CGFloat = 0.8
        let imageData = UIImageJPEGRepresentation(image, humanQuality)

        // 画像のリクエストに合わせて View を表示する
        self.addUploadProgressView()
        // アップロード回数を計測する。API のリクエストは並列で行われる為 for 文の index は利用できない
        var uploadPhotoCount = 0
        for image in imageList {
            API.call(Endpoint.UploadPhoto(id: reviewID, imageData: imageData)) { response in
                // リクエストの回数をインクリメントする
                uploadPhotoCount += 1
                switch response {
                case .Success(let result):
                    // No-op
                case .Failure(let error):
                    // トースト表示なので dismiss() しなくても一定時間が経過すると表示は消える
                    SVProgressHUD.showErrorWithStatus("画像のアップロードに失敗しました")
                }
            }
            if uploadPhotoCount == imageList.count {
                self.removeUploadProgressView()
            }
        }
    }

    private func addUploadProgressView() {
        // ...省略
    }
    
    private func removeUploadProgressView() {
        // ...省略
    }

    private func settingProgressView() {
        // ...省略
    }
}