ObjecTips

Swift & Objective-C で iOS とか macOS とか

AVSpeechSynthesizer の delegate メソッド willSpeakRangeOfSpeechString でクラッシュする危険性

AVSpeechSynthesizer の delegate メソッド speechSynthesizer(_:willSpeakRangeOfSpeechString:utterance:) は音声読み上げの際にこれから読み上げる箇所を NSRange で知らせてくれる。
このメソッドを使って読み上げテキスト全体のうち今どこが読まれているのかを知る事ができ、ハイライト表示などにも利用する事ができる。

NSRangelocation にはテキストの開始位置、length には長さが入る。
読み上げ中のテキスト全体は AVSpeechUtterance から speechString で取得可能。これから読み上げる部分テキスト取得の具体的な実装は以下

NSRange をそのまま使うパターン、Swift.StringNSString にキャストする

    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) {
        let substring = (utterance.speechString as NSString).substring(with: characterRange)
        print(substring)
    }

もしくは NSRangeRange<String.Index> に変換して Swift.String をそのまま使うパターン

    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) {
        if let range = Range<String.Index>(characterRange, in: utterance.speechString) {
            let substring = utterance.speechString[range]
            print(substring)
        }
    }

クラッシュの危険性

当該の delegate メソッドは日本語での読み上げではかなり不具合があり、短い文章ならともかく通常の文章では正しい読み上げ位置をレポートしてくれる事はほぼ無く、読み上げ位置が間違っている、スキップする、停止するといった問題がある。*1
また特定の日本語テキストの並び、組み合わせによっては NSRange に不正な値が入ってきてしまいその値を使う事でアプリがクラッシュしてしまうケースがある事が最近分かった。ちなみに問題が確認された日本語テキストは iOS 13.2 では不正な値が入っている現象が解消されており*2、バージョンを遡って確認したところ iOS 12 から iOS 13.1.3 で問題が確認された。

