Iganinのブログ

日頃の開発で学んだ知見を中心に記事を書いています。

【iOS】iOS13でSystemフォントがTimes New Romanフォントに変更されることがある

概要

タイトル通りです。バグレポートでも上がっているようにUITextViewなどでsystemフォントを指定していた際に、アプリ実行時にTimes New Romanフォントに変更されてしまう、という事象が発生しているようです。

NSAttributedStringKey の .fontに明示的にUIFontを指定しないと起きるのではないか等原因に関して言及されていますが、はっきりとした理由はわからないと言うのが正直なところではないでしょうか。

事象と対応

私の場合ですが、Interface Builder上でUITextViewのplainの文字列にsystemフォントを設定していたところ、アプリ実行時に該当のエラーが発生しました。下記のようにコードから明示的にUIFontを指定することでバグを回避できています。

textView.font = UIFont.systemFont(ofSize: 16.0)

参考にネット上で見つけた関連した文書をまとめていますのでご参照ください。

所感

iOS 13 は本事象だけでなく様々なバグが発生しており、なかなか苦しいなというのが正直な感想です。。 アプリ内で文字フォントがおかしくなっていないか、念のため確認した方が良いかもしれません。

参考

【iOS】Password Autofillの実装

はじめに

iOS11からpassword autofillに対応することが可能になりました。 具体的には、パスワード新規作成時にiOSから提案される強力なパスワードを使用したり、keychainにユーザーID、パスワードのセットを記録し、アプリ使用時やWebサイトのログイン時に記録されたID・パスワードを使用することができます。

具体的な実装としては、下記のように対応することになります。

  • Apple Developer CenterにてAssociated Domainsにチェック
  • アプリのProject設定を変更
  • サーバーにapple-app-site-associationを配置
  • アプリ側のTextFieldのTypeに適切な値を設定する

本記事にて対応の概要と対応する中で学んだ注意点や補足事項を記載します。

環境設定

以下の環境を使用しています。

  • iOS 13.1.2
  • Xcode11.1.0

対応方法

以下で「はじめに」に記載した対応手順を概説します。

Apple Developer CenterにてAssociated Domainsにチェック

Apple Developer Center > Identifiersから自身のアプリを選択し、Associated Domainsにチェックをつける必要があります。この際にCapabilitiesの追加を行なったことによりProvisioning ProfileがInvalidateされますので、再作成を忘れないようにしてください。 f:id:Iganin:20191106201616p:plain

アプリのProject設定変更

アプリのProject設定を変更します。 Signing & Categoriesから「+Capability」ボタンを押下し、Associated Domainsを選択します。この際に、まだProjectにentitlementsファイルがない場合は新規生成されますので、適当なフォルダに格納したり、命名の変更を行なってください。 f:id:Iganin:20191106204028p:plain

検証環境と本番環境のConfigurationを別々にしているのが一般的かと思いますので、Build Settingsから各Configurationにあったentitlementsを設定するのが良いかと思います。

f:id:Iganin:20191106204311p:plain

associated domainsに追加したいdomainを記載します。webcredentials:<domain名>かユニバーサルリンクの対応を行いたい場合はapplinks:<doamain名>とすれば良いでしょう。なお、associated domainsは各entitlementsに対して設定できますので、検証環境と本番環境を分けることが可能です。

f:id:Iganin:20191107015327p:plain

サーバーにapple-app-site-associationを配置

アプリのprojectにて設定したドメインのサーバーにapple-app-site-associationファイルを追加します。ドメイン直下か.well-known配下に格納するようドキュメントには記載されています。例えば、ドメイン直下に格納した場合はhttps:///apple-app-site-associationでアクセスできるか確認します。(※ .well-known配下に格納した場合はhttps:///.well-known/apple-app-site-association)

実際のファイルの中身は下記のようになります。あまり情報はありませんでしたが、webcredentialsとapplinksの双方を記述して問題なさそうでした。ファイル自体はJSON形式で詳しい内容はAppleの公式ドキュメントに記載されています。

{
    "webcredentials": {
        "apps": [ "${TeamID}.${BundleID}" ]
    },
    "applinks": {
        "apps": [],
        "details": [{
            "appID": "${TeamID}.${BundleID}",
            "paths": ["*"]
            }]
    }
}

アプリ側のTextFieldのTypeに適切な値を設定する

ユーザー名、パスワードを入力するTextFieldに適切な値を設定します。Username, New Passwordの組み合わせで強力なパスワードの自動生成、 Username, Passwordの組み合わせでユーザー名, パスワードの自動入力が使用できます。同一画面上にUsernameがなく、password関連の入力のみの場合は上記は機能しないようでした。また、One Time Passwordに設定することでSMSに届いたOne Time Passwordを自動で入力値の候補として使用するようにできます。このあたりの内容の詳細は公式ドキュメントを参照ください。

以上の設定でPassword Autofillの機能追加が完了します。ただ、検証する中で引っ掛かった点がありましたので、いかに補足として記載します。

補足

上記設定をしてもPassword Autofillが機能しない場合のiPhone側確認事項

Password Autofillですが、保存先としてiCloud Keychainを使用しています。 そのため、有効化するためには以下の設定が必要です。

  • Apple Accountでログインする
  • 設定アプリ > Apple ID > iCloud > キーチェーン が オンになっている

Face IDやTouch IDの認証が求められた際の挙動

