NSFetchedResultsControllerとUISearchDisplayControllerを使ったUITableView検索機能の実装

NSFetchedResultsControllerとUISearchDisplayControllerを使ったUITableViewの検索機能を実現するというトピックはネット上でも幾つか見つかるのだが、複数のクラスが組み合わさって動作するためになかなか思うような動きをするコードを書くことができなかった。

UITableView(ここではMaster-Detail Appとする)にUISearchDisplayControllerを組み合わせ、UISearchBarに検索文字列を入力するとすぐに絞り込まれる(ようにコードを書く)のだが、その時表示される絞り込まれた表はUITableViewではなくUISearchResultTableViewのインスタンスである。だから、現在見えている表がどのクラスのインスタンスなのかを意識しなければ思うようなコードを書けないということを痛感した。

表のデータソースにCore Dataを使用している場合、NSFetchedResultsControllerとUISearchDisplayControllerの両クラスの連携が鍵になる。

よく見かけるサンプルコードは次のようなものである。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (void)controllerWillChangeContent:(NSFetchedResultsController *)controller
{
    [self.tableView beginUpdates];
}

- (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject
       atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type
      newIndexPath:(NSIndexPath *)newIndexPath
{
    UITableView *tableView = self.tableView;
  // (省略)
}

- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller
{
    [self.tableView endUpdates];
}

だが、検索して絞り込んだ表から選択して更に編集画面でDBを更新した場合、このcontrollerDidChangeContentメソッドでアベンドしてしまう。

1
2
 Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid update: invalid number of rows in section 0.
(以下省略)

こんなメッセージだ。言いたいことは分かるがどう対応して良いか分からない。

1
[self.tableView endUpdates];

の代わりに

1
[self.tableView reloadData];

とやれば、更新時にアベンドはしなくなるが、検索をキャンセルした場合に表示されるオリジナルの表のレンダリングがおかしくなってしまう。(あと、削除できなくなったり詳細画面に遷移できなくなったり。)散々悩んだ挙句、冒頭の書いたことに思い当たった。そう、分かってしまえば何てことはない。NSFetchedResultsControllerの操作も、どのTableViewに対して行うかを判断すれば良かったのだ。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
- (void)controllerWillChangeContent:(NSFetchedResultsController *)controller
{
    if (self.searchDisplayController.isActive) {
        [self.searchDisplayController.searchResultsTableView beginUpdates];
    }
    else {
        [self.tableView beginUpdates];
    }
}

- (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject
       atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type
      newIndexPath:(NSIndexPath *)newIndexPath
{
    UITableView *tableView = self.searchDisplayController.isActive ? self.searchDisplayController.searchResultsTableView : self.tableView;
   
    // (省略)
}

- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller
{
    if (self.searchDisplayController.isActive) {
        [self.searchDisplayController.searchResultsTableView endUpdates];
    }
    else {
        [self.tableView endUpdates];
    }
}

UITableViewCellの再利用について

iOS 5.x までは
– (UITableViewCell*)tableView:(UITableView*)tableView cellForRowAtIndexPath:(NSIndexPath*)indexPath
メソッド内で

1
2
3
4
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:TABLE_CELL_NAME];
if (cell == nil) {
    cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:TABLE_CELL_NAME];
}

のようなコードを書く必要があったが、iOS 6.0 からは – (void)viewDidLoad メソッドに

1
[self.tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:TABLE_CELL_NAME];

というセットアップをすれば

1
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:TABLE_CELL_NAME forIndexPath:indexPath];

と書くだけで良いというのがネットで得られる情報なのだが、UISearchDisplayController を使って検索機能を実装しようとすると検索文字列を入力した途端に落ちてしまう。検索結果は UITableView が使われるのではなく、UISearchResultTableView のインスタンスが(しかも都度)生成されるので、適切な場所でセットアップをしてあげる必要がある。ここではupdateFilteredContentForNameメソッドで検索をした直後に記述したら(5行目)うまくいった。

