ObjecTips

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

複数の 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だった気がするけどデフォルト値が変わったのか記憶違いなのか

iOS 13 beta 3 で追加された Core Image API

前回までの変更点

koze.hatenablog.jp

今回は iOS 13 beta 3 で変更点をチェック。
追加フィルタは1つで、他若干の追加APIが見られた。

https://gist.github.com/Koze/86315376f00fa44662f0d841f046e5d2/revisions

iOS 13 beta 3

CIRoundedRectangleGenerator

まず追加フィルタ

roundedRectangleGenerator() - CIFilter | Apple Developer Documentation

試して見たところ UIBezierPathinit(roundedRect:cornerRadius:) と同じで rect と radius とを設定して、シェイプの fill color を設定するだけのシンプルなものだった。

transformed(by:highQualityDownsample:)

こちらは追加API

transformed(by:highQualityDownsample:) - CIImage | Apple Developer Documentation

新たに追加されたAPIだけど iOS 10 まで遡って利用可能。ドキュメントは記載が無いがヘッダにコメントが載っていた。

// specifying true or false here will override the context's kCIContextHighQualityDownsample setting.

これまでは CIContext でハイクオリティのダウンサンプリングを利用するかどうかを設定可能だったが、今回のオプションではコンテキスト全体ではなくてスケール処理の一部でハイクオリティを利用するかどうかをコントロール出来る様になりそう。

kCIInputEnableEDRModeKey

追加のオプションキー

kCIInputEnableEDRModeKey - Core Image | Apple Developer Documentation

こちらもまだドキュメントは無いがヘッダにはよれば以下

/** NSNumber (BOOL) : Allows the output to have an Extended Dynamic Range with values greater than 1 possible */

Extended Dynamic Range を有効にするオプションキーらしい。

@available(iOS 12.0, *)
public let kCIInputEnableEDRModeKey: String

現在は String 型になっていて上記の様に宣言されているが、CIRAWFilter 内で宣言されており併記されている他のキーは全て CIRAWFilterOption 型になっているので、このキーも今後のアップデートで CIRAWFilterOption に属する様になるかも知れない。

iOS 13 で追加された Core Image の CIFilter

iOS 13 で何か追加の CIFilter はあったかな?と思って diff を取ってみたら面白かった。
使った出力はワンライナーでこれ

CIFilter.filterNames(inCategory: nil).forEach{print($0)}

CIFilter Changes iOS 12 - iOS 13
https://gist.github.com/86315376f00fa44662f0d841f046e5d2/revisions

iOS 13 beta 1

CIDocumentEnhancer
// CICategoryColorEffect
public protocol CIDocumentEnhancer : CIFilterProtocol {
    @available(iOS 5.0, *)
    var inputImage: CIImage? { get set }
    var amount: Float { get set }
}

f:id:Koze:20190623235940p:plain

CIKMeans

No Header!

CIMorphologyRectangleMaximum
// CICategoryBlur
public protocol CIMorphologyRectangleMaximum : CIFilterProtocol {
    @available(iOS 5.0, *)
    var inputImage: CIImage? { get set }
    var width: Float { get set }
    var height: Float { get set }
}

f:id:Koze:20190624000259p:plain

CIMorphologyRectangleMinimum
// CICategoryBlur
public protocol CIMorphologyRectangleMinimum : CIFilterProtocol {
    @available(iOS 5.0, *)
    var inputImage: CIImage? { get set }
    var width: Float { get set }
    var height: Float { get set }
}

f:id:Koze:20190624000623p:plain

CIPaletteCentroid
// CICategoryColorEffect
public protocol CIPaletteCentroid : CIFilterProtocol {
    @available(iOS 5.0, *)
    var inputImage: CIImage? { get set }
    @available(iOS 5.0, *)
    var paletteImage: CIImage? { get set }
    var perceptual: Bool { get set }
}
CIPalettize
// CICategoryColorEffect
public protocol CIPalettize : CIFilterProtocol {
    @available(iOS 5.0, *)
    var inputImage: CIImage? { get set }
    @available(iOS 5.0, *)
    var paletteImage: CIImage? { get set }
    var perceptual: Bool { get set }
}

