スマート反転モード(Smart Invert)への対応について

iOS 11からスマート反転モードが搭載されました。アプリ側で対応させるには、反転させたくない写真や映像などのViewに対してaccessibilityIgnoresInvertColorsを設定すれば良いのですが、UISliderだけはデフォルトで反転しないようです。

UISliderの内部のsubviewに対してaccessibilityIgnoresInvertColors=NOを設定しても反転してくれません。取り合えず、つまみはそのままで、minimumとmaximumのtrackImage(or tintColor)を入れ替えると反転しても少しマシに見えます。暫定措置

// Subclass of UISlider
if (UIAccessibilityIsInvertColorsEnabled()) {
    UIImage *minImage = [self minimumTrackImageForState:UIControlStateNormal];
    UIImage *maxImage = [self maximumTrackImageForState:UIControlStateNormal];
    [self setMinimumTrackImage:maxImage forState:UIControlStateNormal];
    [self setMaximumTrackImage:minImage forState:UIControlStateNormal];
}

High volume warning

スライダーは様々な使い方ができますが、ボリューム用途に限っては、どうもヨーロッパでは音量を大きくした場合に右側を黄色点滅にして、聴覚への悪影響を警告しなければいけない決まりがあるそうです。黄色が指定されていて反転させられないようです。日本独自のカメラのシャッター音みたいなもんでしょうか。アップルのエンジニアは大変だわ。

意外?NSTimerの正しい使い方

iOSでタイマーを使う時、強い参照にしてる方って結構多いんじゃないでしょうか?私もそうだったんですが、先日タイマー関連を見直していて弱参照が使える事に気付きました。

自動的にcurrentRunLoop(for DefaultRunLoopMode)に追加されるscheduledTimerは、すでに強参照で保持されているので、弱参照にしておけばinvalidateで取り除かれた時に即解放となり、nil代入が不要になります。

@property (nonatomic, weak) NSTimer *timer;

- (void)startTimer {
    if (self.timer == nil) {
        self.timer = [NSTimer scheduledTimerWithTimeInterval:60 target:self selector:@selector(update) userInfo:nil repeats:YES];
    }
}

- (void)stopTimer {
    if (self.timer != nil) {
        [self.timer invalidate];
    }
}

Appleのサンプルでもweakを使っていましたが、assignを指定している方もおられますね。

Swift 3.0なら更に短く書けます。

private weak var timer: Timer?