不正な値は NSRangelength-1 になっている。
本来 NSRange は適切な範囲を取得できない場合は locationNSNotFoundlength には 0 が入るはず。(これについてドキュメントソースはみつからず。NSString のAPIの場合は明示的に書かれていたりする https://developer.apple.com/documentation/foundation/nsstring/1416849-range

NSRange のドキュメントによれば

NSRange - Foundation | Apple Developer Documentation

location

The start index (0 is the first, as in C arrays).

length

The number of items in the range (can be 0).

と書かれており、length0 を取り得るものの、マイナス値については言及が無く NSNotFound の様な定数も存在していない。
この length-1 が入った NSRange をそのまま先の substring 取得の実装で使用すると
NSRangeException が発生してアプリはクラッシュしてしまう。

不正な NSRange の原因

NSRangeException のメッセージには Range {7, 18446744073709551615} out of bounds; と書かれていた。
NSRangelocation length 共に Int 型であり、最大値は Int.max = NSInteger.max9223372036854775807
NSNotFound もドキュメントによれば同じ値になる。

NSNotFound is now formally defined as NSIntegerMax

NSNotFound - Foundation | Apple Developer Documentation

しかし実際に Exception で表示されている値は 18446744073709551615 これは NSUIntegerMax の値になっている。
つまり NSRangeInt 型に UInt.max が入ってきてしまっている。
Swift なら型の範囲外の値が入ってきた時点で Exception が起きて処理が止まるはず。なぜこういう事態が起きているのか。

NSRange の Swift と Objective-C でのヘッダでの定義を見てみるとヒントがあった。

Swift

public struct _NSRange {

    public var location: Int

    public var length: Int

    public init()

    public init(location: Int, length: Int)
}

Objective-C

typedef struct _NSRange {
    NSUInteger location;
    NSUInteger length;
} NSRange;

これまでの Objective-C の世界で NSUInteger で動いていたものを Swift の世界では Int で扱っている。型が変わってしまっている!なぜこんな事に。

NSRange のドキュメントを見ると

length
The number of items in the range (can be 0). For type compatibility with the rest of the system, LONG_MAX is the maximum value you should use for length.

location
The start index (0 is the first, as in C arrays). For type compatibility with the rest of the system, LONG_MAX is the maximum value you should use for location.

NSRange - Foundation | Apple Developer Documentation

この様に最大値は LONG_MAX == 9223372036854775807
つまり 64bit の IntergerMax である(にすべき)と記述されている。
それ以上の数値を扱う想定がされていない。そこで Swift への移行を契機に(定義のシンプルさも考慮して)Int 型に変更になったのだと思われる。

NSRangelocation length の Objective-C Swift それぞれの型と想定される値の取り得る範囲は以下になる。

min max
Objective-C Type 0 18446744073709551615
Usage 0 9223372036854775807
Swift Type -9223372036854775808 9223372036854775807

9223372036854775807 == NSNotFound == Int64.max
18446744073709551615 == UInt64.max

回り道になったけど本題に戻って、問題の原因は NSRange仕様上の最大値が Int64.max であるにも関わらず型としての最大値 UInt64.max が指定されてしまう事があるという点になる。
おそらく AVFoundation の Objective-C 側の処理で何かしらの例外が発生した際に NSRangeNSUInteger length;-1 を入れてオーバーフローで NSUIntegerMax になってしまったか、直接 NSUIntegerMax を入れてしまったか、そんなところだろうと推測される。*3

対策

NSRange の取り得る範囲をチェックして有効な時のみ処理を行う様にする。*4

型としての値の範囲チェックのついでに NSNotFound でない事もチェックしておいてもいいかも知れない。
簡便に以下の様に NSNotFound の場合も処理を行わない様に実装する事もできる。

    if 0 <= characterRange.length, characterRange.length < NSNotFound {
        let substring = (utterance.speechString as NSString).substring(with: characterRange)
        print(substring)
    }


他の実装方法として NSRange の有効性をチェックする extension を書く事もできる。個人的には純粋な値の有効性のチェックは公式ドキュメントの仕様ベースで実装し、 NSNotFound かどうかのチェックは extension には含めない様にして extension の外でエラー処理を分けた方がいい気がする。

extension の実装

extension NSRange {
    var isValid: Bool {
        if 0 <= location, location <= LONG_MAX,
            0 <= length, length <= LONG_MAX {
            return true
        }
        return false
    }
}

extension の利用

        guard characterRange.isValid else {
            print("invalid range")
            return
        }
        guard characterRange.location != NSNotFound, characterRange.length != NSNotFound else {
            print("location or length are not found")
            return
        }
        let substring = (utterance.speechString as NSString).substring(with: characterRange)
        print(substring)

以上

*1:AVFoundation API だけでなくシステムの読み上げ箇所ハイライト機能(アクセシビリティ>読み上げコンテンツ>内容を強調表示)も同じ問題を抱えている

*2:とは言っても正しい読み上げ位置のレポートはされないまま

*3:もっと下の階層のC関数でエラー -1 を返してそれがそのまま Objective-C の階層まで上がってきた説もある

*4:API側の渡す値が常に正しいのであればこの様な処理はいらないけど、現実にはそうではないので止む無くガード処理という感じ

Xcode 11.2 + Storyboard UITextView でクラッシュ

Xcode 11.2 で Storyboard か xib で UITextView を使っていると iOS 13.2 より前のバージョンでクラッシュするという凶悪なバグが発生している。
実際にクラッシュを起こしてみると以下の様に _UITextLayoutView ってクラスは無いとエラーメッセージが表示された。

*** Terminating app due to uncaught exception 'NSInvalidUnarchiveOperationException', reason: 'Could not instantiate class named _UITextLayoutView because no class named _UITextLayoutView was found; the class needs to be defined in source code or linked in from a library (ensure the class is part of the correct target)'

Stack Overflow

stackoverflow.com

Apple Developer Forums

forums.developer.apple.com

これらの書き込みをまとめると

  • 影響範囲は現行最新版の iOS 13.2 より前の全てのバージョン
  • 発生条件は Storyboard か xib で UITextView を使っている事
  • 回避策1 UITextView を全てコードで生成する
  • 回避策2 Xcode 11.1 を使う

回避策については Apple の人も同様に言っている https://forums.developer.apple.com/thread/125287#391939

対策

UITextView の Storyboard 生成をコード生成に置き換えるかどうかはプロジェクト次第(コスト次第)だと思う。
もしソースの公開されていない外部ライブラリを使っている場合、中でどういう実装をしているのか分からないので Xcode 11.1 に戻した方が無難そう。
何らかの事情で Xcode 11.2 を使い続ける場合、配布ビルドのみ Xcode 11.1 で行うのもアリかと。


余談

Developer Forums に _UITextLayoutView を動的に追加すれば良いなんてコメントもあった。 https://forums.developer.apple.com/thread/125287#391979

なるほどと思い実際に試してみたところ以下の Objective-C 実装を application(_:didFinishLaunchingWithOptions:) のタイミングで実行する事で iOS 12 でもクラッシュを回避する事ができた。
がしかしコメントにもある様に、実際にこの実装でリリースするのは如何なものかいう感じなので次のバージョンの Xcode が出るまでの開発中の一時凌ぎとして使うならといったところ。興味が湧いて実装しただけなので参考までに。*1

@interface UITextViewWorkaround : NSObject
+ (void)executeWorkaround;
@end

@implementation UITextViewWorkaround

+ (void)executeWorkaround {
    if (@available(iOS 13.2, *)) {
    }
    else {
        const char *className = "_UITextLayoutView";
        Class cls = objc_getClass(className);
        if (cls == nil) {
            cls = objc_allocateClassPair([UIView class], className, 0);
            objc_registerClassPair(cls);
#if DEBUG
            printf("added %s dynamically\n", className);
#endif
        }
    }
}

@end

_UITextLayoutViewsuperClassUIView である事は以下の記事の方法で確認した。

koze.hatenablog.jp

以上

追記

iOS と tvOS については Xcode 11.2.1 GM で修正された。
https://developer.apple.com/documentation/xcode_release_notes/xcode_11_2_1_gm_seed_release_notes/

*1:この実装を掲載した後まるっと StackOverflow に転載されていてびっくり

複数の AVSpeechSynthesizer の同時再生 iOS 13

最近 iOS 13 でバグも含め挙動が変わった点で色々と苦労している。そんな中良い改善点もあった。タイトルの複数の AVSpeechSynthesizer の同時再生がその1つ。

これまでは AVSpeechSynthesizer の同時再生はできなかった。できないと一口に言っても妙な挙動をするのでこの機会にまとめて整理しておく。

検証のためのコード

以下の様なソースコードでポチポチボタンをタップして色々なパターンで動作を確認してみた。

import UIKit
import AVFoundation

class ViewController: UIViewController {
    
    let synthesizerA = AVSpeechSynthesizer()
    let synthesizerB = AVSpeechSynthesizer()
    let synthesizerC = AVSpeechSynthesizer()

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


    @IBAction func speak1() {
        let utterance = AVSpeechUtterance(string: "1, 1, 1")
        utterance.rate = 0.2
        synthesizerA.speak(utterance)
    }

    @IBAction func speak2() {
        let utterance = AVSpeechUtterance(string: "2, 2, 2")
        utterance.rate = 0.2
        synthesizerA.speak(utterance)
    }

    @IBAction func speak3() {
        let utterance = AVSpeechUtterance(string: "3, 3, 3")
        utterance.rate = 0.2
        synthesizerB.speak(utterance)
    }

    @IBAction func speak4() {
        let utterance = AVSpeechUtterance(string: "4, 4, 4")
        utterance.rate = 0.2
        synthesizerB.speak(utterance)
    }

    @IBAction func speak5() {
        let utterance = AVSpeechUtterance(string: "5, 5, 5")
        utterance.rate = 0.2
        synthesizerC.speak(utterance)
    }

    @IBAction func speak6() {
        let utterance = AVSpeechUtterance(string: "6, 6, 6")
        utterance.rate = 0.2
        synthesizerC.speak(utterance)
    }

    @IBAction func speakAll() {
        speak1()
        speak2()
        speak3()
        speak4()
        speak5()
        speak6()
    }
}

検証結果

記号の意味は以下とする
| は再生開始点
- は生成中
> は再生終了点

iOS 7

未確認

iOS 8~12

Bの開始時にAが再生中であればBは開始されない。

A  |----->
B      |   

Aの再生中にBを2度開始すると、2回目のBは順番待ちにスタックされ再生中のAの終わりにBの2つめが再生される。また2つ目のBのスタックはCによって打ち消されない。

A  |---------->
B      |
B        |     ----->

上記のケースでAの再生中にCを2度開始してもスタックされたBの2つ目が再生される。

A  |---------->
B      |
B        |     ----->
C          |
C            |

Aの再生中にBを2度開始してさらにAを開始するとAの終わりにはAの2つ目が再生される。
再生中のAへのスタックは他のスタックを打ち消す。

A  |---------->
B      |
B        |
A          |   ----->   
iOS 13

同時再生可能!
AとBは完全にパラレルでインスタンス毎に動作する。
音声再生中の次の音声再生開始はインスタンス毎にスタックされ他には影響しない。

A |------->
B |----------->
A       |  -------->
B       |      --------->

別の視点での利点

以下の様に実装した再生の一時停止と再生再開のコードを使って検証

    @IBAction func pauseOrResumeA() {
        if synthesizerA.isSpeaking {
            if synthesizerA.isPaused {
                synthesizerA.continueSpeaking()
            }
            else {
                synthesizerA.pauseSpeaking(at: .immediate)
            }
        }
    }

先ほどの図式に / を一時停止と再生再開として追加する。

iOS 12

これまでは同時再生ができなかったので、Aを再生後、一時停止をしてBを2回再生開始した場合でも、Aの再生再開と再生完了までBは永久に再生が行われないという問題(仕様)があった。

Aが一時停止中(isSpeaking && isPaused)だとBは永久に再生されない

A  |---------/
B      |
B        |

Aが再生再開(continueSpeaking)して再生終了(!isSpeaking)するとようやくBの再生が始まる。一時停止と再生再開の挙動を除くと最初のケースで説明した状況と同じ。

A  |---------/     /--->
B      |
B        |              ----->

実際の実装ケースでは画面別に AVSpeechSynthesizer を使っている場合、一時停止のまま別の画面に移動してしまうと他の画面で一切 AVSpeechSynthesizer 動作しなくなるという問題がある。このため画面から移動する際には強制的に AVSpeechSynthesizer を停止する必要がある。
さらにまた元の画面に戻った際に続きから再生を再開したいのであれば一時停止した位置を保持しておき、そこから新たに再生を開始するという工夫も必要。

iOS 13

同時再生が可能なので、Aが一時停止中だろうがなかろうがBは関係なく再生できる。

A  |---------/     /--->
B      |------------>

実装への影響としては複数の AVSpeechSynthesizer のインスタンスの同時再生が可能になったので、画面毎(もしくはView単位、Class単位)で使っている AVSpeechSynthesizer が再生中か、一時停止状態か、停止状態かを気にする必要がなくなった。
それぞれ別個に一時停止とその地点からの再生再開が可能。
他のインスタンスとの協調動作を気にかける必要が無く実装の切り出しや細分化もしやすくなる。

iOS 13 で AVSpeechSynthesisVoice.currentLanguageCode() の挙動が変更に(多分バグ)

Xcode 11, iOS 12.2, iOS 13.1, iOS 13.1.3 で動作確認


デフォルトの読み上げ音声の言語の確認

AVSpeechSynthesisVoice.currentLanguageCode() 

参考に読み上げ確認の実装
AVSpeechSynthesisVoice を設定せずデフォルト状態での読み上げ)

        let synthesize = AVSpeechSynthesizer()
        let utterance = AVSpeechUtterance(string: "1 2 3")
        // utterance.voice = AVSpeechSynthesisVoice()
        synthesize.speak(utterance)

