2010年4月29日木曜日

Three20 の TTPhotoViewController でローカルファイルを扱う際の注意点

Three20 フレームワークを使ってアプリを作成しているときに、ローカルファイルを TTPhotoViewController に読み込ませようとして一部はまってしまったので共有します。


■結論から
  • TTPhotoViewController の中に一部 NSHTTPURLResponse でないと動かないところがあるため、ファイル読み込みでは動作しない
  • @"bundle://filename.jpg"@"documents://filename.jpg" といった記法を用いることで回避可能
  • またはフレームワーク自体を書き換えてしまう

■問題
以下のコードの様に、ファイルスキームのURLを用いて画像を読み込もうとするとエラーになります。
photos = [[NSMutableArray alloc] init];
PhotoBookPhoto *photo;
NSString *imagesDirectoryPath = [[[NSBundle mainBundle] bundlePath] stringByAppendingPathComponent:@"Images"];
NSArray *imagePathes = [[NSFileManager defaultManager] directoryContentsAtPath:imagesDirectoryPath];

for (int i=0; i<imagePathes.count; i++) {
NSString *imagePath = (NSString *)[imagePathes objectAtIndex:i];
imagePath = [imagesDirectoryPath stringByAppendingPathComponent:imagePath];
LOG(@"%d - %@ %@", i, imagePath, [[NSURL fileURLWithPath:imagePath] absoluteString]);
photo = [[[PhotoBookPhoto alloc] initWithURL:[[NSURL fileURLWithPath:imagePath] absoluteString]
smallURL:[[NSURL fileURLWithPath:imagePath] absoluteString]
size:CGSizeMake(960, 640)] autorelease];
photo.photoSource = self;
photo.index = i;
[photos addObject:photo];
}

■原因
TTRequestLoader 中の以下のコードが直接的な原因です。
///////////////////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
#pragma mark -
#pragma mark NSURLConnectionDelegate


///////////////////////////////////////////////////////////////////////////////////////////////////
- (void)connection:(NSHTTPURLConnection*)connection didReceiveResponse:(NSHTTPURLResponce*)response {
_response = [response retain];
NSDictionary* headers = [_response allHeaderFields];
本来 NSURLConnectionDelegate のデリゲートメソッドである、 connection:didReceiveResponse:NSURLResponce を引数にとるのですが、何故か NSHTTPURLResponce が直接指定されています。通常はこれでも問題ありませんが、fileスキームのURLは当然HTTPではありませんから、 NSHTTPURLResponse を返しません。従って allHeaderFields の呼び出しでエラーになってしまいます。


■対処法
ということで、この部分のコードを一部書き換えてしまい、 NSURLResponce がきても動作する様にしてしまいました。
///////////////////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
#pragma mark -
#pragma mark NSURLConnectionDelegate


///////////////////////////////////////////////////////////////////////////////////////////////////
- (void)connection:(NSURLConnection*)connection didReceiveResponse:(NSURLResponse*)response {
_response = [response retain];
if ([_response isMemberOfClass:NSHTTPURLResponse.class]) {
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)_response;
NSDictionary* headers = [httpResponse allHeaderFields];
int contentLength = [[headers objectForKey:@"Content-Length"] intValue];

// If you hit this assertion it's because a massive file is about to be downloaded.
// If you're sure you want to do this, add the following line to your app delegate startup
// method. Setting the max content length to zero allows anything to go through. If you just
// want to raise the limit, set it to any positive byte size.
// [[TTURLRequestQueue mainQueue] setMaxContentLength:0]
TTDASSERT(0 == _queue.maxContentLength || contentLength <=_queue.maxContentLength);

if (contentLength > _queue.maxContentLength && _queue.maxContentLength) {
TTDCONDITIONLOG(TTDFLAG_URLREQUEST, @"MAX CONTENT LENGTH EXCEEDED (%d) %@",
contentLength, _urlPath);
[self cancel];
}

_responseData = [[NSMutableData alloc] initWithCapacity:contentLength];
} else {
_responseData = [[NSMutableData alloc] initWithCapacity:response.expectedContentLength];
}
}
また、あとから知ったのですが、Three20においては以下のようなURLが使用できるみたいです。 Three20/TTGlobalCorePaths.h にて定義されています。
photo = [[[PhotoBookPhoto alloc] initWithURL:@"bundle://image.png"
smallURL:@"documents://thumbnails/smallImage.png"
size:CGSizeMake(960, 640)] autorelease];
たぶん Three20以外では使用できない記法 なので注意が必要ですが、この方法で読み込むと NSHTTPURLResponce が返却されるためコードを書き換えなくても正常に動作します。 こちらのほうが簡単でThree20の開発チームの人もそうしているのでお勧めです。


