iOS (iPhone/iPad) アプリ開発でタイマーを使ってテーブルの項目を定期的に更新させる

TODO リストを表示するアプリとかで、予定開始時刻までの残り時間をリアルタイムに表示する、みたいなアプリを考える。UITableView に TODO リストの一覧を表示させると同時に、その TODO の残り時間をリアルタイムに表示させたい場合は、UITableView を定期的に更新してやる必要がある。これを UITableViewController, NSTimer, NSDate の組合せで作るとき、いくつかしょうもないことでハマったのでメモ。

下記は、1秒毎に残り時間を更新するという前提で書いている。

  • UITableViewController のサブクラスでテーブルを強制的に更新させるには [self.tableView reloadData] する。
  • [NSData date] 等で得られるインスタンスを保持するためには retain させる。また、retain したら release する。
  • timer は 1 秒よりずっと短い間隔で呼びださないと、処理時間のずれによってときどき更新されてないように見える。
  • 各項目の時間を同期させるためには、timer が呼びだす ticker 内で現在時刻を取得しておく必要がある。
  • 選択状態を維持するためには、ticker 内で reloadData した後に自力で選択しなおす必要がある。


ソース例

iOS の Empty アプリケーションを作って、TimerTableController クラスを UITableViewController のサブクラスとして生成しておく。IB を全く使わないで作ってるので、storyboard や nib は生成する必要はない。

  • AppDelegate.m

実装が必要なのは以下のメソッドのみ。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    self.window = [[[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]] autorelease];
    // Override point for customization after application launch.
    
    TimerTableController *table = [[TimerTableController alloc] initWithStyle: UITableViewStylePlain ];

    [self.window addSubview: table.tableView];
    
    self.window.backgroundColor = [UIColor whiteColor];
    [self.window makeKeyAndVisible];
    return YES;
}

一瞬 [self.window addSubview: table] としたくなるかもしれないけど、UITableViewController は View ではないのでできない。

  • TimerTableController.h
#import <UIKit/UIKit.h>

@interface TimerTableController : UITableViewController
{
    NSTimer *timer;       // タイマー
    NSDate *current;      // 更新時間を保持する
    NSMutableArray *array;// 項目のデータを保持する
    NSInteger selected;   // 選択されたセルの位置を記憶する
}
@end
  • TimerTableController.m

冒頭で挙げたポイントを、コメントとして対応するコードに書いてみた。

#import "TimerTableController.h"

@implementation TimerTableController

- (id)initWithStyle:(UITableViewStyle)style
{
    self = [super initWithStyle:style];
    if (self) {
        // 0.1秒間隔で self の -ticker: を繰り返し呼びだす。
        // 1 秒間隔にすると、タイマーの時間のずれによって、ときどき更新が飛んでいるように見えることがある

        timer = [NSTimer scheduledTimerWithTimeInterval:0.1f target: self
                                               selector:@selector(ticker:) userInfo: nil
                                                repeats: YES ];
        array = [[NSMutableArray alloc] init];

        // サンプルデータを作る。10秒後、20秒後・・・に予定時刻がくる TODO リストを作る
        for ( int i = 1; i < 20; i++ )
        {
            NSDate *date = [[NSDate dateWithTimeIntervalSinceNow: i * 10] retain];
            [array addObject: date];
        }
        
        current = [[NSDate date] retain];
        // 表示項目間の時刻ずれを防ぐために使う
        
        selected = -1; // 選択項目なし
    }
    return self;
}

// タイマーから呼びだされるメソッド
-(void)ticker:(NSTimer*)timer
{
    /*
    NSMutableArray *tmparr = [[NSMutableArray alloc] initWithCapacity:[array count]];

    // 予定時刻が過ぎたときにリストから消去する場合は、ここのコメントアウトを外す
    // ただし、選択状態のセルがある場合に、セルの位置がずれたり消えたりする問題がおこるが
    // それには対処していない。
    for ( NSDate *date in array )
    {
        NSTimeInterval val = [date timeIntervalSinceNow];
        if ( val < 0 ){
            [tmparr addObject: date];
        }
    }
    for ( NSDate *date in tmparr )
    {
        [array removeObject: date];
    }
    [tmparr release];
    */
    
    // 前回から秒の数値が変ってない場合は更新しない。
    NSDate* tmp = [NSDate date];
    if ( [tmp timeIntervalSinceDate: current] < 1.0f )
    {
        return;
    }
    
    [current release];
    current = [tmp retain]; // 表示項目間の時刻ずれを防ぐために使う
    
    [self.tableView reloadData]; // 強制的に更新する
    
    if ( selected >= 0){ // 選択状態を維持する
        NSIndexPath *index = [NSIndexPath indexPathForRow: selected inSection:0]; 
        [self.tableView selectRowAtIndexPath: index
                                    animated:YES scrollPosition:UITableViewScrollPositionNone];
    }
}

- (void)didReceiveMemoryWarning
{
    [super didReceiveMemoryWarning];
}

- (void)viewDidLoad
{
    [super viewDidLoad];
}

- (void)viewDidUnload
{
    for ( NSDate* date in array )
    {
        [date release];
    }
    [array release];
    [current release];
    [super viewDidUnload];
}

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

- (void)viewDidAppear:(BOOL)animated
{
    [super viewDidAppear:animated];
    [timer fire];
}

- (void)viewWillDisappear:(BOOL)animated
{
    [timer invalidate];
    [super viewWillDisappear:animated];
}

- (void)viewDidDisappear:(BOOL)animated
{
    [super viewDidDisappear:animated];
}

- (BOOL)shouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation
{
    return (interfaceOrientation == UIInterfaceOrientationPortrait);
}

#pragma mark - Table view data source

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
    return 1;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    return [array count];
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    static NSString *CellIdentifier = @"Cell";
       
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    if (cell == nil) {
        cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier] autorelease];
    }

    // 現在時刻との差分を計算する
    NSDate *date = (NSDate*)[array objectAtIndex: [indexPath row]];
    NSTimeInterval interval = [date timeIntervalSinceDate: current];
    int val = (int)interval;

    // 現在時刻の差分と、予定時刻を表示させる
    if ( val > 0 ){
        [cell.textLabel setText: [NSString stringWithFormat: @"%d: %@", val, [date description]]];
    }else{
        [cell.textLabel setText: [NSString stringWithFormat: @"done: %@", [date description]]];        
    }
    
    return cell;
}

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
    selected = [indexPath row]; // 選択されたセル番号を覚えておく。
}

@end