func startTimer() {
    if timer == nil {
        timer = Timer.scheduledTimer(timeInterval: 60, target: self, selector: #selector(update), userInfo: nil, repeats: true)
    }
}

func stopTimer() {
    timer?.invalidate()
}

余談

NSTimerに似ているCADisplayLinkはどうなのか検証したところ、実機とシミュレータで異なった結果になりました。

@property (nonatomic, weak) CADisplayLink *link;

self.link = [CADisplayLink displayLinkWithTarget:self selector:@selector(update)];

// 実機 iOS 10.0
// self.link is retained

// シミュレータ Xcode 8.3.3
// self.link is null

CADisplayLinkはaddToRunLoopしてから使うでしょうから、strongにしておいて自力で解放する方が良さそうです。

@property (nonatomic) CADisplayLink *link;

- (void)startDisplayLink {
    if (self.link == nil) {
        self.link = [CADisplayLink displayLinkWithTarget:self selector:@selector(update)];
        [self.link addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
    }
}

- (void)stopDisplayLink {
    if (self.link != nil) {
        [self.link invalidate];
        self.link = nil;
    }
}

超簡単!テーブルのセルの長押しでアクションシートを出す

テーブルに長押しジェスチャーを追加する場合、編集モードでは並べ替えのドラッグと干渉してしまうので工夫が必要です。

もし、長押しで指の追跡やリリースを検出しなくて良い(何らかのアクションを起こすだけ)なら前の記事で書いたMenuControllerのメソッドを拝借すれば簡単です。

- (BOOL)tableView:(UITableView *)tableView shouldShowMenuForRowAtIndexPath:(NSIndexPath *)indexPath {
    // ここではジェスチャーが効いているのでハイライトが消せない
    // reloadして強制リセット
    // Cancel gestures during tracking
    [tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
    
    UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"title" message:@"message" preferredStyle:UIAlertControllerStyleActionSheet];
    if (alertController.popoverPresentationController != nil) {
        alertController.title = nil;
        alertController.message = nil;
        alertController.popoverPresentationController.sourceView = self.view;
        alertController.popoverPresentationController.sourceRect = [tableView rectForRowAtIndexPath:indexPath];
    }
    [alertController addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
    [self presentViewController:alertController animated:YES completion:nil];
    return YES;
}

- (BOOL)tableView:(UITableView *)tableView canPerformAction:(SEL)action forRowAtIndexPath:(NSIndexPath *)indexPath withSender:(id)sender {
    return NO;
}

- (void)tableView:(UITableView *)tableView performAction:(SEL)action forRowAtIndexPath:(NSIndexPath *)indexPath withSender:(id)sender {
}

そもそもメニューは表示させてないので、UIMenuControllerWillShowMenuなどの通知は一切飛んできません。

ちなみに、この長押しジェスチャーはセル内のcontentViewにセットされています。iOS 10にて確認。

テーブルのセルに削除やカスタムのメニューを表示させる

テーブルにはUIMenuControllerのメニューを表示させるデリゲートが用意されていますが、項目をカスタマイズしたり、内蔵のDeleteを出したい時にはCellのサブクラスに一手間加える必要があります。しかも押した時のアクションはそのCell内のメソッドで受けるようになっているので、VC側で記述できるように工夫しておきます。

@implementation MyCell

- (BOOL)canBecomeFirstResponder {
    return YES;
}

- (BOOL)canPerformAction:(SEL)action withSender:(id)sender {
    if (action == @selector(delete:)) {
        return YES;
    }
    return NO;
}

- (void)performSelector:(SEL)aSelector sender:(id)sender {
    UIView *superview = self;
    while ((superview = superview.superview) != nil) {
        if ([superview isKindOfClass:[UITableView class]]) {
            UITableView *tableView = (id)superview;
            NSIndexPath *indexPath = [tableView indexPathForCell:self];
            if (indexPath != nil) {
                [tableView.delegate tableView:tableView performAction:aSelector forRowAtIndexPath:indexPath withSender:sender];
            }
            break;
        }
    }
}

- (void)delete:(id)sender {
    [self performSelector:_cmd sender:sender];
}

@end

セルからsuperviewを遡ってテーブルを探します。セル側でこうしておくと、Deleteが押された時の処理はテーブルのdelegateに渡されるので、実際の処理はVC側で実装できます。

- (BOOL)tableView:(UITableView *)tableView shouldShowMenuForRowAtIndexPath:(NSIndexPath *)indexPath {
    return YES;
}

- (BOOL)tableView:(UITableView *)tableView canPerformAction:(SEL)action forRowAtIndexPath:(NSIndexPath *)indexPath withSender:(id)sender {
    return YES;
}

- (void)tableView:(UITableView *)tableView performAction:(SEL)action forRowAtIndexPath:(NSIndexPath *)indexPath withSender:(id)sender {
    if (action == sel_registerName("delete:")) {
        // 削除処理
    }
}

iPhone Plusの横向き起動について

iPhone Plusではアプリを横向きで起動できます。ところが、独自の回転処理を挟んでいる場合でも起動時には呼ばれません。これらのメソッドはあくまでも回転を検知した場合だけ呼ばれるようです。

- (void)willTransitionToTraitCollection:(UITraitCollection *)newCollection withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator

- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator

起動時も画面の向きに応じた処理をしたい場合にはviewDidLoadで呼んでおきましょう。

- (void)viewDidLoad
{
    [super viewDidLoad];
    [self willTransitionToTraitCollection:self.traitCollection withTransitionCoordinator:self.transitionCoordinator];
}

viewWillAppearならナビゲーションからプッシュされた時だけ呼ぶ事も出来ます。

- (void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];
    if ([self isMovingToParentViewController]) {
        [self willTransitionToTraitCollection:self.traitCollection withTransitionCoordinator:self.transitionCoordinator];
    }
}

起動時以外は問題なく回転を検知して呼んでくれます。

向きを変えて元の画面に戻った時の元の画面の挙動。
willTransitionToTraitCollection => viewWillAppear => viewDidAppear

閉じた状態で向きを変えて再アクティブにした時。
willTransitionToTraitCollection => applicationWillEnterForeground => applicationDidBecomeActive

iOS 11にて確認

NSStringオブジェクトの==比較について

// テスト1
NSString *a = [NSString stringWithString:@"STR"];
NSString *b = [NSString stringWithFormat:@"%@", @"STR"];
NSLog(@"aとbは%@", a == b ? @"同じ" : @"違う");

// テスト2
NSString *a = [NSString stringWithString:@"STR"];
NSString *b = @"STR";
NSLog(@"aとbは%@", a == b ? @"同じ" : @"違う");

テスト1は同じ、テスト2は違う
CFGetRetainCountでリファレンスカウントしてみると、テスト2のbは開放不要になってますね。

NSUserDefaultsの罠

アプリの設定や状態を記録しておくのに便利なNSUserDefaultsですが、必ず保存できるとは限りません。そもそもiOSには処理が集中した時に、アラームや着信などの重要な処理を優先させる仕組みになっているため、synchronizeに失敗する場合があります。

と言っても、さほど問題ではありませんが、以下のような場合には注意が必要です。

[[NSUserDefaults standardUserDefaults] setObject:currentArray forKey:@"ListArray"];
[[NSUserDefaults standardUserDefaults] setInteger:selectedIndex forKey:@"ListSelectedIndex"];
[[NSUserDefaults standardUserDefaults] synchronize];

これは閲覧していたリストの「配列」と「何番目が選択されていたかのインデックス」を保存するコードですが、この内の片方だけしか保存されない稀なケースがあります。こうしてset~で複数のデータを記録する場合、配列とそのインデックスの様に相関関係がある場合には、使う際にチェックしないとNSRangeExceptionでクラッシュするかもしれません。

こういった場合、配列とインデックスを1つの配列にまとめてから保存すれば、synchronizeの失敗、イコール、配列とインデックスの両方の保存失敗となって整合性が保てるかもしれません(未確認)。NSUserDefaultsは手軽な反面、データベースの機能が少ないので、重要なデータを扱う場合は、トランザクション機構を備えた別の方法にした方が良い場合もあります。最近だとCore Data以外にもAndroidでも使えるRealmも人気です。

最近はXcodeやiTunes Connectからアプリのクラッシュ情報が見やすくなっていますので、起動時のクラッシュが頻発している場合は、こういったバックアップ処理がネックになっているかもしれません。

ナビゲーションの戻るボタンのタイトルを消して矢印だけにする

戻るボタンのタイトルを消して左矢印「<」だけにするにはどうすれば良いか?実は前の画面と繋がっているため、前の画面で空のnavigationItemを割り当てておかなくてはなりません。

// 遷移元VC
- (void)viewDidLoad {
    [super viewDidLoad];
    self.navigationItem.backBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"" style:UIBarButtonItemStylePlain target:nil action:nil];
}

こうして空ボタンを作ってから遷移すると戻るボタンのタイトルが消えるんですが、遷移元が複数ある場合、そのすべてに割り当てないといけません。

タイトルを消したいVC側だけで実現するには、遷移先で遷移元を取得して空ボタンを割り当て、戻る際に解放すると上手くいきます。iOS8, 9, 10, 11で確認済み。

※iOS 11になってから空文字を割り当てなくても[UIBarButtonItem new];でOKになりました。

// 遷移先VC
- (void)hideBackBarButtonTitle {
    if ([self isMovingToParentViewController]) {
        UIViewController *previousViewController = self.navigationController.viewControllers[self.navigationController.viewControllers.count - 2];
        previousViewController.navigationItem.backBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"" style:UIBarButtonItemStylePlain target:nil action:nil];
    }
}