iOS 12

デバイスの言語設定の優先順序に基づいて LanguageCode が返される。
英語にしか対応していない未ローカライズのアプリでもデバイスの言語設定が 日本語(日本) になっていれば AVSpeechSynthesisVoice.currentLanguageCode()ja-JP を返しデフォルトで日本語で読み上げが行われる。

iOS 13

デバイスの言語設定ではなくアプリの表示言語が反映される様に挙動が変わっている。
例えば英語にしか対応していない未ローカライズではデバイスの言語設定が 日本語(日本) になっていても AVSpeechSynthesisVoice.currentLanguageCode()en-US を返す様になってしまっており、デバイスの設定言語で読み上げされないという現象が発生してしまっている。


日本語設定のデバイスが手元にあれば以下で簡単に挙動を確認できる

  • Xcode のテンプレートからアプリを作成して(ローカライズなど何もせず) AVSpeechSynthesisVoice.currentLanguageCode() を出力する。
  • iOS 12 では ja-JP、iOS 13 では en-US になる。
  • iOS 12 では AVSpeechSynthesizer はデフォルトでデバイス設定の日本語で読み上げする。
  • iOS 13 ではデバイス設定が日本語でもアプリが日本語ローカライズされていないと AVSpeechSynthesizer はデフォルトで英語で読み上げする。