QuickType barからユーザーID・パスワードを選択した際に、Face IDかTouch IDの入力を求められます。 この際に、Face IDかTouch IDが表示された際にapplicationWillResignActive(_:)が呼ばれ、入力が完了した際にapplicationDidBecomeActive(_:)が呼ばれます。したがって、該当メソッド内で画面遷移などを実装している場合、意図しない挙動となる可能性が高いため注意が必要です。

Associated Domainsが登録されるタイミング

公式ドキュメントによると、アプリがiOSバイスにインストールされたタイミングでentitlementファイルのassociated domainsに登録されているドメインからassociationファイルをダウンロードし、アプリのIdentifierが登録されているか確認し、登録を行うようです。

この際にサーバーから500-599のエラーが返却された際はデフォルトでは3時間おきに8回まで通信リトライをするとのことです。

ドメインの関連付けに成功した後はアプリがデバイスから削除されるまでは関連付けは維持され続けるようです。サーバー側のassociationファイルの修正の反映を即座に確認したい場合はアプリの削除をすることが推奨されていました。 この辺りの詳細は* 公式ドキュメント - About the Password AutoFill Workflow を参照ください。

参考

以下参考文献です。公式のドキュメントから関連した文書を集めました。 やはりまずは一次情報に一通り当たるのが理解の近道かと思います。

【iOS】Push通知に必要なDeviceTokenの文字列取得に関して

はじめに

サーバーからのPush通知を実行するためには、サーバーにDeviceTokenの文字列を保持し、そのDeviceTokenを指定してAPNs(Apple Push Notification Service)に通信を行う必要があります。DeviceTokenはregisterForRemoteNotifications()の実行によって、application(_:didRegisterForRemoteNotificationsWithDeviceToken:)メソッドを通して通知されます。

しかし、通知されるDeviceTokenはData型であるため、Serverに送信するためには多くの場合、ここからString型に変換する必要があります。本記事では、変換に関してiOS13以降でのdescriptionメソッドに関する変更も交え記載します。

環境設定

以下の環境を使用しています。

  • iOS 13.1.2
  • Xcode11.1.0

iOS13以降での変更点

まずiOS13以降でdescriptionメソッドの返却値が変更になった点に関して記載します。 iOS13以前では(deviceToken as NSData).descriptionにて<1xxxxxx5 55xxxxca xxxxx572 00c3xxxx xxxxxxx5 92xxxx90 xxxxxxx7d bxxxxxxa>という文字列が取得できていました。サービスによってはここから以下のメソッドで<>と半角スペースを取り除いて送ったり、そのままServerに送信していたりしていたかと思います。

// <>と半角スペースを取り除く場合のメソッド例
let token = ((deviceToken as NSData).description.trimmingCharacters(in: CharacterSet(charactersIn: "<>")) as NSString).replacingOccurrences(of: " ", with: "")

しかし、iOS13以降でNSDataのdescriptionメソッドの返却値が下記のように変更になりました。{length=32,bytes=0x3dd8xxxxxxxx0be290cxxxxxxxxxx2e0...7xxxxxxxxxxxx0a}形式が大きく変わっていますし、文字列部分に...という省略が加わったことにより、deviceTokenの取得ができなくなりました。

AppStoreに出ているアプリでも本事象が原因と思われるバグ修正のリリースがいくつか見られます。

対応方法

純粋に文字列部分のみ取得したい場合は、@mono0926さんの以下のメソッドがシンプルで良いかと思います。12xxxx6a5xxxxxxxcad80xxxxxxxxxx3b93exxxxxx9xxxxxxx32xxxxxxb83967beこちらのような文字列が取得できます。

let token = deviceToken.map { String(format: "%.2hhx", $0) }.joined()

ただ、 NSDataのdescriptionメソッドの返却値を今までそのまま送っており、諸般の事情で<>や半角スペースを含んだ状態でDeviceTokenの文字列を送らなければならない場合もあるかと思います。その場合は、例えば以下のようにしてNSDataのdescriptionメソッドと同様の文字列を取得できます。

元の文字列を参考にDataを4bytes区切りで文字列に変換しています。最後に<>を付与して返却することでiOS13未満でのNSDataのdescriptionメソッドの返却値を再現しています。配列生成時にcapacityを考慮するとより処理効率が上がりそうですが、実行速度はそれほど問題にはならないと判断しこのままにしています。String(deviceToken: deviceToken)のように使用して文字列を取得できます。

extension String {
    /// DeviceTokenをDataから生成する初期化メソッドです。
    /// RemoteNotificationの実行に必要なTokenを生成します。
    ///
    /// - Parameter deviceToken: Dataから生成したDeviceToken文字列
    ///   例: <70a7ff15 cxxx3c44 bd0xxx2c f75xxxxb dxxxc23f ff14xxxe 6xxxx3f2 b6xxxxxb>
    init(deviceToken: Data) {
        let groupingUnit: Int = 4 // 4bytesずつ区切って文字列を作成する
        let groupCount: Int = 8 // 4bytes区切りの文字列を8つ作成する
        
        var deviceTokenStrings = [String](repeating: "", count: groupCount)
        deviceToken.enumerated().forEach { deviceTokenStrings[Int($0) / groupingUnit] += String(format: "%.2hhx", $1) }
        self = "<" + deviceTokenStrings.joined(separator: " ") + ">" // 前後を < > で囲う
    }
}

まとめ

Push通知の送信に必要なDeviceTokenの文字列取得に関して、<>や半角スペースを含めない場合の方法と含める場合の方法を記載しました。NSDataのdesctiptionメソッドを使用しており、iOS13以降で対応が必要になった場合はご参考にしていただけますと幸いです。

