スマート反転モード(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

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

iPhoneを売却する前に必要な作業

iPhoneを売却するには単純にデータを消しておくだけではダメで「初期化されたアクティベート済み端末」にしておく必要があります。面倒そうですが、本体のみで簡単にできますのでご紹介しましょう。

1. iPhoneを探すをオフにしてアカウントからログアウト

「iPhoneを探す」によって、端末が盗まれてしまった場合でも他人が勝手に初期化できないようになっていますので、売却前には必ず無効にしておきます。店舗買取では事前に教えてもらえますが、そのままオークションに出品してしまうとまずいです。

2. 必要ならPCでバックアップをとる

3. すべてのコンテンツと設定を消去

設定 → 一般 → リセット → すべてのコンテンツと設定を消去
確認のパスコードを入力すると、工場出荷状態に初期化されます。

パソコンを使って初期化した場合は、iOSは強制的に最新バージョンにアップデートされます。古いiOSのまま売却したい方は端末で初期化しましょう。

4. ネットワークにつないでアクティベート

初期化してもアクティベートだけは済ませておかないと、店舗では買い取ってもらえません。また、オークションでも落札者がすぐに動作確認できるようにアクティベートだけは済ませておく方が親切かと思います。

5. SIMを抜く

6. ネットワーク設定をリセット

設定 → 一般 → リセット → ネットワーク設定をリセット

これでもう安心です。個人情報が入っていない、初期化されたアクティベート済み端末が出来上がりました。お疲れ様でした。

意外?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:")) {
        // 削除処理
    }
}

MPMediaLibraryAuthorizationStatusチェックについて

iOS10からは、アプリがiTunesメディアファイルにアクセスする際に、ユーザの許可が必要になりました。

そのアラート画面で許可された後、画面などのUIを更新する場合は、そのままではバックグラウンドで処理されてクラッシュの原因になってしまいます。(毎回クラッシュする訳ではないから厄介)

ブロック内でperformSelectorOnMainThreadかGCDを使ってメインスレッドからUIを更新します。

[MPMediaLibrary requestAuthorization:^(MPMediaLibraryAuthorizationStatus status) {
    if (status == MPMediaLibraryAuthorizationStatusAuthorized) {
        // This will work in the background
        NSLog(@"isMainThread: %d", NSThread.isMainThread);
        dispatch_async(dispatch_get_main_queue(), ^{
            [self.tableView reloadData];
        });
    }
}];

或いは、UIとは分離させて通知を投げる様にしておくと良いと思います。むしろそうゆう使い方が前提かも。

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];
}

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