iOS 12 iOS 13
Settings>General>Language & Region>iPhone Language 日本語(日本) 日本語(日本)
Locale.preferredLanguages.first ja-JP ja-JP
AVSpeechSynthesisVoice.currentLanguageCode() ja-JP en-US

f:id:Koze:20191022205827p:plain:w400f:id:Koze:20191022205841p:plain:w400

仕様変更かバグか

公式ドキュメント
currentLanguageCode() - AVSpeechSynthesisVoice | Apple Developer Documentation

Return Value
An NSString object containing the BCP 47 language and locale code for the user’s current locale.

Discussion
This code reflects the user’s language and region preferences as selected in the Settings app.

設定アプリで選択した言語と地域が反映されると書いてある。
よって iOS 13 での挙動は意図しないものなので多分バグ。*1*2

Workaround

iOS 13 では iOS 12 の処理を真似た独自実装で currentLanguageCode を生成して返す様に試みる。

Workaround for that AVSpeechSynthesisVoice current ...

実装内容1

AVSpeechSynthesisVoice の言語コードについては Objective-C ヘッダに以下のコメントの記述がある

Use a BCP-47 language tag to specify the desired language and region.

システムの優先言語設定を取得できる Locale.preferredLanguages の値は zh-Hant-HK の形式でこの場合

  • zhlanguageCode
  • HantscriptCode
  • HKregionCode

となる。
AVSpeechSynthesisVoice.speechVoices() で取得できる利用可能な音声の language の形式は zh-HK となっており scriptCode は存在しない。
このためわざわざ preferredLanguages から一度 Locale を作成して languageCoderegionCode を取り出して再構築するという手順を踏んでいる。

実装内容2

オリジナルの currentLanguageCode の挙動を確認したところ、speechVoices のリストに存在しない言語と地域がデバイスに設定されている時は en-US を返す様になっていたのでそこを踏襲しつつ、現在のデバイス設定の言語コードと地域コードから AVSpeechSynthesisVoice 用の言語コードを作成する様にしている。