参考

【iOS】iOS13からSKProductsRequestのDelegateメソッドがメインスレッドで呼ばれなくなった

はじめに

In App Purchaseを実装する際にApp Storeに課金Productを登録し、アプリからそのプロダクトの情報を取得します。 具体的には、下記のようにSKProductsRequestを作成し、取得したい課金プロダクトのIdentifiersをSetで指定、通信結果を func productsRequest(request: SKProductsRequest, didReceiveResponse response: SKProductsResponse)で受け取ります。(※補足ですがRequestが解放されないよう強参照で保持するようAppleのDocumentに記載があります)

ただ、このメソッドの挙動がiOS13以降で変わったようなのでその内容を記載します。

private var request: SKProducstRequest?
request = SKProductsRequest(productIdentifiers: Set(productIdentifiers))
request?.delegate = self
request?.start()

環境設定

以下の環境を使用しています。

  • iOS 13.1.2
  • Xcode11.1.0

どう変わったか

端的にまとめると下記2つのメソッドがメインスレッドで実行されなくなりました。 具体的にはcom.apple.root.default-qos (concurrent)で実行されているようです。

func productsRequest(SKProductsRequest, didReceive: SKProductsResponse)
func request(SKRequest, didFailWithError: Error)

したがって今までメインスレッドで実行されることを想定していた実装の場合は注意が必要です。

対応方法

各メソッド内の処理を明示的にメインスレッドで実行するようにすれば大丈夫です。

func productsRequest(SKProductsRequest, didReceive: SKProductsResponse) {
    DispatchQueue.main.async {
        // なんらかの処理
    }
}

func request(SKRequest, didFailWithError: Error) {
    DispatchQueue.main.async {
        // なんらかの処理
    }
}

備考

自分で確かめていませんが、MacOSでの実行時は上記のメソッドは元々メインスレッドで実行されていなかったようです。iOS13以降ではProject CatalystによってiOSアプリとMacOSアプリのソースコードを共通化する仕組みが進められており、そのあたりの影響でこのような挙動の変更が発生したのかもしれません。(定かではないです)

参考

【iOS】アプリクライアント側での文字入力のValidation機構について(UITextField, UITextViewなど)

はじめに

サービスで使用するEmailアドレスやPasswordなどをアプリから入力する際に、アプリ側でValidationをかけることがあります。 そのような際に行う対応として直感的には以下のようにするかと思います。

extension ViewController: UITextFieldDelegate {
    func textFieldDidEndEditing(_ textField: UITextField) {
        if textField === textField {
            let text = textField.text
            
            if text?.isEmpty == true {
                errorLabel.text = "文字を入力してください。"
            } else if text?.contains("@") == false {
                errorLabel.text = "正しいメールアドレスの形式ではありません。"
            }
        }
    }
}

ただ、この方法では以下のような問題が発生します。

  • 同様なバリデーション処理が様々な箇所に分散してしまう。結果として修正漏れなどによる不具合が発生する
  • コードが冗長となり可読性が下がる

本記事では、Compositeパターンを適用することでValidation処理を構造的に作成し、 上記であげた問題に対処する方法を記載します。

環境設定

以下の環境を使用しています。

  • Xcode11.0.0
  • Swift 5.1.0

方法

Validator Protocolの作成

Validation用のエラーとValidation結果およびValidatorが準拠するべきProtocolを定義します。 ValidationResultはSwift.Resultを使用してもよかったのですが、Resultに指定した型Tの処理の部分が 冗長になるかと考えこの方法をとりました。

ValidationのErrorはLocalizedErrorに準拠することで errorDescription: String?を実装すると、 error.localizedDescriptionでエラーメッセージを表示させることができます。

Validatorはvalidate対象を引数とし、結果を返却するメソッドを持つProtocolです。

/// Validation結果
///
/// - valid: 有効
/// - invalid: 無効
enum ValidationResult {
    case valid
    case invalid(ValidationError)
}

/// Validation結果のエラーに使用
/// LocalizedErrorを使用することで .localizedDescriptionでエラーメッセージを表示可能
protocol ValidationErrorProtocol: LocalizedError { }

/// 文字列のValidationに関する責務
protocol Validator {
    func validate(_ value: String) -> ValidationResult
}

Validatorを複数持つCompositeValidator Protocolを作成します。 Composite ValidatorはvalidatorsとしてValidatorの配列を持ち、validateメソッド実行時に それぞれのValidatorのvalidateメソッドを順次実行、その返却値を元にcomposite validatorの validate実行結果を返却します。 UITextFieldのtextに対して適応するValidatorは基本的にComposite Validatorを使用します。

/// 文字列のValidationに関する責務。複数のValidatorを保持する
/// validatorsに格納されている順にValidationをかけていき、
/// 該当したエラーを返却する。
protocol CompositeValidator: Validator {
    var validators: [Validator] { get }
    func validate(_ value: String) -> ValidationResult
}

 extension CompositeValidator {
    
    func validate(_ value: String) -> [ValidationResult] {
        return validators.map { $0.validate(value) }
    }

     func validate(_ value: String) -> ValidationResult {
        let results: [ValidationResult] = validate(value)
        
        let errors = results.filter { result -> Bool in
            switch result {
            case .valid: return false
            case .invalid: return true
            }
        }
        return errors.first ?? .valid
    }
    
}

Validatorの実装の具体例

