カテゴリー 開発Tips

ソフトウェア開発における豆知識をご紹介!

Xcodeで「No signing certificate “iOS Development” found」

Xcodeの新規プロジェクト、または既存プロジェクトを開き、
プロジェクト設定のSigning欄でTeamを選択したら
No signing certificate “iOS Development” found
というエラーが出てしまった場合、
有効な証明書(*.cer)ファイルがキーチェーンに登録されていない
(登録されている証明書が全て期限切れ等)
可能性がある。

Developerサイトの「Certificates, Identifiers & Profiles」にて
新しい証明書を作成し、ダウンロードしたものを
キーチェーンアクセスの証明書のところにドラッグ&ドロップすれば登録され、Xcodeで再読込(Teamを再設定)すればエラーは消える。

[CakePHP3]2つのファイルアップロードのうちどちらか一方を必須にしたい

複数のファイルアップロードフィールドを配置し、それら全てではなく、そのうち一つが入力されることを保証するバリデーションを行いたいというケースがある。

https://book.cakephp.org/3.0/ja/core-libraries/validation.html#id9
ここにあるように条件付きバリデーションで実装しようとしてnotEmpty()メソッドに on オプションをつけてみたが、$this->Form->file()でレンダリングされた時点で全てのファイルアップロードフィールドに required 属性が付与されてしまって全てが必須になってしまう。

こういう時は時間があればバリデーションクラスを実装する方が美しいが、余裕がなければ標準から外れてしまうが思い切ってバリデータの使用は諦めて、コントローラーで実装してしまった方が早い。

その場合、エラーメッセージはEntityクラスのsetError()メソッドを使って、

$foo = $this->Foos->patchEntity($foo, $this->request->getData());
if ($foo->getErrors()) {
    // 入力チェックエラー
}
else if (!isset($this->request->getData('file1')['name'])
        && !isset($this->request->getData('file2')['name'])) {
    // 独自のバリデーションエラー
    $foo->setError('file1', 'ここにエラーメッセージを書く');
}
else {
    // 保存処理など
}

 

のような形でセットしてやれば、画面の方で他のバリデーションエラーと同様に表示ができる。

[CakePHP3]ファイルを持つエンティティにpatchEntityメソッドでファイルアップロードフィールドをコピーしたい

Webサイトであれば、アップロードしたファイルをストレージに保存し、ファイルのパスまたはURLをデータベースで管理するといったケースはよく行われるだろう。

とあるBlogエンティティに画像のファイルパスを複数持っているとする。例えばこんな感じ。

  • blog_title
  • blog_image_path_1
  • blog_image_path_2

これを愚直にこのようなフォームにしてしまうとする。

<?= $this->Form->create($blog, ['enctype' => 'multipart/form-data']) ?>
  <?= $this->Form->text('blog_title') ?>
  <?= $this->Form->file('blog_image_path_1') ?>
  <?= $this->Form->file('blog_image_path_2') ?>
<?= $this->Form->end() ?>

このフォームのリクエストを受け取って

$blog = $this->Blogs->get($id); // DBから取得
$blog = $this->Blogs->patchEntity($blog, $this->request->getData()); // 画面の入力を反映
$this->Blogs->save($blog); // DBに保存

としてしまうのは、2つのファイルが両方とも必須項目であればともかく、片方だけ変更するといったケースでは、指定しなかった方のファイルパスがブランクになってしまって都合が悪い。

そういう場合は、イレギュラーな対応かもしれないが、直接コピーすることをせずに、エンティティにはない項目を一時的に使用して、最終的にエンティティに格納するようにする。そもそもファイルアップロードフィールドには初期値は設定できないのだ。

<?= $this->Form->create($blog, ['enctype' => 'multipart/form-data']) ?>
  <?= $this->Form->text('blog_title') ?>
  <?= $this->Form->file('blog_image_file_1') ?>
  <?= $this->Form->file('blog_image_file_2') ?>
<?= $this->Form->end() ?>

 

$blog = $this->Blogs->get($id);
$blog = $this->Blogs->patchEntity($blog, $this->request->getData());
// 1つ目のファイルが指定されていたら反映
if (isset($this->request->getData('blog_image_file_1')['name'])) {
    $blog->blog_image_path_1 = $this->request->getData('blog_image_file_1')['name'];
}
// 2つ目のファイルが指定されていたら反映
if (isset($this->request->getData('blog_image_file_2')['name'])) {
    $blog->blog_image_path_2 = $this->request->getData('blog_image_file_2')['name'];
}
$this->Blogs->save($blog);

尚、入力チェックや、基本ディレクトリ名の合成などは省略しているので、各々の状況に合わせてアレンジして欲しい。

ファイルアップロードフォームから渡ってくる項目の詳細については以下を参照のこと。
https://secure.php.net/manual/ja/features.file-upload.post-method.php

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

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

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

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

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

- (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メソッドでアベンドしてしまう。

Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid update: invalid number of rows in section 0.

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

- (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
メソッド内で

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

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

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

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

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

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

- (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 – ルーティング設定

基本的なルートの設定

_welcome:
    path:      /
    defaults:  { _controller: TipsBlogBundle:Main:index }

これは以下と等価。

class MainController extends Controller
{
    /**
     * @Route("/")
     */
    public function indexAction()
    {
    }
}

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

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

これは以下と等価。

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

または

/**
 * @Route("/blog")
 */
class BlogController extends Controller
{
    /**
     * @Route("/{slug}", name="blog_show")
     */
    public function showAction($slug)
    {
    }
}

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

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

これは以下と等価。

/**
 * @Route("/blog/{page}", name="blog", defaults={"page" = 1})
 */
public function indexAction($page)
{
}

ルートの条件

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

これは以下と等価。

/**
 * @Route("/blog/{page}", name="blog", defaults={"page" = 1}, requirements={"page" = "\d+"})
 */
public function indexAction($page)
{
}

別のパターンでは、

homepage:
    path:      /{culture}
    defaults:  { _controller: TipsBlogBundle:Main:index, culture: en }
    requirements:
        culture:  en|ja

これは以下と等価。

/**
 * @Route("/{culture}", name="homepage", requirements={"culture" = "en|ja"})
 */
public function indexAction($culture)
{
}

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

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

contact:
    path:     /contact
    defaults: { _controller: AcmeDemoBundle:Main:contact }
    methods:  [GET]

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

これはこう書ける。

class MainController extends Controller
{
    /**
     * @Route("/contact")
     * @Method({"GET"})
     */
    public function contactAction()
    {
    }

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

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

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

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

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

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

 

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

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

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

- (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 メソッドに結合した方が良い。



また次のフィールドを宣言しておく。

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

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

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

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

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

- (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
メソッドにコードを記述する。

- (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バージョンを判別する方法は以下のサイトを参考にした。
https://syszr.com/s14.html