例えば uk ウクライナ語、vi ベトナム語などOSで選択可能な言語で且つ読み上げ不可能な言語(speechVoices のリストに存在していない言語)を選択した場合、currentLanguageCodeen-US を返す。
また ja-US など地域が不一致のパターンにおいても en-US を返す。*3


macOS

追試で macOS でも調べてみたところ、Catalina では

  • languageCode はアプリの言語
  • regionCode はシステム環境設定の地域設定

となる様で日本語環境では AVSpeechSynthesisVoice.currentLanguageCodeen-JP を返した。
iOS の様に利用可能な音声リストが無い場合は en-US を返す様な処理が入っていないっぽい。
そして en-JP で読み上げ可能な言語が無いのでデフォルト設定のままでは読み上げされないという現象が起きていた。
よって en-US なり ja-JP なりの利用可能な AVSpeechSynthesisVoice を自分で設定してやる必要がある。*4

ちなみに Mojave ではそもそも AVSpeechSynthesizer AVSpeechSynthesisVoice AVSpeechUtterance あたりは import できてコンパイルもできるけど一切機能しないという問題があるらしいので挙動の検証以前の問題だった。
Mojave では旧来の NSSpeechSynthesizer を使うのが正解っぽい。

更新

Workaround を修正

Workaround for that AVSpeechSynthesisVoice current ...

Localeidentifier には regionCode が含まれていない場合があるので対応した。
具体的には en-US ja-JP 以外に en ja のケースがある。

また、前述のデバイス言語から読み上げ音声への変換の例外(zh-Hans から zh-CN といったケース)に対応するために、一旦 AVSpeechSynthesisVoice を作成して取得される language を戻り値として使用する様に変更した。

*1:仕様が正しくてドキュメントが更新されていないという事も有り得るっちゃ有り得るけど

*2:バグレポートしといた

*3:iOS 12の挙動を確認してみると厳密にはこの処理に当てはまらないケースもある。例えばデバイス設定で簡体字 Locale zh-Hans-US を指定すると currentLanguageCode は zh-CN 返す、ただし繁体字 zh-Hant-US では en-US を返すなどフォールバックの有無の例外が存在する。

*4:Apple 社内で macOS の AVSpeechSynthesizer 周りはあまり検証されていないのではないか、、

iOS 13 で EventKitUI に追加された UIFont のカテゴリメソッド(多分Private用)

タイトルの通り iOS 13 で EventKitUI に UIFont のカテゴリメソッドが追加された。

@property(nonatomic, readonly, class) UIFont *ek_defaultOccurrenceSecondaryTextFont;
+ (UIFont *)ek_defaultOccurrencePrimaryTextFontForSizeClass:(UIUserInterfaceSizeClass)sizeClass;
+ (UIFont *)ek_defaultOccurrenceSmallPrimaryTextFontForSizeClass:(UIUserInterfaceSizeClass)sizeClass;

EventKit の prefix ek がついている。
Objective-C の場合、名前の衝突を防ぐためにカテゴリメソッドには prefix とアンダーバーを付けるのが通例。
この時点で既に Public API っぽくない匂いがしてくる。

公式ドキュメントは存在している

使ってみる

このカテゴリメソッド、Swift から直接は読み込めずに利用する事ができない。
その理由は上記のメソッドが EventKitUI/UIFont+EKDayOccurrenceView.h に定義されているにも関わらず framework のヘッダや modulemap でこのヘッダを読み込む様に指定されていないため。

/*
 *  EventKitUI.h
 *  EventKitUI
 *
 *  Copyright 2010 Apple Inc. All rights reserved.
 *
 */

#import <EventKit/EventKit.h>

#import <EventKitUI/EventKitUIDefines.h>
#import <EventKitUI/EKEventEditViewController.h>
#import <EventKitUI/EKEventViewController.h>
#import <EventKitUI/EKCalendarChooser.h>

Objective-C から利用するには以下の様に直接ファイルヘッダを指定して読み込む。
Swift から利用したい場合は <Module>-Bridging-Header.h で以下を指定する。

#import <EventKitUI/UIFont+EKDayOccurrenceView.h>
使った結果

ログ出力の実装

    NSLog(@"%@", UIFont.ek_defaultOccurrenceSecondaryTextFont);
    NSLog(@"%@", [UIFont ek_defaultOccurrencePrimaryTextFontForSizeClass:UIUserInterfaceSizeClassCompact]);
    NSLog(@"%@", [UIFont ek_defaultOccurrencePrimaryTextFontForSizeClass:UIUserInterfaceSizeClassRegular]);
    NSLog(@"%@", [UIFont ek_defaultOccurrencePrimaryTextFontForSizeClass:UIUserInterfaceSizeClassUnspecified]);
    NSLog(@"%@", [UIFont ek_defaultOccurrenceSmallPrimaryTextFontForSizeClass:UIUserInterfaceSizeClassCompact]);
    NSLog(@"%@", [UIFont ek_defaultOccurrenceSmallPrimaryTextFontForSizeClass:UIUserInterfaceSizeClassRegular]);
    NSLog(@"%@", [UIFont ek_defaultOccurrenceSmallPrimaryTextFontForSizeClass:UIUserInterfaceSizeClassUnspecified]);