上記で記載したValidatorプロトコル、 Composite Validatorプロトコルを元に実際の実装をみていきます。 今回は試しに名前の入力値に対するValidatorを検討します。以下を仕様とします。

  • 文字入力必須
  • 1文字以上、8文字以下
  • 英語大文字小文字のみ入力可

それぞれのケースのエラーをまずは作成します。

enum ValidationError: ValidationErrorProtocol {

    case empty
    case length(min: Int, max: Int)
    case nameFormat
    
    var errorDescription: String? {
        switch self {
        case .empty: return "文字を入力してください"
        case .length(let min, let max): return "\(min)文字以上\(max)文字以下で入力してください。"
        case .nameFormat: return "英語大文字小文字のみで入力してください。"
        }
    }
}

次にそれぞれのケースのValidatorを作成します。

文字入力必須
struct EmptyValidator: Validator {
    
    func validate(_ value: String) -> ValidationResult {
        if value.isEmpty == true {
            return .invalid(.empty)
        } else {
            return .valid
        }
    }
}
1文字以上、8文字以下
struct LengthValidator: Validator {
    let min: Int
    let max: Int
    
    func validate(_ value: String) -> ValidationResult {
        if value.count >= min && value.count <= max {
            return .valid
        } else {
            return .invalid(.length(min: min, max: max))
        }
    }
}
英語大文字小文字のみ入力可
struct NameFormatValidator: Validator {
    
    let regExpression = "^[a-zA-Z]+$"
    
    func validate(_ value: String) -> ValidationResult {
        let predicate = NSPredicate(format: "SELF MATCHES %@", regExpression)
        let result = predicate.evaluate(with: value)
        
        switch result {
        case true: return .valid
        case false: return .invalid(.nameFormat)
        }
    }
}

最後に今まで作成したValidatorを組み合わせたCompositeValidatorを作成します。 これで名前のTextFieldの入力値を検査するValidatorが作成できました。

struct NameValidator: CompositeValidator {
    var validators: [Validator] = [
        EmptyValidator(),
        LengthValidator(min: 1, max: 8),
        NameFormatValidator()
    ]
}

実際の使い方を見てみます。以下のようになります。 Validationの具体的な実装はNameValidator内部に閉じ込められ、同様の処理が拡散することを防いでいます。 Validationの内容を変えたい場合はNameValidatorを変更するか、より下層のここのValidatorを変更すれば良いため修正漏れも防げます。 また、今後新しいComposite Validatorを作成したい場合は今まで作成したValidatorを有効に利用することもできます。 if else などが連続してかかれることもないためコードの冗長性もずいぶん改善され可読性が上がりました。

extension ViewController: UITextFieldDelegate {
    
    func textFieldDidEndEditing(_ textField: UITextField) {
        if textField === textField {
            let nameValidator = NameValidator()
            let result: ValidationResult = nameValidator.validate(textField.text ?? "")
            
            switch result {
            case .valid: break
            case .invalid(let error): print(error.localizedDescription)
            }
        }
    }
}

UITextFieldのExtensionとして定義する

先ほどまでのやり方でかなり改善されましたが、さらに簡略化する方法を考えてみます。

一つの方法としてUITextFieldのExtensionメソッドとしてvalidateを作成する方法があります。 Validateの種別(名前、メールアドレス、パスワード)を定義して、その種別に応じたvalidateを実行するメソッドを作成することで実行時の実装はかなり簡略化できます。

extension UITextField {
    
    // Validateの種類を定義する。
    // name, email, passwordなど。
    // パスワードの確認など比較対象がある場合は、
    //case confirmEmail(origin: String)のように比較対象を入れられるようにすると良い
    // validatorにも比較対象を入れられるようにすることで、そのようなValidationも実装可能
    enum ValidateType {
        case name
    }
    
    // UITextFieldのValidationメソッド
    // Validationの結果をUI上に反映させたい場合は反映先のUILabelを引数として渡すとスッキリする(この実装が綺麗かどうかは要検討)
    @discardableResult
    func validate(type: ValidateType, errorMessageOn label: UILabel? = nil) -> ValidationResult {
        
        let result: ValidationResult
        
        // typeごとに使用するValidatorの初期化やvalidateの実行を行う
        switch type {
        case .name:
            result = NameValidator().validate(text ?? "")
        }
        
        // typeによらずErrorLabelへの反映は共通処理のためここで反映させる
        switch result {
        case .valid: label?.text = nil
        case .invalid(let error): label?.text = error.localizedDescription
        }
        
        // Validate結果を返す
        return result
    }
}

上記のExtensionメソッドを使用することで、使用側の実装は下記のようにかなりシンプルになりました。

extension ViewController: UITextFieldDelegate {
    func textFieldDidEndEditing(_ textField: UITextField) {
        if textField === textField {
            textField.validate(type: .name, errorMessageOn: errorLabel)
        }
    }
}

まとめ

ユーザーの文字入力をアプリ側でValidateする際に、それらの仕組みをCompositeパターンを使用して構造的に作成する方法を記載しました。本記事の方法を使用することで、修正漏れが少なく可読性が高い保守しやすいValidationの仕組みを作成することができるかと思います。

参考

iOSDC 2019に参加しました

はじめに

9/5(木) - 9/7(土)に開催されたiOSDC 2019に参加しました。今年も昨年同様に早稲田大学 西早稲田キャンパスで開催されました。 今回は昨年に引き続き2回目の参加です。9/5(木)が前夜祭、9/6(金) - 9/7(土)がDay1 Day2という位置付けで今年はお茶会などの新しい試みもありました。