- (void)showBackBarButtonTitle {
    if ([self isMovingFromParentViewController]) {
        self.navigationController.topViewController.navigationItem.backBarButtonItem = nil;
    }
}

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    [self hideBackBarButtonTitle];
}

- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    [self showBackBarButtonTitle];
}

自前で矢印の画像を割り当てた場合もタイトルを消せますが、左端からのエッジスワイプが無効にされます。

NSKeyedUnarchiverを使った正しい復元方法

NSKeyedArchiverで圧縮(シリアライズ)したコードは復元できない場合があるらしく、例外処理を挟んでいないとクラッシュするかもしれません。Appleのドキュメント「NSKeyedUnarchiver」のunarchiveObjectWithDataには注意書きがあります。

Discussion
This method raises an NSInvalidArchiveOperationException if data is not a valid archive.

と、言う事はアーカイブした後で復元できるかチェックしておけば良いとも考えたんですが、どうやら、iOSやアプリのアップデートにより、復元できない場合がある模様。アーカイブのチェックは、復元の時だけ例外処理を挟んでおくのが良さそうです。

NSData *archiveData = [[NSUserDefaults standardUserDefaults] objectForKey:@"ArchiveKey"];
if (archiveData != nil) {
    NSArray *array;
    @try {
        array = [NSKeyedUnarchiver unarchiveObjectWithData:archiveData];
    }
    @catch (NSException *exception) {
        [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"ArchiveKey"];
        [[NSUserDefaults standardUserDefaults] synchronize];
    }
}