1
2
3
4
5
6
7
- (BOOL)searchDisplayController:(UISearchDisplayController *)controller
shouldReloadTableForSearchString:(NSString *)searchString
{
    [self updateFilteredContentForName:searchString];
    [self.searchDisplayController.searchResultsTableView registerClass:[UITableViewCell class] forCellReuseIdentifier:TABLE_CELL_NAME];
    return YES;
}

Symfony2のアノテーション@Route、@Method – ルーティング設定

基本的なルートの設定

1
2
3
_welcome:
    path
:     /
    defaults
: { _controller: TipsBlogBundle:Main:index }

これは以下と等価。

1
2
3
4
5
6
7
8
9
class MainController extends Controller
{
    /**
     * @Route("/")
     */

    public function indexAction()
    {
    }
}

プレースホルダー付きのルート

1
2
3
blog_show:
    path
:     /blog/{slug}
    defaults
: { _controller: TipsBlogBundle:Blog:show }

これは以下と等価。

1
2
3
4
5
6
7
8
9
class BlogController extends Controller
{
    /**
     * @Route("/blog/{slug}", name="blog_show")
     */

    public function showAction()
    {
    }
}

または

1
2
3
4
5
6
7
8
9
10
11
12
/**
 * @Route("/blog")
 */

class BlogController extends Controller
{
    /**
     * @Route("/{slug}", name="blog_show")
     */

    public function showAction($slug)
    {
    }
}

プレースホルダーの必須/任意設定

1
2
3
blog:
    path
:     /blog/{page}
    defaults
: { _controller: TipsBlogBundle:Blog:index, page: 1 }

これは以下と等価。

1
2
3
4
5
6
    /**
     * @Route("/blog/{page}", name="blog", defaults={"page" = 1})
     */

    public function indexAction($page)
    {
    }

ルートの条件

1
2
3
4
5
blog:
    path
:     /blog/{page}
    defaults
: { _controller: TipsBlogBundle:Blog:index, page: 1 }
    requirements
:
        page
: \d+

これは以下と等価。

1
2
3
4
5
6
    /**
     * @Route("/blog/{page}", name="blog", defaults={"page" = 1}, requirements={"page" = "\d+"})
     */

    public function indexAction($page)
    {
    }

別のパターンでは、

1
2
3
4
5
homepage:
    path
:     /{culture}
    defaults
: { _controller: TipsBlogBundle:Main:index, culture: en }
    requirements
:
        culture
: en|ja

これは以下と等価。

1
2
3
4
5
6
    /**
     * @Route("/{culture}", name="homepage", requirements={"culture" = "en|ja"})
     */

    public function indexAction($culture)
    {
    }

HTTP メソッド の制約をつける

同一のURLでもHTTPメソッドによって呼び出すメソッドやコントローラを使い分けることが可能。

1
2
3
4
5
6
7
8
9
contact:
    path
:    /contact
    defaults
: { _controller: AcmeDemoBundle:Main:contact }
    methods
: [GET]

contact_process
:
    path
:    /contact
    defaults
: { _controller: AcmeDemoBundle:Main:contactProcess }
    methods
: [POST]

これはこう書ける。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MainController extends Controller
{
    /**
     * @Route("/contact")
     * @Method({"GET"})
     */

    public function contactAction()
    {
    }

    /**
     * @Route("/contact")
     * @Method({"POST"})
     */

    public function contactProcessAction()
    {
    }
}

NSDateComponentsからNSDateを取得するとnilが返ってくる

日付値に対して加減演算を行い、その結果を日付値として取得したくてNSDateComponentsクラスを使用した。以下のようなコードを書いたら、 result は常に nil が入って来る。

1
2
3
4
5
NSDate* dt = [NSDate date];
NSCalendar* cal = [[NSCalendar alloc] initWithCalendarIdentifier:NSGregorianCalendar];
NSDateComponents* dc = [cal components:NSYearCalendarUnit fromDate:dt];
// dcに対する操作
NSDate* result = [dc date];

そこでNSCalendarオブジェクトを使って以下のように変更したら期待通りの値が取得できた。

1
2
3
4
5
NSDate* dt = [NSDate date];
NSCalendar* cal = [[NSCalendar alloc] initWithCalendarIdentifier:NSGregorianCalendar];
NSDateComponents* dc = [cal components:NSYearCalendarUnit fromDate:dt];
// dcに対する操作
NSDate* result = [cal dateFromComponents:dc];