感想

セッションについて

昨年同様、実務に直結しそうな実践的な内容が多かったように思います。 特に、以下のセッションが個人的に印象的でした。

Swiftにおけるインポートとリンクの仕組みを探る(Kishikawa Katsumiさん)

Swiftにおけるインポートとリンクの仕組みを探る - Speaker Deck

ライブラリのインポートやリンクのエラーはアプリ開発をしているとしばしば遭遇します。 その都度情報を集めるのですが、どれも断片的な情報が多く、またこうしたらこうなった、というような体験談が多くを占めています。

その中で本セッションの内容はインポートとリンクに関して体系立てて説明されており、問題に対処する上での本質的な知識を得られると感じました。 このようなまとまった情報はあまり世の中になく、非常に貴重なものだと考えています。 セッション内では応用編の話は時間の関係上されませんでしたが、資料は公開されていますので時間を見つけて挑戦したいと思います。

詳解 Auto-Renewable Subscription(rocknameさん)

詳解 Auto-Renewable Subscription / Detailed Auto-Renewable Subscription - Speaker Deck

課金の基礎的な話や実際の挙動をシーケンス図を交えて説明してくださり大変参考になりました。 また、Graceful Periodやサーバー間通信など最新の課金周りの情報に加え、polling updateやアプリ側の実践的な実装の話もあり、 Auto-Renewable Subscriptionを実装する上での一通りの知識はこちらのスライドを見ることで把握できるのではないかと思います。

運営の方々について

非常に手際よく準備や対応をされており素晴らしいなと感じました。 day2の最後にハイタッチした場面が印象に残っているのですが、 とても楽しそうに対応されていてこちらも今回参加して本当によかったと感じました。

最後に

カンファレンスに参加するというと、セッションの動画やスライドは後で確認できるし、別に参加する必要はないんじゃないか、という意見も耳にします。 しかしながら、今回のカンファレンスでなんども耳にした双方向のコミュニケーションは実際にその場にいないと行うことはできません。 また、ただ学ぶだけでなく、久しぶりに友人と会うのは嬉しいですし、単純に尖った人々と同じ空間にいるというだけでもモチベーションを刺激され、 とても前向きな気持ちになれます。

今年もとても素敵な時間を過ごすことができました。 参加してとてもよかったです。ありがとうございました。来年も参加できればと思います。楽しみにしています。

【iOS】チュートリアル画面などで使えるUIViewに穴をあける方法

はじめに

チュートリアル画面などで、画面全体を半透明な黒いUIViewでおおい、一部分をハイライトして、なんらかの説明文言を表示するといったことがあるかと思います。 この際に、ハイライトしたい部分にUIImageなどを上から重ねるのも手としてはありますが、その部分について黒いUIViewに穴を開けられると便利です。 本記事では該当部分に穴を開ける方法と開けたい場所を指定する際に便利な方法を記載します。

環境設定

以下の環境を使用しています。

  • Xcode10.3.0
  • Swift 5.0.1

穴を開ける方法

UIViewlayerに対してmaskを使用することで円形の穴を開けます。 詳細な説明はコードに記載いたしましたが、大まかな流れとしては、mask用のlayerを作成し、そこに円形のPathを作成、 穴を開けたいUIViewlayermaskにmask用に作成したlayerを指定することで穴をあけるとなります。 ここでPathとして円の代わりに星型等を使用すれば星型の穴をあける等も可能です。

なお、UIViewControllerviewDidAppear(_:)などで穴をあけるlayerを追加したところ穴が問題なくあきましたので、 UIViewにextensionでメソッドを作成すると便利に使用できて良いと思います。 当然UIViewを継承しているUIButtonなどでも同様に使用可能です。

public extension UIView {
    
    func makeHole(at point: CGPoint, radius: CGFloat) {
        
        let maskLayer = CAShapeLayer()

        // fillはPathの内部を指定の色で塗りつぶします
        // そのためには内部かどうかの判定が必要になりますが、fillRuleはこの判定方法です
        // .evenOddはある点Pから任意の方向へ無限遠点に射線を引きPathとの交差回数を数えます
        // Pathとの交差回数が奇数回の場合は内側、偶数回の場合は外側と判定します
        // 詳細は参考文献を参照ください
        maskLayer.fillRule = .evenOdd
        maskLayer.fillColor = UIColor.black.cgColor
        
        // 画面全体にPathを描きます
        let maskPath = UIBezierPath(rect: self.frame)
        maskPath.move(to: point)
        
        // addArcで弧形を描画します
        // centerとradiusを指定し、 0.0 ~ 2πで描画するため円形となります
        maskPath.addArc(withCenter: point, radius: radius, startAngle: 0.0, endAngle: 2.0 * CGFloat.pi, clockwise: true)
        
        // 上記から円の内側から無限遠まで射線を引くと、円のPathと画面全体の外縁のPathとで2回交わるため
        // 外側に、円の外側から無限遠まで射線を引くと、奇数回Pathと交わるため内側になります
        // すなわち画面全体のうち円以外の部分が黒色に塗りつぶされます
        maskLayer.path = maskPath.cgPath
        
        // 自身のlayerのmaskとして上記で作成したmask用のlayerを指定しました
        // maskの黒色の部分と重複している箇所の色が残るため、
        // 結果として円の内側は色がなくなり、円形の穴があくことになります
        self.layer.mask = maskLayer
    }
    
}

穴を開けたい場所を指定する際の方法