■んーでも
そもそもこのThree20、開発者の人(FaceBookアプリを作った人)がiPhoneでのプログラミングを既にやめちゃっていると聞いたのでメンテがどうなるのか不安です。

iPadアプリに挑戦中

運良くiPadを輸入して手に入れることができましたので、現在iPadアプリの作成にとりかかっています。最初はiPhoneと対して変わりあるまいと思って作っていたのですが、実機で動かしてみると様々な違いや問題が分かってきました。


■今作っている物とか課題とか

現在作っているのはiPad用の紙のノートです。



数年前からシステム手帳を持ち歩いていたのですが、実際スケジューリング等はすべてiPhoneで行っていました。それでも紙を手放せなかった唯一の理由がアイディア出しです。アナログ人間な物で、手で紙に書き付けないとアイディアが出てこないのです。iPhoneのスクリーンは明らかに小さすぎて手書きには不向きでした。

そこでiPadの大きくなった液晶を使えば紙の代わりができるのではないかと思って早速試して見ました。

ペンの色を4色、太さを4種類用意しています。二本指で左右にスワイプするとページをめくることができます。デバイスを横向きにすると、二ページ見開きの状態にすることができます。この見開きのままでも線を書いたりページをめくったりできます。



できるかぎり実際の紙に近づけたかったため、UIを一切排除しています。


■Gesture Recognizer

ページング処理やペン選択ツールの表示にはスワイプジェスチャを使用していますが、今回このジェスチャを実装するためiPhone OS 3.2より新しく搭載された UIGestureRecognizer というクラスを使用してみました。このクラスを使えば各種ジェスチャを自動的に認識してくれるので、自分でいちいちtapの位置を拾って前回のtapとの差分を検出し・・・ということをしなくても済むようになります。

// Add gesture recognizer to the paper view
UISwipeGestureRecognizer *toolPopoverGestureRecognizer = [[[UISwipeGestureRecognizer alloc] initWithTarget:self
action:@selector(handleToolPopover:)] autorelease];
toolPopoverGestureRecognizer.numberOfTouchesRequired = 2;
toolPopoverGestureRecognizer.direction = UISwipeGestureRecognizerDirectionDown | UISwipeGestureRecognizerDirectionUp;
[self.bookView addGestureRecognizer:toolPopoverGestureRecognizer];
しかしながらいくつか問題が。まず認識精度がそれほどよくないです。特に複数指のスワイプに関しては自分で実装した方が精度が出ると思います。また、ジェスチャは通常のタップとは別に検出されているようなので、二本指でタップしてジェスチャしたときには通常の線描画を切るような処理を含めないと、ジェスチャするたびに線が画面に増えてイライラします。


■他のお絵かきアプリのUI

予想通りというか、ふたを開けてみれば他にもたくさんのドロー系アプリがApp Storeにリリースされていたので、それらのUIを見て研究してみる事にしました。

Adobe ideas for iPad



二本指ドラッグでスクロール、ピンチ操作で拡大縮小。一本指で描画中に二本目が触れると即座に描画をキャンセルしてくれるため、誤ってキャンバスに線が増えてしまうということはありません。よく考えられています。

常に左横にツールバーが表示されているようになっています。ツールバーを置くのは邪魔だろうと思っていたのですが、キャンバスの拡大縮小や移動が自由にできればほとんど邪魔にはならないことがわかりました。むしろすぐアクセスできて便利です。

Autodesk SketchBook Pro



こちらも二本指ドラッグでスクロール、ピンチ操作で拡大縮小。一本指で描画中に二本目が触れると即座に描画をキャンセルしてくれるところも全く同じです。

ツールバーを表示するには、画面中央下の小さなポッチをタップするか、または指三本で画面にタップ。この方が画面を広く使えて嬉しい・・・と思っていたのですが、実際に試してみると意外とイライラします。三本指というのが直感的ではないのかもしれません。このへんは人によるのかもしれませんが、私は常にツールバーが表示されている方がスムーズに操作できました。

Penultimate



iWorkと非常に良く似た作りになっていて、しかも操作系はシンプルです。ジェスチャは一切なく、ボタンはツールバーとして常に表示。それはまったく問題ないのですが、画面がスクロールできず拡大縮小もないため、非常に画面が狭く感じ書きづらいです。いくらiPadが大画面とはいえ、ペン先が通常のペンに比べて太い(私が好んで使うペンは0.4mmですが、iPad上の指は10mmぐらい幅があるので、およそ25倍も大きい)ため、せいぜい数文字しか綺麗に書けません。