List File Attributes バージョン1.1.0版公開

ダウンロード

◆ これは何か?

  • ファイルのパス名、サイズ、更新日時などの属性をリスト化するアプリケーションです。
  • とはいっても、ただリスト化するだけでは価値が無くて、それを属性項目ごとにクリップボードへコピーして、他のアプリケーションで利用することを想定しています。
  • 個人利用ではほとんど使う機会はないと思いますが、法人などではファイルを資産として管理する場合、ファイルの一覧を作成することがあると思います。しかし対象のファイル数が多いと、これがなかなか面倒くさい作業で、そういった作業の負荷を軽減するためにこのアプリケーションを作成しました。

◆ 動作環境

.NET Framework 4.0 が動作する環境。

  1. お使いのコンピュータにまだ .NET Framework 4.0 を導入されていない場合は以下のURLからダウンロードしてインストールを行う必要があります。
    https://www.microsoft.com/ja-jp/net/netfx4/download.aspx

◆ 使い方

  • 情報を取得したいファイルまたはそれらを含むフォルダをマウス等でドラッグ&ドロップします。
  • パスに関しては区切り文字の設定や、何文字目からコピーするかを指定します。
  • コピーしたい項目(名前、パス、サイズ、更新日時、作成日時)をプルダウンリストから選び、コピーボタンを押下するとクリップボードにその項目がコピーされます。
  • 一度実行後、続けて作業を行いたい場合は「全てクリア」ボタンを押してリストをクリアしてから、再度ファイルまたはフォルダをドロップして作業を行うことができます。

◆ 利用条件

  • 本ソフトウェアはフリーウェアとして公開しています。ご利用に当たり料金は発生いたしません。
  • 著作権はクロスラボラトリーにあります。

◆ 免責事項

  • 本ソフトウェアは無保証です。ご利用によって生じたいかなる損害に対しても作者は責任を負わないものとします。ご自身の責任においてご利用ください。

更新履歴

  • v1.0.0 (2013-07-07)
    公開
  • v1.1.0 (2013-07-10)
    パスを相対パスと絶対パスに分けて表示・コピーするように修正

Letter Case Converter バージョン1.1.0公開

ダウンロード

◆ これは何か?

  • アルファベットの大文字表記、小文字表記を含めた記法を変換するためのアプリケーションです。
  • 主にソフトウェアの開発者が、項目名や変数を定義する際に、コーディング規約あるいは命名規約等に適合させるために大文字・小文字を揃えたり単語をハイフンやアンダースコアで連結させるといったことを頻繁に行いますが、その作業を支援するために作成しました。
  • 例えば次のような記法に相互に変換ができます。
    • Upper Camel Case(PascalCaseとも)
      aaa bbb ccc ⇒ AaaBbbCcc
      (例)McDonald, PlayStation
    • Lower Camel Case
      aaa bbb ccc ⇒ aaaBbbCcc
      (例)iPhone, jQuery, cdmaOne
    • Start Case
      aaa bbb ccc ⇒ Aaa Bbb Ccc
      (例)App Store, Internet Explorer
    • Spinal Case
      aaa bbb ccc ⇒ aaa-bbb-ccc
      (例)AIR-EDGE, co-op, e-business
    • Upper Snake Case
      aaa bbb ccc ⇒ AAA_BBB_CCC
      (使用例)DBのオブジェクト名、プログラムの定数名など
    • Lower Snake Case
      aaa bbb ccc ⇒ aaa_bbb_ccc
      (使用例)DBのオブジェクト名、プログラムのローカル変数名など

◆ 動作環境

.NET Framework 4.0 が動作する環境。

◆ 使い方

  • 変換元欄にテキストを貼り付けます。
  • 変換元欄に貼り付けたテキストの区切り文字を選びます。
  • 変換後の大文字・小文字の種別と、区切り文字を選びます。
  • 変換ボタンを押すと変換結果欄に変換後のテキストが表示されます。
  • コピーボタンを押すとクリップボードにコピーされます。
  • 大文字・小文字の種別は以下のようになっています。
    • 全ての単語の先頭が大文字 ⇒ Aaa Bbb Ccc
    • 2番目以降の単語の先頭が大文字 ⇒ aaa Bbb Ccc
    • 全て大文字 ⇒ AAA BBB CCC
    • 全て小文字 ⇒ aaa bbb ccc