穴をあける場所に関しては、既存のいづれかのUIViewの上に開けたい場合はその場所を指定するのが良さそうです。 ただ、 view.frameで指定した場合の座標は親のUIViewからの相対位置となるため、画面全体に広げたUIViewからの位置を指定する場合は正しい値となりません。 そのため、以下のように画面全体のViewに対する相対座標を求めるのがおすすめです。

    // MARK: IBOutlet
    @IBOutlet private weak var targetView: UIView!
    @IBOutlet private weak var parentView: UIView!

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        let frame = targetView.frame
        print("frame")
        print(frame)
        
        print("parentView frame")
        print(parentView.frame)
        
        print("converted frame")
        let convertedFrame = parentView.convert(targetView.frame, to: self.view)
        print(convertedFrame)
    }

上記でtargetViewが対象のView、parentViewがtargetViewの親Viewです。 上記の実行結果は下記になります。

frame
(20.0, 20.0, 40.0, 40.0)
parentView frame
(107.0, 348.0, 200.0, 200.0)
converted frame
(127.0, 368.0, 40.0, 40.0)

規定のself.viewからの座標が出力されているのがわかります。 この値を使うことで、画面全体を覆った場合等に適切な箇所に穴をあけることが可能になります。

まとめ

チュートリアルのような画面でUIViewに穴を開けたい際には以下のように行うのがおすすめです。

  • maskLayerをつかって穴を開ける
  • 穴の場所を指定するために基準とするViewからの相対位置を指定する(convertPoint:toView:など)

参考

【iOS】UITableViewのtableFooterViewを使用する際のはまりどころ

はじめに

UITableViewを使用する際に繰り返しの要素はsecitoncellを用いて表示することが多いかと思います。 主にこれらがUITableViewのメイン要素となり、各sectionのサマリや補足情報をsectionHeaderViewsectionFooterViewに記載します。 そして、繰り返し要素そのものには直接関係しませんが、繰り返し情報の前提となる条件や全体に対するなんらかの補足情報、 もしくは広告を表示したいとなった場合、UITableView全体のheaderfooterであるtableHeaderView, tableFooterViewを使用することになるかと思います。

ただし、特にtableFooterViewでは独特の癖があり、UIViewControllerchildViewController化しtableFooterViewに追加しようとした際に少々難儀しました。 次回同様のことがあった際に、対応方法を即座に思い出せるよう記事として残します。

環境設定

以下の環境を使用しています。

  • Xcode10.3.0
  • Swift 5.0.1

注意点

footerに設定するView自体のレイアウトではauto layoutを使用しない

tableFooterViewheightを以下のようにautoLayoutを用いて指定すると、tableFooterViewが正常に表示されません。 例えば下記のような書き方をすると、レイアウトが崩れてしまいます。

footerView.translatesAutoresizingMaskIntoConstraints = false
footerView.heightAnchor.constraint(equalToConstant: 50.0)

なお、translatesAutoresizingMaskIntoConstraintsfalseにしていてもconstraintを使用していなければ正常に表示されました。

viewのレイアウトをしてからtableFooterViewに代入する

tableFooterViewに該当のviewを設定してから、footerView内のsubViewのレイアウトを変更しようとすると変更が正常に反映されません。例えば下記のコードのようにtableFooterViewsubviewとしてfooterInnerViewを追加して、footerInnerView.center = footerView.centerしてからtableFooterViewに設定した場合は、正しく中央に配置されますが、tableFooterViewに設定してから先ほどのレイアウトを行うと中央に正しく配置されません。

        footerView.backgroundColor = .black
        footerView.frame.size.height = footerHeight
        footerView.frame.size.width = UIScreen.main.bounds.width
        
        let footerInnerView = UIView(frame: CGRect(x: 0, y: 0, width: 100.0, height: 40.0))
        footerInnerView.backgroundColor = .yellow
        footerView.addSubview(footerInnerView)
        footerInnerView.center = footerView.center
        tableView.tableFooterView = footerView

f:id:Iganin:20190819160808p:plain
レイアウトしてからtableFooterViewとして設定

f:id:Iganin:20190819160838p:plain
tableFooterViewに設定してからレイアウト

ChildViewControllerをfooterViewに設定する際の注意点

child化したUIViewControllertableFooterViewに追加する際にも、同様に追加~レイアウトを行う順番に気をつけないとレイアウト崩れが発生するため注意が必要です。以下のコードではレイアウトがうまくいきますが、tableView.tableFooterView = footerViewの後にviewController.view.frame = footerView.frameを実行するとレイアウトが崩れます。

        // ChildViewController
        viewController.view.backgroundColor = .blue
        addChild(viewController)
        footerView.addSubview(viewController.view)
        viewController.didMove(toParent: self)
        viewController.view.frame = footerView.frame
        
        tableView.tableFooterView = footerView

また、レイアウトを以下のようにautoLayoutを用いて実現しようとするとレイアウトが崩れるため注意が必要です。

        // ChildViewController
        viewController.view.backgroundColor = .blue
        addChild(viewController)
        footerView.addSubview(viewController.view)
        viewController.didMove(toParent: self)
        
        viewController.view.translatesAutoresizingMaskIntoConstraints = false
        viewController.view.topAnchor.constraint(equalTo: footerView.topAnchor)
        viewController.view.leadingAnchor.constraint(equalTo: footerView.leadingAnchor)
        viewController.view.trailingAnchor.constraint(equalTo: footerView.trailingAnchor)
        viewController.view.bottomAnchor.constraint(equalTo: footerView.bottomAnchor)
        
        tableView.tableFooterView = footerView