iOS 13 beta 2

CIGaborGradients
// CICategoryStylize
public protocol CIGaborGradients : CIFilterProtocol {
    @available(iOS 5.0, *)
    var inputImage: CIImage? { get set }
}

f:id:Koze:20190624002803p:plain

CIKeystoneCorrectionCombined
// CICategoryGeometryAdjustment
public protocol CIKeystoneCorrectionCombined : CIFilterProtocol {
    @available(iOS 5.0, *)
    var inputImage: CIImage? { get set }
    var focalLength: Float { get set }
    var topLeft: CGPoint { get set }
    var topRight: CGPoint { get set }
    var bottomRight: CGPoint { get set }
    var bottomLeft: CGPoint { get set }
}
CIKeystoneCorrectionHorizontal
// CICategoryGeometryAdjustment
public protocol CIKeystoneCorrectionHorizontal : CIFilterProtocol {
    @available(iOS 5.0, *)
    var inputImage: CIImage? { get set }
    var focalLength: Float { get set }
    var topLeft: CGPoint { get set }
    var topRight: CGPoint { get set }
    var bottomRight: CGPoint { get set }
    var bottomLeft: CGPoint { get set }
}
CIKeystoneCorrectionVertical
// CICategoryGeometryAdjustment
public protocol CIKeystoneCorrectionVertical : CIFilterProtocol {
    @available(iOS 5.0, *)
    var inputImage: CIImage? { get set }
    var focalLength: Float { get set }
    var topLeft: CGPoint { get set }
    var topRight: CGPoint { get set }
    var bottomRight: CGPoint { get set }
    var bottomLeft: CGPoint { get set }
}
CIPerspectiveRotate
// CICategoryGeometryAdjustment
public protocol CIPerspectiveRotate : CIFilterProtocol {
    @available(iOS 5.0, *)
    var inputImage: CIImage? { get set }
    var focalLength: Float { get set }
    var pitch: Float { get set }
    var yaw: Float { get set }
    var roll: Float { get set }
}

念の為 diff を取ってみたら beta 1 から beta 2 で追加フィルタがあった!
いずれもドキュメントは存在しないので名前と入力パラメータから機能を推測するしかない。
なのに CIKMeans に至ってはオンラインドキュメントにも Xcode 内にも一切記述が無いという隠れフィルタ!楽しい。

CIFIlter をインスタンス化して取れるプロパティから入出力を調べてみたところ、おそらく以下の様なプロトコル定義になると思う。

CIKMeans
// CICategoryReduction
public protocol CIKMeans : CIFilterProtocol {
    @available(iOS 5.0, *)
    var inputImage: CIImage? { get set }
    var extent: CGRect { get set }
    var means: CIImage? { get set }
    var count: Int { get set }
    var passes: Int { get set }
    var perceptual: Bool { get set }
}

ググってみたところ KMeans ってのは機械学習に出てくる k-means の事だと思われるが CIKMeans フィルタをどう使うかはまだ未調査で不明。
ちなみに CIPaletteCentroidCIPalettize のフィルタの attributes を見ると paletteImage の入力にはこの CIKMeans で得た画像を使用するらしい。

CIGaborGradients についてもググってみたところ機械学習で使われる画像処理 Gabor Filter というものらしい。

CIDocumentEnhancer は名前と効果からドキュメントスキャナ時のフィルタ処理だろうと推測。

CIMorphologyRectangleMinimum CIMorphologyRectangleMaximum については iOS 11 で CIMorphologyMinimum CIMorphologyMaximum CIMorphologyGradient という3つのフィルタが登場しており、WWDC 2017 でこれらのフィルタは depth のマスクを処理するのに良いと紹介されていた。今回追加された2つのフィルタは入力パラメータは少し違うものの用途としては同じ様なものではないかと思われる。

Advances in Core Image: Filters, Metal, Vision, and More - WWDC 2017 - Videos - Apple Developer

とりあえず beta 2 ではこの様な感じで、また今後の beta でも何かしらのフィルタの増減があるかも知れないので余裕があればチェックしてみるかも。