◆ 利用条件

  • 本ソフトウェアはフリーウェアとして公開しています。ご利用に当たり料金は発生いたしません。
  • 著作権はクロスラボラトリーにあります。

◆ 免責事項

  • 本ソフトウェアは無保証です。ご利用によって生じたいかなる損害に対しても作者は責任を負わないものとします。ご自身の責任においてご利用ください。

更新履歴

  • v0.0.1 (2013-04-18)
    公開
  • v1.0.0 (2013-05-25)
    アイコンを追加して正式版とする
  • v1.1.0 (2013-07-10)
    連続した区切り文字をスキップするように修正

Master-Detail Appで新規追加画面から戻るとUITableViewに空のセルが追加されてしまう

 Xcodeでプロジェクトを作成する時に選べるテンプレートの中に「Master-Detail Application」というのがある。これはいわゆる一覧画面と詳細画面を行き来する割と代表的なアプリケーションの形であり、頻繁に利用されるパターンでもある。一覧と詳細と言えば、その多くがデータベースのようなものと組み合わせて使うのが一般的だ。ローカルのデータベースの場合、特にこだわりがなければCoreDataを使う事になると思うが、その際にちょっとハマったケースがあるのでメモしておく。

 それは詳細画面というより編集画面を新規追加の局面で開いた場合だ。ところで「Master-Detail Application」ではUITableViewControllerの他にUINavigationControllerが使われているので、編集画面にて保存の操作を行うにはUINavigationBarに保存ボタンを追加し、NSManagedObjectContextオブジェクトのsaveメソッドを呼ぶ事で保存を行うことができる。ただ、編集画面を新規追加で開いた瞬間にNSManagedObjectクラスのインスタンスが生成されるようで、保存せずに戻るボタンが押された場合には一覧画面に空のセルが追加されてしまう。これを防ぐには戻るタイミングで未保存の変更がある場合には、以下のようにロールバックするコードを書けば良い。

1
2
3
4
5
- (void)viewWillDisappear:(BOOL)animated {
    if ([self.managedObjectContext hasChanges]) {
        [self.managedObjectContext rollback];
    }
}

 これで空のセルが追加されるのを防ぐ事ができる。

UITextFieldがキーボードに隠れないようにオフセットする

iPhoneでアプリの画面にたくさんTextFieldを配置した時、画面下の方のTextFieldを選択するとキーボードが下からニョキッと出てきてTextFieldを隠してしまう。もちろんそのままでも入力は可能だが、キーボードを仕舞うまでどう入力されているか分からないので不便である。
そこで、TextFieldを選択したときに隠れてしまう場合は表示をずらして隠れないようにしたい。ネットでサンプルを探したのだが、これというものが見当たらなかったので作ってみた。

InterfaceBuilderでまず全体にUIScrollViewを敷き詰めて、そこにUITextFieldを並べていく。
まず、(隠れてしまう)全てのTextFieldについて「Editing Did Begin」と「Editing Did End」の2つのイベントハンドラを定義しておく。この際、どのTextFieldに対しても同じメソッドを適用する。例えばそれぞれ – (void)textEditingDidBegin および - (void)textEditingDidEnd とする。
この時、InterfaceBuilderを使って普通にイベントハンドラを定義しようとすると両方とも「Editing Did End」イベントに結合してしまうので、ConnectionsInspectorで「Editing Did Begin」イベントから – (void)textEditingDidBegin メソッドに結合した方が良い。
また次のフィールドを宣言しておく。

1
2
3
4
5
6
7
@interface ViewController () {
    float _keyboardOffset; // 表示のオフセット(どれだけずらすか)
    float _offsetMargin; // 実際にオフセットを適用し始めるマージン
}
    :
    :
@end

そしてそれぞれ次のように初期化する。