ログの結果

    <UICTFont: 0x7f9d01403320> font-family: "UICTFontTextStyleCaption2"; font-weight: normal; font-style: normal; font-size: 11.00pt
    <UICTFont: 0x7f9d01404960> font-family: ".SFUI-Semibold"; font-weight: bold; font-style: normal; font-size: 13.00pt
    <UICTFont: 0x7f9d01404960> font-family: ".SFUI-Semibold"; font-weight: bold; font-style: normal; font-size: 13.00pt
    <UICTFont: 0x7f9d01404960> font-family: ".SFUI-Semibold"; font-weight: bold; font-style: normal; font-size: 13.00pt
    <UICTFont: 0x7f9d01207540> font-family: ".SFUI-Semibold"; font-weight: bold; font-style: normal; font-size: 11.00pt
    <UICTFont: 0x7f9d01404960> font-family: ".SFUI-Semibold"; font-weight: bold; font-style: normal; font-size: 13.00pt
    <UICTFont: 0x7f9d01207540> font-family: ".SFUI-Semibold"; font-weight: bold; font-style: normal; font-size: 11.00pt

実際の見た目

f:id:Koze:20191015172736p:plain

メソッド名から察するに EventKitUI でのイベントの繰り返し設定周りで使われているフォントだと思われるけど、実際にUIのどこに使われているのかは分からなかった。
Private で使っているAPIのヘッダが Public ヘッダとして出されちゃってる感があるので小ネタというか参考程度に。Apple Feedback Assistant にレポートすれば多分将来いなくなると思う。*1 *2


ちなみに UIFont+EKDayOccurrenceView.h の5行目のファイルテンプレート部分に Created by でファイル作成者のアカウント名と日付が載っている。
通常の Apple 謹製 framework の Public Header にこの項目は載っていない事からも間違って Public に出てきている事が推測される。

This API seems to be private use. You can directly ...

追記

iOS 13.4 Beta 1 で deprecated に修正されました。

//
//  UIFont+EKDayOccurrenceView.h
//  EventKit
//
//  Copyright 2019 Apple Inc. All rights reserved.
//

#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

@interface UIFont (EKDayOccurrenceView)

@property (nonatomic, readonly, class) UIFont *ek_defaultOccurrenceSecondaryTextFont
API_DEPRECATED("No replacement. This is not intended for third party use and will return nil.", ios(13.0, 13.4))
API_UNAVAILABLE(macos, macCatalyst, watchos);

+ (UIFont *)ek_defaultOccurrencePrimaryTextFontForSizeClass:(UIUserInterfaceSizeClass)sizeClass
API_DEPRECATED("No replacement. This is not intended for third party use and will return nil.", ios(13.0, 13.4))
API_UNAVAILABLE(macos, macCatalyst, watchos);

+ (UIFont *)ek_defaultOccurrenceSmallPrimaryTextFontForSizeClass:(UIUserInterfaceSizeClass)sizeClass
API_DEPRECATED("No replacement. This is not intended for third party use and will return nil.", ios(13.0, 13.4))
API_UNAVAILABLE(macos, macCatalyst, watchos);

@end

NS_ASSUME_NONNULL_END

*1:レポートしておいた

*2:iOS 13.4 Beta 1 で deprecated に修正されました

prepareForSegue での segue の identifier による分岐を Swift enum で管理する

まず初めに以下の様なString型の enum を用意する。

    enum SegueIdentifier: String {
        case a
        case b
    }

Swift 5 以前

UIViewControllerprepareForSegue メソッド内で segue.identifier から enum SegueIdentifier の作成を行う。
segue.identifier の型は String? なので以下の様にguard文(もしくはif let文)で unwrap する必要がある。

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        guard let identifier = segue.identifier,
            let segueIdentifier = SegueIdentifier(rawValue: identifier) else {
                return
        }
        switch segueIdentifier {
        case .a:
            // do something
            break
        case .b:
            // do something
            break
        }
    }

Swift 5.1

Swift 5.1 ではSwitch文に optional の enum が利用できる様になったのでguard文を一部簡略化して以下の様に書ける。
SegueIdentifier(rawValue: identifier)nil の場合に対応するために case .none: が必要になる。

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        guard let identifier = segue.identifier else {
            return
        }
        switch SegueIdentifier(rawValue: identifier) {
        case .a:
            // do something
            break
        case .b:
            // do something
            break
        case .none:
            break
        }
    }

