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

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

戻るボタンのタイトルを消して左矢印「<」だけにするにはどうすれば良いか?実は前の画面と繋がっているため、前の画面で空の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];
}

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

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