UIViewControllerchild化は同じ手続きが多いため、以下のようなメソッドを用意している場合があると思います。 この場合、autoLayoutを使用してレイアウトを決定しているためレイアウトが崩れてしまいます。 UIViewControllerchildViewControllerとして追加した際のレイアウトがうまくいかなかった際は原因の一つとして疑ってみてください。

extension UIViewController {
    func addChildViewController(_ child: UIViewController, on containerView: UIView) {
        self.addChild(child)
        containerView.addSubview(child.view)
        child.didMove(toParent: self)
        child.view.translatesAutoresizingMaskIntoConstraints = false
        child.view.topAnchor.constraint(equalTo: containerView.topAnchor)
        child.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor)
        child.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor)
        child.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
    }
}

まとめ

tableFooterViewを使用する際には以下2点に気をつける必要があります。

  1. footerに設定するView自体のレイアウトではauto layoutを使用しない
  2. tableFooterViewにViewを追加する前にレイアウトを済ませておく

参考

【iOS】サーバーからのリモート通知を実施するために必要なアプリ側対応の概略

はじめに

Push通知はサービスユーザーに情報を送る上でとても有効な手段です。最近では、Firebase の Cloud Messagingの登場により、キャンペーンのプッシュや固定文言の定期PushはFirebase側で取り扱うことができるようになりましたが、ユーザー個別に動的な内容を送りたい(ユーザー個人に特化したレコメンドなど)場合などは、それだけでは十分ではないことが多く、APNsを介したリモートPush通知の仕組みはいまだに重要であり続けています。

本記事では自身の理解の整理もかねて、Push通知を送るために必要な設定を箇条書きレベルで記載していきます。

環境設定

以下の環境を使用しています。

  • Xcode10.2.1
  • Swift 5.0.1

証明書/Keys

Establishing a Certificate-Based Connection to APNs

Push通知を送ることを可能にするために専用の証明書を作成する必要があります。本番用と開発用があるため、それぞれ作成し、サーバー側に配置します。Firebase Cloud Messagingを使用する場合もこの証明書は必要であり、 Projectの設定画面からアップロードすることになります。

Establishing a Token-Based Connection to APNs

今から開発を始める場合はPush Notification Authentication keyを使用するという選択肢もあります。 JWT(JSON Web Token) をベースとして接続を形成します。証明書は1年に1回更新が必要ですが、Keysの場合は更新が必要なく、運用がより簡易的になります。

アプリ側でPush通知を行うための設定をする

アプリ側でリモートPush通知を行うため設定をします。対応内容は大まかに分けて以下です。

  • Apple Developer CenterのApp側でRemote Push Notificationの使用設定
  • プリプロジェクト側でRemote Push通知の使用設定

Apple Developer CenterのApp側でRemote Push Notificationの使用設定

developer center上でApp ID Configurationを変更します。

  1. Certificates, Identifiers & Profilesに移動
  2. 該当アプリのidentifier表示
  3. Capabilitiesの中のRemote Push Notificationにチェック

プリプロジェクト側でRemote Push通知の使用設定

Registering Your App with APNs

アプリのProject側でCapabilityを変更します。詳細な内容は上記リンクのEnable the Push Notifications Capabilityに記載があります。

  1. プロジェクトのアプリ Targetを選択
  2. Capabilitiesの中のPush Notificationsをチェック(Push起因でアプリを起動し、なんらかの処理を行いたい場合は、Background Modeの中のRemote Notificationsにもチェックを入れます)
  3. entitlementのPush Notificationsの設定を追加(ASP Environment)

3について、entitlementがまだない場合にはdevelopment環境の設定のentitlementが生成されます。 Remote Push Notificationsの設定には開発環境設定(development)、本番環境設定(production)があるため、 本番環境用のEntitlementと開発環境用のEntitlementを分けて生成することをお勧めします。

検証、本番用それぞれ作成後は、Build Settings > Code Signing EntitlementにてBuild ConfigurationsごとにEntitlementを分けます。

実装

Asking Permission to Use Notifications

アプリの実行時にPush通知の設定を行います。主な対応内容は以下です。

  1. Notificationsの利用許諾
  2. APNsにDeviceTokenの登録
  3. 自身のNotifications Provider ServerにDeviceTokenを登録

大まかな流れは以下の様になります。

  1. requestAuthorization(options:completionHandler:)を実施
  2. CompletionHandler内でerrorとgrantedで条件分岐を実施、 error == nil, granted == trueの場合に3を実施
  3. registerForRemoteNotifications()でDeviceTokenをAPNsに登録
  4. 3の成功、失敗がAppDelegateに通知される
    1. 成功 application(_:didRegisterForRemoteNotificationsWithDeviceToken:)
    2. 失敗 application(_:didFailToRegisterForRemoteNotificationsWithError:)
  5. 4にて成功した場合に自身のサービスのPush通知発行サーバーに4で渡されたDeviceTokenを送信

AppleのドキュメントにNever cache device tokens in local storage.と記載がありますので、 Device Tokenをローカルに保存し、それを使用し続けるのは避けた方が良さそうです。

またrequestAuthorizationは初回実行時にのみダイアログが表示されます。 通知が許諾されているか否かはサービス上とても大きな意味を持ちますので、 ユーザー体験を考慮しながら初回のrequestAuthorizationが適切なタイミングで実行されるよう 体験設計を慎重に行う必要があります。

まとめ