以下の様に case .none: の代わりに default: でもコンパイルは通るけど、その場合 enum の定義が増えた際に新しい enum の値は default にマッチしてしまうためコンパイラによる警告が出ない。.none を使っている場合は新しい enum の値が記述されておらず case が網羅されていない事をコンパイラが警告してくれるため .none を使う方が良いと思われる。

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        guard let identifier = segue.identifier else {
            return
        }
        switch SegueIdentifier(rawValue: identifier) {
        case .a:
            // do something
            break
        case .b:
            // do something
            break
        default:
            break
        }
    }

Swift 5.1 + extension

Switch文が optional に対応した事で enum が作成出来ない時(rawValue の値が不正で初期化に失敗した時)はどうせ .none に引っかかるしわざわざnullチェックのguard文を書かなきゃいけないのは面倒だな、、という気持ちが出てきた。

  • segue.identifier の型が String ではなく String?
  • enum Foo: String = protocol RawRepresentable のイニシャライザの引数が optional を許容しない
init?(rawValue: Self.RawValue)

この2点がguard文を必須にしているため、以下の様な extension を書いて RawRepresentable に optional な引数を許容するイニシャライザを追加してみる。

gist38ded67adef6a706b05bbaeed3c1c9ff

その結果以下の様に記述できる様になる。

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        // boilerplate has gone!
        switch SegueIdentifier(rawValue: segue.identifier) {
        case .a:
            // do something
            break
        case .b:
            // do something
            break
        case .none:
            break
        }
    }

extension を1つ記述するだけで全ての同様の箇所のguard文(もしくはif let文)を消去する事ができる。
ボイラープレートを減らす事ができてスッキリ。

iOS 13 で UIView の beginAnimations 等のアニメーション関連のメソッドが deprecated

タイトルの通り、iOS 13 で以下のメソッドが depecated になった。

UIView - UIKit | Apple Developer Documentation

Animating Views

class func beginAnimations(String?, context: UnsafeMutableRawPointer?)
class func commitAnimations()
class func setAnimationStart(Date)
class func setAnimationsEnabled(Bool)
class func setAnimationDelegate(Any?)
class func setAnimationWillStart(Selector?)
class func setAnimationDidStop(Selector?)
class func setAnimationDuration(TimeInterval)
class func setAnimationDelay(TimeInterval)
class func setAnimationCurve(UIView.AnimationCurve)
class func setAnimationRepeatCount(Float)
class func setAnimationRepeatAutoreverses(Bool)
class func setAnimationBeginsFromCurrentState(Bool)
class func setAnimationTransition(UIView.AnimationTransition, for: UIView, cache: Bool)

代替が無くて困るという訳ではないけど既存コードの置き換えが必要になってくる。

beginAnimations, commitAnimations の置き換え

従来は begincommit の間に処理を書けば良いだけだったのが、新パターン*1では Xcode での警告 Use the block-based animation API instead の通りブロックベースのAPIを使う必要があり、また animatedtruefalse の時で呼び出すメソッドも変わってくるため一度処理をブロックでまとめてそれぞれのメソッドの引数に渡す必要がある。

    func setShowsSomething(_ shows: Bool, animated: Bool) {
        let block = {
            // do something
        }
        if animated {
            UIView.animate(withDuration: CATransaction.animationDuration(),
                           animations: block)
        }
        else {
            UIView.performWithoutAnimation(block)
        }
    }

また、別のアニメーション処理からこのメソッドが呼び出された際にも正しく指定した時間でアニメーション処理を行って欲しい場合は overrideInheritedDuration: UIView.AnimationOptions オプションを使うと良い。

    func setShowsSomething(_ shows: Bool, animated: Bool) {
        let block = {
            // do something
        }
        if animated {
            UIView.animate(withDuration: CATransaction.animationDuration(),
                           delay: 0,
                           options: [.overrideInheritedDuration],
                           animations: block)
        }
        else {
            UIView.performWithoutAnimation(block)
        }
    }

逆にこのオプションを指定しない場合は別アニメーションで指定されたアニメーション時間が継承されてアニメーションが発生する事になる。

    UIView.animate(withDuration: 1.0) {
        self.view1.backgroundColor = .red
        UIView.animate(withDuration: 0.2) { // 0.2 は無視されて1.0で動作する
            self.view2.backgroundColor = .red
        }
    }

継承と上書きのどちらが良いかはケースバイケースだろうけど継承されるのがデフォルトの挙動というのは一応気にした方がいいと思う。

Duration に 0 を指定すればどうか?

animatedfalse の時はアニメーション時間を0にすれば挙動は同じでは?と思うかも知れない(この実装例はまま見る)。
この場合条件分岐は三項演算子で animated ? CATransaction.animationDuration() : 0 とシンプルに書けるしブロックの変数化も不要で直接記述出来る。

    func setShowsSomething(_ shows: Bool, animated: Bool) {
        UIView.animate(withDuration: animated ? CATransaction.animationDuration() : 0,
                       animations: {
                        // do something
        })
    }