例えば、こんな感じでユーザーデフォルトにアーカイブDataを保存していて、例外をキャッチした時には、ゴミDataを取り除いておきます。

Appleのドキュメント「Archives and Serializations Programming Guide」のArchiveに例外についての詳しい説明があります。型が一致しない場合に例外が発生するようです。

Type Coercions
NSKeyedUnarchiver supports limited type coercion. A value encoded as any type of integer, be it a standard int or an explicit 32-bit or 64-bit integer, can be decoded using any of the integer decode methods. Likewise, a value encoded as a float or double can be decoded as either a float or a double value. When decoding a double value as a float, though, the decoded value loses precision. If an encoded value is too large to fit within the coerced decoded type, the decoding method throws an NSRangeException. Further, when trying to coerce a value to an incompatible type, such as decoding an int as a float, the decoding method throws an NSInvalidUnarchiveOperationException.

UIViewControllerの経路を判別する

とあるViewControllerをモーダルでもナビゲーションでも使いたい時に、その経路を判別したいことがあります。例えば、再表示の時だけ再描画したいとか・・・。

表示する時

viewDidLoad、view~LayoutSubviewsでは残念ながら使えません。
view~Appearで判別できます。

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    if ([self isBeingPresented]) {
        // presentViewControllerされました。
    } else if ([self isMovingToParentViewController]) {
        // pushViewControllerされました。
    }
}

戻る・閉じる時

view~Disappearで判別できます。

- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    if ([self isBeingDismissed]) {
        // dismissViewControllerされました。
    } else if ([self isMovingFromParentViewController]) {
        // popViewControllerされました。
    }
}

注意

isBeingがモーダル、isMovingがナビゲーション、となっています。
ただし、ナビゲーションコントローラ自体をpresentしてモーダル表示した場合は、[self.navigationController isBeing~];
にしないと、該当VCからでは判別できないので注意してください。

以上をふまえて判別メソッドをつくる

このVCが新たに表示される時かどうか

- (BOOL)isMovingToVisibleViewController {
    if (self.navigationController == nil) {
        return [self isBeingPresented];
    }
    return [self.navigationController isBeingPresented] || [self isMovingToParentViewController];
}

このVCが無くなる時かどうか

- (BOOL)isMovingFromVisibleViewController {
    if (self.navigationController == nil) {
        return [self isBeingDismissed];
    }
    return [self.navigationController isBeingDismissed] || [self isMovingFromParentViewController];
}

iOS 9.2