以上でリモートPush通知を実行するためのアプリ側対応は完了です。まとめますと下記のようになります。

  • 証明書/Keys作成(サーバーへの登録は別途行う必要があります)
  • アプリの設定(Identifier・プロジェクト双方のCapabilities設定、Entitlement登録)
  • 実装(許諾確認、DeviceTokenのAPNs・サーバー双方への登録)

参考

体験設計について

動画

APNsおよび証明書、認証Keyについて

アプリ側設定について

サーバー側設定について

【iOS】UINavigationControllerでcompletionHandlerを伴ったpush/pop遷移ができるようにする

はじめに

画面のモーダル遷移を実現するUIViewControllerpresent(viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)?)dismiss(animated: Bool, completion: (() -> Void)?) では completionがあり画面遷移完了後の処理を記述することができます。

一方でUINavigationControllerpushViewController(viewController: UIViewController, animated: Bool)popViewController(animated: Bool)ではcompletionがなくデフォルトでは、 画面遷移完了後の処理を記述することができません。本記事ではUINavigationControllerでもdismissメソッドと同様に 画面遷移完了後の処理を記述する方法を記載します。

環境設定

以下の環境を使用しています。

  • Xcode10.2.1
  • Swift 5.0.1

方法

UINavigationControllerに以下のメソッドを追加します。

transitionCoordinatorViewControllerの遷移に関するアニメーションを規定するProtocolで、 遷移実行時にUIViewControllertransitionCoordinatorプロパティに格納されます。

transitionCoordinatoranimate(alongsideTransition:, completion:)メソッドのcompletiontransitionの完了後に呼ばれるため、ここに実行したいメソッドを入れれば画面遷移完了後に メソッドを呼ぶという目的を達成できます。

// TransitionCoordinatorはViewControllerのtransitionに関するアニメーションを規定するProtocolです
// transitionCoordinatorはactiveなtransitionやpresentation/dismissalが実行されている時に
// viewControllerのtransitionCoordinatorに含まれ、transitionが完了したタイミングで解放されます
extension UINavigationController {
    
    func popViewController(animated: Bool, completion: @escaping (() -> Void)) {
        popViewController(animated: animated)        
        if animated, let coordinator = transitionCoordinator {
            coordinator.animate(alongsideTransition: nil) { _ in
                // coordinatorで実行するanimationの完了時にcompletionが実行されます
                // 本メソッドにおいてcontextが同じanimationはpopViewControllerのため
                // pop完了後に本メソッドが実行されます
                completion()
            }
        } else {
            completion()
        }
    }
    
    func pushViewController(_ viewController: UIViewController, animated: Bool, completion: @escaping (() -> Void)) {
        pushViewController(viewController, animated: animated)
        if animated, let coordinator = transitionCoordinator {
            coordinator.animate(alongsideTransition: nil) { _ in
                // coordinatorで実行するanimationの完了時にcompletionが実行されます
                // 本メソッドにおいてcontextが同じanimationはpushViewControllerのため
                // push完了後に本メソッドが実行されます
                completion()
            }
        } else {
            completion()
        }
    }
}

ログの出力

以下のメソッドでログ出力を行い呼ばれる順番を確認しました。

// 呼び出し元
viewWillDisappear
viewDidDisappear
viewWillAppear
viewDidAppear

// 呼び出し先
viewWillDisappear
viewDidDisappear
viewWillAppear
viewDidAppear

// Completion
// UIViewController present および dismiss
// UINavigationController push および pop

結果は以下のようになりました。Aが呼び出し元のViewController、Bが遷移先のViewControllerです。 なおanimatedはtrueであってもfalseであっても同様の結果になりました。

// UIViewControllerのpresentおよびdismiss
A viewWillDisappear
B viewWillAppear
B viewDidAppear
A viewDidDisappear
present completion
B viewWillDisappear
A viewWillAppear
A viewDidAppear
B viewDidDisappear
dismiss completion

// 本記事でのUINavigationControllerのpushおよびpop
A viewWillDisappear
B viewWillAppear
A viewDidDisappear
B viewDidAppear
push completion
B viewWillDisappear
A viewWillAppear
B viewDidDisappear
A viewDidAppear
pop completion

modalでの遷移かpushでの遷移かの違いにより、viewWillAppearやviewDidAppearなどライフサイクル部分の順序に違いはありますが、 completionの呼ばれるタイミングは遷移元および遷移先のライフサイクルが全て完了した後であり、 presentメソッドやdismissメソッドのcompletionで実行しようとしていることをするには問題ないであろうことがわかります。

なお、補足ですが、たまにみられる以下の書き方ですと、completionのタイミングがvewDidDisappearやveiwDidAppearの前となってしまい、 presentやdismissとタイミングが大きく異なるため意図した挙動にならない可能性が高いです。

extension UINavigationController {
    
    func popViewController(animated: Bool, completion: (() -> Void)? = nil) {
        CATransaction.begin()
        CATransaction.setCompletionBlock(completion)
        popViewController(animated: animated)
        CATransaction.commit()
    }
    
    func pushViewController(_ viewController: UIViewController, animated: Bool, completion: (() -> Void)? = nil) {
        CATransaction.begin()
        CATransaction.setCompletionBlock(completion)
        pushViewController(viewController, animated: animated)
        CATransaction.commit()
    }
}

// ログの結果
A viewWillDisappear
B viewWillAppear
push completion
A viewDidDisappear
B viewDidAppear
B viewWillDisappear
A viewWillAppear
pop completion
B viewDidDisappear
A viewDidAppear

参考