1つ気を付けなければならないのはアニメーション時間の継承の問題で、例えば上記のメソッドを以下の様に呼んだとすると*2 animated: false にも関わらず実際には1.0秒のアニメーションが発生してしまう。

UIView.animate(withDuration: 1.0) {
    setShowsSomething(true, animated: false) // 1.0秒でアニメーション動作
}

上記の挙動を避けるには animated false の際には performWithoutAnimation を使うか、 withDuration: 0 を指定する場合は以下の様にやはり overrideInheritedDuration: UIView.AnimationOptions オプションを使って実装する必要がある。

        UIView.animate(withDuration: animated ? CATransaction.animationDuration() : 0,
                       delay: 0,
                       options: [.overrideInheritedDuration],
                       animations: {
                        // do something
        })

余談 performWithoutAnimation と animate:withDuration: 0 の挙動の違い

performWithoutAnimation animate:withDuration: 0 は挙動が違う点があって、ブロック処理内でさらに呼び出された他の処理のアニメーションが有効か否かが変わってくる。 performWithoutAnimation が間に挟まるとそれ以下のネストではアニメーションが発生しなくなる。

アニメーションしない

UIView.animate(withDuration: 1.0) {
    UIView.performWithoutAnimation {
        UIView.animate(withDuration: 0.2) {
            // without animation
        }
    }
}

1.0秒でアニメーション

UIView.animate(withDuration: 1.0) {
    UIView.animate(withDuration: 0) {
        UIView.animate(withDuration: 0.2) {
            // animate with 1.0 sec
        }
    }
}

performWithoutAnimation を挟むと overrideInheritedDuration があろうともアニメーションしない

UIView.animate(withDuration: 1.0) {
    UIView.performWithoutAnimation {
        UIView.animate(withDuration: 0.2,
                       delay: 0,
                       options: [.overrideInheritedDuration],
                       animations: {
                        // without animation
        })
    }
}

0.2秒でアニメーション

UIView.animate(withDuration: 1.0) {
    UIView.animate(withDuration: 0) {
        UIView.animate(withDuration: 0.2,
                       delay: 0,
                       options: [.overrideInheritedDuration],
                       animations: {
                        // animate with 0.2 sec
        })
    }
}

余談 performWithoutAnimation と animate:withDuration: 0 のパフォーマンスの違い

以下のコードでそれぞれを10,000回呼び出してみたところおおよそ以下の様な結果に。
やはりアニメーション無し指定の方がパフォーマンスが良いが現実的なケースでは無いのであくまで参考程度に。

iOS 13, iPhone XS iOS 13, iPad Pro 11インチ
performWithoutAnimation 0.06-0.07sec 0.064-0.074sec
animate:withDuration: 0 2.74sec 2.74sec
    @IBAction func test1() {
        let start = CFAbsoluteTimeGetCurrent()
        let view = self.view
        for _ in 0..<10000 {
            UIView.performWithoutAnimation {
                view?.alpha = CGFloat.random(in: 0..<1)
            }
        }
        let end = CFAbsoluteTimeGetCurrent()
        print(#function, end - start)
    }

    @IBAction func test2() {
        let start = CFAbsoluteTimeGetCurrent()
        let view = self.view
        for _ in 0..<10000 {
            UIView.animate(withDuration: 0) {
                view?.alpha = CGFloat.random(in: 0..<1)
            }
        }
        let end = CFAbsoluteTimeGetCurrent()
        print(#function, end - start)
    }

結論

アニメーション無しの際にネスト以下もアニメーションを無しにするのであれば animated false では performWithoutAnimation を使う。
ネストとパフォーマンスをケアする必要が無い場合は三項演算子+ブロックの直接記述が楽。
overrideInheritedDuration オプションはケースバイケースで。

最後に duration について

アニメーションのデフォルト値の取得には CATransaction.animationDuration() を使ったがこのメソッドの実際の返り値は 0.25 になる。
しかし UIView.beginAnimations() で処理を行った際に UIView.inheritedAnimationDuration でデフォルトのアニメーション時間を確認してみるとこちらでは 0.2 が取得されるので*3、厳密に挙動を揃えたいのであれば CATransaction.animationDuration() は使わずに 0.2 を指定する必要がある。

print(CATransaction.animationDuration()) // 0.25

UIView.beginAnimations(nil, context: nil)
print(UIView.inheritedAnimationDuration)  // 0.2
UIView.commitAnimations()

*1:とは言えiOS 4.0以降のメソッドなので全く新しくは無い

*2:こんな意味不明なコード自体書くなという話は置いといて、メソッドの利用者が自分の書いたコードをどう使うかまでは制御出来ないのでメソッド側でどこまでケアすべきかという観点で、、と長々と釈明

*3:何年か前にデフォルトアニメーションの時間を調べた時に0.25だった気がするけどデフォルト値が変わったのか記憶違いなのか