その他特筆として、ペンの書き味が素晴らしいです。描画速度が速く非常に追従性がよい。すらすら書けます。


■お絵かきアプリのUIまとめ

いくつか使ってみて、さらには自分でも作ってみて感じたのが以下のようなこと。
  • 紙ではなくてホワイトボードのメタファとして使用するとうまくいく
  • 拡大・縮小・スクロールは絶対必須 無いとアプリとして成立しない
  • ジェスチャを使ってメニューを出すのは思ったよりも効果的ではない
  • 書き味はや動作速度は大事、使っていて楽しくなる
まず一番目。ペン先が太く、消しゴム削除がたやすく、アンドゥリドゥもできるので、書いている間隔がホワイトボードに近い気がします。そのため紙ではなくてホワイトボードだと思って実装するとよさそうです。

二番目。ペン先がやたら太いわりには画面サイズが1024x768しかないので、拡大縮小スクロールできないと話になりません。逆にこれができれば事実上キャンバスサイズは無限大にできるわけで、デバイスサイズ以上の活躍をしてくれます。

三番目。ジェスチャ自体は上手く使えば非常に有効です。たとえばアンドゥリドゥなどの操作はジェスチャで行う方が直感的でした。しかしメニューは常時画面に表示していた方が良い気がします。

最後、四番目。動作速度は極めて大事だと感じました。特に描画速度は最も大事で、線を引くのがもっさりしてしまうとそのせいで綺麗な曲線にならなかったり、単純にイライラしたりします。実物のホワイトボードに書くぐらいの速さで描画ができるように目指したいです。

2010年4月1日木曜日

UIBarButtonItem の見た目を画像にしたいときのテクニック

iPhoneアプリでよく使われる、ナビゲーションバーに配置するボタン UIBarButtonItem の見た目を完全に画像にする時のテクニックです。
参考にしたページはこちら。
http://discussions.apple.com/thread.jspa?threadID=1505647
http://www.iphonedevsdk.com/forum/iphone-sdk-development/13809-uibarbuttonitem-customview-action.html
http://discussions.apple.com/thread.jspa?threadID=1546506&tstart=60


■作戦1:initWithImage
てか、 UIBarButtonItem には initWithImage あるからそれでいいんじゃないか、と思ってさっそく以下のコードを書いてみました。
UIBarButtonItem* buttonItem = [[UIBarButtonItem alloc]
initWithImage:[UIImage imageNamed:@"appstore.png"]
style:UIBarButtonItemStyleBordered
target:self
action:@selector(appstoreAction)];
self.navigationItem.rightBarButtonItem = buttonItem;
するとあら不思議、確かに画像は表示されたのですが、画像の周りにいつものボタンの枠が表示されてしまっています。styleを変更してもうまくいきません。どうやらこれはあくまでいつものボタンの中に画像を表示するだけのメソッドで、ボタンの見た目そのものをそっくりそのまま画像に入れ替える(ボタン画像を作ってさしかえる)用途には使えないようです。


■作戦2:initWithCustomViewとUIImageViewを組み合わせる
それなら initWithCustomView を使って画像を表示させてみましょう。
UIImageView* customView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"appstore.png"]];
customView.userInteractionEnabled = YES;
UIBarButtonItem* buttonItem = [[UIBarButtonItem alloc] initWithCustomView:customView];
buttonItem.target = self;
buttonItem.action = @selector(appstoreAction);
self.navigationItem.rightBarButtonItem = buttonItem;
この方法だと、ボタンの縁が消えてappstore.pngがそのまま表示され、見た目は臨み通りの状態に表示させることができたのですが、今度は別の問題が。なんとボタンを押してもアクションが呼びだされません。海外のフォーラムでも同じような問題を抱えている投稿が見受けられました。


■作戦3:initWithCustomViewとUIButtonを組み合わせる
それならということで、カスタムビューを UIImageView から UIButton に変更してみることにしました。
UIButton *customView = [[UIButton alloc] initWithFrame:CGRectMake(0, 0, 95, 33)];
[customView setBackgroundImage:[UIImage imageNamed:@"appstore.png"]
forState:UIControlStateNormal];
[customView addTarget:self action:@selector(appstoreAction) forControlEvents:UIControlEventTouchUpInside];
UIBarButtonItem* buttonItem = [[UIBarButtonItem alloc] initWithCustomView:customView];
self.navigationItem.rightBarButtonItem = buttonItem;
ビルドして試してみると、見た目は作戦2と同様に綺麗に表示され、さらにタップするときちんとアクションが実行されるようになりました!