1
2
3
4
5
6
7
8
- (void)viewDidLoad
{
    [super viewDidLoad];
    :
    :
    _keyboardOffset = 0.0f;
    _offsetMargin = 120.0f; // 4インチディスプレイの場合は 210.0f 程度
}

_offsetMargin というのは、例えば画面の上の方にあるTextFieldを選択したときは表示をずらす必要がないので、じゃあY座標がどこまでの範囲内ならオフセットしないかというのを決めておく必要がある。3.5インチの場合はだいたい120ピクセルぐらいまではずらさなくても大丈夫。4インチの場合は3.5インチの場合と90ピクセルくらい差があるのでマージンもそれだけ増やして大丈夫だ。

次に最初に定義したイベントハンドラを実装する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (IBAction)textEditingDidBegin:(UITextField *)sender {
    // オフセットすべきサイズを計算する
    CGRect textFieldRect = [sender frame];
    _keyboardOffset = MAX(0, textFieldRect.origin.y - _offsetMargin);
    // アクティブなTextFieldが表示されるようにオフセットする
    CGRect viewFrame = [self.scrollView frame];
    viewFrame.origin.y -= _keyboardOffset;
    self.scrollView.frame = viewFrame;
}

- (IBAction)textEditingDidEnd:(UITextField *)sender {
    // ScrollViewのオフセットを元の値に戻す
    CGRect viewFrame = [self.scrollView frame];
    viewFrame.origin.y += _keyboardOffset;
    self.scrollView.frame = viewFrame;
    _keyboardOffset = 0.0f;
}

尚、UITextFieldDelegateとかオフセット時のアニメーションとかは本題から外れるので適宜実装してください。

UITableViewの一覧にCora Dataで取得した内容を反映する

 Core Dataのサンプルとか、UITableViewのサンプルとかはネット上でも書籍でもかなり豊富にあるのだが、それらの組み合わせの例は意外と少ない。先日見つけた書籍はかなり詳しく書いてあって「これはいけるか!?」と思われたが、実は肝心なコードが抜けていた。それはMaster-Detail型で言うところのMaster画面でCore Dataから取得したデータの一覧をUITableViewに表示する際、一覧の見出しにデータの内容が反映されないのだ。おそらくその書籍の著者にとってはあまりに自明の事なので記載するのを忘れてしまったのだろう。あるいはそれくらいは自分で考えろという事なのかもしれない。

 まあここで毒づいても仕方が無いので対処方法をメモしておく。

 一覧に内容を反映させるには MasterViewController.m の 
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
メソッドにコードを記述する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    UITableViewCell *cell;
    // iOSのバージョンによりセルの取得方法が変わる
    if (6.0 <= [[[UIDevice currentDevice] systemVersion] floatValue]) {
        cell = [tableView dequeueReusableCellWithIdentifier:@"AnyId" forIndexPath:indexPath];
        [self configureCell:cell atIndexPath:indexPath];
    }
    else {
        cell = [tableView dequeueReusableCellWithIdentifier:@"AnyId"];
        if (!cell) {
            cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"AnyId"];
        }
    }
    // ここでセルの表示を変更
    NSManagedObject* managedObject = [self.fetchedResultsController objectAtIndexPath:indexPath];
    cell.textLabel.text = managedObject.anyProperty;
    return cell;
}


 尚、iOSのOSバージョンを判別する方法は以下のサイトを参考にした。
http://syszr.com/s14.html

“The model used to open the store is incompatible with the one used to create the store” に対処する

Core Dataを使った開発をしていたら次のようなエラーが出てシミュレータで起動しなくなったので、対処方法をメモしておく。

The model used to open the store is incompatible with the one used to create the store

プロジェクトをクリーンしてもダメで、このエラーメッセージで検索したところ、sqliteのファイルを削除しないとダメなのだそうだ。
そのsqliteファイルは以下の場所。ターミナルを開いてrmコマンドで削除する。

/Users/{ユーザー名}/Library/Application Support/iPhone Simulator/{iOSバージョン}/Applications/{英数字}/Documents/{プロジェクト名}.sqlite

参考にしたページはこちら
http://u2k772.blog95.fc2.com/blog-entry-167.html

アーカイブ